
内核态写代码内存分配是绕不过去的一道坎。做过内核模块或者驱动的开发者几乎每天都会和 kmalloc、kzalloc、vmalloc 这些接口打交道但真的被问到这些接口背后的机制到底是什么、GFP_ATOMIC 和 GFP_KERNEL 用错了会怎样、分配失败时内核凭什么判定杀进程时能讲清楚的人其实不多。用户态开发转过来的人尤其容易在这上面栽跟头用户态里 malloc 失败返回 NULL 顶多自己处理一下内核里一次错误的内存分配轻则触发内核告警和 oom重则让整个系统直接卡死或者崩溃。这篇文章我把 Linux 内核内存分配这条链路从头拆到尾从伙伴系统和 SLUB 缓存到 GFP 标志位和上下文匹配再到驱动开发里的选型套路和故障排查工具全部结合真实场景讲。不管你是刚写第一个内核模块的新手还是已经踩过不少坑想系统理一遍的老手都应该能从里面拿到点能直接用的东西。1. 先把调用链路的地基打清楚1.1 一次 kmalloc 到底经历了什么大多数开发者知道 kmalloc 能分配内存却说不清楚这段内存是从哪来的。实际上一次 kmalloc 调用会经过一条明确的路径调用者传入 size 和 gfp 标志后分配器先判断这个大小能不能在 SLUB 缓存里找到对应的尺寸桶如果能就直接从缓存里拿一个已经建好的对象如果缓存里没有空闲对象SLUB 再向页分配器申请新的 slab 页如果请求的大小超过了 kmalloc 支持的最大缓存尺寸则直接跳过 SLUB进入伙伴系统按阶分配物理连续的页块。这条链路的后端就是伙伴系统buddy system。伙伴系统把物理内存按 2 的幂次分成一个个空闲块列表order 0 是一页通常 4KBorder 1 是两页order 10 在多数 64 位架构上是 4MBMAX_ORDER 决定了最大阶数。分配时系统从对应阶数的链表里取一块如果没有合适大小的块就向上找一个更大的块并不断拆半直到拆出想要的阶数释放时则反过来如果两个物理相邻且阶数相同的块都空闲就立刻合并成更大的块。这种分久必合、合久必分的机制保证了外部碎片在页面级别上能被有效回收也是内核能稳定提供高阶连续内存的根基。不过伙伴系统本身并不负责判断现在内存还够不够分配。每个内存 zone 都维护着一组水位线min、low、high。当空闲页低于 low 时后台的 kswapd 线程会被唤醒开始回收如果 free 页已经跌到 min 以下当前分配进程就不能等了得亲自下场做直接回收direct reclaim。分配失败后还有最后一道防线如果 gfp 里带了 __GFP_HIGH分配器可以动用为紧急情况保留的少量内存如果这都拿不到内存且内核判定没有进程可以被杀掉就会走到 OOM 路径。理解了这条链路再往后看选型和排障都会顺很多。1.2 SLUB 缓存为什么大多数小内存不直接找伙伴系统如果每次 kmalloc 都直接进伙伴系统就会产生两个问题一是频繁的页块拆分和合并会让系统忙于整理碎片性能上不去二是像分配一个 64 字节的结构体这种请求如果直接给一整页浪费太大。SLUB 分配器过去还有 SLAB现在主流内核里基本是 SLUB 一统天下就是为这个而生的它把内存预先切成一个个固定大小的对象按常见尺寸建立了 kmalloc-8、kmalloc-16、kmalloc-32、kmalloc-64、kmalloc-96、kmalloc-128、kmalloc-192、kmalloc-256、kmalloc-512一直到 kmalloc-8k 等一组缓存。你请求 100 字节它就会从 kmalloc-128 缓存里拿一个 128 字节的对象给你用不完的部分就是内部碎片但换来的是分配速度飞快。SLUB 的快体现在两个地方每个 CPU 都有自己的 freelist分配和释放最常见的情况是直接操作本 CPU 的链表全程无锁这在多核系统上意义重大同时对象复用不需要反复和伙伴系统打交道只有整块 slab 空了才会向页分配器补充。正因为有这层缓存驱动代码里高频调用 kmalloc 才不会成为性能瓶颈。如果某类结构体你会大规模反复分配还可以用 kmem_cache_create 建一个专属缓存比如内核里 inode、dentry 都有独立缓存。这类缓存的运行状态在 /proc/slabinfo 和 /sys/kernel/slab 下面都能看到排内存问题时是重要的第一手材料。1.3 vmalloc 的定位虚拟连续背后的真实代价kmalloc 的意思是物理连续、虚拟也连续这对 DMA 场景至关重要但也正因如此当请求变大时它很容易失败——毕竟找到一块 2MB 的物理连续内存在碎片化的系统上并不容易。vmalloc 走的是另一条路它只保证虚拟地址连续物理页可以东一块西一块分配时逐页向伙伴系统申请然后通过修改页表把这段虚拟区间映射起来。这种设计让 vmalloc 能轻松处理几十 MB 甚至更大的缓冲区代价也非常现实每次分配都要建页表条目释放时要 TLB shootdown让所有 CPU 刷新对应的 TLB开销比 kmalloc 高一个量级而且映射后的内存缓存行为差因为在物理上不连续处理器预取和 cache line 局部性都受影响。更要命的是vmalloc 内部可能睡眠所以绝对不能在硬中断、软中断或者持有自旋锁的上下文里调用。实际工程里我的经验是缓冲区超过单页后的几十 KB 到几百 KB同时又不需要物理连续、也不需要进 DMA 时就果断用 vmalloc 或者 kvmalloc一旦涉及 DMA想都不要想老老实实走 DMA API。2. GFP 标志位决定分配行为的方向盘2.1 一张表看懂常见 GFP 组合gfp 标志位是每次内存分配请求的行为说明书它告诉内核三件事我现在能不能睡觉、如果内存不够我可以接受什么代价、分配出来的页有哪些用途限制。很多驱动出问题根本原因不在接口选错而在 gfp 传错。下面是我平时查阅最多的几个组合标志组合能否睡眠回收与 IO 行为典型使用场景GFP_KERNEL可以可触发直接回收、kswapd允许 IO 和文件系统操作进程上下文、持有互斥锁、一般的驱动初始化GFP_ATOMIC否不能睡眠回收只能用紧急保留区硬中断、软中断、自旋锁保护的临界区GFP_NOWAIT否不触发直接回收只允许 kswapd 后台回收不允许阻塞但可以等后台回收的路径GFP_NOFS可以允许回收但不允许回写文件系统页文件系统事务内部避免回收时和事务死锁GFP_NOIO可以允许回收但不发起块设备 IOIO 栈内部路径避免自锁GFP_HIGHUSER_MOVABLE可以倾向于可移动页和高端内存用户态页分配内核驱动里很少直接用选型时一个常见误区是以为GFP_ATOMIC 就是中断里专用的平时别用。其实 GFP_ATOMIC 的核心语义是不允许睡眠只要执行路径里不能睡哪怕你是普通进程上下文持着自旋锁也必须传它。反过来中断里如果你只是想要一块预先分配好的缓冲区那应该提前在初始化阶段用 GFP_KERNEL 把它准备好而不是真的在中断里临时去分配。2.2 上下文匹配最容易被忽视的规则内核开发里的上下文概念比用户态复杂得多。同一个函数可能既会被系统调用在进程上下文调用又会被定时器在软中断上下文调用。匹配规则其实很朴素凡是不能睡眠的地方就用 GFP_ATOMIC凡是可能睡眠且不在特殊栈上的地方就用 GFP_KERNEL凡是涉及文件系统内部、IO 内部嵌套调用就分别退回 GFP_NOFS、GFP_NOIO。下面这些是我实际踩过、也帮别人查过的高发场景持自旋锁spinlock、rwlock、raw_spinlock时分配必须 GFP_ATOMIC。哪怕只是一小段临界区分配触发的回收路径极可能去睡眠。硬中断、软中断、tasklet、timer 回调必须 GFP_ATOMIC。这些上下文根本不允许调度器切换。RCU 读临界区里分配多数情况也不允许睡眠稳妥做法是 GFP_ATOMIC 或者提前分配。文件系统事务内、或者持有某文件系统的锁用 GFP_NOFS。否则回收线程为了腾内存可能去写同一个文件系统形成死锁。块设备层正在处理 IO 的路径用 GFP_NOIO防止回收时再次下发 IO 把自己卡住。工作队列、普通系统调用、probe 函数GFP_KERNEL。这条规则我建议直接刻在脑子里。因为内核的错误检测机制通常会在你踩线时给出一个非常明显的提示BUG: sleeping function called from invalid context但触发时机往往不是第一次调用而是某次内存紧张、真的触发了回收路径之后。线上复现困难压测时偶发崩溃这种 bug 最恶心而根源往往就是某个不起眼的 GFP 标错。2.3 选错 GFP 的现场事故复盘我刚做内核驱动时就吃过一次大亏。某个网络模块在收包路径里做内存统计代码写的是 kmalloc(sizeof(*stat), GFP_KERNEL)这行代码初始阶段跑得好好的因为刚开机内存充足kmalloc 直接就从缓存里拿到了对象根本不会触发回收自然也不会睡眠。等到业务流量上来、内存出现压力某次分配触发了直接回收回收路径尝试睡眠内核立刻抛 BUG: sleeping function called from invalid context整个协议栈卡住最后系统直接宕机。修复就一行把 GFP_KERNEL 改成 GFP_ATOMIC但定位过程折腾了一整天因为本地小流量根本复现不出来。另一个印象深刻的案例是存储方向。某驱动在 IO 完成回调里释放旧缓冲区并新分配一个传入的也是 GFP_KERNEL。IO 完成回调本身运行在中断上下文后果比上例更严重几乎每次压测都触发内核 panic。后来是同事用 trace 抓调用栈发现分配点出现在 scsi 中断上下文里这才把问题定位清楚。这两个案例给我的教训是不要用现在系统内存够不够来判断 gfp 是否正确而要用我这个函数可能被谁调用来判断。3. 实操驱动代码里的内存分配完整套路3.1 选型决策先回答四个问题写驱动时我一般不会直接抄一个分配接口而是先过一遍四个问题要分配多大分配时能不能睡眠这块内存的生命周期跟着谁走它将来会不会给 DMA 用这四个答案基本能把接口定死。大小在几十字节到几 KB 之间的常规结构体用 kzalloc 或 devm_kzalloc大小超过 8KB 的要么用 kvmalloc 让它自动决定先试 kmalloc 再退回 vmalloc要么在明确不需要物理连续时直接用 vmalloc如果是给 DMA 用的无论大小一律走 DMA API比如 dma_alloc_coherent。能不能睡眠决定了 gfp 参数生命周期跟着设备走就优选 devm 系列跟着请求走就在请求结构体里自己维护指针并确保释放路径完整热路径上频繁分配则要考虑内存池 mempool 或者预分配。下面是我一个字符设备驱动里典型的 probe 函数分配写法static int demo_probe(struct platform_device *pdev) { struct demo_dev *dev; int ret; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; platform_set_drvdata(pdev, dev); /* 16KB 的软件缓冲区不涉及 DMA交给 kvmalloc 处理 */ dev-workbuf kvmalloc(16 * 1024, GFP_KERNEL); if (!dev-workbuf) return -ENOMEM; /* 1MB 的一致性 DMA 缓冲区物理地址通过 dma_handle 拿 */ dev-dma_buf dma_alloc_coherent(pdev-dev, SZ_1M, dev-dma_handle, GFP_KERNEL); if (!dev-dma_buf) return -ENOMEM; ret demo_init_irq(pdev); if (ret) return ret; return 0; }注意这里我在每个可能失败的分配点都检查了返回值并且一旦失败直接返回负数错误码。probe 返回错误后bus 框架会负责调用 remove 路径而 devm_kzalloc 分配的部分会被 devres 自动释放但 kvmalloc 和 dma_alloc_coherent 拿到的资源必须自己在 remove 或 error path 里释放否则就是泄漏。3.2 生命周期管理devm_ 系列的正确用法devresmanaged device resource是内核提供的一套资源自动回收机制devm_kzalloc、devm_kmalloc、devm_krealloc 这类接口会把资源的释放注册到设备的 devres 链表上。设备解绑unbind或者 probe 失败时框架自动把所有 devres 资源释放掉。好处非常直接你不用在每个错误分支里手写一堆 goto err_free 来释放之前分配的内存probe 后期某个步骤失败时前面所有 devm 分配的内存都会自动回收。这不是说普通 kmalloc 就没用了。devm 资源是跟 device 生命周期绑死的如果你的内存生命周期跟某个请求、某个连接或者某个临时操作一致硬用 devm 反而会拖到设备解绑才释放造成逻辑上的泄漏。我的习惯是设备级别的稳定资源用 devm_kzalloc请求级别、临时性强的资源用 kmalloc/kfree并且在函数里严格做好配对。还有一点要提醒devm_ 分配本身也是要传 gfp 的probe 里传 GFP_KERNEL 没问题但千万别以为带 devm 前缀就可以在原子上下文里用了。3.3 DMA 与 CMA连续物理内存从哪来给 DMA 控制器用的缓冲区要求的是物理地址连续或者至少能通过 IOMMU 把分散页映射成连续 IO 地址。最省事的接口是 dma_alloc_coherent一次调用同时返回内核虚拟地址和设备可用的 DMA 地址而且保证缓存一致性驱动里不用手动做 dma_map/dma_unmap。缺点是分配较重所以应该在初始化阶段完成不要在数据路径上高频调用。当系统里一致性内存池耗尽或者你需要一块很大的连续内存比如 8MB、16MB 给多媒体或 GPU 用时真正起作用的是 CMAContiguous Memory Allocator。CMA 的思想是平时允许这些页被可移动页占用一旦设备需要连续内存就把可移动页赶走、把物理页整理成连续块。所以 CMA 内存也叫 movable 类型页在 /proc/pagetypeinfo 里能看到它们的分布。配置方式有两种内核启动参数 cma64M或者在设备树里声明 reserved-memory 区域reserved-memory { #address-cells 2; #size-cells 2; display_mem: display90000000 { compatible shared-dma-pool; reusable; size 0x0 0x10000000; linux,cma-default; }; };注意 CMA 分配是可能睡眠的因为整理可移动页涉及回收和迁移所以同样不能在原子上下文里调用。项目里遇到过的情况是多媒体驱动在初始化时申请 16MB CMA 成功但在运行中某个回调里又临时申请第二次结果失败。后来把临时申请改成初始化阶段一次性预留问题就没了。这说明凡是能提前做的事尽量别放到关键路径上。3.4 高负载下的分配策略重试、回退与内存池分配失败是驱动开发者必须正面处理的问题。很多内核崩溃看起来是空指针实际是某个 kmalloc 返回了 NULL而调用者没检查就直接解引用了。正确的处理姿势是分层第一层能缩减大小的就缩减第二层允许阻塞的路径适当重试第三层关键路径用 mempool 或者预分配保证一定拿得到内存。kvmalloc 系列kvmalloc、kvzalloc、kvfree在 4.12 之后的内核里很常用它会先用 kmalloc 尝试不行再退回 vmalloc省去你手写 fallback 的麻烦。但要注意kvmalloc 的语义里带一些限制而 GFP_ATOMIC 这种不能睡眠的场景不适合硬套 kvmalloc因为退回 vmalloc 时是可能睡眠的。mempool 是另一个很实用的东西。它本质上是预分配一小批对象放在池子里运行时分配优先从池里取池空了才走正常分配路径即使正常分配失败池里的存量也能兜底。网络驱动收包、块设备请求队列里经常用到。我常用的写法是static int demo_prealloc_pool(struct demo_dev *dev) { dev-pool mempool_create_kmalloc_pool(64, sizeof(struct demo_pkt)); if (!dev-pool) return -ENOMEM; return 0; }运行时就用 mempool_alloc(dev-pool, GFP_ATOMIC) 拿对象用完 mempool_free 归还。mempool 的对象大小和分配方式固定性能也稳定是热路径上值得优先考虑的方案。4. 分配失败与内存问题的排查工具箱4.1 典型故障速查表真到了线上出问题的时候很少有人能从头把源码读一遍。我的习惯是先看 dmesg、再看 /proc 下的统计文件、最后上调试工具。下面这张表是我在排查内存问题时的快速对照表现象可能原因首选排查手段sleeping function called from invalid context在原子上下文用了可能睡眠的 GFP抓调用栈确认上下文改 GFP_ATOMIC 或提前分配kmalloc 大块返回 NULL高阶物理连续页不足/碎片化查看 /proc/buddyinfo、/proc/pagetypeinfo考虑 vmalloc/CMARedzone overwritten / Object already freeslab 对象越界写或重复释放开 slub_debug加 KASAN 复现内存只增不减系统逐渐卡死内核对象泄漏kmemleak 扫描配合 /proc/slabinfo 看哪个缓存膨胀用户进程被杀dmesg 有 Out of memory系统内存耗尽或 cgroup 超限看 /proc/meminfo、cgroup memory.events分配路径明显变慢频繁直接回收/碎片整理看 /proc/zoneinfo 的 pgscan/pgsteal压缩大分配4.2 内存泄漏定位kmemleak 实操kmemleak 是内核自带的可达性扫描器思路类似用户态 valgrind周期性扫描内核内存找出那些没有任何指针引用、但也没有被释放的对象。用它定位内核态内存泄漏比人肉翻代码高效得多。前提是内核要打开 CONFIG_DEBUG_KMEMLEAK然后挂载 debugfs按下面几步走mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/kmemleak echo scan /sys/kernel/debug/kmemleak cat /sys/kernel/debug/kmemleak首次 scan 后kmemleak 会输出类似 unreferenced object 0xffff88810xxx (size 4096): 的段落后面跟着分配时的调用栈。通过栈帧就能定位到是哪个驱动、哪行代码分配后没有释放。实际使用中有几个坑kmemleak 只在扫描时能看见藏在寄存器或 per-cpu 变量里的指针静态变量里的指针可能被误报另外链表结构如果只是头节点被保存其余节点靠遍历访问也可能误报。遇到这种情况可以在确认安全后调用 kmemleak_not_leak 标记。经验是先连续 scan 两三次看未引用对象是否持续增长增长的就是真泄漏。4.3 越界写与 UAFslub_debug 和 KASAN 怎么配合内存越界和 use-after-free 是内核开发里的头号杀手因为它们经常不影响当前代码而是隔一段时间炸在毫不相关的模块里。定位这类问题首选 slub_debug在内核启动参数里加上 slub_debugFZPU 就能开启。这几个字母分别代表F 做健全性检查、Z 在对象前后加 redzone 哨兵、P 在释放后用特征字节填充、U 记录对象最近一次分配和释放的调用栈。开启后如果越界写踩到 redzone内核会立刻报 Redzone overwritten配合调用栈一眼就能看出是谁干的。KASAN 是更强的方案它靠编译器插桩检测全局越界、栈越界、堆越界和 UAF精度比 slub_debug 高得多但内存开销和性能损耗也大得多一般只在 QEMU 虚拟机或者专门的调试内核里开。我的做法是先在开发环境开 slub_debugFZPU 跑用例能定位最好定位不了就换 KASAN 复现。KASAN 还有一个优势是能抓到释放后又被读/写的场景这类问题 slub_debug 只凭红区哨兵很难稳准地抓出来。4.4 碎片化从 /proc/buddyinfo 看系统状态很多时候分配失败不是内存总量不够而是物理内存碎片化太严重。判断碎片化最直接的方式是看 /proc/buddyinfoNode 0, zone DMA32 1234 456 78 0 0 0 0 0 0 0 0 Node 0, zone Normal 5678 1234 456 78 12 3 0 0 0 0 0每列数字表示对应阶数下空闲块的个数从左到右是 order 0 到 order 10。如果 order 0、order 1 数量很大高阶全是 0说明大部分内存被拆成了单页找连续大块会很难。这时候可以尝试触发内存压缩echo 1 /proc/sys/vm/compact_memory。compaction 会把可移动页往一起搬腾出连续的物理区间。但它不是万能的内核自己分配的不可移动页比如某些 slab 缓存搬不动所以减少高阶不可移动分配才是治本。命令行里还可以用 cat /proc/pagetypeinfo 查看不同迁移类型Unmovable、Reclaimable、Movable、CMA下的页分布。CMA 区域通常显示 Movable 或 CMA这也是判断 CMA 配置是否生效最快的方法。我排查过一个多媒体模块驱动要求 8MB 连续内存系统明明显示还有 1GB 空闲却总是分配失败一看 buddyinfo 就明白了高阶空闲块全为 0而且不可移动页把内存切得七零八落。后来把缓冲区改成 CMA 预留问题直接消失。4.5 OOM 与 cgroup进程被杀的另一种可能线上最常见的一个问题是我的进程好好的怎么突然被杀了很多人的第一反应是去找 OOM killer 日志。但要注意现代系统里进程被杀还有一个非常常见的原因cgroup 内存限制。容器和 systemd 管理的服务大多挂在各自的 cgroup 下当 cgroup 的 memory.max 被触及时内核只会在该 cgroup 内部选进程杀而不是在整个系统范围里找。这时候 dmesg 里同样会有 Out of memory: Killed process 之类的记录信息里能看到是哪一层的 cgroup 超限。排查思路是分两步先确认全局内存状态看 /proc/meminfo 里 MemAvailable再看 cgroup 统计cgroup v2 路径下是 /sys/fs/cgroup/ 对应目录里的 memory.current 和 memory.events里面如果记录了 oom_kill 计数那基本就是被限制杀的。驱动开发里还有一个容易被忽视的点内核态内存也会计入 cgroup 的 kmem 统计如果内核开启了对应配置驱动一旦泄漏内核对象也可能耗尽某个 cgroup 的限额把无辜的用户进程连坐杀掉。这算是内核内存问题里最隐蔽的连锁反应之一。写到最后我想说几个自己的习惯这些年排查内核内存问题踩过的坑不少也沉淀下来几件事第一写驱动前先想清楚这个函数会在什么上下文被调用gfp 值从第一行代码就写对比事后查崩溃效率高一百倍第二凡是用到分配内存的地方返回 NULL 的处理必须写哪怕当前逻辑觉得这里不可能失败因为内存压力上来之后一切皆有可能第三调试工具不是等到出问题才开开发阶段就把 slub_debug 和 kmemleak 打开让问题在低成本的测试环境里暴出来而不是留到线上。内核内存分配说到底是分层缓存 精细上下文控制的游戏把伙伴系统、SLUB、GFP 这几个核心概念吃透再配合一套可靠的排查工具箱大部分内存问题都能被你控制在可控范围内。