内核内存泄漏是所有长期运行系统最头疼的问题之一,它不像用户态进程那样可以简单重启搞定,往往表现为可用内存缓慢下降、系统响应越来越差,直到某天触发 OOM。作为常年和内核驱动打交道的开发者,我用了很长时间才真正搞明白 Kernel Memory Leak Detector 这一整套工具的用法和边界——kmemleak、KASAN、slab 统计、debugfs 输出,每一样都有各自的适用场景,用错了方向反而会浪费大量排查时间。这篇文章会从泄漏的本质开始,讲清楚内核内存在什么情况下、为什么会泄漏,然后给出我在实际项目中验证过的一套排查思路和操作步骤,希望能让同样被这个问题折磨的人少走一些弯路。
1. 内核层内存泄漏为什么比用户态程序难抓十倍
1.1 先看看泄漏的常见种类
先说结论:内核内存泄漏并不是一个单一问题,而是至少四种完全不同的问题,排查手段也因此完全不同。第一种是 slab 泄漏,也就是通过 kmalloc、kzalloc 分配的小块内存没有被释放,这一类最常见,也最容易通过 kmemleak 抓到。第二种是 vmalloc 区域泄漏,通常是某些驱动或文件系统用 vmalloc 分配了较大的虚拟地址空间,却因为某些错误路径没有走到释放逻辑。第三种是页分配器泄漏,表现为系统空闲页持续下降,但 slab 和 vmalloc 统计都很正常,这种情况往往指向 alloc_pages 后没有配对 free_pages,问题可能藏在底层驱动里。第四种是资源或引用计数泄漏,比如 kobject、文件描述符、设备引用这类对象,它们本身的内存不一定增长,但引用计数不归零导致对象永远无法释放,间接造成内存泄漏。
这四种泄漏在现象上高度相似——free 输出中 available 内存持续下降,但定位路径完全不同。我自己踩过最深的坑是拿 kmemleak 去查引用计数泄漏,结果 kmemleak 明确报告“未发现 unreferenced object”,让人非常困惑。后来才意识到,kmemleak 只关心“没有引用的对象”,如果一个对象一直被某个错误保存的指针引用着,它当然不会被报告为泄漏,但它又确实永远不会被释放。这是理解整个排查工作的第一块基石。
1.2 内核态的检测难点
用户态内存泄漏好排查,是因为每个进程有独立地址空间,进程退出时内核会回收全部内存,而且有 valgrind、ASAN 这类成熟工具。内核态呢?没有“内核进程退出”这回事,系统运行多久,泄漏就累积多久;也没有一个独立进程的地址空间可以供工具追踪。更麻烦的是,内核内存分配点分散在成千上万个函数里,几乎每个驱动子系统都有机会分配内存。用大白话说,用户态排查相当于在划分好的一个房间里面找东西,而内核态排查是在一栋没有隔断的厂房里找一根丢了的钉子。
还有一个容易被忽略的问题:很多内核内存泄漏并不是代码路径完全没写释放,而是“错误路径没释放”。比如一个驱动在正常流程中能配对 kfree,但只要某个 ioctl 调用参数异常,提前从分支返回,分配的那块内存就再也不释放了。这种泄漏往往只在特定操作触发后才出现,平时测试很难复现。这也是为什么我在给自己维护的驱动写测试时,总会加上一轮“长时间反复调用错误的 ioctl 参数”的负向测试,再配合内存统计的前后对比,很多藏着的问题就是这样暴露出来的。
提示:遇到内核内存异常增长,第一件事不是急着打开某个调试工具,而是先确认增长发生在哪个类型(slab、vmalloc、pages),否则很容易拿错工具查错方向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 准备调试内核:内存诊断相关的关键开关
2.1 编译内核时值得打开的配置项
发行版内核基本都不适合做调试,它们为了性能和稳定性关闭了太多诊断功能。排查内核内存泄漏,第一步通常是编译一个专门的调试内核。基于我自己的经验,最值得打开的是这组配置:
CONFIG_DEBUG_KMEMLEAK=y:内核自带的内存泄漏检测器,核心功能是扫描所有已分配对象并检查是否存在引用链,如果对象分配了但没有已知指针指向它,就上报。CONFIG_KASAN=y:越界访问和 use-after-free 检测,它和泄漏检测互补,适合处理“内存没有少,但行为异常”的情况。CONFIG_SLUB_DEBUG=y:SLUB 分配器的额外调试信息,打开后 slab 缓存会记录更多分配元数据,方便定位对象归属。CONFIG_DEBUG_PAGEALLOC=y:对页分配器做额外检查,可以暴露页级别的不正常释放。CONFIG_STACKTRACE=y:让内核在分配点记录调用栈,kmemleak 的 backtrace 依赖它。
展开说一下为什么 CONFIG_STACKTRACE 这么关键。kmemleak 报告一个泄漏对象时,会附带该对象首次分配时的调用栈,没有这个配置,你就只能看到“某个地址泄漏了”,完全无法知道是哪个函数分配的。在我的经验里,调用栈信息占排查工作量的七成。打开这些选项后内核运行会慢一些,尤其是 KASAN,性能损失按倍算,所以调试内核不适合长时间跑生产负载,一般只在实验环境复现问题时用。
编译调试内核时还有一个容易踩的坑:部分发行版默认开启模块签名校验,调试用的模块无法加载。如果只是排查问题,不涉及生产环境安全约束,可以在编译时把 CONFIG_MODULE_SIG 关掉,否则每次编译内核模块都要处理签名,非常耽误时间。
2.2 启动和 sysfs 接口准备
调试内核编译安装完成后,还要做两件事:确认 debugfs 已挂载,并确定 kmemleak 被正确启动。内核文档里描述的行为在不同版本上略有差异,但我这边最常用的操作是这样:
bash复制# 挂载 debugfs(如果系统没有自动挂载)
mount -t debugfs none /sys/kernel/debug
# 触发一次手动扫描
echo scan > /sys/kernel/debug/kmemleak
# 读取扫描结果
cat /sys/kernel/debug/kmemleak
手动触发扫描很重要。内核默认会周期性扫描,但等待周期太慢,排查期间通常需要反复手动触发来观察泄漏对象是否在增多。我习惯的做法是:先记录一次当前输出,然后在目标操作(比如反复加载卸载某个驱动)之后再扫描一次,对比两次输出的差异,能快速确定问题是否由该操作引起。
另外,如果打开的 kmemleak 在启动时没有自动启用,可以检查启动参数或直接尝试往 kmemleak 节点写入 scan 看是否收到 unable to scan 之类的反馈。少数架构或内存模型下 kmemleak 支持的完整度不同,尤其是有大段保留内存的嵌入式场景,需要结合实际平台验证。
注意:kmemleak 的扫描需要冻结所有任务,扫描过程中系统可能会出现短暂卡顿,在生产服务器上不要轻易手动打扰。我一般把这一步局限在测试机或维护窗口内执行。
3. 诊断工具怎么选:kmemleak、KASAN 与 slab 统计的适用边界
3.1 kmemleak 是什么原理,它又有什么局限
kmemleak 的核心机制可以这样理解:内核维护一张已分配内存对象的清单,周期性地扫描内核的全局变量、栈、寄存器以及已知的动态分配区域,把这些地方能找到的所有指针都“标记”一遍,凡是没有被任何地方引用的已分配对象,就认为已经泄漏。这个思想很像用户态工具的”标记-清除“,但它做在操作系统内部,能找到几乎全部内核地址空间中的可疑内存块。
kmemleak 的局限也在于“引用链”这个概念。如果一个对象分配后,恰好在某个链表的节点里保存了指向自己的指针,但整个链表已经没有任何代码使用了,kmemleak 仍然认为它“有引用”。这种情况下,kmemleak 不会报任何东西。还有就是 kmemleak 对“分配到但没有录入清单”的特殊内存区域无法追踪,比如某些 DMA 内存或显存映射区域。所以 kmemleak 报告“没有泄漏”并不等于系统真的没有泄漏,只能说明它没有发现“无引用”的泄漏对象。
在实际使用中,kmemleak 输出通常长这样:
code复制unreferenced object 0xffff888105b40000 (size 512):
comm "modprobe", pid 2105, jiffies 4294895423 (age 136.454s)
hex dump (first 32 bytes):
data...
backtrace:
[<ffffffff81234a9a>] kmem_cache_alloc_trace+0x13a/0x280
[<ffffffff8165f202>] some_driver_alloc_resource+0x82/0x100 [demo_driver]
[<ffffffff8165f8d1>] some_driver_probe+0x1a1/0x220 [demo_driver]
这里最关键的信息就是 backtrace 里最后一行,它告诉你泄漏对象是在哪个函数的哪个动作里被分配的。顺着这个调用栈去查驱动源码,基本能快速锁定分配点,然后重点审查这个分配点对应的释放路径。
3.2 KASAN 与 kmemleak 是互补关系,不是替代关系
很多初学者会把 KASAN 当成“内存泄漏检测器”,这是个误区。KASAN 的全称是 Kernel Address Sanitizer,它检测的是内存访问错误:越界读写、释放后使用、双重释放等。它通过编译器插桩,在每个内存读写前后插入检查代码,一旦发现越界访问就立即报错并打印访问地址、类型和调用栈。如果代码存在越界写,可能覆盖了相邻内存中的分配元数据,进而导致后续释放逻辑出错,间接产生内存泄漏,但 KASAN 本身并不会告诉你“这块内存没有释放”。
我用一个简单类比来说明两者的分工:KASAN 是交通警察,只看你有没有闯红灯压线越界的事故现场;kmemleak 是资产管理审计,专门盘查仓库里有没有一直占用却没人领用的物品。排查问题时,我通常先跑 kmemleak,如果没有收获而系统又存在异常行为,再切换 KASAN 内核复现一次,检查是否有内存被破坏导致泄漏的隐藏条件。两者配合使用,才能覆盖“没释放”和“不该写却写了”这两类不同的问题。
KASAN 的使用还要注意它对内存占用的成倍放大。打开 KASAN 后,每个内存对象都会附带一块 shadow memory,整体内存开销可能是正常的数倍。在内存有限的嵌入式设备上,可能需要调整内核配置或者在启动时关闭某些内存大户,否则系统可能先因为 OOM 挂掉,问题还没复现,环境先崩了。
3.3 用 slabtop、/proc/meminfo、/proc/slabinfo 快速定位方向
在打开 kmemleak 和 KASAN 之前,我通常会先做一次更底层的定性分析,确定泄漏发生在哪个分配层次。最直观的入口是 /proc/meminfo,里面有几个重点字段:
Slab:slab 分配器使用的内存总量,持续增长说明 slab 级别可能存在泄漏。SReclaimable:可以被回收的 slab 内存,如 dentry、inode 缓存。SUnreclaim:不可回收的 slab 内存,持续增长是更危险的信号。VmallocUsed:vmalloc 区域已使用内存,增长说明 vmalloc 分配未释放。Pages相关字段和MemAvailable:整体可用内存的余量。
再配合 slabtop 或者直接查看 /proc/slabinfo,可以找出是哪一个 slab 缓存对象在持续增长。比如某个驱动用的 kmalloc-1k 在每次操作后数量稳步上升,那就很说明问题了。/proc/slabinfo 的格式比较复杂,但核心列是对象总数(active_objs)和对象总大小(num_objs),观察一个缓存对象的数量变化趋势,比看总量更有意义。
实际操作中我习惯写一小段脚本循环采样 Slab、SUnreclaim 和特定 slab 缓存的对象数,把数据存成 CSV,然后用简单的图表工具画增长曲线。有了曲线,就能看出泄漏是线性的(某个操作每次泄漏一个对象)还是随机的(某种边界条件偶尔触发)。这个判断对缩小排查范围帮助很大——线性泄漏意味着代码中某个分配点缺少配对释放,而随机泄漏则更可能是错误路径或并发条件触发。
这三类工具的组合拳可以概括为:先用统计工具定位泄漏层次,再用 kmemleak 定位具体分配点,最后用 KASAN 排查是否有内存破坏因素。顺序不要颠倒,否则很容易在错误方向上浪费大量时间。
4. 一次完整的内核泄漏排查过程
4.1 异常征兆:可用内存持续下降,但找不到罪魁祸首
具体说说我近期处理过的一个场景。某台用于长时间压力测试的服务器,运行某个外设驱动,系统可用内存从启动时的 10GB 左右缓慢下降到 6GB,然后稳定在 6GB 附近,不再继续变化。如果只看这个现象,可能会觉得“问题不算严重”,但真实情况是内存虽然不再下降,驱动性能却明显变差,因为内存碎片化和 slab 缓存膨胀拖慢了分配速度。而且这种“不再下降”很可能是某个缓存达到了上限或者某种平衡,不代表没有泄漏。
第一步,我执行 free -m 确认可用内存和缓存情况,然后看 /proc/meminfo,发现 Slab 字段从正常的几十 MB 增长到几百 MB,其中 SUnreclaim 占比很高。这基本上把问题锁定在 slab 层。接着执行 slabtop -s 按对象数排序,看到某个 kmalloc-2k 缓存的对象数量异常地大,并且持续增长。这时还没有到“谁分配了它”的层面,但至少明确了方向:问题出在一个常用 2KB 大小内存对象的驱动或内核模块上。
我还顺手查了一下这个 slab 缓存对应的分配标志。由于 kmalloc-2k 是通用缓存,很多子系统都会使用,仅凭名字无法直接判断归属,所以接下来的关键一步就是把分配调用栈抓出来。这就要利用 kmemleak 了。
4.2 挂载 debugfs,用 kmemleak 抓出分配调用栈
在这台测试机上,我使用的调试内核已经打开了 CONFIG_DEBUG_KMEMLEAK。先执行挂载和一次手动扫描:
bash复制mount -t debugfs none /sys/kernel/debug
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
日志输出里出现了大量 unreferenced object 记录,绝大部分都指向同一个函数调用栈。栈底落在一个外设驱动的模块名上,调用的函数是一个 devm_kmalloc 的辅助封装。看到这个结果后,我基本确定就是这个驱动在内部分配了 2KB 内存块,却因为某些原因没有释放。
接着我跑了一个快速的“加载-操作-卸载-加载”循环,每次循环前后手动执行 echo scan 和读取 /sys/kernel/debug/kmemleak 的新输出,对比新增的泄漏对象数量。实测下来,每完成一轮循环,新增泄漏对象数量正好等于循环中执行的发送请求次数,这就把问题从“驱动泄漏”缩小到了“驱动中某条具体命令执行后没有释放内存”。整个过程相当于给嫌疑犯拍了一张带地址的照片,剩下的就是回到源码去看那条路径的释放逻辑。
需要留意的是,kmemleak 不会立刻报告刚分配的内存,因为对象在分配之后短时间内可能还在某个临时结构体中,引用链还没有消失。所以每次扫描之后等待几秒再读取输出,效果会更好。我在实际操作中会在 echo scan 之后加一个 sleep 5,再 cat 结果。
4.3 定位源码里的错误路径并验证修复
回到驱动源码后,问题很快就找到了。驱动里有一个处理请求的函数,正常路径下会在函数末尾释放内存,但中间有一个校验参数的分支,该校验失败时会直接 goto out_err,而 out_err 只做了错误码返回,没有释放之前已经分配的那块 2KB 内存。这种错误路径漏释放是最典型的泄漏来源,平时测试很难覆盖到,因为谁都想不到一个错误返回会影响内存。
修复方式很直接:在 out_err 标签处补上对应的释放调用,或者调整函数结构,把释放放到所有分支都会经过的统一出口处。我采用了后者,因为这种结构更不容易在未来改动时再次漏掉。做这种修改时,一个特别重要的原则是保持“分配点和释放点在同一层次”,不要在子函数里分配、在父函数里释放,否则一旦某个分支改变,配对关系很容易被破坏。
修好后,重新编译模块,加载到测试环境再跑同样的循环测试。这回观察 /proc/meminfo 的 Slab 字段和 kmemleak 输出,新增泄漏对象归零,经过连续 72 小时压力测试,SUnreclaim 保持稳定,没有继续增长。至此,这个泄漏问题从现象出现到修复验证,整个链路就走通了,全程约三个小时,其中一半时间花在确认泄漏层次和抓调用栈上。
4.4 排查过程的常见坑和经验整理
- kmemleak 输出为空不代表没有泄漏,尤其在引用计数泄漏场景下,需要结合
Slab趋势判断。 - 驱动模块卸载后,模块自身代码占据的内存会被释放,但模块通过
kmalloc分配的对象仍可能残留在系统中,所以排查时不要只看模块装载前后的内存总量。 - 有些泄漏对象被 RCU 机制延迟释放,kmemleak 可能暂时标记为引用状态,此时需要等待 RCU 回调完成后再扫描。
- 在虚拟化环境中,客户机内存统计可能受宿主机内存回收机制影响,现象不够干净,尽量在物理机或可控的虚拟环境下复现。
- 如果是 DMA 缓冲泄漏,kmemleak 往往无能为力,因为 DMA 分配的内存可能没有经过 kmalloc 系列接口,需要通过
dma_alloc_coherent对应的释放路径去审查,或借助 IOMMU 调试接口。
这些经验看着琐碎,但每一条都对应一次实际的“鬼打墙”。尤其是 RCU 和 DMA 这两种情况,如果不知道这些前提,很容易在看到 kmemleak 正常时误判系统没有泄漏。
5. 写出不容易泄漏的内核代码:防御性习惯
5.1 让“成对操作”可以机械验证
内核代码中大量泄漏是因为“分配与释放不成对”。从防御性编程的角度,我最推荐的做法是让成对操作变得“可机械验证”,具体来说分三步。
第一,明确规定分配和释放必须发生在同一个函数层级。如果是 probe 中分配、remove 中释放,那么中间所有函数都不能擅自持有这个对象的长期指针。第二,在错误路径上统一走一个出口。很多内核函数会有大量 goto err_xxx 的写法,每个 err_xxx 标签应该负责释放到某个阶段为止的资源,但要小心不要在新增分支时漏掉某个 goto err_xxx 标签。第三,在提交代码前做一次简单的文本扫描:搜索 = kmalloc、= kzalloc、= kmem_cache_alloc,然后确认同一 diff 中是否有对应的 kfree 或 kmem_cache_free,如果没有,就要重点审查。这个方法虽然粗糙,但在 review 阶段拦截泄漏非常有效。
我还会在驱动里维护一段“内存资源快照”的调试代码,用文件节点或者 debugfs 输出当前分配计数和释放计数。比如定义一对函数 my_drv_alloc 和 my_drv_free,内部维护递增与递减的原子变量,在长期运行测试中只需查看计数是否归零,就能快速发现泄漏。这种自制的分配追踪器在开发阶段比任何外部工具都直观。
5.2 引用计数管理与生命周期意识
很多内核内存泄漏本质上不是“没释放”,而是“引用计数没有归零”。最常见的场景是 kobject、文件引用、设备引用。内核里大量模式使用 kref_get 和 kref_put 做生命周期管理,一旦 get 和 put 的次数不对等,对象就会永远残留在内存里。就算 kmemleak 扫描时能看到该对象有引用链,这个对象仍然会占用内存却无法被使用。
为了避免这类问题,我通常坚持三条规则。第一,kref_get 和 kref_put 必须在注释中成对出现,注释里明确写明“这个引用由谁持有、在什么路径下释放”。第二,不要在普通上下文中直接操作 atomic 引用计数,统一使用 kref 接口,因为它会在归零时自动调用释放回调,减少遗漏。第三,在模块卸载时加入完整性校验,比如使用 WARN_ON(atomic_read(&obj->refcount) != 0),强制暴露残留引用。这些方法不会完全避免引用泄漏,但至少让问题在源头浮现。
另外还要提一个很隐蔽的坑:屏蔽中断或持有自旋锁期间,不应该调用会导致调度的函数,也不应该做可能失败且忘记释放资源的操作。有些泄漏就发生在持锁状态下尝试重新分配内存并失败后,开发者忘记恢复之前的资源。这个场景下,我习惯在写代码时把“分配资源”和“加锁”的顺序固定下来,能不加锁就不在锁内分配。
5.3 用工具固化到日常开发流程
最后回到主题,Kernel Memory Leak Detector 这整条工具链,不应该只在系统出问题后才想起来。我现在的日常工作流是:每个负责的模块在合并到主线前,至少跑一遍带 kmemleak 的内核,执行完模块的完整功能测试后检查一次泄漏报告;然后每隔一段时间用 slabtop 做持续观察,保存前后数据对比。这样,哪怕不能完全避免泄漏,也能把泄漏的发现时间从“用户反馈系统挂了”提前到“开发阶段测试失败”。
工具的使用应该成为习惯,而不是救火手段。有经验的排查者一眼看到内存异常时会先分层次,再取调用栈,最后回源码,跟诊断生病的过程很像。这套方法熟练之后,绝大部分常见的内核内存泄漏可以在几小时内定位并修复,希望这篇基于实际项目排查经验的记录,能帮你把这条链路建立起来,省掉那些我当年自己摸索时浪费的时间。
