4. 内存越界与踩踏:kmalloc/slab内存分配机制、越界访问的典型场景、使用KASAN和SLUB debug检测、案例分析
内存越界,说白了就是你的代码写到了不该写的地方。在驱动开发里,这玩意儿排得上「最难调试的Bug」前三名。我入行第三年的时候,被一个内存踩踏折磨了整整两周,最后发现只是多写了一个字节。嗯,从那以后,我对内存分配机制就格外上心了。
4.1 kmalloc与slab分配器:内核的「内存管家」
先聊聊kmalloc。很多新手以为kmalloc就是内核版的malloc,其实没那么简单。kmalloc背后站着的,是slab分配器。
slab分配器解决什么问题?你想想看,驱动里频繁分配和释放小对象(比如几十字节的缓冲区),如果每次都去操作伙伴系统,那效率得多低。slab的做法是:提前申请一大块内存,切成大小固定的「对象池」。你要用,直接从池子里拿;用完,放回去就行。
我个人习惯把slab理解成「乐高积木盒」——每个格子大小固定,拿取方便,但你要是硬塞一个放不下的东西进去,盒子就裂了。
struct kmem_cache:每个slab缓存对应一个kmem_cache,管理着相同大小的对象slab对象:实际分配的内存块,每个对象前后可能有redzone(红色警戒区)freelist:空闲对象链表,分配时从这里取
// kmalloc 底层调用链(简化版)
void *kmalloc(size_t size, gfp_t flags)
{
struct kmem_cache *cachep;
// 根据size找到合适的slab缓存
cachep = kmalloc_slab(size, flags);
// 从缓存中分配对象
return slab_alloc(cachep, flags, _RET_IP_);
}
4.2 越界访问的典型场景:我踩过的坑
越界访问分两种:读越界和写越界。写越界更致命,因为它会破坏别人的数据。我总结了几种最常见的翻车现场:
场景一:缓冲区大小计算错误
这是最蠢也最容易犯的错。比如你有一个结构体,里面有个柔性数组,你手动计算大小的时候忘了算结构体头部的对齐填充。
// 错误示例:少算了4字节
struct my_dev {
u32 id;
u8 data[]; // 柔性数组
};
// 分配时只给了data的空间,忘了id的4字节?
dev = kmalloc(sizeof(struct my_dev) + data_len, GFP_KERNEL);
// 实际上 sizeof(struct my_dev) 已经包含了id,但如果你手动拼凑...
// 我曾经见过有人写:kmalloc(4 + data_len, GFP_KERNEL) —— 把id硬编码成4,结构体改了代码没改
场景二:字符串操作越界
驱动里经常要处理设备名称、路径等字符串。strcpy、sprintf这些函数,如果你不限制长度,分分钟踩踏。我遇到过最离谱的一次,是某个网卡驱动的name字段只有16字节,结果固件返回的设备名有20字节,直接覆盖了后面的关键指针。
场景三:数组索引越界
这个太常见了。循环里用i做索引,结果i跑到了数组边界之外。特别是处理描述符表、中断向量表这类东西时,一个off-by-one错误就能让系统崩给你看。
// 经典off-by-one
for (i = 0; i <= MAX_DESCRIPTORS; i++) { // 应该是 < 不是 <=
desc_table[i] = ...; // 当i == MAX_DESCRIPTORS时越界
}
4.3 检测利器:KASAN与SLUB debug
说实话,没有工具的时候调内存越界,基本靠猜。现在好了,内核提供了两把瑞士军刀。
KASAN(Kernel Address Sanitizer)
KASAN的原理其实不复杂:它在每个内存分配的前后都放上「影子内存」,用来标记这块内存是否可访问。你越界读写时,影子内存会检测到,然后立刻触发oops,告诉你具体是哪一行代码干的。
启用方式很简单,编译内核时打开:
CONFIG_KASAN=y
CONFIG_KASAN_INLINE=y # 或者 CONFIG_KASAN_OUTLINE
我个人推荐用INLINE模式,虽然生成的代码大一点,但检测精度更高。KASAN跑起来后,你的系统会变慢一些(大概2-3倍),但为了定位Bug,这点代价值得。
SLUB debug
SLUB debug是slab分配器自带的调试功能。它比KASAN轻量,但能抓到一些KASAN漏掉的问题,比如「释放后使用」(use-after-free)。
启用方式:
CONFIG_SLUB_DEBUG=y
CONFIG_SLUB_DEBUG_ON=y # 默认开启所有slab缓存的调试
SLUB debug会做几件事:
- Redzone检测:在每个对象前后填充特殊标记(0xCC),越界时会破坏这些标记,分配器在free时检查并报错
- 对象毒化:释放后将对象内容填充为0x6B,再次访问时能明显看出是野指针
- 分配历史追踪:记录每个对象是谁分配的,谁释放的,方便回溯
| 特性 | KASAN | SLUB debug |
|---|---|---|
| 检测越界写 | ✅ 精确到字节 | ✅ 通过redzone |
| 检测释放后使用 | ✅ | ✅ 通过对象毒化 |
| 性能开销 | 高(2-3x) | 中(1.5-2x) |
| 内存开销 | 高(约2x) | 中(redzone占用) |
| 适用场景 | 开发阶段全面检测 | 生产环境轻量监控 |
4.4 案例分析:一次真实的kmalloc踩踏
讲个我亲身经历的故事。某次在调试一个PCIe网卡驱动时,系统在高压流量下随机死机。没有任何规律,有时候跑一小时,有时候跑五分钟。
我先开了KASAN,重新编译内核。跑起来后,大概20分钟就抓到了报告:
==================================================================
BUG: KASAN: slab-out-of-bounds in netdev_rx_handler_register+0x...
Write of size 8 at addr ffff88003b5678c0 by task ksoftirqd/0:27
...
The buggy address belongs to the object at ffff88003b5678b0
which belongs to the cache kmalloc-256 of size 256
The buggy address is located 16 bytes inside of
256-byte region [ffff88003b5678b0, ffff88003b5679b0)
==================================================================
报告说得很清楚:在netdev_rx_handler_register函数里,往一个kmalloc-256的对象偏移16字节处写了8字节,但那个对象只有256字节,而偏移16字节是在合法范围内的啊?
等等,仔细看——「16 bytes inside of 256-byte region」。意思是这个对象本身是256字节,但实际分配时可能因为对齐或其他原因,可用空间比256小?不对,KASAN不会犯这种错。
我继续往下看调用栈,发现这个对象其实是一个struct net_device,但驱动在注册时错误地复用了另一个已经释放的net_device结构体。说白了,是use-after-free,但表现成了越界写——因为释放后那块内存被slab重新分配给了别的对象。
最终定位到:驱动在卸载时没有正确清理NAPI实例,导致中断处理函数还在访问已经free掉的net_device。修复方法就是在驱动卸载路径里加上netif_napi_del。
- KASAN报告里的「slab-out-of-bounds」不一定是真的越界,也可能是use-after-free的变种
- 遇到随机panic,先开KASAN跑压力测试,比看代码有效率得多
- 修复后一定要在KASAN环境下重新验证,确保没有其他隐藏问题
4.5 写在最后
内存越界调试,说白了就是「让错误尽早暴露」。KASAN和SLUB debug就是你的探照灯。我建议每个驱动开发者都在自己的开发内核里默认开启KASAN,虽然慢点,但能省下无数调试时间。
嗯,下一章我们会聊聊内存泄漏的检测与修复,那又是另一番风景了。