2025/12/30 16:15:53
网站建设
项目流程
金诚财富网站是谁做的,长沙传媒公司招聘信息,2020十大热点事件,怎么查看服务器上的网站在 C 语言的面试和实际开发中#xff0c;sizeof 是一个出现频率极高的关键词。初学者往往认为它只是用来计算变量占用空间的#xff0c;但实际上#xff0c;sizeof 的背后隐藏着CPU 架构、硬件总线甚至高并发性能的秘密。
今天#xff0c;我们不注重于对齐规则#xff0c;…在 C 语言的面试和实际开发中sizeof是一个出现频率极高的关键词。初学者往往认为它只是用来计算变量占用空间的但实际上sizeof的背后隐藏着CPU 架构、硬件总线甚至高并发性能的秘密。今天我们不注重于对齐规则而是从硬件视角出发彻底搞懂为什么要对齐不对齐会发生什么以及如何掌控对齐。1.sizeof的结果居然和想象中不一样我们可以先来看一段简单的代码大家可以先思考一下它会输出多少#includestdio.hstructdemo{chara;// 1 字节intb;// 4 字节};intmain(){printf(sizeof(struct demo) %zu\n,sizeof(structdemo));return0;}我是在 64 位 Linux 系统下运行大家的结果大概率也是这样的怎么样可能有的人已经知道了这个细节但按照正常人的思维1加4是等于5的结果为什么会是8呢为什么有3个字节的内存被白白浪费了其实这并不是编译器的 BUG 而是编译器为了迎合 CPU 而做出的妥协。要理解这一点我们必须跳出软件的代码逻辑看看硬件总线是如何工作的。2. CPU 读取内存的方式要理解内存对齐首先要明白一个核心概念CPU 读取内存并不是一个字节一个字节读的而是一块一块读的。2.1 数据总线与读取粒度CPU 和内存之间有一条“ 高速公路 ”叫数据总线 (Data Bus)。这条路的宽度是固定的对于32位 CPU总线宽度 32 bit一次搬运4 字节。对于64位 CPU总线宽度 64 bit一次搬运8 字节。除此之外还有一条规则我拿32位 CPU 来举例子它一次只能搬运4个字节。这个规则的大致内容是这个32位 CPU 每次搬运只能从地址为4的倍数处04812等开始搬运4个字节。可能有人无法理解这条规则存在的意义那我们先来看一下内存条的物理结构。实际上现代标准内存条DIMM物理位宽通常都是 64 位的。对于64 位 CPU它能充分利用内存条的物理位宽一次吞吐 8 字节。对于32 位 CPU受限于 CPU 内部的数据总线宽度它逻辑上依然只能把内存当作 4 字节宽的设备来访问。假设我们是一个 32 位的 CPU一次读4字节CPU 和内存之间有32 根数据线D0 - D31。内存条内部并不是一个字节一个字节存的而是像4列宽的表格一样存的想象一下有四列的 execl 表格CPU 每次读一行一个 Row 也就是4个字节。它的物理寻址逻辑是这样的Row 0 (地址 0): 包含字节 0, 1, 2, 3Row 1 (地址 4): 包含字节 4, 5, 6, 7Row 2 (地址 8): 包含字节 8, 9, 10, 11当你给内存发送读取地址 0 的信号时内存芯片会同时把 Row 0 的 4 个字节通过 32 根线一次性发给 CPU。那如果你想读地址 1 呢也就是说你想要字节 1, 2, 3, 4。字节 1, 2, 3 在Row 0里。字节 4 在Row 1里。而内存控制器一次只能选中一行。它不能让数据线的一半连着 Row 0另一半连着 Row 1。这就是物理限制。这也就解释了为什么会有4的倍数这条规则——这本质上是为了匹配内存颗粒的物理位宽以达到最高的传输效率。这里再简单提一下对于 64 位 CPU 上面的表格中每行就是 8 个单元代表每次读取 8 个字节。如果我们非要读取地址 1 的数据跨越了第0行和第1行时到底会发生什么在严格的架构上如早期的 ARM、DSP硬件电路为了简化设计强制要求地址必须对齐。如果 CPU 发现你试图从地址 1 读取 4 字节它根本无法生成对应的时序信号直接抛出Bus Error或Hard Fault程序当场崩溃。在宽容的架构上如 x86CPU 硬件内部集成了复杂的处理逻辑。它会帮我们处理这种情况自动发起两次内存访问一次读第0行一次读第1行然后在 CPU 内部将两部分数据拼接起来。虽然程序没崩但消耗了双倍的总线周期。因此内存对齐的本质就是为了保证在任何架构下都能通过单次总线传输完成数据的读取既不死机也不降速。2.2 不同数据类型的自然对齐逻辑明白了总线的物理限制后我们就能推导出不同数据类型在内存中的存放法则。只要遵循下面这条黄金定律就能保证数据永远不会跨越行边界**数据的起始地址必须是该数据类型大小的整数倍。**即Address % sizeof(Type) 0。下面我来解释一下为什么我们还是看上面的内存表格。对于 char因为它只有 1 个字节。无论你把它放在哪个位置它都绝对不可能同时跨越两行。因此 char 不需要对齐地址任意。对于short它占 2 个字节。 如果放在 0占 [0, 1]在 Row 0 内安全。 如果放在 1占 [1, 2]在 Row 0 内安全。 如果放在 2占 [2, 3]在 Row 0 内安全。 如果放在 3 (奇数)占 [3, 4]。跨行了。 因此为了防止这种情况short 的地址必须是 2 的倍数对于 int占满了一整行。如果放在 0占 [0, 1, 2, 3]完美填满 Row 0安全。如果放在 1占 [1, 2, 3, 4]。跨越了 Row 0 和 Row 1。跨行因此int 的地址必须是 4 的倍数这样才能保证它永远只落在同一行里被 CPU 一次抓取。对于Double (8字节)在 64位系统 下必须 8 字节对齐否则跨越总线周期。在 32位系统 下虽然总线一次只能搬 4 字节被迫读两次这已经是极限了但依然建议 8 字节对齐或者至少 4 字节对齐否则可能需要读三次对于Long千万不要想当然地认为 long 是 8 字节。在 64位 Windows 下long 依然是 4 字节同 int。在 64位 Linux 下long 才是 8 字节。工程经验在设计跨平台通信协议时严禁使用 long请直接使用stdint.h中的int32_t或int64_t明确指定字节宽彻底消灭歧义。2.3 结构体中的隐形填充在明白了上面所讲的细节之后我们再回过头看最开始的那个结构体 Demo就能理解编译器为什么要浪费那 3 个字节了。structdemo{chara;// 1 字节intb;// 4 字节};假设起始地址是 0我们来模拟一下编译器安排内存的过程放置char achar是一字节数据对齐要求是 1。把它放在地址 0x00。放置int bint是四字节数据对齐要求是 4。它必须放在 4 的倍数地址上。如果紧挨着 a 放地址是 0x01 而 0x01 不是4的倍数放在这里CPU 读它需要两次总线操作甚至报错。因此把int b放在地址 0x04。这时0x01, 0x02, 0x03 这三个位置是空的。编译器会在这些位置自动填入Padding Bytes填充字节顾名思义这些字节只是起到了填充的作用。最终的内存布局如下计算总大小1 (a) 3 (padding) 4 (b) 8 字节。这就解释了为什么sizeof(demo)是 8而不是 5。这是用空间换时间的典型策略。3. 编译器的对齐原则讲完了总线宽度和自然对齐我们再来看看软件层面。编译器在处理结构体时并不是随意填充的而是严格遵循一套对齐算法。这也正是我们能手动计算sizeof的依据。编译器主要遵循两条核心原则成员对齐和整体对齐。3.1 成员对齐结构体中每个成员相对于结构体首地址的偏移量 (Offset)必须是该成员大小的整数倍或者编译器指定的对齐模数#pragma pack(n)取二者较小值。我们还是以 struct demo 为例structDemo{chara;// 1 byteintb;// 4 bytes};结构体第一个成员的首地址与整个结构体的首地址是相同的所以我们说结构体第一个成员相对于结构体首地址的偏移量为0。放置achar大小为 1。偏移量 0 正好是 1 的倍数。放在 Offset 0。放置bint大小为 4。此时离 a 最近的可以存放的位置是Offset 4。也就是说为了让 b 放在 Offset 4编译器自动在 a 后面填充了 3 个字节Padding。最终的内存布局是这样的。3.2 整体对齐结构体的总大小必须是其内部最大成员大小的整数倍。为什么要这样规定其实这是为了结构体数组。如果结构体是这样的structdemo_reverse{inta;// 4 bytescharb;// 1 byte};a 在Offset 0占据着0123四个字节的位置。b 是 char 型可以任意放那就放在Offset 4这个位置。这样看起来确实是不需要填充的。但是如果我们定义一个数组struct Reverse arr[2];会发生什么arr[0]占据地址 0x00 到 0x04。arr[1]如果结构体大小是 5也就是说没有填充那么arr[1]的起始地址就是 0x05。这时候问题出现了arr[1].a的地址0x05。0x05 不是 4 的倍数。这意味着数组里第二个元素的 int 成员竟然不对齐了CPU 读它会变慢或者崩溃。怎么解决呢其实也很简单编译器强制要求结构体总大小 最大成员的倍数。这样就不会出现上面的问题了。目前大小为 5需要补齐到 8 (这是4的倍数)。所以在char b后面编译器会加 3 个字节的Padding。此时的内存布局是这样的3.3 如何控制对齐在某些场景下比如网络传输我们不想浪费空间或者协议规定了紧凑排列我们可以修改编译器的默认行为。3.3.1#pragma pack(n)告诉编译器不要按成员大小对齐按我指定的 n 来对齐。#pragmapack(1)// 强制按 1 字节对齐相当于不对齐structpacked_demo{chara;intb;};#pragmapack()// 用来恢复默认a在 0b在 1因为 1 是pack(1)的倍数。总共 1 4 5 字节。但是CPU 读写效率会降低。3.3.2__attribute__((packed))(GCC 特有)在 Linux 内核源码中下面这种情况是很常见的structpacket{charhead;intlen;}__attribute__((packed));这个效果和 3.3.1 中介绍的方法效果是相同的。4. 实战避坑网络通信与跨平台传输理解了对齐你就能避开嵌入式网络编程中最大的“坑”。很多人在本地跑代码没问题一联调设备就崩原因往往就在这里。4.1 当 64位服务器遇上 32位单片机在网络编程物联网中我们经常直接把结构体指针转成void*通过Socket或串口直接发送出去。假设我们定义了一个通信协议包structPacket{charcmd;// 1 byte (命令字)intlength;// 4 bytes (数据长度)};发送端64位 Linux 开发机根据对齐规则cmd后面补 3 字节Paddingsizeof(Packet) 8。发送的数据流为[cmd] [X] [X] [X] [len_byte1] [len_byte2]...接收端资源受限的 32位 STM32 单片机为了节省珍贵的 RAM固件工程师可能在编译选项里开启了-fpack-struct全局紧凑模式或者手动设置了对齐。接收端认为sizeof(Packet) 5。此时它读取第 1 个字节当cmd紧接着读取第 2-5 个字节当length。结果是接收端把发送端填充的那 3 个垃圾字节当成了 length 的高位数据。那么这种问题该怎么解决呢请看下一章。4.2 强制取消对齐在设计跨平台通信协议时我们不能依赖编译器的默认对齐行为因为你不知道对方的编译器是怎么想的。我们需要显式地让编译器不要填充。structPacket{charcmd;intlength;}__attribute__((packed));#pragmapack(1)structPacket{charcmd;intlength;};#pragmapack()这也是上面提到的两种方法。sizeof变成了5。发送端和接收端的内存布局完全一致。但代价是性能的牺牲。CPU 读取这个length时因为地址不对齐Offset 1硬件需要进行两次总线访问和拼接。但在网络传输的正确性面前这点 CPU 损耗是必须付出的。4.3 关于 long 的陷阱除了对齐数据类型的大小是另一个大坑。这里我先说结论永远不要在跨平台协议中使用long。因为 C 语言标准只规定long至少和int一样大但是并没有规定具体是多少。在Windows 64位中long是4 字节。在Linux 64位中long是8 字节。如果你在协议里写了long timestamp;Windows 发给 Linux数据就会彻底错位。那么不用 long 的话该怎么定义 long 大小的数据类型呢答案是永远使用stdint.h中的定长类型明确指定位宽不给编译器留任何解释空间#includestdint.h#pragmapack(1)structProtocolHeader{uint8_tcmd;uint32_tlength;int64_ttimestamp;};#pragmapack()在底层开发中显式定义是永远优于隐式定义的。5. 高并发下的伪共享如果说前面的Padding是为了对齐而填充那么在多线程高并发领域我们有时需要反其道而行之也就是为了不对齐而填充。这听起来很矛盾让我们回到硬件层面聊聊Cache Line。关于 Cache Line的详细内容我前面的文章已经讲的非常详细了这里我们就直接进入本章节的内容。5.1 同一个缓存行内的竞争Cache Line 大小通常是64 字节。这意味着当你读取一个 4 字节的整数时CPU 会顺便把附近的 60 个字节也都加载到 L1 Cache 中空间局部性。但在多核多线程环境下这个机制可能会好心办坏事。假设我们有一个全局结构体被两个线程频繁访问structSharedData{longa;// 线程 A 频繁修改longb;// 线程 B 频繁修改};long占 8 字节a 和 b 紧挨着加起来才 16 字节。在内存中它们有很大概率会处在同一个 64 字节的 Cache Line中。请看下面的过程Core A 修改了变量 a。根据MESI 缓存一致性协议它必须广播告诉其他核心这一行 Cache Line 已经被修改了它是脏的你们已经获取的 Cache Line 无效。Core B 此时想修改变量 b。虽然 b 和 a 逻辑上无关但因为它们在同一行 Cache LineCore B 发现自己手里的 Cache 失效了。Core B 不得不重新从 L3 或主存拉取最新的 Cache Line 数据然后修改 b。这一修改又导致 Core A 的 Cache Line 失效。结果是两个线程明明修改的是不同的变量却在硬件缓存层面互相打架导致 CPU 在核心间疯狂倒腾数据总线带宽被占满性能暴跌。这就是著名的伪共享 (False Sharing)。5.2 解决方案为了解决这个问题我们需要手动填充把 a 和 b分到不同的缓存行让他们老死不相往来。具体做法如下我们需要在 a 后面强行塞入填充字节使其填满一个 Cache LinestructSharedData{volatilelonga;// 强制填充 56 字节加上long型的a正好64字节charpadding1[56];volatilelongb;// 尾部也填充防止 b 和后面的变量冲突charpadding2[56];};5.3 应用案例这种方法虽然会浪费很多字节但是在极致的高并发领域是非常有必要的。Linux 内核在自旋锁和一些网络设备驱动的数据结构中经常使用____cacheline_aligned宏来强制对齐到 64 字节防止多核竞争。6. 结语到这里这篇文章就结束了从最开始的一个经典案例到对硬件层面的解读和编译器的优化以及作为编程人员的我们该怎样控制对齐再到网络和跨平台通信的坑和高并发下的伪共享相信大家已经很全面的认识并理解了内存对齐存在的意义以及如何控制它。如果大家有任何问题可以发在评论区我看到了都会恢复最后如果这篇文章对你有帮助记得点赞和关注哦~