【DEV.0x01】如何优雅地开发树外内核模块
本文最后更新于:2025年2月26日 上午
我在 5:20 make install—— 13:14 准时 kernel panic
0x00. 一切开始之前
虽然说已经做了这么多年的内核开发(?)了,但是笔者开发的内核模块一般都仅局限于极其狭隘的特定用途(例如写点 小 rootkit 之类的),也没咋组织过比较优雅的代码结构,基本上都是一个丑陋的 Makefile 包办一切,需要这个内核模块的时候直接粗糙地手动 insmod
——能用是能用,但确实也就局限于自用了:(
因此这篇博客主要讲讲如何使用 Kbuild 组织代码并使用 DKMS 等工具优雅地开发与打包一个 out-of-tree 的 Linux 内核模块
0x01. 基本的内核模块
我们首先写一个基础的内核模块:
1 |
|
下面我们开始搭建模块源码文件结构,组织如下:
1 |
|
使用 Kbuild 组织源码文件
基础的 Kbuild 示例
Kbuild 是 Linux kernel 构建系统的一部分,简而言之,当我们在源码目录下编写了 Kbuild
文件之后,在编译时 Linux kernel 的编译基础设施便会根据 Kbuild
来自动地编译好我们的内核模块,若没有 Kbuild
则会选择寻找 Makefile
相比起把所有待编译文件与配置都写到一个 Makefile 里(例如 nvidia-open 便是这么做的,看起来并不美观),拆分到多级 Makefile 当中通常是更易于维护的做法,而使用 Kbuild 组织编译结构则是更加优雅的一种方式
下面是一个最基础的 Kbuild 文件的示例,语法上有点类似于 Makefile:
1 |
|
各符号说明如下:
MODULE_NAME
:一个简单的自定义变量,我们用来定义我们的模块名obj-m
:这个符号用来指定要被编译的内核模块列表,+=
意味着添加上我们的内核模块,而$(MODULE_NAME).o
则是我们的内核模块编译的后期产物,这通常由单个或多个目标文件合并而成,最后会被链接为$(MODULE_NAME).ko
文件,也就是我们所熟悉的 LKM ELF如果要将模块编译进内核 ELF 文件(vmlinux)中,则应当使用
obj-y
ccflags-y
:ccflags
意味着编译选项,-y
意味着开启的编译选项,这里我们添加了-I
选项以引入我们自己的头文件目录,更多编译选项可以参见 GCC 的文档$(MODULE_NAME)-y
:$(MODULE_NAME).o
所需要的目标文件,-y
意味着编译过程需要该文件,这里我们加入了一个main.o
,意味着我们的源码目录下应当有一个main.c
注意到这里有很多的 -字符
,这个设定其实是为了让我们灵活地配置编译选项,例如我们可以将 -y
替换为 -$(某个变量名)
,从而在该变量为 y
的时候才包含该选项,例如以下示例意味着在选项配置中包含 CONFIG_X86_64=y
时才会在模块中编译入 x86_64_tools.c
:
1 |
|
这同样适用于对内核模块的配置,对于树内模块,我们可以在 .config
文件当中配置某个指定变量的值为 =y
或 =m
,从而决定将该模块编译进内核或是作为独立的 ELF 存在,例如以下示例意味着这个模块是否编译进内核由变量 CONFIG_MY_A3KMODULE
的值决定:
1 |
|
多级目录的多级 Kbuild 组织
鉴于绝大部分的内核模块都是各种不同类型设备的驱动,他们通常都只有单级结构,但不乏有一些场景会需要结构设计较为复杂的内核模块,如果把所有源码文件都堆到同一级子目录下未免不太雅观,因此根据代码架构设计拆分到不同的多级目录中是较为常见的做法,而文件一多起来再将所有编译配置选项都放到同一个 Kbuild 或是 Makefile 当中对维护者而言自然是一种地狱般的体验(例如应该不太会有人想去细看这个文件)
因此拆分成多级 Kbuild 文件在软件工程中通常是比较恰当的做法,我们以如下代码目录作为示例讲解:
1 |
|
首先是 src/sub/sub_sub
目录,我们使用 Kbuild
文件配置这个子目录下需要编译的文件,这里简单编写如下,需要注意的是这里应当和源码根目录下的 Kbuild 一样以源码根目录文件作为相对根目录(后面我们会解释为什么):
1 |
|
在 src/sub/sub_sub/sub_sub.c
当中我们简单定义一个打印函数:
1 |
|
然后是 src/sub
目录,我们使用 Kbuild
文件配置这个子目录下需要编译的文件,同样是以源码根目录文件作为相对根目录来包含入咱们的 sub.c
源码文件,但在此之外我们额外地使用 include
语句将 sub_sub 目录的 Kbuild 文件包含到该文件当中:
1 |
|
在 src/sub/sub.c
当中我们同样简单定义一个打印函数:
1 |
|
现在大家应该明白为什么以源码根目录文件作为相对根目录,因为我们最终要在源码根目录下的 Kbuild
文件当中 include
下一级子目录的 Kbuild
文件,通过多级包含的方式完成多级 Kbuild 的配置 ,根目录下的 Kbuild 便仅需要包含次一级子目录的 Kbuild 文件,而无需再去管理更细的细节:
1 |
|
使用 Kconfig 配置动态编译选项
现在我们来看 Kconfig ——内核的编译选项配置系统,在我们编译内核时通常需要先 make menuconfig
创建配置文件 .config
,其中便包含了配置选项,这些选项会被 Kconfig 系统解析给编译器,通过宏定义的方式作用于源代码,例如下面这个配置决定了是否启用 procfs 文件系统:
1 |
|
在内核或是驱动源码中便可以根据是否定义了 CONFIG_PROC_FS
宏来决定是否将 procfs 相关的代码编译进来,以 arch/arm/kernel/dma.c
为例:
1 |
|
Kconfig 配置文件则通过 Kconfig 源文件进行生成,这类文件的语法定义参见 Kconfig Language ,下面是一个来自 Nornir-Rootkit 项目的简单的例子,其定义了一个编译选项 CONFIG_NORNIR_PROCFS_UAPI
,默认值为 =y
:
1 |
|
在实际工程中的 Kconfig 文件通常更加复杂,包括多级的选项菜单定义,这通常是为了实现一个比较友好的用户菜单,这里我们依旧以 Nornir-Rootkit 项目为例,其基于 kconfig-frontends 项目(从内核 Kconfig 系统中拆分出来的独立项目,不过已年久失修)的图形化 Kconfig 的配置界面如下:
注:虽然 fork 出来的 kconfig-frontends 项目已经年久失修,但我们依然能用一些其他的开源 Kconfig 支持项目(或是自己再从内核里拆分一下)来为我们的代码使用 Kconfig 进行配置
当 Kconfig 系统完成 Kconfig 配置文件的解读与设定之后,通常会生成一份正式的配置文件,其中给出了各个配置的值,对于 Linux kernel 而言这通常是源码目录下的 .config
文件,以 IRQ 子系统为例:
1 |
|
下面我们改进一下上面的的代码结构,添加一个我们自己的 Kconfig 文件:
1 |
|
在这个 Kconfig 当中,我们配置一个二级菜单,以及两个编译配置选项:
1 |
|
下面我们使用 kconfiglig 来演示配置文件的生成,对于上面的配置文件我们有这样一个简单的二级菜单入口,其中第二级菜单依赖于第一级菜单的开启:
我们控制第一级菜单开启,而第二级菜单不开启,生成的 .config
文件如下:
1 |
|
和前面类似,我们通过文件包含的形式将生成的 .config
配置文件纳入到我们的编译体系当中,我们将 src/Kbuild
修改如下:
1 |
|
在 src/sub/Kbuild
当中,我们通过 $(模块名)-(选项名)
的方式 控制源文件的编译 ,通过 ccflags-$(选项名) += -D选项名
的方式来在编译过程中动态定义该宏 :
1 |
|
我们将 src/main.c
修改如下,以判定是否引入对应函数:
1 |
|
对于 src/sub/sub_sub/Kbuild
我们同样进行这样的修改:
1 |
|
我们将 src/sub/sub.c
修改为如下,以判定是否引入该函数:
1 |
|
编译载入,可以看到确实没有调用二级目录的 foo_sub_sub()
函数:
重新配置 .config
,开启 CONFIG_ENANLE_SUB_SUB
选项:
1 |
|
重新编译载入,可以看到二级目录的 foo_sub_sub()
函数被引入并调用:
灵活使用 Kbuild 与 Kconfig,我们便能比较优雅地编写一个内核模块:)
0x02. 使用 DKMS 为开源内核模块添加自动升级
内核模块需要针对当前内核版本进行编译,而内核版本的升级不一定会带着树外的内核模块一起重编译,从而导致内核版本与模块版本的不匹配而使得内核模块无法被载入(例如过去很多 Ubuntu 用户老生常谈的 NVIDIA 显卡驱动在内核升级之后就掉了的问题)
笔者自己是有在 Gentoo 上遇到过这个问题,安装 DKMS 之前每次编译安装完内核之后还要手动重新安装一次 NVIDIA 显卡驱动
不过 NVIDIA 仅为开源驱动提供了 DKMS 支持,对于闭源驱动而言仍旧需要在每次升级内核后手动安装,而开源驱动目前缺少了一部分电源管理相关的模块, 这会使得笔者的笔记本无法正常地进行休眠 ,因此笔者只好继续驻留在 NVIDIA 闭源驱动上,保持每次更新内核后都重新安装 NVIDIA 闭源驱动的习惯(虽然一行
emerge
就解决了)……
由 Dell 公司开发的 Dynamic Kernel Module System 尝试通过自动化地重编译内核模块来解决这个问题,作为一个构建安装内核模块的框架,其支持通过一些钩子以在内核更新后自动检测与重编译一些内核模块并安装,同时还为用户提供了一个简易的管理界面
DKMS 对内核模块的管理过程如下图状态机所示,这实质上代表了 DKMS 支持的五个命令:
- add:将树外模块添加到树中,模块状态变为
Added
- build:构建内核模块,模块状态变为
Built
- install:安装内核模块,模块状态变为
Installed
- uninstall:卸载内核模块,模块状态变回
Built
- remove:从树中移除内核模块,模块状态变回
Not in tree
基本的 DKMS 使用与配置文件编写
要使用 DKMS 管理我们的内核模块,我们首先要准备一份这样的 dkms.conf
文件,这里还是以我们前面的内核模块为例,变量字面含义都比较明显所以笔者这里就不再赘述了:
1 |
|
要使用 DKMS,首先我们的内核模块源码 应当位于 /usr/src 目录下,且格式应当为 /usr/src/<PACKAGE_NAME>-<PACKAGE_VERSION>
,因此我们的测试模块应当放在 /usr/src/a3kmod-1.1.4
目录下:
这里的
.config
文件你可以选择准备一份默认配置,而不需要再手动选择生成选项
1 |
|
接下来将我们的内核模块添加到 DKMS 追踪列表当中,从而在后续能够自动更新:
1 |
|
然后开始构建内核模块,需要注意的是在此阶段我们尚未为内核模块添加签名支持:
1 |
|
完成构建之后进行安装:
1 |
|
此时我们的测试模块 a3kmod
就进入到树中了,我们可以使用 dkms status
命令查看 dkms 管理的内核模块:
1 |
|
不过此时我们的内核模块尚未装载到内核当中,我们可以像其他模块那样使用 modprobe
来手动地即时 insmod
:
1 |
|
简单测试一下,可以发现确实被成功载入:
不过重启后我们的内核模块并不会被自动载入,因为这不属于 DKMS 的职能范围,我们若是需要这个功能,则可以在
/etc/modules-load.d/
中新建一个a3kmod.conf
文件,并添加我们的内核模块名:
1a3kmod
此时再重启便能看到我们的内核模块被自动载入了:
接下来我们测试一下内核更新后的自动编译,我们编译一个新版本的内核并安装,之后重启:
1 |
|
在 make install
的日志中我们可以看到 dkms 的输出内容(这里忘了截图了被后面的输出覆盖了),重启计算机,可以看到我们的内核模块成功地在新内核上被载入,而无需我们再手动进行编译:
为内核模块添加签名支持(🕊)
打包一个支持 DKMS 的内核模块的软件包(🕊)
按各大发行版要求去打成对应的格式就行,没什么好说的:)