跳到主要内容

5.3 理解 KASAN 的基本原理

上一节我们列了一张长长的「愿望清单」,把内核里可能出现的各种内存错误都扒拉出来晒了一遍。现在的问题是:KASAN 到底是怎么把这张清单上的坏蛋一个个抓出来的?

在回答这个问题之前,我们要先建立几个关键认知。这几个认知如果不搞清楚,后面看日志就像看天书。

编译时插桩:它是怎么做到的?

首先,KASAN 是一个动态分析工具。这意味着什么?

意味着它不是靠静态扫描代码找 Bug 的,它是跑起来才能干活。如果你的代码路径从来没有被执行过,或者你的测试用例写得不够烂(没有覆盖到边界情况),KASAN 也就是个摆设。这也是为什么我一直强调「好的测试用例」和「Fuzzing(模糊测试)」的重要性——你不去撞墙,KASAN 就不会报警。

这就引出了它的核心技术:Compile-Time Instrumentation(编译时插桩)

这名字听着高大上,其实原理很暴力。当我们使用 GCC 或 Clang 编译内核时,只要带上 -fsanitize=kernel-address 这个选项,编译器就会在你的每一条内存访问指令前后,偷偷塞进一些「检查代码」。

这些检查代码干了什么事?

它维护了一块额外的内存区域,叫 Shadow Memory(影子内存)

你可以把影子内存理解为真实内存的「账本**——或者更形象一点,是放在屋外的监控摄像头**。

但摄像头这个比喻有一点不对:摄像头是被动录像的,而 KASAN 的影子内存是主动拦截的。它是这样工作的:真实内存每 8 个字节,会在影子内存里对应 1 个字节。

  • 如果影子字节是 0,说明这 8 个字节都可以访问。
  • 如果影子字节是 1,说明只有第一个字节可访问。
  • 如果影子字节是负数(比如 0xFF),说明这块内存根本就是非法的。

但真实情况比这更暴力:每次你读写内存时,编译器插入的检查代码会先去查这个「账本」,发现账本上写着「红区」或者「已释放」,直接当场触发 panic。它不是在记录问题,它是在执行私刑。


代价是什么?什么时候能用?

这种暴力检查是有代价的。主要来自两个方面:

  1. 时间(CPU):每次内存访问都要先查影子内存,指令多了,分支预测也可能被打乱。
  2. 空间(RAM):影子内存本身要占地方。

这里有个反直觉的事实:KASAN 的 CPU 开销其实非常感人——通常只有 2 倍到 4 倍。如果你用过 Valgrind 这种动态插桩工具(它的开销通常是 20 倍甚至 50 倍),你会觉得 KASAN 简直快得飞起。

真正的痛点在 RAM

还记得刚才那个比例吗?1:8。也就是每 8 字节真实内存就要消耗 1 字节影子内存。这对于 x86_64 这种动辄拥有 128 TB 内核虚拟地址空间(VAS)的架构来说,意味着 KASAN 要划拉走 16 TB 的虚拟地址空间用于影子内存(虽然物理内存未必真的用掉那么多,但地址空间资源是被占用了)。

对于企业级服务器,这都不是事儿。但对于资源受限的嵌入式系统——比如你的 Android 手机、电视盒子、或者低端路由器——这点开销可能就是不可承受之重。

这就是为什么现代 Linux 内核支持三种不同「档次」的 KASAN 模式。我们在表 5.2 里总结了一下它们的行为:

table (表 5.2:KASAN 的三种模式及其开销对比)

模式昵称内存/CPU 开销适用场景架构限制
Generic KASAN通用版高 / 中主动调试、抓 Bugx86_64, ARM, ARM64, RISC-V 等
Software tag-based软件标签版中 / 低真实负载压力测试仅 ARM64
Hardware tag-based硬件标签版低 / 极低甚至可用于生产环境仅 ARM64 (MTE)

回到那个「监控摄像头」的类比

  • Generic KASAN 就像是给房子里的每件物品都配了一个 24 小时盯着的保镖。安全是安全,但太贵了,你只能在关键时候(调试阶段)用,平时根本雇不起。
  • Tag-based modes 就像是给物品贴了RFID 标签。只有当你试图拿走它的时候,门禁系统才会响。这要轻量得多。

看到这里你可能会问:为什么这么好的东西(Tag-based),只在 ARM64 上有?

答案在市场里。Android 阵营几乎全是 ARM64 的天下。Google 极其需要在生产环境(用户的手机上)也能检测到内存错误,因为他们没办法让几亿用户都开着 Generic KASAN 跑手机。所以,基于硬件特性的 MTE(Memory Tagging Extension) 被推了出来,让低开销的内存检查成为可能。


使用门槛:编译器和硬件

既然是编译器技术,编译器的版本就很关键。你总不能让十几年前的 GCC 去生成现代的插桩代码。

目前的硬性要求如下:

  • GCC:必须是 8.3.0 或更高版本。
  • Clang:理论上任意版本都行,但如果你想要检测全局变量的越界访问,你需要 Clang 11 或更高

硬件方面,KASAN 传统上是个「64位俱乐部」的特权功能。

为什么是 64 位?

回想一下那个 1:8 的影子内存比例。在 32 位系统上,地址空间只有 4GB,划出去 1/8 给影子内存,剩下的也就捉襟见肘了。更别提内核地址空间通常只有 1GB 或者 3GB(取决于配置),这会让内核直接窒息。

但事情正在起变化

如果你仔细看内核文档,你会发现 Generic KASAN 已经支持 32 位的 ARM 架构了——这是从 Linux 5.11 内核开始的新特性。这意味着,即使是算力较弱的老式 ARM 开发板,也能享受到这一福利(当然,你会更痛切地感受到内存压力)。

至于那两个看起来更高级的 Tag-based 模式,目前依然固执地只支持 ARM64

为什么还是 ARM64?

因为 Android。几乎所有现代智能手机、智能穿戴设备、智能电视的核心都是 ARM64。对于移动生态来说,能够「在线上环境(生产环境)」以极低的代价捕捉内存错误,价值千金。这不仅是技术选择,更是商业驱动。


接下来的路

在本章的后续内容中,为了演示方便,我们默认使用的是 Generic KASAN。这不仅是因为它支持最广泛的架构(包括你可能手头有的 x86_64 PC 或 32-bit ARM 板子),更因为它是调试模式下最狠、最有效的那个。

如果你手头正好有一块 ARM64 的板子,可以尝试切换到 Tag-based 模式,体验一下那种「甚至敢在生产环境开启」的丝滑感。但无论如何,原理是相通的——只要你理解了 Generic 模式下的影子内存机制,剩下的也就是实现细节的不同罢了。