☰
Linux 内核内存踩踏检测:KASAN 原理、配置与报告解读
2026/9/29 1:56:12 网站建设 项目流程

内存踩踏这四个字,干内核这行的人听到多少都会心里一紧。它不像空指针那样一崩了之,而是某个模块偷偷越界写了几字节,把隔壁邻居的数据结构改得面目全非,最后炸在毫不相干的地方——报错栈指向 A,真凶却在 B。我最早遇到这类问题是在一个字符设备驱动里,insmod之后运行几分钟内核就随机 panic,Oops栈每次都不一样,查了整整两天才定位到是一处kmalloc之后多写了 4 个字节。那次之后我才认真把 KASAN(Kernel 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_debug=FZPU。F 是 sanity check,Z 是 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_KASAN=y,这是总开关,不开后面全白搭。第二个是CONFIG_KASAN_GENERIC=y,这是经典模式(也叫 generic KASAN),也是绝大多数 x86 场景下的默认选择,它的检测最全面。与之对应的还有CONFIG_KASAN_SW_TAGS和CONFIG_KASAN_HW_TAGS,这俩是基于内存标签的方案,主要在 arm64 上用,原理和实现跟 generic 不太一样,第一篇先不展开。

第三个是CONFIG_KASAN_INLINE=y。这个决定插桩方式:inline 模式把检查代码直接内联到每次访问处,性能好一点但内核体积大;outline 模式把检查代码抽成独立函数调用,体积小但性能稍差。实测下来,如果是跑在虚拟机或者开发板上做调试,inline 是更好的选择,省下来的时间比省空间值钱。第四个是CONFIG_KASAN_STACK=y,前面说过,它负责栈越界检测,但默认是 n,除非你怀疑栈有问题,否则建议先别开,开了容易误报。

第五个是CONFIG_KASAN_VMALLOC=y,这个要多说两句。早期的 KASAN 对 vmalloc 区域是没法有效检测的,直到这个选项引入才算补上。如果你要调试的是模块越界、或者用了大块 vmalloc 缓冲区的驱动,务必打开它,不然这类 bug 你会完全抓不到。第六个是CONFIG_KASAN_OUTLINE=y,跟 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.fault=report是默认,只打印报告;kasan.fault=panic是直接 panic,适合做 CI 回归,确保出问题就挂掉;kasan.fault=warn则只发警告。我一般调 bug 时用report,跑回归时用panic,这样 CI 不会漏掉问题。

如果你用的是 QEMU 做开发,启动参数大概长这样:qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append "console=ttyS0 kasan_multi_shot kasan.fault=report" ...。用串口把日志导出来,配合dmesg抓报告,效率很高。用物理机的话直接在 GRUB 里改linux行加参数即可。

3.3 确认 KASAN 是否真的生效

跑起来了不等于生效了,这一步千万别省。验证方法有几个。

最直接的是看启动日志:dmesg | grep -i kasan,正常情况下你会看到类似kasan: KernelAddressSanitizer initialized的输出,可能还带着 shadow 区域的地址范围。没看到这行,说明根本没启用,回去检查配置。

第二种是看/proc/config.gz或者/boot/config-$(uname -r),确认CONFIG_KASAN=y真的编进去了。有时候配置改了但忘了重新编译,或者用了旧的.config,都可能导致开没开你自己都不确定。

第三种最可靠:故意写个会越界的测试模块验证一下。申请一个 16 字节的kmalloc缓冲区,然后往第 20 字节写。如果 KASAN 生效,会立刻打印一份slab-out-of-bounds报告;如果没生效,可能什么都不打印,或者过一会儿才在别处崩。这个自测用例花五分钟就能写完,但能帮你省掉后面几个小时的"为什么没报错"的困惑。我自己养成了一个习惯:每换一个新环境,第一件事就是跑这个越界自测,确认工具链是通的。

4. 读懂一份 KASAN 报告

4.1 报告的头尾结构

KASAN 报告看着吓人,其实结构很规整。从头到尾大概分四段:错误摘要、崩溃现场、分配/释放栈、影子内存快照。我拿一份典型的 slab 越界报告来拆。

开头的BUG: KASAN: slab-out-of-bounds in my_func+0x123/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_alloc+0x88,那问题代码大概率就在这个函数附近,往它前后的内存操作去找准没错。

释放栈在 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.fault=panic让它出错即停,或者提高日志级别过滤
内存不足 OOM影子区和隔离区吃内存调小隔离区,或换更大内存的机器
编译报错找不到影子符号配置冲突,比如 tag 模式和 generic 同时开两者只能开一个,关掉多余的那个

这张表里的每一条我都踩过至少一次,尤其是"只报一次就没了"和"内存不足",第一次遇到时都卡了半天。建议你搭好环境后把这张表存下来,出问题先对一遍,能省不少时间。

6. 几个实战心得与后续方向

聊完原理和操作,最后分享几个我自己踩坑攒下来的经验,这些是文档里不会写的。

第一,别急着改代码,先把报告存下来。KASAN 报告的每一行都有信息,很多人一看崩溃就手忙脚乱去改代码,改了半天把原始现场破坏了。正确做法是先把完整报告(包括分配栈、释放栈、影子表)导出来存档,然后再动手。我第一次排查时就是急着改,结果改完问题"消失"了,其实是把 bug 藏得更深了。

第二,善用kasan.fault=panic配合自动化。如果你想做回归测试、确保每次改动都不引入新的越界,就在 CI 里用 panic 模式跑,一旦有问题立刻挂掉,日志里就有完整报告。这比事后人肉翻日志高效太多。我在一个驱动项目里就是靠这套 CI,几乎拦下了所有后来引入的越界。

第三,栈检测慎开。CONFIG_KASAN_STACK开了之后误报会明显增多,因为内核里不少代码会做栈上的技巧性操作,KASAN 分不清意图。除非你真的怀疑栈有问题,否则先别开,把精力放在更常见的 slab 和 vmalloc 上。

第四,学会看影子表判断 bug 类型。前面说过,fc是红区、fb是已释放,这个规律比读错误类型关键词更直接。看报告时先从影子表判断这是哪类越界,再回头看调用栈,思路会清晰很多。这个技巧我用了几年,几乎没判断错过。

第五,别忘了 KASAN 只是工具链的一环。它擅长抓确定性的内存越界,但竞态、时序、逻辑错误它管不着。真正排查复杂问题时,往往要 KASAN 配合 KCSAN(并发检测)、配合lockdep(死锁检测)、配合代码走查一起上。把 KASAN 当成第一步——先把内存层面干净的问题筛掉,剩下的才有资格谈别的。

第一篇就先到这。环境搭起来了、报告能看懂了,下一步自然是怎么用它去解决更具体的问题:比如怎么从分配栈反推代码位置、怎么区分真越界和误报、怎么在大型驱动里用 KASAN 缩小排查范围。这些我会放到第二篇里展开,因为每个都值得单独细讲。如果你现在手上正好有个随机崩溃的驱动,别犹豫,先把 KASAN 跑起来,大概率第一次跑完它就会给你一份指名道姓的报告——那种"终于抓到你了"的感觉,值得体验一次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询