☰
深入解析Linux内核kmalloc_cache:二维缓存矩阵与SLUB分配机制
2026/10/22 21:38:21 网站建设 项目流程

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 位系统上可能是 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泄漏,可以按这个流程走:

  1. 加载模块,记录初始slabinfo中相关kmalloc-*的active_objs。
  2. 跑业务逻辑,触发模块的主要路径。
  3. 再次抓取slabinfo,对比active_objs。
  4. 如果某档位持续上涨,用slabtop确认,再用 tracepoint 定位具体调用点。
  5. 找到分配点后,检查所有分支的释放路径。

这个流程我在多个项目里用过,基本能覆盖大部分kmalloc相关问题。关键是要有"基线对比"的意识,单次快照说明不了问题。

9. 关于 kmalloc_cache 的一点个人体会

把kmalloc_cache这套结构吃透之后,我写内核代码时对内存的"手感"明显好了。以前kmalloc就是随手一写,现在会下意识想:这个 size 会落到哪个档位?flags 选对了吗?释放路径全不全?这些思考不费多少时间,但能避免很多后期排查的麻烦。

SLUB 的设计哲学其实很朴素:用结构化的缓存矩阵,把千变万化的分配请求收敛到有限的管理单元上。kmalloc_cache就是这个哲学的集中体现。理解了它,再去看kmem_cache_create、kmem_cache_alloc这些接口,会发现它们和kmalloc是同一套逻辑的不同入口。

最后分享一个我常用的检查习惯:每次写完涉及kmalloc的模块,加载后先跑一遍slabtop,看看有没有意外的缓存增长。这个动作花不了几秒,但经常能在早期发现苗头。内存问题越早发现越好查,等到线上跑出问题时,现场往往已经很难复现了。

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

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

立即咨询