10、内存对齐:对齐原理、alignof/alignas、结构体对齐

内存对齐这个话题,说实话,很多C语言开发者一开始都不太在意。我早年写嵌入式程序时也吃过这个亏——明明结构体里就几个字段,sizeof一算,比预期大了不少。当时我还在纳闷,这编译器是不是偷偷给我塞东西了?

后来我才明白,这不是编译器在捣乱,而是CPU在「提要求」。说白了,内存对齐就是让数据存放在「合适」的地址上,方便CPU一次性把数据取走。你想想看,如果CPU每次读4字节,结果一个int横跨在两个内存行上,那它就得读两次,再拼起来——多累啊。

10.1 为什么需要内存对齐?

现代CPU读取内存,不是按字节来的,而是按「字」来的。比如32位CPU,一次读4字节。如果数据刚好对齐,一次搞定。如果没对齐,轻则多读一次,重则直接崩溃(某些ARM平台就是这样)。

我在项目中遇到过这样一个场景:一个传感器数据包,结构体里塞了char、int、short,结果在ARM Cortex-M3上跑,程序直接进了HardFault。查了半天,发现是结构体成员没对齐,CPU读int时地址不是4的倍数。从那以后,我对齐这件事再也不敢马虎了。

核心原则: 任何数据类型的地址,必须是其自身大小的整数倍。

举个例子:

  • char:1字节,任何地址都行
  • short:2字节,地址必须是2的倍数
  • int:4字节,地址必须是4的倍数
  • double:8字节,地址必须是8的倍数

10.2 alignof 和 alignas —— C11 带来的对齐利器

C11标准引入了两个关键字:_Alignof_Alignas。为了方便使用,头文件 <stdalign.h> 提供了 alignofalignas 这两个宏。

10.2.1 alignof:查询对齐要求

alignof 返回一个类型的对齐字节数。说白了,就是告诉你这个类型的数据应该放在什么样的地址上。

#include <stdio.h>
#include <stdalign.h>

int main() {
    printf("char 的对齐要求: %zu\n", alignof(char));
    printf("int  的对齐要求: %zu\n", alignof(int));
    printf("double 的对齐要求: %zu\n", alignof(double));
    printf("float 的对齐要求: %zu\n", alignof(float));
    return 0;
}

输出结果(常见平台):

char 的对齐要求: 1
int  的对齐要求: 4
double 的对齐要求: 8
float 的对齐要求: 4

嗯,这里要注意:alignof 返回的是「最小对齐要求」,不是「最大」。有些平台可能要求更严格,但不会比这个值小。

10.2.2 alignas:手动设置对齐

alignas 可以让你手动指定一个变量或类型的对齐方式。我个人习惯在需要缓存行对齐、或者与硬件寄存器对接时使用它。

#include <stdio.h>
#include <stdalign.h>

// 让这个结构体按 32 字节对齐(常用于缓存行对齐)
struct alignas(32) CacheLine {
    int data[8];
};

int main() {
    struct CacheLine cl;
    printf("CacheLine 的对齐要求: %zu\n", alignof(struct CacheLine));
    printf("CacheLine 的地址: %p\n", (void*)&cl);
    return 0;
}
小技巧: 如果你想让一个变量对齐到页边界(通常是4096字节),可以用 alignas(4096)。我在做内存池分配器时就这么干过,省了不少心。

10.3 结构体对齐 —— 最让人头疼的部分

结构体对齐,说白了就是编译器在结构体成员之间「塞填充字节」,让每个成员都满足自己的对齐要求。同时,结构体整体的大小也要是最大成员对齐要求的整数倍。

为什么会这样?因为如果你定义一个结构体数组,第二个结构体的起始地址也必须对齐。否则第二个结构体里的成员又可能不对齐了。

10.3.1 一个经典例子

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

struct Example1 {
    char a;    // 1字节
    int b;     // 4字节
    short c;   // 2字节
};

struct Example2 {
    int b;     // 4字节
    short c;   // 2字节
    char a;    // 1字节
};

int main() {
    printf("Example1 大小: %zu\n", sizeof(struct Example1));
    printf("Example2 大小: %zu\n", sizeof(struct Example2));
    return 0;
}

输出结果:

Example1 大小: 12
Example2 大小: 8

你看,同样的成员,只是顺序不同,大小差了4个字节。这就是对齐填充搞的鬼。

我来给你拆解一下 Example1 的内存布局:

偏移 内容 说明
0 char a 1字节
1-3 填充 为了让int b对齐到4的倍数
4-7 int b 4字节
8-9 short c 2字节
10-11 填充 为了让结构体大小是4的倍数

Example2 的布局就紧凑多了:

偏移 内容 说明
0-3 int b 4字节
4-5 short c 2字节
6 char a 1字节
7 填充 为了让结构体大小是4的倍数
避坑指南: 我曾经在写网络协议解析时,直接拿结构体指针去强转接收到的字节流。结果因为结构体内部有填充字节,解析出来的数据全是错的。后来我改用 #pragma pack(1) 或者手动按字节解析,才解决了问题。

10.4 知识体系总览

下面这张图,我把内存对齐的核心知识点串了起来。你可以把它当作一个快速索引,遇到问题时回来瞄一眼就行。

内存对齐知识体系 对齐原理 CPU读取效率 · 硬件要求 alignof / alignas 查询对齐 · 手动设置 结构体对齐 成员顺序 · 填充字节 地址必须是类型大小的整数倍 未对齐导致性能下降或崩溃 alignof:查询类型对齐要求 alignas:手动指定对齐方式 成员顺序影响结构体大小 整体大小是最大对齐的倍数 核心原则:按最大成员对齐,成员按自身对齐,整体收尾对齐 实践建议:大成员在前,小成员在后;必要时使用 #pragma pack 或 alignas

10.5 实用建议

说了这么多,我总结几条实战经验给你:

  • 调整成员顺序:把大的成员(如double、int)放在前面,小的(如char、short)放在后面。这样能减少填充字节,结构体更紧凑。
  • 使用 offsetofoffsetof(struct, member) 可以查看成员在结构体中的偏移量,调试时很有用。
  • 慎用 #pragma pack(1):虽然可以取消对齐,节省空间,但会导致访问未对齐成员时性能下降。在跨平台通信时可以用,但平时不建议。
  • alignas 对齐缓存行:如果你在做高性能计算,把热点数据按64字节对齐,可以避免伪共享问题。
我的习惯: 每次定义结构体后,我都会用 sizeofoffsetof 验证一下布局。尤其是在做底层驱动或网络协议时,这一步能省去后面大量的调试时间。

内存对齐这件事,说白了就是「空间换时间」的典型例子。多花几个字节的填充,换来CPU一次读取搞定,这笔账怎么算都划算。你只要记住:对齐是CPU的脾气,不是编译器的任性,后面写代码时心里就有数了。


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