☰
Linux内核内存分配全解:伙伴系统、slab、kmalloc与vmalloc实践
2026/10/8 11:42:49 网站建设 项目流程

做内核开发或者嵌入式移植,内存分配迟早是绕不开的一道坎。我见过不少人把用户态malloc那套经验原封不动搬进内核,结果要么在中断上下文里调用一个允许睡眠的分配接口,要么分配失败不看返回值直接解引用,系统当场oops。今天就把Linux内核内存分配的底子掰开揉碎讲一讲:伙伴系统怎么管物理页、slab分配器解决了什么问题、kmalloc和vmalloc在什么场景选哪个、GFP标志位到底该怎么填,以及实际写内核模块时最容易踩的几个坑。不管你是刚碰内核的新手、正在准备面试,还是在排查线上内存问题,这份内容都能当随手查的参考。

1. 内存分配的整体框架与设计思路

1.1 内核为什么要自己管理内存

用户态的malloc是glibc或者运行时库提供的,底层会调用brk或者mmap。但拿到的那段地址并不等于真实物理内存,真正的物理页通常在进程第一次访问该地址时,由缺页异常处理分配并建立映射。也就是说,用户态进程的内存管理大部分被内核包办了,应用层只要关心“我要多少字节”就够了。

内核自己就没这个优待了。内核要负责整个系统的物理内存调度、映射和回收:所有进程的页表、内核模块的代码段、驱动里的数据结构、socket缓冲区、page cache,全都要从内核手里拿内存。内核运行的时候没有第二个“内核”替它兜底,所以它必须自己维护一套完整的内存分配体系。第一次从应用层转向看内核源码的人,通常很难转过弯来:我们写的不是普通业务代码,而是“操作系统自身资源的管理器”,思考方式得换成系统视角。

再具体看几个高频场景:进程创建时要分配task_struct、内核栈、mm_struct,网络收发时会分配sk_buff,文件系统会缓存inode和dentry。这些对象尺寸小、数量大、生命周期还各不相同,指望一套统一的分配器全吃下来,性能和碎片都会很惨。于是内核采用了分层策略,底层用伙伴系统统一管理物理页,上层用slab/slub给小对象加速,另外再用vmalloc解决大块虚拟连续内存的需求。这三者配合,才基本覆盖了内核所有的内存分配路径。

1.2 内核地址空间是怎么划分的

以x86-64的4级页表为例,内核地址空间整体位于高地址区域,和用户态虚拟地址之间有一条明显的分界线。从宏观上看,内核地址空间大致分成了几块:

  • 直接映射区:也叫线性映射区,基地址是PAGE_OFFSET。物理内存会被线性映射到这个区域,虚拟地址和物理地址之间基本只差一个固定偏移。内核想访问某块物理页时,直接把物理地址加上偏移就能访问,速度最快。
  • vmalloc区:这是给vmalloc专用的区域。分配时物理页不需要连续,但虚拟地址连续,内核会单独建立页表映射。好处是能分配很大的虚拟块,坏处是访问要经过页表转换,局部性和性能都会打折。
  • 模块区:加载内核模块、安装BPF程序等场景使用的地址区域。
  • 固定映射区:为特殊硬件和内核自身保留的编译期固定映射,比如某些寄存器地址的映射。

这个划分并不是拍脑袋定的。直接映射区让内核绝大部分代码访问内存时免去建页表的麻烦;vmalloc区牺牲一点性能换取更大的地址空间灵活性。当你看到“vmalloc area exhausted”之类的报错时,基本就能锁定是这个区域被耗尽了,第4节再深入聊。

1.3 分配器分层:伙伴、slab、vmalloc各司其职

整体结构可以这样理解:物理页帧的分配由伙伴系统统管,这是最底层;slab/slub分配器从伙伴系统批量拿页,再把页切分成小对象,给频繁分配释放的内核对象做缓存;vmalloc是一个独立体系,它自己不跟伙伴系统直接打交道,而是通过页表把零散的物理页拼成连续的虚拟内存。

这套分层的好处是职责清晰,各解决各的问题。伙伴系统提供简单的物理页分配和合并机制,slab解决“小而频繁”的性能与碎片问题,vmalloc实现“大而灵活”的虚拟内存布局。写驱动时,开发者真正碰得最多的其实是slab层的kmalloc,所以下一节我会把三个分配器的内部逻辑和选择依据拆开放开讲。

2. 分配器核心原理与API怎么选

2.1 伙伴系统:物理页的“总账房”

伙伴系统的核心数据结构是每个内存zone里的free_area数组。数组的下标对应order,order从0到MAX_ORDER-1。Linux 6.x里MAX_ORDER默认是11,也就是order最大到10,单块最多能表示2^10页,按4KB一页算就是4MB的连续物理块。

分配流程读代码会更直观:

  1. 根据请求大小计算需要的最小order。
  2. 从该order对应的链表中摘下一个空闲块,有就直接返回。
  3. 如果没有,向更高order的链表找;找到后把块对半分裂,一半返回给请求者,另一半挂到低一级order的链表上。如果分裂出来的块仍然比需求大,就继续分裂。

释放流程刚好相反:

  1. 归还一个页块时,先检查它的“伙伴”是否空闲。
  2. 如果伙伴空闲,就合并成高一阶的块,再继续向上检查伙伴,直到不能合并为止。
  3. 把最终的空闲块挂到对应order的链表。

这里“伙伴”的判定很有讲究:两个大小相同、物理地址相邻、并且同属一个父块的两半才是伙伴。正是靠着这个奇偶变换的判定规则,合并过程不需要全局扫描,效率才足够高。伙伴系统设计精妙,但也有天然弱点:频繁分配和释放会把连续的大块不断拆碎,形成碎片。内核后来引入迁移类型和内存规整机制,就是专门用来缓解这个问题的。

2.2 slab/slub:给小对象开VIP通道

如果创建task_struct要每次从伙伴系统拿一整页、用完再还回去,代价根本扛不住。slab分配器就在伙伴系统之上加了一层对象缓存,每个cache专门管理一种对象类型。内核启动时会创建一大堆内建cache,比如task_struct、inode、dentry,以及按尺寸统一管理的kmalloc-*系列通用缓存。

slab的分配过程大致是这样:

  1. 从该cache的per-CPU freelist里取一个空闲对象,这一步极快。
  2. 如果freelist空了,就向伙伴系统申请一批页,切成对象后放入缓存。
  3. 释放对象时放回freelist,但内存页一般不会立刻还给伙伴系统,而是留着继续复用,避免反复拆页合页的性能损耗。

现在内核默认的slub实现,相比老式slab,对象布局更紧凑,对多核并发和NUMA的友好度也更高。你可以直接看/sys/kernel/slab/目录里的信息,或者用slabtop命令查看每个cache的活跃对象数和内存占用。这也是排内存泄漏最先要打开的文件。

2.3 kmalloc、vmalloc和alloc_pages该怎么选

日常写驱动,最常用的分配接口基本就是kmalloc和vmalloc。很多新手在这里犯迷糊,我直接给一份实用对照表:

维度kmalloc / kzallocvmallocalloc_pages
物理地址连续不一定连续连续
虚拟地址连续连续取决于后续映射
典型分配大小几十字节到几十KB大块,MB级甚至更大按页划分
分配速度快慢,需建立页表快
底层实现slab/slub专用vmalloc区伙伴系统
典型用途驱动缓冲区、结构体大块软件缓冲区DMA、页表和底层页管理

选型的基本经验就一句话:默认用kmalloc;需要大块且允许物理不连续时,才用vmalloc;如果对物理连续性有硬性要求,比如DMA缓冲区、页表,就要用alloc_pages或者kmalloc。这里特别提醒,vmalloc虽然能分大块,但访问性能远不如kmalloc,拿它做热路径缓冲区,吞吐损失可能非常明显。反过来,kmalloc长时间拿不到满足order的连续内存也会失败,这时候与其硬扛,不如想想是不是该换成vmalloc。

2.4 GFP标志位:决定你能不能睡觉

GFP_KERNEL和GFP_ATOMIC是内核里最常看到的一对标志,也是新手踩坑最多的地方。GFP_KERNEL允许分配流程在内存不足时睡眠,等内存回收到满意水位再返回;这要求调用者必须处于进程上下文,并且不能持有自旋锁之类不允许睡眠的资源。GFP_ATOMIC则禁止睡眠,只能基于当前可用的内存状态立刻尝试分配,失败就返回NULL。中断上下文、软中断、持有自旋锁、RCU读侧临界区,都是不能睡眠的上下文,这些地方必须用GFP_ATOMIC。

除了这两个,还有几个经常搭配出现的扩展标志:

  • __GFP_ZERO:要求分配后清零,kzalloc就是kmalloc加这个标志的组合。
  • __GFP_HIGH:允许唤醒紧急回收流程,让分配尽量成功,但代价是可能影响整个系统的稳定性。
  • __GFP_NOFAIL:表示必须分配成功,内核会一直重试,非常危险。除非你是内核核心路径且经过充分评估,否则不要碰。

判断上下文的懒人办法:代码里如果持有了spinlock,或者处于中断/软中断回调里,就用GFP_ATOMIC;只是普通进程上下文、没持有锁,GFP_KERNEL就是最常见的选择。拿不准的时候,宁可先用GFP_ATOMIC保证不崩溃,再考虑性能优化,也比在中断里睡一觉把系统拖崩强得多。

3. 实操:写一个验证内存分配的内核模块

3.1 搭建编译环境

学习内核内存分配,光看理论不够,我建议直接在虚拟机或者自己的Linux开发机上写一个最小内核模块,把kmalloc、vmalloc、alloc_pages都实际跑一遍。模块编译依赖和当前内核版本匹配的头文件。各发行版安装方式不太一样:

  • Debian/Ubuntu:sudo apt install linux-headers-$(uname -r) build-essential
  • RHEL/CentOS:sudo yum install kernel-devel-$(uname -r) gcc
  • 如果自己编译过内核,那直接用源码目录下的编译环境也行

安装完成后,确认/lib/modules/$(uname -r)/build目录存在,就可以开始写代码了。有一点务必注意:模块ko文件的vermagic必须与当前内核版本严格一致,否则insmod时会出现“Invalid module format”的报错。

3.2 一个包含三种分配方式的示例模块

下面这个模块演示了三种分配方式:kzalloc分配128字节结构体缓冲区、vmalloc分配1MB大块内存、alloc_pages分配两个物理页。代码里故意把错误处理路径写成标准的goto cleanup形式,这是在真实驱动里必须养成的习惯。

// mem_alloc_demo.c #include <linux/module.h> #include <linux/kernel.h> #include <linux/slab.h> #include <linux/vmalloc.h> #include <linux/init.h> static char *kmem_buf; static char *vmem_buf; static struct page *page_buf; static int __init mem_alloc_demo_init(void) { // kmalloc分配128字节并清零,GFP_KERNEL适合进程上下文 kmem_buf = kzalloc(128, GFP_KERNEL); if (!kmem_buf) { pr_err("kmalloc failed\n"); return -ENOMEM; } snprintf(kmem_buf, 128, "hello from kmalloc\n"); pr_info("kmem_buf: %s", kmem_buf); // vmalloc分配1MB大块内存,物理页不需要连续 vmem_buf = vmalloc(1 * 1024 * 1024); if (!vmem_buf) { pr_err("vmalloc failed\n"); goto free_kmem; } pr_info("vmem_buf allocated, size: 1MB\n"); // alloc_pages分配order=1,也就是2个连续物理页(8KB) page_buf = alloc_pages(GFP_KERNEL, 1); if (!page_buf) { pr_err("alloc_pages failed\n"); goto free_vmem; } pr_info("alloc_pages got order=1, pages: %d\n", 1 << 1); return 0; free_vmem: vfree(vmem_buf); free_kmem: kfree(kmem_buf); return -ENOMEM; } static void __exit mem_alloc_demo_exit(void) { // 释放时严格匹配:alloc_pages订单为多少,释放就用多少 __free_pages(page_buf, 1); vfree(vmem_buf); kfree(kmem_buf); pr_info("mem_alloc_demo exited\n"); } module_init(mem_alloc_demo_init); module_exit(mem_alloc_demo_exit); MODULE_LICENSE("GPL");

这段代码的核心要点是释放必须与分配严格配对。kfree对应kmalloc,vfree对应vmalloc,__free_pages的第一个参数和order要与alloc_pages时完全一致。我在维护一个驱动时见过有人分配order=1,释放时误填为order=0,结果伙伴系统的链表信息被破坏,系统过了一段时间才开始频繁报内存分配失败,排查难度极高。

3.3 编译、加载和验证流程

编写对应的Makefile:

obj-m += mem_alloc_demo.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

这里一定要记得Makefile里all和clean下面的命令必须用Tab缩进,用空格的话make会直接报“missing separator”错误。这在初学内核模块时几乎是必踩的坑。

编译和执行:

make sudo insmod mem_alloc_demo.ko dmesg | tail -20 sudo rmmod mem_alloc_demo dmesg | tail -20

正常情况下,dmesg里会依次出现kmem_buf的日志、vmalloc分配成功的日志、alloc_pages分配成功的日志;卸载后能看到mem_alloc_demo exited的输出。如果在某个分配点报错,说明对应分配失败,日志里会输出对应的pr_err内容,也能帮助定位是哪条路径没走通。

测试时最出效果的操作是改变分配大小和order值,反复加载卸载几十次,观察dmesg里有没有异常。比如把vmalloc改成分配512MB,大概率能观察到分配时间明显变长,同时加载期间模块操作会卡顿一会儿,这就是vmalloc需要建立页表的开销。

3.4 分配失败处理和回滚:新手必养成

内核模块里最忌讳“分配完不看返回值”。在用户态malloc失败还能捕获处理,在内核里一旦忽略NULL返回,下一个解引用操作就可能制造一个oops。而且内核里分配失败的频率远比你想象的高:内存碎片、超过zone水位线、vmalloc区耗尽,都会让接口返回NULL。

正确的姿势是写完分配之后马上判断返回值,失败则跳转到统一的错误处理标签,把之前已经分配好的资源按后进先出的顺序释放干净。上面的示例代码里,如果vmalloc失败,程序会跳转到free_kmem,释放kzalloc的内存;如果alloc_pages失败,就会走到free_vmem,先释放vmalloc再释放kmalloc。这种“一个入口、一个出口、逐层回滚”的结构,在真实驱动里非常实用,能最大限度避免错误路径上的二次泄漏。

4. 常见问题与排查技巧

4.1 kmalloc失败但系统内存充足:碎片和水线的锅

我碰到最多的场景是:业务方报“kmalloc分配XXX字节失败”,一看free命令,内存还剩好几个GB。这种状况通常有两个原因,一是内存碎片,二是zone水位线限制。

内存碎片可以用/proc/buddyinfo直接看出来。这个文件的每一行代表一个zone,列从order 0到order 10,数字代表该大小的空闲块数量。比如某一行的order 5以上都是0,说明已经没有32页以上的连续物理块了。这种状态下,你让kmalloc去申请一个64KB的连续内存,很可能就满足不了。

另一个原因是zone的watermark限制了分配。内核为每个zone维护了min、low、high三档水位线。当zone的空闲内存低于min时,即使系统全局还有不少剩余内存,分配也会失败,这样做的目的是把最后一点内存预留给紧急回收。想看当前zone水位线就查/proc/zoneinfo。如果确认是水线问题,可以考虑调节内核参数降低min,但这属于“拆东墙补西墙”,并不推荐长期使用,最好还是从业务层面优化分配策略。

缓解碎片最有效的手段是预分配。需要大块连续内存的驱动,在系统刚启动、碎片还不严重的时候就先拿到buffer,后续不再反复申请释放。另一个常用做法是借助内存规整机制,让内核在做内存分配时尽量把可迁移页挪走,腾出连续块。不过这需要确认你的页属于可迁移类型,有些驱动的页固定不可移动,也就没法参与规整。

4.2 vmalloc地址空间耗尽

和物理内存不足不同,vmalloc地址空间耗尽指的是内核那一段虚拟地址空间被用光了,典型报错是“vmalloc: allocation failure: exhausted virtual address space”。这类问题在嵌入式设备上尤其常见,因为默认的vmalloc区域大小往往只有几百MB。

排查第一步先看现状:

cat /proc/vmallocinfo | awk '{sum+=$2} END {print sum/1024/1024 " MB"}'

统计出来的总量如果已经逼近上限,那就要去/proc/vmallocinfo里看具体分配者是谁,重点找那些name字段相同、大小很大且持续不释放的条目。这类问题多数是驱动里的vmalloc没有对应vfree,或者分配后长期持有不释放。少量泄漏觉得无所谓,时间一长虚拟地址空间就会慢慢被吃干。

早期内核还允许通过启动参数vmalloc=来调整区域大小,但这个办法只是拖延问题。根治的方向永远是查清谁在持有大块vmalloc内存、是否有必要的生命周期如此长。

4.3 内存泄漏排查:从slabinfo到kmemleak

内核模块的内存泄漏不像用户态那样可以用Valgrind,但在没有额外工具的情况下,仍然有几套可行性很好的排查手法。

第一招看/proc/slabinfo。如果某个cache的活跃对象数持续增长,而业务负载并没有对应增长,那基本可以断定有对象没释放。比如文件系统dentries数量暴涨,往往是某些目录没有被正确释放;自定义驱动模块如果创建了自己的kmem_cache,活跃对象数不回落,就说明某个结构体实例漏掉了。

第二招用slabtop看整体趋势。这个命令的界面类似top,能按内存占用量排序显示当前所有slab cache,适合快速定位哪个cache最“肥”。

第三招上kmemleak。这是内核自带的内存泄漏扫描器,专门检测“已经不再被引用却仍然被标记为已分配”的对象。启用时需要在编译内核时打开CONFIG_DEBUG_KMEMLEAK,然后通过/sys/kernel/debug/kmemleak触发扫描。kmemleak漏报误报都有,但它能直接告诉你泄漏对象的调用栈,这个信息价值极高。

我自己在评审内核代码时的习惯是:每看到一个分配点,就在同函数的每个退出路径上找对应的释放点,尤其是错误处理路径。很多泄漏都不是主流程漏的,而是某个失败分支里少写了一行释放代码。内存分配的成对性检查,比事后用工具扫更前置、也更有效。

4.4 OOM和kswapd到底是怎么回事

当系统内存持续走低,内核会先唤醒kswapd内核线程做异步回收。回收的优先级是:先回收只读页缓存,再回收可回收的匿名页,最后才考虑压缩内存。如果回收速度跟不上分配速度,系统就会触发OOM killer,挑一个进程杀掉来释放内存。

这里有个常见误解:内核模块分配失败并不会触发OOM killer。OOM killer杀的是用户态进程,内核模块拿到的是NULL返回值,不会自动“被杀”。所以内核模块分配失败后如果不管三七二十一继续往下解引用,崩掉的只会是模块自身,甚至把整个系统拖进不可恢复的oops。这也是为什么我在上文反复强调检查返回值,它不只是一个编码风格问题,而是内核模块稳定性的生命线。

在排查线上问题时,如果系统日志里频繁出现“page allocation failure: order:N, mode:0xXXXX”,不妨把mode里置位的标志挨个翻译一遍,确认调用的上下文是否合理。很多时候你会发现,某段代码在一个明明不能睡眠的位置传了GFP_KERNEL,水线告急时这个分配让整个系统的调度和回收被拖得很惨。

4.5 面试高频题速查

内核内存分配这块也是面试官很爱考的内容,我把常见问题整理成了一张速查表,答顺这些基本不会出大问题:

问题参考答案
伙伴系统按什么规则管理空闲页块按2的幂次划分,order n 代表 2^n 个连续物理页,分配时向上借块并分裂,释放时向下合并伙伴块
kmalloc和vmalloc最核心的区别是什么kmalloc物理连续、虚拟连续,走slab/slub,适合中小块快速分配;vmalloc虚拟连续但物理不连续,适合大块分配但开销更大
GFP_KERNEL和GFP_ATOMIC区别前者允许睡眠,必须用在进程上下文且不持有自旋锁;后者不可睡眠,适合中断、软中断、持锁上下文
slub缓存里的对象在物理上连续吗单个slab内部的对象来自同一批页,物理连续;不同slab之间可以分散
内核栈为什么通常只有8KB或16KB内核栈位于固定映射区且分配成本高,规模有限,局部变量不能开太大,否则会栈溢出
DMA缓冲区为什么不能用vmallocDMA需要物理连续地址,vmalloc只保证虚拟连续
alloc_pages返回的struct page怎么转成虚拟地址可以用page_address()获取直接映射区地址,但要注意HIGHMEM等特殊情况

5. 最后分享几条实战体会

我把内核内存分配的文档看了很多遍之后,真正起作用的还是那几个最土的习惯:分配前先确认自己在什么上下文,分配后立刻检查返回值,错误路径上把所有资源按后进先出逐个释放,释放时严格匹配分配方式。前两条挡住大多数崩溃,后两条挡住大多数泄漏。

个人印象最深的一次排障,是某个驱动在定时器的回调里用了GFP_KERNEL分配内存。定时器回调属于软中断上下文,不能睡眠,结果日志里不停刷“BUG: sleeping function called from invalid context”。那个报错信息很长,最开始大家都没注意,后来才发现问题根本不是内存不够,而是选错了GFP标志。从那之后,我每次写驱动都会先想想这段代码能不能睡得着觉,再决定用哪个标志位。

如果你打算继续深入内核内存管理源码,建议按mm/page_alloc.c、mm/slub.c、mm/vmalloc.c的顺序去读,配合Documentation/core-api/memory-allocation.rst文档一起看,比零散地搜文章要好得多。碰到面试题里那些概念的时候,别只背结论,试着用本章的实操模块去验证一下,记忆会深得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询