深入解析Linux内核kmalloc_cache:二维缓存矩阵与SLUB分配机制
发布时间:2026/10/22 21:38:59 锦皓数字建站

1. 从一次内存泄漏排查说起为什么需要关注 kmalloc_cache前阵子帮朋友看一个内核模块的问题现象很典型模块加载后跑一段时间/proc/meminfo里的Slab项持续上涨卸载模块后也不见回落。用slabtop一看某个kmalloc-192的缓存对象数量只增不减。顺着这条线索往下挖最后定位到模块里一处kmalloc之后忘了kfree的分支。问题本身不复杂但排查过程中我重新把kmalloc_cache这套结构翻了一遍发现很多平时写代码时想当然的地方其实背后都有讲究。这篇就接着上一部分 SLUB 分配器的内容专门聊kmalloc_cache这个结构。如果你写过内核模块、调过驱动、或者单纯对 SLUB 怎么管理通用内存分配感兴趣这篇应该能帮你把kmalloc到底从哪儿拿内存这件事彻底理清楚。我会从kmalloc_cache的组织方式讲起拆开kmalloc_caches这个二维数组说清楚 size index 和 type 是怎么映射到具体缓存的再结合kmalloc的调用链看看一次分配到底走了哪些路径。中间会穿插几个我自己踩过的坑比如kmalloc大小和实际缓存不匹配导致的浪费、GFP标志选错引发的连锁反应。先把结论摆前面kmalloc_cache不是一张简单的表而是一个按大小档位 × 分配类型组织的二维缓存矩阵SLUB 通过它把任意kmalloc请求快速路由到最合适的 slab 缓存上。理解了这个矩阵的构造逻辑很多关于内存开销、碎片、性能的疑问就都有答案了。2. kmalloc_caches 二维数组的构造逻辑2.1 为什么是二维而不是一维刚接触这块的时候我有个疑问既然kmalloc是按大小分配搞一个大小到缓存的一维数组不就行了为什么要弄成二维后来看代码才明白多出来的那一维是分配类型kmalloc_type。内核里kmalloc的调用场景差异很大有的是普通内核对象分配有的是 DMA 相关有的要求内存可回收reclaimable有的来自cgroup受限上下文。这些场景对内存的物理属性、回收策略、记账方式要求都不一样。如果所有请求都挤在同一个缓存里一个要求 DMA 能用的分配和一个普通分配混在一起物理页属性就没法保证。所以 SLUB 的做法是同一档大小按类型拆成多个独立的缓存。这样每个缓存内部的 slab 页属性一致分配时不用再做属性筛选。这就是二维结构的根本原因——第一维是大小档位第二维是分配类型。用生活化的类比这就像仓库管理同样是小件区但易碎品、危险品、普通货物要分开放因为它们的存储条件不同。你不能因为都是小件就堆一个货架上。2.2 大小档位的划分规则kmalloc支持的大小不是连续的而是一组离散档位。在常见的 64 位配置下档位大致是这样排的档位序号对象大小字节典型用途08极小结构体、指针数组116小型描述符232常见小对象364中等小对象496非 2 的幂档位5128常用档位6192非 2 的幂档位7256常用档位8512中等对象91024较大对象102048大对象114096接近页大小128192超过单页这里有个细节值得说档位里混进了 96 和 192 这种非 2 的幂。为什么因为实测发现内核里大量分配请求集中在这些尺寸附近如果只有 64 和 128那么一个 96 字节的请求会被塞进 128 的缓存浪费 32 字节而 192 的请求塞进 256 会浪费 64 字节。加上这两个档位后整体内存浪费明显下降。这是典型的用一点管理复杂度换实际内存效率的取舍。注意档位的具体数值和内核配置有关CONFIG_SLAB_MERGE_DEFAULT、架构位宽、是否开启CONFIG_RANDOM_KMALLOC_CACHES都会影响最终结果。看代码时不要死记数字要理解划分思路。2.3 类型维度的实际含义第二维的类型在代码里对应enum kmalloc_cache_type。常见的有这么几类KMALLOC_NORMAL普通分配最常用。KMALLOC_RECLAIM可回收分配内存紧张时可以被回收。KMALLOC_DMA要求物理地址连续且落在 DMA 可寻址范围。KMALLOC_CGROUP带 cgroup 内存记账的分配。每种类型对应一组独立的缓存。也就是说kmalloc-128这个名字其实对应好几个实际缓存分别服务于不同场景。你在slabtop里看到的kmalloc-128往往只是其中一类其他类型可能显示为带后缀的名字。这里有个容易踩的坑类型选错不会报错但会带来隐性代价。比如你本可以用KMALLOC_NORMAL却因为习惯写了GFP_DMA那分配就会落到 DMA 类型的缓存里。DMA 缓存为了保证可寻址往往预留更保守长期跑下来内存占用会比普通缓存高。我见过一个驱动因为全程用GFP_DMA在非 DMA 场景下白白多占了几十 MB。3. size index 与 type 如何映射到具体缓存3.1 从请求大小到 index 的换算当你调用kmalloc(size, flags)时第一步是把size换算成档位序号。这个换算不是简单的log2因为存在 96、192 这种非 2 的幂档位。内核用的是查表加位运算结合的方式。大致逻辑是先判断size落在哪个区间再用预计算的映射表拿到 index。对于 2 的幂档位可以用fls找最高有效位快速定位对于 96、192 这种需要额外判断。整个换算过程被设计得尽量短因为kmalloc是热路径每次分配都要走一遍。我实测过一个细节在 x86_64 上kmalloc(100, GFP_KERNEL)会落到 128 档位而不是 96。因为 100 大于 96只能往上取到 128。所以如果你对内存敏感把结构体大小压到刚好不超过某个档位能省下可观的浪费。比如把 100 字节的结构体优化到 96 字节以内每个对象就省 32 字节十万个对象就是 3 MB 多。3.2 type 的判定时机类型不是从size推出来的而是从flags里解析出来的。kmalloc内部会检查flags中的__GFP_DMA、__GFP_RECLAIMABLE等位决定用哪个 type。这里有个顺序问题类型判定在大小换算之前还是之后实际代码里两者是耦合的因为最终的缓存指针要通过kmalloc_caches[type][index]取。所以流程是先解析 flags 得到 type再把 size 换算成 index最后查二维数组。伪代码大概长这样static __always_inline struct kmem_cache * kmalloc_slab(size_t size, gfp_t flags) { unsigned int index; enum kmalloc_cache_type type; if (size 8) index 0; else if (size 16) index 1; /* ... 中间省略 ... */ else index 12; type kmalloc_type(flags, _RET_IP_); return kmalloc_caches[type][index]; }真实代码比这复杂有KMALLOC_MIN_SIZE处理、有CONFIG_RANDOM_KMALLOC_CACHES的随机化但主干逻辑就是这样。3.3 缓存指针的缓存机制每次kmalloc都去查二维数组虽然数组在内存里访问很快但仍然是热路径上的开销。所以内核做了一层优化把常用的缓存指针直接内联到调用点附近或者用__builtin_constant_p在编译期就把 size 是常量的情况算好。这就是为什么kmalloc经常被写成宏或者__always_inline函数——编译器看到kmalloc(64, GFP_KERNEL)这种常量参数能直接把 index 和 type 都算出来最终变成一次数组索引加一次 slab 分配调用。如果你传的是变量 size那就只能运行时算。实操建议在性能敏感的代码里尽量让kmalloc的 size 是编译期常量。我做过对比测试常量 size 的分配路径比变量 size 短一截在高频分配场景下差距能到百分之几。4. kmalloc 调用链上的关键分支4.1 小对象走 slab大对象走页分配kmalloc并不是所有请求都走kmalloc_cache。当 size 超过最大档位通常是 8192 或更大取决于配置时会直接走页分配器绕过 slab。这个分界点很关键。为什么要有这个分界因为 slab 的价值在于把小对象打包进页里减少碎片和管理开销。当对象本身就接近或超过一页时打包没意义了直接分配页更高效。所以kmalloc内部有个判断size 大于KMALLOC_MAX_CACHE_SIZE就走alloc_pages。这个阈值在不同架构上不一样。32 位系统上可能是 819264 位可能更大。写代码时如果依赖这个边界一定要用KMALLOC_MAX_CACHE_SIZE宏而不是硬编码数字。4.2 分配失败时的重试与降级kmalloc在缓存里拿不到对象时会触发 slab 的扩容向页分配器要新页切成对象挂到 freelist 上。这个过程涉及GFP标志的传递。这里有个我踩过的坑GFP_KERNEL在原子上下文里用会出问题。因为GFP_KERNEL允许睡眠等待内存而原子上下文比如中断处理、持有自旋锁时不能睡眠。如果你在中断里写了kmalloc(size, GFP_KERNEL)轻则警告重则死锁。正确做法是用GFP_ATOMIC它不允许睡眠拿不到就返回 NULL。但GFP_ATOMIC也不是随便用的。它成功率高但代价是可能动用紧急内存池长期大量使用会耗尽预留内存。所以原则是能睡眠的上下文用GFP_KERNEL不能睡眠的才用GFP_ATOMIC且要处理 NULL 返回。4.3 与 per-CPU 缓存的交互SLUB 的高性能很大程度来自 per-CPU 缓存。每个 CPU 有自己的 freelist分配时优先从本地拿避免锁竞争。kmalloc_cache对应的每个 slab 缓存都有自己的 per-CPU 结构。这意味着同一个kmalloc请求在不同 CPU 上可能拿到不同 slab 页里的对象。这对大多数场景没影响但如果你依赖对象地址的某种局部性比如假设连续分配的对象在内存上相邻就会失望。我见过有人写代码假设两次kmalloc的地址差是固定值结果在多核机器上跑出各种诡异问题。正确的做法是不要对kmalloc返回的地址做任何跨调用的连续性假设。需要连续内存就用kmalloc一次分配一整块或者用vmalloc。5. 几个容易混淆的相邻概念5.1 kmalloc_cache 与 kmem_cache 的关系初学者容易把这两个搞混。kmem_cache是 SLUB 里通用的缓存结构任何自定义缓存都是kmem_cache。而kmalloc_cache是专门为kmalloc服务的那批缓存它们是kmem_cache的特例。关系上kmalloc_caches[type][index]里存的就是struct kmem_cache *。也就是说kmalloc_cache本质上是预创建好的一批kmem_cache按大小和类型组织成矩阵。你自己调kmem_cache_create创建的缓存和kmalloc用的缓存在 SLUB 内部是同一套机制管理的。理解这一点后很多行为就通了kmalloc的缓存也会出现在/proc/slabinfo里也会被slabtop统计因为它们就是普通的 slab 缓存。5.2 kmalloc 与 kzalloc、kcalloc 的区别这三个经常一起出现区别在初始化kmalloc只分配不初始化内存里是脏数据。kzalloc分配并清零。kcalloc分配数组并清零内部会做乘法溢出检查。从kmalloc_cache的角度看它们走的是同一套缓存区别只在分配后是否memset。所以kzalloc的额外开销就是一次清零。对于大对象清零成本不可忽略对于小对象现代 CPU 的memset很快通常不是瓶颈。经验如果对象马上会被完整覆盖用kmalloc就行别习惯性写kzalloc。我在一个高频路径上把kzalloc改成kmalloc省下的清零时间在压测里能看出来。5.3 与 vmalloc 的边界kmalloc保证物理连续vmalloc只保证虚拟连续。kmalloc_cache管的是前者。当你要的 size 很大、物理连续内存难找时kmalloc会失败这时候才考虑vmalloc。但vmalloc有代价需要建立页表映射TLB 压力大访问速度可能不如kmalloc的内存。所以原则是能用kmalloc就用kmalloc实在要不到连续物理页再用vmalloc。不要因为vmalloc能分配更大就无脑选它。6. 排查与观测怎么看清 kmalloc_cache 的实际状态6.1 用 slabinfo 看缓存分布/proc/slabinfo是最直接的窗口。里面每一行是一个缓存字段包括对象大小、活跃对象数、总对象数等。关注kmalloc-*开头的行能看出各档位的使用情况。我排查内存泄漏时的套路是隔一段时间抓两次slabinfo对比active_objs的变化。如果某个kmalloc-*的活跃对象持续上涨且不回落基本就是泄漏。再结合代码里该档位对应的分配点范围就缩小了。6.2 slabtop 的实时视角slabtop是slabinfo的动态版按对象数或内存占用排序。它的好处是能一眼看到谁在吃内存。我一般按Slab大小排序看排前面的几个缓存是不是符合预期。有个细节slabtop显示的kmalloc-*可能不包含所有类型。因为类型维度的缓存名字可能带后缀或者被合并显示。要精确统计还是得看slabinfo原始数据。6.3 用 tracepoint 追踪单次分配如果泄漏点很隐蔽静态统计不够用可以上 tracepoint。kmalloc相关的 tracepoint 能记录每次分配的调用地址、size、返回指针。配合perf或ftrace能精确定位到是哪行代码在反复分配。这个手段比较重一般只在前面几步定位不到时才用。但它确实有效——我有个同事就是靠 tracepoint 抓到一个在错误分支里反复kmalloc却没释放的 bug。7. 写代码时的几条实用原则7.1 让 size 贴近档位上限前面提过kmalloc按档位分配实际占用是档位大小而非请求大小。所以结构体设计时尽量让sizeof落在档位内且接近上限。比如你的结构体是 130 字节会落到 192 档位浪费 62 字节如果能压到 128 以内就省下 64 字节。压缩手段包括调整字段顺序减少 padding、用位域、把不常用的字段拆出去单独分配。这些优化在对象数量大时收益明显。7.2 flags 要精确不要图省事全用GFP_KERNEL或全用GFP_ATOMIC。根据上下文选上下文推荐 flags说明进程上下文可睡眠GFP_KERNEL最常用中断/软中断GFP_ATOMIC不可睡眠持有自旋锁GFP_ATOMIC不可睡眠DMA 场景GFP_KERNEL | GFP_DMA按需加 DMA可回收对象GFP_KERNEL | __GFP_RECLAIMABLE内存紧张时可回收选错 flags 的代价前面说过要么死锁要么浪费内存要么记账错误。7.3 释放要及时且路径唯一kmalloc的配对kfree一定要在所有路径上都覆盖。我见过太多 bug 是正常路径释放了错误路径忘了。建议用goto统一错误处理或者用__free之类的清理属性新内核支持减少遗漏。小技巧如果编译器支持__attribute__((cleanup))可以封装一个自动释放的包装让kfree在作用域结束时自动执行。这在复杂函数里能显著降低泄漏风险。7.4 别对地址做连续性假设前面强调过per-CPU 缓存让分配地址不可预测。任何依赖两次分配相邻的代码都是脆弱的。需要连续内存就一次分配够或者用专门的数据结构。8. 一个完整的观测实例假设你怀疑某个模块有kmalloc泄漏可以按这个流程走加载模块记录初始slabinfo中相关kmalloc-*的active_objs。跑业务逻辑触发模块的主要路径。再次抓取slabinfo对比active_objs。如果某档位持续上涨用slabtop确认再用 tracepoint 定位具体调用点。找到分配点后检查所有分支的释放路径。这个流程我在多个项目里用过基本能覆盖大部分kmalloc相关问题。关键是要有基线对比的意识单次快照说明不了问题。9. 关于 kmalloc_cache 的一点个人体会把kmalloc_cache这套结构吃透之后我写内核代码时对内存的手感明显好了。以前kmalloc就是随手一写现在会下意识想这个 size 会落到哪个档位flags 选对了吗释放路径全不全这些思考不费多少时间但能避免很多后期排查的麻烦。SLUB 的设计哲学其实很朴素用结构化的缓存矩阵把千变万化的分配请求收敛到有限的管理单元上。kmalloc_cache就是这个哲学的集中体现。理解了它再去看kmem_cache_create、kmem_cache_alloc这些接口会发现它们和kmalloc是同一套逻辑的不同入口。最后分享一个我常用的检查习惯每次写完涉及kmalloc的模块加载后先跑一遍slabtop看看有没有意外的缓存增长。这个动作花不了几秒但经常能在早期发现苗头。内存问题越早发现越好查等到线上跑出问题时现场往往已经很难复现了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。