第 9 章 内核内存分配进阶:选择、回收与生存
上一章我们聊了内核内存分配的底层逻辑,也就是那个永远转动的引擎——页面分配器(Buddy System),以及建立在它之上的 Slab 分配器。如果你以为这就是全部,那你可能低估了这个系统的复杂性。
这一章,我们要把镜头拉远一点,看看作为一个模块作者或驱动开发者,你真正会面临的“选择困难症”。手里有这么多把锤子——kmalloc、vmalloc、自定义 Slab、甚至 kvmalloc——到底该用哪一把?
如果不选对,轻则性能烂得像蜗牛,重则直接触发那个令人闻风丧胆的家伙——OOM Killer。
本章有一条明确的主线:当内存不够用时,内核到底会做什么? 我们会从如何创建自己的专用缓存开始,一路讲到 vmalloc 的虚拟连续性,最后直面那个让所有后端工程师手心出汗的场景:系统内存耗尽。
准备好了吗?这章的干货有点多,而且有些地方如果你亲自试一下,可能会把系统搞挂——建议在虚拟机里折腾。
9.1 当标准库不够用:创建自定义 Slab 缓存
上一章我们花了很大篇幅讲 Slab 分配器的好处:速度快、缓存对象、减少碎片。但这些讨论大多是基于内核已有的通用缓存(比如 kmalloc-192)。
试想一下这样的场景:你正在写一个驱动,代码里有一个特定的结构体(struct my_device_context),它被极其频繁地分配和释放。如果你一直用 kmalloc() 和 kfree(),虽然能跑,但并不是最优解。
这里有一个反直觉的事实:通用的往往是低效的。
内核早就预料到了这一点,它允许你——作为模块作者——创建属于自己的“私有金库”,也就是自定义 Slab 缓存。
9.1.1 亲手搭建一个专用缓存
我们要做的事情其实很直观,就像开一家只有你自己的工厂一样,分三步走:
- 建厂(创建):告诉内核你需要什么规格的“产品”(对象大小),给这个工厂起个名。
- 生产与使用:从工厂里拿一个产品出来用,用完放回去。
- 关停(销毁):不用了,把工厂拆了,地皮还给内核。
这一步一步来,别急。
第一步:建厂——kmem_cache_create()
这是所有事情的起点。你可以把它想象成向内核内存管理局提交的一份“建厂申请”。
#include <linux/slab.h>
struct kmem_cache *kmem_cache_create(const char *name,
unsigned int size,
unsigned int align,
slab_flags_t flags,
void (*ctor)(void *));
我们得仔细看看这几个参数,因为每一个坑都在细节里。
name:给缓存起个名字。这不仅仅是给人看的,/proc文件系统、slabtop这些工具都会显示这个名字。起个好记的名字,比如 "my_dev_ctx",方便以后调试。size:这是最关键的一个——每个对象的大小。单位是字节。- ⚠️ 踩坑预警:这里你填的是“理论大小”,但内核实际给你的可能会更大。
- 为什么?就像我们上一章提到的,内核为了对齐、为了元数据,或者仅仅是因为没有刚好大小的坑位,会给你分配一个“差不多大”的容器。比如你要 328 字节,内核可能给你一个 448 字节的槽位(别惊讶,这很常见)。
align:对齐要求。如果你不在乎,填 0。但在某些架构(特别是 ARM)上,或者你要做 DMA,对齐至关重要。通常填sizeof(long)是个安全的选择,保证按字长对齐。flags:标志位。这里有几个非常实用的“调试开关”:SLAB_POISON:投毒模式。内核会在这个内存区域填上0xa5a5a5a5这种魔数。如果你看到野指针指向了这个值,那就说明你用了未初始化的内存。调试神器。SLAB_RED_ZONE:红区。在你的对象前后插入“守卫页”,专门用来捕捉溢出错误。如果你写越界了,碰到红区内核立马报警。SLAB_HWCACHE_ALIGN:硬件缓存行对齐。为了性能,通常建议开启。这就是为什么普通的kmalloc出来的内存总是对齐到 Cache Line 的原因。
ctor:构造函数指针。这是个很有意思的设计。内核虽然是用 C 写的,但这里透着一股面向对象的味道。- 每当内核从这个缓存里给你切出一个新对象时,这个函数会被自动调用,用来做初始化。
- ⚠️ 注意:构造函数是在分配时调用的,不是在你用
kmem_cache_alloc的时候——这意味着内核可能会预分配一些对象,构造函数就预跑了。
如果创建成功,你会得到一个 struct kmem_cache * 指针。千万别弄丢它,这是你以后取货的唯一凭证。通常我们会把它存成一个全局变量。
第二步:生产与使用——分配与释放
工厂建好了,现在开始干活。
分配:kmem_cache_alloc()
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfpflags);
s:就是你刚才存起来的那个缓存指针。gfpflags:老规矩,GFP_KERNEL(可以睡)还是GFP_ATOMIC(不能睡)。
这就像去食堂打饭,你递过去饭盘(缓存指针),食堂阿姨给你打一勺菜(对象内存)。
释放:kmem_cache_free()
void kmem_cache_free(struct kmem_cache *s, void *x);
s:还是那个缓存指针。x:你要还回去的那块内存。
这里有个必须遵守的铁律:必须还要把指针还给原来的缓存。你不能把从 A 工厂拿出来的货,退给 B 工厂。那会发生什么?通常是内核 Panic。
第三步:关停——kmem_cache_destroy()
当你卸载模块,或者不再需要这个结构体时,必须清理现场。
void kmem_cache_destroy(struct kmem_cache *s);
只有当你的缓存里所有借出去的对象都还回来了,这个销毁操作才会成功。如果还有“漏网之鱼”,内核会拒绝销毁并在日志里抱怨。
9.1.2 上手写代码:自定义 Slab 演示
光说不练假把式。我们写一个模块,把上面这套流程跑一遍。
假设我们有一个频繁使用的结构体 myctx:
// ch9/slab_custom/slab_custom.c
#define OURCACHENAME "our_ctx"
/* 我们的演示结构体。
* 假设这个东西分配释放非常频繁,所以我们决定给它建个专用缓存。
* Size: 328 bytes。
*/
struct myctx {
u32 iarr[10]; // 40 bytes; total=40
u64 uarr[10]; // 80 bytes; total=120
s8 uname[128], passwd[16], config[64]; // 208 bytes; total=328
};
static struct kmem_cache *gctx_cachep;
创建缓存的代码:
static int create_our_cache(void)
{
// ...
gctx_cachep = kmem_cache_create(OURCACHENAME,
sizeof(struct myctx),
sizeof(long), // 对齐
SLAB_POISON | SLAB_RED_ZONE | SLAB_HWCACHE_ALIGN,
our_ctor); // 构造函数,可以是 NULL
if (!gctx_cachep)
return -ENOMEM;
return 0;
}
关于构造函数的小插曲:
static void our_ctor(void *new)
{
struct myctx *ctx = new;
/* 这里很像 C++ 的构造函数 */
memset(ctx, 0, sizeof(struct myctx));
/* 为了演示,我们在这里填一些当前进程的信息 */
snprintf_lkp(ctx->config, sizeof(ctx->config), "%d.%d,%ld.%ld",
current->tgid, current->pid, current->nvcsw, current->nivcsw);
}
这玩意儿有个反直觉的地方:你只调用了 一次 kmem_cache_alloc(),但在日志里你可能会看到构造函数被调用了 18 次。
别慌。这正如我们之前说的,内核为了效率,会预先填充这个缓存(Batching)。它一次性造了 18 个对象放在那等着你去拿。所以构造函数跑了 18 次。
分配与使用:
struct myctx *obj;
obj = kmem_cache_alloc(gctx_cachep, GFP_KERNEL);
if (!obj) return -ENOMEM;
/* 打印一下看看实际大小 */
pr_info("Our cache object size is %u bytes; ksize=%lu\n",
kmem_cache_size(gctx_cachep), ksize(obj));
print_hex_dump_bytes("obj: ", DUMP_PREFIX_OFFSET, obj, sizeof(struct myctx));
/* 用完了记得还 */
kmem_cache_free(gctx_cachep, obj);
⚠️ 这里有一个真实的“坑”:
注意看日志里的 kmem_cache_size 和 vmstat 的输出。你定义的结构体是 328 字节,但实际上内核分配的大小很可能是 448 字节。这种“内碎片”在使用 Slab 时是不可避免的代价。如果你在嵌入式系统上对内存抠门到了字节级,这一点必须算计进去。
9.2 虚拟的连续:vmalloc 及其伙伴
当我们需要一大块内存,大到 Slab 分配器(或者说是背后的 Buddy System)给不出来的时候(比如超过 4MB),我们就得换一种思路了。
这就引出了 vmalloc。
9.2.1 vmalloc() 是什么?
你可以把 kmalloc 想象成买地皮,这块地必须是物理上连成一片的(物理连续),适合搞建筑。而 vmalloc 就像是搞虚拟办公,它给你分配了一串连续的门牌号(虚拟地址连续),但背后的办公室(物理内存)可能分散在城市各处。
void *vmalloc(unsigned long size);
- 虚拟连续:返回的指针是连续的。
- 物理离散:底层的物理页面可能到处都是。
- 开销:因为要在页表里建立一堆映射,分配和访问的开销都比
kmalloc大。
什么时候用它?
- 需要巨大的缓冲区(几 MB 甚至上百 MB)。
- 只需要软件访问,不需要和硬件做 DMA(硬件只认物理地址,通常不认 vmalloc 出来的地址,除非有 IOMMU)。
- 不在中断上下文里(因为它可能会睡眠)。
⚠️ 再次提醒:千万别在持有自旋锁的时候调用 vmalloc,因为它会睡,睡了就会死锁。
9.2.2 vmalloc 的小弟们
内核提供了一系列变体,记住它们能让你的代码更健壮:
vzalloc(size):作用同vmalloc,但会把内存清零。z代表 Zero。如果你想避免泄露未初始化的内核内存,用这个。kvmalloc(size, flags):这是一个“偷懒”但聪明的 API。- 它的逻辑是:先试着用
kmalloc搞定(因为快且物理连续)。 - 如果
kmalloc失败了(太大),它自动回退到vmalloc。 - 对于你这种不想纠结到底该用哪个的程序员,这是福音。
- 释放时用
kvfree()。
- 它的逻辑是:先试着用
9.2.3 代码演示:看看 vmalloc 的真面目
写个模块试试看:
// ch9/vmalloc_demo/vmalloc_demo.c
static int vmalloc_try(void)
{
void *vptr_rndm, *vptr_init;
/* 1. 普通 vmalloc */
vptr_rndm = vmalloc(10000);
// 注意:实际上分配的是 2 个页面(8192 字节),因为页面对齐
if (!vptr_rndm) return -ENOMEM;
print_hex_dump_bytes(" content: ", DUMP_PREFIX_NONE, vptr_rndm, 16);
// 输出可能是乱码,因为 vmalloc 不清零
/* 2. vzalloc:清零版 */
vptr_init = vzalloc(10000);
if (!vptr_init) {
vfree(vptr_rndm);
return -ENOMEM;
}
print_hex_dump_bytes(" content: ", DUMP_PREFIX_NONE, vptr_init, 16);
// 输出全是 00
vfree(vptr_rndm);
vfree(vptr_init);
return 0;
}
9.3 到底该用谁?——决策时刻
现在你脑子里至少有四种分配器了:kmalloc, vmalloc, kmem_cache, __get_free_pages。写代码时是不是卡住了?
别怕,这有个简单的判断逻辑(决策树),你可以把它贴在显示器旁边:
决策逻辑:从上往下问
-
你是要给谁用?
- 如果是给 DMA 硬件用?
- 别用这些,去用 DMA 专用 API(
dma_alloc_coherent)。
- 别用这些,去用 DMA 专用 API(
- 如果是给普通内核代码或驱动软件逻辑用? -> 继续。
- 如果是给 DMA 硬件用?
-
大小是关键
- 很小(小于一页,几 KB)?
- 首选
kmalloc()/kzalloc()。这是最快、最省事的。性能最优先。
- 首选
- 中等(小于几 MB,比如 1MB - 4MB)?
- 如果你非常确定需要物理连续:只能硬着头皮用
kmalloc(但要小心失败),或者用低级页面分配器__get_free_pages()。 - 如果你不在乎物理是否连续:用
kvmalloc()。它会智能选。
- 如果你非常确定需要物理连续:只能硬着头皮用
- 巨大(超过 4MB)?
- 基本只能用
vmalloc()了。
- 基本只能用
- 很小(小于一页,几 KB)?
-
是否频繁分配/释放同一个对象?
- 是 -> 考虑创建 自定义 Slab 缓存(
kmem_cache_create)。这能极大提升性能并减少碎片。
- 是 -> 考虑创建 自定义 Slab 缓存(
9.3.1 性能陷阱:别乱用 vmalloc
这里有一个常见的误区:
“反正 vmalloc 能分配大内存,那我把小内存分配也全换成 vmalloc 算了?”
千万别这么做。
kmalloc是从内存池直接拿,很快。vmalloc需要修改页表,还要处理 TLB 失效,慢得多。- 而且
vmalloc出来的内存在x86上不能直接做 DMA 映射,还得去kmap一下,更是慢上加重。
一句话总结:默认首选 kmalloc。只有在它真的满足不了你的需求(太大)时,再退一步求助于 vmalloc 或 kvmalloc。
9.4 内存不够了怎么办?——回收与 OOM
现在,假设你的驱动运行得很完美,内存分配也没问题。但是,系统跑久了,内存越来越少……这时候内核就开始搞“内务”了。这叫 Memory Reclamation(内存回收)。
9.4.1 水位线与 kswapd
内核把每个内存区域(Zone)的空闲内存量定义成三个水位:
min:最低警戒线。low:有点紧了。high:舒服,很充裕。
有一个叫 kswapd 的内核线程,像个勤快的清洁工,时刻盯着这些水位。
- 当水位低于
high:它开始打扫,主要是把不用的页面缓存和 Slab 对象扔掉。 - 如果打扫到
low还不够:它开始更激进地回写内存。 - 如果跌破
min:内核这就慌了,可能会直接阻塞申请内存的进程,直到回收出内存。
9.4.2 最后的防线:OOM Killer
如果 kswapd 拼了命干活,内存还是不够,甚至连 min 水位都守不住了,这时候,内核就会请出那个“冷血杀手”——OOM Killer。
它的逻辑很简单粗暴:为了保住整个系统不崩,必须杀掉一部分进程来释放内存。
它会根据一套打分机制(oom_score),选出那个“最该死”的进程(通常是占用内存最多的),然后发送 SIGKILL 信号。
这对你意味着什么?
如果你正在跑的服务突然被杀了,日志里只有一行 Killed,那就是它干的。
实战:手动触发 OOM 想体验一下被 OOM 的感觉吗?(建议在虚拟机里操作) 可以通过 SysRq 键触发:
echo f > /proc/sysrq-trigger
这时候,系统会立刻评估谁最该死,然后动手。
9.4.3 保护重要进程:oom_score_adj
既然 OOM 杀手这么无情,我们能不能保护关键进程(比如 sshd)?
可以。调整 /proc/<pid>/oom_score_adj。
- 范围:
-1000到1000。 -1000:绝对不杀。1000:优先杀(想自杀的话)。
# 保护 SSH 进程
echo -1000 > /proc/$(pidof sshd)/oom_score_adj
9.5 本章回响
这一章,我们从微观的自定义 Slab 缓存,走到了宏观的 vmalloc,最后直面了系统级的内存危机。
我们发现,内存管理不仅仅是“申请”和“释放”那么简单。它是一场关于权衡的博弈:
- 速度 vs 大小:Slab 快但受限,vmalloc 大但慢。
- 连续 vs 离散:物理连续很贵,虚拟连续很便宜。
- 个体 vs 整体:你的驱动想占着内存不放,但 OOM Killer 为了全系统的生存,随时可能把你踢出局。
还记得本章开头提到的“选择困难症”吗?现在你应该有一个清晰的心智模型了:内核提供了一整套工具,让你在不同的维度上做权衡。没有最好的 API,只有最适合当前场景的那一个。
下一章,我们将离开内存这片“土地”,去探索另一个核心资源——CPU 时间。我们将看看内核是如何像调度员一样,决定谁有资格在 CPU 上运行,谁又必须排队等待。
那是关于时间的故事。
练习题
练习 1:understanding
题目:假设你正在编写一个内核模块,频繁分配和释放一个名为 struct packet_obj 的数据结构(大小为 100 字节)。你决定创建一个名为 packet_cache 的自定义 Slab 缓存。为了能够尽早发现缓冲区溢出错误,并利用硬件缓存行对齐来提高性能,你应该在调用 kmem_cache_create() 时使用哪些标志位?
答案与解析
答案:SLAB_RED_ZONE | SLAB_HWCACHE_ALIGN
解析:考察对 Slab 标志位的理解。根据知识点定义:
SLAB_RED_ZONE:在分配的缓冲区周围插入红区,用于检测缓冲区溢出或下溢错误,符合题目中“尽早发现缓冲区溢出错误”的需求。SLAB_HWCACHE_ALIGN:确保缓存对象对齐到硬件缓存行边界,以提高性能,符合题目中“利用硬件缓存行对齐”的需求。SLAB_POISON虽然也是用于调试,但它主要用于检测未初始化内存引用(用特定模式填充),虽然也可以开启,但题目明确要求的是“检测溢出”和“性能对齐”。
练习 2:application
题目:在内核模块开发中,你需要分配一个巨大的数组(大小约为 32 MB)用于临时数据存储。考虑到物理内存连续性的限制和分配失败的风险,以下哪个 API 是最合适的选择? A. kmalloc() B. kmem_cache_alloc() C. __get_free_pages() D. kvmalloc()
答案与解析
答案:D. kvmalloc()
解析:考察实际场景中的 API 选择。
- A.
kmalloc():基于 Slab 分配器,最大只能分配几 KB(通常最大 8KB 或 4MB,取决于 order 和架构),无法可靠地分配 32MB。 - B.
kmem_cache_alloc():用于分配特定大小的对象,不适合大块内存分配。 - C.
__get_free_pages():直接分配物理连续页面。32MB 需要连续的 8192 个 4KB 页面(order = 13),在碎片化的内存系统中极易失败。 - D.
kvmalloc():这是最合适的。它的设计初衷就是为了处理较大的内存分配:它会尝试使用kmalloc()获得物理连续内存;如果失败(或者请求过大),它会回退到使用vmalloc(),后者只保证虚拟连续,不要求物理连续,从而大大提高大块内存分配成功的可能性。
练习 3:thinking
题目:在设计一个高可靠性系统的内核模块时,你需要决定如何处理内存分配失败和系统内存压力的情况。请对比并分析以下两种机制的应用场景和根本区别:
- 在
kvmalloc()内部实现的 fallback(回退)机制。 - 当内存严重不足时内核触发的 OOM Killer 机制。
答案与解析
答案:解析见详细说明。
解析:考察对内存分配策略与系统级保护机制的深度理解。
1. kvmalloc() 的 Fallback 机制(战术性调整):
- 目的:为了满足单次分配请求的成功。
- 场景:当开发者请求分配较大的内存块(如几十 MB)时,物理连续内存可能不足。
kvmalloc()会智能地从“高性能但要求高”(kmalloc/物理连续)降级到“性能较低但容易成功”(vmalloc/虚拟连续)。这是一种服务调用者的机制,目的是让程序继续运行。
2. OOM Killer 机制(战略性止损):
- 目的:为了拯救整个系统免于崩溃。
- 场景:当系统内存几乎耗尽,且通过页面回收、Slab 收缩等手段都无法释放出足够内存时触发。它是一种极端手段,通过牺牲进程(通常是占用内存最多的)来释放资源,防止系统死机。
根本区别:
- Fallback 是一种优化/自适应行为,旨在透明地处理内存碎片问题,对用户进程是友好的。
- OOM Killer 是一种灾难恢复行为,表明系统已经处于故障边缘,它具有破坏性(杀进程),是系统最后的防线。
在设计高可靠性模块时,应优先利用 kvmalloc() 等机制避免分配失败,同时合理设置 oom_score_adj 保护关键进程,避免被 OOM Killer 误杀。
要点提炼
面对频繁分配释放的特定结构体,通用的 kmalloc 往往存在性能瓶颈和碎片化问题。通过 kmem_cache_create 创建专用 Slab 缓存,可以像建立“私有金库”一样高效管理对象。创建时需注意,内核为了对齐和元数据可能会分配比你指定 size 更大的空间,这是一种“内碎片”代价。利用 SLAB_POISON(投毒模式)和 SLAB_RED_ZONE(红区)等标志位,还能在开发阶段有效捕捉内存越界和未初始化使用等错误。
当需要分配大块内存(超过几 MB)时,基于物理连续内存的 kmalloc 往往会面临失败,此时应选用 vmalloc。vmalloc 的核心逻辑是只保证虚拟地址连续,而底层物理内存可以离散,这通过建立页表映射实现。但它也有显著的副作用:分配和访问开销大、不能直接用于 DMA,且绝不能在持有自旋锁或中断上下文中调用,因为它可能会引起睡眠。
对于大多数拿捏不准的分配场景,或者需要分配“中等大小”内存(介于几 KB 到几 MB 之间)时,kvmalloc 是最佳的“偷懒”方案。它会智能地先尝试使用物理连续且快速的 kmalloc,一旦失败则自动回退到 vmalloc。这种策略避免了开发者手动决策的复杂性,同时兼顾了小内存的性能和大内存的可用性,释放时只需配套使用 kvfree。
内存分配不仅仅是代码逻辑,更直接关系到系统稳定性。当物理内存耗尽时,内核会启动 kswapd 守护线程进行后台回收;若水位线跌破最低警戒线,内核就会触发 OOM Killer(内存溢出杀手)。这是一种为了保全系统而不得不牺牲部分进程的残酷机制,它会根据 oom_score 评分杀掉占用内存最多的进程。在生产环境中,可以通过调整 /proc/<pid>/oom_score_adj 参数(设为 -1000)来保护关键服务(如 sshd)免受误杀。
综合来看,内核内存分配没有万能钥匙,只有基于场景的权衡。默认应首选高性能的 kmalloc,仅在需要物理连续的大块内存时才考虑底层页面分配器;仅在处理巨大缓冲区且不需要硬件交互时才退守 vmalloc;而对于高频使用的特定对象,自定义 Slab 缓存则是性能优化的首选方案。理解这些 API 背后的物理连续性、开销成本以及回收机制,是编写健壮内核模块的关键。