本章是我们关于可加载内核模块(LKM)框架及如何使用它编写内核模块的内容的下半部分。为了从中获得最大的收益,我建议您在阅读本章之前先完成前一章,并尝试其中的代码和问题/练习。
在本章中,我们将从上一章结束的地方继续。我们将讨论如何使用“更好的”Makefile来构建LKM,如何为ARM平台交叉编译内核模块(作为一个典型例子),什么是模块堆叠以及如何实现它,如何设置和使用模块参数。在这个过程中,您将了解内核API/ABI的稳定性(或者说,其缺乏稳定性!),编写用户空间代码和内核代码之间的关键差异,如何在系统启动时自动加载内核模块,以及安全问题及其解决方法。最后,我们将介绍内核文档(包括编码风格)以及如何开始为主线内核做贡献。
简而言之,本章将覆盖以下主题:
- 用于内核模块的“更好”Makefile模板
- 内核模块的交叉编译
- 收集最小系统信息
- 内核模块的许可协议
- 模拟“类库”功能的内核模块
- 向内核模块传递参数
- 内核中不允许使用浮点数
- 在系统启动时自动加载模块
- 内核模块与安全性概述
- 内核开发者的编码风格指南
- 为主线内核做贡献
技术要求
本章的技术要求——所需的软件包——与第4章《编写你的第一个内核模块 - 第一部分》中“技术要求”部分所展示的内容相同;请参考该部分。和往常一样,您可以在本书的GitHub仓库中找到本章的源代码。可以使用以下命令克隆仓库:
git clone https://github.com/PacktPublishing/Linux-Kernel-Programming_2E
书中展示的代码通常只是相关的代码片段。请参考仓库中的完整源代码。
内核模块的“更好”Makefile模板
上一章介绍了用于从源代码生成内核模块、安装并清理模块的Makefile。然而,正如我们在之前简要提到的那样,我现在将介绍一个我认为更优的“更好”Makefile,并解释为什么它更好。
归根结底,我们都必须编写更好、更安全的代码——无论是用户空间代码还是内核空间代码。好消息是,有多种工具可以帮助提高代码的健壮性和安全性,静态和动态分析器就是其中之一(正如在《在线章节:内核工作区设置》中提到的一些工具,我在这里不再重复)。
我设计了一个简单但有用的Makefile“模板”,适用于内核模块,其中包含几个目标,帮助您运行这些工具。这些目标使您能够非常轻松地进行有价值的检查和分析;否则,您可能会忘记、忽略或者永远推迟这些检查!这些目标包括:
- “常见”目标——构建(all)、安装和清理目标(考虑到“debug”设置,在关闭调试的情况下剥离模块)。
- 内核编码风格生成和检查(通过indent和内核的checkpatch.pl脚本)。
- 内核静态分析目标(sparse、gcc和flawfinder),并提到Coccinelle。
- 一些虚拟的内核动态分析目标,指出您应该花时间配置并构建一个“调试”内核,使用它来捕捉错误(通过KASAN和LOCKDEP / CONFIG_PROVE_LOCKING;稍后会详细介绍)。
- 一个简单的tarxz-pkg目标,它将源文件打包并压缩到父目录中。这使您能够将压缩的tar-xz文件传输到任何其他Linux系统,然后提取并在该系统上构建LKM。
您可以在ch5/lkm_template目录中找到代码(以及一个README文件)。为了帮助您理解其用途和功能并开始使用,以下图示简单地展示了当您执行make <tab><tab>并运行make help时,代码输出的内容和截图:
lkm_template $ make <tab><tab>
all clean help install sa sa_flawfinder sa_sparse
checkpatch code-style indent nsdeps sa_cppcheck sa_gcc tarxz-pkg
正如我们在图5.1中看到的那样,我们输入make,紧接着按下Tab键两次,这样就可以显示所有可用的目标(您可能需要安装Bash自动补全包,才能使类似的功能生效;大多数发行版通常会预装它)。请仔细研究并使用它!例如,运行make sa(请参见图5.1中的sa目标及其他目标)将会执行所有静态分析(sa)目标,分析您的代码。
注意以“FYI: KDIR=...”开头的行(已突出显示)展示了Makefile当前理解的各种变量和“设置”。我们在这里复现了这一行内容:
FYI: KDIR=/lib/modules/6.1.25-lkp-kernel/build ARCH= CROSS_COMPILE= ccflags-y="-UDEBUG -DDYNAMIC_DEBUG_MODULE" MYDEBUG=n DBG_STRIP=n
在Makefile中,我们使用了一个名为MYDEBUG的变量来确定是否执行‘调试’构建。显然,由于MYDEBUG变量默认设置为n,我们并没有执行所谓的调试构建(下一部分将深入探讨这具体意味着什么)。
另外,我们的变量DBG_STRIP=n本质上告诉Make:不要剥离模块对象的调试“符号”。实际上,它默认是设置为y的;我们的Makefile具有智能,只有在MYDEBUG为n(关闭)且内核配置没有使用模块签名功能(该功能需要符号;我们将在本章稍后讨论这一点)时,才会剥离调试符号(绝不应剥离更多模块内容!)。KDIR变量是指向(有限的)内核源代码树的路径,实际上是内核头文件所在的路径,通常是这个值;ARCH和CROSS_COMPILE变量默认是空的,因为我们并没有交叉编译该模块。
好了,让我们使用这个“更好”的Makefile来简单地构建我们的“模板”模块,然后插入它,移除它,并查看内核的printk输出是否显示(图5.2中的截图展示了输出):
需要注意的是,要充分利用这个“更好”的Makefile,您需要在系统上安装一些软件包/应用程序;这些包括(对于基础的Ubuntu系统)indent(1)、linux-headers-$(uname -r)、sparse(1)、flawfinder(1)、cppcheck(1)和tar(1)。(《在线章节:内核工作区设置》已明确指出这些应该被安装。此外,运行我们的ch1/pkg_install4ubuntu_lkp.sh脚本(在Ubuntu上)将确保它们被安装。)
另外,请注意,Makefile中提到的所谓动态分析(da)目标只是虚拟的目标,它们除了打印一条消息外不会执行任何操作。它们的存在是为了提醒您通过在配置合适的“调试”内核上运行代码,来彻底测试您的代码!
说到“调试”内核,下一部分将向您展示如何配置一个调试内核的基本步骤。
配置“调试”内核
有关配置和构建内核的详细信息,请参阅第2章《从源代码构建6.x Linux内核 - 第一部分》和第3章《从源代码构建6.x Linux内核 - 第二部分》。
在调试内核上运行代码可以帮助您发现一些难以察觉的错误和问题。我强烈建议在开发和测试过程中这样做!
实际上,您应该有两个内核来运行和测试您的工作:一个是精心配置用于优化的常规生产内核,另一个是调试内核,故意配置使得许多内核调试选项被启用(可能未优化,但目的是用于捕捉错误)。
在这里,我至少期望您将自定义的6.1内核配置为开启以下内核调试配置选项(即设置为y;您将在make menuconfig的UI中找到它们,许多选项可以在Kernel Hacking子菜单下找到;以下列表是针对Linux 6.1.25版本的):
-
CONFIG_DEBUG_KERNEL和CONFIG_DEBUG_INFO -
CONFIG_DEBUG_MISC -
通用内核调试工具:
CONFIG_MAGIC_SYSRQ(魔术SysRq热键功能)CONFIG_DEBUG_FS(debugfs伪文件系统)CONFIG_KGDB(内核GDB;可选,推荐使用)CONFIG_UBSAN(未定义行为检查器)CONFIG_KCSAN(动态数据竞争检测器)
-
内存调试:
CONFIG_SLUB_DEBUGCONFIG_DEBUG_MEMORY_INITCONFIG_KASAN:强大的内核地址消毒器(KASAN)内存检查器CONFIG_DEBUG_SHIRQCONFIG_SCHED_STACK_END_CHECKCONFIG_DEBUG_PREEMPT
-
锁调试:
CONFIG_PROVE_LOCKING:非常强大的lockdep功能,用于捕捉锁问题!它还会启用几个其他锁调试配置,详见第13章《内核同步 - 第二部分》。CONFIG_LOCK_STATCONFIG_DEBUG_ATOMIC_SLEEPCONFIG_BUG_ON_DATA_CORRUPTIONCONFIG_STACKTRACECONFIG_DEBUG_BUGVERBOSECONFIG_FTRACE(ftrace:在其子菜单中,至少启用几个“追踪器”,包括‘内核函数[图形]追踪器’)CONFIG_BUG_ON_DATA_CORRUPTION
-
特定架构(在x86中显示为“x86 Debugging”):
CONFIG_EARLY_PRINTK(架构特定)CONFIG_DEBUG_BOOT_PARAMSCONFIG_UNWINDER_FRAME_POINTER(选择FRAME_POINTER和CONFIG_STACK_VALIDATION)
几点说明: a) 如果您现在不明白所有前面提到的内核调试配置选项的作用,也不用太担心;等您读完本书后,很多选项会变得清晰。 b) 启用一些Ftrace追踪器(或插件),例如CONFIG_IRQSOFF_TRACER,会很有用,因为我们在本书的《Linux内核编程 - 第二部分》伴随卷的《处理硬件中断》章节中会实际使用它们(注意,尽管Ftrace本身可能默认启用,但并不是所有追踪器都会启用)。 c) 有关调优和构建调试内核的详细信息,请参阅《Linux内核调试》一书。
请注意,启用这些配置选项确实会导致性能下降,但这没关系。我们运行这种“调试”内核的目的是为了捕捉错误和问题(特别是那些难以发现的错误!)。它确实可能是救命稻草!在您的项目中,您的工作流程应包括在以下两个系统上测试和运行代码:
- 调试内核系统,其中启用了所有必要的内核调试配置选项(如前所示,至少启用)
- 生产内核系统(其中大多数前面提到的内核调试选项可能会被关闭;即使在生产环境中启用一些所谓的“调试”内核配置也能非常有帮助)。
不用说,在本书接下来的所有LKM代码中,我们将使用前面提到的“更好”的Makefile。
好了,现在您已经准备好了,我们开始进入一个有趣且实际的场景——为另一个目标(通常是ARM)编译内核模块。
为内核模块进行交叉编译
在第3章《从源代码构建6.x Linux内核 - 第二部分》中,我们在“树莓派内核构建”部分展示了如何为“外部”目标架构(如ARM[64]、PowerPC、MIPS等)交叉编译Linux内核。实际上,内核模块也可以进行类似的交叉编译;只需设置交叉工具链以及适当的“特殊”ARCH和CROSS_COMPILE环境变量,您就可以轻松地交叉编译内核模块。
例如,假设我们正在开发一个嵌入式Linux产品;我们代码运行的目标设备具有AArch64(ARM-64)CPU。让我们以一个实际的例子为例:将我们的ch5/lkm_template内核模块交叉编译到树莓派4单板计算机(SBC)上!
这很有趣。您会发现,尽管看起来简单明了,我们最终将进行四次迭代才会成功。为什么?请继续阅读以找出答案。
顺便提一下,我已经总结了您将在接下来的几个部分中学习到的内容,这些内容涉及交叉构建和加载内核模块时遇到的问题,以及如何解决这些问题。(我建议您至少在第一次阅读时,先阅读完整的说明,以理解详细信息,然后再查看总结。)
首先,我们确实需要一个合适的交叉工具链设置,所以现在让我们来了解一下如何设置它。
为交叉编译设置系统
交叉编译内核模块的前提条件是非常明确的:
- 目标系统的内核源代码树:我们需要将目标系统的内核源代码树安装为主机系统工作区的一部分,通常是一个x86_64桌面(在我们的例子中,目标是树莓派;请参考官方树莓派文档:www.raspberrypi.org/documentati…)。
- 交叉工具链(主机到目标) :通常,主机系统是x86_64,而目标是ARM-64,所以我们需要一个x86_64到ARM64的交叉工具链。正如第3章《从源代码构建6.x Linux内核 - 第二部分》中树莓派内核构建部分明确提到的那样,您必须将树莓派特定的x86_64到ARM工具链作为主机系统工作区的一部分进行安装(如果您尚未设置工具链,请参考上文位置学习如何安装它)。
快速提示:如果您还没有安装,请使用以下命令安装aarch64-linux-gnu交叉编译器:
sudo apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
好了,从这个点开始,我将假设您已经具备了第3章《从源代码构建6.x Linux内核 - 第二部分》中我们涵盖的所有前提条件,在“树莓派内核构建”部分,您已经安装了树莓派6.1.34内核源代码树和x86_64到ARM64的交叉工具链。因此,我也假设工具链前缀是aarch64-linux-gnu;我们可以通过尝试调用gcc交叉编译器快速检查工具链是否已安装,并且其二进制文件已添加到路径中:
$ aarch64-linux-gnu-gcc
aarch64-linux-gnu-gcc: fatal error: no input files
compilation terminated.
它工作正常——只是我们没有传递任何C程序作为编译参数,因此它报告错误。
您当然也可以查找编译器版本,使用命令aarch64-linux-gnu-gcc --version(更多选项请使用-v开关)。顺便提一下,GCC的现代替代品是Clang工具链;它如今被广泛使用(包括Android AOSP),在某些方面甚至优于GCC(有关使用Clang进行交叉编译的更多信息,请参阅《进一步阅读》部分)。在这里,我们继续使用GCC,无论是本地编译还是交叉编译。
好了,让我们现在开始为树莓派设备交叉编译我们的模块吧!
尝试 1 – 设置ARCH和CROSS_COMPILE环境变量
交叉编译内核模块非常简单(至少我们以为是这样!)。首先,确保您正确设置了“特殊”环境变量ARCH和CROSS_COMPILE。请按照以下步骤操作:
让我们为树莓派目标重新构建我们刚刚讨论过的lkm_template内核模块;这样做的一个优点是可以使用前面提到的“更好”Makefile。下面是构建步骤:
为了避免破坏原始代码,首先在开始时创建一个名为cross的新文件夹,并将代码复制到其中。(顺便提一下,代码库中已经设置好了,路径是:ch5/cross。)
cd <book-dir>/ch5; mkdir cross ; cd cross
cp ../lkm_template/lkm_template.c ../lkm_template/Makefile .
这里,<book-dir>是本书GitHub源代码树的根目录。
现在,运行以下命令(假设交叉编译工具已经添加到路径中):
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
(顺便提一下,书中的代码库有一个小的包装脚本ch5/cross/buildit,它执行一些有效性检查并为您运行此命令。)但它不会立刻工作(或者它可能工作;请参见以下内容)。我们遇到了编译失败,如下所示:
$ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
--- Building : KDIR=~/arm64_prj/kernel/linux ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- ccflags-y="-UDEBUG -DDYNAMIC_DEBUG_MODULE" MYDEBUG=n ---
aarch64-linux-gnu-gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
make -C ~/arm64_prj/kernel/linux M=/home/c2kp/Linux-Kernel-Programming_2E/ch5/cross modules
make[1]: *** /home/c2kp/arm64_prj/kernel/linux: No such file or directory. Stop.
make: *** [Makefile:93: all] Error 2
$
(注意,这次ARCH和CROSS_COMPILE环境变量已经正确设置。)为什么会失败?
前面交叉编译尝试失败的线索在于它试图使用当前主机系统的内核源代码树进行构建,而不是目标的内核源代码树。因此,我们需要修改Makefile,将其指向正确的目标内核源代码树。这样做很容易。在下面的代码中,我们看到修正后的Makefile代码写法:
# ch5/cross/Makefile:
# To support cross-compiling for kernel modules:
# For architecture (cpu) 'arch', invoke make as:
# make ARCH=<arch> CROSS_COMPILE=<cross-compiler-prefix>
[ ... ]
else ifeq ($(ARCH),arm64)
# *UPDATE* 'KDIR' below to point to the ARM64 (Aarch64) Linux kernel source
# tree on your box
#KDIR ?= ~/arm64_prj/kernel/linux
KDIR ?= ~/rpi_work/kernel_rpi/linux
else ifeq ($(ARCH),powerpc)
[ ... ]
else
[ … ]
endif
[ ... ]
# IMPORTANT :: Set FNAME_C to the kernel module name source filename (without .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} ccflags-y="${ccflags-y}" MYDEBUG=${MYDEBUG} DBG_STRIP=${DBG_STRIP} ---'
@echo
make -C $(KDIR) M=$(PWD) modules
[...]
仔细查看这个(新的“更好”的Makefile,正如前面所解释的)Makefile,您会看到它是如何工作的:
最重要的是,我们根据ARCH环境变量的值,条件性地设置KDIR变量,指向正确的内核源代码树(当然,我用了ARM-32和PowerPC的示例路径;请用实际路径替换内核源代码树的路径)。
如常,我们将obj-m设置为目标文件名。
您可以设置变量ccflags-y(CFLAGS_EXTRA被认为已弃用),以添加DEBUG符号(以便在LKM中定义DEBUG符号,使得pr_debug()/dev_dbg()宏有效)。默认情况下,“调试”构建是关闭的。
@echo '<...>'行相当于Shell的echo命令;它只是在构建过程中输出一些有用的信息(@前缀隐藏了echo语句本身的显示)。
最后,我们有“常规”Makefile目标:all、install和clean。请注意,在这些目标中,调用make时,我们确保通过-C开关将目录切换到KDIR的值!
尽管在前面的代码中没有显示,这个“更好”的Makefile——冒着重复的风险——有几个额外的有用目标。您绝对应该花时间去探索并使用它们(如前面所解释的;开始时,只需键入make help,查看输出并尝试各种功能)。
完成此操作后,让我们重新尝试使用这个版本的Makefile进行模块交叉编译,看看结果如何。
尝试 2 – 将Makefile指向目标的正确内核源代码树
现在,使用上一节中描述的增强版和修复后的Makefile,应该能够正常工作了。在我们尝试的新的目录——cross(因为我们正在交叉编译,而不是因为我们生气!)中,按照以下步骤操作:
第二次尝试使用适用于交叉编译的make命令进行构建:
$ 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/cross/Module.symvers] Error 1
[ ... ]
哎呀。这次我们遇到了modpost错误。在这个模块构建阶段,MODPOST,构建系统必须能够访问并检查所有导出的内核符号。此信息通常存储在Module.symvers文件中(位于内核源代码树的根目录)。它通常是在模块构建完成后生成的(在内核构建过程中)。当这个文件不存在时,我们会遇到这种modpost失败——就像我们现在这样。为了解决这个问题,我直接清理了我的(树莓派)内核源代码树(使用make mrproper),然后重新构建;此时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- ccflags-y="-UDEBUG -DDYNAMIC_DEBUG_MODULE" MYDEBUG=n ---
aarch64-linux-gnu-gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 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), BuildID[sha1]=03<...>4, not stripped
当然,实际情况是,构建可能由于多种原因失败,而不仅仅是这里提到的这些原因……在另一个实例中,构建失败是因为我们正在编译的目标内核源代码树仍处于“原始”状态。它可能甚至没有.config文件(以及其他必需的头文件)存在于根目录中,而这些文件至少是用来配置内核的。为了解决这个问题,您需要按照常规方式配置并交叉编译内核,然后重新尝试模块构建。
尝试 3 – 交叉编译我们的内核模块
现在,我们已经使用正确配置的树莓派内核源代码树(在主机系统上,且存在Module.symvers文件)生成了交叉编译的内核模块,应该能够在开发板上工作(嘿,我们是乐观主义者)。
当然,验证的标准在于实际运行。所以,我们启动树莓派,通过scp将交叉编译的内核模块对象文件传输到树莓派,然后在树莓派的ssh会话中尝试运行,结果如下(以下输出直接来自设备):
$ sudo insmod ./lkm_template.ko
insmod: ERROR: could not insert module ./lkm_template.ko: Invalid module format
显然,insmod失败了!理解原因很重要。问题出在我们尝试加载模块的内核版本与该模块编译时所用的内核版本不匹配。
登录到树莓派后,让我们打印出当前正在运行的树莓派内核版本,并使用modinfo工具打印出有关内核模块本身的详细信息:
rpi $ cat /proc/version
Linux version 6.1.21-v8+ (dom@buildbot) (aarch64-linux-gnu-gcc-8 (Ubuntu/Linaro 8.4.0-3ubuntu1) 8.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1642 SMP PREEMPT Mon Apr 3 17:24:16 BST 2023
rpi $ modinfo ./lkm_template.ko
filename: /home/pi/lkp2e/ch5/cross/./lkm_template.ko
version: 0.2
license: Dual MIT/GPL
description: a simple LKM template; do refer to the (better) Makefile as well
author: Kaiwan N Billimoria
srcversion: 606276CA0788B10170FC6D5
depends:
name: lkm_template
vermagic: 6.1.34-v8+ SMP preempt mod_unload modversions aarch64
rpi $
从上述输出中可以明显看到,在这台ARM64的树莓派板上,我们正在运行的是6.1.21-v8+内核。事实上,这是我在设备的microSD卡上安装树莓派操作系统时继承的内核(这是一个故意设置的情景,最初没有使用我们之前为树莓派构建的6.1内核)。然而,内核模块显示它是针对6.1.34-v8+ Linux内核编译的(modinfo中的vermagic字符串揭示了这一点)。显然,这是一个不匹配。那么,这意味着什么呢?继续阅读,下一节将揭示答案。
检查Linux内核ABI兼容性问题
Linux内核有一个规则,作为内核应用二进制接口(ABI)的一部分:只有当内核模块是针对当前运行的内核版本构建时,才能将该内核模块插入到内核内存中——内核版本、构建标志,甚至内核配置选项都很重要!
构建所用的内核是您在Makefile中指定的内核源代码位置(我们之前通过KDIR变量进行了指定)。
换句话说,内核模块与其构建所用的内核之外的内核是二进制不兼容的。例如,如果我们在一个x86_64的Ubuntu 22.04 LTS系统上构建了一个内核模块,那么它只会在运行这个精确环境(硬件、库和内核)的系统上工作!它无法在Fedora 38、RHEL 8.x、树莓派等系统上运行,甚至无法在运行不同内核的x86_64 Ubuntu 22.04系统上工作。
现在,重要的是——再次思考这一点——这并不意味着内核模块完全不兼容且不可移植。不,它们在不同架构间是源代码兼容的(至少应该是这样写的)。因此,假设您有源代码,您总是可以在给定的系统上重新构建内核模块,然后它将在该系统上工作。只是二进制镜像(.ko文件)与除其构建目标内核之外的内核不兼容。(在某些版本的内核中,内核日志会明确指出这一点)。所以,现在很清楚了;在这里,存在一个不匹配——二进制模块与它构建时所用的内核和我们尝试运行(加载)它的内核之间——因此,内核模块无法插入。
虽然在这里我们没有使用这种方法,但确实有一种方法可以确保第三方内核模块(如果它们的源代码可用)在构建和部署时成功,方法是通过名为DKMS(动态内核模块支持)的框架。以下是它的直接引用:
动态内核模块支持(DKMS)是一个程序/框架,它能够生成通常位于内核源代码树之外的Linux内核模块源代码。其概念是让DKMS模块在安装新内核时自动重新构建。
请注意:“...让DKMS模块在安装新内核时自动重新构建”这一关键短语,证明了这一点:内核模块必须精确地为它们将要部署的内核构建。例如,Oracle的VirtualBox虚拟机管理程序(在Linux主机上运行时)使用DKMS来自动构建并保持其内核模块的最新版本。
顺便说一下,如果您没有树莓派来尝试这些实验怎么办?别担心,您可以使用其他ARM架构的板子,甚至使用虚拟化!(顺便说一下,第1章提到的SEALS项目允许通过QEMU轻松虚拟化ARM-32、ARM-64和x86_64 PC)。
尝试 4 – 交叉编译我们的内核模块
现在,我们已经理解了二进制兼容性问题,接下来有几种可能的解决方案:
- 我们必须配置、(交叉)构建并启动设备,使用产品所需的自定义内核,并基于该内核源代码树构建所有内核模块。
- 使用DKMS方法。
- 或者,我们可以重新构建内核模块,使其与当前树莓派设备运行的内核匹配。
在典型的嵌入式Linux项目中,几乎肯定会有一个为目标设备定制的内核配置,您必须使用该内核。该产品的所有内核模块必须/将会基于该内核进行构建。因此,我们采取第一种方法——我们必须使用自定义配置和构建(6.1.34)内核启动设备,由于我们的内核模块是基于该内核构建的,它现在应该能够正常工作。
我们在第3章《从源代码构建6.x Linux内核 - 第二部分》中的“树莓派内核构建”部分已经介绍了树莓派的内核构建。如果需要,您可以回去查看详细内容。
好吧,我将假设您已经按照步骤(在第3章《从源代码构建6.x Linux内核 - 第二部分》中的“树莓派内核构建”部分中介绍)配置并构建了树莓派的6.x内核。关于如何将我们的自定义内核映像、DTB和模块文件复制到设备的microSD卡等详细内容不再赘述;我建议您参考官方的树莓派文档:树莓派官方内核构建文档。
尽管如此,我们将指出一种在设备上切换内核的便捷方法(在这里,我假设设备是运行64位内核的树莓派4B):
- 将您自定义构建的内核二进制文件
Image复制到设备microSD卡的/boot分区,并命名为kernel8.img。(为安全起见,首先确保将原始(默认)树莓派内核映像保存为kernel8.img.orig)。 - 将刚刚交叉编译的内核模块(ARM64的
lkm_template.ko,在上一节中构建)从主机系统复制(scp)到microSD卡(可以放到/home/pi目录)。
[可选] 在树莓派板上,您可以通过以下方式指定启动的内核;在我们的案例中不需要这样做:引导加载程序将默认选择文件kernel8.img作为启动内核。如果您希望指定一个不同的内核进行启动,可以在设备的microSD卡上编辑/boot/config.txt文件,设置kernel=xxx行来指定启动的内核映像。
保存并重启后,我们登录到设备并重新尝试我们的内核模块。图5.3是展示刚刚交叉编译的lkm_template.ko内核模块在树莓派4B设备上使用的截图:
啊,成功了!请注意,这次设备上运行的当前内核版本(6.1.34-v8+)与模块构建时所用的内核版本完全匹配——在modinfo输出的vermagic行中,我们可以看到它是6.1.34-v8+。呼,终于完成了。
如果您看到rmmod抛出非致命错误(尽管清理钩子仍然被调用),原因是您尚未在设备上完全设置新的内核。您需要将所有内核模块(位于/lib/modules/<kernel-ver>/下)复制到设备,并在那里运行depmod(8)工具(以root权限)。这里我们不会进一步深入这些细节——如前所述,树莓派的官方文档已经涵盖了所有这些步骤。
当然,树莓派板是一个相当强大的系统;您可以安装(默认的)树莓派操作系统(基于Debian),并安装开发工具和内核头文件,从而在板上编译内核模块!(不需要交叉编译。)不过,在这里我们采用了交叉编译的方法,因为这是典型的做法,也是嵌入式Linux项目中常常需要使用的方式。
以下是我们刚刚学习的关于交叉编译内核模块的总结。
总结交叉编译和加载内核模块时出现的问题及解决方法
让我们总结一下我们在这一有趣的交叉编译内核模块的部分中学到的内容:
| 尝试编号 | 失败原因 | 解决方法 |
|---|---|---|
| 1 | 模块构建失败,因为我们试图将目标内核模块构建到当前主机(x86_64)系统的内核源代码树中(或者指定了无效的路径),而不是构建到目标的内核源代码树。 | 修改模块Makefile,将KDIR变量指向正确的内核源代码树,即目标系统的内核源代码树: KDIR ?= ~/rpi_work/kernel_rpi/linux |
| 2 | 模块构建失败,在MODPOST阶段(由于目标内核源代码树中缺少Module.symvers文件)。 | 表明目标内核源代码树尚未配置和/或构建;因此需要进行配置和构建,之后此文件会出现,模块可以成功构建。 |
| 3 | 交叉编译的模块无法在设备(树莓派)上加载,内核报错“Invalid module format”。 | 这是由于内核ABI规则导致的:内核只会将内核模块插入到内核内存中,如果该内核模块是针对当前内核构建的。因此,模块在系统之间没有二进制兼容性,但可以编写为源代码可移植。(尝试#4解决了这个问题。) |
| 4 | <同上,尝试#3> | 解决方法:我们使用自定义的6.1内核启动设备,并将模块交叉编译为与该内核相同的内核(通过Makefile中的KDIR变量引用该内核),然后将二进制模块复制到目标的根文件系统,再使用insmod加载。成功! |
表5.1:从x86_64主机到AArch64(树莓派4)目标的交叉编译和运行内核模块的总结
完成。
LKM框架是一个相当庞大的工作,还有很多内容需要探索。让我们继续前进。在下一节中,我们将研究如何在内核模块中获取一些最小的系统信息。
收集最小的系统信息
有时,特别是当编写一个需要在不同架构(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 CONFIG_MIPS
strncat(msg, "MIPS, ", 7);
#endif
#ifdef CONFIG_PPC
strncat(msg, "PowerPC, ", 10);
#endif
#ifdef CONFIG_S390
strncat(msg, "IBM S390, ", 11);
#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();
/* 字长范围:最小值和最大值:定义在 include/linux/limits.h 中 */
[ ... ]
}
EXPORT_SYMBOL(llkd_sysinfo);
该LKM展示了其他细节——例如各种基本数据类型的大小和字长范围——这些内容在此没有展示;请参考我们的GitHub仓库中的源代码并亲自尝试。
前面的内核模块代码很有教学意义,它展示了如何编写可移植的代码。请记住,内核模块本身是一个二进制的不可移植目标文件,但它的源代码可以(也许应该,根据您的项目需求)以一种方式编写,使其能够在不同的架构之间移植。然后,在目标架构上简单构建后,它就可以准备好进行部署。
现在,请忽略这里使用的EXPORT_SYMBOL()宏。我们将在稍后讨论它的使用。另外,my_snprintf_lkp()是对snprintf()的一个简单包装,提供更好的安全性,目前仅在当前源文件(min_sysinfo.c)中定义(请查看)。顺便提一下,一旦我们进一步探讨模拟“类库”功能时,我们将在其他地方定义snprintf_lkp()包装器;现在不用担心这个问题。下一节将深入探讨安全性方面的内容,这也是本书中几个地方讨论的主题。
在我们熟悉的x86_64 Ubuntu 22.04 LTS系统上构建并运行该模块时,我们获得了以下输出(从sudo dmesg命令开始):
我们刚才看到的llkd_sysinfo()函数的输出在图5.4中被矩形框住。接着是llkd_sysinfo2()函数的输出(当然是相同的;它只是更注重安全性,下一节将讨论它)。接下来,它使用sizeof()运算符来显示各种数据类型的字节大小;最后,我们的模块显示了该架构的字长范围(包括所有无符号和有符号的8、16、32和64位字)。
太好了!类似地(如前所示),我们可以为AArch64目标交叉编译这个内核模块,然后将交叉编译的内核模块传输(scp)到目标设备并运行它(以下输出来自运行我们自定义64位6.1.34-v8+内核的树莓派4B):
$ sudo insmod ./min_sysinfo.ko
min_sysinfo:min_sysinfo_init(): inserted
min_sysinfo:llkd_sysinfo(): llkd_sysinfo(): minimal Platform Info:
CPU: AArch64 (ARM-64), little-endian; 64-bit OS.
min_sysinfo:llkd_sysinfo2(): llkd_sysinfo2(): minimal Platform Info:
CPU: AArch64 (ARM-64), little-endian; 64-bit OS.
min_sysinfo:show_sizeof(): sizeof: (bytes)
char = 1 short int = 2 int = 4
long = 8 long long = 8 void * = 8
float = 4 double = 8 long double = 16
min_sysinfo:llkd_sysinfo2(): Word [U|S][8|16|32|64] ranges: unsigned max, signed max, signed min:
U8_MAX = 255 = 0x ff, S8_MAX = 127 = 0x 7f, S8_MIN = -128 = 0x ffffff80
U16_MAX = 65535 = 0x ffff, S16_MAX = 32767 = 0x 7fff, S16_MIN = -32768 = 0x ffff8000
U32_MAX = 4294967295 = 0x ffffffff, S32_MAX = 2147483647 = 0x 7fffffff, S32_MIN = -2147483648 = 0x 80000000
U64_MAX = 18446744073709551615 = 0xffffffffffffffff, S64_MAX = 9223372036854775807 = 0x7fffffffffffffff, S64_MIN = -9223372036854775808 = 0x8000000000000000
PHYS_ADDR_MAX = 18446744073709551615 = 0xffffffffffffffff
我们再次可以看到这次与AArch64平台相关的详细信息!
顺便提一下,强大的Yocto项目(www.yoctoproject.org/)是生成树莓派完整嵌入式Linux BSP的一种(行业标准)方法。或者(且更容易快速尝试),Ubuntu为设备提供了自定义的64位Ubuntu内核和根文件系统(wiki.ubuntu.com/ARM/Raspber…)。
我强烈建议您通过代码并亲自尝试这个模块。
增强安全性意识
如今,安全性显然是一个重要的关注点。专业开发人员被期望编写安全的代码。近年来,针对Linux内核的许多已知漏洞被曝光(有关更多信息,请参阅《进一步阅读》部分)。与此同时,许多提升Linux内核安全性的努力也在进行中。
在我们之前的内核模块(ch5/min_sysinfo/min_sysinfo.c)中,注意不要使用过时的函数(如 sprintf、 strlen等;是的,它们在内核中存在!)。静态分析工具可以大大帮助发现潜在的安全相关问题以及其他bugs;我们强烈推荐您使用它们。《在线章节:内核工作区设置》提到了一些有用的静态分析工具。在以下输出中,我们使用了“更好”Makefile中的一个sa目标,运行了一个相对简单的静态分析器:flawfinder(由David Wheeler编写),分析ch5/min_sysinfo/文件夹中的源代码:
$ make [tab][tab]
all clean help install sa sa_flawfinder sa_sparse
checkpatch code-style indent nsdeps sa_cppcheck sa_gcc tarxz-pkg
$ make sa_flawfinder
make clean
make[1]: Entering directory '<...>/Linux-Kernel-Programming_2E/ch5/min_sysinfo'
--- cleaning ---
[...]
--- static analysis with flawfinder ---
flawfinder *.[ch]
Flawfinder version 2.0.19, (C) 2001-2019 David A. Wheeler.
Number of rules (primarily dangerous function names) in C/C++ ruleset: 222
Examining min_sysinfo.c
FINAL RESULTS:
min_sysinfo.c:54: [2] (buffer) char:
Statically-sized arrays can be improperly restricted, leading to potential
overflows or other issues (CWE-119!/CWE-120). Perform bounds checking, 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 maximum
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.
min_sysinfo.c:138: [1] (buffer) strncat:
Easily used incorrectly (e.g., incorrectly computing the correct maximum
[ ... ]
仔细查看上述flawfinder对strncat()函数发出的警告(在它生成的许多警告中!)。根据它的建议,我们使用strlcat()替换了strncat(),并将llkd_sysinfo()函数的代码重写为llkd_sysinfo2()(但为什么要这样做?这是为了提高安全性和更好地保护;请查看其手册页:linux.die.net/man/3/strlc…)。同样,实际上要充分利用snprintf() API,需要检查其返回值以查看是否发生了(可能的)缓冲区溢出(这是许多漏洞的根本原因!);因此(如前所述),我写了一个简单的包装器my_snprintf_lkp()。
flawfinder的输出经常提到CWE编号(这里是CWE-119/120),表示此处看到的安全问题的通用类别(可以用Google查找,您将看到更多细节)。在此实例中,CWE-120表示“没有检查输入大小的缓冲区复制(‘经典缓冲区溢出’)”:cwe.mitre.org/data/defini…。正如您所意识到的,缓冲区溢出缺陷是最常见的漏洞之一,经常成为黑客攻击的目标(是的,甚至是内核!)。
我们还添加了几行代码来显示平台上无符号和有符号变量的字长范围(最小值和最大值,以十进制和十六进制显示)。我们留给您阅读这些内容。作为一个简单的作业,您可以在您的Linux机器上运行这个内核模块并验证输出。
当然,Linux内核安全性和强化技术还有很多内容值得探讨。我曾在这里谈到过这些问题及可能的内核强化措施:《通过强化措施抵御黑客攻击 - Linux开发者概述》,由Kaiwan Billimoria(2023年6月,嵌入式物联网峰会,布拉格):www.youtube.com/watch?v=KQa…。
接下来的《内核模块与安全性 - 概述》部分将涵盖这个话题。此外,确保查看本章《进一步阅读》部分(在“Linux内核安全性”标题下)的众多资源。
现在,让我们继续讨论Linux内核和内核模块代码的许可问题。
内核模块的许可
众所周知,Linux内核代码本身是根据GNU GPL v2(即GPL-2.0)许可证发布的(GPL代表通用公共许可证),并且对于大多数人来说,它将保持这种方式。如前所述,在第4章《编写你的第一个内核模块 - 第一部分》中提到,给内核代码授权是必要的且重要的。我们将这个简短的许可讨论分为两个部分:一部分适用于内联内核代码(或主线内核),另一部分适用于编写第三方外部内核模块(这是我们中的许多人会做的)。
内联内核代码的许可
首先,我们从内联内核代码开始。关于许可的关键点是:如果你的目的是直接使用Linux内核代码和/或将代码提交到主线内核(我们将在后续部分详细讨论),你必须按照Linux内核发布的相同许可证发布代码:GNU GPL-2.0。相关的细节已被很好的文档化;请参阅官方文档:Linux内核许可规则。
对于新接触许可的人来说,丰富的开源许可证,以及它们的注意事项,确实可能让人感到困惑。请查看这个网站帮助理清思路:选择许可证。此外,Bootlin在其《嵌入式Linux系统开发培训》指南中的开源许可部分也有很好的覆盖:开源许可证与合规性。
为了保持一致性,最近的内核规定:每个源文件的第一行必须是一个SPDX(软件包数据交换)许可证标识符(详情请参见SPDX官网),这是表达代码所用许可证的一种简洁格式。(当然,脚本需要指定解释器在第一行)。因此,内核中大多数C源文件的第一行是关于SPDX许可证的注释:
// SPDX-License-Identifier: GPL-2.0
外部内核模块的许可
对于外部内核模块,情况仍然有些“流动”,可以说还不够明确。但无论如何,为了与内核社区互动并得到帮助(这非常有益),你应该(或期望)将代码发布为GNU GPL-2.0许可证(尽管双重许可当然是可能且可以接受的)。
内核模块的许可方式有两种:
- 通过在源代码的第一行添加SPDX-License-Identifier标签(作为注释)。这严格适用于源代码树中的模块,而不一定适用于外部模块。
- 通过
MODULE_LICENSE()宏。请注意官方内核文档明确指出(内核许可规则):“可加载的内核模块还需要一个MODULE_LICENSE()标签。此标签既不是正确的源代码许可信息(SPDX-License-Identifier)的替代品,也不用于表达或确定模块源代码所提供的确切许可证。”
这个标签的唯一目的是为内核模块加载器和用户空间工具提供足够的信息,以便知道该模块是自由软件还是专有软件。
以下是从include/linux/module.h内核头文件中复制的注释,清楚地展示了哪些许可“标识”是可以接受的(注意双重许可)。显然,内核社区强烈建议将内核模块发布为GPL-2.0(GPL v2)和/或其他类似的许可证,例如BSD/MIT/MPL。如果你打算将代码提交到主线内核,毫无疑问,GPL-2.0是唯一的发布许可证:
// include/linux/module.h
[...]
/*
* The following license idents are currently accepted as indicating free
* software modules
*
* "GPL" [GNU Public License v2 or later]
* "GPL v2" [GNU Public License v2]
* "GPL and additional rights" [GNU Public License v2 rights and more]
* "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]
*
* The following other idents are available
*
* "Proprietary" [Non free products]
[ ... ]
接下来它还指出,使用MODULE_LICENSE()主要是历史遗留下来的尝试,目的是传达更多的信息,但它现在的主要作用是检查并标记专有模块,从而限制它们使用GPL下导出的符号(我们会进一步讨论)。它也有助于社区、最终用户和模块供应商快速检查代码库是否为开源。
顺便说一下,内核源代码树中有一个LICENSES/目录,在其中你可以找到关于相关许可证的详细信息;快速查看这个文件夹可以看到以下子文件夹:
$ ls <...>/linux-6.1.25/LICENSES/
deprecated/ dual/ exceptions/ preferred/
我们留给你去查看这些内容,对于许可问题的讨论就到这里;现实情况是,这个话题非常复杂,需要法律知识。强烈建议你咨询公司内的专业法律人员(律师)以确保为你的产品或服务正确处理法律事务。
顺便提一下,关于GPL许可证的常见问题解答可以在这里找到:GPL FAQ。
有关许可模型、如何不滥用MODULE_LICENSE宏,尤其是关于多重许可/双重许可的信息,可以在本章的《进一步阅读》部分找到相关参考资料。
现在,让我们回到技术话题。下一节将解释如何在内核空间中有效地模拟类库功能。
为内核模块模拟“类库”功能
用户模式编程和内核模式编程之间的一个主要区别是后者完全没有传统意义上的“类库”概念。类库本质上是一个API的集合或归档,方便开发人员实现以下重要目标:不重新发明轮子、软件重用、模块化、可移植性等。但在Linux内核中,类库——按照传统意义的定义——并不存在。尽管如此,内核源代码树中的lib/文件夹包含了类库-like的例程,其中有一些会被编译进内核映像,因此在运行时可供内核/模块开发人员使用。
好消息是,从广义上讲,有两种技术可以实现在内核空间为内核模块提供“类库-like”功能:
- 第一种技术是通过显式地“链接”多个源文件,包括所谓的“类库”代码,构建你的内核模块对象。
- 第二种技术是模块堆叠(module stacking)。
接下来,我们将详细讨论这些技术。一个可能的剧透是,第一种方法通常优于第二种,但这也取决于项目的具体情况。继续阅读以下章节,我们会列出其中的一些优缺点。
通过链接多个源文件来执行类库模拟
到目前为止,我们处理的内核模块都只有一个C源文件。那么,当一个内核模块有多个C源文件时怎么办?这是一个(相当典型的)实际情况。在这种情况下,所有源文件必须编译并链接成一个单一的.ko二进制对象。
举个例子,假设我们正在构建一个名为projx的内核模块项目。它包含三个C源文件:prj1.c、prj2.c和prj3.c。我们希望最终的内核模块名为projx.ko。在Makefile中指定这些关系,如下所示:
obj-m := projx.o
projx-objs := prj1.o prj2.o prj3.o
在上面的代码中,注意projx标签在obj-m指令后以及在下一行的-objs指令前作为前缀的使用。我们前面的Makefile示例会让内核构建系统先将三个单独的C源文件编译成单独的对象(.o)文件,然后将它们链接在一起,形成最终的二进制内核模块对象文件projx.ko,正如我们希望的那样。
实际上,我们正是利用这一机制在本书的源代码树中构建了一个小型的“类库”例程(该“内核类库”的源文件位于本书源代码树的根目录:klib.h和klib.c)。这个“类库”的目的当然是,其他内核模块可以通过链接到这些代码来使用其中的函数!例如,在接下来的第8章《内核内存分配 - 模块作者的第一部分》中,我们的ch8/lowlevel_mem/lowlevel_mem.c内核模块代码将调用我们类库代码中的一个函数,路径为../../klib.c。通过以下方式在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.c内核模块的代码和../../klib.c类库代码。然后,构建系统将它们链接在一起,形成一个单一的二进制内核模块lowlevel_mem_lkm.ko,达成了我们的目标。(为什么不做一个简单的作业:在本章的“问题”部分中进行Assignment 5.1,以更好地理解这种方法?)
接下来,让我们理解一个基础概念——内核模块中函数和变量的作用域。
理解内核模块中函数和变量的作用域
在进一步讨论之前,快速回顾一下基本知识是个不错的主意。当你用C语言编程时,你将理解以下内容:
- 在函数内声明的变量显然是局部的,只在该函数内有效(局部变量在函数返回时“消失”,因此它们被称为“自动变量”)。一个常见的错误是试图在变量作用域之外引用局部或自动变量;这通常被称为“作用域之后使用”(Use After Scope,UAS)或“返回后使用”(Use After Return,UAR)缺陷。
- 以
static修饰符开头的变量和函数的作用域仅限于当前“单元”;实际上是它们声明的文件。这很好,因为它有助于减少命名空间污染。声明为static的函数内数据变量在函数调用之间保持其值。
在2.6版本之前的Linux(即<=2.4.x,已是古老历史),内核模块的静态和全局变量,以及所有函数,都是在整个内核中可见的。回想起来,这显然并不是一个好主意。从2.5版本(及2.6版本以后,现代Linux)开始,所有内核模块的变量(静态和全局数据)和函数默认仅对该内核模块私有,并且在外部不可见。因此,如果两个内核模块lkmA和lkmB都有一个名为maya的全局(或静态)变量,它们是唯一的;不会发生冲突。
要改变作用域,LKM框架提供了EXPORT_SYMBOL()宏。使用它,你可以声明一个数据项或函数为全局作用域——实际上,使它对所有其他内核模块可见。
举个简单的例子。我们有一个名为prj_core的内核模块,它包含一个全局变量和一个函数:
static int my_glob = 5;
static long my_foo(int key)
{ [...] }
尽管my_glob变量和my_foo()函数可以在这个内核模块内部使用,但它们在模块外部是不可见的。这是有意为之。为了让它们在模块外部可见,我们可以导出它们:
int my_glob = 5;
EXPORT_SYMBOL(my_glob);
long my_foo(int key)
{ [...] }
EXPORT_SYMBOL(my_foo);
现在,它们在内核模块外部有了作用域(注意,在前面的代码中,static关键字被故意去掉了)。其他内核模块现在可以“看到”并使用它们。具体来说,这一思想被广泛应用于两种方式:
- 内核通过
EXPORT_SYMBOL*()宏导出了一个精心挑选的全局变量和函数子集,构成了内核和其他子系统的核心功能。这些全局变量和函数现在可以从内核模块中使用!我们将很快看到一些示例。 - 内核模块作者(通常是设备驱动程序)使用这一思想导出某些数据和/或功能,以便其他内核模块可以在更高的抽象级别利用这一设计,并使用这些数据和/或功能——这一概念被称为模块堆叠,我们将很快通过一个示例深入探讨它。
通过第一种用例,例如,设备驱动程序的作者可能希望处理(拦截)来自外部设备的硬件中断。常见的做法是通过request_threaded_irq() API:
// kernel/irq/manage.c
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
irq_handler_t thread_fn, unsigned long irqflags,
const char *devname, void *dev_id)
{
struct irqaction *action;
[...]
return retval;
}
EXPORT_SYMBOL(request_threaded_irq);
正因为request_threaded_irq()函数被导出,才可以从设备驱动程序中调用它,而设备驱动程序通常作为(往往是外部的)内核模块编写。
类似地,模块开发人员通常需要一些“便利”例程——例如,字符串处理函数。Linux内核在lib/string.c中提供了几个常用字符串处理函数的实现(你期望它们会存在):str[n]casecmp()、str[n|l|s]cpy()、str[n|l]cat()、str[n]cmp()、strchr[nul]()、str[n|r]chr()、str[n]len()等。当然,这些函数都是通过EXPORT_SYMBOL()宏导出的,因此它们可以被模块作者使用。
我们这里用str[n|l|s]cpy()表示内核提供了四个函数:strcpy()、strncpy()、strlcpy()和strscpy()。注意,一些接口可能已被弃用(strcpy()、strncpy()和strlcpy())。通常,始终避免使用被弃用的内容,详细信息请参见这里:已弃用接口、语言特性、属性和约定。
另一方面,让我们快速浏览内核核心中的一个(微小的)CFS(完全公平调度器)CPU调度代码。这里,pick_next_task_fair()函数是在调度代码中调用的,用于查找下一个要进行上下文切换的任务:
// kernel/sched/fair.c
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct cfs_rq *cfs_rq = &rq->cfs;
[...]
update_idle_rq_clock_pelt(rq);
return NULL;
}
我们在这里不想深入研究调度(第10章《CPU调度 - 第一部分》和第11章《CPU调度 - 第二部分》将会涉及!);这里的关键点是:由于前面的函数没有使用EXPORT_SYMBOL*()宏标记,它无法被任何内核模块调用。它仍然是内核核心私有的;这当然是有意的设计决策。
你也可以使用相同的宏将数据结构标记为导出。同样,显而易见的是,只有全局作用域的数据——而不是局部变量——可以被标记为导出。(顺便提一下,如果你想了解EXPORT_SYMBOL()宏如何工作,请参考本章的《进一步阅读》部分。)
回顾一下我们对内核模块许可的简要讨论。Linux内核有一个可以说是“有趣”的提议:还有一个宏叫做EXPORT_SYMBOL_GPL()。它与EXPORT_SYMBOL()宏非常相似,不同之处在于,只有包含GPL字样的内核模块,才能看到并使用通过EXPORT_SYMBOL_GPL()导出的函数或数据项!啊,内核社区的甜蜜复仇。它的确在内核代码库的多个地方被使用。(我留给你去找出这些宏在代码中的出现;在6.1.25内核中,通过cscope快速搜索显示“仅”有约17,000次使用实例!)
要查看所有导出的符号,请导航到内核源代码树的根目录并运行make export_report命令。但请注意,这仅适用于已经配置并构建的内核树。
现在,让我们看看实现类库-like内核功能的另一种关键方法:模块堆叠(module stacking)。
理解模块堆叠(Module Stacking)
第二个重要概念——模块堆叠——是我们现在要进一步探讨的内容。
模块堆叠是一个概念,在一定程度上为内核模块作者提供了一个“类库-like”功能。在这种情况下,我们通常会以一种方式设计我们的项目或产品,使得我们有一个或多个“核心”内核模块,它们的任务是充当某种类库。它将包含数据结构和功能(函数/API),这些数据结构和功能将被导出给项目中的其他内核模块使用(以及任何想要使用它们的人;前一节讨论了这一点)。
为了更好地理解这一概念,让我们看几个实际的例子。首先,在我的主机系统上,这是一个Ubuntu 22.04.3 LTS本地Linux系统,我通过Oracle VirtualBox 7.0虚拟化软件运行了一个或多个虚拟机。好的,在主机系统上执行lsmod命令并过滤出字符串vbox,得到以下结果:
$ lsmod | grep vbox
vboxnetadp 28672 0
vboxnetflt 28672 1
vboxdrv 614400 3 vboxnetadp,vboxnetflt
回顾我们之前的讨论,lsmod输出的四列中,第三列是使用计数。在第一行中它为0,在第三行中为3。不仅如此,vboxdrv内核模块在其右侧(使用计数列之后)列出了两个内核模块。如果有任何内核模块出现在第三列之后,它们表示模块依赖关系;可以这样理解:显示在右侧的内核模块依赖于左侧的内核模块。
因此,在前面的示例中,vboxnetadp和vboxnetflt内核模块依赖于vboxdrv内核模块。它们以何种方式依赖它呢?当然是调用vboxdrv核心内核模块中的函数(API)和/或使用其数据结构!一般来说,出现在第三列右侧的内核模块意味着它们调用了左侧内核模块的一个或多个函数和/或使用了数据结构(这会导致使用计数增加;这个使用计数是一个很好的引用计数示例(这里它实际上是一个32位原子变量),我们将在最后一章中详细探讨)。实际上,vboxdrv内核模块类似于一个“类库”(在有限的意义上,并没有像用户模式下的类库那样的用户空间含义,除了它提供模块化功能之外)。你可以看到,在这个快照中,它的使用计数是3,而依赖于它的内核模块被堆叠在它之上——字面意思!(你可以从前两行lsmod输出中看到它们)。另外,注意vboxnetflt内核模块有一个正的使用计数(1),但它右侧没有显示任何内核模块;这仍然意味着当前有东西在使用它,通常是一个进程或线程。
顺便提一下,我们在这个例子中看到的Oracle VirtualBox内核模块实际上是VirtualBox Guest Additions的实现。它们本质上是一个Para-virtualization(半虚拟化)构造,有助于加速来宾虚拟机的工作。Oracle VirtualBox为Windows和macOS主机提供类似的功能(所有主要的虚拟化厂商也都提供类似功能)。
另一个模块堆叠的例子,如承诺所示:内核有一个通用的核心HID(人机接口设备)驱动程序,通常作为一个模块运行;它有几个“依赖”于它的其他驱动程序,这些驱动程序堆叠在它之上,正好利用了我们在这里讨论的“类库-like”功能;截图清楚地显示了这一点:
下面是一些快速的脚本技巧,用于查看所有使用计数非零的内核模块(它们通常——但并不总是——有一些依赖的内核模块显示在它们的右侧;我们进一步按使用计数按升序对输出进行排序):
lsmod | awk '$3 > 0 {print $0}' | sort -k3n
模块堆叠的一个含义:你只能成功卸载(rmmod)一个内核模块,如果它的使用计数为0;即它未被使用。因此,在前面的第一个例子中,我们只能在移除两个依赖于它的内核模块后,才可以移除vboxdrv内核模块(从而将使用计数降至0)。
尝试模块堆叠
让我们设计一些非常简单的概念验证代码来展示模块堆叠。为此,我们将构建两个内核模块:
- 第一个我们称之为
core_lkm;它的任务是充当一个“类库”,向内核和其他模块提供一些函数(我们将这些函数视为由这个“核心”模块提供的API)。 - 第二个内核模块,我们称之为
user_lkm,是“库”的“用户”(或消费者/客户端)。它将简单地调用第一个模块中的函数(并使用一些数据)。
为了实现这一点,我们的这对内核模块需要做以下事情:
- “核心”内核模块必须使用
EXPORT_SYMBOL()宏将一些数据和函数标记为已导出(如果愿意,你也可以使用EXPORT_SYMBOL_GPL()宏)。 - “用户”内核模块必须通过C语言的
extern关键字声明它期望使用的外部数据和/或函数(记住,导出数据或功能只是建立适当的链接;编译器仍然需要知道被调用的数据和/或函数)。
对于最近的工具链,标记导出的函数和数据项为static是允许的。由于这会导致编译器警告,因此我们不为导出的符号使用static关键字。
接下来,编辑自定义的Makefile来构建这两个内核模块。
示例“核心”模块 – “类库”
以下是“核心”或“类库”内核模块的代码。为了(希望)让这个示例更加有趣,我们将把我们之前模块中某个函数的代码(ch5/min_sysinfo/min_sysinfo.c中的llkd_sysinfo2())复制到这个内核模块中并导出,从而使它对我们的第二个“用户”LKM可见,后者将调用这个函数。
在这里,我们不展示完整代码;你可以参考本书的GitHub仓库来获取完整的代码:
// 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);
/* 供其他LKM调用的函数 */
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);
[...]
示例“用户”模块 – “类库”客户端
接下来是user_lkm内核模块,它是堆叠在core_lkm内核模块之上的“用户”或客户端模块,作为“核心”或“类库”模块的消费者:
// 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);
在这个例子中,user_lkm模块通过extern声明了core_lkm模块中的函数和变量,并在user_lkm_init()函数中调用了core_lkm模块中的函数。通过这种方式,user_lkm模块可以使用core_lkm模块提供的功能,从而实现模块堆叠。这种方法利用了“类库-like”的特性,使得内核模块能够互相依赖和共享功能。
尝试运行
Makefile与我们之前的内核模块大致相同,不同之处在于这次我们需要构建两个内核模块对象(它们位于同一目录下),如下所示:
obj-m := core_lkm.o
obj-m += user_lkm.o
好,让我们试一试:
首先,构建内核模块:
$ make
--- Building : KDIR=/lib/modules/6.1.25-lkp-kernel/build ARCH= CROSS_COMPILE= ccflags-y="-DDEBUG -g -ggdb -gdwarf-4 -Wall -fno-omit-frame-pointer -fvar-tracking-assignments -DDYNAMIC_DEBUG_MODULE" MYDEBUG=y ---
gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
make -C /lib/modules/6.1.25-lkp-kernel/build M=/home/c2kp/Linux-Kernel-Programming_2E/ch5/modstacking modules
[ … ]
LD [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/modstacking/core_lkm.ko
CC [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/modstacking/user_lkm.mod.o
LD [M] /home/c2kp/Linux-Kernel-Programming_2E/ch5/modstacking/user_lkm.ko
[ … ]
$ ls *.ko
core_lkm.ko user_lkm.ko
现在,让我们执行一系列快速的测试,演示模块堆叠的概念验证。首先,我们做错了:我们将首先尝试在插入core_lkm模块之前插入user_lkm内核模块。
这会失败——为什么?你会发现,user_lkm内核模块依赖的导出功能(和数据)在内核中还不可用。更具体地说,符号不会出现在内核的符号表中,因为包含这些符号的core_lkm内核模块还没有被插入:
$ sudo dmesg -C
$ sudo insmod ./user_lkm.ko
insmod: ERROR: could not insert module ./user_lkm.ko: Unknown symbol in module
$ sudo dmesg
[13204.476455] user_lkm: Unknown symbol exp_int (err -2)
[13204.476493] user_lkm: Unknown symbol get_skey (err -2)
[13204.476531] user_lkm: Unknown symbol llkd_sysinfo2 (err -2)
正如预期的那样,由于所需的符号不可用,insmod失败(内核日志中看到的具体错误消息可能会根据内核版本和设置的调试配置选项略有不同)。
现在,让我们正确地操作:
查看图5.6;是的,现在它按预期工作了!
请注意,对于core_lkm内核模块,使用计数列已增加到1,现在我们可以看到user_lkm内核模块出现在其右侧,表明它依赖于core_lkm模块。(回想一下,lsmod输出的最右列的内核模块依赖于最左列的内核模块。)
现在,让我们移除内核模块。移除内核模块也有顺序依赖(就像插入时一样)。首先尝试移除core_lkm模块会失败,因为显然,还有另一个模块仍在内核内存中依赖它的代码/数据;换句话说,它仍在使用中:
$ sudo rmmod core_lkm
rmmod: ERROR: Module core_lkm is in use by: user_lkm
请注意,如果模块已经安装在系统上,你可以使用modprobe -r <modules...>命令来移除所有相关模块;我们将在《系统启动时自动加载模块》部分中讨论模块的安装。
上述rmmod失败消息不言自明。那么,让我们按正确的方式来操作:
$ sudo rmmod user_lkm core_lkm
$ dmesg
[...]
CPU: x86_64, little-endian; 64-bit OS.
[13889.717265] user_lkm:user_lkm_exit(): bids you adieu
[13889.732018] core_lkm:core_lkm_exit(): bids you adieu
完成!
接下来,你会注意到,在user_lkm内核模块的代码中,我们使用了一个条件#if语句来指定其许可证:
#if 1
MODULE_LICENSE("Dual MIT/GPL");
#else
MODULE_LICENSE("MIT");
#endif
我们可以看到,它默认是根据“双重MIT/GPL”许可证发布的;那么,有什么问题吗?考虑一下,在core_lkm内核模块的代码中,我们有:
int exp_int = 200;
EXPORT_SYMBOL_GPL(exp_int);
exp_int整数只对那些以GPL许可证运行的内核模块可见。所以,给你一个练习:将core_lkm.c中的#if 1语句改为#if 0,这样它就只以MIT许可证发布。然后,重新构建并重试。它会在构建阶段失败:
$ make
[...]
MODPOST /home/c2kp/Linux-Kernel-Programming_2E/ch5/modstacking/Module.symvers
ERROR: modpost: GPL-incompatible module user_lkm.ko uses GPL-only symbol 'exp_int'
[...]
阅读错误消息;许可证确实很重要!
你是否注意到,在我们的模块堆叠演示中(在ch5/modstacking文件夹下),“核心”模块和“用户”模块的代码都在同一个目录下,从而简化了Makefile规则和构建过程。如果情况不是这样呢?没问题;假设它们位于不同的文件夹中,如下所示:
modstacking2
├── core_module
└── user_module
一切保持不变,当然,Makefile有所不同:
对于“核心”模块的Makefile,我们现在只需要构建一个模块,即核心模块:
obj-m := core_module.o
对于“用户”模块的Makefile,我们当然需要像之前一样引用它和核心模块,只是这次我们必须指定核心模块的正确(相对)路径。因此,它变成了:
obj-m := user_module.o
obj-m += ../core_module/core_module.o
构建它们并按正确的顺序插入内核,所有操作都会成功。(我们留给你做这个练习。)
在结束这一节之前,这里有一个快速的列表,列出一些模块堆叠可能出错的地方;也就是说,检查时要注意的事项:
- 尝试以错误的顺序插入/移除内核模块。
- 尝试插入一个已经在内核内存中的导出例程——一个命名空间冲突问题。以下是一个例子:
$ cd <book_src>/ch5/min_sysinfo ; make && sudo insmod ./min_sysinfo.ko
[...]
$ cd ../modstacking ; sudo insmod ./core_lkm.ko
insmod: ERROR: could not insert module ./core_lkm.ko: Invalid module format
$ sudo dmesg
[...]
[335.823472] core_lkm: exports duplicate symbol llkd_sysinfo2 (owned by min_sysinfo)
$ sudo rmmod min_sysinfo
$ sudo insmod ./core_lkm.ko # now it's ok
- 使用
EXPORT_SYMBOL_GPL()宏时可能导致的许可证问题。
提示:始终查看内核日志(使用dmesg或journalctl)。它通常有助于显示实际出了什么问题。
模拟“类库-like”功能 - 总结与结论
让我们简要总结一下我们在内核模块空间中模拟类库-like功能时学到的关键点。我们探讨了两种技术:
- 第一种技术:通过将多个源文件链接在一起,构建一个单一的内核模块。
- 第二种技术:即模块堆叠技术,我们实际上构建多个内核模块并将它们“堆叠”在一起。
第一种技术不仅效果很好,而且还有以下优点:
- 我们不需要显式地标记(通过
EXPORT_SYMBOL())每个我们使用的函数和/或数据项为导出。 - 函数和数据只对实际链接到它们的内核模块可用(而不是对所有其他模块可用)。这是一个好处!而且这一切只需要稍微调整Makefile——非常值得。
“链接”(第一种)方法的一个缺点是:当链接多个文件时,内核模块的大小可能会变得相当大。
这总结了你对内核编程一个强大功能的学习——能够将多个源文件链接在一起形成一个内核模块,和/或利用模块堆叠设计,二者都允许你开发更复杂的内核项目。(在继续学习下一个主题之前,确保完成示例代码和建议的作业。)
那么,我们能否向内核模块传递参数呢?接下来的部分将向你展示如何做到这一点!
向内核模块传递参数
一种常见的调试技术是对代码进行仪器化;也就是说,在适当的地方插入打印,以便你可以跟踪代码的执行路径。当然,在内核模块中,我们会使用多功能的printk(及其相关函数)来实现这一点。因此,假设我们做类似下面的事情(伪代码):
#define pr_fmt(fmt) "%s:%s():%d: " fmt, KBUILD_MODNAME, __func__, __LINE__
[ ... ]
func_x() {
pr_debug("At 1\n");
[...]
while (<cond>) {
pr_debug("At 2: j=0x%x\n", j);
[...]
}
[...]
}
好,太棒了。但是,嘿,我们不希望调试打印出现在生产(或发布)版本中。这正是我们使用pr_debug()的原因:只有在定义了DEBUG符号时,它才会输出printk!确实如此,但如果我们的客户是工程类客户,并且希望动态地打开或关闭这些调试打印呢?有几种方法可以实现这一点;其中一种方法是如下伪代码:
static int debug_level; /* 初始为零 */
func_x() {
if (debug_level >= 1)
pr_debug("At 1\n");
[...]
while (<cond>) {
if (debug_level >= 2)
pr_debug("At 2: j=0x%x\n", j);
[...]
}
[...]
}
啊,这样不错。那么,我们真正想要的是:如果我们能够将debug_level变量作为参数传递给内核模块呢?然后——一个强大的功能——内核模块的用户就可以控制哪些调试消息显示,哪些不显示。
这是一个人为的示例,不过,现实中你总是可以——即使在生产环境中!——使用内核的动态调试框架来查看调试打印,随时启用它们。回想一下,我们在第4章《编写你的第一个内核模块 - 第一部分》中简要介绍了这一关键主题,在《内核强大的动态调试特性介绍》部分。
除了调试仪器化之外,还有许多情况下,模块参数非常有用(例如:设置音频驱动的初始音量级别、相机驱动的亮度级别等)。接下来,让我们学习如何使用模块参数。
声明和使用模块参数
模块参数作为名称-值对在模块插入(insmod/modprobe)时传递给内核模块。
例如,假设我们有一个名为mp_debug_level的模块参数;然后,我们可以在insmod时传递它的值,如下所示:
sudo insmod ./modparams1.ko mp_debug_level=2
这里,mp前缀表示模块参数。当然,它不必命名为这样;它是小细节,但一致地遵循这些命名约定有助于使阅读大型代码库变得更加直观。
这将非常强大。现在,最终用户可以决定希望以什么样的详细程度输出调试信息。我们可以很容易地安排默认值为0,因此模块默认不输出调试消息。
你可能会想,它是如何工作的:内核模块没有main()函数,因此没有常规的(argc,argv)参数列表,那么究竟是如何传递参数的呢?其实,这是通过链接器的小技巧来实现的……只需要做这个:将你打算作为模块参数的变量声明为全局(静态)变量,然后通过使用module_param()宏来告诉构建系统将其视为模块参数。
这在我们第一个模块参数的演示内核模块中很容易看到(如常,完整的源代码和Makefile可以在本书的GitHub仓库中找到):
// 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");
顺便提一下,在static int mp_debug_level;声明中,将其改为static int mp_debug_level = 0;显式初始化变量为0没有问题,对吗?嗯,不对:内核的checkpatch.pl脚本输出显示,这并不是内核社区认为的好编程风格;如果你这样做,它会触发这个“错误”:
ERROR: do not initialise statics to 0
#28: FILE: modparams1.c:28:
+static int mp_debug_level = 0;
在前面的代码块中,我们通过module_param()宏声明了两个变量作为模块参数。module_param()宏本身接受三个参数:
- 第一个参数:我们希望被视为模块参数的代码中的变量。它应该使用
static修饰符声明。 - 第二个参数:它的数据类型。
- 第三个参数:权限(实际上是通过
sysfs的可见性;稍后会解释)。
接下来,MODULE_PARM_DESC()宏允许我们“描述”参数代表什么。考虑一下:这就是你向内核模块(或驱动程序)的最终用户告知哪些参数实际可用的方式。查找是通过modinfo(8)工具进行的。此外,你可以通过使用-p选项开关,专门打印模块参数信息,如下所示:
$ cd <booksrc>/ch5/modparams/modparams1 ; make
[ … ]
$ 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)
modinfo输出显示可用的模块参数(如果有的话)。在这里,我们可以看到我们的modparams1.ko内核模块有两个参数:它们的名称、描述和数据类型(最后一个组件,在括号内;顺便说一下,charp是“字符指针”,即字符串)将被显示。
好了,现在让我们快速尝试一下我们的演示内核模块:
$ sudo dmesg -C
$ sudo insmod ./modparams1.ko
$ sudo dmesg
[630238.316261] modparams1:modparams1_init(): inserted
[630238.316685] modparams1:modparams1_init(): module parameters passed: mp_debug_level=0 mp_strparam=My string param
在这里,我们从dmesg输出中看到,由于我们没有显式传递任何内核模块参数,因此模块变量显然保持了默认(原始)值。现在,让我们重新操作,这次向模块参数传递显式的值:
sudo rmmod modparams1
sudo insmod ./modparams1.ko mp_debug_level=2 mp_strparam="Hello modparams1"
sudo dmesg
[...]
[630359.270765] modparams1:modparams1_exit(): removed
[630373.572641] modparams1:modparams1_init(): inserted
[630373.573096] modparams1:modparams1_init(): module parameters passed: mp_debug_level=2 mp_strparam=Hello modparams1
它按预期工作。是不是很有趣:这些(参数)变量现在已经在内核内存中被我们从用户空间传递的新值修改了!
现在我们已经看到如何声明并将一些参数传递给内核模块,让我们接下来看看如何在运行时获取并甚至修改它们。
在插入后获取/设置模块参数
让我们再次仔细看看我们之前在modparams1.c源文件中使用的module_param()宏:
module_param(mp_debug_level, int, 0660);
注意第三个参数——权限(或模式):它是0660(当然,这是一个八进制数字,表示所有者和组具有读写权限,其他用户没有权限)。这可能有些困惑,直到你意识到,如果这个权限(或模式)参数被指定为非零,系统会在sysfs文件系统下创建一个伪文件,表示内核模块参数,路径为:/sys/module/<module-name>/parameters/。
sysfs通常挂载在/sys下。此外,默认情况下,所有伪文件的所有者和组都是root。
因此,对于我们的modparams1内核模块(首先确保你已将其插入内核内存!),让我们来查看它们:
$ ls /sys/module/modparams1/
coresize holders/ initsize initstate notes/ parameters/ refcnt sections/ srcversion taint uevent version
$ ls -l /sys/module/modparams1/parameters/
total 0
-rw-rw---- 1 root root 4096 Oct 11 17:09 mp_debug_level
-rw-rw---- 1 root root 4096 Oct 11 17:09 mp_strparam
$
果然,它们就在这里!更妙的是,这些“参数”现在可以随时读取和写入(不过,当然,这些操作需要root权限)!
检查一下;让我们查找当前mp_debug_level模块参数的值:
$ cat /sys/module/modparams1/parameters/mp_debug_level
cat: /sys/module/modparams1/parameters/mp_debug_level: Permission denied
$ sudo cat /sys/module/modparams1/parameters/mp_debug_level
[sudo] password for c2kp:
2
我们可以看到,mp_debug_level内核模块参数的当前值确实是2(这正是我们在执行insmod时分配的值)。
现在,让我们通过写入它(以root身份)动态地将其更改为0,这意味着modparams1内核模块将不再发出任何“调试”信息:
$ sudo sh -c "echo 0 > /sys/module/modparams1/parameters/mp_debug_level"
$ sudo cat /sys/module/modparams1/parameters/mp_debug_level
0
完成——你看,工作得很顺利。你也可以以类似的方式获取和/或设置mp_strparam参数;我们会把这个简单的练习留给你来做。深入思考这个概念吧。这是一个非常强大的功能:你可以编写简单的脚本,通过内核模块参数控制设备(或其他任何东西)的行为,获取(或屏蔽)调试信息,等等;这种可能性几乎是无穷的。
实际上,在某些情况下,直接使用八进制数字(如0660)作为module_param()的第三个参数并不被认为是最佳的编程实践。可以通过适当的宏来指定sysfs伪文件的权限(这些宏在include/uapi/linux/stat.h中定义),例如:
module_param(mp_debug_level, int, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP);
然而,话虽如此,我们的“更好”Makefile中的checkpatch目标(当然,它会调用内核的checkpatch.pl“编码风格”Perl脚本检查器)会友好地提醒我们,直接使用八进制权限会更好:
$ make checkpatch
[ ... ]
checkpatch.pl: /lib/modules/<ver>/build//scripts/checkpatch.pl --no-tree -f *.[ch]
[ ... ]
WARNING: Symbolic permissions 'S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP' are not preferred. Consider using octal permissions '0660'.
#32: FILE: modparams1.c:32:
+module_param(mp_debug_level, int, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP);
所以,内核社区对这个问题有不同看法。因此,我们将继续使用“常规”的八进制数字表示法0660。(有趣的是,顺便提一下,这是一个最近(6.3)的提交,修复了某些驱动程序的这个问题:dm: avoid using symbolic permissions)。
再次强调一个有用的提示:根据我们的学习,使用debug_level类型的模块参数来提供“动态”调试仪器化似乎是一个不错的做法。然而,我认为,Linux内核内置的动态调试功能(CONFIG_DYNAMIC_DEBUG)要更强大得多!我们在第4章《编写你的第一个内核模块 - 第一部分》中简要介绍了这个关键主题,在《内核强大的动态调试特性介绍》部分。你可以从官方内核文档(www.kernel.org/doc/html/v6…)以及我的《Linux内核调试》书籍的第三章中了解更多。
学习模块参数数据类型和验证
在我们之前的简单内核模块中,我们设置了两个参数,分别是整数(int)和字符串数据类型(charp)。那么,模块参数还能使用哪些其他数据类型呢?事实证明,有很多种类型:moduleparam.h头文件中列出了所有可用类型(在注释中,以下为摘录):
// include/linux/moduleparam.h
[...]
* Standard types are:
* byte, hexint, short, ushort, int, uint, long, ulong
* charp: a character pointer
* bool: a bool, values 0/1, y/n, Y/N.
* invbool: the above, only sense-reversed (N = true).
好的,类型涵盖得很全面。实际上,如果需要,你甚至可以定义自己的数据类型。不过,通常情况下,标准类型已经足够使用。
验证内核模块参数
默认情况下,所有内核模块参数都是可选的;用户可以选择是否显式传递它们。但是,如果我们的项目要求用户必须显式地为某个内核模块参数传递一个值该怎么办?我们在这里解决这个问题:让我们改进之前的内核模块,创建另一个模块(ch5/modparams/modparams2),关键区别在于我们设置了一个额外的参数,名为control_freak。假设我们要求用户在模块插入时必须显式传递此参数:
首先,在代码中设置新的模块参数:
static int control_freak;
module_param(control_freak, int, 0660);
MODULE_PARM_DESC(control_freak, "Set to the project's control level [1-5]. MANDATORY");
如何实现这个“强制传递参数”呢?其实,这有点像一种黑客技巧:在插入时,检查(参数)变量的值是否为默认值(这里是0)。如果是,当然意味着最终用户没有通过显式传递来覆盖它;因此,我们可以在适当的位置输出消息并中止(我们还进行了简单的有效性检查,确保传递的整数在给定范围内)。以下是ch5/modparams/modparams2/modparams2.c的初始化代码:
static int __init modparams2_init(void)
{
pr_info("inserted\n");
if (mp_debug_level > 0)
pr_info("module parameters passed: "
"mp_debug_level=%d mp_strparam=%s\n control_freak=%d\n",
mp_debug_level, mp_strparam, control_freak);
/* param 'control_freak': 如果它没有传递(隐式猜测),
* 或者是相同的默认值,或者不在正确的范围内,
* 那就是不可接受的! :-)
*/
if ((control_freak < 1) || (control_freak > 5)) {
pr_warn("I'm a control freak; thus, you *Must* pass along module parameter 'control_freak', value in the range [1-5]; aborting...\n");
return -EINVAL;
}
return 0; /* success */
}
现在,让我们做点不可思议的事情,加载这个模块而不传递control_freak参数(哆嗦)!
$ sudo dmesg -C
[sudo] password for c2kp:
$ sudo insmod ./modparams2.ko
insmod: ERROR: could not insert module ./modparams2.ko: Invalid parameters
$ sudo dmesg
[ … ]
kern :info : [632275.085408] modparams2:modparams2_init(): inserted
kern :warn : [632275.085866] modparams2:modparams2_init(): I'm a control freak; thus, you *Must* pass along module parameter 'control_freak', value in the range [1-5]; aborting...
$
它按设计工作,因为参数没有显式传递,模块被中止(通过返回负的errno值,-EINVAL)。
另外,作为一个快速演示,请注意,我们仅在mp_debug_level为正时,才会输出printk,显示模块参数的值。
最后,关于这个话题,内核框架提供了一种更严格的方式来“获取/设置”内核(模块)参数,并执行有效性检查,通过module_param_cb()宏(cb代表回调)。我们在这里不再深入探讨;有关如何使用它的详细信息,请参考《进一步阅读》部分提到的博客文章。
现在,让我们继续讨论如何(以及为什么)我们可以覆盖模块参数的名称。
覆盖模块参数的名称
为了说明这个功能,让我们以(6.1.25版本)内核源树中的一个例子为例:直接映射缓冲I/O库驱动drivers/md/dm-bufio.c,其中有一个变量dm_bufio_current_allocated需要作为模块参数使用。然而,这个名字实际上是一个内部变量的名称,对于使用该驱动的用户来说并不直观。因此,驱动的作者更希望使用另一个更直观、更有意义的名称来表示这个参数——current_allocated_bytes,作为别名或名称覆盖。正是通过module_param_named()宏,我们可以实现这一点,这样就能覆盖并完全等效于内部变量名称,如下所示:
module_param_named(current_allocated_bytes, dm_bufio_current_allocated, ulong, S_IRUGO);
MODULE_PARM_DESC(current_allocated_bytes, "Memory currently used by the cache");
你可以在这里查看此宏的用法,以及类似的module_param_named()宏的多个实例:elixir.bootlin.com/linux/v6.1.…。
因此,当用户对这个驱动执行insmod时,他们可以像下面这样做:
sudo insmod <path/to/>dm-bufio.ko current_allocated_bytes=4096 ...
内部,实际的变量dm_bufio_current_allocated现在将被赋值为4096。
与硬件相关的内核参数
出于安全原因,指定硬件特定值的模块或内核参数有一个单独的宏:module_param_hw[_named|array]()。David Howells于2016年12月1日提交了一系列补丁,支持这些新的硬件参数的内核支持。该补丁的邮件(lwn.net/Articles/70…)提到以下内容:
为指定硬件参数的模块参数提供了一种注解(例如,IO端口、IOMEM地址、中断、DMA通道、固定DMA缓冲区和其他类型)。
这将使这些参数能够在核心参数解析器中被锁定,以支持安全启动。
这部分内容结束后,我们将进入一个稍微特殊的内核编码方面——内核中的浮点数使用。
内核中不允许使用浮点数
多年前,我作为一个年轻且经验不足的程序员(我),在编写温度传感器设备驱动时经历了一次有趣的经历(虽然当时并不那么有趣)。我试图将温度值以毫度数摄氏度表示,并希望以三位小数的“常规”摄氏度表示温度值,结果做了如下操作:
int temperature;
double temperature_fp;
[... 处理过程 ...]
temperature_fp = temperature / 1000.0;
printk(KERN_INFO "temperature is %.3f degrees C\n", temperature_fp);
结果一切都变得糟糕了!
经典的《LDD》书(《Linux设备驱动程序》,由Corbet、Rubini和Kroah-Hartman编写)指出了我的错误——内核空间中不允许使用浮点(FP)运算!这是一个有意识的设计决策;保存处理器(FP)状态、启动FP单元、进行计算然后关闭并恢复FP状态,在内核中并不被认为是值得做的事情。内核(或驱动)开发人员最好避免在内核空间中进行FP计算。
那么,你可能会问,如何处理(比如我这个例子中的)温度转换呢?很简单:将整数的毫度数摄氏度值传递给用户空间,并在那里进行FP运算!
在许多情况下,在内核中执行某些类型的工作是错误的;用户空间才是进行这类工作的正确地方(每条规则都有少数例外)。这包括执行浮点运算、文件I/O(是的,不要尝试在内核中通过类似系统调用的代码路径进行文件读写)以及运行应用程序,尽管内核确实提供了(虽然令人惊讶,但有时非常有用)通过UMH(用户模式助手)API来执行此类工作的能力,比如call_usermodehelper*()等(使用时需谨慎)等等。
强制在内核中使用浮点数
虽然我们说过在内核空间不允许使用浮点数,但确实存在一种方法可以强制内核执行浮点运算:将浮点代码放在kernel_fpu_begin()和kernel_fpu_end()宏之间。内核代码库中确实有一些地方使用了这种技术(通常是一些涉及加密/AES、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 = %.4f\n", OURMODNAME, mypi, num/den);
#endif
return 0; /* success */
}
它实际上是有效的,直到我们尝试通过printk()显示浮点值!此时,它变得相当混乱。如下图所示:
关键行是“请移除格式字符串中的不支持的 %f”
这告诉了我们发生了什么。系统实际上并没有崩溃或发生内核恐慌,因为这只是一个警告,它通过WARN_ONCE()宏输出到内核日志。然而,请注意,在生产系统中,/proc/sys/kernel/panic_on_warn sysctl(伪文件)可能已设置为值1,这会导致内核(正确地)发生恐慌。
虽然在前面的截图(图5.7)中看不到,但在下方,开始于“Call Trace:”的部分,当然是对在前面WARN_ONCE()代码路径中“捕获”的进程或线程当前内核栈状态的窥视(等一下,你将在第6章《内核内部基础 – 进程与线程》中学习到关于用户模式和内核模式栈等的关键细节)。
可以通过从底部向上读取内核栈来解释它,这将极大地帮助调试工作。顺便说一下,下面是Call Trace:输出的一个截断部分(接续图5.7的输出):
[ … ]
[633848.592829] CR2: 000056086ea0dbd8 CR3: 0000000775ed8003 CR4: 00000000007706e0
[633848.593607] PKRU: 55555554
[633848.593889] Call Trace:
[633848.594148] <TASK>
[633848.594374] vsnprintf+0x71/0x560
[633848.594717] vprintk_store+0x19a/0x5a0
[633848.595101] vprintk_emit+0x8e/0x1f0
[633848.595468] ? 0xffffffffc0a33000
[633848.595811] vprintk_default+0x1d/0x30
[633848.596194] vprintk+0x67/0xb0
[633848.596587] ? 0xffffffffc0a33000
[633848.596927] _printk+0x58/0x81
[633848.597246] fp_in_lkm_init+0x62/0x1000 [fp_in_lkm]
[633848.597735] do_one_initcall+0x46/0x230
[633848.598163] ? kmem_cache_alloc_trace+0x1a6/0x330
[633848.598638] do_init_module+0x52/0x220
[633848.599022] load_module+0xb56/0xd40
[633848.599388] ? security_kernel_post_read_file+0x5c/0x80
[ … ]
通过从底部向上阅读,我们可以看到do_one_initcall()调用了fp_in_lkm_init()(属于内核模块[fp_in_lkm],当然是我们写的内核模块),然后调用了[_]printk(),最终因为尝试打印一个浮点数值而引发了各种问题!(顺便提一下,忽略任何以?符号开头的调用帧。)
这里的教训很明显:尽可能避免在内核空间中使用浮点运算。
练习:如果去掉那个出问题的printk(),它会工作吗?尝试一下。
现在,让我们进入一个实际话题——如何在系统启动时安装和自动加载内核模块。
系统启动时自动加载内核模块
到目前为止,我们编写的简单“树外”内核模块都位于各自的私有目录中,并且需要手动加载,通常通过insmod或modprobe(8)工具。在大多数实际的项目和产品中,您将需要您的树外内核模块在启动时自动加载。本节将介绍如何实现这一目标。
假设我们有一个名为foo.ko的内核模块。我们假设我们有其源代码和Makefile。为了使其在系统启动时自动加载,您需要首先将内核模块安装到系统的已知位置。为此,我们希望该模块的Makefile包含一个install目标,通常如下所示:
install:
make -C $(KDIR) M=$(PWD) modules_install
这不是新内容;我们之前在我们的示例内核模块的Makefile中也设置了安装目标。此外,由于它写入的是root拥有的目录,我们可以始终修改规则,使用sudo来确保安装成功(或者,用户必须通过sudo来执行此命令,无论哪种方式):
install:
sudo make -C $(KDIR) M=$(PWD) modules_install
为了演示这个“自动加载”过程,我们展示了以下步骤,用于安装并在启动时自动加载ch5/min_sysinfo内核模块:
- 首先,进入模块的源目录:
cd <book_src>/ch5/min_sysinfo
2. 接下来,首先构建内核模块(使用make),并在构建成功后进行安装(正如您很快会看到的,我们的“更好的”Makefile通过确保先进行构建,接着是安装和depmod,使得这个过程更简单):
make && sudo make install
假设构建成功,sudo make install命令将内核模块安装到这里:/lib/modules/<kernel-ver>/extra/,正如预期的那样(请查看以下信息框和提示)。我们试一下:
$ cd <book_src>/ch5/min_sysinfo
$ make # <-- 确保首先在本地构建 'min_sysinfo.ko' 内核模块对象
[...]
$ sudo make install
[sudo] password for c2kp:
--- 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/Linux-Kernel-Programming_2E/ch5/min_sysinfo modules_install
make: Entering directory '/home/c2kp/kernels/linux-6.1.25'
INSTALL /lib/modules/6.1.25-lkp-kernel/extra/min_sysinfo.ko
SIGN /lib/modules/6.1.25-lkp-kernel/extra/min_sysinfo.ko
DEPMOD /lib/modules/6.1.25-lkp-kernel
make: Leaving directory '/home/c2kp/kernels/linux-6.1.25'
sudo depmod
[If !debug, stripping debug info from /lib/modules/6.1.25-lkp-kernel/extra/min_sysinfo.ko]
if [ "n" != "y" ]; then \
sudo strip --strip-debug /lib/modules/6.1.25-lkp-kernel/extra/min_sysinfo.ko ; \
fi
$ ls -l /lib/modules/6.1.25-lkp-kernel/extra/
total 16
-rw-r--r-- 1 root root <...> min_sysinfo.ko
$
现在,忽略SIGN这一行;它表示模块已经“签名”,这是一个安全特性,我们会在稍后讨论。
在sudo make install期间,您可能会看到有关SSL的(非致命)错误;它们可以安全忽略。它们表明系统未能“签名”内核模块(这里它工作正常)。有关此方面的更多内容,请参见后面的安全部分。
另外,如果您发现sudo make install失败,可以尝试以下方法:
a) 切换到root shell(sudo -s),然后在其中运行make和make install命令。
b) 一个有用的参考:Makefile: installing external Linux kernel module,Stack Overflow,2016年6月。
另一个常见的模块工具depmod(8)通常会在sudo make install过程中被默认调用(如前面的输出所示)。如果由于某种原因没有调用,您可以手动执行depmod(我们的Makefile已经做了这一步);它的工作是解析模块依赖关系(查看其手册页了解详细信息):
sudo depmod
安装内核模块后,您可以通过其--dry-run选项查看depmod的效果:
$ sudo depmod --dry-run | grep min_sysinfo
extra/min_sysinfo.ko:
alias symbol:llkd_sysinfo min_sysinfo
alias symbol:llkd_sysinfo2 min_sysinfo
确保安装的模块在启动时自动加载。做到这一点的一种方法是创建/etc/modules-load.d/<foo>.conf配置文件(其中<foo>是模块的名称。当然,您需要root权限来创建此文件)。最简单的情况很简单:只需将内核模块的名称放入文件中,完成即可。以min_sysinfo为例,我们可以这样做:
$ sudo vim /etc/modules-load.d/min_sysinfo.conf
# Auto load kernel module demo for the LKP2E book: ch5/min_sysinfo
min_sysinfo
<save & exit>
$ cat /etc/modules-load.d/min_sysinfo.conf
# Auto load kernel module demo for the LKP2E book: ch5/min_sysinfo
min_sysinfo
$
在现代Linux中,事实上的初始化框架是systemd。另一种更简单的方法是在启动时自动加载模块:告诉systemd加载它:只需将(已经安装的)模块名称输入到(预先存在的)/etc/modules-load.d/modules.conf文件中。
重启系统:
sync; sudo reboot
系统启动后,使用lsmod查看模块,并通过dmesg或journalctl查看内核日志。您应该看到与加载内核模块相关的信息(在我们的示例中是min_sysinfo)。
完成了!我们的 min_sysinfo 内核模块已经成功在系统启动时自动加载到内核空间!(我们模块输出的“平台细节”确实出现在内核日志中;只是这里的 grep 没有匹配所有输出行。)
记住,您必须先构建您的内核模块,然后执行安装;为了帮助自动化此过程,我们的“更好的” Makefile 在其模块安装目标中包含了以下内容:
makefile
复制代码
// ch5/min_sysinfo/Makefile
[ ... ]
install:
@echo
@echo "--- installing ---"
@echo " [First, invoking the 'make' ]"
make
@echo
@echo " [Now for the 'sudo make install' ]"
sudo make -C $(KDIR) M=$(PWD) modules_install
sudo depmod
@echo " [If !debug and !(module signing), stripping debug info from ${KMODDIR}/extra/${FNAME_C}.ko]"
if [ "${DBG_STRIP}" = "y" ]; then \
sudo ${STRIP} --strip-debug ${KMODDIR}/extra/${FNAME_C}.ko ; \
fi
它确保首先完成构建,然后进行安装并(明确)执行 depmod。(我们的 Makefile 还具备智能判断,如果是作为发布版本构建且未启用模块签名(稍后会介绍),它将自动去除模块中的调试符号。)
另一种在启动时自动加载内核模块的方法是通过启动脚本“手动”加载它,该脚本调用 modprobe(8) 工具,后者是比 insmod 更智能的版本(更多内容将在接下来的部分讨论)。
如果您的自动加载内核模块在加载时需要传递某些(模块)参数怎么办?
确保这一点有两种方法:通过所谓的 modprobe 配置文件(位于 /etc/modprobe.d/ 下),或者,如果模块已编译进内核,则通过内核命令行。
这里我们展示第一种方法:只需设置您的 modprobe 配置文件(例如,在此示例中,我们使用 mykmod 作为我们的 LKM 名称;同样,您需要 root 权限来创建此文件):/etc/modprobe.d/mykmod.conf;在其中,您可以像这样传递参数:
options <module-name> <parameter-name>=<value>
例如,在我 x86_64 Ubuntu 22.04 LTS 系统上的 /etc/modprobe.d/alsa-base.conf 文件中包含以下行(其他行也有):
# Ubuntu #62691, enable MPU for snd-cmipci
options snd-cmipci mpu_port=0x330 fm_port=0x388
在这里,我们的理解是:如果需要加载 snd-cmipci 模块,它将与模块参数 mpu_port=0x330 fm_port=0x388 一起加载到内核内存中。接下来是一些与内核模块自动加载相关的其他要点。
内核模块自动加载 – 额外细节
一旦内核模块通过 sudo make install 安装到系统上,您还可以通过使用一个更智能的工具 modprobe(8) 来交互式地将其插入到内核中(或者通过脚本)。以我们为例的 min_sysinfo 模块为例,我们可以首先卸载该模块,然后执行以下操作:
sudo modprobe min_sysinfo
有一个有趣的补充说明:在需要加载多个内核模块的情况下(例如,使用模块堆叠设计),modprobe 工具能够智能地按正确的顺序加载这些模块(我们在“理解模块堆叠”部分讨论过这个需求),以确保模块依赖关系得到解决,并且一切顺利!
那么,modprobe 如何知道正确的加载顺序呢?当执行本地构建时,构建过程会生成一个名为 modules.order 的文件。这个文件会告诉 modprobe 等工具加载内核模块的顺序,以便解决所有的依赖关系。当内核模块被安装到内核中(即安装到 /lib/modules/$(uname -r)/extra/ 或类似的位置)时,depmod 工具会生成一个 /lib/modules/$(uname -r)/modules.dep 文件。这个文件包含了依赖关系信息,指示某个内核模块是否依赖于另一个模块。通过这个信息,modprobe 可以按正确的顺序加载模块,避免出现问题。
为了展示这个过程,我们来安装我们的模块堆叠示例模块:
$ cd <...>/ch5/modstacking
$ sudo make install
[ ... ]
INSTALL /lib/modules/6.1.25-lkp-kernel/extra/core_lkm.ko
SIGN /lib/modules/6.1.25-lkp-kernel/extra/core_lkm.ko
INSTALL /lib/modules/6.1.25-lkp-kernel/extra/user_lkm.ko
SIGN /lib/modules/6.1.25-lkp-kernel/extra/user_lkm.ko
DEPMOD /lib/modules/6.1.25-lkp-kernel
make: Leaving directory '/home/c2kp/kernels/linux-6.1.25'
sudo depmod
[ ... ]
$ ls -l /lib/modules/6.1.25-lkp-kernel/extra/
total 248
-rw-r--r-- 1 root root 118377 Oct 12 16:37 core_lkm.ko
-rw-r--r-- 1 root root 12672 Oct 12 15:51 min_sysinfo.ko
-rw-r--r-- 1 root root 117097 Oct 12 16:37 user_lkm.ko
如上所示,我们的两个模块(core_lkm.ko 和 user_lkm.ko)现在已安装到预期位置 /lib/modules/$(uname -r)/extra/ 下,并且 depmod 已运行。
接下来,检查这个内容:
$ grep user_lkm /lib/modules/6.1.25-lkp-kernel/* 2>/dev/null
/lib/modules/6.1.25-lkp-kernel/modules.dep:extra/user_lkm.ko: extra/core_lkm.ko
depmod 已经在 modules.dep 文件中安排了依赖关系!它显示了 extra/user_lkm.ko 模块(左侧)依赖于 extra/core_lkm.ko 模块(通过 <k1.ko>: <k2.ko> 格式表示,意味着 k1.ko 模块依赖于 k2.ko 模块)。因此,modprobe 会解析 /lib/modules/$(uname -r)/modules.order 文件,识别出这些依赖关系,并按顺序加载它们,避免任何问题。
顺便提一下,生成的 /lib/modules/$(uname -r)/modules.symbols 文件包含了所有已导出的模块符号信息。同样,模块目录中的 Module.symvers 文件也包含了相同的信息。(此外,所有内核符号表中的符号——无论是导出的还是私有的——以及它们的(虚拟)地址,都可以通过 /proc/kallsyms 伪文件查看(需要 root 权限;详细信息请参阅 nm(1) 的 man 页面))。
在系统启动时自动加载内核模块
在现代 Linux 系统中,实际上是 systemd 负责在系统启动时自动加载内核模块,它通过解析诸如 /etc/modules-load.d/* 的文件内容来完成。负责此功能的 systemd 服务是 systemd-modules-load.service(8)(有关详细信息,请参阅 modules-load.d(5) 的 man 页面)。
如果您发现某个自动加载的内核模块行为异常(例如,导致死机或延迟,或根本无法正常工作),您可能希望禁用它在启动时自动加载。可以通过将该模块加入黑名单来实现。您可以选择在内核命令行中指定(当其他方法失败时很有用!)或者在 /etc/modules-load.d/<foo>.conf 配置文件中指定。内核命令行中,您可以使用 module_blacklist=mod1,mod2,...,内核文档中提供了该语法/解释:
module_blacklist= [KNL] Do not load a comma-separated list of
modules. Useful for debugging problem modules.
您可以通过执行 cat /proc/cmdline 查看当前的内核命令行。
在讨论内核命令行时,还有许多其他有用的选项,它们使我们能够利用内核的帮助来调试与内核初始化相关的问题。以下是一些示例(这些选项来自官方内核文档:www.kernel.org/doc/html/la…
debug[KNL] 启用内核调试(事件日志级别)。initcall_debug[KNL] 跟踪执行中的初始化调用。对于调试内核启动过程中哪里出问题非常有用。ignore_loglevel[KNL] 忽略日志级别设置——这将把所有内核消息打印到控制台。对调试很有帮助。我们也将它作为printk模块参数,可以动态更改,通常通过/sys/module/printk/parameters/ignore_loglevel。
快速启动!此外,initcall_debug 内核参数可以帮助您定位内核启动过程中运行较长时间的功能;这对于实现系统“快速启动”非常有用,某些项目需要这种功能。(顺便说一下,确保检查 systemd-analyze、内核树中的 scripts/bootgraph.pl 脚本等内容,更多信息请参阅本章的进一步阅读部分)。
结语
如前所述,内核模块的自动加载是产品中非常有用且通常是必需的功能。
内核模块与安全性 – 概述
一个具有讽刺意味的现实是,在提高用户空间安全性的巨大努力下,近年来获得了丰厚的回报。二十多年前,执行缓冲区溢出(BoF)攻击相对简单,但如今要成功执行这种攻击变得非常困难。为什么?因为有许多增强的安全机制层次来防止这些攻击类型。
简要列举一些反制措施:编译器保护(如 -fstack-protector[...], -Wformat-security, -D_FORTIFY_SOURCE=3)、部分/完全的 RELRO、改进的安全检查工具(如 checksec.sh、地址消毒器、PaxTest、静态分析工具等)、安全库、硬件级保护机制(如 NX、SMEP、SMAP 等)、[K]ASLR、更好的测试(如模糊测试)等等。呼~
讽刺的是,过去几年中,内核空间攻击变得越来越常见!已经证明,即便是暴露单个有效的内核(虚拟)地址(及其相应的符号)给一个聪明的攻击者,也能让他们找出一些关键的内核内部结构位置,从而为各种特权升级(privesc)攻击铺平道路。在接下来的部分中,我们将列举并简要描述一些 Linux 内核提供的安全/强化特性。然而,最终,作为内核开发者,你有一个重要角色要扮演:首先编写安全的代码!使用我们的“更好的”Makefile 是一个很好的开始方式 —— 它包含多个与安全相关的目标(例如,所有静态分析相关的目标)。在本节关于内核模块的安全性中,我们将涵盖一些相关的主题;让我们从一些关键的基于 proc 的 sysctl 开始。
影响系统日志的 Proc 文件系统可调项
我们直接参考 proc(5) 的手册页 – 这非常有价值! – 以获取有关两个安全相关的可调项或 sysctl(链接:man7.org/linux/man-p…
dmesg_restrictkptr_restrict
关于 dmesg_restrict sysctl 的简要说明
dmesg_restrict sysctl 是确定查看内核日志所需的最小权限的一种方式。回想一下,像这种看似无害的内容实际上对一个有决心的黑客来说是非常有价值的信息!相关的条目在 proc(5) 的手册页中如下所示:
dmesg_restrict
/proc/sys/kernel/dmesg_restrict(自 Linux 2.6.37 起)
此文件中的值决定了谁可以查看内核 syslog 的内容。如果文件中的值为 0,则没有任何限制。如果值为 1,则只有特权用户才能读取内核 syslog。(有关详细信息,请参见 syslog(2)。)自 Linux 3.4 起,只有具有 CAP_SYS_ADMIN 权限的用户才能更改此文件中的值。
(回想一下,“CAP_SYS_ADMIN 权限”用户等同于 root。)
在 (x86_64) 系统上,dmesg_restrict 可调项的默认值如下:
- Ubuntu 22.04(运行基于 5.19 内核)为 1,这是安全的;直到 20.04 版本,Ubuntu 的默认值为 0(较弱)。
- Fedora 38(运行基于 6.2 内核)为 0。
如何查看我的 Linux 系统上的当前值?很简单,这里有两种方法:
$ cat /proc/sys/kernel/dmesg_restrict
0
$ sysctl -a 2>/dev/null |grep -w dmesg_restrict
kernel.dmesg_restrict = 0
显然,它的值是 0,这意味着没有任何限制(这是在 Fedora 39 工作站上;最近的 Ubuntu 发行版将其设置为 1)。
顺便提一下,Linux 内核使用强大的细粒度 POSIX 权限模型。CAP_SYS_ADMIN 权限基本上是一个“捕获所有”权限,相当于传统的 root(超级用户/sysadmin)访问。CAP_SYSLOG 权限使进程(或线程)具有执行特权 syslog(2) 操作的能力。
关于 kptr_restrict sysctl 的简要说明
正如前面提到的,“泄露”内核地址及其相关符号可能会导致信息泄露攻击。为帮助防止这些攻击,内核和模块作者首先应避免打印内核地址。如果必须打印内核地址,则应始终使用一种较新的(且特定于内核的)printf风格格式:而不是使用熟悉的 %p 或 %px 来打印内核(虚拟)地址,应使用 %pK 格式说明符来打印地址。使用 %px 格式说明符会打印出实际地址;虽然这在调试时可能有用,但在生产环境中绝对应避免。以下是这些 printk 格式说明符如何提供帮助的详细信息……
kptr_restrict sysctl(自 2.6.38 起)影响 printk() 输出时打印内核地址的行为;使用 printk("&var = %pK\n", &var) 而不是经典的 printk("&var = %p\n", &var) 被认为是一种安全的最佳实践。理解 kptr_restrict 可调项的工作原理对这一点至关重要:
kptr_restrict
/proc/sys/kernel/kptr_restrict(自 Linux 2.6.38 起)
此文件中的值决定了是否通过 /proc 文件和其他接口暴露内核地址。如果文件中的值为 0,则没有限制。如果值为 1,使用 %pK 格式说明符打印的内核指针将被替换为零,除非用户拥有 CAP_SYSLOG 权限。如果值为 2,使用 %pK 格式说明符打印的内核指针将始终被替换为零,无论用户的权限如何。该文件的初始默认值为 1,但在 Linux 2.6.39 中默认值已更改为 0。自 Linux 3.4 起,只有具有 CAP_SYS_ADMIN 权限的用户才能更改此文件中的值。
再次说明,在我们最近的 Ubuntu 和 Fedora 系统上,dmesg_restrict 和 kptr_restrict 的默认值分别为 1 和 0。你可以利用 sysctl 工具查看(并作为 root 更改)内核 sysctl;在我们的 Ubuntu 22.04 VM 上:
$ sysctl kernel.dmesg_restrict kernel.kptr_restrict
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 1
以下是 kptr_restrict sysctl 在不同情况下如何影响内核地址显示的总结:
| kptr_restrict 值 | 非特权用户和 printk 使用 %pK | 特权用户(root)和 printk 使用 %pK |
|---|---|---|
| 0 | 没有限制(内核地址被暴露) | 没有限制(内核地址被暴露) |
| 1 | 内核地址被零替换(除非调用者具有 CAP_SYSLOG 权限) | 没有限制(内核地址被暴露) |
| 2 | 内核地址被零替换 | 内核地址被零替换 |
表 5.2:kptr_restrict sysctl 值在不同情况下如何影响内核地址的显示
你可以 – 而且必须 – 在生产系统上将这些 sysctl 更改为安全的值(1 或 2),这是一种安全的最佳实践。
- a) 由于 procfs 本质上是一个易失性文件系统,你可以始终使用
sysctl(8)工具的-w选项(或直接更新/etc/sysctl.conf文件)来使更改永久生效。 - b) 为了调试,如果必须打印实际的内核(未修改的)地址,建议使用
%px格式说明符;但在生产环境中请移除这些打印。 - c) 关于
printk格式说明符的详细内核文档可以在 此处 找到,建议浏览其中的内容。
当然,安全措施只有在开发者使用它们时才有效;截至 6.1.25 内核源代码,printk()(大约 33k 次)和相关函数(pr_*():大约 54k 次)总共约有 87,000 次使用。其中,只有 24 次使用了安全意识强的 %pK 格式说明符!(为了查找,我使用了常规表达式 printk(.*%pK 和 pr_.*(.*%pK 在内核源代码中做了 grep。虽然不一定完美,但可以给出合理的近似。第一版书籍在 5.4.0 内核源代码中只找到了 14 次 %pK 格式说明符的使用;所以,它在慢慢改进。)
随着 2018 年初硬件级缺陷(现在广为人知的 Meltdown、Spectre 以及其他处理器猜测安全问题)的出现,检测信息泄漏的紧迫性重新提高,从而使开发者和管理员能够阻止泄漏。
一个有用的 Perl 脚本 scripts/leaking_addresses.pl 在 4.14(2017 年 11 月发布)内核版本中加入了主线,它增强了检测内核地址泄漏的功能。
接下来,我们将介绍一个令人兴奋且强大的安全特性,特别是对于模块作者来说!
理解内核模块的加密签名
一旦恶意攻击者在系统上获得立足点,他们通常会尝试某种特权升级(privesc)手段,以便获得 root 权限。获得 root 权限后,典型的下一步是安装 rootkit:本质上是一组脚本和内核模块,能够悄无声息地接管系统(通过“劫持”系统调用、设置后门、键盘记录器等),从而使攻击者能够随时重新进入系统。
当然,这并不容易——现代生产质量的 Linux 系统具有强大的安全防护措施,配备了 Linux 安全模块(LSM)和各种用户/内核硬化功能,这意味着这并非易事,但对于一个技术高超且动机明确的攻击者来说,一切皆有可能(对于敏感安装,这种心态是必要的)。假设他们成功安装了一个足够复杂的 rootkit,那么系统现在就被视为已经被攻陷。
一个有趣的想法是:即使拥有 root 权限,也不允许任何东西——包括 insmod(或 modprobe,甚至底层的 [f]init_module() 系统调用)——将内核模块插入内核地址空间,除非它们已经用内核密钥环中的安全密钥进行加密签名。这一强大的安全功能自 3.7 内核引入,已有十多年历史!(相关的首次提交记录可以在这里查看:链接)
与此功能相关的几个内核配置选项包括 CONFIG_MODULE_SIG、CONFIG_MODULE_SIG_FORCE、CONFIG_MODULE_SIG_ALL 等。你可以通过常规的 make menuconfig UI 来查看并设置这些选项:导航至 Enable loadable module support > Module signature verification。大多数选项默认是关闭的;我首先启用了 Module signature verification。以下是显示这些选项的部分截图:
现在我们更清楚地理解了:在安装模块时,我们可以通过适当的内核配置来实现以下功能:
- 安装的模块可以签名(
CONFIG_MODULE_SIG=y)。 - 所有安装的模块都将始终签名(
CONFIG_MODULE_SIG_ALL=y)。 - 安装的模块在加载到内核内存之前必须经过签名(即“强制签名”(
CONFIG_MODULE_SIG_FORCE=y))。 - 模块在安装时会被压缩(
CONFIG_MODULE_COMPRESS_XZ=y等等)。
为了帮助理解第一个选项 CONFIG_MODULE_SIG=y 的含义,我们可以查看其 Kconfig 文件的 "help" 部分,内容如下(来自 init/Kconfig):
config MODULE_SIG
bool "Module signature verification"
depends on MODULES
select SYSTEM_DATA_VERIFICATION
help
Check modules for valid signatures upon load: the signature is simply
appended to the module. For more information see
<file:Documentation/admin-guide/module-signing.rst>. Note that this
option adds the OpenSSL development packages as a kernel build
dependency so that the signing tool can use its crypto library.
You should enable this option if you wish to use either
CONFIG_SECURITY_LOCKDOWN_LSM or lockdown functionality imposed via
another LSM - otherwise unsigned modules will be loadable regardless of the
lockdown policy.
!!!WARNING!!! If you enable this option, you MUST make sure that the
module DOES NOT get stripped after being signed. This includes the
debuginfo strip done by some packagers (such as rpmbuild) and
inclusion into an initramfs that wants the module size reduced.
请仔细阅读上述 "Help" 屏幕。我们的“更好的”Makefile 严肃对待这一警告,并且在启用 CONFIG_MODULE_SIG* 内核配置时,不会去剥离(调试)符号。
两种模块签名模式
模块签名有两种可能的模式:
-
宽松模式(默认):
- 当
CONFIG_MODULE_SIG_FORCE关闭时,允许未签名的模块(或没有密钥的模块)加载。作为副作用,内核将被标记为“污染”(设置 E 位,表示内核和相关模块都被污染)。这种设置(宽松模式)通常是典型现代 Linux 发行版的默认设置。
- 当
-
严格模式:
- 当
CONFIG_MODULE_SIG_FORCE打开时(设置为y,或者内核命令行中包括module.sig_enforce=1),只允许有效的已签名模块加载(这些模块会通过内核中的密钥验证)。此外,任何无法被内核解析的签名块或不匹配的模块都将拒绝加载。这对于增强安全性非常有用。(不过需要注意的是,模块不能以任何形式被剥离。)
- 当
污染内核
“污染”一词意味着“被污染或弄脏”... 在某些情况下,内核会被标记为污染(例如发生 Oops 错误、加载了专有或未签名的模块等)。内核会维护一个污染位掩码;它本身没有实际影响,但在调查问题时会有所帮助。更多详情请参见:Tainted Kernels。
查看当前的内核配置
在以下输出中,我查找了在我的 x86_64 Fedora 38 系统上(运行最近的 6.5 内核)的这些内核配置:
$ grep MODULE_SIG /boot/config-6.5.6-200.fc38.x86_64
CONFIG_MODULE_SIG_FORMAT=y
CONFIG_MODULE_SIG=y
# CONFIG_MODULE_SIG_FORCE is not set
CONFIG_MODULE_SIG_ALL=y
[ ... ]
设置 CONFIG_MODULE_SIG_ALL=y 确保所有安装的内核模块都会被签名!(使用模块签名确实要求系统上安装 OpenSSL 开发包,因为它包含执行签名的工具。)这种设置被认为对于安全性是有益的。然而,出于安全考虑,建议使用与内核默认密钥不同的密钥对(公钥/私钥),这些密钥可以通过 certs/x509.genkey 配置并在需要时生成。因此,建议你显式设置这个文件,并避免使用内核默认设置。
手动签署模块
你还可以通过使用 scripts/sign-file 程序来手动签署内核模块(该程序是从其 C 源文件构建的)。所有这些内容及更多信息在官方内核文档中都有很好的说明:模块签名文档。此外,Ubuntu 也有自己的内核模块签名工具,叫做 kmodsign。
内核模块的加密签名与仅在签名时加载的行为
在生产系统中,建议使用内核模块的加密签名和仅在签名时加载的行为(近年来,随着 IoT 边缘设备的普及,安全性成为了一个关键问题)。
完全禁用内核模块
一些过于谨慎的人可能希望完全禁用内核模块的加载。虽然这种做法比较激进,但它可以完全锁定系统的内核空间(并使任何 rootkit 基本上无害)。这可以通过两种主要方式实现:
-
通过配置内核禁用内核模块:
在配置内核时,将CONFIG_MODULES内核配置项设置为 off(默认情况下是启用的)。这样做是非常激进的,它使得这个决定变得永久性。 -
如果
CONFIG_MODULES已启用,可以在运行时动态禁用模块加载:
通过modules_disabledsysctl,可以动态禁用模块加载。你可以查看这个设置:$ cat /proc/sys/kernel/modules_disabled 0默认情况下是 off (0)。正如
proc(5)手册页所描述的那样:/proc/sys/kernel/modules_disabled (since Linux 2.6.31) A toggle value indicates if modules are allowed to be loaded in an otherwise modular kernel. This toggle defaults to off (0), but can be set true (1). Once true, modules can be neither loaded nor unloaded, and the toggle cannot be set back to false. The file is present only if the kernel is built with the CONFIG_MODULES option enabled.这个设置默认为关闭(0),但可以设置为真(1)。一旦设置为真,模块将无法加载或卸载,并且无法再设置为假。只有在内核是用
CONFIG_MODULES选项编译时,才会出现这个文件。
内核锁定 LSM —— 介绍
一个更强大的内核硬化功能是相对较新的(从 5.4 版本开始)内核安全模块(LSM)—— lockdown。它可以非常严格和侵入性,因此默认情况下是禁用的。简而言之,它禁止用户空间应用/脚本修改运行中的内核(通过其所谓的完整性模式)。更严格的锁定模式(Confidential)除了禁止修改外,还防止提取被认为是机密的内核信息(从而帮助防止信息泄露等)。
请注意,当在(U)EFI x86 或 AArch64 上启用安全启动模式时,锁定功能可以自动启用!这可能会导致模块无法加载(我曾遇到过这种情况)。安全性往往是把双刃剑。
有关 lockdown LSM 的更多内容,可以在“进一步阅读”部分找到。
总的来说,内核安全硬化与恶意攻击是一场猫捉老鼠的游戏。例如,一种名为 (K)ASLR 的安全硬化措施(我们将在后续的 Linux 内存管理章节中讨论 (K)ASLR 的含义)通常会被攻破。此外,请参阅这篇文章——有效绕过 Android 上的 kptr_restrict。安全不是一件容易的事;它始终是一个不断进行的工作。几乎不言而喻:开发者——无论是用户空间还是内核空间——都需要了解系统所需和实际的安全态势,并必须编写安全意识的代码,使用工具和测试来持续验证它的安全性。(虽然更辛苦,但我们必须做到。)
让我们在本章结束时,稍微谈谈内核社区——编码风格指南,以及如何向上游贡献代码的快速指南。
内核开发者编码风格指南
许多大型项目都有自己的编码规范,Linux 内核社区也不例外。在编写内核代码和/或内核模块代码时,遵循 Linux 内核编码风格指南是一个非常好的选择。你可以在以下位置查看官方文档的编码风格指南:Linux Kernel Coding Style(强烈建议阅读!)。
此外,作为开发者向上游提交代码的(非常详尽的)检查清单的一部分,要求你通过一个 Perl 脚本检查你的代码,以确保代码符合 Linux 内核的编码风格:scripts/checkpatch.pl。
默认情况下,这个脚本只会在格式良好的 git 补丁上运行。你也可以在独立的 C 代码(如你的外部内核模块代码)上运行它,如下所示(正如我们的“更好的”Makefile 所做的那样):
<kernel-src>/scripts/checkpatch.pl --no-tree -f <filename>.c
养成这个习惯对你的内核代码很有帮助,它能帮助你捕捉到那些烦人的小问题——以及可能导致你的补丁被卡住的更严重问题!再次提醒你:我们的“更好的”Makefile 中的 indent 和 checkpatch 目标正是为了这个目的。
练习:
- 切换到你的模块目录(包含“更好的”Makefile 的目录)。
- 执行:
make checkpatch。通过这个 Makefile,checkpatch目标将会在你的代码上执行,进而调用内核的checkpatch.pl脚本。 - 仔细阅读生成的任何警告和错误,修复它们并重复操作。(注意:通常情况下,第一个警告可以忽略,因为它与在第一行指定 "SPDX-License" 标签有关。)
除了编码风格指南,你还会时不时需要深入阅读内核的详细和有用的文档。温馨提示:我们在《在线章节:内核工作区设置》中已经涵盖了如何定位和使用 Linux 内核文档的内容。熟悉内核文档是很有价值的。
向主线内核贡献代码
在本书中,我们通常在内核源树之外进行内核开发,通过 LKM 框架进行开发。那么,如果你是在内核源树内部编写代码,目标是将代码提交到主线内核呢?这是一个值得赞扬的目标——开源的基础正是社区成员愿意付出工作并将代码贡献给上游项目。
如何开始贡献代码
当然,最常见的问题是:我该如何开始呢?为了帮助你更好地开始,官方内核文档中有一个详尽的答案:如何进行 Linux 内核开发。
实际上,你可以通过在内核源树根目录下运行 make pdfdocs 命令来生成完整的 Linux 内核文档;成功后,你会在以下位置找到 PDF 文档——同样的《如何进行 Linux 内核开发》文档:<root-of-kernel-source-tree>/Documentation/output/latex/development-process.pdf。
这是一份非常详细的 Linux 内核开发流程指南,包括代码提交的准则。以下是这份文档的截图:
作为内核开发过程的一部分,为了保持质量标准,严格的、必须遵循的检查清单——可以说是一份很长的食谱!——是内核补丁提交过程的重要组成部分(当前最新版本的检查清单有24个要点,毫不含糊)。官方的检查清单位于此处:Linux 内核补丁提交检查清单。
尽管对于内核新手来说,这似乎是一项繁重的任务,但仔细遵循这份清单能为你的工作提供严格性和可信度,最终产生高质量的代码。我强烈建议你阅读并尝试清单中提到的流程。
那么,成为一个内核黑客的实际操作建议是什么呢?当然,继续阅读本书!哈哈,是的,除了那之外,还可以参加超级棒的Eudyptula挑战(www.eudyptula-challenge.org/)。哦,等等,遗憾的是,截至写作时,这个挑战已经关闭了。
不用担心,这里有一个网站,列出了所有挑战(以及解决方案,但不要作弊!)。快去看看并尝试这些挑战,这将极大加速你的内核黑客技能:Eudyptula挑战。
总结
在本章中,我们深入探讨了通过LKM框架编写内核模块的若干重要话题,包括使用“更好的”Makefile、配置调试内核的技巧(这非常重要!)、交叉编译内核模块、在内核模块中收集最小的平台信息,以及内核模块的许可问题等。我们还介绍了如何模拟类库功能,使用两种不同的方法(首先是通常首选的链接方法,其次是模块堆叠方法),以及如何使用模块参数、避免使用浮点算术、内核模块在启动时的自动加载等内容。安全问题,特别是有关模块的安全性及其解决方案,也得到了讨论。最后,我们总结了本章内容,介绍了内核编码风格规范,并讨论了如何开始向主线内核贡献代码。因此,恭喜你!你现在已经掌握了内核模块的开发方法,并且可以开始向内核主线贡献代码的旅程。
在下一章(我们第二部分——理解和操作内核的第一章),我们将深入探讨一个有趣且必要的话题。我们将开始详细探索Linux内核的内部结构,了解其架构、如何封装进程和线程等内容。
问题
在本章结束时,以下是一些问题,帮助你测试自己对本章内容的掌握: 问题链接。你可以在本书的GitHub仓库中找到一些问题的答案:答案链接。
进一步阅读
为了帮助你深入学习该主题,我们提供了一个详细的在线参考文献和链接清单(有时还包括书籍),并按章节进行整理。这些资源可以在本书的GitHub仓库中的《进一步阅读》markdown文档中找到。进一步阅读文档可以在此访问:进一步阅读链接。