一、内存对齐在操作系统内核中的真实角色

说实话,很多搞应用层开发的程序员,一辈子可能都碰不到内存对齐的底层细节。但做内核开发就不一样了——你写的每一行代码,都在和硬件打交道。

我个人习惯把内存对齐看作「硬件和软件之间的握手协议」。CPU 从内存读数据,不是按字节一个一个拿的,而是按「字」为单位。32位 CPU 一次读 4 字节,64位一次读 8 字节。如果数据没对齐,CPU 就得读两次,再拼起来。性能损失不说,有些架构直接给你抛异常。

我在做嵌入式 Linux 内核裁剪时,就遇到过因为结构体对齐没处理好,导致网络驱动在 ARM 平台上频繁崩溃的坑。后来发现是 DMA 传输时,缓冲区地址没按 4 字节对齐,数据直接写歪了。

1.1 内核结构体的对齐策略

内核里到处都是结构体。task_struct、inode、sk_buff……这些结构体如果不对齐,内存浪费还是小事,关键是 CPU 访问会出问题。

Linux 内核默认采用 自然对齐 策略:

  • char 类型:1 字节对齐
  • short 类型:2 字节对齐
  • int 类型:4 字节对齐
  • 指针类型:8 字节对齐(64位系统)

但内核开发者不会傻傻地让编译器自动排。他们会手动调整成员顺序,把大的成员往前放,小的往后放。为什么?因为这样可以减少填充字节。

核心原则:结构体总大小必须是最大成员对齐值的整数倍。末尾会补 padding。

举个例子,我见过一个新手写的驱动结构体:

struct bad_driver {
    char a;      // 1 字节
    int b;       // 4 字节,需要从偏移 4 开始
    short c;     // 2 字节,从偏移 8 开始
};
// sizeof = 12,实际有效数据只有 7 字节

改成这样就好多了:

struct good_driver {
    int b;       // 4 字节,偏移 0
    short c;     // 2 字节,偏移 4
    char a;      // 1 字节,偏移 6
};
// sizeof = 8,没有浪费

你看,只是调了个顺序,大小从 12 降到了 8。内核里成千上万个这样的结构体,省下来的内存相当可观。

1.2 伙伴系统与对齐的关系

伙伴系统(Buddy System)是内核管理物理内存的核心算法。它把内存按 2 的幂次分成块:4KB、8KB、16KB……一直到 4MB。

为什么要按 2 的幂分?因为这样合并和拆分都方便。但这里有个隐藏要求:每个内存块的起始地址必须对齐到它的大小

比如一个 16KB 的块,起始地址必须是 16KB 的整数倍。这就是伙伴系统对对齐的硬性要求。

我曾经调试过一个内存泄漏问题,最后发现是伙伴系统分配器返回的地址没对齐,导致 DMA 控制器直接罢工。嗯,这种问题查起来特别痛苦,因为表面现象是数据传输失败,根子却在内存管理。

我的经验:在内核里做内存分配,永远不要假设地址是自然对齐的。用 kmalloc 时,明确指定 GFP 标志,或者用 kmem_cache_alloc 从 slab 分配器拿内存,slab 天然保证对齐。

二、对齐策略的实战对比

不同的操作系统,对齐策略其实大同小异。我整理了一个表格,方便你对比:

系统 默认对齐方式 最大对齐值 特殊处理
Linux 自然对齐 sizeof(void*) __attribute__((packed)) 可强制紧凑
FreeBSD 自然对齐 16 字节(x86_64) mbuf 结构体有手动对齐宏
Windows NT 8 字节(x86)/ 16 字节(x64) 16 字节 DECLSPEC_ALIGN 宏控制
RT-Thread 4 字节(ARM Cortex-M) 8 字节 rt_align 宏用于临界区

你想想看,为什么 Windows 在 x64 上默认对齐到 16 字节?因为 SSE 指令需要 16 字节对齐。Linux 也类似,但更灵活——你可以用 __alignof__ 查当前类型的对齐要求。

三、位域与对齐的纠缠

位域(bit-field)是 C 语言里一个很特殊的存在。它允许你按位定义结构体成员,节省内存。但位域和对齐之间,有剪不断理还乱的关系。

标准 C 规定:位域不能跨存储单元。也就是说,如果一个位域从当前字节的中间开始,放不下下一个位域了,编译器会把它塞到下一个字节去。

举个例子:

struct bitfield_example {
    int a : 4;   // 占 4 位
    int b : 4;   // 占 4 位,和 a 挤在同一个 4 字节单元
    int c : 8;   // 占 8 位,可能跨到下一个单元
    int d : 20;  // 占 20 位,需要新单元
};
// 实际大小可能是 8 字节,而不是你想象的 4 字节

为什么会这样?因为编译器要保证每个位域都在一个对齐的存储单元内。int 类型的位域,存储单元是 4 字节。如果 c 从第 8 位开始,占 8 位,它还在第一个 4 字节内。但 d 需要 20 位,第一个单元只剩 16 位了,所以 d 会从第二个 4 字节开始。

注意:位域的内存布局是 实现定义 的。不同编译器、不同平台,结果可能不一样。写跨平台代码时,千万别依赖位域的具体内存排列。

我在项目中遇到过一个问题:用位域定义了一个网络协议头,在 x86 上跑得好好的,移植到 ARM 上就解析错了。查了半天,发现是位域的字节序(大端/小端)处理方式不同。从那以后,我处理协议头都改用移位和掩码操作,不再用位域了。

四、内核中的对齐宏与工具

Linux 内核提供了一组非常实用的对齐宏,我几乎每天都在用:

// 对齐到 2 的幂次
#define ALIGN(x, a)         __ALIGN_KERNEL(x, a)
#define __ALIGN_KERNEL(x, a)    __ALIGN_KERNEL_MASK(x, (typeof(x))(a) - 1)
#define __ALIGN_KERNEL_MASK(x, mask)    (((x) + (mask)) & ~(mask))

// 检查是否对齐
#define IS_ALIGNED(x, a)    (((x) & ((typeof(x))(a) - 1)) == 0)

// 向下对齐
#define ALIGN_DOWN(x, a)    ((x) & ~((typeof(x))(a) - 1))

这些宏的原理很简单:向上对齐就是加 (a-1) 再截断。比如你要把 13 对齐到 8,13 + 7 = 20,20 & ~7 = 16。完美。

我个人习惯在写内核模块时,凡是涉及 DMA 缓冲区、共享内存、硬件寄存器映射的地方,都显式调用 ALIGN 宏。别指望编译器帮你做,它没那么智能。

五、伙伴系统的对齐实现细节

伙伴系统的核心数据结构是一个数组,每个元素是一个链表,挂载着对应大小的空闲内存块。

分配时,如果请求 3KB,伙伴系统会从 4KB 的链表中取一块。如果 4KB 链表空了,就从 8KB 链表取一块,拆成两个 4KB,一个返回给用户,一个挂回 4KB 链表。

这里的关键是:拆分出来的两个块,地址必须是对齐的。比如一个 8KB 块起始地址是 0x1000,拆成两个 4KB 块,地址分别是 0x1000 和 0x2000。0x1000 是 4KB 对齐的,0x2000 也是 4KB 对齐的。这就是「伙伴」的含义——它们互为伙伴,地址只差一个块大小。

合并时也一样:只有两个地址对齐、大小相同的相邻块,才能合并成一个更大的块。

一句话总结:伙伴系统靠对齐来保证拆分和合并的正确性。没有对齐,伙伴系统就崩了。

我曾经在调试一个内存碎片问题时,手动打印了伙伴系统的链表状态。发现很多 4KB 块无法合并成 8KB,就是因为它们的起始地址不是 8KB 对齐的。根源是某个驱动用 __get_free_pages 分配时,没指定正确的 order,导致地址错位。

六、一张图看懂内存对齐与伙伴系统的关系

下面这张 SVG 图,把本章的核心逻辑串起来了。从结构体对齐,到位域布局,再到伙伴系统的块管理,你看完应该能有个整体印象。

内存对齐与伙伴系统核心逻辑 结构体对齐 • 自然对齐:成员按自身大小对齐 • 末尾填充:总大小是最大对齐倍数 • 手动重排:大成员在前,小在后 • packed 属性:强制紧凑布局 位域与对齐 • 位域不能跨存储单元 • 存储单元 = 成员类型大小 • 实现定义,跨平台需谨慎 • 协议头建议用移位操作 伙伴系统 • 按 2 的幂次分块 • 块地址对齐到块大小 • 伙伴地址差 = 块大小 • 合并需地址对齐且相邻 内核对齐宏:ALIGN、IS_ALIGNED、ALIGN_DOWN 向上对齐:(x + (a-1)) & ~(a-1) | 检查对齐:(x & (a-1)) == 0 核心结论 对齐是硬件要求,也是内存管理的基础。结构体对齐减少浪费,伙伴系统靠对齐保证正确性。

七、避坑指南与个人经验

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  • 别在共享内存结构体里用位域。 不同编译器、不同架构,位域的布局完全不同。用 uint32_t 加掩码操作,可读性差一点,但绝对安全。
  • DMA 缓冲区必须对齐到缓存行大小。 一般是 64 字节。不对齐的话,cache 一致性操作会出问题,数据可能被写乱。
  • 用 __attribute__((packed)) 要小心。 它虽然能省空间,但会让成员不对齐,访问时可能触发异常或性能骤降。我一般只在网络协议头这种必须紧凑的场景用。
  • 调试对齐问题,用 offsetof 宏。 它可以帮你查出每个成员在结构体里的偏移,比肉眼数快多了。

一个小技巧:写内核代码时,在结构体定义后面加一行 BUILD_BUG_ON(sizeof(struct xxx) != 期望大小);。这样如果编译器因为对齐调整改变了结构体大小,编译阶段就会报错,不用等到运行时崩溃。

好了,关于内存对齐在内核中的应用,就聊到这里。记住一句话:对齐不是可选项,是硬性要求。 无论是结构体布局、位域设计,还是伙伴系统的内存管理,对齐都贯穿始终。理解它,你写内核代码时就能少掉很多头发。


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