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_cachekmalloc_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_cachekfree同样能正确处理因为页的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_req200 bytes_alloc256的行。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 或触发 flushbytes_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。这个比例关系直接反映了空间利用率和页管理开销之间的权衡理解它对调优很有帮助。