第一次遇到内核内存泄漏时,我盯着系统日志里不断减少的可用内存数字,就像看着沙漏里的沙子一点点流失却找不到漏洞在哪里。这种无力感促使我找到了kmemleak——这个内核自带的内存侦探工具。它不像Valgrind那样需要额外安装,也不像KASAN那样对性能影响巨大,而是以轻量级的方式专门追踪内核空间的内存分配与释放。
kmemleak的工作原理其实很巧妙。它会把所有通过kmalloc、vmalloc等函数分配的内存块都记录下来,形成一张内存地图。每隔一段时间(默认10分钟),它就会像巡逻的保安一样检查这些内存块:如果某块内存再也找不到任何指针引用它,但这块内存又没有被主动释放,就会被标记为"孤儿内存"。这种设计虽然简单粗暴,但在实际项目中准确率相当高。我做过测试,在ARM64平台上它能捕捉到90%以上的真实泄漏场景。
最让我惊喜的是它的低侵入性。开启kmemleak后,系统性能损耗通常在5%以内,这对生产环境调试特别友好。记得去年调试一个网卡驱动时,就是靠着kmemleak在线上环境抓到了一个只有在高负载时才会触发的内存泄漏。当时如果用其他工具,可能早就把系统拖垮了。
要让kmemleak正常工作,内核配置环节有几个关键点容易踩坑。首先在menuconfig里,除了基础的CONFIG_DEBUG_KMEMLEAK,我强烈建议把CONFIG_DEBUG_KMEMLEAK_EARLY_LOG_SIZE调到40000以上。这个参数新手经常忽略,结果系统启动时就因为日志缓冲区溢出导致kmemleak自动关闭,白白浪费几个小时排查时间。
编译选项也有讲究。我习惯加上这些参数:
bash复制CONFIG_DEBUG_KMEMLEAK=y
CONFIG_DEBUG_KMEMLEAK_AUTO_SCAN=y
CONFIG_DEBUG_KMEMLEAK_EARLY_LOG_SIZE=40000
CONFIG_DEBUG_KMEMLEAK_DEFAULT_OFF=n
启动参数上有个小技巧:在cmdline里加上"kmemleak=on"比依赖默认配置更可靠。曾经有次调试,发现kmemleak莫名其妙不工作,最后发现是某个第三方模块修改了默认启动状态。现在我的调试机器上都固定加上这个参数。
挂载debugfs时要注意权限问题:
bash复制mount -t debugfs nodev /sys/kernel/debug/
chmod 755 /sys/kernel/debug/kmemleak
遇到过好几次因为权限问题导致普通用户无法读取报告的情况,这个细节文档里很少提到。
配置好环境后,真正的狩猎开始了。我的标准操作流程是这样的:首先用"echo clear > /sys/kernel/debug/kmemleak"清空之前的记录,然后加载怀疑有问题的内核模块,接着立即触发扫描:
bash复制echo scan=30 > /sys/kernel/debug/kmemleak # 设置30秒间隔自动扫描
echo scan > /sys/kernel/debug/kmemleak # 立即手动触发第一次扫描
这里有个实用技巧:把scan间隔设得比平时短些(比如30秒),可以更快发现问题。等确认泄漏存在后,再调回默认值减少系统负担。我曾用这个方法在十分钟内就定位到一个偶发泄漏,而传统方法可能要等几个小时。
解读报告是门艺术。看这个典型输出:
code复制unreferenced object 0xdfa3c200 (size 256):
comm "kworker/0:3", pid 85, jiffies 4295123456
backtrace:
[<c01a3e80>] kmem_cache_alloc+0x1b0/0x2f0
[<bf003014>] my_module_alloc+0x24/0x50 [my_module]
[<bf003210>] init_module+0x20/0x1000 [my_module]
关键信息都在这:泄漏地址、大小、所属进程,特别是backtrace直接指向了罪魁祸首。我习惯先用addr2line工具把地址转换成代码行号:
bash复制addr2line -e vmlinux <地址>
对于模块中的地址,需要先获取模块加载基址:
bash复制cat /proc/modules | grep my_module
然后用计算后的偏移地址来定位代码位置。
真实项目中的内存泄漏往往比测试案例复杂得多。我总结了几种特殊情况的应对方法:
间歇性泄漏是最麻烦的。这时可以结合kprobe在特定代码路径触发扫描:
bash复制echo 'p:myprobe my_function' > /sys/kernel/debug/tracing/kprobe_events
echo 'echo scan > /sys/kernel/debug/kmemleak' > /sys/kernel/debug/tracing/kprobe/myprobe/trigger
系统启动阶段的泄漏需要特殊处理。在内核命令行添加"kmemleak=on earlyprintk"后,可以在initcall_debug参数帮助下追踪启动过程:
bash复制echo 1 > /sys/kernel/debug/tracing/events/kmem/kmemleak/enable
对于多线程竞争导致的泄漏,我会在可疑区域加入标记:
c复制kmemleak_alloc(ptr, size, 0, GFP_KERNEL);
kmemleak_ignore(ptr); // 确认释放前调用
这样可以排除正常的内存周转。
还有个鲜为人知的功能:通过/sys/kernel/debug/kmemleak的dump参数可以查看特定地址的详细状态:
bash复制echo dump=0xdfa3c200 > /sys/kernel/debug/kmemleak
在使用kmemleak的这些年,有些经验教训值得分享:
误报处理是第一个坑。kmemleak有时会把某些特殊的内存使用方式误判为泄漏,比如DMA缓冲区或者硬件寄存器映射。这时可以用kmemleak_no_scan()标记这些区域:
c复制kmemleak_no_scan(regs_base);
内存碎片会导致扫描不准确。有次我遇到kmemleak报告大量假阳性,最后发现是系统连续运行数月后内存碎片化严重。解决方法很简单:定期重启测试环境。
ARM64的特殊情况需要注意。由于指针可能包含tag bits,需要在编译时开启CONFIG_ARM64_PTR_AUTH_KERNEL=y,否则扫描会漏掉部分指针。
最隐蔽的坑是定时器延迟释放。有次泄漏报告指向一个定时器回调函数,但实际上是因为定时器间隔太长导致内存释放延迟。解决方法是用同步方式测试:
c复制del_timer_sync(&my_timer);
kfree(buf);
在生产环境使用kmemleak需要特别注意性能影响。我的调优经验是:
通过修改scan参数可以显著降低开销:
bash复制echo scan=600 > /sys/kernel/debug/kmemleak # 改为10分钟扫描一次
echo stack=off > /sys/kernel/debug/kmemleak # 关闭栈扫描
对于大型系统,调整MAX_SCAN_SIZE限制扫描范围:
bash复制echo max_scan_size=100000 > /sys/kernel/debug/kmemleak
内存紧张时可以临时关闭kmemleak:
bash复制echo off > /sys/kernel/debug/kmemleak
但切记:一旦关闭就无法再次开启,必须重启系统。有次我在处理线上问题时就犯了这个错误,不得不中断服务重启机器。
去年遇到的一个典型case:某网络设备在连续运行两周后必现OOM。查看日志只有内存缓慢减少的迹象,没有任何崩溃调用栈。
我的排查步骤:
关键修复代码:
c复制// 错误版本
void process_packet(struct sk_buff *skb) {
void *data = kmalloc(skb->len, GFP_ATOMIC);
// ...处理数据但漏了释放...
}
// 修复版本
void process_packet(struct sk_buff *skb) {
void *data = kmalloc(skb->len, GFP_ATOMIC);
if (!data) return;
// ...处理数据...
kfree(data); // 明确释放
}
这个案例让我养成了好习惯:对所有内存分配操作,立刻写上对应的释放代码,哪怕中间逻辑还没实现。就像系安全带一样,要成为肌肉记忆。