
内核内存分配这个话题网上资料确实不少但要么是源码级的分析贴读三行就劝退要么是几行字的API手册用起来才发现到处是坑。这篇东西我打算用这几年实际写驱动、调系统、排查线上问题的经验把 Linux 内核内存分配的底层逻辑、API 选型、完整流程和常见坑位一次讲透。不管你是刚接触内核模块的嵌入式工程师还是已经在写驱动但经常被内存问题折磨的老手这篇文章都能给你一套可以直接参照的思考框架。搞懂内核内存分配最核心的一点是先改变习惯用户态 malloc 随便用内核态不行。内核态没有标准库帮你兜底没有缺页的时候自动补内存的宽松环境你得精确地告诉分配器要多少、有什么约束、能不能睡、能不能回收。不说清楚这些你的模块轻则性能劣化重则把整个系统拖进 OOM。下面我用四个大块来展开每块都是实际工程里绕不开的环节。1. 先搞清楚内核内存分配的舞台物理内存怎么变成内核能用的内存1.1 内核态为什么不能直接用 malloc很多人第一次写内核模块都会有个困惑我在用户态用 malloc 用得很顺手为什么到了内核里就不能直接调原因很简单内核里没有 glibc也没有 malloc。你在用户态调用 malloc 的时候背后是 glibc 管理的堆它维护自由链表空间不够就通过 brk 或 mmap 向内核申请而且申请到的只是虚拟内存真正物理内存是等你访问时才由缺页异常分配的。内核态完全不是这个玩法。内核自己就是分配物理内存的机构它要直接面对物理内存页、页表、DMA 地址、NUMA 节点这些硬骨头。如果你在内核态随便造一个用户态风格的堆那等于让银行自己印自己花的钞票没人监管迟早崩盘。内核内存分配器的设计目标非常明确高效、确定、能感知中断上下文、能支撑整个内核的并发访问。也正因为如此它的 API 和用户态完全是两套体系。在内核态内存分配里最底层的基本单位是物理页框通常叫 page大小在多数架构下是 4KB。整块物理内存被划分成一张张页内核管理这些页的分配情况谁申请到了哪一页、哪一页空闲都需要精确记录。这个物理页管理的角色主要由伙伴系统Buddy System承担。slab/slub 分配器、vmalloc、percpu 分配器等全部或直接或间接地建立在伙伴系统之上。1.2 从物理页到虚拟地址伙伴系统与页表的分工这一节首先要建立两个视角物理内存视角和虚拟内存视角。物理内存是真实的硬件资源比如你的机器插了 16GB 内存那就是 16GB4096 页。虚拟内存是 CPU 通过页表给每个进程包括内核自己呈现的一个假地址空间64 位系统下大得惊人可以远大于物理内存。伙伴系统Buddy System负责管理物理页。它的核心思想很好理解把空闲页按大小分成多个链表每个链表里的页块大小是 2^order 个连续页order 从 0 一直到 MAX_ORDER-1。比如 order0 就是一页4KBorder3 就是 8 个连续页32KBorder10 就是 1024 个连续页4MB。你要分配 n 个连续页时内核会从能满足你的最小 order 开始找如果没有就往上拆大块拆两半一直拆到能满足为止。释放的时候如果相邻的块也是空闲的就合并回去所以叫伙伴。内核在管理物理页时还把整个物理内存划分成了不同的 zone。x86-64 上最常见的是 ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_MOVABLE 等。ZONE_DMA/DMA32 是为了某些老设备只能访问低地址内存而保留的ZONE_NORMAL 是主要的普通内存区ZONE_MOVABLE 则专门给可迁移的页用主要是为了缓解内存碎片。每个 zone 都有自己的水位线watermark参数这个我们在第三节细说。虚拟地址的映射方式也分两种。一种是线性映射direct map64 位系统下直接把物理内存线性映射到内核虚拟地址空间的某个固定区域通过 PAGE_OFFSET 这种基址加上物理地址即可算出虚拟地址这是大部分内核内存访问的主要方式。另一种是动态映射用 vmalloc 分配的内存就是这种它只保证虚拟地址连续物理地址完全不连续CPU 访问时要通过页表逐页查找所以性能比线性映射差一些。理解了这个舞台才知道后面 API 选型为什么那么关键kmalloc 走的是线性映射、物理连续vmalloc 走的是动态映射、物理不连续。这决定了它们各自的性能特点和适用场景。2. API 选型才是第一道坎kmalloc 还是 vmalloc2.1 常用分配函数全览与差异对比内核里常见的内存分配函数我建议你先把这几个刻在脑子里kmalloc、kzalloc、kcalloc、kmalloc_array、vmalloc、kvmalloc、alloc_pages、__get_free_pages。kmalloc 是最常用的。它分配的内存物理连续、虚拟地址也是连续的而且走的是 slab/slub 分配器小对象分配速度快适合驱动里大部分小体积缓冲区场景。kmalloc 有个最大上限具体取决于配置但通常不会超过几MB比如 4MB 左右。你要分配超过这个上限的大块连续内存kmalloc 会直接失败。kzalloc 其实就是 kmalloc 加 __GFP_ZERO 标志分配完自动清零防止你拿到残留数据。内核驱动里绝大多数场景都应该用 kzalloc比如分配一个结构体实例你没有精力去逐个初始化每个字段直接清零最稳妥。kcalloc 和 kmalloc_array 是为数组分配的它们会在计算总大小时做溢出检查防止 n*size 整数溢出。这俩是审代码时特别容易查到的点有人图省事直接 kmalloc(n * size)万一 n 和 size 是外部可控的值乘积溢出后分配的缓冲区比预期小后面一发 write 就内存踩踏了。vmalloc 分配的是虚拟地址连续、物理地址不连续的内存。它要建立和拆除页表项分配成本高访问时还可能因为页表遍历导致 cache miss所以性能明显不如 kmalloc。但它的优势是容量大只要虚拟地址空间够分配几百MB 也没啥问题适合那些很少访问、或者只在特定函数里用一次的大缓冲区。比如内核网络模块里某些大块数据结构就会用 vmalloc。kvmalloc 是很多老手的心头好它先尝试 kmalloc如果分配失败再自动退化为 vmalloc。这样既能在大多数情况下拿到高性能的连续内存又能在内存不足时靠 vmalloc 的大容量兜底。不过注意kvmalloc 成功后你根本不知道内存到底是 kmalloc 还是 vmalloc 分配的所以释放时统一用 kvfree千万别自己判断去调 kfree 或 vfree。我见过有人知道 kvmalloc 内部会回退 vmolloc 后释放时手动判断如果地址大于 X 就 vfree结果把地址规则搞错直接内核 panic。下面这张表是我个人在实际选型时常用的速查表你可以直接抄函数物理连续性最大可分配量性能释放函数适用场景kmalloc连续较小几MB内快kfree小缓冲区、常用结构体、DMA需要的连续内存kzalloc连续同上快kfree需要清零的结构体、设备私有数据kcalloc/kmalloc_array连续同上快kfree数组分配防溢出vmalloc不连续大数百MB甚至更多慢vfree大块缓冲区、不常访问的数据kvmalloc不确定大快/慢自适应kvfree不确定大小、希望有兜底的大块分配alloc_pages/__get_free_pages连续按order快__free_pages直接操作页、需要页级标注时另外还要认识一套专门给设备驱动用的小工具devm_kzalloc、devm_kmalloc 这类带 devm 前缀的分配函数。它们会把内存的生命周期绑定到设备对象上设备卸载时自动释放不需要你手动调 kfree。对驱动老手来说这套 API 能省下一大半的心力至少不用担心 remove 路径里忘了释放导致资源泄漏。2.2 GFP 标志位每次分配都要先想好睡眠还是战斗GFP 是 Get Free Page 的缩写其实是一组位标志它告诉内核这次分配允许做什么、不允许做什么。等你接触的内核代码多了会发现GFP 标志位选错是内核内存问题里最高发的一类。最常见的三个 GFP 标志是 GFP_KERNEL、GFP_ATOMIC 和 GFP_USER。GFP_KERNEL 是最常规的分配标志它表示这次分配是在普通进程上下文中进行的可以睡眠等待内存可以触发内存回收甚至可以把当前进程换出 CPU。你在写普通的内核线程、系统调用、进程上下文里的工作都可以放心使用 GFP_KERNEL。GFP_ATOMIC 表示分配过程不许睡眠必须在原子上下文中断处理程序、软中断、自旋锁保护区里使用。因为不能睡眠分配时如果内存不够内核不能停下来慢慢回收只能赶紧试一把不行就返回 NULL。所以 GFP_ATOMIC 的分配成功率比 GFP_KERNEL 低不少使用时一定要判空。有人说 GFP_ATOMIC 一定不会睡眠这话对但注意它本身还是有一定内存储备比如 emergency 内存池可以取用的只是限制很多。GFP_USER 通常用于为用户态进程分配内存页比如给用户态 page cache 分配内存时就会用到。它会把分配出来的页当作用户页来对待可回收、可交换配合 mmap 或 get_user_pages 使用。除了这些核心标志还有一堆修饰性的 GFP 派生标志实际操作中要记住几个关键的__GFP_ZERO分配后把内存清成 0kzalloc 内部就是加了这个标志。__GFP_HIGH允许使用系统紧急保留内存中断上下文如果一定要分配经常会带这个。__GFP_NOWARN抑制分配失败时内核打印的警告信息。很多驱动故意加这个防止因为某次可重试的分配失败刷屏。__GFP_NOFAIL告诉内核这次分配必须成功不许返回失败。看起来很爽但内核会为了满足你做各种激进回收甚至无限循环。非极端情况下不要用线上代码里出现这个基本等于在把系统往死里逼。__GFP_RECLAIMABLE标记分配的内存是可回收的内核管理时会更愿意把它们放在可回收区有助于碎片整理。选 GFP 标志位时你可以把它当成一个上下文承诺我能不能睡眠我允许内核花多少力气去满足我最要命的是在原子上下文里用了 GFP_KERNEL。朋友曾经在一处 spinlock 保护的临界区里写了 kmalloc(..., GFP_KERNEL)表面上看不出问题但一旦内存紧张内核会在锁里尝试睡眠然后抱着锁去执行内存回收过一会儿另一个 CPU 上想拿同一把锁的进程也被堵死整个系统像被按了暂停键。这种问题排查起来特别阴间因为不是必现只在内存压力大的时候才触发。3. 执行起来一次 kmalloc 背后的内核旅程3.1 从 zone 到水印检查内存不足时内核先做什么很多人以为调用 kmalloc 后内核直接从一个链表里拿块内存就完事了。实际上当 kmalloc 分配的小对象原对象object已经不够时它会从 slab/slub 分配器的本地缓存里取再不够就向伙伴系统申请新的页。而伙伴系统分配页时要走的路径比我前面讲的找链表、拆块要复杂得多因为内核必须先判断我现在到底能不能分页出去。这里的核心概念是 zone 的水位线watermark每个 zone 都有三个水位参数min、low、high可以在 /proc/zoneinfo 里看到。它们是三个数字代表该 zone 空闲页数的阈值。如果当前 zone 空闲页数在 high 以上说明内存很充裕分配请求直接走快速路径从 per-cpu 页表缓存里拿页就行。如果降到 low 和 high 之间内核会唤醒后台线程 kswapd开始异步回收一些可回收页同时当前分配请求还是可以继续满足。如果降到 min 以下内存就比较危险了普通分配请求会被卡住进入直接回收direct reclaim流程也就是当前进程亲自下场帮助内存回收这可能是一个相当慢的过程。如果连 min 水位都撑不住内核还有最后一道防线PF_MEMALLOC 标志和紧急内存池。带 __GFP_HIGH 或来自回收路径的分配可以动用一部分保留内存确保系统关键路径不死锁。所以你在中断上下文里用的 GFP_ATOMIC 分配为什么运气好还能成功就是因为它可以动用保留内存。实际工程中我建议每个驱动作者都养成一个习惯定期看一眼 /proc/zoneinfo 里的水位线确认你的机器长时间稳定运行后各个 zone 的空闲页是不是长期压在 min 附近。如果是说明系统内存压力很大你模块里的分配策略就需要调整比如减少缓存、改用可回收内存、或者在低峰期预分配。3.2 内存回收与 OOM内核最后的倔强当内存严重不足kswapd 和直接回收都无法满足分配请求时内核就要放大招了OOM Killer。OOM 会在系统几乎无内存可用的瞬间选择一个进程杀掉释放它的内存来满足当前分配。很多第一次接触内核内存分配的人觉得 OOM Killer 是随机杀人的其实不是。内核会为每个进程计算一个 oom_score大致基于进程的内存占用和 oom_score_adj分高的进程优先被杀。你可以在 /proc/ /oom_score 里看到每个进程的分数也可以通过 /proc/ /oom_score_adj 调整范围从 -1000 到 1000。-1000 表示禁止被 OOM 杀通常只给那些负责系统关键功能的进程设置。不过OOM Killer 对内核态内存分配请求来说并不是一定能解决问题的。比如你在中断上下文用 GFP_ATOMIC 分配系统不能走慢速回收路径那基本不会触发 OOM Killer而是直接返回 NULL。反过来GFP_KERNEL 分配时如果系统决定触发 OOM你分配的那个进程反而可能先被干掉。所以内核代码里判空和处理失败从来不是可选项而是必要操作。值得多提一句的是内存碎片化问题。碎片化不是说物理内存总量不够而是连续页块不够。举个例子系统有 8GB 内存空闲了 4GB但因为长期分配释放这 4GB 全都是零散的 4KB 页你请求一页很容易但请求一个 order8连续 1MB就难了。伙伴系统面对这种情况会尝试 compact内存规整把可移动页搬走、腾出连续区域。这就是为什么内核里把用户态页标记为可回收/可迁移很重要的原因一大片 LRU 页可以被搬碎片就能被慢慢整理出来。如果你发现系统明明内存还有很多却分配不出大块连续内存大概率就是碎片问题。这时可以读 /proc/buddyinfo 看看各 order 的空闲页分布后面第 4 节我会给具体方法。3.3 一个真实的驱动内存分配配置案例说了这么多理论我拿一个真实驱动里总结出来的配置方案来演示。假设你要给一个网络设备驱动写一个 DMA 环形缓冲区描述符每个描述符 64 字节共有 256 个总共 16KB。听上去很小但因为 DMA 要硬件访问这块内存必须物理连续、而且在 DMA 地址范围内。这时的正确做法不是 kmalloc也不是 vmalloc而是使用 DMA APIdma_alloc_coherent。struct desc *desc; dma_addr_t dma_handle; desc dma_alloc_coherent(pdev-dev, 256 * sizeof(struct desc), dma_handle, GFP_KERNEL); if (!desc) { dev_err(pdev-dev, failed to alloc dma coherent memory\n); return -ENOMEM; } // 硬件寄存器里写 dma_handle驱动代码里直接用 desc为什么不能 kmalloc因为 kmalloc 返回的虚拟地址我们无法保证 DMA 引擎一定能访问。DMA 需要的是总线地址而不是 CPU 的虚拟地址。dma_alloc_coherent 会把物理内存、虚拟地址、DMA 地址三者的关系一次性搞定还能保证 Cache 一致性问题。如果你只是需要普通的内核缓冲区、完全不走 DMA那就用 kzalloc 就够了不要用 DMA API。再举一个典型分配例子内核模块启动时要缓存一批上层的临时数据大小取决于网络包的数量最多可能要 8MB而且不是 DMA 使用。这种情况就不该硬上 kmalloc因为 8MB 连续内存容易分配失败。如果性能要求没那么苛刻直接用 kvmalloc 最合适如果数据会被高频访问、对性能敏感那可以考虑改设计拆成多个小缓冲区或者考虑预分配内存池。顺带说一个我在项目里反复踩到的细节40GB 内存的机器上kmalloc 一次性分配 4MB 连续空间明明是可行的但因为碎片化严重成功率并不高。后来我把分配时机从运行期搬到了模块加载早期那时内存一片崭新成功率极高。等系统跑了一两周再去分大块连续内存那就是在赌运气。类似的思路在驱动设计阶段就要考虑大块内存早分配小块内存按需分配长期存活的内存和一次性使用的内存分开。4. 内存问题排查与实战避坑4.1 通过 proc 文件系统快速定位内存状态内核内存问题跟用户态不一样你不能直接上 gdb 看堆。真正好用的工具都集中在 /proc 和 /sys 下。我先说最重要的四个文件/proc/meminfo、/proc/buddyinfo、/proc/slabinfo、/proc/zoneinfo。/proc/meminfo 是总览里面几个字段要会看。MemTotal、MemFree 当然不用说重点是 MemAvailable它才是内核估算的还能用多少的值比 MemFree 更接近真实因为 MemFree 没算可回收的 page cache。还有个 Slab 字段它表示内核 slab 分配器占用的内存如果你的某个模块不停泄漏小对象Slab 会持续增长。/proc/buddyinfo 直接反映伙伴系统每个 zone、每个 order 的空闲页数量。它的格式类似这样Node 0, zone Normal, 1234 456 789 234 56 12 7 3 1 0 1这些数字从左到右分别对应 order0 到 order10 的空闲页块数。比如 order0 是 1234 块那就是 1234 页的单页块order3 是 234 块每块 8 页。如果后面大 order 的数字长期为 0说明你的系统碎片化已经很严重了。看到这种迹象再去查是不是有驱动频繁分配释放大块内存或者系统中可迁移页太少。/proc/slabinfo 给的是每个 slab cache 的详细信息包括对象数量、活跃对象、总内存占用。排查内存泄漏时最常用的手段就是先记录一份 slabinfo跑几天业务再对比一次看哪个 cache 的活跃对象数量涨得离谱。我曾经靠这个方式半天内定位到一个驱动每次收发报文都分配一个对象且忘记释放那个 cache 的活跃对象数随时间线性增长非常直观。/proc/zoneinfo 是更细粒度的 zone 数据里面有每个 zone 的 min/low/high 水位线、当前空闲页数、各种回收统计。它适合回答内存到底够不够这类问题比如你发现某个 zone 的空闲页一直在水位线附近反复横跳说明那个 zone 压力很大。4.2 内存泄漏定位kmemleak 与 slabinfo 实操内核态内存泄漏比用户态可怕得多因为泄漏到一定程度不是程序崩溃而是整个系统变得极慢或者被 OOM Killer 杀掉无辜进程。定位手段主要有三种代码审查、kmemleak、slabinfo 对比。kmemleak 是内核提供的一个内存泄漏检测器它扫描内核的内存对象并报告那些没有被引用到的对象。它不是默认开启的需要内核配置了 CONFIG_DEBUG_KMEMLEAK很多发行版内核没开。如果条件允许我在调试驱动的内核里一定会开它。用法很简单把模块加载进去、跑一遍相关操作然后echo scan /sys/kernel/debug/kmemleak cat /sys/kernel/debug/kmemleak它会列出疑似泄漏的内存块并给出分配时的调用栈。这是目前定位内核泄漏最好用的手段。但注意kmemleak 性能开销不小生产环境千万别常开只在调试内核上开。如果没有 kmemleak就用笨办法把 /proc/slabinfo 里每个 cache 的 active_objs 字段记下来然后执行你的模块操作 100 次再读一次。如果某个 cache 的增长量和操作次数成正比那 100% 是这个操作路径在泄漏。这个思路虽然土但非常可靠而且不依赖任何内核配置。再看一种容易被忽略的情况你分配了内存也释放了但释放时机不对。比如把要释放的指针覆盖了或者只在模块卸载时释放而模块常驻系统让人反复调用分配接口长期跑下来一样膨胀。这种问题看代码才好查kmemleak 反而不一定能捕捉到因为它只看有没有被引用。处理这类问题我的经验是每次分配后把指针放到结构体里统一管理并在所有可能错误返回的路径上增加 goto err_free 集中释放可以大幅度降低泄漏概率。4.3 我踩过的三个经典内核内存坑第一个坑是原子上下文分配睡眠。前面讲过spinlock 里用 GFP_KERNEL 分配内存表面跑得好好的某天线上内存吃紧系统瞬间卡死几秒甚至触发 watchdog。这个问题的本质是上下文承诺没兑现。排查方法倒也不难如果内核开了 CONFIG_DEBUG_ATOMIC_SLEEP这类错误会在日志里直接打印 BUG: sleeping function called from invalid context。我建议所有开发内核代码的人第一步就把相关 debug 选项全开上跑一遍静态分析工具比如 smatch、sparse再上真实硬件压测。你有相当一部分偶发的问题其实都是这类违反上下文约束造成的。第二个坑是 vmalloc 内存的释放。有人分配了内存后用 kfree 释放页面地址空间是好的但页表项没有释放地址空间的 VMware 区域长期泄漏。也许一两次看不出来但一个长期运行的机器上反复执行加载/卸载模块、创建/销毁连接这类操作内核虚拟地址空间会被慢慢蚕食。后来系统 mmap 新内存失败、内核打印 vmalloc: allocation failure你才开始怀疑是不是哪里泄漏了。实际上不是真正的内存不够而是虚拟地址空间碎片太多。所以强烈建议vmalloc 分配的用 vfreekvmalloc 分配的用 kvfreekmalloc 分配的用 kfree不要自己优化。第三个坑是 __GFP_NOFAIL 滥用。我见过有人为了让分配必成功在 kmalloc 里加上 __GFP_NOFAIL。结果在内存压力大的系统上内核为了满足这个请求疯狂回收其他进程全部卡到天荒地老。实际上驱动代码里几乎没有必须使用 __GFP_NOFAIL 的场景更合理的做法是接受分配失败然后设计一个退避重试机制或者提前在低峰期把内存池建立好。还有一个伴生的坑是混淆物理内存地址和内核虚拟地址。你在驱动里做 DMA 的时候硬件需要的是物理地址总线地址不是 kmalloc 返回的虚拟地址。如果不加转换直接用 virt_to_phys在某些架构上可能能用但在有 IOMMU 或非连续内存的平台上会拿到完全错误的结果。正确姿势是使用 dma_map_single、dma_alloc_coherent 等 DMA API让内核帮你处理映射和地址转换。很多新手驱动写坏了都是栽在地址这个东西上。最后再分享一个小技巧如果你是排查线上内核内存问题别急着用各种重型工具。先做一件事把如下命令的输出连续记录 24 小时基本能筛掉一半问题cat /proc/meminfo cat /proc/buddyinfo cat /proc/slabinfo cat /proc/zoneinfo把这四个文件每隔几分钟记录一次拉到本地方便对比。内存泄漏、碎片化、回收死循环这些问题在这些数据的趋势曲线里都会露出马脚。等到数据证明某个方向不对劲再对症下药比在一堆源码里大海捞针高效得多。就我个人感觉内核内存分配的知识点其实不复杂难的是每次分配前都保持那根弦到底该用哪个 API、该带什么 GFP、该不该判空、释放时机对不对。这些东西没法靠背 API 表解决得靠在真实项目里踩坑、看 /proc 数据、翻内核日志慢慢养成肌肉记忆。希望这篇文章能帮你把内核内存分配的路数梳理清楚下次写代码的时候少踩几个坑。