如果你在内核开发这条路上待过几年,大概率经历过这样的场景:新写的驱动在测试环境跑得好好的,一旦上到生产负载,不到半天系统就随机重启;或是某个文件系统在极端压力下出现数据损坏,但dmesg里干干净净,什么异常都没有。这种问题最难的地方不是修,而是找不到它在哪。用户态程序崩了会给你core dump,内核如果内存被写坏,往往要等它破坏到某个关键链表、某个锁、或者伙伴系统的元数据时,系统才轰然倒下,这时候离案发现场可能已经过去了数百万条指令,甚至几个小时。
linux KASAN(Kernel Address Sanitizer)就是为解决这类问题而生的。它是从用户态AddressSanitizer移植到内核的运行时内存错误检测工具,能够在内存访问发生的瞬间抓住越界访问、释放后使用(use-after-free)、双重释放、栈溢出等典型内存错误,并打印出完整的调用栈和对象生命周期信息。这篇内容不需要你有很深的内核背景,只要你写过驱动、调试过内核模块、或者被内存问题折磨过,就能跟着实操起来,把KASAN变成你日常调试的标配工具。
1. 为什么说内核内存错误是开发者的噩梦
1.1 从一次半夜复现的内核崩溃说起
我想先从一次真实经历说起。多年前我在调试一个网络驱动的并发问题,现象是系统偶尔在深夜流量高峰时panic,看门狗把日志dump出来,寄存器现场指向完全无关的地址,栈也被破坏得面目全非。我们用了一周时间加打印、开内核配置、关CPU调频,都没能抓到第一现场。后来偶尔在邮件列表里看到有人推荐KASAN,于是重新编译了内核,同一台机器跑了一个晚上,第二天早上dmesg里醒目的红字等着我:BUG: KASAN: use-after-free in xxx_free_netdev+0xXX/0xXX。分配栈、释放栈、当前访问点三份调用栈清清楚楚地摆在那里,问题十分钟就定位了。
这个经历让我对内核内存调试的看法彻底改变。内核崩溃和用户态程序崩溃本质上的不同在于:用户态程序的内存错误通常只影响自己,最多产生一个segfault;内核的内存错误则可能破坏系统中的任何数据结构,从页表到文件系统元数据,从网络协议栈到调度器队列,最终症状五花八门,而且往往与根因毫无关联。更糟的是,像越界写一个字节这种错误,数据落点可能在另一个完全无关对象里,等你发现的时候,那个对象早就被用了一百遍,原始污染源根本无从追查。
用个生活化的类比:用户态出错像楼上住户往楼下泼水,楼下马上能感觉到;内核内存出错像水管埋在墙里漏水,等到墙面开裂、地板变形,你才意识到出了问题,这时候要找出是哪个接头漏的,难度完全不在一个量级。
1.2 KASAN到底在解决什么问题
KASAN做的事情,简单说就是给每一次内存访问加一道“安检”。它在编译阶段,由编译器在每次内存读写的指令前插入检查代码,在运行时判断这次访问是否越界、是否触碰了已经释放的内存、是否访问了不允许访问的红区(redzone)。一旦发现异常,立即停止执行,打印一份结构化报告,而不是等着系统慢慢被破坏后崩溃。
它能检测的问题类型非常明确,包括但不限于:
- 堆内存越界读写(slab-out-of-bounds)
- 释放后使用(use-after-free)
- 双重释放(double free)
- 栈变量的越界访问和栈溢出
- 全局变量的越界访问
- 部分内核对象的生命周期管理错误
KASAN不是万能的,它属于动态检测工具,意味着只有在你的代码路径真正执行到错误访问时它才会触发;但它一旦触发,提供的信息量是其他手段无法比拟的。这也决定了它的典型用法:在开发、测试、CI阶段持续跑KASAN内核,让内存错误尽早暴露,而不是等到生产环境炸了再追悔莫及。
2. KASAN的底层原理:影子内存与编译器插桩
2.1 影子内存的本质:给每个内存地址做记账
KASAN的核心是影子内存(shadow memory),我习惯叫它“地址记账本”。原理不复杂:操作系统把一块专门的内存区域留出来,用来记录另一块内存区域的可访问状态。问题是怎么设计这套映射关系,才能用尽量小的代价覆盖尽量多的地址空间。
KASAN采用的方案是1/8映射。什么意思呢?就是每8字节的真实内存,用1个字节的影子内存来记录状态。在x86_64架构上,如果某个内存地址是addr,那么它的影子地址可以通过简单位运算得到:shadow_addr = (addr >> 3) + KASAN_SHADOW_OFFSET。右移3位就是因为8字节对应1字节。举个例子,一个地址0xffff888012345678,右移三位后变成0x1fff11102468acf,加上一个编译时的偏移常量,就能定位到它对应的影子字节。
这个影子字节的值含义很直接:
- 值为0:表示对应的8个字节全部可正常访问。
- 值为1到7:表示前N个字节可访问,剩下的不可访问。
- 值为负数:表示这8个字节整体不可访问,可能是已经释放的内存,也可能是红区或者未初始化的区域。
有了这套机制,编译器插入的检查代码逻辑就变得非常高效。访问一个地址时,先算出影子地址,读一个字节,看看该字节描述的可访问范围和本次访问的字节数是否匹配。如果访问的偏移量超过了影子字节允许的范围,就判定为非法访问,立刻调用报告函数。
2.2 红区(Redzone):让越界行为立刻暴露
光有影子内存还不够。假如你分配了一个64字节的对象,使用范围就是[0, 64),如果你越界写到了第70个字节,而这个地址恰好落在另一个合法对象的起始区域,那么影子内存会认为这次访问是合法的,问题就被放过了。为了堵住这个漏洞,KASAN引入了红区(redzone)机制。
所谓红区,就是在每个合法对象的前后故意添加一段特殊标记的内存区域。这段话值得仔细理解:分配器在返回给你一个kmalloc对象时,实际在真实数据区前后都留了冗余空间,这些冗余空间在影子内存中被标记为不可访问。如果你写越界越过1字节,就会立刻踩到后面的红区,被影子内存抓住。同理,如果你读越界读过了头,也会撞上红区。
在slub分配器里,红区是KASAN启用时自动配置的,不需要你手动处理。栈变量和全局变量一样也有红区:编译器在函数栈帧里为每个局部数组变量周围填充redzone字节,并标记影子状态;全局变量同样会被编译器在两侧填充被标记为不可访问的区域。
有了红区,KASAN的检测精度就从“8字节粒度”提升到了真正的字节级,能够精确报告“越界了12字节”还是“越界了0字节到右侧红区”。这一点在实际排查中的作用非常大,后面讲报告解读时会看到。
2.3 编译器如何插入检查
KASAN的检查逻辑并不是手写在内核里的,而是依赖于编译器的Sanitizer能力。内核编译时,如果打开了CONFIG_KASAN,编译器的-fsanitize=kernel-address选项会被加到内核的所有C文件编译参数中。编译器会分析每个内存访问指令,自动在合适的位置插入对__asan_load1、__asan_store8这类内部函数的调用,或者把它们内联展开成一段紧凑的影子检查代码。
这里有一个编译选项值得了解:CONFIG_KASAN_INLINE和CONFIG_KASAN_OUTLINE。前者把检查逻辑内联到每次访问点,代码膨胀更明显但性能相对好;后者把检查收敛成函数调用,代码体积更小但每个访问多一次调用开销。对于调试用途,两者差别不大;如果你的内核镜像体积有严格要求,可以选OUTLINE。
编译器插桩有一个天然盲区:它只对编译器看到的C代码生效。那些用汇编手写的内存访问、内联汇编块里的读写、以及某些侵入性很强的指针操作,可能在插桩范围之外。理解这一点很重要,后面排查时会避免被误导。
3. Generic、SW_TAGS、HW_TAGS:三种模式的区别与选型
3.1 三种模式横向对比
KASAN并不是只有一种实现。Linux内核里根据架构和检测思路,发展出三种不同模式,很多人在menuconfig里看到Generic KASAN、SW_TAGS KASAN、HW_TAGS KASAN的时候会一头雾水,这里把它们放在一起对比看就清楚了。
| 对比项 | Generic KASAN | SW_TAGS KASAN | HW_TAGS KASAN |
|---|---|---|---|
| 支持架构 | x86_64、arm64等 | arm64 | arm64 |
| 实现方式 | 影子内存+编译器插桩+红区 | 软件内存标签模拟 | ARM MTE硬件内存标签 |
| 内存开销 | 约1/8影子内存+红区冗余 | 较小,不需要红区 | 很小,依赖硬件 |
| CPU开销 | 很大,通常2-5倍 | 中等 | 很低 |
| 检测粒度 | 字节级,借助红区 | 对象级(标签匹配) | 16字节粒度 |
| 生效条件 | 无特殊硬件要求 | arm64,无需MTE | 需要ARMv8.5-A及MTE硬件 |
Generic KASAN是最早也是覆盖最全的模式,代码里的每一次内存访问都在监控之下,红区机制让它能精确到字节。代价是内存和性能开销都很高,所以通常只在调试专用内核里开启。
SW_TAGS KASAN的思路则完全不同。它借鉴了ARM MTE(Memory Tagging Extension)的标签思想,但不依赖硬件,而是通过编译器给每个内存对象分配一个随机标签(tag),并把这个标签用影子内存记录下来。每次访问时,编译器生成的代码会比较指针自带的标签和影子内存里记录的标签是否一致,不一致就说明访问了已经释放的对象或者不属于当前对象的区域。这种方式不需要红区,也不需要逐个字节做范围检查,代价自然低不少。它的检测能力也有侧重点:对释放后使用非常敏感,但对“同一个对象内部的越界”无能为力——因为标签只在对象边界变化。
HW_TAGS KASAN是三者里最“先进”的,它把标签比较下沉到ARM MTE硬件指令里,CPU在每次内存访问的硬件层面直接完成标签校验,开销几乎可以忽略。这也让它成为唯一有可能在生产环境长期开启的KASAN模式。不过它需要特定硬件支持,且内核版本和工具链要求都比较新。
3.2 我应该用哪一种
选型这个问题,我自己经验可以总结成几句话。如果你在x86_64环境下做开发调试,或者跑QEMU虚拟机验证,直接选Generic KASAN,不要犹豫。它是功能最完整、报告信息最丰富的模式,兼容性也最好,几乎所有GCC和Clang版本都能编译。唯一要记住的是,它很慢,不要在开KASAN的内核上跑性能基准测试。
如果目标平台是arm64,而且你想在内存受限或性能敏感的环境中长时间跑测试,先考虑SW_TAGS KASAN,它不需要MTE硬件,支持面广,性能损耗比Generic低一个档次,代价是部分越界问题可能漏报。如果手里已经有支持MTE的ARMv8.5-A硬件,那HW_TAGS KASAN是最优选择,它可以跑在接近生产环境的负载下,同时保持不错的内存错误捕获能力。
有一点要特别注意:同一种KASAN模式下,CONFIG_KASAN_GENERIC、CONFIG_KASAN_SW_TAGS、CONFIG_KASAN_HW_TAGS三个Kconfig选项是互斥的,按需选一个就行。内核编译完,可以通过/sys/kernel/debug/kasan或者启动日志确认实际生效的模式。
4. 内核编译全流程:从配置到跑起来
4.1 配置内核的完整步骤
开启KASAN最重要的一步是正确配置内核,我用x86_64和QEMU环境举例,流程在其它架构上基本一样。
第一步,确认你的GCC版本足够新。GCC 4.9.3之后的版本才支持-fsanitize=kernel-address选项,现代发行版自带的GCC基本都满足。如果你用Clang,同样支持,但需要确认目标架构下KASAN的兼容性。
第二步,进入内核配置界面。以Linux 6.x内核为例,路径是:
Kernel hacking -> Memory Debugging -> KASAN: runtime memory debugger或者在源码目录执行make menuconfig后,用/搜索KASAN,直接跳转到相关配置项。我习惯直接编辑.config,这样更可控。
默认的defconfig不会开启KASAN,需要手动添加或修改以下关键配置:
# 核心开关 CONFIG_KASAN=y # 选一种模式(互斥) CONFIG_KASAN_GENERIC=y # CONFIG_KASAN_SW_TAGS=y # CONFIG_KASAN_HW_TAGS=y # 建议开启:栈变量越界检测 CONFIG_KASAN_STACK=y # 检查粒度与代码膨胀的取舍,调试用INLINE更常见 CONFIG_KASAN_INLINE=y # CONFIG_KASAN_OUTLINE=y如果你不是从头配内核,可以考虑这种方式:先make defconfig,然后把希望开启的选项追加到.config里,最后执行make olddefconfig让依赖关系自动处理。KASAN对slub分配器有硬性依赖,CONFIG_SLUB是默认就有的,不用担心。
第三步,编译内核。这一步比普通内核慢不少,原因很简单:KASAN插桩会让代码体积增长,编译器工作量也更大。我在8核机器上编一个普通内核大概需要十几分钟,开KASAN后可能接近半小时,要有心理准备。
make -j$(nproc)编译完成后,确认arch/x86/boot/bzImage生成成功,然后把它搬到一个方便启动的位置。
4.2 启动参数与常见开关
KASAN运行时的行为也可以通过内核启动参数控制,这几个参数在调试时很常用。
默认情况下,KASAN在捕获到第一个内存错误之后就会自我禁用,理由是害怕在异常状态中继续运行会产生大量级联报告,干扰判断。如果你希望一次运行抓到尽可能多的错误,加这个参数:
kasan_multi_shot另一个有用的参数是kasan.fault,它可以控制KASAN发现错误后的行为:
kasan.fault=report:只报告错误,继续执行(默认行为)。kasan.fault=panic:遇到错误立即panic,适合需要第一时间停机抓现场的自动化测试环境。
在CI环境里,我通常同时加上panic_on_warn=1和kasan.fault=panic,保证任何KASAN报告都会立刻转化为一个可被监控系统捕获的panic,而不是让测试继续跑下去污染结果。
用来调试的资料:CONFIG_KASAN开启后,内核日志中会打印一行类似下面这样的信息,看到它说明KASAN已经正常初始化:
Memory state around the buggy address: KernelAddressSanitizer initialized (syzkaller) or:如果是基于systemd的现代发行版,可以在/proc/cmdline里确认参数是否生效。
4.3 验证KASAN确实生效
有时候配置了半天,你不确定KASAN是不是真的在起作用。这里分享两个我常用的验证手段。
第一个方法,看启动日志。KASAN初始化成功时,dmesg里会有类似KernelAddressSanitizer initialized的输出,不同内核版本措辞略有差异,搜索kasan关键字基本能找到。
第二个方法是写一个故意越界的测试模块,这是最稳妥的验证方式。比如写一个非常简单的模块,在init函数里申请一块内存,然后故意往地址末尾之后写几个字节。这个模块加载后,如果KASAN工作正常,立刻会在dmesg里抛出一份slab-out-of-bounds报告。测试完卸载模块即可,代码就几行,不复杂。
验证用的驱动源码通常不需要纳入正式代码库,我一般放在tools/testing/selftests类似的临时目录,或者干脆在QEMU虚拟机里验证。下面是一段极简的触发代码思路:
static int __init kasandemo_init(void) { char *p = kmalloc(16, GFP_KERNEL); if (!p) return -ENOMEM; p[16] = 1; /* 越界写 */ kfree(p); return 0; }如果KASAN没有报告,先回头检查内核配置是否真的编进去了。常见的原因是CONFIG_KASAN被某个架构依赖限制住了,或者你改完.config后忘了执行make olddefconfig导致配置项被重置。
5. 实例拆解:一条KASAN报告的完整排查链路
5.1 use-after-free报告逐行解读
KASAN报告的信息密度很高,很多新手看到一屏英文就头大,其实每一行都有固定的含义。下面我用一份典型的use-after-free报告作为例子,逐行拆解。
================================================================== BUG: KASAN: use-after-free in mydriver_foo+0x1c/0x40 [mydriver] Write of size 4 at addr ffff0000c0582c00 by task kworker/u8:2/85 CPU: 2 PID: 85 Comm: kworker/u8:2 Tainted: P Hardware name: QEMU Standard PC (i440FX + PIIX, 1996) Call trace: dump_backtrace+0x0/0x340 show_stack+0x18/0x2c dump_stack_lvl+0x60/0x90 print_address_description+0x54/0x3a0 kasan_report+0x1a0/0x2d0 __asan_store4_noabort+0x28/0x38 mydriver_foo+0x1c/0x40 [mydriver] mydriver_work_handler+0x84/0x120 [mydriver] process_one_work+0x1d8/0x700 worker_thread+0x2c4/0x480 kthread+0xf8/0x120 ret_from_fork+0x10/0x20 Allocated by task 100: kmalloc_trace+0x1f0/0x3b0 mydriver_init+0x38/0x80 [mydriver] mydriver_probe+0x1c4/0x240 [mydriver] ... Freed by task 120: kfree+0x1d0/0x3c0 mydriver_cleanup+0x50/0xa0 [mydriver] mydriver_remove+0x84/0x180 [mydriver] ... The buggy address belongs to the object at ffff0000c0582c00 which belongs to the cache kmalloc-64 of size 64 The buggy address is located 0 bytes to the right of 16-byte region [ffff0000c0582bf0, ffff0000c0582c00) ==================================================================第一行是报告的灵魂:BUG: KASAN: use-after-free in mydriver_foo+0x1c/0x40,冒号前指明了错误类型,冒号后是出错时正在执行的函数名,可以理解为“凶手行凶时被拍到的画面”。后面紧跟Write of size 4 at addr ...,告诉你这是一次4字节的写操作,不是读操作,写的是哪个地址,哪个任务干的。
紧接着的Call trace是当前访问点的完整调用栈,它回答的问题是“谁、通过什么样的路径、访问了这块内存”。但注意,这份调用栈只代表访问发生的那一刻,并不代表问题根因就在这个函数里——真正的问题往往藏在对象分配和释放的调用栈里。
5.2 顺着调用栈定位根因
前面报告的精彩之处在Allocated by task和Freed by task两段。
Allocated by task 100显示这个对象是在mydriver_init里通过kmalloc_trace分配的,说明出生点很明确——这是驱动初始化时创建的一个结构体。
Freed by task 120显示它被mydriver_cleanup里的kfree释放了,而当前访问发生在mydriver_work_handler这个异步工作队列中。
看到这里,问题已经非常清楚了:一个对象在mydriver_init里出生,在mydriver_cleanup里被释放,但还有一个mydriver_work_handler工作队列在异步引用它。驱动在移除时只做了kfree,却没有确保所有对该对象持有引用的工作队列完成或取消,于是出现了一个悬空指针。这就是典型的生命周期管理问题。
这类问题的修复方向通常有三种:
- 用引用计数(kref/refcount)管理对象,确保最后一个引用释放时才真正
kfree。 - 移除驱动时,先
cancel_work_sync或flush_work,把异步任务同步完再释放对象。 - 用RCU保护对象访问,在release回调里延迟释放。
哪种方案更合适取决于你的代码结构,但KASAN报告已经帮你把故障路径完全勾勒出来了,修起来目标非常明确。
报告末尾还有一段非常重要的辅助信息:
The buggy address belongs to the object at ffff0000c0582c00 which belongs to the cache kmalloc-64 of size 64 The buggy address is located 0 bytes to the right of 16-byte region [ffff0000c0582bf0, ffff0000c0582c00)这段说的是:出问题的地址在kmalloc-64这个slab缓存里,对象总大小64字节,而当前访问的地址正好位于一个16字节合法区域的右侧0字节处。这里的“0 bytes to the right of”含义很关键——你访问的地址是曾经对象的一部分,但对象已经被释放,红区标记让这次访问暴露了。
排查到这一步,你通常还缺一个东西:出错指令对应的C代码行号。报告里给出的是函数偏移mydriver_foo+0x1c/0x40,如果你编译内核时保留了调试信息,可以用内核源码自带的faddr2line脚本把偏移转换成源码行号:
./scripts/faddr2line vmlinux mydriver_foo+0x1cvmlinux是编译内核时生成的带符号文件,不是压缩后的bzImage。faddr2line脚本会自动解析地址,输出对应的文件和行号,直接对照源码看现场即可。这是我几乎每次查KASAN报告必用的工具。
5.3 第二种常见报告:slab-out-of-bounds
use-after-free之外,另一类高频报告是slab-out-of-bounds,也就是越界访问。它与UAF的关键区别在于:对象并没有被释放,你访问的地址超出了它应有的范围。
一份典型的越界报告会这么写:
BUG: KASAN: slab-out-of-bounds in hci_uart_tx_wakeup+0x4c/0x... Read of size 8 at addr ffff0000c0584f00 by task kworker/u9:0/12 ... The buggy address is located 0 bytes to the right of 8-byte region [ffff0000c0584ef8, ffff0000c0584f00)看到“0 bytes to the right of 8-byte region”就知道,你申请了8字节,却试图读第9个字节,刚好踩到红区。这种越界常见原因包括:
- 循环边界没控制好,多读了一个元素。
- 结构体大小定义错误,比如驱动里手动算偏移,越过成员边界。
- 把错误类型的指针强转后访问,导致实际读写范围超出原对象。
这类报告相对好修,因为它已经把越界方向和距离都告诉你了。“0 bytes to the right”意味着你越过尾边界1字节;“N bytes to the left”则意味着你在头部之前越界了。结合kmalloc-64之类的缓存信息,你也可以确认对象实际分配大小是否符合预期。
如果你看到Read of size 8而分配大小是16,但报告说越界到了相邻对象,那么你的代码可能遍历了数组却没有检查边界。经验之谈:越界报告里,地址离红区越远,说明你的逻辑错误越离谱,通常不是一个字节的偏差,而是索引号完全算错了。
5.4 修复后如何回归验证
找到根因并修复之后,KASAN的价值还没有结束——你需要用同样的压力路径重新验证修复是否有效。
我的标准流程是:修完代码后,在开KASAN的内核上重跑之前触发问题的负载,最好能连续跑几小时或一个晚上。如果KASAN不再报告同样的错误,基本可以确认修复有效。但这还不够,建议再多跑几种不同负载,因为生命周期类bug往往会在多个异步路径上出现类似问题,你修好了一个,另一个可能还在。
如果修复代码涉及引用计数或RCU改动,建议同时打开KCSAN(数据竞争检测器)跑一轮。KASAN管内存合法性,KCSAN管并发访问的竞争问题,两者一起用,覆盖面更全面。不过KCSAN的开销也不小,别指望在生产内核里常开。
6. 只有长时间跑过KASAN才会知道的坑
6.1 性能开销不能只看spec
很多人对KASAN的第一反应是:就是编译时加个选项嘛,慢点就慢点。但实际用过之后,你要有心理准备:Generic KASAN的运行时开销绝对不只是“慢一点”。
在文件系统读写、网络收包这类热路径上,性能下降可能达到2倍甚至更多。原因是每次普通内存访问前都多了一次影子内存检查和潜在的分支跳转,缓存和流水线都会受影响。更隐蔽的是,内存占用也明显上升:影子内存本身占了约1/8的物理内存映射空间,而每个分配对象周围的红区还会让slab的实际内存消耗膨胀。你在一个内存只有2GB的嵌入式板子上开Generic KASAN,系统可能直接OOM。
所以我的建议很明确:不要把KASAN当作长期开启的默认配置。它应该属于专门的“调试内核”和CI测试环境。如果你需要在生产设备上做有限度的内存错误监控,宁可考虑HW_TAGS KASAN(如果能满足硬件条件),也别把Generic模式直接丢到生产环境。
6.2 误报与盲区
KASAN的误报率相比其他动态检测工具要低一些,但它绝对不是零误报。我自己遇到过几种容易误判的情况,这里列出来帮你少走弯路。
第一种是生命周期比较“野”的对象。有些驱动或框架代码会故意从slab缓存中释放对象后,马上再复用它来保存临时数据,这种“释放即复用”手法在没有KASAN的时代是安全的,但在KASAN眼里,释放后的写入会被当成use-after-free。遇到这种报告,不要急着断定代码有bug,先在邮件列表或者代码注释里找找有没有类似的“释放后复用”约定。如果确实是有意为之,可以考虑用kmem_cache的SLAB_TYPESAFE_BY_RCU这类机制来声明合规的重用方式,让KASAN理解这个模式。
第二种是汇编代码绕过了检查。前面说过,KASAN依赖编译器插桩,但内核里部分热路径会手写汇编,这些访问不在检查范围之内。如果KASAN长时间静默,而崩溃现象仍然复现,别认定KASAN没用——它很可能根本没有“看”到那次访问。
第三种是关于vmalloc和DMA内存的盲区。早期KASAN对vmalloc区域覆盖不完整,对ioremap映射的MMIO内存更是完全不检查。别指望KASAN能帮你抓到驱动访问外设寄存器越界的问题,那是设备模型和ioremap层的问题。以内核的更新节奏来看,新版对vmalloc的覆盖已经好了不少,但如果你的驱动大量使用vmalloc,建议先确认当前内核版本对vmalloc的KASAN支持是否到位。
还有一个很反直觉的现象:KASAN报告出来了,但不代表那个对象的内存就真的坏在了那一行。有一种情况是,报告中显示的“当前访问点”其实是一个合法访问,只不过它读到的地址本身已经是一个悬空指针,这个悬空指针是在更早的另一个模块里被写坏的。换句话说,报告是“压垮骆驼的最后一根稻草”。这时候要多看Allocated和Freed栈,配合Freed栈之前的调用路径一起分析,才能找到最初弄脏指针的位置。
6.3 与其它调试工具共存的注意事项
KASAN不是唯一的内存调试利器,开发环境里还有KMSAN、KCSAN、UBSAN等工具,它们之间有些能共存,有些会打架。
最大的冲突是KASAN和KMSAN。KMSAN用于检测未初始化内存读取,它同样依赖影子内存,在x86_64上无法和KASAN同时开启。如果你需要排查“读到了未初始化的堆内存”这类问题,只能二选一。好在这类问题场景相对特殊,多数情况下KASAN优先级更高。
KCSAN与KASAN则可以共存,前者检测数据竞争,后者检测内存合法性。功能上正好互补。我在CI环境里经常同时开这两个,跑一轮压力测试,既能抓越界又能抓竞争。副作用是性能开销相乘,测试时间可能需要拉长。
另一个经验是开KASAN时注意和CONFIG_DEBUG_PAGEALLOC、CONFIG_SLUB_DEBUG这类工具的联动。KASAN启用后,slub层面也会启用一些 debug 机制,红区检测和slub自身的校验可能同时触发,打印信息会互相干扰。如果我明确要排查的是内存越界,一般只开KASAN,不开额外干扰项,让报告干净利落。
遇到编译问题也要留心。老版本GCC不支持-fsanitize=kernel-address,编译会直接报错。新版GCC和Clang支持得都很好,但如果你的工具链太老,要么升级GCC,要么换Clang。确定了工具链之后,make olddefconfig这一步千万别跳过,KASAN牵扯的内核依赖不少,漏掉依赖会让配置项莫名其妙消失。
最后还想提醒一点:KASAN报告里的CPU和PID对多核系统定位并发问题很有帮助。同一个对象在同一时刻被两个CPU访问,KASAN会分别打报告,通过CPU列你可以判断是锁没锁好还是任务迁移的问题。在做并发排查时,把多个报告放在一起对比,往往能找到访问模式的规律。
如果你准备在自己的项目里引入KASAN,我最想分享的一个建议是:不要只在出问题时临时开一下,而是把KASAN内核放进CI或者日常开发环境里长期跑。我在做驱动开发时,很多bug就是通过跑一晚上压力测试才暴露的,隔天起来看到报告,修复成本低得多。内核里大部分内存类bug,只要被KASAN抓到过一次,其实就已经定位了,剩下的事只是修生命周期管理的问题。与其在线上环境反复扑空,不如把KASAN当作一道日常防线,让内存错误在上线之前就现形。