5.7 使用 Clang 构建内核与模块
现在,让我们进入 Clang 的世界。
还记得上一节结尾留下的那个悬念吗?有些越界缺陷,比如全局变量的「左越界」(Left-out-of-bounds),只有用 Clang 11 或更高版本编译才能被捕获。这不是玄学,是编译器能力的差异。
为什么要换编译器?
在动手之前,先说说我们为什么要这么做。
LLVM 这个项目(全称 Low Level Virtual Machine)名字里虽然带着 "Virtual Machine",但它现在已经跟传统虚拟机没什么关系了,它是一套非常强大的编译器后端基础设施。而 Clang(发音跟 "slang" 押韵),就是基于 LLVM 构建的现代化 C/C++ 编译器前端。
你可以把 Clang 看作 GCC 的现代替代品。
但 GCC 老当益壮,为什么要换?原因很硬:
- 诊断信息更友好:Clang 报错比 GCC 清楚得多,这在调试时能救命。
- 某些 Bug 它抓得更准:回到我们刚才说的那个悬念——在上一节我们提到,全局内存上的「左越界」访问,GCC(9.3, 10, 11 版本) reliably 地漏掉了,但 Clang 能抓到。
这一点至关重要。这意味着要彻底扫除内存盲区,我们手里得有这把新武器。
别混用编译器(血的教训)
在继续之前,我要先给你打一支预防针。
我第一次尝试 KASAN 测试失败的时候,百思不得其解,就去请教了 KASAN 的主要维护者之一 Marco Elver。他指出了我犯的一个低级错误:我试图用 Clang 编译内核模块,但目标内核本身却是用 GCC 编译的。
这是绝对不行的。
内核和模块必须由同一个编译器构建。为什么?因为底层二进制接口(ABI)必须完全一致。如果编译器不同,结构体对齐、调用约定、甚至函数名修饰都可能对不上。这种不匹配会导致莫名其妙的崩溃,或者——正如我遇到的那样——导致 KASAN 根本无法正常工作。
所以,规矩只有一条:要么全 GCC,要么全 Clang。既然我们要抓那些难搞的 Bug,那就把内核和模块都用 Clang 11(或者更高版本)重编一遍。
准备工作:安装 Clang
如果你还在用 Ubuntu 20.04 LTS,你需要手动装一下 Clang 11。
sudo apt install clang-11 --install-suggests
如果你像我一样,系统里同时躺着 Clang 10 和 Clang 11,为了不搞混,建议手动建一个软链接,把 llvm-objdump 指向版本 11:
sudo ln -s /usr/bin/llvm-objdump-11 /usr/bin/llvm-objdump
「更好的路」——直接上 Ubuntu 21.10
说实话,在旧系统上手动折腾版本很容易头发掉光。有一个更省心的办法:直接安装 Ubuntu 21.10(或者更高版本)作为一个新的 x86_64 虚拟机。
Ubuntu 21.10 自带了 Clang 13。我就是这么干的——开了一个新 VM,省去了依赖地狱的痛苦。接下来的步骤,无论你是用 Clang 11 还是 Clang 13,流程都是一样的。
构建:用 Clang 替换 GCC
好了,工具备好了,现在开始动刀。
我们要编译那个 5.10.60 的内核。这跟我们在第 1 章里做的事情很像,唯一的区别是在 make 命令里显式指定编译器。
把终端切到你的内核构建目录,执行:
$ time make -j8 CC=clang
SYNC include/config/auto.conf.cmd
*
* Restart config...
* Memory initialization
*
第一次遇到的差异
当你第一次用 CC=clang 跑这个命令时,kbuild 构建系统会检测到环境变了。Clang 支持一些 GCC 没有的高级特性,于是系统会停下来问你一些新配置。
它首先会问你是否要自动初始化内核栈变量:
Initialize kernel stack variables at function entry
> 1. no automatic initialization (weakest) (INIT_STACK_NONE)
2. 0xAA-init everything on the stack (strongest) (INIT_STACK_ALL_PATTERN) (NEW)
3. zero-init everything on the stack (strongest and safest) (INIT_STACK_ALL_ZERO) (NEW)
choice[1-3?]:
这是一个很诱人的选项——开启它能帮你直接把大量的「未初始化内存读取(UMR)」扼杀在摇篮里。
但是!
我们的目标是测试 KASAN 和 UBSAN 能不能自己抓出这些 Bug。如果编译器一把内存都初始化了,Bug 就被掩盖了。所以,为了验证我们的工具链,我这里故意选默认的 选项 1(不自动初始化)。
接下来,构建过程还会问你一些关于堆内存初始化的问题,比如分配时是否清零、释放时是否清零。这些我都保持默认按了回车(或者选 y,毕竟这不会掩盖 UMR 以外的 Bug):
Enable heap memory zeroing on allocation by default (INIT_ON_ALLOC_DEFAULT_ON) [Y/n/?] y
Enable heap memory zeroing on free by default (INIT_ON_FREE_DEFAULT_ON) [Y/n/?] y
*
* KASAN: runtime memory debugger
*
KASAN: runtime memory debugger (KASAN) [Y/n/?] y
KASAN mode
> 1. Generic mode (KASAN_GENERIC)
choice[1]: 1
[...]
Back mappings in vmalloc space with real shadow memory (KASAN_VMALLOC) [Y/n/?] y
KUnit-compatible tests of KASAN bug detection capabilities (KASAN_KUNIT_TEST) [M/n/?] m
[...]
一切顺利的话,内核就编译好了。剩下的步骤你应该很熟了——安装模块、安装内核。
千万别忘了,命令行里也得带上 CC=clang:
sudo make CC=clang modules_install && sudo make CC=clang install
搞定后,重启你的虚拟机,在 GRUB 菜单里选你新编的这个内核。
验证:真的是 Clang 编的吗?
进系统后,第一件事是确认你没在做梦。跑一下这个:
$ cat /proc/version
Linux version 5.10.60-dbg02 (letsdebug@letsdebug-VirtualBox) (Ubuntu clang version 13.0.0-2, GNU ld (GNU Binutils for Ubuntu) 2.37) #4 SMP PREEMPT Wed ...
看括号里的那行字——Ubuntu clang version 13.0.0-2。
看到这个,说明内核确实是用 Clang 翻译出来的。为了区分,我在用 Clang 编的内核 uname 里加了 -dbg02,而 GCC 编的那版我叫它 5.10.60-dbg02-gcc。这样以后 ls /lib/modules 时就不会搞混了。
最后一块拼图:编译模块
内核准备好了,现在轮到我们的测试模块 test_kmembugs。
切到源码目录,把 CC 变量传递给 make:
cd <book_src>/ch5/kmembugs_test
make CC=clang
很简单,对吧?
其实我为了省事,已经把这个逻辑写进了 load_testmod 脚本里——它会自动检测当前内核是用哪个编译器编的,然后用对应的编译器去构建模块。这样我就不用每次手动指定了。
动手时间
俗话说得好,纸上得来终觉浅。
现在轮到你了:
练习:从零开始,用 Clang 构建一个调试内核,并用它来编译我们的
test_kmembugs.ko模块。跑一遍之前的那些测试用例,特别是那个曾经被 GCC 漏掉的「左越界」Bug,看看 Clang 是怎么把它揪出来的。
到这里,我们关于内核内存缺陷检测的第一部分深度之旅就告一段落了。
手里有了 KASAN、UBSAN,还有了 Clang 这把利剑,我们现在的武器库已经相当完备了。接下来,我们要做个复盘——把所有用过的工具和技术放在一起,仔细比对一下它们的优劣,看看在什么场景下该用什么招。
这就到了下一节:「Catching memory defects in the kernel – comparisons and notes (Part 1)」。那有一张大表等着你。