17、指针与内存对齐:内存对齐的概念、结构体对齐、指针偏移与对齐的关系、pragma pack
内存对齐这个话题,说实话,很多C语言开发者写了三五年代码都不一定真正搞明白。但一旦你开始做底层驱动、网络协议解析、或者嵌入式系统开发,它就会像幽灵一样冒出来,让你头疼不已。
我个人习惯把内存对齐看作是「硬件给你定的规矩」。CPU读取内存不是按字节来的,而是按「字」来的——32位系统一次读4字节,64位系统一次读8字节。如果你把数据放得歪七扭八,CPU就得读两次再拼起来,性能直接腰斩。
什么是内存对齐?
说白了,内存对齐就是要求数据的起始地址是某个值的整数倍。这个值通常是数据本身的大小,或者是编译器指定的某个对齐值。
举个例子:一个4字节的int,它的地址最好是4的倍数。如果它被放在地址0x1001,那CPU读一次只能拿到一部分,还得再读一次才能拼完整。嗯,这就是所谓的「非对齐访问」。
核心规则:
- 1字节数据(char):任何地址都可以
- 2字节数据(short):地址必须是2的倍数
- 4字节数据(int、float):地址必须是4的倍数
- 8字节数据(double、long long):地址必须是8的倍数
我在项目中遇到过最典型的场景,就是解析网络数据包。协议栈里各种字段紧挨着,如果不考虑对齐直接强转指针,轻则性能下降,重则直接触发硬件异常——ARM平台上的总线错误就是这么来的。
结构体对齐:编译器在背后做了什么?
结构体对齐是内存对齐最「坑」的地方。你以为结构体成员是紧挨着放的?太天真了。编译器会在成员之间插入填充字节(padding),让每个成员都乖乖对齐。
来看个例子:
struct Example {
char a; // 1字节
int b; // 4字节
short c; // 2字节
};
你猜这个结构体占多少字节?直觉告诉你1+4+2=7字节。但实际呢?
printf("sizeof(struct Example) = %zu\n", sizeof(struct Example));
// 输出:12
为什么会这样?我们来拆解一下:
| 成员 | 偏移量 | 占用 | 说明 |
|---|---|---|---|
| char a | 0 | 1 | 没问题,任何地址都行 |
| 填充 | 1-3 | 3 | 为了让int b对齐到4的倍数 |
| int b | 4 | 4 | 对齐到4的倍数 |
| short c | 8 | 2 | 对齐到2的倍数 |
| 填充 | 10-11 | 2 | 结构体总大小必须是最大成员对齐值的整数倍 |
看到了吗?白白浪费了5个字节。我曾经在一个嵌入式项目里,结构体定义得乱七八糟,结果一个只有几十字节的结构体,硬生生膨胀到一百多字节。MCU的RAM本来就金贵,这种浪费简直要命。
优化技巧:把成员按大小从大到小排列,或者从小到大排列,能减少填充。比如上面的结构体改成:int b; short c; char a; 大小就从12字节降到了8字节。
指针偏移与对齐的关系
指针偏移操作,说白了就是通过指针加减整数来访问不同地址。但这里有个大坑——指针偏移的单位是「指向类型的大小」,不是字节。
int arr[4] = {10, 20, 30, 40};
int *p = arr;
p = p + 1; // 实际地址增加了4字节,不是1字节
这还好理解。但当你用指针访问结构体成员时,对齐问题就暴露出来了:
struct Data {
char type;
int value;
short flag;
};
struct Data d;
char *base = (char *)&d;
// 访问value成员
int *p_val = (int *)(base + offsetof(struct Data, value));
// 这里base+1的地址可能不是4的倍数!
// 如果结构体没有填充,这个指针就是非对齐的
我曾经在调试一个SD卡驱动时,就栽在这个坑里。结构体定义没注意对齐,用指针强转后直接访问,结果在Cortex-M4上跑得好好的,换到Cortex-M0上就崩了——因为M0不支持非对齐访问。
避坑指南:千万不要假设结构体成员是连续存放的。用offsetof宏获取真实偏移量,用memcpy拷贝数据,而不是直接指针强转。我曾经因为直接强转指针读寄存器,导致整个系统死机,查了两天才发现是对齐问题。
pragma pack:打破对齐规则
有时候我们就是想让结构体紧凑排列,不要填充。比如网络协议头、文件格式头,这些数据在内存里就是连续存放的,填充反而会破坏结构。
这时候就要用到#pragma pack了:
#pragma pack(1) // 设置对齐值为1字节
struct PacketHeader {
uint8_t version; // 1字节
uint16_t length; // 2字节
uint32_t sequence; // 4字节
uint8_t flags; // 1字节
};
#pragma pack() // 恢复默认对齐
printf("sizeof = %zu\n", sizeof(struct PacketHeader));
// 输出:8(没有填充)
pack(1)的意思就是「所有成员都按1字节对齐」,说白了就是取消对齐。但代价是什么?访问效率下降。CPU读一个4字节的int,如果地址不是4的倍数,就得读两次。
pack还可以设置其他值:
| pack值 | 效果 | 适用场景 |
|---|---|---|
| pack(1) | 完全紧凑,无填充 | 网络协议、文件格式、二进制通信 |
| pack(2) | 按2字节对齐 | 某些16位MCU |
| pack(4) | 按4字节对齐 | 32位系统默认行为 |
| pack(8) | 按8字节对齐 | 64位系统默认行为 |
重要提醒:pragma pack会影响整个翻译单元。如果你在头文件里用了pack(1),记得在结尾恢复。否则其他结构体也会被影响,后果很严重。我见过有人把pack(1)写在头文件里忘了恢复,结果整个工程的结构体大小全乱了,链接时各种报错。
知识体系总览
下面这张图把本章的核心逻辑串起来了,你可以对照着回顾:
内存对齐说白了就是「用空间换时间」的典型。默认对齐让CPU读得快,但浪费内存;pack(1)省空间,但牺牲访问速度。具体用哪种,得看你的场景——是RAM紧张还是CPU紧张?
我个人建议:除非你明确知道自己在做什么(比如解析协议头),否则不要轻易用pack(1)。默认对齐是经过硬件工程师精心设计的,别自作聪明去破坏它。
最后一个小技巧:用offsetof宏和alignof运算符来检查你的结构体布局。写个单元测试,打印每个成员的偏移量,一眼就能看出有没有浪费空间。我在代码审查时经常用这招,帮团队发现过不少隐藏的内存浪费。
公众号:蓝海资料掘金营,微信deep3321