1. 从一次内存泄漏排查说起
前阵子帮一个朋友看他们服务进程的内存增长问题,/proc/meminfo里Slab那一栏涨得离谱,但业务侧的对象数量明明没有明显增加。用slabtop一拉,发现kmalloc-256这个缓存条目数量异常高,而且只增不减。当时第一反应是某个驱动或者内核模块在频繁申请小块内存却没有释放,但具体是哪个调用路径,一时半会儿定位不到。后来顺着kmalloc_caches这个全局数组一路摸下去,才把kmalloc_cache的结构和它背后的设计逻辑理清楚。
这篇文章就围绕kmalloc_cache这个结构展开,聊清楚它到底是什么、为什么 SLUB 分配器要单独为它设计一套结构、它和普通kmem_cache之间是什么关系,以及在实际排查内存问题时怎么利用这些结构信息快速定位。内容偏向内核内存管理的实操理解,适合已经对 SLUB 有基本概念、想进一步搞清楚kmalloc背后机制的读者。如果你只是想知道“怎么申请内存”,那kmalloc的 API 文档就够了;但如果你想在出问题时能看懂slabtop、/proc/slabinfo里的数据,或者想自己写一个内核模块观察分配行为,那这些结构细节是绕不开的。
2. kmalloc_cache 结构的设计动机与整体思路
2.1 为什么 kmalloc 需要独立的 cache 体系
内核里kmalloc是最常用的内存分配接口之一,它和kmem_cache_alloc最大的区别在于:调用者不需要自己创建缓存,直接给一个大小就能拿到内存。这个“直接给大小”的能力,背后必须有一套预置的、按尺寸分档的缓存体系来支撑。如果每次kmalloc都临时创建一个kmem_cache,那创建和销毁缓存本身的开销会远远超过分配内存的开销,完全不可接受。
所以 SLUB 的做法是:在系统初始化阶段,预先创建一批覆盖常见尺寸的缓存,每个缓存负责一个尺寸档位。kmalloc拿到请求大小后,向上取整到最近的档位,然后从对应缓存里分配。这套预置缓存就是通过kmalloc_caches这个二维数组来管理的,而数组里每个元素就是一个kmalloc_cache结构。
这里有个容易混淆的点:kmalloc_cache并不是一个和kmem_cache并列的独立类型,它本质上是对kmem_cache的封装。在较新的内核版本里,kmalloc_cache结构体内部嵌了一个kmem_cache成员,同时附加了一些kmalloc专属的元信息。这样设计的好处是,kmalloc路径可以复用kmem_cache的全部分配逻辑,同时又能携带自己需要的额外状态。
2.2 尺寸分档策略与两套 cache 数组
SLUB 把kmalloc的尺寸档位分成两套:一套是常规尺寸,一套是 DMA 专用尺寸。常规尺寸从 8 字节起步,按 8、16、32、64、96、128、192、256……一直往上,直到KMALLOC_MAX_CACHE_SIZE。注意这里并不是严格的 2 的幂次序列,中间插入了 96 和 192 这两个非幂次档位,原因是实际内核代码里对这两个尺寸的请求量很大,单独设档可以减少内部碎片。
DMA 专用那套则是给需要 DMA 一致性内存的设备用的,尺寸档位和常规套类似,但底层页分配走的是 DMA 区域。两套数组在kmalloc_caches里按enum kmalloc_cache_type索引区分,常见的就是KMALLOC_NORMAL和KMALLOC_DMA两类。
为什么要按类型分开而不是在分配时临时判断?因为 DMA 内存的物理页来源不同,如果混在一个缓存里,SLUB 的页管理逻辑会变得很复杂。分开之后,每个kmalloc_cache只管自己那一类页,per-CPU 缓存、partial 链表这些机制都能保持简洁。
2.3 kmalloc_cache 与 kmem_cache 的嵌套关系
理解这个嵌套关系是看懂后续代码的关键。可以这样类比:kmem_cache像是一个通用的“仓库”模板,定义了怎么存、怎么取、怎么管理库存;kmalloc_cache则是在这个模板外面贴了一张标签,写明“这个仓库专门存放 256 字节以下的货物,且货物来自普通内存区域”。标签本身不改变仓库的运作方式,但让外部调用者能快速找到对应的仓库。
在实际代码里,kmalloc的快速路径大致是这样的:请求大小经过kmalloc_index换算成档位索引,然后通过kmalloc_slab拿到对应的kmem_cache指针,最后调用slab_alloc完成分配。整个过程中,kmalloc_cache结构主要起的是“索引载体”和“属性标记”的作用,真正干活的还是kmem_cache那一套。
注意:不同内核版本里
kmalloc_cache的字段命名和嵌套方式有差异。早期版本可能直接用kmem_cache数组,后来才引入kmalloc_cache包装。看代码时先确认版本,别拿旧结构的字段名去套新代码。
3. 核心字段拆解与实操观察要点
3.1 kmem_cache 内嵌成员的关键字段
既然kmalloc_cache的核心是内嵌的kmem_cache,那先看这个内嵌结构里哪些字段和kmalloc行为直接相关。第一个是size,表示这个缓存里每个对象实际占用的字节数,注意它不一定等于档位值,因为 SLUB 会做对齐和元数据填充。比如kmalloc-96这个缓存,size可能是 96,也可能因为对齐要求变成 128,具体取决于架构和配置。
第二个是object_size,这是调用者请求的原始大小上限。对kmalloc缓存来说,object_size通常就是档位值,而size是加上对齐和 red zone 等调试信息后的实际大小。排查内存浪费时,对比这两个值能看出内部碎片有多严重。
第三个是offset,表示对象内部空闲指针的位置。SLUB 把空闲对象串成链表时,需要在一个对象内部找个位置存下一个空闲对象的指针,offset就是干这个的。对kmalloc缓存来说,这个值通常是 0,也就是复用对象起始位置,因为kmalloc返回的内存本来就不要求保持内容。
第四个是cpu_slab,这是 per-CPU 缓存的指针。SLUB 的快速分配几乎都走 per-CPU 路径,每个 CPU 有自己的当前 slab 页和空闲对象列表。kmalloc高频分配时,这个字段的命中率直接决定性能。
3.2 kmalloc_cache 附加的专属信息
除了内嵌的kmem_cache,kmalloc_cache还会带一些kmalloc专属的标记。比如有的版本里会有一个type字段,记录这个缓存属于KMALLOC_NORMAL还是KMALLOC_DMA。这个信息在分配时用来决定从哪个数组取缓存,在释放时用来做校验。
还有的版本会记录这个缓存对应的档位索引,方便在kfree时快速反查。因为kfree拿到一个指针后,需要知道它属于哪个缓存才能正确释放。SLUB 的做法是通过页结构反查kmem_cache,但如果能直接通过指针地址范围或者附加信息快速定位,就能省掉一次查页操作。不过实际实现里,kfree主要还是靠virt_to_head_page拿到页,再从页的slab_cache字段取缓存,所以这个档位索引更多是辅助用途。
3.3 用 slabtop 观察 kmalloc 缓存的实际状态
理论说再多,不如实际看一眼。在终端里跑slabtop,按S键可以按缓存大小排序,你会看到一堆kmalloc-*条目。重点看三列:OBJS是当前活跃对象数,ACTIVE是活跃对象占比,SLABS是占用的 slab 页数。
如果某个kmalloc-256的OBJS很高但ACTIVE很低,说明大量对象被分配了但没释放,或者释放了但没回到 per-CPU 缓存之外。这时候可以结合/proc/slabinfo看更细的数据,比如slabdata里的num_objs和inuse。
我自己的习惯是先用slabtop -s c按缓存名排序,快速扫一遍所有kmalloc-*,看哪个尺寸档的SLABS数量异常。然后再用slabtop -s a按活跃对象数排序,定位具体是哪个缓存吃掉了内存。这两个视角结合,基本能锁定问题范围。
提示:
slabtop默认只显示前若干个缓存,如果目标缓存没出现,可以用-o输出到文件再全文搜索,或者直接读/proc/slabinfo用grep kmalloc过滤。
4. 从初始化到分配:kmalloc_cache 的完整实操链路
4.1 初始化阶段的缓存创建顺序
系统启动时,kmalloc_caches的初始化发生在 SLUB 自身初始化完成之后。顺序大致是:先建立kmem_cache的基础设施,包括kmem_cache_node和kmem_cache_cpu的初始化;然后遍历尺寸档位表,为每个档位创建对应的kmem_cache,并包装成kmalloc_cache填入数组。
这里有个细节:创建kmalloc缓存时,用的创建函数是create_kmalloc_cache而不是普通的kmem_cache_create。前者会在创建过程中设置一些kmalloc专属的标志,比如SLAB_KMALLOC,同时跳过一些对kmalloc不适用的初始化步骤。这个标志后续在kfree路径里会用来做快速判断。
另一个细节是 DMA 缓存的创建时机。常规缓存创建完之后,如果系统支持 DMA,会再创建一套 DMA 缓存。这两套缓存的尺寸档位表是共享的,只是类型不同。初始化时如果某个尺寸的 DMA 缓存创建失败,系统不会直接崩溃,而是把对应槽位置空,后续 DMA 分配请求会回退到常规缓存或者直接失败。
4.2 kmalloc 快速路径的代码走读
以一个 200 字节的分配请求为例,走一遍快速路径。第一步,kmalloc(200, GFP_KERNEL)进入__kmalloc,先判断大小是否超过KMALLOC_MAX_CACHE_SIZE。如果超过,直接走页分配器,不经过 SLUB。200 字节没超过,继续。
第二步,调用kmalloc_slab(200, flags)找缓存。这个函数内部会先算kmalloc_index(200),得到索引值。kmalloc_index的实现是一串if-else或者查表,把 200 映射到 256 档位。然后根据flags里的__GFP_DMA标志决定从KMALLOC_NORMAL还是KMALLOC_DMA取,最终返回kmalloc_caches[type][index]里的kmem_cache指针。
第三步,拿到kmem_cache后调用slab_alloc。slab_alloc先尝试从当前 CPU 的cpu_slab取空闲对象,如果freelist非空,直接弹出一个对象返回,这是最快路径。如果freelist为空,进入慢路径,从kmem_cache_node的 partial 链表取新 slab 页,或者向页分配器申请新页。
第四步,返回对象指针。调用者拿到的是对象起始地址,可以直接使用。整个过程中,kmalloc_cache结构本身没有被修改,它只是提供了索引和类型信息。
4.3 kfree 路径如何反查缓存
释放路径和分配路径不对称,因为kfree只拿到一个指针,没有大小信息。SLUB 的做法是:先通过virt_to_head_page把对象地址转成页结构,然后从页的slab_cache字段取出kmem_cache指针。这个字段在分配 slab 页时就设置好了,指向该页所属的缓存。
拿到kmem_cache后,kfree调用slab_free,把对象还回 per-CPU 缓存或者 partial 链表。如果 per-CPU 缓存里的空闲对象超过阈值,会触发 flush,把一部分对象还给 node 的 partial 链表,甚至释放整个 slab 页。
这里有个性能相关的点:kfree路径里有一次virt_to_head_page操作,涉及地址到页结构的转换。对高频释放场景,这个开销不可忽略。所以 SLUB 在 per-CPU 缓存里尽量批量处理,减少对页结构的访问次数。
注意:如果对象地址不是来自
kmalloc缓存,而是来自自定义kmem_cache,kfree同样能正确处理,因为页的slab_cache字段指向的是创建时传入的缓存。但混用kmalloc和kmem_cache_alloc的返回值去kfree是危险的,虽然底层机制能兜住,但语义上不应该这么做。
4.4 用 tracepoint 观察分配行为
如果想看某个kmalloc缓存的实际分配和释放频率,可以用内核的 tracepoint。kmem/kmalloc和kmem/kfree这两个 tracepoint 能记录每次调用的指针、大小和调用者地址。开启方式:
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmalloc/enable echo 1 > /sys/kernel/debug/tracing/events/kmem/kfree/enable cat /sys/kernel/debug/tracing/trace_pipe输出里会看到类似kmalloc: call_site=... ptr=... bytes_req=200 bytes_alloc=256的行。bytes_req是请求大小,bytes_alloc是实际分配大小,两者差值就是内部碎片。如果某个调用点频繁出现bytes_req远小于bytes_alloc的情况,说明尺寸档位选择不够优,可以考虑调整请求大小或者换用自定义缓存。
我实际用这个 tracepoint 排查过一个案例:某个网络驱动在收包路径里频繁kmalloc(1500),实际落到kmalloc-2048,每次浪费 500 多字节。后来改成用页片段缓存,内存占用直接降了三成。这种优化没有 tracepoint 很难发现,因为从代码上看kmalloc(1500)完全合理。
5. 常见问题与排查技巧实录
5.1 kmalloc 缓存内存只增不减怎么查
这是最常遇到的问题。现象是slabtop里某个kmalloc-*的SLABS持续增长,OBJS也跟着涨,但业务侧看不到对应的对象增长。排查思路分三步。
第一步,确认是泄漏还是缓存滞留。用slabtop观察一段时间,如果OBJS持续增长且不回落,基本是泄漏;如果OBJS波动但SLABS不降,可能是 per-CPU 缓存滞留,属于正常现象,但滞留过多也值得关注。
第二步,用 tracepoint 定位调用点。开启kmem/kmalloctracepoint,跑一段时间后统计call_site分布。如果某个调用点的分配次数远高于释放次数,那就是嫌疑点。可以用perf或者自己写脚本聚合 trace 输出。
第三步,检查释放路径。有时候分配和释放次数匹配,但释放的对象没有回到正确的缓存。比如用kmalloc分配却用kfree_sensitive释放,或者跨缓存释放。这种情况 tracepoint 上看不出异常,需要结合代码审查。
5.2 尺寸档位选择不当导致的内部碎片
内部碎片是kmalloc的固有代价,但过大的碎片说明档位选择有问题。判断标准很简单:如果某个调用点的bytes_req和bytes_alloc比值长期低于 50%,就值得优化。
常见的优化手段有三种。一是调整请求大小,比如把结构体里的字段重新排列,让总大小刚好落在档位边界上。二是改用自定义kmem_cache,自己控制对象大小和对齐。三是用kmalloc_array或者kvmalloc替代,前者能按数组元素大小分配,后者在大块分配时走页分配器。
我踩过的一个坑是:某个结构体在 32 位架构上大小是 96 字节,刚好落在kmalloc-96档位,但在 64 位架构上因为指针对齐变成 104 字节,落到kmalloc-128,碎片率一下子从 0 变成 19%。这种跨架构差异在写代码时很容易忽略,后来养成了用pahole检查结构体实际大小的习惯。
5.3 per-CPU 缓存导致的统计偏差
slabtop和/proc/slabinfo里的数据是全局汇总,但 SLUB 的分配主要走 per-CPU 缓存。这会导致一个现象:某个 CPU 上释放的对象滞留在该 CPU 的缓存里,其他 CPU 看不到,全局统计上表现为OBJS没降但ACTIVE降了。
排查这种偏差,可以看/sys/kernel/slab/kmalloc-256/cpu_slabs里的 per-CPU 数据。如果某个 CPU 的objects远高于其他 CPU,说明该 CPU 上的缓存滞留严重。这时候可以尝试触发一次drop_caches或者手动 flush per-CPU 缓存,观察数据是否回落。
不过要注意,per-CPU 缓存滞留本身是性能优化手段,不是 bug。只有当滞留量过大导致内存浪费时才需要干预。判断标准可以看cpu_partial配置,这个值决定了 per-CPU 缓存里最多保留多少个空闲对象,超过就 flush 到 node。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 处理建议 |
|---|---|---|---|
| 某 kmalloc 缓存 SLABS 持续增长 | 内存泄漏 | tracepoint 统计 call_site | 定位泄漏调用点并修复 |
| OBJS 高但 ACTIVE 低 | per-CPU 缓存滞留 | 查看 cpu_slabs 数据 | 调整 cpu_partial 或触发 flush |
| bytes_req 远小于 bytes_alloc | 尺寸档位选择不当 | tracepoint 对比两值 | 调整结构体大小或换自定义缓存 |
| kfree 报错或崩溃 | 跨缓存释放或重复释放 | 检查分配释放配对 | 统一分配释放接口 |
| DMA 分配失败但常规分配正常 | DMA 缓存未初始化或耗尽 | 查看 kmalloc_dma 缓存状态 | 检查 DMA 区域配置 |
提示:排查内存问题时,先看全局统计,再看 per-CPU 数据,最后用 tracepoint 定位到具体调用点。这个顺序能避免一上来就陷入细节,提高效率。
6. 几个容易踩的坑和实操心得
第一个坑是混淆kmalloc和kmem_cache_alloc的释放接口。虽然kfree底层能处理来自自定义缓存的对象,但反过来用kmem_cache_free释放kmalloc返回的指针,如果缓存指针传错了,会直接破坏 SLUB 的内部链表。我见过一个案例,某模块把kmalloc的返回值传给了kmem_cache_free,而且传的是另一个缓存的指针,结果 slab 页的 freelist 被写坏,系统跑了一段时间才崩溃,排查花了很久。
第二个坑是忽略GFP标志对缓存选择的影响。kmalloc的flags里如果带了__GFP_DMA,会走 DMA 缓存;如果带了__GFP_RECLAIM相关标志,会影响页分配器的行为,但不影响缓存选择。很多人以为GFP_KERNEL和GFP_ATOMIC会走不同缓存,其实不会,它们走同一个kmalloc缓存,区别只在页分配阶段的回收策略。
第三个心得是关于kmalloc_size_roundup这个函数。它能把请求大小向上取整到最近的档位,返回实际会分配的大小。在需要精确控制内存用量的场景,可以先用这个函数算一下,再决定是否调整请求大小。比如你本来要申请 200 字节,算出来会落到 256 档位,那不如直接申请 256,至少能把剩余空间利用起来。
第四个心得是关注KMALLOC_MIN_SIZE和KMALLOC_MAX_CACHE_SIZE这两个边界。小于KMALLOC_MIN_SIZE的请求会被提升到最小档位,大于KMALLOC_MAX_CACHE_SIZE的请求会直接走页分配器。如果你的代码里有大量小于最小档位的分配,说明这些分配本可以用更轻量的方式处理,比如用位图或者内嵌数组。
最后分享一个观察技巧:在/sys/kernel/slab/目录下,每个缓存都有一个子目录,里面能看到object_size、objs_per_slab、order等参数。对比不同kmalloc缓存的objs_per_slab,能看出 SLUB 是怎么根据对象大小选择 slab 页阶数的。比如kmalloc-8的objs_per_slab可能是 512,而kmalloc-4096可能只有 8。这个比例关系直接反映了空间利用率和页管理开销之间的权衡,理解它对调优很有帮助。