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 位配置下,档位大致是这样排的:
| 档位序号 | 对象大小(字节) | 典型用途 |
|---|---|---|
| 0 | 8 | 极小结构体、指针数组 |
| 1 | 16 | 小型描述符 |
| 2 | 32 | 常见小对象 |
| 3 | 64 | 中等小对象 |
| 4 | 96 | 非 2 的幂档位 |
| 5 | 128 | 常用档位 |
| 6 | 192 | 非 2 的幂档位 |
| 7 | 256 | 常用档位 |
| 8 | 512 | 中等对象 |
| 9 | 1024 | 较大对象 |
| 10 | 2048 | 大对象 |
| 11 | 4096 | 接近页大小 |
| 12 | 8192 | 超过单页 |
这里有个细节值得说:档位里混进了 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 位系统上可能是 8192,64 位可能更大。写代码时如果依赖这个边界,一定要用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,看看有没有意外的缓存增长。这个动作花不了几秒,但经常能在早期发现苗头。内存问题越早发现越好查,等到线上跑出问题时,现场往往已经很难复现了。