☰
Linux内核内存分配实战:kmalloc、GFP与伙伴系统
2026/10/10 11:51:27 网站建设 项目流程

做内核开发的人,迟早都要跟内存分配打交道。不管是写字符设备驱动、搭网络协议栈的收包路径,还是给硬件维护一块DMA缓冲区,最后都会落到一行kmalloc或者alloc_pages上。可很多人在用户态玩惯了malloc,一进内核就懵:为什么内存分配还要带这么多标志位?为什么中断上下文不能随便分配?为什么内存明明还有几十MB,kmalloc却返回失败?这篇就按我自己的理解,把 Linux 内核内存分配从头到尾捋一遍,给刚入门或者已经踩过坑的同学一份能直接对照实践的指南。

1. 先把内核内存这件事的底摸清

1.1 物理内存、虚拟内存和连续性的认知基础

内核里分配内存,第一件事是要分清楚你拿到的到底是什么。绝大多数情况下,进程看到的内存地址都是虚拟地址,背后由 MMU 做映射。内核空间也是一样:你在模块里通过kmalloc拿到的指针,同样是一个虚拟地址,它对应的物理页不一定和你直觉里想的一样。

这里有个核心概念必须刻在脑子里:连续性。

kmalloc返回的虚拟地址,在物理上也是连续的。这意味着你可以拿它去做 DMA,可以直接把这一整块内存交给硬件。而vmalloc返回的地址,只是虚拟地址连续,底层物理页是分散的,不能用于需要物理连续的场景。用生活里的例子说,kmalloc相当于整租了一套三居室,三个房间挨在一起;vmalloc相当于你在同一栋楼的不同楼层各租了一个单间,门牌号顺着看是连续的,但房子彼此不挨着。

为什么要反复强调物理连续性?因为硬件 DMA 引擎在搬运数据时,只知道物理地址,它不认 MMU 页表。CPU 可以通过页表把零散物理页拼成连续的虚拟地址,但大多数 DMA 控制器没有这本事。所以只要涉及 DMA,你就得找物理连续的内存。

另外,内核里说的“内存”默认是指物理页。每个物理页对应一个struct page结构,整个物理内存在系统启动时就被归纳成一张大表。这张表是所有内存分配操作的总台账。你每次分配内存,本质上是让分配器在这张表里为你划出若干标记为“已用”的物理页。

1.2 page、order、zone:Buddy System 的基础词表

内核用伙伴系统(Buddy System)管理物理页。它的核心思路简单粗暴:把所有空闲物理页按 2 的幂次分组,也就是 order。order 0 是 1 个页,order 1 是 2 个页,order 2 是 4 个页,以此类推。分配 3 个连续页时,伙伴系统会从 order 2(4 页)里切给你,剩下 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或者直接改用 SG(Scatter-Gather)DMA,让硬件自己支持分散缓冲区。很多现代 DMA 控制器都支持 SG 模式,可以用一个描述符数组把不连续的页串起来,这样软件逻辑也不用硬凑物理连续性。

3. GFP 标志位就是分配方案的灵魂

3.1 最常用的 GFP 组合:KERNEL、ATOMIC 和 NOWAIT

GFP(Get 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_kbytes
slab 缓存无限增长对象没有正确释放用slabtop定位缓存再审查代码
内核模块内存占用异常高vmalloc 使用过多检查是否本可用kmalloc,改成 per-cpu 或池化

这张表只覆盖了常见情况,实际开发中遇到的内存问题往往叠加了多个因素,排查时不要只盯一处。比如分配失败既可能是因为碎片,也可能是因为 cgroup 内存限制,还可能是系统真的进入 OOM 边缘。别急着改代码,先收集现场信息。

我在实际工作中还养成了一个习惯:在内存分配失败路径里打印alloc_pages失败时的 order、GFP 标志、CPU 编号和当前的zoneinfo。这段额外日志在故障定位时价值巨大,因为它能告诉你分配器到底在哪个 zone、哪个水位、因为什么原因拒绝了请求。很多看似“灵异”的内存问题,其实就是缺这些细节导致的盲猜。

如果你正在做一个长期维护的内核模块,我给的最实际的建议是:开头尽早确认上下文是否可睡眠,明确内存生命周期归属,数据路径优先用池化分配,最后给系统留一点紧急水位线。这几条实践守则听起来平淡,但它们真的是我在大量内核内存问题排查中沉淀下来的经验。

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

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

立即咨询