写 Linux 内核代码尤其是驱动和内核模块几乎绕不开一对“双胞胎”kmalloc 和 vmalloc。名字就差一个字母底层机制却是两套完全不同的世界而它们最终又都依赖同一个总仓库——页分配器也就是我们常说的“伙伴系统”。我在刚接触内核开发时曾想当然地在驱动里用 kmalloc 分配一个几 MB 的缓冲区结果返回 NULL。当时很困惑内存明明还剩很多怎么会失败后来才明白kmalloc 要的是物理连续在内存碎片化的情况下几 MB 的连续物理块并不保证存在。这篇文章就把三件事彻底讲透页分配器如何管理物理内存kmalloc 怎么从 slab 缓存里抠小块内存vmalloc 又是如何绕开物理连续性限制的。内容会尽量落到工程实操层面适合正在写驱动、搞内核模块或者单纯想弄懂内核内存子系统的朋友。读完你至少能自己判断这块内存到底该跟谁要、为什么这么选、出了问题怎么查。1. 三套分配机制的分工与整体脉络1.1 三个接口各自解决什么问题很多人误以为 kmalloc、vmalloc 和页分配器是三个并列的函数其实它们处在不同层级。打个比方页分配器是整个系统的内存仓库里面整整齐齐存放着物理页kmalloc 和 vmalloc 像两个门店从仓库批量拿货后再加工给上层用户。仓库只管“物理页”这个级别的进销存门店各自有不同的分销策略。kmalloc 解决的是“小内存、物理连续”的问题。它最典型的应用场景是分配几百字节到几 KB 的内核对象比如文件描述符、socket 缓冲、设备驱动里的数据结构。由于它返回的虚拟地址落在直接映射区虚拟连续的同时物理也连续硬件可以直接通过物理地址访问这块内存所以 DMA 场景首选它。vmalloc 解决的是“大内存、物理可以不连续”的问题。它从 vmalloc 区分配一段连续的虚拟地址再把分散的物理页通过页表逐页映射过去。对 CPU 来说使用起来和普通内存无异但物理地址不连续所以不能直接用来做 DMA。它适合分配几 MB 甚至更大的软件缓冲区或者在内核里创建需要大块连续虚拟地址空间的映射。页分配器则是所有内存的源头。它管理物理页的分配和回收无论是 kmalloc 还是 vmalloc最终都要从页分配器拿物理页。内核里还有一组直接面对页分配器的接口比如 alloc_page、alloc_pages、__get_free_pages这些算是更底层的“批发”入口。1.2 调用链上的分工最终都要从伙伴系统拿页把三个机制串起来看调用链就清晰了。kmalloc 申请内存时会先从 slab/slub 缓存里找空闲对象缓存不够了再一次性从页分配器申请一整块页切成多个同等大小的对象继续供应。所以 kmalloc 的“慢路径”最终会走到伙伴系统的 alloc_pages。vmalloc 的路径更直接它先在 vmalloc 区域划出一段虚拟地址然后一页一页地调用 alloc_page 从页分配器拿物理页再修改页表建立映射。释放时反向操作先卸载映射、归还物理页再释放虚拟地址区间。它每一次分配都伴随着多个页面操作开销明显高于 kmalloc。这里有个关键认知无论上层用哪个接口物理页的最终分配权都在伙伴系统手里。所以我们的讨论顺序按“底层向上层”展开先讲页分配器再讲 kmalloc 和 vmalloc这样理解起来最顺。2. 页分配器一切内存分配的“总仓库”2.1 伙伴系统以阶为单位的物理页管理伙伴系统Buddy Allocator是 Linux 物理内存分配的核心算法。它的设计目标是在分配和释放频繁交错的情况下仍然尽量维持物理内存的连续性同时把外部碎片控制在可接受范围。它的组织方式是把物理页按“阶”order分组。order 0 表示 1 个物理页通常 4KBorder 1 表示 2 个连续物理页8KBorder 2 表示 4 个连续页16KB以此类推order n 表示 2^n 个连续页。每个 order 对应一个空闲链表链表里的每一项都是该 size 的连续物理块。分配时如果刚好有空闲的对应 order 块直接取走如果没有就往更高 order 找找到后逐层拆分。比如要分配 order 24 页但 free_area[2] 是空的就从 free_area[3]8 页里取出一块拆成两个 4 页的块一个给请求者另一个挂回 free_area[2]。释放时反过来做“合并”如果释放的块和它的“伙伴块”恰好都在空闲列表中就把它们合并成高一个 order 的块继续向上尝试。这里的“伙伴”不是随意定义的。两个相邻的物理页块如果它们合并后能正好组成更高一阶的连续块它们才互为伙伴。判断方法很巧妙——给定一个物理页号 pfn它的伙伴页号就是pfn ^ (1 order)。这个异或操作在底层代码里反复出现。2.2 分配、释放与碎片问题在内核代码里使用页分配器最常用的方式有两种struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); void free_pages(struct page *page, unsigned int order); unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order); void free_pages(unsigned long addr, unsigned int order);前者返回 struct page 指针后者返回虚拟地址。前者更底层也更重要因为内核中有大量代码用 struct page 来跟踪物理页状态。伙伴系统虽然设计精妙但有一个天然敌人——外部碎片。系统运行一段时间后小块的不可移动内核对象散落在物理内存各处导致高阶的连续块很难凑出来。内核因此引入了迁移类型migrate type机制把空闲页按用途标记为不可移动UNMOVABLE、可回收RECLAIMABLE、可移动MOVABLE等类型分开放到各自的链表。可移动的页比如用户进程页可以被内存规整compaction搬到别处腾出连续空间这样大块连续内存的分配成功率显著提高。判断内存碎片状况最直观的方法是看/proc/buddyinfo。每一行对应一个 zone后面的数字代表各 order 空闲块的个数。如果你看到小 order 的数字很大、大 order 的数字几乎为 0那基本可以断定系统正处于碎片化状态。2.3 GFP 标志告诉分配器“你现在什么处境”向页分配器申请内存时不能只告诉它“我要多少”还得通过 gfp_mask 告诉它“我现在在什么环境下要”。这是内核内存管理最容易被初学者忽略的部分。GFP_KERNEL 是最常规的标志允许睡眠允许触发磁盘 IO 和文件系统操作来回收页面。GFP_ATOMIC 用于原子上下文中断处理、自旋锁保护区等分配过程不能睡眠只能动用一些紧急储备成功率比 GFP_KERNEL 低。还有 GFP_DMA / GFP_DMA32 表示需要从 DMA 可访问的 zone 分配用于硬件 DMA 场景。选择 GFP 标志的实质是告诉内存子系统的“回收器”能为你付出多大代价。如果你允许它睡眠、允许它做 IO它能回收的页面就多分配成功率自然高如果只能在原子上下文里挣扎那分配失败的概率就会上升。这也是为什么在中断里分配内存经常失败——不是内存真的没了而是“能用的手段太少了”。代码里见到的gfp_mask并不是单一标志而是一组位标志的组合。比如GFP_KERNEL实际上等价于__GFP_RECLAIM | __GFP_IO | __GFP_FS的组合它告诉分配器可以回收页面、可以做 IO、可以访问文件系统。理解这一点你就能根据场景自由组合出合适的标志而不是死记硬背那几个宏。3. kmalloc 深度解析物理连续的小块内存3.1 物理连续为什么那么重要kmalloc 的核心特征就一个词物理连续。这个特性在大多数软逻辑场景下看起来“没必要”但在硬件交互场景里是硬性要求。拿 DMA 来说设备访问内存用的是物理地址。如果驱动程序把一个缓冲区传给 DMA 控制器它必须保证这段缓冲区在物理地址空间上是连续的否则设备只能做“分散-聚集”传输这需要额外维护 SG 链表很多硬件甚至根本不支持。so 当你在驱动里准备一块数据缓冲区并打算交给硬件时不用犹豫直接考虑 kmalloc 或者更底层的页分配器。物理连续的另一个隐含好处是虚拟地址也连续而且落在内核直接映射区。直接映射区通常用 2MB 甚至 1GB 的大页映射物理内存访问这样的地址对 TLB 非常友好。再加上物理连续对 cache line 预取也友好。别小看这一点热路径上频繁访问的内存块物理连续与否的性能差距能拉出好几个百分点。3.2 slab/slub 缓存机制kmalloc 之所以能够快速分配小块内存是因为它背后站着 slab/slub 分配器。现在内核默认配置下通常是 SLUB但原理内核同源——预先从伙伴系统申请一大块页切成多个大小相同的对象缓存起来上层来申请时直接发一个现成的对象用完还回来继续循环使用。这个思路很像图书馆的预借书架不用每次想看书都跑去仓库翻箱倒柜。kmalloc 维护了一批大小固定的缓存比如 kmalloc-8、kmalloc-16、kmalloc-32、kmalloc-64一直到 kmalloc-8k 等。用户申请 300 字节时分配器会把它圆整到最接近的缓存档位比如 512然后从 kmalloc-512 里取一个对象。现代内核还引入了非 2 次幂的缓存比如 kmalloc-96、kmalloc-192用来降低圆整带来的内部碎片。这么做的动机很朴素很多内核对象的大小不是严格的 2 次幂硬圆整上去白白浪费内存。有了这些“非主流”档位实际内存利用率能提高几个百分点在内存吃紧的设备上意义很大。如果申请的大小超过了 KMALLOC_MAX_CACHE_SIZE通常是一页到几页kmalloc 就没法从 slab 缓存里满足了它会直接退化成一次页分配器的调用走kmalloc_large路径。也就是说kmalloc 并不是“永远快”只是在小块分配时快。3.3 kmalloc 的使用边界与性能特征kmalloc 能用到的场景有明确边界。首先是大小一般建议单次分配不要超过 8KB超过这个量就该考虑 vmalloc 或者直接走页分配器。其次是生命周期如果内存块只在内核态使用、不需要跨进程映射、不需要超大虚拟连续空间kmalloc 都是合理选择。原子上下文里也能用 kmalloc但必须使用GFP_ATOMICvoid *ptr kmalloc(256, GFP_ATOMIC); if (!ptr) { /* 处理分配失败 */ }这个小片段在中断处理、软中断、自旋锁保护区里很常见。不过要清醒GFP_ATOMIC 的成功率低于 GFP_KERNEL因为分配器能做的“挣扎”极其有限。写代码时尽量把内存分配移出原子区实在不行也要有失败兜底逻辑而不是无脑崩溃。kmalloc 的性能特征在小块分配上非常亮眼。per-CPU 的 freelist 让分配常常只是取一个指针再移动一下头部几乎不需要锁最坏也就是本地 CPU 的简单操作。实测下来一个 64 字节的 kmalloc 分配通常只需要几十到几百纳秒这个量级在所有内核内存分配接口里都是第一梯队。不过有一点要牢记kmalloc 返回的内存不会自动清零。需要清零时用 kzalloc参数完全一样。很多人一开始没注意拿到手的内存带着上一次使用者的残留数据调试半天才反应过来。4. vmalloc 深度解析虚拟连续的大块内存4.1 vmalloc 的设计动机vmalloc 存在的意义就是“让大块虚拟地址连续成为可能哪怕物理页是散的”。这个需求在软件层面非常常见比如内核需要一个大缓冲区做数据聚合或者要给某个子系统映射一块远超物理连续能力的大内存又或者模块加载器需要把内核模块的代码段和数据段映射到可执行的内存区域。使用 vmalloc 很简单void *buf vmalloc(4 * 1024 * 1024); if (!buf) { /* 分配失败 */ } vfree(buf);和 kmalloc 最大的区别在于vmalloc 分配的内存虚拟地址在 vmalloc 区物理页则可以从任意可用的地方凑。对 CPU 而言只要页表映射正确访问 vmalloc 内存和访问普通内存完全一样。但把它交给硬件做 DMA 就是灾难因为设备看到的物理地址根本不连续。内核里还有一个常用变体 vzalloc分配的同时清零。另外kvmalloc 这个混合接口会根据请求大小自动判断走 kmalloc 还是 vmalloc适合“大小不定但一般不大”的场景。它的实现很聪明先尝试 kmalloc如果失败就回退到 vmalloc上层不用关心最终是哪种分配方式。4.2 页表映射与 TLB 开销vmalloc 的代价在于页表操作。每分配一次都要在 vmalloc 区找到一段空闲虚拟地址涉及红黑树查找和维护再逐页建立 PTE 映射释放时还要反向解除映射。x86 上内核页表是所有进程共享的修改一个全局映射往往需要触发 TLB shootdown在多核系统上这个 IPI 开销会被明显放大。除了分配和释放慢vmalloc 还会增加 TLB 压力。直接映射区的内存通常由大页映射一条 TLB 条目能覆盖 2MB 甚至 1GB而 vmalloc 区基本是 4KB 小页映射访问同样大小的内存需要更多 TLB 条目。频繁访问 vmalloc 内存的代码TLB miss 率会肉眼可见地上升进而拖慢整体性能。我实测过一个场景某模块在大流量下频繁分配和释放几 MB 的 vmalloc 缓冲区系统的 softirq 耗时明显增加火焰图上 vmalloc 和 vfree 相关函数占据了大块比例。改成启动时预分配、运行时复用之后问题立刻消失。所以 vmalloc 适合低频、大块、生命周期较长的分配不适合放在热路径里高频折腾。4.3 使用限制与典型场景vmalloc 的第一条硬限制是不能用在原子上下文。因为分配过程中可能要睡眠等待页面回收你在自旋锁里或者中断里调用 vmalloc内核会直接报 scheduling while atomic 的错。这一点和 kmalloc(GFP_ATOMIC) 形成了鲜明对比。第二条限制是前面提到的 DMA 问题。vmalloc 内存物理不连续不能直接拿给 DMA 设备。有些驱动工程师图省事用 vmalloc 分配缓冲区后再传给 DMA API结果数据错乱或者根本传不出去这时候排查了半天原因往往最后发现是分配方式选错了。第三条限制是虚拟地址空间本身的稀缺性。虽然 64 位内核的 vmalloc 区看起来很大但它要服务模块映射、各种子系统的大块映射请求加上每次分配还有对齐和元数据开销滥用 vmalloc 依然会吃紧。系统里可以通过/proc/vmallocinfo查看都有谁在占用 vmalloc 空间排查泄漏时这个文件是头号参考。典型场景其实很明确大块软件缓冲区比如几 MB 的数据聚合区、内核模块加载代码段映射、以及某些网络栈里的数据缓冲。做这些场景时vmalloc 是很合理的选择做小对象和热路径分配时尽量绕开它。5. 工程上怎么选一个可落地的决策路径5.1 决策逻辑从大小、上下文、硬件需求倒推工程上选内存分配接口不需要背很多规则按下面这个顺序问自己三个问题就行。第一这个内存块多大小于等于一页到几页先考虑 kmalloc超过这个量思考一下是否真的需要物理连续。第二硬件要不要直接访问它如果要做 DMA物理连续是硬指标kmalloc 配合 GFP_DMA / GFP_DMA32 / CMA 才是正路不做 DMA 的话vmalloc 完全够用。第三当前在什么上下文原子上下文只能用 kmalloc(GFP_ATOMIC)其他情况下才考虑 vmalloc。如果还是犹豫还有两个辅助判断一是生命周期长期存活且容量大的缓冲区优先考虑在初始化阶段一次性分配而不是频繁分配释放二是访问频率热路径上宁可多做几次 kmalloc 小对象缓存也不要放一个慢吞吞的 vmalloc 调用。5.2 性能对比与实测感受拿一次实际对比来说。分配 64 字节内存kmalloc 在缓存命中时通常几百纳秒以内运气好甚至几十纳秒vmalloc 绝对不做这种小分配因为光是找虚拟地址区间和管理页表的开销就远大于收益。分配 4MB 内存kmalloc 基本很难成功除非内存刚开机特别空闲而 vmalloc 乘着空闲页面多时也就几十微秒量级但注意这“几十微秒”在热路径上是不可接受的。从空间利用率看kmalloc 有 slab 对象圆整导致的内部碎片vmalloc 有页粒度和 vmap_area 元数据的开销。小块分配 kmalloc 更省大块分配 vmalloc 更灵活。从 TLB 角度kmalloc 在直接映射区大页映射友好vmalloc 是小页映射TLB 压力大。5.3 典型场景对照表场景推荐接口理由DMA 缓冲区几 KB 以内kmalloc GFP_DMA/DMA32物理连续硬件可直接访问内核数据结构小对象kmalloc/kzalloc快缓存友好大块软件缓冲区几 MBvmalloc/vzalloc不要求物理连续灵活原子上下文小分配kmalloc(GFP_ATOMIC)不睡眠安全频繁创建销毁的大缓存预分配 复用 / pool避免 vmalloc 高频开销同一类对象频繁分配kmem_cache_create 专用缓存减少碎片分配更快大小不确定但期望尽量简单kvmalloc自动回退 vmalloc6. 踩坑实录与排查技巧6.1 常见问题速查表内存分配问题在运行期非常隐蔽症状五花八门根因却常常集中在几个固定模式上。我把这些年遇到的典型问题整理成了一个速查表排查时直接对着看速度会快很多。现象根因分析解决思路kmalloc 返回 NULL 但 free 内存充足物理碎片化连续块不足看 /proc/buddyinfo考虑 vmalloc 或内存规整vmalloc 在中断/自旋锁中报 scheduling while atomic原子上下文调用睡眠分配改用 kmalloc(GFP_ATOMIC)DMA 数据错乱或硬件无法访问缓冲区vmalloc 物理地址不连续改用 kmalloc / DMA pool / SG 列表vmalloc 高频分配导致性能劣化页表操作 TLB shootdown初始化时分配运行期复用slab 内存只增不减有对象泄漏或长期占用缓存查 /proc/slabinfoKASAN 辅助定位SLUB 报 Redzone overwritten越界写常见于缓存溢出开 slub_debug / KASAN 定位vmalloc 区空间被耗尽vmalloc 泄漏或大量映射未释放查 /proc/vmallocinfo 找调用者6.2 排查工具与个人习惯排查内存问题我个人的习惯是“先从系统视图入手再落到具体调用点”。启动后第一步打开 /proc/buddyinfo看碎片化程度然后看 /proc/meminfo 里的 Slab、VmallocUsed、PageTables 等字段确认嫌疑方向再针对性看 /proc/slabinfo 或 /proc/vmallocinfo找到具体的消耗者。这套三板斧基本能定位八成问题。剩下两成属于越界访问这类“隐蔽作案”这类问题就要靠内核自带的调试设施了。编译内核时开启 KASAN可以把堆越界、栈越界、野指针访问在发生时立刻拦住并打印出调用栈。在 SLUB 上开 slub_debug会给每个对象填充警戒区域redzone检测是否被改写。这些调试手段开启后性能会有损耗但排查疑难杂症时值得。举个例子我之前处理过一个驱动长时间运行后随机崩溃的问题现场没有任何规律。开了 slub_debug 后很快就抓到一次越界写驱动往一个 sizeof(struct xxx) 大小的对象里复制了更多数据直接把相邻对象的内存冲掉了。这种问题如果靠肉眼看代码可能得熬好几个通宵。最后分享一个我一直在用的判断准则一段分配逻辑如果连“敢不敢在中断里调用”都回答不了那它很可能存在问题。先把大小、连续性要求、上下文环境想清楚再选接口这一步做对了后续能省掉大量莫名其妙的调试时间。内存管理这种底层子系统尊重它的规则它就会尊重你的代码。