
内存踩踏这四个字干内核这行的人听到多少都会心里一紧。它不像空指针那样一崩了之而是某个模块偷偷越界写了几字节把隔壁邻居的数据结构改得面目全非最后炸在毫不相干的地方——报错栈指向 A真凶却在 B。我最早遇到这类问题是在一个字符设备驱动里insmod之后运行几分钟内核就随机 panicOops栈每次都不一样查了整整两天才定位到是一处kmalloc之后多写了 4 个字节。那次之后我才认真把 KASANKernel Address Sanitizer这套东西吃透。这篇就围绕 linux kernel 内存踩踏的检测把 KASAN 的原理、配置、实操和踩坑经验一次讲清楚适合正在写驱动、调内核模块、或者被内存越界折磨过的朋友。第一篇先解决看得见的问题也就是怎么把 KASAN 跑起来、怎么看懂它吐出来的报告。1. 从一次随机崩溃说起内存踩踏为什么这么难缠1.1 内存踩踏到底是什么先把概念说清楚。内存踩踏不是一个内核官方术语它是中文圈对内存越界写、释放后使用use-after-free、重复释放double free这类问题的统称。共同点是错误发生的那一刻和崩溃的那一刻往往不在同一个地方。比如你申请了 64 字节的缓冲区实际写了 68 字节多出来的 4 字节落到了紧邻的下一个对象头部。这个对象可能是个链表指针可能是个引用计数。当下次有人遍历这个链表、或者做引用计数增减时读到的是被污染的值于是崩溃点就跑到了完全无关的函数里。这种案发现场和凶手分离的特性是内存踩踏排查最要命的地方。传统的printk大法基本失效因为你根本不知道往哪打印。用dump_stack也帮不上忙崩溃时的调用栈是受害者不是施害者。而且这类问题有个很讨厌的规律它高度依赖内存布局。同一份代码换个内核版本、加个调试选项、甚至换台机器表现可能完全不同。我见过一个驱动在开发机上跑一周都没事搬到测试机上十分钟就崩——原因仅仅是两个分配对象的相对位置变了越界写刚好踩到了关键字段。1.2 传统排查手段为什么常常失灵在没有 KASAN 的年代大家排查这类问题主要靠三招各有各的局限。第一招是 SLUB 的调试选项比如CONFIG_SLUB_DEBUG、slub_debugFZPU。F 是 sanity checkZ 是 redzone 填充P 是 poison 填充U 是 user tracking。这套东西的原理是在对象前后填上特定的魔数比如0x6b之类分配和释放时检查这些魔数有没有被改。它对付一些简单的、在对象尾部越界的写是有效的但问题是它只在特定的检查点才验证中间过程完全不管而且检测粒度是对象级的对象内部越界它看不出来。第二招是CONFIG_DEBUG_PAGEALLOC把每个页单独映射释放后立刻 unmap这样 use-after-free 一访问就页错误。它的局限是只能抓页级别的越界几十字节的越界它管不着而且它会让内存占用激增、性能暴跌。第三招纯靠人肉把可疑代码翻来覆去地读配合二分注释法缩小范围。这招最笨但有时最有效问题是太耗时间一个中等规模驱动可能要读上一整天。这几种手段的共性是要么检测粒度太粗要么覆盖的时机不全要么成本高得没法常态化使用。KASAN 的价值就在于它把检测做到了字节级而且是全时段的——每一次内存访问都被检查不管你越界多少、什么时候越界。1.3 KASAN 在这类问题里的定位KASAN 属于动态分析工具原理是编译期插桩加运行期检查。它靠在内存访问指令前后插入检查代码配合一块叫影子内存的辅助区域来记录每个字节的可访问状态。任何一个load或store只要碰到了标记为不可访问的区域立刻报错并打印详细信息。它能抓的典型问题包括slab 对象的越界读写、栈上的越界、全局变量的越界、use-after-free、以及 vmalloc 区域的越界。它抓不到的问题也很明确没有经过它插桩的代码路径比如纯汇编、或者没开插桩的第三方模块、性能敏感到不能接受的场景、以及一些竞态导致的问题那得用 KCSAN。所以 KASAN 的定位是开发调试工具不是生产环境常驻工具。它更适合用在开发内核、CI 回归测试、以及问题复现阶段。搞清楚这个定位很重要不然你会纠结于它那感人的性能开销反而用不对地方。2. KASAN 的设计原理影子内存怎么把看不见变看得见2.1 影子内存与 1/8 映射KASAN 的核心数据结构叫影子内存shadow memory。它为内核地址空间里的每 8 个字节分配 1 个字节的影子字节用这 1 个字节记录那 8 个字节里有几个字节是合法可访问的。这个 8:1 的映射比例是个精巧的工程取舍——如果做到 1:1检测粒度更细但影子内存要吃掉 100% 的物理内存直接没法用如果做 1:64那就只能检测大块越界意义不大。8:1 的好处是影子区只占实际内存的八分之一同时在 64 位地址空间下影子区能够塞进一段连续的、不会被正常分配打扰的区域。地址换算是理解 KASAN 的钥匙。给定一个内核虚拟地址addr它对应的影子地址是这样算的先把addr右移 3 位等价于除以 8再加上一个架构相关的常量KASAN_SHADOW_OFFSET。用公式写出来就是shadow (addr 3) KASAN_SHADOW_OFFSET。这个 offset 是各架构自己定的目的是让内核直接映射区、vmalloc 区、模块区等不同区域的影子地址能够落在同一段连续的物理空间里方便页表一次性建立映射。为什么右移 3 位而不是别的数字因为 2 的 3 次方是 8对应每 8 字节一个影子字节。所以设计参数是定死的KASAN_SHADOW_SCALE_SHIFT 3。你不需要记具体的 offset 数值那玩意儿各架构、各版本都不一样硬背没意义记公式和比例就够了。2.2 影子值编码与访问检查影子字节里的值分两类一类是合法的计数另一类是非法的标记。先看合法计数。值 0 表示这 8 个字节全部可访问。值 1 到 7 表示前 N 个字节可访问、剩下的不可访问。举个例子如果你kmalloc(5)申请了 5 字节那么覆盖它的 8 字节区域的影子值就是 5意思是前 5 字节合法、后 3 字节越界。这就解释了为什么 KASAN 能抓到那种只多写了两三个字节的精确越界。再看非法标记。这些值都是负数也就是最高位为 1具体数值在0xFA到0xFE这个区间里。它们用来编码这块内存属于什么类型的不可访问。比如 slab 对象的红区redzone、已经被释放的 slab 对象、全局变量的红区、栈的左右红区、vmalloc 的无效区域等等各自有各自的编号。这样当 KASAN 报错时它能告诉你你访问的是已释放的 slab 对象还是你访问的是 vmalloc 空洞对你判断 bug 类型帮助极大。具体的数值我建议你别死记因为不同内核版本对这几个常量的定义有过调整。真正可靠的做法是去看你手上内核源码里的mm/kasan/kasan.h和mm/kasan/report_generic.c那里有权威定义。但有个通用规律可以记0xFA附近通常跟 slab 红区/释放相关0xFE附近跟 vmalloc 相关栈相关的从0xF1开始排。知道这个大致分区看报告时心里就有数了。访问检查本身很简单编译器在每条内存访问指令前插一段代码load/store时先算出影子地址、读出影子值然后判断本次访问的范围是否落在合法区间内。如果是kmalloc出来的 5 字节对象你从偏移 0 读 8 字节就非法从偏移 4 读 2 字节也非法。判断通过就继续执行判断不通过就跳进 KASAN 的报错例程。2.3 红区与三个分配域的插桩光有影子内存还不够还得有东西去标记哪些内存不可访问。这部分工作由红区redzone和分配器钩子共同完成。红区的思路比较直白在分配对象的时候故意在对象前后各留一段空隙把这些空隙的影子值标成不可访问。这样你一旦越界写到红区就会被逮个正着。对象之间永远不可能严丝合缝地贴在一起越界写必然会先碰到红区而不是直接污染邻居的数据。KASAN 对三个主要的分配域分别做了插桩。第一个是 slab 分配器kmalloc、kmem_cache_alloc走的都是它它会在分配和释放时标记影子内存并记录分配和释放的调用栈。释放之后的 slab 对象在重新分配前影子值会保持已释放状态所以 use-after-free 一访问就报。第二个是 vmalloc 分配器它主要处理大块的内存申请和模块加载区域做法是直接标记整段区域的影子页表层面配合。第三个是栈通过在编译期给每个栈帧加左右红区来实现但这个功能因为开销和误报问题在后来的版本里默认是关的需要手动打开。理解这三者的差异很重要因为它决定了你遇到 bug 时该往哪个方向查。slab 相关的 bug 最常见报告里会说清楚是kmalloc还是kmem_cache、对象多大、在哪被释放。vmalloc 相关的通常是模块加载或大块缓冲区。栈相关的报告相对少见一旦出现往往说明有递归过深或者局部数组溢出排查起来更费劲。3. 从零把 KASAN 跑起来编译配置与启动参数3.1 关键配置项逐条拆解要把 KASAN 用起来第一步是选对配置。打开make menuconfig进Kernel hacking-Memory Debugging你会看到一堆 KASAN 相关的选项。我把关键几个挑出来说明其余的按需开就行。第一个是CONFIG_KASANy这是总开关不开后面全白搭。第二个是CONFIG_KASAN_GENERICy这是经典模式也叫 generic KASAN也是绝大多数 x86 场景下的默认选择它的检测最全面。与之对应的还有CONFIG_KASAN_SW_TAGS和CONFIG_KASAN_HW_TAGS这俩是基于内存标签的方案主要在 arm64 上用原理和实现跟 generic 不太一样第一篇先不展开。第三个是CONFIG_KASAN_INLINEy。这个决定插桩方式inline 模式把检查代码直接内联到每次访问处性能好一点但内核体积大outline 模式把检查代码抽成独立函数调用体积小但性能稍差。实测下来如果是跑在虚拟机或者开发板上做调试inline 是更好的选择省下来的时间比省空间值钱。第四个是CONFIG_KASAN_STACKy前面说过它负责栈越界检测但默认是 n除非你怀疑栈有问题否则建议先别开开了容易误报。第五个是CONFIG_KASAN_VMALLOCy这个要多说两句。早期的 KASAN 对 vmalloc 区域是没法有效检测的直到这个选项引入才算补上。如果你要调试的是模块越界、或者用了大块 vmalloc 缓冲区的驱动务必打开它不然这类 bug 你会完全抓不到。第六个是CONFIG_KASAN_OUTLINEy跟 inline 是二选一别同时开。还有两个辅助选项值得提CONFIG_KASAN_KUNIT_TEST和CONFIG_TEST_KASAN它们会编译一堆自测用例用来验证 KASAN 本身是否工作正常。第一次搭建环境时建议打开跑一遍测试确认工具链没问题之后再关掉。3.2 编译、安装与启动配置选好之后就是编译。这里有个坑我先提醒KASAN 会显著拉长编译时间因为几乎每个编译单元都要被插桩。我的一台四核虚拟机上全量编译带 KASAN 的内核比不带慢了一倍多如果你的机器配置不高做好心理准备或者用make -jN把并发拉满。编译命令跟平时没区别make -j$(nproc)出内核镜像make modules_install make install装到系统里。真正需要注意的是启动参数。默认情况下 KASAN 在报第一次错之后会把自己关掉避免刷屏如果你想让它多抓几次可以加kasan_multi_shot。这个在多 bug 场景下很有用一次跑完能收一摞报告。新一点的内核还支持kasan.fault参数控制报错行为。kasan.faultreport是默认只打印报告kasan.faultpanic是直接 panic适合做 CI 回归确保出问题就挂掉kasan.faultwarn则只发警告。我一般调 bug 时用report跑回归时用panic这样 CI 不会漏掉问题。如果你用的是 QEMU 做开发启动参数大概长这样qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append consolettyS0 kasan_multi_shot kasan.faultreport ...。用串口把日志导出来配合dmesg抓报告效率很高。用物理机的话直接在 GRUB 里改linux行加参数即可。3.3 确认 KASAN 是否真的生效跑起来了不等于生效了这一步千万别省。验证方法有几个。最直接的是看启动日志dmesg | grep -i kasan正常情况下你会看到类似kasan: KernelAddressSanitizer initialized的输出可能还带着 shadow 区域的地址范围。没看到这行说明根本没启用回去检查配置。第二种是看/proc/config.gz或者/boot/config-$(uname -r)确认CONFIG_KASANy真的编进去了。有时候配置改了但忘了重新编译或者用了旧的.config都可能导致开没开你自己都不确定。第三种最可靠故意写个会越界的测试模块验证一下。申请一个 16 字节的kmalloc缓冲区然后往第 20 字节写。如果 KASAN 生效会立刻打印一份slab-out-of-bounds报告如果没生效可能什么都不打印或者过一会儿才在别处崩。这个自测用例花五分钟就能写完但能帮你省掉后面几个小时的为什么没报错的困惑。我自己养成了一个习惯每换一个新环境第一件事就是跑这个越界自测确认工具链是通的。4. 读懂一份 KASAN 报告4.1 报告的头尾结构KASAN 报告看着吓人其实结构很规整。从头到尾大概分四段错误摘要、崩溃现场、分配/释放栈、影子内存快照。我拿一份典型的 slab 越界报告来拆。开头的BUG: KASAN: slab-out-of-bounds in my_func0x123/0x456是摘要告诉你错误类型、发生在哪个函数、哪个地址。紧接着的Write of size 4 at addr ffff... by task insmod/1234说明这次是写操作、写 4 字节、由哪个进程触发。注意这里的by task很重要它告诉你施害者是谁而不只是受害者。再往下是Call Trace也就是崩溃时的调用栈。这里要提醒一点这个栈是检测到越界的那一刻的栈不是越界写发生时的栈。对平凡的越界写来说两者是同一个因为检查就在访问指令旁边。但对 use-after-free 这类问题栈同样能准确反映访问点所以还是有价值的。中间两段是Allocated by task和Freed by task分别记录对象最初被谁分配的、以及被谁释放的。这两段对定位 use-after-free 至关重要因为它们把对象的生命周期完整串了起来。很多时候你看到释放栈就恍然大悟原来是先kfree了还在用。4.2 影子字节表怎么读报告最后那段Memory state around the buggy address是影子内存快照很多人到这就跳过其实它信息量很大。它以出错地址为中心前后各打印若干行每行 16 个字节对应 128 字节的实际内存。读法是这样的每个十六进制值就是一个影子字节对应 8 字节实际内存。00表示全可访问01到07表示前几字节可访问fa、fb、fc这类表示各种不可访问区域。出错行前面会有一个符号标出来。拿前面那个 64 字节对象越界的例子你会看到一长串fc fc fc表示红区而对象本体那一行的影子值可能是00 00 00 00 00 00 00 00因为对象对齐后占满了整行 8 字节乘 16 组。有个细节值得注意fc这类红区值刚好落在用户可写区域的边界之外当越界写碰到它时KASAN 立刻知道这是红区。所以你在报告里看到红区被入侵基本就能确认是经典的对象尾部越界。而如果出错行的影子值是fb已释放标记那多半是 use-after-free。这个判断方法比看错误类型关键词更直观我经常两种对照着看。4.3 分配栈与释放栈的价值很多人只看出错地址和函数名忽略了分配栈这是很可惜的。分配栈告诉你这个对象是从哪来的、走的哪条分配路径、大小是多少往往能直接指向问题代码。比如你看到分配栈里是my_driver_alloc0x88那问题代码大概率就在这个函数附近往它前后的内存操作去找准没错。释放栈在 use-after-free 场景里的价值更高。它会告诉你对象是在哪个函数被释放的、调用路径是什么。有时候你会看到释放发生在中断上下文或者工作队列里而访问发生在进程上下文这就提示你存在并发时序问题单纯的代码走查还不够得考虑加锁或者引用计数。我自己的习惯是看到报告先不看错误类型直接从中间的分配栈和释放栈开始读搞清楚这个对象的来龙去脉再回头看出错地址。这个顺序能帮你更快建立场景感比一上来就盯着崩溃函数有效得多。5. 实操中的坑与性能权衡5.1 误报、漏报与抑制KASAN 不是万能的误报和漏报都会遇到得知道怎么应付。先说误报。最常见的是它报了一个实际上合法但不太规范的访问。比如某些内核代码故意做重叠的内存拷贝、或者访问了某些元数据区域这些在正常逻辑下没问题但 KASAN 不知道你的意图一律按越界处理。这种时候可以用KASAN_NOCHECK相关的标注把特定函数或变量排除掉或者调整红区大小。但我要提醒抑制要谨慎别为了消掉一个报告就乱关检查很可能顺手把一个真 bug 也屏蔽了。每次抑制都要问自己一句这个访问真的是设计意图吗。再说漏报。KASAN 漏报主要有三个原因一是代码没被插桩比如纯汇编实现的关键路径、或者用了-fno-sanitize编译的第三方模块二是访问发生在 KASAN 初始化之前比如很早期的启动代码三是影子内存本身被别的东西踩了这种情况极少但确实存在。排查漏报最直接的办法是缩小范围、单独复现、看反汇编确认目标函数里有没有__asan相关的调用。5.2 性能开销实测与取舍性能是绕不开的话题。KASAN 的开销主要体现在三块内存占用、运行速度、以及编译时间。内存占用方面影子区本身要吃掉实际内存的八分之一再加上每个对象前后的红区、以及为了支持 use-after-free 检测而延迟释放的隔离区quarantine综合下来内存占用增长个百分之三十到五十很正常。如果你的机器内存紧张跑带 KASAN 的内核可能会触发 OOM这时候可以适当调小隔离区大小用kasan.quarantine之类的参数控制。运行速度方面我实测下来的结论是纯计算型负载影响不大可能慢个百分之二三十但内存密集型负载比如频繁分配释放的驱动慢两三倍都有可能。这是因为每次内存访问都多了一组影子地址计算和检查指令访问越频繁开销越明显。取舍的原则很简单开发调试阶段全开宁可慢也要抓到问题性能测试和回归测试阶段如果你对性能数字敏感可以关掉 KASAN或者只保留CONFIG_KASAN_OUTLINE这种开销较小的模式生产环境坚决不开。记住它是显微镜不是日常眼镜。5.3 常见问题速查调试 KASAN 环境本身也有一堆问题我把最常见的整理成一张表方便你对着查。现象可能原因处理办法启动日志里没有 KASAN 初始化信息配置没开或者没重新编译检查.config里的CONFIG_KASAN确认后重新编译内核起来了但越界不报插桩方式不对或者目标是 vmalloc 未开检测确认CONFIG_KASAN_INLINE打开CONFIG_KASAN_VMALLOC只报一次就没了默认单次模式启动参数加kasan_multi_shot报告刷屏把串口冲爆内核里有循环触发的 bug加kasan.faultpanic让它出错即停或者提高日志级别过滤内存不足 OOM影子区和隔离区吃内存调小隔离区或换更大内存的机器编译报错找不到影子符号配置冲突比如 tag 模式和 generic 同时开两者只能开一个关掉多余的那个这张表里的每一条我都踩过至少一次尤其是只报一次就没了和内存不足第一次遇到时都卡了半天。建议你搭好环境后把这张表存下来出问题先对一遍能省不少时间。6. 几个实战心得与后续方向聊完原理和操作最后分享几个我自己踩坑攒下来的经验这些是文档里不会写的。第一别急着改代码先把报告存下来。KASAN 报告的每一行都有信息很多人一看崩溃就手忙脚乱去改代码改了半天把原始现场破坏了。正确做法是先把完整报告包括分配栈、释放栈、影子表导出来存档然后再动手。我第一次排查时就是急着改结果改完问题消失了其实是把 bug 藏得更深了。第二善用kasan.faultpanic配合自动化。如果你想做回归测试、确保每次改动都不引入新的越界就在 CI 里用 panic 模式跑一旦有问题立刻挂掉日志里就有完整报告。这比事后人肉翻日志高效太多。我在一个驱动项目里就是靠这套 CI几乎拦下了所有后来引入的越界。第三栈检测慎开。CONFIG_KASAN_STACK开了之后误报会明显增多因为内核里不少代码会做栈上的技巧性操作KASAN 分不清意图。除非你真的怀疑栈有问题否则先别开把精力放在更常见的 slab 和 vmalloc 上。第四学会看影子表判断 bug 类型。前面说过fc是红区、fb是已释放这个规律比读错误类型关键词更直接。看报告时先从影子表判断这是哪类越界再回头看调用栈思路会清晰很多。这个技巧我用了几年几乎没判断错过。第五别忘了 KASAN 只是工具链的一环。它擅长抓确定性的内存越界但竞态、时序、逻辑错误它管不着。真正排查复杂问题时往往要 KASAN 配合 KCSAN并发检测、配合lockdep死锁检测、配合代码走查一起上。把 KASAN 当成第一步——先把内存层面干净的问题筛掉剩下的才有资格谈别的。第一篇就先到这。环境搭起来了、报告能看懂了下一步自然是怎么用它去解决更具体的问题比如怎么从分配栈反推代码位置、怎么区分真越界和误报、怎么在大型驱动里用 KASAN 缩小排查范围。这些我会放到第二篇里展开因为每个都值得单独细讲。如果你现在手上正好有个随机崩溃的驱动别犹豫先把 KASAN 跑起来大概率第一次跑完它就会给你一份指名道姓的报告——那种终于抓到你了的感觉值得体验一次。