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)写在头文件里忘了恢复,结果整个工程的结构体大小全乱了,链接时各种报错。

知识体系总览

下面这张图把本章的核心逻辑串起来了,你可以对照着回顾:

内存对齐 对齐概念 地址必须是类型大小的整数倍 硬件决定对齐规则 结构体对齐 成员间插入填充字节 总大小是最大对齐值的倍数 指针偏移与对齐 偏移单位是类型大小 非对齐指针导致性能下降 pragma pack pack(1)取消对齐,紧凑排列 pack(n)按n字节对齐 应用场景 网络协议解析 文件格式读写 嵌入式寄存器访问 对齐是性能与空间的权衡

内存对齐说白了就是「用空间换时间」的典型。默认对齐让CPU读得快,但浪费内存;pack(1)省空间,但牺牲访问速度。具体用哪种,得看你的场景——是RAM紧张还是CPU紧张?

我个人建议:除非你明确知道自己在做什么(比如解析协议头),否则不要轻易用pack(1)。默认对齐是经过硬件工程师精心设计的,别自作聪明去破坏它。

最后一个小技巧:offsetof宏和alignof运算符来检查你的结构体布局。写个单元测试,打印每个成员的偏移量,一眼就能看出有没有浪费空间。我在代码审查时经常用这招,帮团队发现过不少隐藏的内存浪费。


公众号:蓝海资料掘金营,微信deep3321