跳到主要内容

第 5 章 编写你的第一个内核模块——第二部分

这可能是你第一次意识到:所谓的「模块化」,在实际工程中并不是把东西扔进独立文件夹就完事了。上一章我们让一个最简单的内核模块跑了起来,就像刚学会用打火机生火;但如果你真的要靠这堆火过冬,你需要的是一套不仅能生火,还能控制火势、防止回火、并在天冷时自动添柴的系统。

这就引出了本章的核心任务:如何把内核模块当成严肃的工程项目来对待

这不仅仅是代码风格的问题。当你试图把一个模块从 x86_64 移植到 ARM 板子上,当你试图在调试内核和发布内核之间切换,或者当你试图让多个模块像积木一样协作时,你会发现自己正在和一套极其严苛的规则打交道——这套规则不在乎你的直觉,只在乎配置、版本和二进制接口(ABI)是否完全匹配。

旧方案为什么不行

上一章的 Makefile 是一个「能跑就行」的极简版本。它能编译,能安装,但也仅此而已。在真实项目中,这种简陋的构建系统就像用记事本写代码一样,虽然也能工作,但会让你在实际工程中寸步难行:

  • 缺乏自动化检查:你可能会在代码里埋下缓冲区溢出的漏洞,或者用了已经被内核社区废弃的 API,但构建系统对此一言不发。
  • 调试体验糟糕:如果你想打开一些调试宏,你得手动去改代码,而不是简单地传一个开关。
  • 缺乏安全意识:没有代码风格检查,没有静态分析,生成的模块可能带着一堆安全隐患就上线了。

本章的任务

我们需要把那个「玩具」般的模块开发环境,升级为一个专业的开发工作流。

这不仅仅是多写几行 Makefile 的事。我们将经历从「配置环境」到「编写代码」,再到「安全加固」和「部署上线」的全过程。在这个过程中,你会遇到一些反直觉的现象——比如为什么明明编译通过的模块,在板子上就是加载不起来?为什么内核里不仅不能随便用浮点数,甚至连 sprintf 都要被嫌弃?

这是一条从「能用」到「好用」,再到「不仅好用而且安全」的必经之路。


5.1 一个“更好”的内核模块 Makefile 模板

让我们从一个最实际的问题开始:如何让构建过程变得更聪明、更安全?

上一章我们用了一个非常基础的 Makefile。它能完成任务,但就像我刚才说的,它不够聪明。现在我给你展示一个我认为“更好”的 Makefile 模板。这个模板不仅是为了编译,更是为了在编译阶段就帮你把坑填上。

这个模板的目标很简单:强制你关注代码质量。它集成了静态分析、代码风格检查、自动化清理和打包等功能。这些功能你可能会觉得“以后再做”,但经验告诉我们,“以后”通常意味着“永远不做”。

这个 Makefile 能做什么

你可以把这个模板看作一个自动化的代码审查员。当你准备提交代码时,它会帮你把那些显而易见的错误揪出来。具体来说,它包含以下几类目标:

  1. 常规构建目标all(构建)、install(安装)、clean(清理)。这里有个小心机:它会根据你是否开启“调试模式”来决定是否剥离模块的调试符号。
  2. 代码风格目标indent(自动格式化代码)和 checkpatch(运行内核官方的代码风格检查脚本 scripts/checkpatch.pl)。
  3. 静态分析目标:集成了 sparsegcc 的静态检查以及 flawfinder 等工具。还有针对 Coccinelle 的支持。
  4. 动态分析占位符:它定义了一些“假”目标(da_kasanda_lockdep 等),用来提醒你:如果真的要抓内存泄露或死锁,你需要配置并运行一个专门的“调试内核”。我们很快就会讲到怎么配置这个内核。
  5. 打包目标tarxz-pkg。这个目标会把你的源码打包成一个 .tar.xz 文件。这非常适合移植——你可以把压缩包传到另一台机器上,解压后直接编译。

看看它的真面目

这个 Makefile 位于 ch5/lkm_template 目录。让我们看看它运行起来是什么样。在你的终端里,试着按两次 Tab 键:

lkm_template $ make <tab><tab>
all clean help install
checkpatch code-style indent nsdeps sa_cppcheck sa_gc
sa_sparse sa_flawfinder

图 5.1:我们的“更好”Makefile 的 help 目标输出截图

这不仅仅是列出命令。注意看图 5.1 中高亮的 FYI: 这一行。它揭示了 Makefile 当前对我们设置的几个关键变量的理解:

FYI: KDIR=/lib/modules/6.1.25-lkp-kernel/build ARCH= CROSS_COMPILE=

这里有几个关键点:

  1. MYDEBUG 变量:在这个 Makefile 里,我用 MYDEBUG 来控制是否进行“调试构建”。默认情况下它被设为 n(关闭)。如果设为 y,构建过程会保留调试符号,并定义 DEBUG 宏。
  2. DBG_STRIP 变量:这个变量控制是否剥离符号。默认是 y。我们的 Makefile 有点智能:它只在非调试模式(MYDEBUG=n)且内核未开启模块签名时才剥离符号。为什么?因为模块签名需要符号信息来验证完整性(我们后面会讲模块签名)。
  3. KDIR 变量:这是指向内核源码树(准确地说是内核头文件)的路径。默认值是我们当前运行的内核版本对应的构建目录。
  4. ARCHCROSS_COMPILE:这两个变量默认为空,因为我们还没开始交叉编译。

动手试一试

光说不练假把式。我们用这个“更好”的 Makefile 来构建我们的模板模块,然后把它插进内核,再拔出来,看看 printk 的输出是否正常(见图 5.2)。

图 5.2:使用“更好”的 Makefile 构建 lkm_template 模块并试用(在我们的 x86_64 Ubuntu 虚拟机上)

⚠️ 注意 要想充分利用这个 Makefile,你的系统上得安装几个必要的包:

  • indent(1)(代码格式化)
  • linux-headers-$(uname -r)(内核头文件)
  • sparse(1)(静态分析工具)
  • flawfinder(1)(安全扫描)
  • cppcheck(1)(C++ 静态检查)
  • tar(1)(打包工具)

这些工具通常在“内核工作区搭建”那章里都有提到。如果不确定,运行一下 ch1/pkg_install4ubuntu_lkp.sh 脚本(在 Ubuntu 上)是个省事的办法。

还有个细节:Makefile 里提到的那些“动态分析”目标(da_*)其实是空壳。运行它们只会打印提示信息。它们存在的意义就像贴在显示器上的便签:时刻提醒你,光靠普通内核是抓不到深层 Bug 的,你得去配置一个专门的“调试内核”。

说到调试内核,下一节我们就来讲怎么配置它。


5.2 配置一个“调试”内核

在开发阶段,如果你能在一个开启了各种调试选项的内核上运行代码,你的生活会幸福很多。那些在普通内核上让你抓耳挠腮的、随机出现的崩溃,在调试内核面前往往会原形毕露——甚至会在刚发生时就立刻停下来报警。

我强烈建议你在开发时准备两个内核环境:

  1. 生产内核:精心配置、全速优化的内核,用于最终发布和日常使用。
  2. 调试内核:故意开启了大量内核调试选项的内核(可能没怎么优化),专门用来抓 Bug。

配置和编译内核的具体细节,可以回看第 2 章和第 3 章。这里我假设你已经熟练掌握了 make menuconfig 的基本操作。现在,我们需要给我们的自定义 6.1 内核开启一些关键的调试配置。

配置列表(以下选项均设为 y) 大部分选项都在 Kernel hacking 子菜单里。

  • 通用调试
    • CONFIG_DEBUG_KERNELCONFIG_DEBUG_INFO:这是地基,必须要有。
    • CONFIG_DEBUG_MISC:一些杂项调试支持。
    • CONFIG_MAGIC_SYSRQ:允许你通过键盘组合键(如 Alt+SysRq+...)执行紧急命令。
    • CONFIG_DEBUG_FS:挂载 debugfs 文件系统,很多调试信息都在里面。
    • CONFIG_KGDB:内核 GDB 支持(可选,但推荐)。
    • CONFIG_UBSAN:未定义行为检查器。
    • CONFIG_KCSAN:动态数据竞争检测器。
  • 内存调试
    • CONFIG_SLUB_DEBUG:开启 SLUB 分配器的调试功能。
    • CONFIG_DEBUG_MEMORY_INIT:内存初始化调试。
    • CONFIG_KASAN神器。内核地址消毒剂,专治各种内存破坏(越界、释放后使用等)。
    • CONFIG_DEBUG_SHIRQ:共享中断调试。
    • CONFIG_SCHED_STACK_END_CHECK:检查栈溢出。
    • CONFIG_DEBUG_PREEMPT:抢占调试。
  • 锁调试
    • CONFIG_PROVE_LOCKING神器。锁依赖检查器,能帮你发现潜在的死锁。开启它会自动打开一系列其他锁调试选项。
    • CONFIG_LOCK_STAT:锁使用统计。
    • CONFIG_DEBUG_ATOMIC_SLEEP:检查在原子上下文中睡眠的错误。
  • 其他
    • CONFIG_BUG_ON_DATA_CORRUPTION:数据损坏时触发 BUG。
    • CONFIG_STACKTRACE:栈回溯支持。
    • CONFIG_DEBUG_BUGVERBOSE:详细的 BUG 报告。
    • CONFIG_FTRACE:内核追踪框架。至少开启几个追踪器,比如“内核函数追踪器”。

架构特定选项(x86)

  • CONFIG_EARLY_PRINTK:早期控制台输出。
  • CONFIG_DEBUG_BOOT_PARAMS:启动参数调试。
  • CONFIG_UNWINDER_FRAME_POINTER:选择帧指针解包器并启用栈验证。

这里有几件事需要说明一下:

  • 别被吓到了:如果你现在看不懂这些选项是干什么的,别担心。等你读完这本书,大部分都会变得清晰起来。
  • Ftrace 的坑:虽然 Ftrace 本身可能默认开启,但它的各种“插件”并不一定都开了。像 CONFIG_IRQSOFF_TRACER 这种,我们需要手动开启,因为后面的书里会用到。
  • 性能损耗:开启这些选项肯定会降低系统性能。但这没关系,我们现在的目的就是为了抓 Bug,尤其是在那些难以重现的 Bug 面前,性能是可以牺牲的。

你的开发流程应该是这样:代码先在调试内核上跑一遍,确保没有明显的内存错误或死锁;然后再在生产内核上验证性能和功能。

好了,有了这套装备,我们终于可以挑战一个实战场景了:为另一台设备(通常是 ARM 板子)交叉编译内核模块


5.3 交叉编译内核模块

在第 3 章里,我们演示了如何为树莓派交叉编译整个 Linux 内核。现在,我们把目光聚焦到一个更具体的任务上:交叉编译一个内核模块

这事儿看起来简单——不就是改个编译器路径吗?但实际上,为了跑通这个过程,我们可能会经历四次失败的尝试。别担心,每一次失败都是因为踩到了一个必须理解的坑。

为了让你有个心理准备,我在本节末尾准备了一个表格,总结了这四次尝试中遇到的问题和解决办法。

但在那之前,我们得先把地基打好。

准备工作:设置交叉编译环境

要交叉编译模块,两样东西必不可少:

  1. 目标设备的内核源码树:宿主机上必须有一份目标设备(比如树莓派)的完整内核源码。注意,是完整源码,不仅仅是头文件,因为我们还需要 Module.symvers 文件(后面会讲到这是个什么鬼)。
  2. 交叉工具链:你需要一个能从 x86_64 宿主机生成 ARM64 代码的编译器。如果你还没装,可以运行:
    sudo apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu

我假设你已经按照第 3 章的指引,把树莓派 6.1.34 的内核源码放在了 ~/rpi_work/kernel_rpi/linux 目录下,并且把工具链装好了。工具链的前缀是 aarch64-linux-gnu-。我们可以简单验证一下:

$ aarch64-linux-gnu-gcc
aarch64-linux-gnu-gcc: fatal error: no input files
compilation terminated.

很好,报“没有输入文件”说明编译器能跑了。作为现代的替代品,Clang 工具链也很流行(Android 就在用),甚至在某些方面比 GCC 更强。不过为了演示统一,我们还是用经典的 GCC。

好了,环境就绪。让我们开始第一次尝试。


尝试 1:设置 ARCH 和 CROSS_COMPILE 环境变量

理论上,这事儿简单得令人发指。只需要在 make 命令里指定架构和交叉编译器前缀就行了。

为了不污染原来的代码,我们新建一个目录 cross,把代码复制过去(书里的源码仓库其实已经准备好了,在 ch5/cross 下):

cd <book-dir>/ch5
mkdir cross
cd cross
cp ../lkm_template/lkm_template.c ../lkm_template/Makefile .

然后尝试编译:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-

(书里的代码仓库提供了一个小脚本 ch5/cross/buildit,它做了些前置检查然后运行这个命令。)

但如果你真的这样跑,大概率会直接崩给你看:

--- Building : KDIR=~/arm64_prj/kernel/linux ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
[...]
make[1]: *** /home/c2kp/arm64_prj/kernel/linux: No such file or directory
make: *** [Makefile:93: all] Error 2

为什么会失败?

线索就在错误信息里:它正试图去 /home/c2kp/arm64_prj/kernel/linux 这个路径找内核,但这显然是我们宿主机的路径(而且可能还是个无效路径)。它根本不知道我们要为树莓派编译。

解决办法:我们要告诉 Makefile,别瞎猜,去这里找目标内核的源码

这就需要修改 Makefile 里的 KDIR 变量。来看看这个修正后的 Makefile 是怎么写的:

# ch5/cross/Makefile:
# 为了支持交叉编译,通过 make ARCH=<arch> CROSS_COMPILE=<prefix> 来调用
[ ... ]
else ifeq ($(ARCH),arm64)
# *更新* 下面的 KDIR 指向你的 ARM64 Linux 内核源码树路径
#KDIR ?= ~/arm64_prj/kernel/linux
KDIR ?= ~/rpi_work/kernel_rpi/linux
else ifeq ($(ARCH),powerpc)
[ ... ]
else
[ … ]
endif
[ ... ]
# 重要:设置 FNAME_C 为内核模块源文件名
FNAME_C := lkm_template
PWD := $(shell pwd)
obj-m += ${FNAME_C}.o
[ ... ]
all:
@echo
@echo '--- Building : KDIR=${KDIR} ARCH=${ARCH} CROSS_COMPILE=${CROSS_COMPILE}'
@echo
make -C $(KDIR) M=$(PWD) modules
[...]

这个“更好”的 Makefile 现在有了智能:

  • 它根据 ARCH 环境变量的值,自动把 KDIR 指向正确的内核源码目录。
  • 它定义了 obj-m,指定要生成的模块对象文件。
  • 它通过 ccflags-y(别用旧式的 CFLAGS_EXTRA)添加了 DEBUG 宏定义,这样 pr_debug() 之类的宏就能工作(当然,默认是关的)。
  • @echo 那几行是为了在编译时打印一些有用的信息,方便你排查问题。
  • 最后,在 allinstallclean 这些目标里,我们使用 make -C $(KDIR) M=$(PWD) modules 这种标准写法,确保构建系统切换到正确的内核目录下工作。

现在,让我们用这个修正后的 Makefile 再试一次。


尝试 2:修正 Makefile 指向正确的源码树

现在,Makefile 已经知道去哪找树莓派的内核源码了。让我们再次尝试构建:

$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
[ ... ]
CC [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/lkm_template.o
MODPOST /home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/Module.symvers
ERROR: modpost: "_printk" [/home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/lkm_template.ko] undefined!
make[2]: *** [scripts/Makefile.modpost:126: /home/c2kp/Linux-Kernel-Programming_2E/ch5/lkm_template.ko] Error 1
[ ... ]

哎呀,又挂了。这次是 modpost 阶段报错。

原因modpost 是构建系统的一个环节,它负责检查模块导出的符号。这些信息存在内核源码根目录下的 Module.symvers 文件里。如果这个文件不在,或者内核树还没完全编译过,modpost 就会找不到像 _printk 这样的内核导出函数。

解决办法:简单粗暴,把目标内核树清理干净,重新编译一遍,确保 Module.symvers 生成:

# 在树莓派内核源码目录下
make mrproper
# 重新配置并编译
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc)

好了,现在 Module.symvers 应该乖乖躺在那里了。再次尝试编译模块:

rpi $ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
--- Building : KDIR=~/rpi_work/kernel_rpi/linux ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
aarch64-linux-gnu-gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0
make -C ~/rpi_work/kernel_rpi/linux M=/home/c2kp/Linux-Kernel-Programming_2E/ch5/cross modules
make[1]: Entering directory '/home/c2kp/rpi_work/kernel_rpi/linux'
CC [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/lkm_template.o
MODPOST /home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/Module.symvers
CC [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/lkm_template.mod.o
LD [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/cross/lkm_template.ko
make[1]: Leaving directory '/home/c2kp/rpi_work/kernel_rpi/linux'
if [ "n" != "y" ]; then \
sudo aarch64-linux-gnu-strip --strip-debug lkm_template.ko ; \
fi

太好了!编译成功了。看看我们的劳动成果:

$ ls -l ./lkm_template.ko
-rw-rw-r-- 1 c2kp c2kp [...] ./lkm_template.ko
$ file ./lkm_template.ko
./lkm_template.ko: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), not stripped

注意这里:文件类型显示是 ARM aarch64,说明我们成功生成了 ARM64 架构的模块!

当然,现实中你可能还会遇到另一种坑:目标内核源码树可能处于“处女”状态(连 .config 都没有),或者根本没配置过。遇到这种情况,老老实实先配置并编译一遍内核吧,然后再回来编译模块。


尝试 3:在设备上加载交叉编译的模块

现在,我们手头上有一个用正确配置的树莓派内核源码树交叉编译出来的模块(而且 Module.symvers 也有了)。理论上,它应该能在板子上跑了。

是骡子是马,拉出来溜溜。我们把模块 scp 到树莓派上,然后试着加载它(以下输出直接来自设备):

rpi $ sudo insmod ./lkm_template.ko
insmod: ERROR: could not insert module ./lkm_template.ko: Invalid module format

又失败了?

是的,而且报错信息很笼统。这通常意味着内核拒绝加载这个模块。让我们用 dmesg 看看到底发生了什么:

rpi $ dmesg
[...]
[ 123.456789] lkm_template: version magic '6.1.34-v8+' should be '6.1.21-v8+'

哈,找到元凶了!

原因:版本不匹配。 我们当前运行的树莓派内核版本是 6.1.21-v8+(这是树莓派 OS 默认自带的内核)。但是,我们的模块是针对 6.1.34-v8+ 编译的(也就是我们在源码树里编译的那个版本)。

内核有一条铁律:一个模块只能被插入到它所编译的那个内核中。精确的版本号、编译参数甚至配置选项都必须一致。

这就引出了下一个我们要深入探讨的话题:Linux 内核的 ABI 兼容性问题。


检查 Linux 内核 ABI 兼容性问题

Linux 内核有一条关于 Application Binary Interface (ABI) 的硬性规定:

内核只会将一个模块插入内存,如果该模块是针对它自己精确构建的。

所谓的“针对它自己构建”,包括:

  • 精确的内核版本
  • 相同的编译器版本和标志
  • 相同的内核配置选项

这并不意味着内核模块完全没有可移植性。它们是源码级可移植的。只要有源代码,你可以在任何架构上重新编译并运行。但是,二进制文件(那个 .ko 文件)是不可移植的。它只能运行在编译它时指定的那个特定内核上。

图 5.1 演示了版本不匹配时的错误日志。

图 5.1:内核日志中关于版本魔数不匹配的报错信息

虽然我们现在不打算用,但有一个叫 DKMS (Dynamic Kernel Module Support) 的框架,专门用来解决第三方模块的自动重编译问题。它的核心思想很简单:当新内核安装时,自动重新编译模块

VirtualBox 的驱动就是用 DKMS 的典型例子。每次你的宿主机内核升级,DKMS 都会自动帮你重新编译 vboxdrv 等模块,确保它们能在新内核上运行。


尝试 4:解决 ABI 问题并成功加载

现在我们明白了问题所在:模块和运行它的内核必须“门当户对”。有几种解决方案:

  1. 硬核做法(嵌入式开发常用):配置、交叉编译并启动你自己定制的内核,然后所有模块都针对这个特定的内核源码树进行编译。这是嵌入式产品的标准做法。
  2. DKMS 做法(桌面/服务器常用):使用 DKMS 框架,让系统在内核更新时自动重编译模块。
  3. 妥协做法(实验用):重新编译你的模块,去匹配板子上当前运行的内核。

既然我们是在搞嵌入式开发,我们采用第一种方案:让板子启动我们之前在第 3 章编译好的那个自定义 6.1 内核

关于如何把内核镜像、设备树和模块拷贝到 SD 卡并启动,官方文档写得很清楚(https://www.raspberrypi.org/documentation/linux/kernel/building.md)。这里只提一个方便的小技巧:如何简单地在两个内核之间切换。

假设你的设备是一块树莓派 4B,运行 64 位内核:

  1. 把你自定义编译好的 Image 内核二进制文件拷贝到 SD 卡的 /boot 分区,命名为 kernel8.img。(为了安全,先把原来的重命名为 kernel8.img.orig 备份)。
  2. scp 把刚交叉编译好的 lkm_template.ko(ARM64 版本)拷贝到 SD 卡的 /home/pi 目录下。
  3. (可选)如果你想指定启动特定的内核,可以编辑 SD 卡上的 /boot/config.txt 文件,用 kernel= 指定文件名。不过默认情况下,bootloader 会自动加载 kernel8.img
  4. 保存并重启。

登录设备后,再次尝试加载模块。图 5.2 展示了成功运行的截图。

图 5.2:交叉编译的 LKM 在树莓派 4B 上成功运行。注意看内核版本、硬件和内核配置是如何完美匹配的。

看!这次成功了! 注意看 modinfo 的输出:vermagic 字段显示模块是针对 6.1.34-v8+ 编译的,而我们当前运行的内核也是 6.1.34-v8+。完美匹配。

⚠️ 注意:如果你在 rmmod 时遇到了非致命错误,或者卸载后模块状态异常,那可能是因为你还没有把新编译的内核模块完全部署好。你需要把所有内核模块(在 /lib/modules/<kernel-ver>/ 下)都拷贝过去,并在设备上运行 depmod


总结:关于交叉编译的那些坑

为了方便你以后查阅,我把这几次尝试中遇到的问题和解决办法总结在下表里:

尝试失败原因解决办法
1Makefile 试图去编译宿主机的内核源码树(x86_64)而不是目标的(ARM64)。修改 Makefile,根据 ARCH 变量将 KDIR 指向正确的目标内核源码目录:KDIR ?= ~/rpi_work/kernel_rpi/linux
2编译失败,modpost 阶段报错。因为目标内核树没有配置/编译过,缺少 Module.symvers 文件。确保 Module.symvers 文件存在。如果缺少,需要先配置并编译目标内核(make modules 会生成它)。
3模块加载失败,报 Invalid module format版本不匹配。模块编译时的内核版本(如 6.1.34)与板子上运行的内核版本(如 6.1.21)不一致。
4同上。启动板子时使用你自定义编译的内核(与模块版本一致),并确保模块是针对该内核编译的。

表 5.1:从 x86_64 宿主机到 AArch64(树莓派 4)目标机的模块交叉编译和运行尝试总结

LKM 框架的内容非常丰富。接下来,我们将探讨如何从内核模块内部获取一些最小的系统信息。


5.4 获取最小的系统信息

有时候,当你写一个需要跨架构移植的模块时,你需要根据当前运行的 CPU 家族来条件性地执行一些代码。内核提供了一些宏和方法来帮你“探测”这些底层的细节。我们现在来构建一个简单的演示模块(ch5/min_sysinfo/min_sysinfo.c),它会展示如何检测 CPU 架构、位宽和字节序。

为了不显得太啰嗦,我这里只展示最核心的函数部分:

// ch5/min_sysinfo/min_sysinfo.c
[ ... ]
void llkd_sysinfo(void)
{
char msg[128];
memset(msg, 0, 128);
my_snprintf_lkp(msg, 47, "%s(): minimal Platform Info:\nCPU: ", __func__);
/* 严格来说,下面这些 #if...#endif 有点丑陋,但为了演示方便 */
/* 在实际工程中,应尽可能将这些逻辑隔离 */

#ifdef CONFIG_X86
#if(BITS_PER_LONG == 32)
strncat(msg, "x86-32, ", 9);
#else
strncat(msg, "x86_64, ", 9);
#endif
#endif

#ifdef CONFIG_ARM
strncat(msg, "AArch32 (ARM-32), ", 19);
#endif

#ifdef CONFIG_ARM64
strncat(msg, "AArch64 (ARM-64), ", 19);
#endif
// ... (其他架构检查略)

#ifdef __BIG_ENDIAN
strncat(msg, "big-endian; ", 13);
#else
strncat(msg, "little-endian; ", 16);
#endif

#if(BITS_PER_LONG == 32)
strncat(msg, "32-bit OS.\n", 12);
#elif(BITS_PER_LONG == 64)
strncat(msg, "64-bit OS.\n", 12);
#endif

pr_info("%s", msg);
show_sizeof();
// ... (显示各种数据类型的范围,略)
}
EXPORT_SYMBOL(llkd_sysinfo);

这个模块演示了如何编写可移植的代码。记住,内核模块的二进制文件是不可移植的,但它的源代码可以是(而且应该是)可移植的。只要在目标架构上重新编译一下,就能部署。

图 5.3:我们简单有趣的 min_sysinfo 模块的内核输出(在 x86_64 虚拟机上)

图 5.3 中高亮的部分就是 llkd_sysinfo() 函数的输出。后面跟着 llkd_sysinfo2() 的输出(这是更安全的一个版本,下一节会讲)。它还用 sizeof() 打印了各种数据类型的字节大小,最后展示了该架构下的字长范围(包括有符号和无符号的 8/16/32/64 位整数)。

同样地,我们可以把这个模块交叉编译为 AArch64 版本,传到树莓派上运行:

图 5.4:在树莓派 4B 上运行我们的自定义 64 位 6.1.34-v8+ 内核时的输出

看看,这次输出的就是 AArch64 平台的相关信息了!

⚠️ 注意:在这个演示模块里,我们暂时忽略了 EXPORT_SYMBOL() 宏的用法,下一节马上就会讲到。另外,my_snprintf_lkp() 只是我简单封装的一个 snprintf() 包装器,为了更安全一点,暂时定义在当前文件里(min_sysinfo.c)。等讲到“模拟库功能”时,我们会把它挪到别的地方去。下一节会深入更多安全细节。


5.5 更有安全意识一点

现在,安全是个大问题。专业的开发者必须写出安全的代码。近年来,针对 Linux 内核的漏洞利用屡见不鲜。与此并行的是,内核社区也在不断努力提升安全性。

在我们刚才的 min_sysinfo.c 模块里,我们其实用了一些老式的、并不推荐的例程(比如 sprintfstrlen 等)。静态分析工具 是抓这类安全相关 Bug 的好帮手。我强烈建议你用起来。

我们可以用“更好”的 Makefile 里集成的 sa_flawfinder 目标来跑一下 flawfinder(由 David Wheeler 编写)这个工具:

$ make sa_flawfinder
make clean
[...]
--- static analysis with flawfinder ---
flawfinder *.[ch]
Flawfinder version 2.0.19, (C) 2001-2019 David A. Wheeler.
[...]
Examining min_sysinfo.c
FINAL RESULTS:
min_sysinfo.c:54: [2] (buffer) char:
Statically-sized arrays can be improperly restricted, leading to pot. overflows
or other issues (CWE-119!/CWE-120). Perform bounds checking or use functions
that limit length, or ensure that the size is larger than the maximum possible
length.
[...]
min_sysinfo.c:136: [1] (buffer) strncat:
Easily used incorrectly (e.g., incorrectly computing the correct max size to add)
[MS-banned] (CWE-120). Consider strcat_s, strlcat, snprintf or automatically
resizing strings. Risk is low because the source is a constant string.

仔细看 flawfinder 关于 strncat() 函数的警告。遵循它的建议,我们在 llkd_sysinfo2() 函数中使用了 strlcat() 来替代它(代码更安全)。同理,为了防止缓冲区溢出(很多漏洞的根源),使用 snprintf() 时也必须检查其返回值。所以我写了一个简单的包装器 my_snprintf_lkp()

图 5.5flawfinder 输出中提到的 CWE 编号(如 CWE-120)。 去 https://cwe.mitre.org/ 搜一下,你会明白它指的是哪一类安全问题。CWE-120 就是“经典的缓冲区溢出”,这是最常见的黑客攻击目标之一。

当然,关于 Linux 内核安全和加固技术,还有很多内容要讲。你可以参考我在 Embedded IOT Summit 2023 上的演讲:《Mitigating Hackers with Hardening on Linux》(https://www.youtube.com/watch?v=KQa_XEiLGMc)。

现在,让我们把话题转向一个稍微枯燥但绝对重要的问题:许可协议


5.6 许可内核模块

众所周知,Linux 内核本身是在 GNU GPL v2 协议下发布的。正如第 4 章简要提到的,给你的内核代码选择正确的许可协议是必须的,而且很重要。我们把这个问题拆成两部分:

  1. 行内内核代码(直接贡献给主线内核的代码)
  2. 第三方树外模块(我们大多数人写的模块)

行内内核代码的许可

如果你的代码是直接写在内核源码树里的,或者打算贡献给主线内核,那么你必须按照内核本身的协议——GNU GPL-2.0——来发布代码。这一点在官方文档里有明确规定:https://docs.kernel.org/process/license-rules.html#linux-kernel-licensing-rules。

为了保持一致,现在的内核有一条硬性规定:每个源文件的第一行必须是 SPDX 许可标识符https://spdx.org/)。这是一种简明扼要的声明代码协议的方式。所以,大部分 C 源文件的第一行长这样:

// SPDX-License-Identifier: GPL-2.0

树外内核模块的许可

对于树外模块,情况稍微“灵活”一点,但有一条底线:如果你想获得内核社区的帮助(这可是巨大的加分项),你应该(或者说被期望)在 GNU GPL-2.0 协议下发布代码(当然,双重许可也是可以的,比如 "Dual MIT/GPL")。

声明模块许可的方式有两种:

  1. SPDX-License-Identifier 标签:作为源文件第一行的注释。严格来说,这主要适用于源码树内的模块。
  2. MODULE_LICENSE() 宏:这是必须的。官方文档明确指出:“可加载内核模块还需要一个 MODULE_LICENSE 标签。这个标签不是 proper 源代码许可信息的替代品(SPDX-License-Identifier),也用于表达或确定源代码本身的许可协议。”

它的唯一作用是告诉内核模块加载器和用户空间工具:这个模块是“自由软件”还是“专有软件”。include/linux/module.h 里清楚地列出了哪些许可标识是可接受的:

/*
* 以下许可标识符目前被接受为软件模块
* "GPL" [GNU Public License v2 or later]
* "GPL v2" [GNU Public License v2]
* "Dual BSD/GPL" [GNU Public License v2 or BSD license choice]
* "Dual MIT/GPL" [GNU Public License v2 or MIT license choice]
* "Dual MPL/GPL" [GNU Public License v2 or Mozilla license choice]
*
* 其他可用标识
* "Proprietary" [Non free products]

显然,内核社区强烈建议你用 GPL-2.0 或者类似的协议(BSD/MIT/MPL)。如果你想贡献代码到主线,那么 GPL-2.0 是唯一的选择

关于许可协议的细节很多,也很复杂(甚至需要法律知识)。强烈建议你咨询公司法务。至于 GPL 的常见问题解答(FAQ),可以看看这里:https://www.gnu.org/licenses/gpl-faq.html。

好了,枯燥的法律话题到此为止。接下来我们回到技术细节:如何在内核空间模拟“库”的功能


5.7 模拟“库”的功能

用户空间编程和内核空间编程的一个巨大区别在于:内核里没有传统意义上的“库”lib/ 文件夹里虽然有一些类似库的例程,但它们是直接编译进内核镜像的,不能像 .so 文件那样动态链接。

好消息是,我们有两种方法可以达到类似的效果:

  1. 显式链接多个源文件:把“库”代码和你的模块代码编译链接成一个单一的 .ko 文件。
  2. 模块堆叠:这是真正的“库”概念,一个“核心”模块导出符号,供其他模块使用。

先说结论:第一种方法通常更优越。但第二种方法也有它的用武之地。让我们详细看看。

通过链接多个源文件来模拟库

到目前为止,我们的模块都只有一个 .c 文件。如果项目大了怎么办?比如有个项目叫 projx,包含 prj1.cprj2.cprj3.c。你想把它们编译成一个叫 projx.ko 的模块。

在 Makefile 里这样写就行:

obj-m := projx.o
projx-objs := prj1.o prj2.o prj3.o

注意 projx 这个标签在 obj-mprojx-objs 里的用法。构建系统会先把三个 .c 文件编译成 .o 文件,然后链接成一个最终的 projx.ko

在我们的书里,我们就用了这个机制来构建一个小的“内核库”(源码在根目录的 klib.hklib.c)。其他模块(比如第 8 章的 lowlevel_mem)可以通过链接这个 klib.o 来使用里面的函数。在 lowlevel_mem 的 Makefile 里是这样写的:

FNAME_C := lowlevel_mem
[ … ]
PWD := $(shell pwd)
obj-m += ${FNAME_C}_lkm.o
lowlevel_mem_lkm-objs := ${FNAME_C}.o ../../klib.o

这行 lowlevel_mem_lkm-objs := ... 就告诉构建系统:把当前模块的代码和上一级目录的 klib.c 代码一起编译并链接成一个 lowlevel_mem_lkm.ko

这种方法的优点很明显:

  • 不需要显式地用 EXPORT_SYMBOL 标记每个函数/数据。
  • 这些函数和数据只对链接进来的那个模块可见,不会污染全局符号表。

缺点嘛,就是最终生成的 .ko 文件可能会比较大。

在深入“模块堆叠”之前,我们需要先理解一个更基础的概念:函数和变量的作用域


理解内核模块中函数和变量的作用域

C 语言里的作用域规则大家都懂:

  • 函数内的局部变量……嗯,是局部的。
  • 带有 static 关键字的变量和函数,作用域仅限于当前文件。这很好,能减少命名空间污染。

在古老的 Linux 内核(2.4 及以前),模块里的所有全局变量和函数默认都是全局可见的。这显然不是个好主意。

从 2.6 内核开始,规则变了:所有内核模块的变量(包括 static 和全局数据)和函数,默认都是模块私有的,对模块外不可见。所以,如果两个模块 lkmAlkmB 都有一个叫 maya 的全局变量,它们互不干扰,各自独立。

要想让它们“走出去”,就得用 EXPORT_SYMBOL() 宏。

举个例子,有个核心模块叫 prj_core

static int my_glob = 5;
static long my_foo(int key) { [...] }

这两个东西只能在模块内部用。要让外面看到,得这样做:

int my_glob = 5;
EXPORT_SYMBOL(my_glob);

long my_foo(int key) { [...] }
EXPORT_SYMBOL(my_foo);

现在,它们的作用域就到了模块之外。注意,我们特意去掉了 static 关键字。

这个机制有两个层面的应用:

  1. 内核导出符号:内核本身通过 EXPORT_SYMBOL*() 宏导出了一大堆核心 API 和变量,这样模块才能调用 printkkmalloc 等函数。树外模块只能使用内核明确导出的符号
  2. 模块间导出符号:模块作者也可以导出自己的符号,供其他高层模块使用。这就是所谓的 “模块堆叠”

比如,你想写个驱动处理硬件中断,你得调用内核导出的 request_threaded_irq() API。正是因为它在内核里被 EXPORT_SYMBOL 了,你的模块才能调用它。

再比如,内核在 lib/string.c 里实现了一系列字符串处理函数(str[n]casecmp, strcpy 等),也都导出了,模块里随便用。

反过来,看个反例。核心调度器里的 pick_next_task_fair() 函数就没有导出(没加 EXPORT_SYMBOL)。所以,你在任何模块里都别想直接调用它,它是内核的私有实现。

还有一个宏叫 EXPORT_SYMBOL_GPL()。它和 EXPORT_SYMBOL() 的区别在于:只有声明为 GPL 协议的模块才能使用导出的符号。这是内核社区的一点点“复仇”。如果你试图在一个“Proprietary”(专有)协议的模块里使用 GPL-only 的符号,加载时会被拒绝。

小贴士:如果你想看内核里导出了哪些符号,在内核源码根目录下运行 make export_report 命令(前提是你已经配置并编译过内核)。

好了,掌握了作用域和符号导出,我们可以正式来看看“模块堆叠”了。


理解模块堆叠

“模块堆叠”就是指一个模块依赖另一个模块导出的符号,形成一种依赖关系。这在设计大型驱动或子系统时非常有用,可以实现分层和复用。

最直观的例子就是 VirtualBox 的驱动。在宿主机上运行 lsmod | grep vbox

vboxnetadp 28672 0
vboxnetflt 28672 1
vboxdrv 614400 3 vboxnetadp,vboxnetflt

看第三列(使用计数),vboxdrv 是 3,而且后面跟着两个模块名。这意味着 vboxnetadpvboxnetflt 依赖 vboxdrv。它们调用了 vboxdrv 提供的 API。vboxdrv 就像一个底层库,其他模块“堆”在它上面。

图 5.5:内核 hid 模块作为核心,被许多其他 HID 相关驱动依赖,展示了“模块堆叠”的设计。

堆叠的一个直接后果:你只能卸载引用计数为 0 的模块。想卸载 vboxdrv?先得把依赖它的 vboxnetadpvboxnetflt 卸载掉。


亲自试一试:模块堆叠

让我们写两个简单的模块来演示这个概念。

  1. core_lkm(核心模块):扮演“库”的角色,导出一个函数和一个变量。
  2. user_lkm(用户模块):扮演“客户”角色,调用核心模块导出的东西。

为了演示,我们把 core_lkm 设计成导出我们之前写的 llkd_sysinfo2() 函数。

核心“库”模块代码

// ch5/modstacking/core_lkm.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
#include <linux/init.h>
#include <linux/module.h>

#define THE_ONE 0xfedface

MODULE_LICENSE("Dual MIT/GPL");

int exp_int = 200;
EXPORT_SYMBOL_GPL(exp_int);

/* 函数实现 */
void llkd_sysinfo2(void) { [...] }
EXPORT_SYMBOL(llkd_sysinfo2);

#if(BITS_PER_LONG == 32)
u32 get_skey(int p)
#else // 64-bit
u64 get_skey(int p)
#endif
{
#if(BITS_PER_LONG == 32)
u32 secret = 0x567def;
#else // 64-bit
u64 secret = 0x123abc567def;
#endif
if (p == THE_ONE)
return secret;
return 0;
}
EXPORT_SYMBOL(get_skey);
[...]

注意,我们用 EXPORT_SYMBOL_GPL 导出了一个整数 exp_int

用户“库”客户端模块代码

// ch5/modstacking/user_lkm.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__

/* 这里的许可协议声明很关键,稍后解释 */
#if 1
MODULE_LICENSE("Dual MIT/GPL");
#else
MODULE_LICENSE("MIT");
#endif

extern void llkd_sysinfo2(void);
extern long get_skey(int);
extern int exp_int;

static int __init user_lkm_init(void)
{
#define THE_ONE 0xfedface
pr_info("inserted\n");
u64 sk = get_skey(THE_ONE);
pr_debug("Called get_skey(), ret = 0x%llx = %llu\n", sk, sk);
pr_debug("exp_int = %d\n", exp_int);
llkd_sysinfo2();
return 0;
}

static void __exit user_lkm_exit(void)
{
pr_info("bids you adieu\n");
}

module_init(user_lkm_init);
module_exit(user_lkm_exit);

注意 extern 声明,告诉编译器这些符号在外部。

构建并运行

Makefile 很简单,就是同时构建两个目标:

obj-m := core_lkm.o
obj-m += user_lkm.o

编译:

$ make
[...]
$ ls *.ko
core_lkm.ko user_lkm.ko

现在,我们来演示一下依赖关系。先故意做错:先插入 user_lkm

$ sudo insmod ./user_lkm.ko
insmod: ERROR: could not insert module ./user_lkm.ko: Unknown symbol in module

失败了。看 dmesg

[...] user_lkm: Unknown symbol exp_int (err -2)
[...] user_lkm: Unknown symbol get_skey (err -2)
[...] user_lkm: Unknown symbol llkd_sysinfo2 (err -2)

因为 core_lkm 还没加载,符号表里根本找不到这些符号。

现在做对了:先插入 core_lkm,再插入 user_lkm

图 5.6:演示模块堆叠概念性验证。上面是 shell,下面是 journalctl -k -f 实时显示内核日志。

成功了!注意 lsmod 的输出,core_lkm 的引用计数变成了 1,后面跟着 user_lkm

现在试着卸载。如果你先卸载 core_lkm,会报错:

$ sudo rmmod core_lkm
rmmod: ERROR: Module core_lkm is in use by: user_lkm

必须按相反顺序卸载:

$ sudo rmmod user_lkm core_lkm
[...]

关于许可协议的“坑”

还记得 user_lkm.c 里的那段 #if 1 吗?我们把它声明为 "Dual MIT/GPL"

但在 core_lkm.c 里,我们用 EXPORT_SYMBOL_GPL 导出了 exp_int。这意味着只有 GPL 协议的模块才能用这个变量

如果你把 user_lkm.c 里的宏改成 #if 0,也就是只用 "MIT" 协议,重新编译会直接报错:

ERROR: modpost: GPL-incompatible module user_lkm.ko uses GPL-only symbol 'exp_int'

这就是内核社区 enforcing(强制执行)其规则的方式。


模块堆叠可能遇到的坑

这里有一个容易踩的坑:命名空间冲突

假设你的模块导出了一个叫 foo 的符号,而内核里已经有一个叫 foo 的符号了(或者另一个模块导出了同名符号)。当你试图加载模块时,会因为符号冲突而失败。

比如,如果我们先加载 min_sysinfo 模块(它导出了 llkd_sysinfo2),然后再加载 core_lkm(它也导出了 llkd_sysinfo2),就会报错:

[...] core_lkm: exports duplicate symbol llkd_sysinfo2 (owned by min_sysinfo)

解决办法:先卸载冲突的模块。

小贴士:遇到模块加载问题,第一件事永远是看 dmesgjournalctl。日志会告诉你真相。


总结:如何模拟“库”功能

有两种方法:

  1. 链接多个源文件:把所有代码编译进一个 .ko 文件。
    • 优点:不需要导出符号(更安全,不会污染全局符号表),Makefile 改动小。
    • 缺点:生成的 .ko 文件可能比较大。
  2. 模块堆叠:多个独立的 .ko 文件,通过导出/引用符号协作。
    • 优点:真正的模块化,动态加载/卸载。
    • 缺点:需要管理依赖关系(加载顺序),符号可能污染全局命名空间,存在许可协议风险。

建议:优先使用第一种方法(链接源文件)。除非你真的需要动态加载的灵活性,或者项目结构决定了必须分层(比如大型驱动栈)。


5.8 向内核模块传递参数

我们经常需要调试代码,最简单的办法就是加 printk。但在生产环境,我们肯定不希望这些调试信息到处乱飞。这就有个需求:能不能动态地开关这些调试信息?

一个简单的办法是加个变量 debug_level。如果能把这玩意儿变成一个模块参数,用户就可以在插入模块时自由控制了。

提示:其实内核有个更强大的 Dynamic Debug 功能(CONFIG_DYNAMIC_DEBUG),可以精细控制任何 pr_debug() 的输出,比传参方便多了。第 4 章简要提过。

除了调试,模块参数还有很多用途,比如设置驱动默认值(音量、亮度等)。让我们来看看怎么搞。


声明和使用模块参数

模块参数是键值对的形式,在插入模块(insmod/modprobe)时传递。

假设我们有个参数叫 mp_debug_level,插入时可以这样传:

sudo insmod ./modparams1.ko mp_debug_level=2

内核模块没有 main(),那参数怎么传进去?靠的是一点链接器的 trick

只需要两个步骤:

  1. 把变量声明为全局 static 变量。
  2. module_param() 宏把它注册为模块参数。

看个例子:

// ch5/modparams/modparams1/modparams1.c
[ ... ]
/* 模块参数 */
static int mp_debug_level;
module_param(mp_debug_level, int, 0660);
MODULE_PARM_DESC(mp_debug_level, "Debug level [0-2]; 0 => no debug messages, 2 => high verbosity");

static char *mp_strparam = "My string param";
module_param(mp_strparam, charp, 0660);
MODULE_PARM_DESC(mp_strparam, "A demo string parameter");

module_param() 宏接受三个参数:

  1. 变量名
  2. 数据类型(这里 intcharp——字符指针,即字符串)。
  3. 权限(实际上是 sysfs 里的文件权限,0660 表示Owner/Group可读写)。

MODULE_PARM_DESC() 宏则是给参数加个描述,用户可以通过 modinfo 查看:

$ modinfo -p ./modparams1.ko
parm: mp_debug_level:Debug level [0-2]; 0 => no debug messages, 2 => high verbosity (int)
parm: mp_strparam:A demo string parameter (charp)

运行一下试试:

# 默认值加载
$ sudo insmod ./modparams1.ko
$ dmesg
[...] modparams1: inserted
[...] modparams1: module parameters passed: mp_debug_level=0 mp_strparam=My string param

# 传入参数
$ sudo rmmod modparams1
$ sudo insmod ./modparams1.ko mp_debug_level=2 mp_strparam=\"Hello params\"
$ dmesg
[...] modparams1: inserted
[...] modparams1: module parameters passed: mp_debug_level=2 mp_strparam=Hello params

太神奇了,内核内存里的变量被你改了!


在插入后修改模块参数

还记得 module_param 的第三个参数 0660 吗?它不仅是个权限掩码,还决定了会不会在 sysfs 里创建对应的文件。只要权限非零,文件就会出现在 /sys/module/<模块名>/parameters/ 下。

$ ls -l /sys/module/modparams1/parameters/
-rw-rw---- 1 root root 4096 [...] mp_debug_level
-rw-rw---- 1 root root 4096 [...] mp_strparam

这意味着,你可以在模块运行时实时读取和修改这些参数

# 读取
$ sudo cat /sys/module/modparams1/parameters/mp_debug_level
2

# 修改
$ sudo sh -c "echo 0 > /sys/module/modparams1/parameters/mp_debug_level"
$ sudo cat /sys/module/modparams1/parameters/mp_debug_level
0

这功能非常强大。你可以写个脚本来动态控制设备行为,或者开关调试信息。

注意:内核社区更倾向于直接用八进制数字表示权限(如 0660),而不是用宏(如 S_IRUSR|S_IWUSR...)。checkpatch.pl 脚本会对此发出警告。


模块参数的数据类型与验证

除了 intcharp,内核还支持很多类型:

  • byte, short, ushort, uint, long, ulong
  • bool(接受 0/1, y/n, Y/N)
  • invbool(逻辑反转)

如果用户必须传递某个参数怎么办?内核没有原生的强制机制,但你可以在 init 函数里检查

比如,我们要求用户必须传 control_freak 参数,且值在 1 到 5 之间:

static int __init modparams2_init(void)
{
if ((control_freak < 1) || (control_freak > 5)) {
pr_warn("I'm a control freak; you *Must* pass along the control_freak param (1-5)!\n");
return -EINVAL; // 返回错误会中止插入
}
return 0;
}

如果用户没传,或者传错了,insmod 会失败。

对于更复杂的参数处理,内核提供了 module_param_cb() 宏,允许你挂接回调函数来做验证和设置。


覆盖参数名称

有时候,你的内部变量名不太直观,你想给用户暴露一个更友好的名字。可以用 module_param_named()

比如,内部变量叫 dm_bufio_current_allocated,你想让用户传 current_allocated_bytes

module_param_named(current_allocated_bytes, dm_bufio_current_allocated, int, 0644);
MODULE_PARM_DESC(current_allocated_bytes, "Memory currently used by the bufio allocator");

这样用户就可以这样插入模块: sudo insmod dm-bufio.ko current_allocated_bytes=4096


硬件相关参数

为了安全,涉及硬件地址(I/O 端口、内存地址、IRQ 等)的参数有专门的宏:module_param_hw[_named|array]()。这是为了配合 Secure Boot 等安全机制,锁定这些敏感参数。


5.9 内核里禁止用浮点数

这是一个年轻且缺乏经验的家伙(也就是当年的我)在写温度传感器驱动时遇到的惨痛教训。

他试图把以毫摄氏度为单位的整数温度转换成带三位小数的摄氏度,于是写了类似这样的代码:

int temperature;
double temperature_fp;

[... 处理 ...]
temperature_fp = temperature / 1000.0;
printk(KERN_INFO "temperature is %.3f degrees C\n", temperature_fp);

结果嘛……并没有像童话故事那样发展。

LDD3(Linux Device Drivers 3)狠狠地教育了他:内核空间里禁止浮点运算!

这是一项有意识的设计决定。在内核里保存/恢复浮点寄存器状态、开启 FPU、计算、关闭 FPU……这一套下来开销太大,不划算。

那怎么办? 简单:把整数传给用户空间,让用户空间的程序去算浮点数!

这其实是一个普遍原则:有些事就不该在内核里做,用户空间才是正解(包括文件 I/O,虽然内核提供了 call_usermodehelper 这种例外,但得慎用)。


强行在内核里用浮点

虽说原则上禁止,但如果你真的、真的、真的需要在内核里做浮点运算,内核还是留了一扇后门:把代码包在 kernel_fpu_begin()kernel_fpu_end() 之间。

内核里确实有几处地方这么干了(比如加密、CRC 计算等)。

我们来写个模块测试一下:

// ch5/fp_in_kernel/fp_in_kernel.c
static double num = 22.0, den = 7.0, mypi;

static int __init fp_in_lkm_init(void)
{
kernel_fpu_begin();
mypi = num / den;
kernel_fpu_end();

#if 1
pr_info("%s: PI = %.4f\n", OURMODNAME, mypi);
#endif
return 0;
}

如果你编译并加载它,计算本身没问题,但当你试图用 printk 打印那个 %f 时……世界会崩塌

图 5.7:尝试在内核空间打印浮点数时 WARN_ONCE() 的输出。

关键的一行是:Please remove unsupported %f in format string。 虽然这只是个 WARNING(通常不会导致系统 Panic,除非你设置了 panic_on_warn),但看那个 Call Trace,它一路追踪到了 vsnprintf -> vprintk -> _printk -> fp_in_lkm_init。如果你读懂了这个栈,你就知道是哪里出事了。

结论:老老实实做整数运算吧。


5.10 系统启动时自动加载模块

以前我们都是用 insmod 手动加载。但在产品里,你肯定希望模块开机就自动跑起来。这节就讲怎么做。

假设你有个模块叫 foo.ko。想让开机自动加载,分两步:

  1. 安装模块到系统目录:把 .ko 文件拷贝到 /lib/modules/<内核版本>/extra/
  2. 配置加载:告诉系统在启动时加载它。

我们的“更好”Makefile 已经帮你搞定了第一步。它有个 install 目标:

install:
@echo "--- installing ---"
make
sudo make -C $(KDIR) M=$(PWD) modules_install
sudo depmod

这个目标做了几件事:

  • 先确保编译了(make)。
  • 调用内核构建系统的 modules_install 目标,把模块安装到正确位置。
  • 运行 depmod,更新模块依赖关系文件。

1. 安装模块

$ cd <book_src>/ch5/min_sysinfo
$ make && sudo make install
[...]
--- installing ---
[First, invoking the 'make']
make
[...]
[Now for the 'sudo make install']
sudo make -C /lib/modules/6.1.25-lkp-kernel/build M=/home/c2kp/... modules_install
make: Entering directory '/lib/modules/6.1.25-lkp-kernel/build'
INSTALL /lib/modules/6.1.25-lkp-kernel/extra/min_sysinfo.ko
DEPMOD /lib/modules/6.1.25-lkp-kernel
[...]

现在,你的模块已经乖乖躺在 /lib/modules/6.1.25-lkp-kernel/extra/min_sysinfo.ko 了。

2. 配置自动加载

在 systemd 系统上(这是现在的标准),最简单的方法是在 /etc/modules-load.d/ 下放个配置文件。文件名随便起,内容写模块名就行。

$ sudo vim /etc/modules-load.d/min_sysinfo.conf
# Auto load kernel module demo
min_sysinfo

3. 重启并验证

$ sudo reboot

重启后,用 lsmoddmesg 检查:

图 5.8:我们的 min_sysinfo 模块在启动时被自动加载到内核内存中。

看!它真的在启动日志里出现了。


如果需要传递参数怎么办?

如果你的模块加载时需要参数,可以通过 /etc/modprobe.d/ 下的配置文件来设置。

比如,有个模块叫 mykmod,需要设置 some_param=123

创建 /etc/modprobe.d/mykmod.conf

options mykmod some_param=123

这样,当 modprobe 加载 mykmod 时,会自动带上这个参数。


模块自动加载的额外细节

这里其实有一个幕后英雄:modprobe。它比 insmod 聪明得多。

  • 智能依赖处理:还记得“模块堆叠”吗?如果你用 insmoduser_lkm,你得先手动加 core_lkm。但 modprobe 会自动去读 /lib/modules/$(uname -r)/modules.dep 文件,把依赖的模块先加载进去。
  • modules.dep 是哪来的? 它是你在运行 sudo make install 时,其中的 depmod 命令生成的。

既然 modprobe 这么好用,为什么不直接用它?

  • 开发阶段:用 insmod 更直接,能看到当前的 .ko 文件。
  • 产品阶段:用 modprobe 更稳妥,它能处理路径和依赖。

黑名单

有些模块你不想让它自动加载(比如它有问题),你可以把它加到黑名单里。

  • 临时方法(内核命令行)module_blacklist=mod1,mod2
  • 永久方法(配置文件):在 /etc/modprobe.d/ 下创建 .conf 文件,写入 blacklist <模块名>

小贴士:可以用 cat /proc/cmdline 查看当前的内核启动参数。调试启动问题时,initcall_debug 参数非常有用,它能帮你看到哪个 initcall 卡住了。


5.11 内核模块与安全——概览

这里有个讽刺的现实:用户空间的安全这几年做得越来越好了。栈保护、ASLR、安全库……想搞个栈溢出攻击越来越难。但内核空间的安全问题却日益凸显。

泄露一个内核地址给攻击者,可能就意味着他可以计算出关键结构体的位置,从而提权。

作为内核开发者,你的代码是第一道防线。虽然内核提供了一些加固机制(LSM、签名等),但如果你写的代码本身就有漏洞,那一切都白搭。

我们的“更好”Makefile 就是一个很好的起点,集成了各种静态分析工具。现在,我们来看看内核本身提供的一些安全加固特性。


影响 system log 的 proc 文件系统调整项

内核提供了一些 sysctl 开关(/proc/sys/...)来控制安全行为。

1. dmesg_restrict

这个开关控制谁能看内核日志。

  • 0(默认):所有人都能看。
  • 1:只有 CAP_SYSLOG 权限(通常是 root)能看。

查看当前值:

$ cat /proc/sys/kernel/dmesg_restrict
0

在生产环境上,你应该把它设为 1。防止普通用户通过日志泄露信息。

2. kptr_restrict

这个开关控制 printk 打印内核指针时的行为。

  • 0:不限制。%p 会打印真实地址。
  • 1:限制。%pK 会被替换成 0,除非有 CAP_SYSLOG
  • 2:无论有没有权限,%pK 总是被替换成 0。

最佳实践:永远不要用 %p%px 打印内核地址。用 `%pK

printk("var = %pK\n", &var); // 安全做法
printk("var = %p\n", &var); // 危险做法,会泄露地址

在生产系统上,把 kptr_restrict 设为 1 或 2。

小贴士:可以用 sysctl -w 或修改 /etc/sysctl.conf 来永久设置这些参数。


理解内核模块的加密签名

如果攻击者拿到了 root 权限,他可能会尝试加载一个恶意的内核模块作为 Rootkit。为了防止这个,内核引入了模块签名机制。

原理:内核里有一个“钥匙环”。当你尝试插入模块时,内核会检查该模块是否被钥匙环里的私钥签名过。如果不是,拒绝加载。

相关的内核配置选项(make menuconfig -> Enable loadable module support -> Module signature verification):

  • CONFIG_MODULE_SIG:启用模块签名验证。
  • CONFIG_MODULE_SIG_ALL:自动给所有安装的模块签名。
  • CONFIG_MODULE_SIG_FORCE强制模式。拒绝加载所有未签名或签名无效的模块。

有两种模式:

  1. Permissive(宽容,默认):未签名的模块也能加载,但内核会被标记为“Tainted”(被污染)。这是大多数发行版的默认设置。
  2. Restrictive(严格):只有签名的模块能加载。这是推荐的安全设置。

注意:一旦模块被签名,就不能再被 strip 剥离符号了,否则签名验证会失败。我们的 Makefile 已经考虑到了这一点,如果开启了签名,它就不会 strip。


禁止加载内核模块

如果你偏执到极致,你可以完全禁止加载任何模块。

  1. 编译时禁止:把 CONFIG_MODULES 设为 n。这是永久性的。
  2. 运行时禁止:修改 modules_disabled sysctl。
$ cat /proc/sys/kernel/modules_disabled
0
$ echo 1 | sudo tee /proc/sys/kernel/modules_disabled

一旦设为 1,任何人都无法再加载或卸载模块。这招通常在服务器完全配置好、不再需要改动硬件时使用,作为最后一道防线。


内核 Lockdown LSM

还有一个更猛的安全机制叫 Lockdown LSM(从 5.4 内核引入)。

它有两种模式:

  • Integrity(完整性):禁止用户空间修改内核。
  • Confidentiality(机密性):禁止用户空间提取内核敏感信息(比如内核内存、dmesg 等)。

这个机制通常在 Secure Boot 开启时自动激活。这可能会导致你的模块加载失败(因为它可能被认为是“不受信”的修改)。

安全是把双刃剑。作为开发者,你必须理解并适应这些规则。


5.12 内核开发者的代码风格指南

Linux 内核社区有自己严格的代码风格指南。遵守它不仅是礼貌,更是为了让你的代码能被接受。

官方指南在这里:https://www.kernel.org/doc/html/latest/process/coding-style.html

更重要的是,内核提供了一个 Perl 脚本 scripts/checkpatch.pl 来检查你的代码是否符合规范。

对于树外模块,你可以这样运行它:

<kernel-src>/scripts/checkpatch.pl --no-tree -f <filename>.c

养成习惯,每次提交代码前都跑一遍。这能帮你抓出很多低级错误。

练习:进入你的模块目录,运行 make checkpatch。仔细阅读警告和错误,修改代码,再跑一次。(注意:关于 SPDX-License 的警告通常可以忽略,因为我们已经在第一行加了)。


贡献到主线内核

如果你在内核源码树里开发,并且想把代码贡献给主线(Upstream),那你就是开源英雄了。

怎么开始? 官方文档写得非常清楚:Documentation/process/howto.rst

里面详细介绍了开发流程、补丁提交规范。特别是这个补丁提交检查清单(Documentation/process/submit-checklist.rst),有 24 条之多,必须严格遵守。

图 5.10:内核开发文档的部分截图,这是通往核心开发者的必经之路。

虽然这看起来很繁琐,但正是这种严格保证了内核的高质量。我强烈建议你读一遍这些文档。

一个快速成为内核高手的秘籍:除了读这本书(哈哈),去参加 Eudyptula Challenge(虽然它关闭了,但网上有镜像)。这是一系列循序渐进的内核编程挑战,做完你会突飞猛进。


本章小结

这一章,我们结束了关于编写内核模块的基础之旅。我们用了一个“更好”的 Makefile 模板武装了自己,学习了如何配置调试内核来抓 Bug,挑战了交叉编译并解决了棘手的 ABI 兼容性问题。我们还深入了内核模块的内部世界:如何获取系统信息、如何处理许可协议、如何模拟“库”功能以及如何通过参数控制模块行为。

最后,我们花了不少篇幅讨论安全——这是内核开发中不可忽视的一环。从静态分析到模块签名,再到内核加固机制,这些不仅是工具,更是思维方式。

恭喜你!现在你已经掌握了开发内核模块的完整流程,甚至已经站在了向主线贡献代码的起跑线上。

下一章,我们将进入内核的更深处——理解内核的内部架构、进程和线程的本质。准备好,我们要开始探索 Linux 内核的灵魂了。


练习题

练习 1:understanding

题目:如果你从 x86_64 主机交叉编译了一个内核模块给 ARM64 设备(树莓派),在主机上生成的模块文件(.ko)是否可以直接复制到另一台运行相同内核版本(如 6.1.21-v8+)的 x86_64 服务器上运行?请结合 Linux 内核 ABI 兼容性规则解释原因。

答案与解析

答案:不可以

解析:根据 Linux 内核 ABI 兼容性规则,内核模块具有严格的二进制接口限制。模块不仅必须针对特定的内核版本编译,还必须针对特定的处理器架构(ISA)编译。虽然内核版本可能一致,但 x86_64 和 ARM64 是完全不同的指令集架构。为 ARM64 编译的二进制文件包含该架构的机器码,无法在 x86_64 处理器上执行。此外,内核模块不仅依赖于版本,还依赖于内核配置和编译器环境,这些在不同架构间通常完全不同。

练习 2:application

题目:在开发驱动程序时,假设你希望一个名为 'get_stats' 的核心函数能被其他内核模块复用,但你不想将其完全开放给专有(Proprietary)模块使用。请问你应该使用哪个宏来导出该符号?如果要限制访问权限,还需要在模块代码中添加什么声明?

答案与解析

答案:使用 EXPORT_SYMBOL_GPL 导出符号,并确保模块使用 MODULE_LICENSE("GPL") 声明许可证。

解析:1. 在内核模块开发中,要将符号导出给其他模块使用,通常使用 EXPORT_SYMBOL_GPL。与 EXPORT_SYMBOL 不同,EXPORT_SYMBOL_GPL 限制只有声明了 GPL 兼容许可证(如 MODULE_LICENSE("GPL"))的模块才能使用该符号。 2. 如果没有使用 MODULE_LICENSE 声明为 GPL,或声明为专有,那么即使链接了导出的 GPL 符号,内核也会在加载时因为版本控制(CRC)检查失败或权限不足而拒绝加载。这符合内核对 GPL 代码的保护机制。

练习 3:application

题目:已知内核空间默认禁止浮点运算。请编写一个代码片段,展示如何在内核中安全地执行 float a = 10.5; float b = 20.5; 的加法运算,并将结果通过 printk 打印出来。你的代码需要包含必要的头文件和保护区域宏。

答案与解析

答案:代码示例如下:

#include <linux/module.h> #include <linux/kernel.h>

void safe_float_op(void) { float a = 10.5; float b = 20.5; float res;

kernel_fpu_begin();
{
res = a + b;
printk("Result: %f\n", res);
}
kernel_fpu_end();

}

解析:内核在进程上下文切换时通常不会保存/恢复浮点寄存器(FPU/MMX/SSE)以优化性能。如果在内核中直接使用浮点运算,会破坏用户空间的浮点状态。因此,必须使用 kernel_fpu_begin() 告诉内核此时需要使用 FPU,它会保存当前的 FPU 状态;运算完成后,必须调用 kernel_fpu_end() 恢复状态。此外,使用 printk 打印浮点数(%f)在某些旧内核版本可能需要特殊配置,但在现代内核中通过上述保护机制是可行的。

练习 4:application

题目:假设你正在设计一个高安全性的 Linux 服务器系统。作为安全管理员,你希望确保内核日志中不会泄露敏感的内核地址信息(防止攻击者利用 KASLR 绕过),同时还要确保即使攻击者获取了 Root 权限,也无法插入恶意的内核模块(Rootkit)。请分别说明你应该修改 /etc/sysctl.conf 中的哪两个参数来实现这两个目标?

答案与解析

答案:1. kernel.kptr_restrict = 2 (或 1) 2. kernel.modules_disabled = 1

解析:1. kernel.kptr_restrict:将其设置为 1(禁止普通用户查看)或 2(禁止所有非显式授权的访问),可以防止 %pK 格式说明符在 printk 或 dmesg 中输出实际的内核指针地址,从而增加攻击者定位内核地址的难度,配合 KASLR 提高安全性。 2. kernel.modules_disabled:将其设置为 1 会彻底禁用模块加载和卸载功能(rmmod 和 insmod 将失效)。这是一个“核选项”,一旦设置,系统将锁定当前的内核代码路径,防止攻击者加载 Rootkit 模块。这通常在服务器启动并加载完所有必要驱动后设置。


要点提炼

要构建一个专业的内核模块开发环境,必须升级构建系统以集成静态分析(如 sparseflawfinder)和代码风格检查,这不仅符合严格的内核规范,还能在编译阶段就自动拦截常见的安全漏洞和代码风格问题,避免低级错误进入生产线。

内核模块具有严格的二进制接口(ABI)限制,模块只能在编译它的特定内核版本和配置上运行,这要求在交叉编译(如为 ARM 板子编译模块)时,必须确保拥有完整且已编译过的目标内核源码树,并正确配置 Makefile 指向架构对应的 KDIR 和工具链,同时妥善解决版本魔数(vermagic)匹配问题。

内核没有传统意义上的动态链接库,实现代码复用主要有两种方式:一种是通过链接多个源文件生成单一的 .ko 文件,这种方式封装性好且不污染全局符号表,是首选方案;另一种是利用 EXPORT_SYMBOL 实现模块堆叠,但必须谨慎处理模块间的加载顺序、符号冲突以及 GPL 许可证兼容性。

利用模块参数机制可以在加载时或运行时动态配置模块行为,通过 module_param 宏将变量暴露给用户空间,配合 /sys/module 接口实现调试开关或硬件参数的热更新,避免了为了微调配置而重新编译内核模块的麻烦。

内核空间默认禁止浮点运算以避免上下文切换带来的巨大性能损耗,这要求开发者必须具备“整数思维”,将涉及浮点计算或格式化输出(如 %f)的逻辑移至用户空间处理,除非在极端必要的情况下使用 kernel_fpu_begin()kernel_fpu_end() 进行显式隔离。