1、内存对齐基础

各位同学,今天咱们来聊聊内存对齐。说实话,我刚入行那会儿,觉得这东西就是个玄学——结构体里字段换个顺序,大小就变了?这不是编译器在搞鬼吗?后来踩了几次坑,才明白这背后是有道理的。

什么是内存对齐

内存对齐,说白了就是数据在内存里摆放时,要遵循某种"对齐规则"。比如一个4字节的int,它的地址必须是4的倍数;一个8字节的double,地址必须是8的倍数。这不是编译器闲得慌,而是CPU硬件的要求。

我打个比方你就懂了。你去图书馆借书,管理员告诉你:"每本书必须放在书架的起始位置,不能横跨两个格子。"这就是对齐。如果你非要把一本书斜着放,横跨两个格子,管理员取书时就得折腾两下——CPU也是这个道理。

核心定义:内存对齐是指数据在内存中的起始地址必须是其自身大小的整数倍(或者某个对齐系数的整数倍)。

为什么需要内存对齐

你可能会问:"不对齐会怎样?" 嗯,我直接告诉你答案:性能下降,甚至崩溃

原因其实很简单。现代CPU读取内存,不是一次读1个字节,而是按"字"来读的。32位CPU一次读4字节,64位CPU一次读8字节。如果数据没对齐,CPU就得读两次,再把数据拼起来——这多累啊。

我记得有一次做嵌入式开发,一个结构体没注意对齐,结果在ARM芯片上直接报总线错误。查了半天,发现是某个short变量落在了奇数地址上。从那以后,我对齐这件事再也不敢马虎了。

  • 性能原因:对齐的数据,CPU一次读取搞定;不对齐的数据,CPU可能要读2次甚至更多
  • 硬件原因:有些CPU(比如ARM、RISC-V)根本不允许非对齐访问,一碰就崩
  • 原子性原因:对齐的数据可以保证原子操作,非对齐的数据在多线程环境下容易出问题

注意:x86架构对非对齐访问比较宽容,只是慢一点。但ARM、MIPS等嵌入式芯片,非对齐访问直接触发异常。做嵌入式开发的同学,这点一定要牢记。

对齐边界与对齐系数

这两个概念容易搞混,我帮你理一理。

对齐边界,指的是某个数据类型自身要求的对齐值。比如:

数据类型 大小(字节) 对齐边界
char 1 1(任意地址)
short 2 2(偶数地址)
int 4 4(4的倍数地址)
float 4 4
double 8 8
指针(32位) 4 4
指针(64位) 8 8

对齐系数,则是编译器或平台设定的"最大对齐单位"。在大多数平台上,对齐系数就是8(64位系统)或4(32位系统)。结构体的整体对齐,取的是成员中最大对齐边界对齐系数的较小值。

举个例子你就明白了:

struct Example {
    char  a;  // 1字节,对齐边界1
    int   b;  // 4字节,对齐边界4
    short c;  // 2字节,对齐边界2
};

这个结构体的大小是多少?直觉告诉你:1 + 4 + 2 = 7字节。但实际是12字节。为什么?

因为int b要对齐到4的倍数,所以char a后面要填充3个字节的"空洞"。short c后面还要填充2个字节,让整个结构体的大小是4的倍数(最大成员的对齐边界)。

我的经验:写结构体时,把大的成员放在前面,小的放后面,能减少填充。比如上面那个例子,改成 int b; short c; char a; 大小就变成8字节了。省了4个字节,这在嵌入式场景下很实用。

知识体系总览

下面这张图,帮你把内存对齐的核心逻辑串起来:

内存对齐知识体系 什么是内存对齐 数据地址 = 对齐边界 × N int → 4的倍数地址 double → 8的倍数地址 为什么需要对齐 CPU按"字"读取内存 非对齐 → 两次读取 + 拼接 ARM等芯片直接报错 对齐边界与系数 对齐边界 = 数据类型大小 对齐系数 = 平台设定值 结构体对齐 = min(最大成员, 系数) 核心结论 内存对齐 = 用空间换时间 + 满足硬件约束 → 性能与兼容性的平衡 💡 小技巧:结构体成员按大小降序排列,可减少填充字节,节省内存

实际代码验证

光说不练假把式。咱们写段代码验证一下:

#include <stdio.h>
#include <stddef.h>

// 不优化顺序的结构体
struct BadOrder {
    char  a;   // 1字节
    int   b;   // 4字节
    short c;   // 2字节
};

// 优化顺序的结构体
struct GoodOrder {
    int   b;   // 4字节
    short c;   // 2字节
    char  a;   // 1字节
};

int main() {
    printf("BadOrder  size = %zu\n", sizeof(struct BadOrder));
    printf("GoodOrder size = %zu\n", sizeof(struct GoodOrder));
    
    printf("BadOrder 偏移: a=%zu, b=%zu, c=%zu\n",
           offsetof(struct BadOrder, a),
           offsetof(struct BadOrder, b),
           offsetof(struct BadOrder, c));
    
    printf("GoodOrder偏移: b=%zu, c=%zu, a=%zu\n",
           offsetof(struct GoodOrder, b),
           offsetof(struct GoodOrder, c),
           offsetof(struct GoodOrder, a));
    
    return 0;
}

运行结果:

BadOrder  size = 12
GoodOrder size = 8
BadOrder 偏移: a=0, b=4, c=8
GoodOrder偏移: b=0, c=4, a=6

看到了吧?同样的三个成员,换个顺序,大小从12字节变成了8字节。这就是对齐的威力。

关键点:结构体的大小必须是最大成员对齐边界的整数倍。BadOrder中最大成员是int(4字节),所以最终大小是12(4的倍数)。GoodOrder同理,8也是4的倍数。

避坑指南

我曾经在做一个网络协议解析的项目时,直接用一个结构体去映射网络数据包。结果因为本地编译器的对齐规则和网络协议的对齐规则不一致,解析出来的数据全是乱的。后来加了 #pragma pack(1) 才搞定。

所以给你几个建议:

  • 跨平台传输数据时,一定要用 #pragma pack(1) 取消对齐,或者手动序列化
  • 嵌入式开发中,注意芯片是否支持非对齐访问,不支持的话要小心处理
  • 性能敏感场景,尽量让结构体成员按大小降序排列,减少填充
  • 调试时,用 offsetof 宏查看成员偏移,能帮你快速定位对齐问题

我个人习惯在结构体定义后面加个静态断言,确保大小符合预期:_Static_assert(sizeof(struct Foo) == 16, "unexpected size"); 这样编译时就能发现问题。

好了,内存对齐的基础就讲到这里。说白了就是一句话:数据放对位置,CPU才跑得快。下一节咱们聊聊位域,那个东西更绕,但用好了能省不少内存。


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