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字节,直接覆盖了后面的关键指针。

避坑指南: 我曾经在调试一个USB存储驱动时,发现系统随机panic。查了三天,最后用KASAN定位到是snprintf的第三个参数写错了,导致格式化后的字符串比目标缓冲区长了2个字节。从那以后,我所有字符串操作都强制用scnprintf或strscpy。

场景三:数组索引越界

这个太常见了。循环里用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,这点代价值得。

使用技巧: KASAN报告里会给出「访问地址」、「访问长度」、「当前进程」和「调用栈」。重点关注调用栈的最后几层,那通常是真正出问题的地方。别被中间那些内核通用函数迷惑了。

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,虽然慢点,但能省下无数调试时间。

嗯,下一章我们会聊聊内存泄漏的检测与修复,那又是另一番风景了。