如何编写真正可编译的 Linux 内核模块
Linux 内核模块(kernel module)是一小段代码,可以在不重新编译整个内核的情况下,动态加载到运行中的内核里。
听起来很简单,但即便是一个最基础的模块,背后也牵扯出相当多的机制:目标文件、元数据、已导出和未解析的符号,以及最终生成的 .ko 文件——它和普通可执行文件截然不同。
下面是一个完整、可用的 Linux 内核模块。它只有二十二行,其中七行是包含头文件和元数据:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Chris Roy");
MODULE_DESCRIPTION("A minimal loadable kernel module");
MODULE_VERSION("0.1");
static int __init hello_init(void)
{
pr_info("hello: loaded, module at %pS\n", hello_init);
return 0;
}
static void __exit hello_exit(void)
{
pr_info("hello: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
在我写这篇文章的这台机器上编译,生成的文件大小约为 106,000 字节。去掉调试信息后,同样的模块只有 4,864 字节。构建产物中 95% 的内容都不是代码。
你的结果会和我不同,而且差异是不可预测的。原因之一在于构建位置:调试信息会记录编译时所在的目录,因此深度嵌套的路径会多占用几百字节,而短路径不会。编译器版本和内核配置也会带来差异。比例保持不变,但具体的字节数仅取决于这台机器的环境。
这个体积差异是很好的切入点,因为大多数内核模块教程只展示上面的代码列表,告诉你运行 make,然后就结束了。
这篇文章将追踪构建过程实际生成了什么、你的模块在写出任何有用代码之前已经依赖了什么,以及为什么你从 2014 年找到的教程现在编译不过了。
目录
准备工作
跟着本文操作,你需要一台愿意往里加载代码的 Linux 机器、当前运行内核对应的头文件,以及一个编译器。
Debian 或 Ubuntu 上:
sudo apt install build-essential linux-headers-$(uname -r)
Fedora 上对应的包是 kernel-devel 和 kernel-headers,Arch 上则是与内核版本匹配的 linux-headers。
先确认头文件装在了构建系统期望的位置:
ls -d /lib/modules/$(uname -r)/build
这个路径是指向头文件包的符号链接,如果它不存在,模块编译就会报错——而错误信息往往完全不会提到头文件,这也是编译失败最常见的原因。
就算模块编译成功,还有两件事会阻止你加载它:Secure Boot 会拒绝未签名的模块,内核 lockdown 在机密模式下会禁止加载。用下面的命令检查这两项:
mokutil --sb-state
cat /sys/kernel/security/lockdown
我这台机器上 Secure Boot 已禁用,lockdown 输出为 [none] integrity confidentiality,方括号标出的是当前生效的模式。如果你的机器显示 Secure Boot 已启用,就需要先给模块签名,或者在固件里关闭 Secure Boot,否则模块无法加载。
我的环境是 Ubuntu 22.04、内核 5.15.0-190-generic、gcc 11.4。你的版本可能不同,文中会在有影响的地方说明。
能跑起来的最小模块
把本文开头的那段代码保存为 hello.c。为方便参考,这里再贴一次:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Chris Roy");
MODULE_DESCRIPTION("A minimal loadable kernel module");
MODULE_VERSION("0.1");
static int __init hello_init(void)
{
pr_info("hello: loaded, module at %pS\n", hello_init);
return 0;
}
static void __exit hello_exit(void)
{
pr_info("hello: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
其中四个部分真正承担了工作。
module_init 和 module_exit 注册了内核在模块加载和卸载时调用的函数。它们不是 main。模块没有单一的入口点来运行并返回,它只有两个在特定事件触发的钩子,期间除非其他代码调用,否则不会做任何事。
__init 和 __exit 是分段标记。__init 告诉内核这段代码只运行一次,之后其内存可以被释放,这也是你在启动日志中能看到"Freeing unused kernel memory"的原因。__exit 告诉构建系统,只有当模块可以被卸载时,才需要这个函数。
MODULE_LICENSE("GPL") 不仅仅是走形式。内核在加载时会检查它,声明非 GPL 许可的模块将被拒绝访问标记为 EXPORT_SYMBOL_GPL 的符号,而大多数重要的符号都属于此类。如果完全省略这个宏,内核会标记自身为"污染"状态并记录一条投诉日志。
pr_info 是 printk(KERN_INFO ...) 的现代写法。它写入内核环形缓冲区,而不是你的终端,这常常让新手第一次接触时感到困惑。
MODULE_AUTHOR、MODULE_DESCRIPTION 和 MODULE_VERSION 宏属于元数据而非行为,它们最终会写入文件中供 modinfo 读取。省略它们不会导致任何故障,但较新的内核在构建时会因缺少 MODULE_DESCRIPTION 发出警告,这是从一开始就写好这三个宏的充分理由。
Makefile 比看起来更奇怪
obj-m += hello.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
这看起来像 Makefile,但本质上不是。obj-m += hello.o 并不是你随手发明的 Make 变量,而是 kbuild(内核自带构建系统)读取的一条声明。
make -C 这行命令会将目录切换到内核头文件所在路径,并在那里运行内核的构建系统,同时传入 M=$(PWD),意思是“模块源码在这里”。你的 Makefile 其实是个薄壳,把活儿交给一个你没写、也不容易替换的构建系统。
正是这种间接层,导致模块构建失败的原因往往显得跟代码无关。你不是像编译 libc 头文件那样去编译内核头文件,而是在运行内核的构建流程,处理你的文件,并套用它的编译标志和规则。
构建过程实际发生了什么
运行 make 并仔细阅读输出,不要跳过它:
make -C /lib/modules/5.15.0-190-generic/build M=/home/chris/lkm modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-190-generic'
CC [M] /home/chris/lkm/hello.o
MODPOST /home/chris/lkm/Module.symvers
CC [M] /home/chris/lkm/hello.mod.o
LD [M] /home/chris/lkm/hello.ko
BTF [M] /home/chris/lkm/hello.ko
Skipping BTF generation for /home/chris/lkm/hello.ko due to unavailability of vmlinux
一共五个步骤,只有第一步是你预期的编译。
CC [M] hello.o 编译你的源码,过程普通。
MODPOST 是最值得了解的一步。它扫描目标文件,查找你引用但未定义的符号,逐一对照内核导出符号表进行检查;若使用了内核未提供的符号,构建就会失败。它还会生成 hello.mod.c,这是一个包含模块元数据的小型胶水文件。
CC [M] hello.mod.o 编译这份生成的胶水代码,随后 LD [M] 将其与你的目标文件链接,生成最终的 .ko。
BTF [M] 会附带供 tracing 工具使用的类型信息。这里被跳过了,因为生成 BTF 需要未压缩的 vmlinux 镜像,而 Ubuntu 默认不提供。构建会发出警告并继续,这是合理的:BTF 有用,但并非必需。
.ko 文件里有什么
.ko 是一个普通的 ELF 对象文件,只是额外加了一些内核特有的 section。看看它的元数据:
modinfo ./hello.ko
version: 0.1
description: A minimal loadable kernel module
author: Chris Roy
license: GPL
srcversion: 39D86510C9FF65D797EAF90
depends:
retpoline: Y
name: hello
vermagic: 5.15.0-190-generic SMP mod_unload modversions
这些信息都存放在一个 ELF section 里,以 null 分隔的字符串形式存储。可以直接从文件里读出来:
objcopy -O binary --only-section=.modinfo hello.ko /dev/stdout | tr '\0' '\n'
这就回到了开头提到的那个数字。模块在磁盘上大约 106,000 字节:
ls -l hello.ko
cp hello.ko /tmp/ && strip --strip-debug /tmp/hello.ko && ls -l /tmp/hello.ko
105984 hello.ko
4864 /tmp/hello.ko
模块本体不到 5 KB,其余全是 DWARF 调试信息——构建过程保留这些,是为了当系统 panic 时,crash、gdb 之类的工具能读懂你的代码。加载模块时,内核并不会加载这些调试 section,所以实际内存开销是那个小数字,而不是大数字。
你的 Hello World 其实已经依赖三个东西
> 这是我当初最希望有人先告诉我的部分。问问这个对象文件需要内核提供什么:nm -u hello.ko
U __fentry__
U _printk
U __x86_return_thunk
U 表示未定义:这些是模块引用的符号,需要在加载时由内核提供。
_printk 好理解,因为 pr_info 会展开成它。
__fentry__ 是编译器插入在每个函数开头的调用,因为内核构建时启用了函数 tracing 支持。你在模块里写的每个函数都会带上这个钩子,不管你需不需要——正是它让 ftrace 之后无需重新编译就能对你的代码进行插桩。
__x86_return_thunk 是一种 Spectre 缓解措施。编译器将普通的 return 指令替换为对 thunk 的调用,以避开该漏洞所依赖的推测执行路径。即便模块只输出一行内容,这个符号也会出现,因为该缓解机制适用于本机上的所有内核代码,无论是否属于模块。
你好世界示例中的三个符号里,有两个是机器强加给你的基础设施。这很贴切地描绘了编写内核代码的实际境况。
在链接成功之前,modpost 已验证这三个符号均存在。如果你调用了内核未导出的函数,构建过程会在此处因“未定义符号”错误而失败,而不是生成一个在加载时才出错的模块。
vermagic 与模块为何拒绝加载
再次查看 modinfo 中的那行输出:
vermagic: 5.15.0-190-generic SMP mod_unload modversions
内核在加载任何模块前,会将该字符串与自身信息比对,若不一致则拒绝加载。它涵盖了内核版本、是否为 SMP 架构、是否编译支持模块卸载以及是否开启符号版本控制。
这就是为什么在一台机器上构建的模块通常无法在另一台机器上加载,也意味着升级内核后必须重新编译模块。
Linux 内核内部没有 ABI 稳定性保证。内部结构体在不同发布版本间会发生变化。如果一个模块按一种布局编译,却在另一种布局下运行,会导致内存损坏而非干净地报错。拒绝加载是内核的一种审慎机制。
这也是 DKMS 存在的意义。如果你安装了 VirtualBox、ZFS 或 NVIDIA 驱动,就已经有一个模块通过这种方式进行重建了。在当前这台机器上:
modinfo vboxdrv | head -3
filename: /lib/modules/5.15.0-190-generic/updates/dkms/vboxdrv.ko
version: 6.1.50_Ubuntu r161033 (0x00320000)
license: GPL
注意路径中的 updates/dkms/。DKMS 保留源码,并在每次安装新内核时重新编译模块,这是 vermagic 校验带来的不可避免的维护成本。
加载时传递参数
一个模块总是做同一件事,往往不是你所期望的。module_param 暴露了一个变量,使其值可以在加载时设置。将此代码保存为 param.c,与 hello.c 放在同一目录:
#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("A module that takes parameters");
static char *who = "world";
static int times = 1;
module_param(who, charp, 0444);
MODULE_PARM_DESC(who, "who to greet");
module_param(times, int, 0644);
MODULE_PARM_DESC(times, "how many times to greet");
static int __init param_init(void)
{
int i;
for (i = 0; i < times; i++)
pr_info("param: hello, %s\n", who);
return 0;
}
static void __exit param_exit(void)
{
pr_info("param: unloaded\n");
}
module_init(param_init);
module_exit(param_exit);
把它加进 Makefile,该指令接受一个列表:
obj-m += hello.o param.o
这三个参数分别是变量名、类型,以及该文件在 /sys/module/<name>/parameters/ 下对应的权限模式。0444 表示只读,模块加载后值即固定。而 0644 允许 root 在模块运行期间通过写入该文件来修改变量值,这在某些场景下很有用;但也意味着如果模块在读取该变量时未做好同步保护,就可能引入竞态条件。
charp 代表字符指针,其他常见类型包括 int、bool、long,以及通过 module_param_array 定义的 charp 数组。若传入的类型与变量不匹配,构建过程会直接报错,而不是等到运行时才出现不可预知的行为。
MODULE_PARM_DESC 会将描述写入模块元数据,modinfo 命令会读取这部分内容:
name: param
parm: who:who to greet (charp)
parm: times:how many times to greet (int)
加载时以 name=value 的形式设置这些参数:
sudo insmod ./param.ko who=kernel times=3
任何人在加载前都可以查看模块接受哪些参数,这正是使用 MODULE_PARM_DESC 的主要价值所在。
加载模块及输出去向
sudo insmod ./hello.ko
sudo dmesg | tail -2
lsmod | grep hello
sudo rmmod hello
pr_info 的输出会进入内核环形缓冲区,因此显示在 dmesg 而非终端里。如果普通用户运行 dmesg 被拒绝,说明启用了 kernel.dmesg_restrict;此时可用 sudo journalctl -k | tail 通过日志系统读取同样的内核消息。
格式字符串里的 %pS 会把内核指针打印成符号名加偏移量,而不是原始地址,这样日志里看到的就是可读内容,而不是一串十六进制数字。
lsmod 读取 /proc/modules,显示三列信息:模块名、占用的内存大小,以及引用计数和依赖它的模块名。引用计数不为零的模块无法卸载,这也是 rmmod 拒绝执行最常见的原因。
加载模块前先提醒一句:用户态程序出 bug 只会崩掉那个程序,但内核模块出 bug 可能直接搞垮整台机器或损坏文件系统。头几次操作务必在虚拟机里进行——恢复一个快照的代价,远低于修复一块损坏的磁盘。
四种常见编译错误及含义
下面这四种错误占去了人们大部分的排查时间,而一旦你学会看懂它们,每种错误其实都在明确地告诉你问题出在哪。
No rule to make target 'modules'. Stop.:内核头文件缺失,或者 /lib/modules/$(uname -r)/build 这个符号链接指向了不存在的位置。安装与你当前运行内核完全匹配的头文件包——注意,如果更新后还没重启过,当前运行的内核往往不是系统里最新安装的那个。
ERROR: modpost: "some_function" [hello.ko] undefined!:你引用了一个内核没有导出的符号。下面是一次真实编译中的报错示例:
ERROR: modpost: "this_symbol_does_not_exist" [bad.ko] undefined!
make[2]: *** [scripts/Makefile.modpost:133: Module.symvers] Error 1
重试是没用的。要么这个函数是内核内部函数、从未导出,要么它通过 EXPORT_SYMBOL_GPL 导出,而你的模块声明的不是 GPL 许可证。可以用 grep the_symbol /proc/kallsyms 查一下:第二列如果是大写 T,说明它是一个全局代码符号。
insmod: ERROR: could not insert module: Invalid module format:编译成功了,但 vermagic 与当前运行的内核不匹配。对比一下 modinfo ./hello.ko | grep vermagic 和 uname -r 的输出,用正确的头文件重新编译即可解决。
insmod: ERROR: could not insert module: Operation not permitted:通常是因为 Secure Boot 拒绝了未签名的内核模块,或者是机密性模式下的 lockdown 策略。别急着怀疑代码有问题,先运行 mokutil --sb-state 和 cat /sys/kernel/security/lockdown 检查系统状态。
再补充一个细节,现在学比事后调试容易得多。加载任何树外(out-of-tree)模块都会给内核打上 taint 标记。这个标记会被记录下来,并在后续任何 oops 或 panic 中汇报:
cat /proc/sys/kernel/tainted
该值是一个位掩码。此处机器显示为 4096,即第 12 位,对应 TAINT_OOT_MODULE,表示曾经加载过树外模块。
第 13 位是 TAINT_UNSIGNED_MODULE,常被人与上述标记混淆,而这正是 Secure Boot 关注的重点。
完整列表位于内核源码的 include/linux/panic.h 中。内核开发者在排查 bug 前,通常要求复现问题于无 taint 标记的内核上,而这个文件正是判断你是否满足该条件的依据。
为什么找到的教程编译不过
网上大多数内核模块教程都早于近年的多项内核变更,以下这些正是导致构建失败的常见原因。
printk(KERN_INFO "...") 虽然仍可用,但 pr_info 是目前的推荐写法,它自动携带日志级别。
将 init_module 和 cleanup_module 直接作为函数名是旧惯例。现在应使用 module_init 和 module_exit 并传入自定义函数名,这样可以避免在同一文件中定义多个入口/出口函数时的命名冲突。
过去 MODULE_LICENSE 实际选填,但现在它是必需的(load-bearing),因为它决定了能否访问仅限 GPL 导出的符号。
M= 参数以前拼写为 SUBDIRS=。旧拼写已被移除,使用旧语法的教程会报错,且错误信息中根本不会提及 SUBDIRS。
头文件路径已变更。凡是引用 /usr/src/linux 的教程都早于内核头文件拆分为独立包(per-kernel headers packages)的时代,内容过时,其余部分也需重新核实。一个更小的线索:<linux/module.h> 多年来已包含 <linux/moduleparam.h>,因此那些刻意同时引入这两个头文件的教程往往是照抄旧文档(尽管两者都包含并无害处)。
如果你的内核上编译教程代码时没有任何警告,说明它够新。如果出现错误,第一个报错里通常会打印它针对的内核版本。
结论
你现在可以编译内核模块、解读构建产物,并解释其依赖的每个符号。
更有价值的是,你知道了它为何会以特定的方式失败。缺少 /lib/modules/$(uname -r)/build 是头文件问题。vermagic 不匹配需要重新编译。MODPOST 阶段出现未定义符号,意味着内核没有导出你所请求的内容,重试再多也没用。
接下来有几个探索方向。用 proc_create 注册一个 /proc 条目并从中读取数据,这是模块能做的最简小有用的事。
阅读内核自带的 Module.symvers 文件,位于 /usr/src/linux-headers-$(uname -r)/ 下,它是 MODPOST 用来核对的表格。在这台机器上,它包含 26,420 个导出符号,每个都标记为 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL。
或者追踪你模块的函数,这得益于首次构建时存在的 __fentry__ 钩子。不需要运行 ftrace 命令。这是 /sys/kernel/tracing 下的一个接口,通过写入文件来驱动:
sudo sh -c 'echo hello_init > /sys/kernel/tracing/set_ftrace_filter'
sudo sh -c 'echo function > /sys/kernel/tracing/current_tracer'
sudo cat /sys/kernel/tracing/trace
在较旧的系统中,该路径是 /sys/kernel/debug/tracing。如果不想手动写文件,trace-cmd 封装了相同的接口。
尾声
以上内容都假设你被允许这样做,而正是这个假设最有趣。加载的模块拥有与内核本身相同的权限。它可以读取任何内存、修补任何函数,并绕过系统认为正在强制执行的任何策略,因为当它运行时,上层已没有任何东西能对它说“不”。
这使得模块加载成为权限模型无法约束的唯一操作,这也是为何内核通过签名和锁定机制而非传统权限来保护它。
在开发一个以能力为核心的桌面操作系统时,我撞上了这道天花板。在这个系统里,程序的 manifest 就是它能做的一切,而底层还得兼容 Debian 生态。到了内核模块这一层,这套模型就表达不下去了,所以搞清楚内核在加载模块前到底检查了什么,就不再只是个细节问题,而成了设计上的硬约束。
我在 thechris.in 上写关于系统及其奥秘的文章。