2026/10/10 11:51:42

Linux内核内存分配实战:kmalloc、GFP与伙伴系统

Linux内核内存分配实战:kmalloc、GFP与伙伴系统 做内核开发的人迟早都要跟内存分配打交道。不管是写字符设备驱动、搭网络协议栈的收包路径还是给硬件维护一块DMA缓冲区最后都会落到一行kmalloc或者alloc_pages上。可很多人在用户态玩惯了malloc一进内核就懵为什么内存分配还要带这么多标志位为什么中断上下文不能随便分配为什么内存明明还有几十MBkmalloc却返回失败这篇就按我自己的理解把 Linux 内核内存分配从头到尾捋一遍给刚入门或者已经踩过坑的同学一份能直接对照实践的指南。1. 先把内核内存这件事的底摸清1.1 物理内存、虚拟内存和连续性的认知基础内核里分配内存第一件事是要分清楚你拿到的到底是什么。绝大多数情况下进程看到的内存地址都是虚拟地址背后由 MMU 做映射。内核空间也是一样你在模块里通过kmalloc拿到的指针同样是一个虚拟地址它对应的物理页不一定和你直觉里想的一样。这里有个核心概念必须刻在脑子里连续性。kmalloc返回的虚拟地址在物理上也是连续的。这意味着你可以拿它去做 DMA可以直接把这一整块内存交给硬件。而vmalloc返回的地址只是虚拟地址连续底层物理页是分散的不能用于需要物理连续的场景。用生活里的例子说kmalloc相当于整租了一套三居室三个房间挨在一起vmalloc相当于你在同一栋楼的不同楼层各租了一个单间门牌号顺着看是连续的但房子彼此不挨着。为什么要反复强调物理连续性因为硬件 DMA 引擎在搬运数据时只知道物理地址它不认 MMU 页表。CPU 可以通过页表把零散物理页拼成连续的虚拟地址但大多数 DMA 控制器没有这本事。所以只要涉及 DMA你就得找物理连续的内存。另外内核里说的“内存”默认是指物理页。每个物理页对应一个struct page结构整个物理内存在系统启动时就被归纳成一张大表。这张表是所有内存分配操作的总台账。你每次分配内存本质上是让分配器在这张表里为你划出若干标记为“已用”的物理页。1.2 page、order、zoneBuddy System 的基础词表内核用伙伴系统Buddy System管理物理页。它的核心思路简单粗暴把所有空闲物理页按 2 的幂次分组也就是 order。order 0 是 1 个页order 1 是 2 个页order 2 是 4 个页以此类推。分配 3 个连续页时伙伴系统会从 order 24 页里切给你剩下 1 个页拆回 order 0 的空闲链表。我在刚学内核时一直没搞明白为什么伙伴系统要把内存拆成“伙伴”后来才理解关键在于释放时的合并。当你释放内存时分配器会检查和你相邻的物理页是否也空闲如果两边能拼成更大的 order就向上合并。这个过程让内存不至于碎成一片小碎片。物理内存还会被划分为不同 zone。最常见的是DMA、DMA32、NORMAL和MOVABLE。DMAzone 是给只能寻址有限物理地址的旧硬件准备的比如 16MB 以下的 ISA 设备如今大多数 64 位平台直接使用DMA32或者干脆从NORMAL里分配。MOVABLE区比较特殊它里面的页可以迁移这是为了应对内存碎片化而出现的机制后面讲调优时会用到。分配内存时GFP 标志里就带着 zone 修饰信息。比如GFP_DMA表示必须从 DMA zone 分配而默认的GFP_KERNEL通常是从NORMAL或DMA32分配。把内存分区管理的好处是不同硬件和使用场景可以各取所需不让某种特殊需求的内存被普通分配耗尽。1.3 引用计数内存管理最容易翻车的地方每分配一页内核都会给struct page里的_refcount加一。这个引用计数决定了物理页能否被释放或回收。当计数降到 0说明没有任何人使用分配器才会把它收回空闲链表。引用计数带来的典型坑是你通过get_page()把引用增加了但忘了put_page()页面永远得不到释放这就是内核内存泄漏。反过来对没有持有引用的页调用put_page()会造成 use-after-free系统崩溃可能立刻发生也可能过一段时间才发作极难排查。我自己的习惯是在代码里凡是增加了引用的路径必须同函数或同错误处理块内成对释放如果要把页传递到别的模块必须在注释里写清楚“谁拿到这个页谁负责put_page”。内核社区对引用计数语义的重视程度非常高任何一个含糊的归属关系都可能引发安全问题。2. 分配 API 怎么选才对味2.1 常用内核内存分配 API 速查表内核提供的分配 API 数量不算少但真正核心的没几个。我按自己平时的使用频率整理了一张对照表方便在动手前先圈定方向API分配粒度是否物理连续典型使用场景kmalloc通常 32B ~ 几 MB是临时缓冲区、驱动数据结构、需要 DMA 的小块内存kzalloc同kmalloc是需要清零的结构体或缓冲区kcalloc数组是分配数组并清零带溢出检查kmem_cache_alloc固定尺寸对象是高频分配、释放同一类结构体alloc_pages/__get_free_pages页2 的幂次是页级分配、DMA 大缓冲区、页表vmalloc页的倍数否大块虚拟连续内存、模块加载devm_kmalloc同kmalloc是驱动初始化阶段分配资源自动释放dma_alloc_coherent页的倍数是DMA 一致性映射缓冲区这张表里最容易被误用的是vmalloc。很多初学者在需要大块内存时第一反应就是它但vmalloc的性能开销比kmalloc高得多因为它要修改页表、刷新 TLB而且内存可能被 swap 到磁盘上。如果驱动要求物理连续vmalloc会直接导致 DMA 失败。反过来如果你只是需要一个很大的软件缓冲区不关心物理连续性vmalloc倒是可以接受的选择。2.2 kmalloc、kzalloc 与 kmem_cache 的三板斧kmalloc是最常用的分配接口底层由 Slab/Slub 分配器实现。它会把内存按大小分类相同大小的对象放在同一个 slab 缓存里分配时直接从缓存里取避免频繁和伙伴系统交互。这个设计有点像用户态的内存池提前把对象准备好用的时候直接拿用完放回。kzalloc其实就是在kmalloc基础上多做了一个清零操作。别小看这个清零内核里很多结构体要求未初始化的字段必须为 0否则随机值可能在某个条件下触发 bug。比如链表头、指针字段如果带着垃圾值初始化时没置空后续遍历就可能访问非法地址。所以我写代码时默认优先用kzalloc除非明确知道后续会立即填满整个缓冲区。kmem_cache_alloc则是更高级的工具。它适合需要反复创建和销毁同一类结构体的场景比如网络协议栈里的skb、文件系统里的inode。通过kmem_cache_create创建一个专用缓存频率极高的分配释放只在这个缓存里折腾不会打扰伙伴系统性能提升非常明显。代价是你得先定义清楚对象大小和对齐要求。我在做某个数据采集驱动时每秒钟需要创建几万个描述符换成kmem_cache之后 CPU 占用直接降了一截效果肉眼可见。2.3 多页连续分配与 vmalloc 的选择逻辑需要超过一个页的内存时选择逻辑开始变得微妙。伙伴系统只能分配 order 为 2 的幂次的页块所以alloc_pages(GFP_KERNEL, order)里的 order 决定了连续页数量order 0 是 1 页order 3 是 8 页。内核有个限制MAX_ORDER在大多数平台上默认是 11也就是说一次最多分配 2 的 10 次方也就是 1024 个页也就是 4MB默认 4K 页时。想要更大的连续内存常规路径走不通。这里就要引入vmalloc。它通过分配多个不连续的物理页再重新映射到连续的虚拟地址空间突破了连续物理内存的限制。代价是每次访问都可能发生 TLB miss因为虚拟地址跨了多个物理页表项。如果这个缓冲区被频繁访问性能会打折。我的建议很简单驱动里能用kmalloc就用kmalloc超过 4MB 或真的分配不到连续内存时再考虑vmalloc或者直接改用 SGScatter-GatherDMA让硬件自己支持分散缓冲区。很多现代 DMA 控制器都支持 SG 模式可以用一个描述符数组把不连续的页串起来这样软件逻辑也不用硬凑物理连续性。3. GFP 标志位就是分配方案的灵魂3.1 最常用的 GFP 组合KERNEL、ATOMIC 和 NOWAITGFPGet Free Pages标志位是 Linux 内核内存分配最精妙也最让新手头疼的部分。每一个分配动作都必须说明两件事第一如果内存不够我能不能睡觉等待回收第二我愿意从哪些 zone 拿内存还有诸如是否希望内存可移动等修饰条件。GFP_KERNEL是最常规的组合。它允许分配过程中睡眠内核可以为了满足分配去执行内存回收、等待磁盘 IO 换页回来。正因为可以睡眠GFP_KERNEL绝对不能在中断上下文、软中断上下文、持有自旋锁的临界区里使用。一旦在这种环境调用GFP_KERNEL内核尝试睡眠时会调度到别的进程而你手里的锁没人释放轻则死锁重则整个系统 hang 住。GFP_ATOMIC就是为原子上下文准备的。它不允许睡眠分配时只会从现有的空闲内存里快速找一个满足条件的页。如果内存不足直接返回 NULL绝不等待。代价是分配成功率比GFP_KERNEL低所以用GFP_ATOMIC的代码必须处理分配失败。GFP_NOWAIT和GFP_ATOMIC类似同样不睡眠但语义上更克制它明确表示“不等待任何回收动作”连直接回收都不碰。适合那些可以接受失败、稍后重试的路径。我见过一个真实场景某位开发者在中断处理函数里直接调用了kmalloc(..., GFP_KERNEL)最初的测试一切正常因为系统内存足够分配不需要睡眠但压力测试时内存紧张分配器开始尝试回收页面中断上下文里触发调度系统直接 panic。从那以后我要求自己写代码前先问一句“我现在所在上下文能不能 sleep”不能 sleep 就老老实实用GFP_ATOMIC。3.2 __GFP_ZERO、__GFP_HIGH 和 __GFP_MEMALLOC 的细节GFP 标志不仅能组成常见宏还支持细粒度修饰。__GFP_ZERO是让分配器返回清零的内存和kzalloc的效果一样。__GFP_HIGH表示这次分配是紧急的可以从系统预留的紧急内存中分配。这部分紧急内存由vm.min_free_kbytes控制是每个 zone 里的一块保留区通常留给中断处理或者内存回收自身使用不能滥用。__GFP_MEMALLOC更是“特权”标志它允许分配器在内存严重不足时借助回收机制本身来获得内存常用于回收进程自身的工作路径。如果普通驱动随意使用__GFP_MEMALLOC可能导致系统在内存压力下无法正常回收页面因为用于回收的关键线程拿不到内存了。另外还有__GFP_RECLAIMABLE这类标注表示分配的内存可以被回收机制追踪用于 Slab 缓存时有助于系统内存压力的缓解。对于模块开发者来说大部分时候不需要自己拼这些底层标志正确使用GFP_KERNEL、GFP_ATOMIC、GFP_NOIO、GFP_NOFS这几个组合就够了。GFP_NOIO和GFP_NOFS特殊一点它们允许睡眠但不能发起 IO 或不能访问文件系统主要用于块设备层和文件系统内部避免分配内存反过来触发 IO 造成递归。3.3 一个典型错误中断上下文里用了 GFP_KERNEL让我还原一个经典事故现场。某个网卡驱动在收包中断处理里判断当前 skb 池不足时直接调用了kmalloc(size, GFP_KERNEL)补充 skb。刚上线时压力不大一切正常。等到跑满带宽内存碎片让分配变得困难内核后台的 kswapd 开始回收页面需要把这个进程放入运行队列等待。这个操作对当前正在中断上下文的 CPU 来说相当于直接跳进调度器然后它发现当前上下文不允许睡眠于是触发BUG: scheduling while atomic。这类问题排查往往很痛苦因为触发条件依赖内存压力和流量峰值。我后来总结的经验是凡是中断、软中断、自旋锁临界区、rcu_read_lock保护区域里统一用GFP_ATOMIC凡是可能睡眠的场景比如进程上下文、mutex 持有时用GFP_KERNEL。如果拿不准宁可先用GFP_ATOMIC保证稳定然后再做优化。其实内核自身也为这类路径提供了更优雅的方案比如napi_alloc_frag、page_frag_alloc这类预分配机制。它们允许在原子上下文快速取用一小块预取的内存区域避免频繁调用通用分配器。使用这些专用分配函数是比硬着头皮用GFP_ATOMIC更可靠的做法。4. 实战从驱动初始化到数据路径的完整分配套路4.1 初始化阶段的内存预算与 devm_kmalloc写一个驱动时内存分配第一个阶段是初始化。这时候进程上下文完整可以睡眠用GFP_KERNEL没问题。但我建议你认真考虑使用devm_kmalloc一类的资源管理分配接口。devm_系列 API 的核心优势是把内存生命周期绑定到设备模型上。比如你为某设备分配的缓冲区会记录在设备资源链表里。当设备被移除或者驱动模块被卸载时内核会自动释放这块内存。这意味着你少写了很多错误路径清理代码也少了很多“忘记释放”的隐患。我自己写驱动时初始化函数里会这样组织static int xxx_probe(struct platform_device *pdev) { struct xxx_priv *priv; struct resource *res; void __iomem *base; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); priv-base base; platform_set_drvdata(pdev, priv); return 0; }这段代码没写任何显式释放逻辑因为devm_已经把一切都安排好了。设备分离或驱动卸载时devm_框架会反向释放资源。这时候如果还需要硬编码释放反而容易造成双重释放。但我必须提醒一下devm_kmalloc适用于设备生命周期跟随的场景却不适合模块级全局缓冲区。模块级缓冲区如果用devm_管理一旦某个子设备被移除缓冲区就被释放但模块还在运行继续用这个字段就会出问题。分配内存之前先想清楚内存的“主人”是谁再决定用什么接口。4.2 数据路径中的原子分配与 Per-CPU 缓冲设计初始化搞定后真正的挑战在数据路径。网络收包、块设备 IO 完成、中断处理这些路径对延迟和成功率要求都很高不能随便 sleep也不能频繁触发全局分配。我常用的优化思路是 Per-CPU 缓冲。比如某个设备需要频繁构造小型描述符可以在每个 CPU 上预留一小块内存池分配时只要访问本 CPU 的池子连锁都不用加。这样的好处是彻底避免多核之间的锁竞争。内核为此提供了this_cpu_ptr、per_cpu_ptr等接口。定义一个 per-cpu 变量static DEFINE_PER_CPU(struct desc_pool, desc_pool);然后数据路径中直接从本 CPU 池取用struct desc_pool *pool this_cpu_ptr(desc_pool); if (pool-count 0) { /* 批量从伙伴系统补充或者直接返回失败 */ return -ENOMEM; } desc pool-free_list[--pool-count];Per-CPU 结构在驱动里非常实用但要注意两个坑一是不能把钩子函数里拿到的 CPU 指针永久保存因为进程可能被调度到其他 CPU 上下次访问就成了别人的池子二是只有当数据路径相对独立时才用 per-cpu如果数据频繁跨 CPU 处理反而需要迁移成本。如果一定要在中断路径里临时分配少量小内存GFP_ATOMIC是可以用的但它不应该成为主要路径。主要路径必须是预分配或者池化内存GFP_ATOMIC只当“保险丝”。4.3 释放内存的对称性与错误路径清理内存分配不仅有分配还要释放。对称性是我反复强调的准则用kmalloc分配的内存必须用kfree释放用__get_free_pages分配的内存必须用free_pages释放用kmem_cache_alloc分配的对象必须用kmem_cache_free归还给缓存vmalloc对应vfree。跨接口混用非常危险。比如__get_free_pages分配的页如果你用kfree释放因为 Slab 分配器可能把这块内存当成一个小对象回收逻辑就乱了最终导致的错误可能是内存损坏或者随机崩溃。反过来kmalloc分配的内存放在页面头部附近用free_pages释放时可能少释放了一部分元数据。错误路径清理是驱动代码里最容易出 bug 的地方。我通常采用统一goto标签的清理结构static int xxx_start(struct xxx_priv *priv) { int ret; priv-buf kzalloc(BUF_SIZE, GFP_KERNEL); if (!priv-buf) { ret -ENOMEM; goto out; } priv-ring kcalloc(RING_SIZE, sizeof(*priv-ring), GFP_KERNEL); if (!priv-ring) { ret -ENOMEM; goto out_free_buf; } ret xxx_setup_dma(priv); if (ret) goto out_free_ring; return 0; out_free_ring: kfree(priv-ring); out_free_buf: kfree(priv-buf); out: return ret; }这样的写法保证每一步失败都能回溯释放已经分配的资源避免泄漏。要注意的是从失败点往回走释放顺序要严格逆序因为后面的资源可能依赖前面的资源。你在实际项目中见过的最隐蔽的内存泄漏往往就是错误路径少了一个kfree平常的 happy path 永远不触发只有特定异常场景才暴露。5. 内存分配失败怎么办排查与调优实录5.1 先学会看系统的内存台账当遇到kmalloc失败、alloc_pages返回 NULL 时别急着改代码先看系统内存到底处于什么状态。以下几个节点是内核运维和开发排查的第一手信息。cat /proc/meminfo看总体内存布局重点关注MemAvailable、Slab和PageTables。free -m只能看大致内存看不到内核内部的 slab 占用。/proc/buddyinfo查看伙伴系统每个 zone 的空闲页分布你能直观看到各个 order 的空闲情况。如果 order 3 以上的块很少说明碎片化严重大块连续内存分配可能失败。/proc/slabinfo是 slab 缓存明细里面记录了每个缓存的对象数、活动数和上限。通过对比一段时间内的数据可以判断是否有 slab 对象持续增长这是内核内存泄漏的典型信号。slabtop命令更适合终端交互式观察按对象数和内存占用排序。我发现很多内核内存泄漏追溯到某个结构体不断创建却没有释放slabtop一眼就能看出哪个缓存在膨胀。最后一个神器是/sys/kernel/debug/kmemleak前提是内核开启了CONFIG_DEBUG_KMEMLEAK。它会扫描内存并报告疑似泄漏的未引用分配。虽然它有误报的可能但排查无从下手时这工具往往能给出一条有价值的线索。5.2 碎片化与 order 分配失败的处理内核内存碎片化是个很现实的问题。伙伴系统维护的空闲页虽然总量充足但都分散成了 order 0 的小块申请连续物理大页时就失败了。缓解碎片化的第一招是启用内存规整compaction。你可以手动触发echo 1 /proc/sys/vm/compact_memory。内核会尝试把可移动的页移动到一端空出连续区域。这个操作在高碎片化场景下相当有效但触发时会造成延迟生产环境慎用。更底层的解决思路是让内存分配器从源头控制碎片。内核提供了MOVABLEzone只有带__GFP_MOVABLE标志的分配才会放入其中。这类内存可以迁移所以当有连续内存需求时可以把 MOVABLE 区的页搬走。对于驱动开发者来说如果确定自己的缓冲区不依赖物理固定地址用__GFP_MOVABLE能让系统更容易整理碎片。还有一招是从应用层配合很多内存压力的根源是用户态页缓存占用了大量物理内存通过调整vm.vfs_cache_pressure或者手动执行sync; echo 3 /proc/sys/vm/drop_caches可以让内核释放部分页缓存和 dentry/inode 缓存腾出连续内存。但这是临时手段频繁使用反而损害缓存命中率和系统性能。5.3 内核可调参数与 Slab 收缩机制除了手动干预内核还提供了一系列可调参数来预防内存分配失败。vm.min_free_kbytes指定每个 zone 必须保留的最小空闲内存量。调高它可以避免内存压力下系统进入 OOM 的窘境让紧急分配和回收流程有缓冲余地但调太高也会浪费内存因为这部分内存默认不参与普通分配。/proc/sys/vm/watermark_scale_factor控制各 zone 水位线之间的比例影响内存回收启动的时机。当min_free_kbytes很高时kswapd 会更早开始回收分配成功率自然上升。我一般建议服务器上把min_free_kbytes调到物理内存的 1% 左右太低会让内存分配频繁失败。Slab 层的收缩依赖注册 shrinker。比如某些驱动在自己的缓存里维护了大量对象系统内存紧张时回收代码会调用这些 shrinker把空闲对象释放回伙伴系统。如果驱动开发时没注册 shrinker就算你在kmem_cache里囤了几十万个对象在系统内存不足时也不肯归还最终内核只能 OOM。注册 Shrinker 的代码不复杂核心是提供count_objects和scan_objects两个回调前者报告可回收对象数量后者执行实际回收动作。在我维护的某驱动里缓存对象大约占总内存 200MB注册 shrinker 后系统内存压力测试从频繁 OOM 变成平稳运行。5.4 常见问题速查表我在论坛和实际工作中经常看到类似的提问这里把典型问题整理成一张速查表现象可能原因优先排查方向kmalloc偶尔返回 NULL内存碎片化严重order 0 也不足查看/proc/buddyinfo和meminfo中断上下文 panic in scheduling用了GFP_KERNEL改为GFP_ATOMIC或使用预分配池驱动卸载后系统异常释放接口不匹配或双重释放检查分配与释放接口是否成对dma_alloc_coherent失败连续物理内存不足考虑 SG DMA 或调整min_free_kbytesslab 缓存无限增长对象没有正确释放用slabtop定位缓存再审查代码内核模块内存占用异常高vmalloc 使用过多检查是否本可用kmalloc改成 per-cpu 或池化这张表只覆盖了常见情况实际开发中遇到的内存问题往往叠加了多个因素排查时不要只盯一处。比如分配失败既可能是因为碎片也可能是因为 cgroup 内存限制还可能是系统真的进入 OOM 边缘。别急着改代码先收集现场信息。我在实际工作中还养成了一个习惯在内存分配失败路径里打印alloc_pages失败时的 order、GFP 标志、CPU 编号和当前的zoneinfo。这段额外日志在故障定位时价值巨大因为它能告诉你分配器到底在哪个 zone、哪个水位、因为什么原因拒绝了请求。很多看似“灵异”的内存问题其实就是缺这些细节导致的盲猜。如果你正在做一个长期维护的内核模块我给的最实际的建议是开头尽早确认上下文是否可睡眠明确内存生命周期归属数据路径优先用池化分配最后给系统留一点紧急水位线。这几条实践守则听起来平淡但它们真的是我在大量内核内存问题排查中沉淀下来的经验。