跳到主要内容

第 8 章 内核内存分配器:伙伴系统与 Slab

8.0 引言:当内核开始管理内存

在之前的章节里,我们像拆钟表一样剖析了内核的内部架构和进程管理。现在,我们要触碰这个操作系统中最为敏感、也最核心的神经系统——内存管理

对于驱动开发者来说,这往往是“噩梦”的开始。你写好了逻辑,注册了中断,看起来一切完美,但系统就是不定期地崩溃,或者运行一段时间后莫名其妙地变慢。很多时候,问题就出在内存分配的细节上:你在不知不觉中浪费了宝贵的物理内存,或者在错误的上下文中睡眠了。

Linux 内核的内存分配并不是简单的“给你一块地”。它分成了两个主要的层次:底层的 页面分配器,也就是我们要讲的伙伴系统;以及构建在其上的 Slab 分配器。理解这两者的工作原理,知道什么时候用哪个,能让你在代码审查时少掉几根头发。

这一章,我们不仅要讲 API 的使用,还要通过代码和内核日志,把那些藏在背后的、反直觉的内存浪费问题挖出来。


8.1 内核内存分配器:是谁在掌管 RAM?

我们要解决的核心问题很简单:在内核里,内存是怎么分配和释放的?

但这背后藏着一个很深的认知陷阱。如果你带着用户空间 malloc() 的直觉进入内核,你会摔得很惨。内核内存是物理连续的,是珍贵的,而且永远不会被换出到磁盘

8.1.1 两大引擎:页面分配器与 Slab 分配器

你可以把内核的内存管理想象成一家大型建材超市。

  • 页面分配器是那个开着叉车的大仓库管理员。他手里有一张清单,记录着所有成捆的木材(2 的幂次方大小的物理页块)。如果你需要 128 根木头,他会给你一捆 128 根的;如果没有,他会把一捆 256 根的锯成两半。他只处理这种大宗交易。他的算法叫伙伴系统
  • Slab 分配器则是仓库门口的零售柜台。他知道大家经常只需要几个钉子(小对象),没必要每次都去仓库里搬一整捆木头。于是他预先把一些木头拆好了,放在各种规格的盒子里。当你需要 20 个钉子时,他给你一个 32 个装的盒子,虽然多了一点点,但总比给你一整捆木头强。

这就引出了一个关键点:Slab 分配器是活在页面分配器之上的。Slab 的“盒子”最终还是要找页面分配器要“整捆的木头”。但对我们开发者来说,知道用哪个柜台能省下不少麻烦。

8.1.2 内核内存的铁律:不可交换

这里有一条必须刻在脑子里的规则:内核内存是不可交换的

用户空间的程序可以用完物理内存,然后被内核扔到 swap 分区里去(也就是磁盘),以此腾出 RAM。但内核代码本身运行的数据结构——比如用来管理内存的数据结构——绝不能被扔出去。否则,当内核想要从磁盘把数据读回来时,它需要内存来操作,而内存管理代码却在磁盘上……这就像是你为了找眼镜而需要戴上眼镜一样,是个死锁。

所以,内核内存是常驻 RAM 的。这也意味着,浪费内核内存比浪费用户内存的代价要大得多


8.2 页面分配器:伙伴系统的奥秘

让我们走进那间大仓库。页面分配器的核心数据结构是 struct zone,而在它里面,有一个数组 free_area[MAX_ORDER]。这就是我们要讲的“伙伴系统空闲链表”。

8.2.1 理解空闲链表的结构

MAX_ORDER 是一个架构相关的常量,在 x86 和 ARM 上通常是 11。这意味着我们有 11 条链表(编号 0 到 10),每条链表上挂着不同大小的“内存块”。

  • Order 0: 挂着 1 页(4 KB)的块。
  • Order 1: 挂着 2 页(8 KB)的块。
  • Order 2: 挂着 4 页(16 KB)的块。
  • ...
  • Order 10: 挂着 1024 页(4 MB)的块。

所有的块都是物理连续的。这就是为什么分配器叫“伙伴系统”:如果你有一个 Order N 的块,把它切成两半,这两个 Order N-1 的块就是“伙伴”。如果它们都空闲了,它们就会重新合并成一个 Order N 的块。这就是“反碎片化”的魔法。

你可以通过 /proc/buddyinfo 这张“仓库账本”看到当前的库存:

$ cat /proc/buddyinfo
Node 0, zone DMA 1 1 1 0 1 1 1 0 1 1 1
Node 0, zone DMA32 3551 1542 476 255 158 77 41 21 11 3 0

每一列数字对应的就是 Order 0 到 Order 10 上空闲块的数量。如果你在 Order 10 那一列看到 0,那就说明系统里已经找不到哪怕一块 4 MB 的连续物理内存了。

8.2.2 算法的实际运作:一个分配请求的一生

假设你的驱动请求 128 KB 的内存。算一下,128 KB / 4 KB = 32 页。32 是 2 的 5 次方。所以分配器会去 Order 5 的链表上找。

  1. 如果有货:直接拿走。结束。
  2. 如果没货:去 Order 6 找。如果 Order 6 有一个 256 KB 的块,分配器会把它切成两半(变成两个 128 KB 的伙伴)。
  3. 处理多余部分:其中一半给你(满足请求),另一半挂回 Order 5 的链表上,留着下次用。

听起来很完美,对吧?但这里有一个巨大的坑,叫做内部碎片

8.2.3 踩坑现场:内部碎片的陷阱

假设你需要 132 KB 的内存。 分配器一看:132 KB 不是 2 的幂。下一个能装下它的“盒子”是 Order 7 的块,也就是 256 KB。 结果:你申请了 132 KB,内核却给了你 256 KB。剩下的 124 KB 就这么浪费了

这就是“锯木头”的代价。如果你只需要稍微超过 128 KB 的一点点,仓库管理员也会被迫去锯一捆 256 KB 的木头给你。

为了缓解这个问题,内核提供了一对 API:alloc_pages_exact()free_pages_exact()。它们会聪明地多分配一点,然后把多余的部分“还”回去。虽然不能完全消除浪费,但能止血。


8.3 使用页面分配器 API:与内核“交易”

现在我们要动手写代码了。页面分配器提供了一组 API,它们的名字里通常都有 page 或者 free_page

8.3.1 核心速查表

这些 API 是你在内核里做“大宗交易”的工具:

API / 宏功能返回值
__get_free_page(gfp)分配 1 页内核逻辑地址
__get_free_pages(gfp, order)分配 $2^{order}$ 页内核逻辑地址
get_zeroed_page(gfp)分配 1 页并清零内核逻辑地址
alloc_page(gfp)分配 1 页struct page * 结构指针
alloc_pages(gfp, order)分配 $2^{order}$ 页struct page * 结构指针

注意区别__get_free_page 返回的是地址(你可以直接用),而 alloc_page 返回的是页描述符struct page),你需要调用 page_address() 把它转换成地址才能用。这就像一个是直接给你钥匙,一个是给你登记卡让你去前台换钥匙。

8.3.2 释放内存:别搞砸了

对应的释放 API 很简单:

void free_page(unsigned long addr);
void free_pages(unsigned long addr, unsigned int order);
void __free_pages(struct page *page, unsigned int order);

这里有个经典的坑:如果你用 alloc_page 分配,得到了 page 指针,千万别把它当成长整型地址传给 free_pages,或者传给 __free_pages 的第一个参数时搞混了。一定要配对使用。


8.4 GFP 标志:告诉内核你的底线

所有的分配函数里都有个叫 gfp_mask 的参数(比如 GFP_KERNELGFP_ATOMIC)。这不是摆设,它是你和内核签订的“生死契约”。

8.4.1 黄金法则:进程上下文 vs 原子上下文

这个规则你必须背下来,违反它系统就会死机或卡死:

  1. GFP_KERNEL:你在进程上下文里(比如在模块的 init 函数,或者系统调用的实现里),而且允许睡眠。这是最常用的标志。它会告诉内核:“如果内存不够,你可以去回收内存,甚至去读写磁盘,慢慢来,我可以等。”
  2. GFP_ATOMIC:你在原子上下文里(比如中断处理函数 ISR,或者你正拿着自旋锁 spinlock)。这里绝对不能睡眠。你必须告诉内核:“必须现在给我,如果不行就直接失败返回,千万别尝试去调度或者 I/O。”

如果你在持有自旋锁的时候用了 GFP_KERNEL,内核可能会尝试睡眠去换取内存,导致调度器混乱。这就是所谓的“在原子区睡眠”,后果通常是系统死锁或panic。

8.4.2 其他常用标志

  • __GFP_ZERO:这是个修饰符,通常与上面两个配合使用(GFP_KERNEL | __GFP_ZERO)。它告诉内核把分配来的内存清零。这是良好的编程习惯,可以防止信息泄露和未初始化内存漏洞。

8.5 Slab 分配器:让小对象分配更高效

回到我们刚才的“建材超市”类比。如果你每次只需要几个字节(比如一个网络包的结构体 sk_buff),页面分配器那把大锯子就太笨重了。这时我们需要 Slab 分配器。

8.5.1 对象缓存与通用缓存

Slab 分配器的设计初衷有两个:

  1. 对象缓存:内核里有很多常用的数据结构(比如 task_struct, inode, dentry)。它们被频繁地分配和释放。Slab 分配器会在系统初始化时预先创建这些对象的“缓存池”。当你需要 task_struct 时,直接从这个池子里拿一个现成的,用完扔回去。这比每次都去初始化一个新结构要快得多。
  2. 通用缓存:对于我们没有特殊要求的常规内存分配,内核维护了一组 kmalloc-N 的缓存(比如 kmalloc-8, kmalloc-16, ... kmalloc-8192)。当你调用 kmalloc(20, ...) 时,内核会发现你需要的超过了 16 字节,于是直接给你一个 32 字节的 slab。

8.5.2 核心速查表:kmalloc 与 kzalloc

这是你日常写驱动用得最多的 API:

void *kmalloc(size_t size, gfp_t flags);
void *kzalloc(size_t size, gfp_t flags);
void kfree(const void *);
  • kmalloc:返回未初始化的内存(内容是随机的,垃圾数据)。
  • kzalloc:返回清零后的内存。推荐优先使用这个,省心又安全。
  • kfree:释放内存。注意,传 NULLkfree 是安全的,它什么都不做。

重要特性:Slab 分配出来的内存也是物理连续的,而且保证了CPU 缓存行对齐(Cache Line Aligned),这在高性能网络和存储驱动里至关重要。


8.6 终极限制:kmalloc 的天花板

既然 Slab 这么好用,那我是不是可以用 kmalloc 申请 100 MB 的内存?

不行。

回到那个建材超市的比喻。Slab 柜台虽然灵活,但它的存货(Slab 对象)最终也是从仓库(页面分配器)搬来的。而仓库里最大的那一单货物(MAX_ORDER)通常限制在 4 MB(1024 页)。

因此,kmalloc 的单次分配上限就是 4 MB

如果你尝试分配超过这个大小的内存(比如 5 MB),kmalloc 会直接返回 NULL,内核甚至会打印警告信息,因为这种分配请求通常意味着设计上有问题。如果你真的需要那么大的连续物理内存,你应该考虑其他的 DMA 分配机制,或者重新设计你的数据结构,使用非连续的内存分页来处理。


8.7 资源管理与技巧

最后,作为内核开发者,我们有几个小技巧能让代码更健壮。

  1. devm_kzalloc:如果你在写设备驱动,尽量用这个 API。它是“资源托管”的。当你的驱动被卸载或者设备断开时,内核会自动帮你释放这块内存。你不用担心忘了 kfree 导致内存泄漏,这在错误处理路径里非常有用。
  2. kcallockmalloc_array:如果你要分配一个数组,不要手算 count * size,直接用这两个 API。它们内部会检查整数溢出。防止你计算 2000000000 * 2 时溢出变成 0,导致分配极小的内存从而引发崩溃。
  3. 注意对齐:Slab 分配器已经帮你做了缓存行对齐,所以尽量把频繁访问的数据结构成员放在结构体开头,以便它们落在同一个缓存行里,提高命中率。

本章回响

回想一下本章开头提到的那个“噩梦”场景。现在你应该明白,为什么简单地“分配内存”在内核里这么复杂了。

内核的内存分配体系——从底层的页面分配器到上层的 Slab——本质上是在三个互相矛盾的目标之间做平衡:物理连续性速度碎片化

  • 如果你需要物理连续的大块内存(比如 DMA),你必须直面伙伴系统的内部碎片问题。
  • 如果你需要速度和小块内存,你依赖 Slab,但依然受限于底层伙伴系统的上限(4 MB)。
  • 无论哪种选择,你都必须时刻清醒地知道自己在哪个上下文(GFP 标志),否则代价可能是整个系统的崩溃。

下一章,我们将继续深入这个话题。你会发现,有时候连“物理连续”都不一定能保证,我们还需要处理更复杂的场景,比如 vmalloc 以及那个让所有服务器管理员都闻之色变的 OOM Killer。


练习题

练习 1:application

题目:假设你编写了一个网络设备驱动,其中断处理函数(ISR)需要为接收到的数据包分配一块物理连续的内存。已知该函数运行在原子上下文中,请问你应该使用以下哪个内存分配标志(GFP flag)?如果选错会有什么潜在后果?

答案与解析

答案:应该使用 GFP_ATOMIC。如果使用 GFP_KERNEL,可能会导致系统死锁或崩溃。

解析:1. 概念应用:中断处理程序(ISR)运行在原子上下文(Atomic Context)中,在此上下文中不允许进程睡眠。 2. GFP 区别GFP_KERNEL 是标准的分配标志,它允许内核在内存不足时通过回收内存或进行 I/O 操作来等待,这会导致调用进程睡眠。GFP_ATOMIC 则明确禁止分配器睡眠,如果内存不足,它会立即失败而不是等待。 3. 后果分析:如果在持有自旋锁或处于中断上下文时调用 GFP_KERNEL,内核可能会尝试调度其他进程,导致死机或系统崩溃。因此,在原子上下文中必须使用 GFP_ATOMIC

练习 2:understanding

题目:如果请求分配 132 KB 的物理连续内存,在标准的 Buddy System(伙伴系统)算法中(假设页大小为 4 KB),内核实际会分配多少内存?这种现象被称为什么?如果有 50 MB 的可用内存碎片化严重,可能会导致分配失败吗?

答案与解析

答案:实际会分配 256 KB。这种现象称为内部碎片。是的,可能会失败。

解析:1. 计算逻辑:Buddy System 只能分配 $2^{order}$ 个页帧的内存块。132 KB 约等于 33 个页帧(132/4)。向上取最近的 2 的幂是 $2^6 = 64$ 个页帧,即 256 KB。 2. 概念定义:请求的 132 KB 小于实际分配的 256 KB,剩余的 124 KB 无法被系统有效利用,这种浪费称为内部碎片。 3. 思考/分析:虽然系统可能有 50 MB 的总空闲内存,但如果这 50 MB 是散落在低阶链表(如 order 0, 1, 2)中的碎片,无法凑出一个大的连续块(如 order 5 或更高),那么 256 KB 的分配请求就会失败。这就是 Buddy System 的局限性。

练习 3:thinking

题目:Linux 内核中有一个预分配的 Slab 缓存名为 kmalloc-192(在支持 CONFIG_SLABCONFIG_SLUB 的系统中)。请分析:为什么内核不直接使用伙伴系统来满足这种小于一页的小内存分配请求,而是维护这样一个 Slab 层?这样做对 CPU 缓存性能有什么潜在影响?

答案与解析

答案:使用 Slab 层是为了减少内部碎片、提高分配速度并优化对象初始化成本。对 CPU 缓存的影响是:Slab 分配器通常会保证内存对齐到缓存行,从而提高缓存命中率。

解析:1. 架构设计:如果直接用 Buddy System 分配 192 字节,Buddy System 最小分配一页(4 KB),导致巨大的内部碎片(浪费 ~95%)。Slab 分配器将页“切分”成固定大小(如 192 字节)的槽位,极大提高了内存利用率。 2. 性能考量:Buddy System 的分配/释放涉及复杂的链表操作和元数据更新,较慢。而 Slab 基于对象缓存,分配和释放通常只是简单的指针操作,速度极快。 3. 缓存一致性:内核经常需要频繁分配/释放特定的数据结构(如 task_struct, dentry)。Slab 允许缓存这些对象,甚至允许初始化对象构造函数,避免重复初始化开销。 4. CPU 缓存对齐:Slab 分配器(特别是通用 kmalloc 缓存)通常会将分配的内存对齐到 CPU 缓存行(如 64 字节)。这避免了“False Sharing”(伪共享),即不同 CPU 核心修改同一缓存行上的不同变量导致的性能抖动,从而提升了多核系统下的性能。


要点提炼

Linux 内核内存管理分为两个核心层次:底层的伙伴系统负责管理物理连续的页框,适用于大块内存分配;上层的 Slab 分配器则构建在伙伴系统之上,通过缓存对象池来高效处理小块内存的频繁分配与释放。

伙伴系统将内存划分为 2 的幂次方大小的块,通过 MAX_ORDER 数组管理。虽然其“分裂与合并”机制能有效减少外部碎片,但极易产生内部碎片(如申请 132KB 却必须占用 256KB),因此在分配非 2 的幂次方大小的内存时需格外警惕浪费。

在内核中分配内存必须严格遵守GFP 标志的使用规则:在进程上下文中允许睡眠时使用 GFP_KERNEL,而在持有自旋锁或中断处理等原子上下文中必须使用 GFP_ATOMIC,否则会导致系统死锁或崩溃。

开发者应根据数据结构的大小选择合适的 API:大块内存(通常指多页)优先使用 get_free_pages 等页面分配器接口;小对象分配(小于一页)则应使用 kmallockzalloc(推荐后者以自动清零),利用 Slab 缓存提高效率。

Slab 分配器虽然灵活,但其最大单次分配上限受限于底层伙伴系统的 MAX_ORDER(通常为 4 MB)。对于驱动开发,应善用 devm_kzalloc 等托管 API 以实现自动释放,并优先使用 kmalloc_array 防止数组分配时的整数溢出风险。