引言
在上一章中,我们学习了如何从源码配置 GNU/Linux 内核,编译它,并执行内核程序。在本章中,我们将聚焦于内核空间和用户空间,深入探讨驱动程序的各个具体细节,开始自己动手实现驱动程序。这一章是入门驱动开发的关键章节。
结构
本章涵盖以下内容:
- 用户空间、内核空间及系统调用接口
- 驱动程序的内核内编译与内核外编译
- 在 Buildroot 环境中的编译
- 在 Yocto 环境中的编译
- 内核设施及辅助函数
- 错误处理
- 模块安装
- 动态模块的加载与卸载
- 模块依赖关系
- 模块参数
- 模块许可
- 模块日志
- 动态内核模块支持
目标
本章的主要目标是帮助你更好地理解和掌握用户空间与内核空间之间的各种交互。同时,我们将为全书的驱动编写打下坚实基础。通过本章学习,你将能够编写简单的驱动程序,完成编译,并将其加载到 GNU/Linux 内核中。
用户空间、内核空间及系统调用接口
Linux 系统主要划分为非特权的用户空间和内核空间。这两部分通过一种被称为接口的系统进行交互,这是一套预定义且成熟的接口,供用户空间应用程序访问 Linux 内核。
下图将帮助你基本理解用户空间与内核空间的层次划分:
以下是对各层的描述:
-
用户空间(Userspace):
- 用户空间指计算机内存和处理能力中运行用户级应用程序和进程的部分。
- 用户级应用程序包括文本编辑器、网页浏览器、媒体播放器,以及普通用户直接使用的任何软件。
- 用户空间应用对硬件和系统资源的访问受到限制,它们依赖操作系统内核执行特权操作,比如对硬件设备的读写或内存管理。
-
内核空间(Kernelspace):
- 内核空间是计算机内存和处理能力中运行操作系统内核的部分。
- 内核是操作系统的核心组件,负责管理系统资源、调度任务、控制硬件设备并执行安全策略。
- 内核空间被视为特权区域,具有对硬件的直接访问权限。内核负责处理硬件中断和系统调用,是操作系统的重要组成部分。
-
系统调用接口(System call interface):
- 系统调用接口(SCI)是一组函数和机制,允许用户空间的应用程序请求内核空间内核提供的服务。
- 系统调用是应用与内核交互的标准化方式。它们搭建起用户空间和内核空间之间的桥梁,使用户级应用能够执行需要特权操作的任务,如文件读写、进程管理和硬件访问。
- 常见的系统调用有 open()/close()、read()、write()、fork()、exec()、exit() 等。
- 用户级应用通过操作系统提供的特定库和语言构造发起系统调用。例如在 C 语言中,可以使用 open()、read()、write() 等函数进行系统调用。
系统调用的典型执行流程如下:
- 用户级应用调用由操作系统库(如 C 标准库)提供的函数发起系统调用。
- 库函数准备好参数并触发软件中断(通常使用汇编指令)。
- CPU 从用户空间切换到内核空间,开始执行内核代码。
- 内核处理系统调用,执行请求的操作,并将结果返回给用户级应用。
- CPU 切换回用户空间,用户级应用继续执行。
下图展示了调用 C 标准库函数 fwrite(来自 libc 或 glibc 库)时涉及的步骤顺序:
以下是该过程的步骤说明:
- 在 test.c 源代码中调用了 fwrite() 函数(来自标准 C 库),该函数由 glibc 实现,glibc 是 Linux 的主要组成部分之一。
- fwrite() 是对 write() 函数的封装。
- write() 函数将 sys_write() 系统调用的标识符及其参数加载到处理器寄存器中,然后执行上下文切换,将控制权交给内核。
- 执行方式取决于处理器架构,有时还会依赖具体的处理器型号。例如,x86 处理器通常使用中断 80h,而 x64 处理器则使用 syscall 指令。处理器切换到内核空间后,将系统调用 ID 传递给系统调用表,调用对应的 sys_write() 函数。
用户空间、内核空间和系统调用接口是 GNU/Linux 操作系统架构中至关重要的组成部分,它们确保系统能够在为应用程序提供安全受控的运行环境的同时,高效管理系统资源和硬件访问。
Linux 的系统调用接口(SCI)在很大程度上遵循 POSIX 标准,但也包含一些 Linux 特有的变体和扩展。POSIX(可移植操作系统接口)是一套规范,定义了类 Unix 操作系统的一组系统接口、库和编程标准,旨在保证应用程序在不同 Unix 平台之间的可移植性。
作为类 Unix 操作系统,Linux 基于 POSIX 原则和标准设计。因此,Linux 中许多函数和系统调用符合 POSIX 规范,这意味着根据 POSIX 标准开发的应用程序通常可以兼容 Linux 和其他类 Unix 系统。
不过,需要注意的是,Linux 还包含一些 POSIX 未覆盖的特定扩展和功能。应用开发者应了解这些差异,并在某些情况下调整应用程序以利用 Linux 特有的功能。
驱动程序也不例外,它们通过系统调用与二进制接口交互。内核本身不会执行任何操作而使系统成为操作系统;它需要进程,因此系统调用接口被用作标准的通信接口。
所有驱动程序都包含两个回调函数。第一个回调 init_module() 在驱动加载时调用,用于初始化和预留资源;第二个回调 cleanup_module() 在卸载时调用,用于清理和释放资源。
下图详细展示了内核模块动态加载到 GNU/Linux 内核的过程,显示了驱动程序的输入和输出挂钩(hook):
入门示例,我们将使用下面这段简单的代码作为“Hello World”内核模块:
/*
* 最简单的 Hello World 内核模块
*/
#include <linux/init.h> /* 宏定义需要 */
#include <linux/module.h> /* 所有模块都需要 */
#include <linux/printk.h> /* 需要使用 pr_info() */
static int __init hello_init(void)
{
pr_info("Hello, world\n");
return 0;
}
static void __exit hello_exit(void)
{
pr_info("Goodbye, world\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
驱动加载时执行的初始化回调用于登记和预留驱动正常工作所需的资源(能力)。相反,卸载驱动时的清理回调用于释放所有不再需要的资源。
我们将基于这段代码框架,重点讲解本章中的各个关键点。
驱动程序在内核内或内核外的编译
现在我们已经有了一个极简的驱动程序,接下来要编译它,方便我们进行测试。
为此,有几种方法,取决于代码是否已经包含在 Linux 内核源码中。在第2章《Linux 内核简介》中,我们已经学习了如何获取 GNU/Linux 内核源码,这对我们现在很有帮助,因为项目源码根目录下的 Makefile 是驱动生成的核心。
在 GNU/Linux 内核中编译
目标是定制 GNU/Linux 内核源码,添加一个条目,这个条目可以通过命令 make menuconfig
查看。
以下是在 drivers/helloworld/
目录下需要的文件,当然其中包括 helloworld.c
:
drivers/helloworld/
├── helloworld.c
├── Kconfig
└── Makefile
所有驱动程序都位于 Linux 内核源码的 drivers/
目录下。此时,我们需要进入源码根目录。
首先,需要在 drivers/Makefile
文件末尾添加如下行,让源码识别我们的驱动:
obj-$(CONFIG_HELLOWORLD) += helloworld/
接着,编辑 drivers/Kconfig
文件,在 endmenu
行之前添加:
source "drivers/helloworld/Kconfig"
然后,我们创建一个新的目录,用来存放我们的 hello world 驱动:
$ mkdir -p drivers/helloworld/
创建并编辑 drivers/helloworld/Kconfig
文件,内容如下:
menu "HELLOWORLD support"
config HELLOWORLD
tristate "HELLOWORLD Support"
default m
help
Say Y to enable HELLOWORLD support, M to compile it as a module
endmenu
最后,创建 drivers/helloworld/Makefile
文件,内容如下:
obj-${CONFIG_HELLOWORLD} := helloworld.o
至此,我们就可以(重新)配置内核,使用对应当前开发机内核的 .config
文件(或重新加载配置)。确认配置界面中能看到新驱动,并且可以选择以模块方式编译。
进入设备驱动菜单,向下找到我们的驱动选项:
$ make menuconfig
下图显示了 GNU/Linux 内核配置步骤中,模块选择的可能性。箭头指向子菜单部分:
在 drivers/helloworld/Makefile
文件中,我们使用了 “tristate”(三态)选项,具有以下几种状态:
- 状态1:未选择(Not selected)。
- 状态2 或 M:作为驱动模块编译,这将生成一个
.ko
文件。 - 状态3 或 *:编译进 Linux 内核的静态部分。
我们可以通过空格键来切换状态。
下图展示了我们子菜单中的这一三态选择部分(如前所述),如果此处保持为空,则该模块将不会被编译:
下图显示了我们驱动被选择为动态模块(M)的情况:
下图展示了我们的驱动被选择编译进内核静态部分的情况:
HELLOWORLD 驱动程序应出现在设备驱动列表的末尾:
$ cat .config | grep -i helloworld
# HELLOWORLD support
CONFIG_HELLOWORLD=m
# end of HELLOWORLD support
因此,我们可以开始编译该模块:
$ make
[...]
LD drivers/helloworld/builtin.o
CC [M] drivers/helloworld/helloworld.o
MODPOST vmlinux
Building modules, stage 2.
MODPOST 1 modules
CC drivers/helloworld/helloworld.mod.o
注意:有时内核需要先准备好模块编译环境,执行:
$ make modules_prepare
以这种方式添加驱动程序非常少见,因为驱动代码已经存在于内核源码中,会受到 GNU/Linux 内核 GPL 许可证的约束!
这也是大多数情况下驱动都是通过源码外部的 Makefile 进行编译的原因。
接下来我们将学习另一种外部编译的方法,即将驱动源码放置在内核源码之外进行编译。
GNU/Linux 内核外的编译
与之前的编译不同的是,我们驱动的源码不是 GNU/Linux 内核源码。因此我们需要一个 Makefile,内容是远程调用位于内核源码根目录的 Makefile:
我们创建一个名为 helloworld
的目录,包含以下两个文件:
helloworld
├── helloworld.c :我们的驱动源码
└── Makefile :用于构建/安装驱动的主文件
我们的 Makefile 内容如下:
obj-m := helloworld.o
KERNEL_DIRECTORY := /lib/modules/$(shell uname -r)/build
all:
make -C $(KERNEL_DIRECTORY) M=$(shell pwd) modules
modinfo ./helloworld.ko
load:
insmod ./helloworld.ko
dmesg
unload:
rmmod ./helloworld.ko
dmesg
install:
make M=$(shell pwd) -C $(KERNEL_DIRECTORY) modules_install
/sbin/depmod -a
clean:
make M=$(shell pwd) -C $(KERNEL_DIRECTORY) clean
这一步骤中,你必须安装了对应 Linux 内核的源码,且 KERNEL_DIRECTORY
变量指向该源码路径!
我们开始构建驱动:
$ make
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
make[1]: Entering directory '/usr/src/linux-headers-6.2.0-35-generic'
CC [M] /path/to/helloworld/helloworld.o
MODPOST /path/to/helloworld/Module.symvers
CC [M] /path/to/helloworld/helloworld.mod.o
LD [M] /path/to/helloworld/helloworld.ko
make[1]: Leaving directory '/usr/src/linux-headers-6.2.0-35-generic'
构建成功后,应生成如下文件:
$ ls -al
total 344
drwxrwxr-x 2 user user 4096 Oct 23 19:54 .
drwxrwxr-x 10 user user 4096 Oct 23 19:54 ..
-rw-rw-r-- 1 user user 436 Oct 23 19:35 helloworld.c
-rw-rw-r-- 1 user user 109288 Oct 23 19:54 helloworld.ko
-rw-rw-r-- 1 user user 497 Oct 23 19:54 .helloworld.ko.cmd
-rw-rw-r-- 1 user user 100 Oct 23 19:54 helloworld.mod
-rw-rw-r-- 1 user user 891 Oct 23 19:54 helloworld.mod.c
-rw-rw-r-- 1 user user 364 Oct 23 19:54 .helloworld.mod.cmd
-rw-rw-r-- 1 user user 93368 Oct 23 19:54 helloworld.mod.o
-rw-rw-r-- 1 user user 39157 Oct 23 19:54 .helloworld.mod.o.cmd
-rw-rw-r-- 1 user user 17336 Oct 23 19:54 helloworld.o
-rw-rw-r-- 1 user user 38181 Oct 23 19:54 .helloworld.o.cmd
-rw-rw-r-- 1 user user 326 Oct 23 19:31 Makefile
-rw-rw-r-- 1 user user 100 Oct 23 19:54 modules.order
-rw-rw-r-- 1 user user 326 Oct 23 19:54 .modules.order.cmd
-rw-rw-r-- 1 user user 0 Oct 23 19:54 Module.symvers
-rw-rw-r-- 1 user user 371 Oct 23 19:54 .Module.symvers.cmd
我们可以检查新生成的 helloworld 驱动:
$ file helloworld.ko
helloworld.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=..., with debug_info, not stripped
$ modinfo helloworld.ko
filename: /path/to/helloworld/helloworld.ko
license: GPL
srcversion: 80158CC88F1748420B63317
depends:
retpoline: Y
name: helloworld
vermagic: 6.2.0-35-generic SMP preempt mod_unload modversions
由于使用了 uname -r
,驱动与内核版本高度匹配,因此可以动态加载:
$ insmod ./helloworld.ko
检查模块是否加载成功:
$ cat /proc/modules | grep -i helloworld
helloworld 16384 0 - Live 0x0000000000000000 (OE)
# 或者
$ lsmod | grep -i helloworld
helloworld 16384 0
卸载模块:
$ rmmod ./helloworld.ko
内核日志将显示驱动的输出信息:
$ dmesg -w
[20429.883271] Hello, world
[20624.543701] Goodbye, world
我们现在拥有了 helloworld 驱动,未来可以持续迭代。
如果需要将源码拆分成多个文件(如 helloworld.c
, start.c
, stop.c
),需要修改上述 Makefile,如下所示:
obj-m += helloworld.o
obj-m += startstop.o
startstop-objs := start.o stop.o
PWD := $(CURDIR)
all:
make M=$(PWD) -C /lib/modules/$(shell uname -r)/build modules
clean:
make M=$(PWD) -C /lib/modules/$(shell uname -r)/build clean
针对嵌入式领域的使用,我们还会简要讨论在 Buildroot 或 Yocto 环境中编译驱动。构建系统种类繁多,无法一一详述,但上述两种是最常见的。
在 Buildroot 项目中编译
在你的 Buildroot 项目中,在 package
目录下创建 helloworld
目录:
package/
└── helloworld
├── Config.in
├── helloworld.mk
└── helloworld.c
将上面的 helloworld.c
源代码放入该目录。
在该目录中创建一个 Config.in
文件。该文件用于配置内核驱动包,并在 Buildroot 配置中启用它:
config BR2_PACKAGE_HELLOWORLD
bool "Helloworld driver"
depends on BR2_PACKAGE_HELLOWORLD_LOADABLE
help
My custom kernel driver
config BR2_PACKAGE_HELLOWORLD_LOADABLE
bool "Enable helloworld Driver"
default y
help
Enable building and loading of helloworld Driver
创建一个 helloworld.mk
Makefile,定义如何构建和安装你的内核驱动。该 Makefile 应指定包名、源码位置、构建指令及其他相关信息:
HELLOWORLD_VERSION = 1.0
HELLOWORLD_SOURCE = $(TOPDIR)/path/to/your/source
HELLOWORLD_SITE = file://$(HELLOWORLD_SOURCE)
HELLOWORLD_INSTALL_TARGET = YES
$(eval $(kernel-module))
根据你的内核驱动实际情况,调整 HELLOWORLD_VERSION
、HELLOWORLD_SOURCE
等变量。
$(eval $(kernel-module))
这一行用于告诉 Buildroot 将其作为内核模块进行编译。
要将自定义内核驱动包包含进 Buildroot 构建,在 Buildroot 配置中(通常位于 configs/
目录下)找到 “Target packages” 一节,启用你的包。你可以通过 menuconfig
界面操作,也可以直接编辑 .config
文件。
配置完成后,使用 make
命令构建 Buildroot 项目:
$ make
Buildroot 构建完成后,你可以在输出目录中找到你的内核驱动模块,根据需要安装并加载到目标设备上。
在 Yocto 项目中编译
Yocto 的配方(recipes)通常组织在不同的 layer 中。你需要进入你想创建配方的 Yocto layer。meta 目录通常用来管理 Yocto 层。
在你的 layer 目录下,创建一个用来放置内核驱动配方的目录,目录结构如下:
meta-yourlayer/
├── recipes-kernel/
│ ├── helloworld/
│ │ ├── helloworld.bb
│ │ └── helloworld.c
在 helloworld
目录下,创建 helloworld.bb
配方文件,用于定义内核驱动的构建规则。.bb
文件内容示例如下:
DESCRIPTION = "Helloworld Driver"
LICENSE = "CLOSED" # 指定合适的许可证
SRC_URI = "file://path/to/your/source.tar.gz"
S = "${WORKDIR}"
# 定义构建依赖
DEPENDS = "linux-yocto"
# 指定目标内核版本
COMPATIBLE_MACHINE = "your-target-machine"
do_compile() {
# 在这里添加构建指令
}
do_install() {
# 如有需要,定义安装指令
}
FILES_${PN} += "${libdir}/modules/${KERNEL_VERSION}/extra/*"
Yocto 使用模板机制,只需定制部分变量,例如驱动源码的 git 仓库地址、许可证、依赖等:
- DESCRIPTION:内核驱动的简短描述。
- LICENSE:指定适用的许可证。
- SRC_URI:指向驱动源码的 URI。
- DEPENDS:构建时的依赖,这里示例中依赖
linux-yocto
。 - COMPATIBLE_MACHINE:指定驱动目标设备。
- do_compile() :编写针对该驱动的编译指令。
- do_install() :如需安装操作,定义安装步骤。
- FILES_${PN} :指定内核模块文件的安装路径。
要将自定义驱动包含进 Yocto 镜像,需要将它添加到镜像配方中。你可以在 Yocto 项目的镜像配方文件中修改,示例如下:
IMAGE_INSTALL_append = " helloworld"
这行代码告诉 Yocto 将 helloworld
包包含进镜像。
定义好驱动配方并添加至镜像后,使用 bitbake
命令构建 Yocto 镜像:
$ bitbake your-image
构建完成后,你将在 Yocto 配置指定的部署目录中找到内核驱动模块。根据需要,将其部署并加载到目标设备上。
内核设施与辅助函数
Linux 内核中的内核设施和辅助函数是驱动开发及底层系统操作的关键组成部分。
这些设施和函数提供了多种服务和抽象,用于管理硬件、执行常见任务以及与内核核心进行交互。以下是一些主要的内核设施和辅助函数:
-
内核模块(可加载内核模块):
module_init()
和module_exit()
:用于指定内核模块的初始化和清理函数。EXPORT_SYMBOL()
和EXPORT_SYMBOL_GPL()
:宏,用于导出模块中的符号供其他模块使用。
-
内核日志:
printk()
:内核日志打印的主要函数。pr_<level>()
:按不同日志级别打印的宏,如pr_err()
、pr_info()
等。
-
内存分配与释放:
kmalloc()
、kzalloc()
、kcalloc()
和kfree()
:动态分配和释放内存的函数。vmalloc()
、vfree()
:用于从内核虚拟内存池分配和释放大块内存。
-
设备注册与管理:
alloc_chrdev_region()
:分配一段字符设备号。cdev_init()
和cdev_add()
:初始化并添加字符设备到内核。register_chrdev_region()
:注册一段字符设备号。
-
内核同步与锁机制:
spin_lock()
和spin_unlock()
:实现自旋锁的函数。mutex_init()
、mutex_lock()
和mutex_unlock()
:互斥锁相关函数。semaphore_init()
、down()
和up()
:信号量相关函数。atomic_t
及相关原子操作:对变量进行原子操作的工具。
-
文件操作:
file_operations
结构体:包含处理文件操作(如打开、读取、写入、关闭等)的函数指针。
-
内核定时器:
init_timer()
和add_timer()
:定时器相关函数。jiffies
:表示系统启动以来计时滴答数的变量。
-
工作队列与工作项:
queue_work()
、queue_delayed_work()
及相关函数:用于延迟执行工作。struct work_struct
:表示工作项的数据结构。
-
中断处理:
request_irq()
和free_irq()
:申请和释放中断处理函数。irqreturn_t
及相关类型:中断处理函数使用的返回类型。atomic_t
及atomic_inc()
/atomic_dec()
:中断上下文中管理共享数据的原子操作。
-
电源管理:
pm_runtime_get()
、pm_runtime_put()
及相关函数:管理设备电源状态。
-
设备树:
of_get_property()
:从设备树中获取属性。of_platform_populate()
:基于设备树信息填充平台总线设备。
-
其他辅助函数:
Linux 内核库中还包含了字符串操作、链表管理、位操作等多种辅助函数。
这些内核设施和辅助函数在内核驱动开发及内核各项功能实现中发挥着至关重要的作用。它们为与内核核心交互及硬件设备管理提供了标准化方式,确保不同硬件平台上的稳定性和兼容性。
错误处理
在 Linux 驱动中处理错误对于维护系统稳定性以及确保硬件或软件组件正常运行至关重要。
错误处理可能较为复杂,取决于驱动的具体上下文。以下是 Linux 驱动错误处理的一般指导原则:
-
返回值:在 C 语言代码中,通常使用返回值来指示错误。可能失败的函数一般返回错误码或负值。标准约定是返回 0 表示成功,负值表示失败,不同的负错误码表示具体的错误类型。
-
错误码:使用预定义的错误码,使错误处理更明确、有意义。在 Linux 内核中,错误码定义于如
errno.h
等头文件中,可在驱动代码中使用。 -
记录错误日志:当发生错误时,使用
pr_err()
、dev_err()
或printk()
等函数将错误详情写入内核日志,方便系统管理员和开发者诊断问题。
示例:if (error_condition) { pr_err("错误信息: %s\n", error_description); return -ENODEV; // 返回合适的错误码 }
-
资源清理:如果驱动已分配资源(内存、I/O 端口等),出错时必须释放这些资源,如释放内存、注销设备或撤销已做的初始化。
-
返回合适的错误码:确保返回的错误码能够准确指示具体错误情况。Linux 提供丰富的错误码,如
-ENOMEM
表示内存不足,-EIO
表示 I/O 错误。选择最合适的错误码描述问题。 -
错误恢复:考虑驱动是否能从某些错误中恢复。例如,硬件通信出错时,可能尝试重置设备或进行优雅恢复。
-
避免内核崩溃:尽最大努力避免内核崩溃(panic)。如果错误严重到系统无法安全继续运行,通常更好的做法是卸载驱动或禁用硬件,而非导致内核崩溃。
-
测试与验证:在各种条件下严格测试驱动,确保正确处理错误,包括负面测试用例以触发错误情形。
-
文档:记录驱动中使用的错误处理策略,便于其他开发者理解和维护代码。
-
用户友好信息:如果驱动向用户空间提供信息,确保错误消息清晰明了,能帮助用户了解发生了什么问题。
-
代码审查:邀请有经验的开发者审查代码,发现潜在错误场景或改进错误处理的建议。
-
使用调试工具:Linux 内核提供多种调试工具和框架(如 kprobes、kdump、kexec),有助于诊断和排查驱动问题。
内核驱动中的错误处理是内核开发的关键环节,理解预期的错误情形及应对方法对创建可靠稳定的驱动至关重要。
本主题将在第13章《调试 GNU/Linux 内核与驱动》中详细讨论。
模块安装
安装驱动模块通过 GNU/Linux 内核主 Makefile 中的 modules_install
目标完成:
$ make modules_install
depmod
命令用于生成模块依赖缓存,这对内核高效管理和动态加载/卸载模块至关重要。安装模块后使用 depmod
:
$ depmod -a
depmod
的主要功能包括:
- 依赖关系解析:
分析内核模块,确定它们对其他模块的依赖关系。
内核模块往往依赖其他模块才能正常工作。depmod
识别这些依赖关系(包括其他模块或符号),并建立数据库。 - 模块加载:
内核在使用modprobe
命令或系统启动时加载模块,会参考depmod
生成的依赖信息。
当请求加载某个模块时,内核会先检查依赖,确保所需模块已全部加载。 - 防止符号冲突:
内核模块可能定义并使用相同符号(函数或变量)。
depmod
维护符号使用记录,避免模块间符号名称冲突。 - 高效模块加载:
depmod
生成名为modules.dep
的文件,存储模块依赖信息,内核据此快速确定加载顺序。 - 更新模块依赖:
安装新模块、修改现有模块或更新内核时,需运行depmod
以保持依赖信息同步。
总结:depmod
在 Linux 系统中负责生成和维护模块依赖信息,确保内核及其模块正常工作,是模块管理与系统稳定的关键工具。
该命令会更新文件 /lib/modules/$(uname -r)/modules.dep
,此文件主要用于 modprobe
的加载与动态加载:
$ cat /lib/modules/$(uname -r)/modules.dep
updates/dkms/sysdig-probe.ko:
kernel/zfs/znvpair.ko: kernel/zfs/spl.ko
kernel/zfs/spl.ko:
misc/vboxnetadp.ko: misc/vboxdrv.ko
(…)
最重要的缓存文件通常是:
/lib/modules/$(uname -r)/modules.dep
:包含模块及其依赖列表。/lib/modules/$(uname -r)/modules.alias
:包含模块别名,允许模块通过不同名称加载。
动态模块的加载与卸载
在 Linux 下加载或卸载驱动有两种方法,一种是手动方式(insmod
/ rmmod
),另一种是自动方式(modprobe
),正如我们在安装时通过 depmod
命令看到的。
你可以使用 insmod
或 modprobe
命令来加载模块。手动加载模块时使用 insmod
,将 <module_name>
替换为你的模块文件名(不含 .ko
扩展名):
$ sudo insmod /path/to/your/module_name.ko
要手动卸载模块,使用 rmmod
命令,替换 <module_name>
为你想卸载的模块名:
$ sudo rmmod module_name
推荐使用 modprobe
命令来加载模块,因为它会处理模块依赖。如果模块有依赖项,modprobe
会自动加载它们,使用方法如下:
$ sudo modprobe module_name
要使用 modprobe
卸载模块,使用 -r
选项,并替换 <module_name>
为你想卸载的模块名:
$ sudo modprobe -r module_name
-r
选项告诉 modprobe
移除指定模块。
如果该模块有依赖关系,modprobe
也会卸载所有依赖它的模块。卸载模块时请务必小心,因为这可能影响系统的功能。
模块依赖关系
驱动程序常常通过导出有用且可复用的函数,为其他驱动提供功能支持。
一个驱动对另一个驱动的依赖,基于它们之间共享的符号。例如,我们接下来会看到一个模块导出一个符号(函数),另一个独立模块通过导入该符号来使用它。
为了集中导出和共享的函数,通常会使用一个 .h
头文件,统一声明它们的函数原型:
#ifndef EXPORT_SYMBOLE_H
#define EXPORT_SYMBOLE_H
void fonction_hello(int numero);
#endif
第一个导出驱动的代码是导出 fonction_hello
函数的驱动代码。注意其中包含了 export_api.h
头文件:
#include <linux/module.h>
#include "export_api.h"
static int __init export_chargement(void)
{
printk(KERN_ALERT "Hello, world\n");
return 0;
}
static void __exit export_dechargement(void)
{
printk(KERN_ALERT "Goodbye, cruel world\n");
}
void fonction_hello(int numero)
{
printk(KERN_INFO "Hello, le numero est %d\n", numero);
}
EXPORT_SYMBOL(fonction_hello);
MODULE_LICENSE("GPL");
module_init(export_chargement);
module_exit(export_dechargement);
第二个导入驱动的代码是导入 fonction_hello
函数的驱动代码,也使用了 export_api.h
头文件:
#include <linux/module.h>
#include "export_api.h"
static int __init import_chargement(void)
{
printk(KERN_ALERT "Hello, world\n");
fonction_hello(10); // 调用导入函数
return 0;
}
static void __exit import_dechargement(void)
{
fonction_hello(20); // 调用导入函数
printk(KERN_ALERT "Goodbye, cruel world\n");
}
module_init(import_chargement);
module_exit(import_dechargement);
MODULE_LICENSE("GPL");
编译阶段,会生成两个相互依赖的驱动模块:
$ make
make M=/home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies -C /lib/modules/6.2.0-35-generic/build modules
make[1]: Entering directory '/usr/src/linux-headers-6.2.0-35-generic'
warning: the compiler differs from the one used to build the kernel
The kernel was built by: x86_64-linux-gnu-gcc-11 (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
You are using: gcc-11 (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
CC [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/export.o
CC [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/import.o
MODPOST /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/Module.symvers
CC [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/export.mod.o
LD [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/export.ko
BTF [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/export.ko
Skipping BTF generation for /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/export.ko due to unavailability of vmlinux
CC [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/import.mod.o
LD [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/import.ko
BTF [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/import.ko
Skipping BTF generation for /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/import.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-6.2.0-35-generic'
查询导出模块信息:
modinfo ./export.ko
filename: /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/./export.ko
license: GPL
srcversion: FA57E7778C239436BA6A952
depends:
retpoline: Y
name: export
vermagic: 6.2.0-35-generic SMP preempt mod_unload modversions
查询导入模块信息:
modinfo ./import.ko
filename: /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_dependencies/./import.ko
license: GPL
srcversion: A6734BD1D5BFFB086A3B429
depends: export
retpoline: Y
name: import
vermagic: 6.2.0-35-generic SMP preempt mod_unload modversions
手动加载时,必须先加载导出函数的模块,再加载需要它的模块,否则加载会失败。
如果尝试不加载第一个模块而直接加载第二个,会看到类似如下错误:
$ sudo insmod import.ko
[sudo] password for thierryg:
insmod: error inserting 'import.ko': -1 Unknown symbol in module
先加载 export.ko
模块,再加载 import.ko
,则加载成功:
$ sudo insmod export.ko
可以通过以下命令检查内核中符号是否存在:
$ cat /proc/kallsyms | grep fonction_hello
f7c5709c r __ksymtab_fonction_hello [export]
f7c570a4 r __kstrtab_fonction_hello [export]
f7c57000 T fonction_hello [drv_export]
然后就可以成功加载第二个模块:
$ sudo insmod import.ko
卸载时,因 import.ko
使用了 export.ko
,所以不能先卸载 export.ko
:
$ sudo rmmod export.ko
会报错:“Module export is in use by import”。
正确的卸载顺序是先卸载第二个模块,再卸载第一个模块:
$ sudo rmmod import.ko
$ sudo rmmod export.ko
动态加载和卸载时,由于 depmod
生成了依赖文件,操作更加简便,自动完成:
动态加载:
$ sudo modprobe export
$ lsmod | grep import
$ lsmod | grep export
modprobe
根据 /lib/modules/$(uname -r)/modules.dep
中的依赖顺序,先加载 export.ko
,再加载 import.ko
。
卸载同样方便:
$ sudo modprobe -r export
modprobe
会先卸载 import.ko
,如果 export.ko
不再被使用,也会自动卸载它。
模块参数
驱动程序就像一个二进制文件,它可以管理一些参数,这些参数会影响驱动加载时的行为,并在初始化时使用。
下面是一个可以管理参数的驱动示例:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
MODULE_LICENSE("Dual BSD/GPL");
static char* whom = "world";
static int howmany = 1;
module_param(howmany, int, S_IRUGO);
module_param(whom, charp, S_IRUGO);
static int param_init(void)
{
int i;
for (i = 0; i <= howmany; i++)
{
printk(KERN_ALERT "(%d) Hello, %s\n", i, whom);
}
return 0;
}
static void param_exit(void)
{
printk(KERN_ALERT "Goodbye, cruel world\n");
}
module_init(param_init);
module_exit(param_exit);
使用之前的 Makefile 编译驱动,并用 modinfo
查看驱动参数:
$ make
make M=/home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters -C /lib/modules/6.2.0-35-generic/build modules
make[1]: Entering directory '/usr/src/linux-headers-6.2.0-35-generic'
warning: the compiler differs from the one used to build the kernel
The kernel was built by: x86_64-linux-gnu-gcc-11 (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
You are using: gcc-11 (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
CC [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/param.o
MODPOST /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/Module.symvers
CC [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/param.mod.o
LD [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/param.ko
BTF [M] /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/param.ko
Skipping BTF generation for /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/param.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-6.2.0-35-generic'
$ modinfo ./param.ko
filename: /home/tgayet/Documents/bpb/03-introduction-to-the-device-drivers-27-10-2023/code_3_parameters/./param.ko
license: Dual BSD/GPL
srcversion: 7A66826FBC8FB7CDB8F5059
depends:
retpoline: Y
name: param
vermagic: 6.2.0-35-generic SMP preempt mod_unload modversions
parm: howmany:int
parm: whom:charp
可以看到有两个参数:
$ tree /sys/module/param/parameters
/sys/module/param/parameters
├── howmany
└── whom
这两个参数是只读的,不能修改:
$ ls -alh /sys/module/param/parameters/
total 0
drwxr-xr-x 2 root root 0 oct. 26 21:51 .
drwxr-xr-x 6 root root 0 oct. 26 21:51 ..
-r--r--r-- 1 root root 4.0K oct. 26 21:51 howmany
-r--r--r-- 1 root root 4.0K oct. 26 21:51 whom
我们可以不带参数加载模块(使用默认值):
$ sudo insmod param.ko
$ lsmod | grep param
param 16384 0
$ dmesg
[ 7658.068510] (0) Hello, world
测试带参数的不同加载方式,记得每次测试后卸载驱动:
$ sudo rmmod param
不带参数或未指定参数时,驱动应使用默认值。
单参数加载示例:
$ sudo insmod param.ko howmany=5
$ dmesg
[ 7404.087814] (0) Hello, world
[ 7404.087821] (1) Hello, world
[ 7404.087822] (2) Hello, world
[ 7404.087823] (3) Hello, world
[ 7404.087824] (4) Hello, world
[ 7404.087825] (5) Hello, world
另一个参数示例:
$ sudo insmod param.ko whom=bpb
$ dmesg
[ 7470.763580] (0) Hello, bpb
两个参数一起:
$ sudo insmod param.ko howmany=10 whom=bpb
$ dmesg
[ 7600.566090] (0) Hello, bpb
[ 7600.566096] (1) Hello, bpb
[ 7600.566097] (2) Hello, bpb
[ 7600.566099] (3) Hello, bpb
[ 7600.566100] (4) Hello, bpb
[ 7600.566101] (5) Hello, bpb
[ 7600.566102] (6) Hello, bpb
[ 7600.566103] (7) Hello, bpb
[ 7600.566104] (8) Hello, bpb
[ 7600.566105] (9) Hello, bpb
[ 7600.566106] (10) Hello, bpb
也可以用 modprobe
动态加载带参数的模块:
$ sudo modprobe param howmany=10
如果使用了未知参数,则参数会被忽略:
$ sudo insmod ./param.ko wrongparam=45
$ dmesg
若加载时指定了错误的参数名,dmesg
会显示如下信息:
[ 7560.515786] param: unknown parameter wrongparam ignored
注意,加载动态模块需要管理员权限。
模块许可
指定模块的许可非常重要,以明确加载模块的权限,确保内核安全。事实上,当加载一个动态模块时,可能会看到如下提示:
$ sudo insmod xxxxxx.ko
loading out-of-tree module taints kernel.
module license 'unspecified' taints kernel.
内核提供了多种宏来指明模块的许可证类型,例如:GPL
、GPL v2
、GPL and additional rights
、Dual BSD/GPL
、Dual MIT/GPL
、Dual MPL/GPL
以及 Proprietary
等,这些宏定义在 include/linux/module.h
中。
可以使用 MODULE_LICENSE
宏来声明模块的许可证,例如:
#include <linux/init.h> /* 用于宏定义 */
#include <linux/module.h> /* 所有模块必需 */
#include <linux/printk.h> /* 用于 pr_info() */
MODULE_LICENSE("GPL");
MODULE_AUTHOR("bpb");
MODULE_DESCRIPTION("A sample driver with a GPL license");
static int __init init_hello(void)
{
pr_info("Hello, world\n");
return 0;
}
static void __exit cleanup_hello(void)
{
pr_info("Goodbye, world\n");
}
module_init(init_hello);
module_exit(cleanup_hello);
在 GNU/Linux 内核中,“taints”(污染)指的是内核被标记为“已污染”的状态。当内核被污染时,意味着内核遇到了一些可能影响其完整性、安全性或可支持性的情况。被污染的内核可能被认为是不可靠的,原因多种多样,每种原因都有对应的污染标记(taint flag),通常用一个或多个字母表示。常见的内核污染原因包括:
- 专有模块(Proprietary modules) :加载专有或闭源内核模块时,内核被标记为
P
。专有模块可能不符合开源原则,且可能导致问题或不稳定。 - 固件问题(Firmware issues) :内核遇到固件相关问题时,被标记为
F
。固件问题可能影响硬件兼容性和功能。 - 内核外模块(Out-of-tree modules) :非主线内核源码树中的模块,如第三方或自定义模块,会使内核被标记为
O
。这类模块测试不够充分,稳定性可能不足。 - 强制加载(Forced load) :
X
标记表示模块被强制加载,可能绕过了安全检查。 - 未签名模块(Unsigned modules) :加载未经数字签名的模块时,内核被标记为
U
。这存在安全隐患,未签名模块可能不可信。 - 硬件问题(Hardware issues) :硬件错误或问题导致内核被标记为
H
。硬件问题可能引起系统不稳定或异常行为。 - 用户空间问题(Userspace issues) :来自用户空间的错误(如无效系统调用或系统文件损坏)会使内核被标记为
A
,表示用户空间进程可能引发问题。 - GPL 违规(GPL violations) :加载违反 GNU 通用公共许可证(GPL)的模块时,内核被标记为
g
。
可以使用如 dmesg
或 cat /proc/sys/kernel/tainted
等工具定期检查内核污染状态。如果内核被污染,应调查原因并采取相应措施。
这些污染标记有助于诊断内核问题,判断潜在问题来源。但运行被污染的内核可能带来风险,在某些情况下,Linux 社区或厂商对被污染内核的支持会受到限制。
注意:虽然污染内核有助于识别问题,但一般建议使用开源模块并遵循最佳实践,以维护 Linux 内核的稳定性和安全性。
模块日志记录
最初,Linux 内核使用的是 printk
函数,通常会跟随一个优先级,比如 KERN_INFO
或 KERN_DEBUG
。近年来,为了避免长篇的字符输入并使代码更简洁,内核引入了一套打印宏,如 pr_info
和 pr_debug
。这些宏定义在头文件 #include <linux/printk.h>
中。
printk()
是 Linux 内核中最知名的函数之一,是打印消息的标准工具,也是最基础的跟踪和调试方法。
用法示例:
printk(LOGLEVEL "Message: %s\n", arg);
举例:
pr_info("Hello, world 4\n");
其中,LOGLEVEL
是日志级别。注意,日志级别是与格式字符串直接拼接的,不是单独的参数。
可用的日志级别如下:
日志级别宏 | 字符串 | 宏函数 |
---|---|---|
KERN_EMERG | "0" | pr_emerg() |
KERN_ALERT | "1" | pr_alert() |
KERN_CRIT | "2" | pr_crit() |
KERN_ERR | "3" | pr_err() |
KERN_WARNING | "4" | pr_warn() |
KERN_NOTICE | "5" | pr_notice() |
KERN_INFO | "6" | pr_info() |
KERN_DEBUG | "7" | pr_debug() 和 pr_devel()(当定义了 DEBUG 时) |
KERN_DEFAULT | "" | |
KERN_CONT | "c" | pr_cont() |
例如,内核的 printk.h
中定义了:
#define pr_info(fmt, arg...) \
printk(KERN_INFOfmt, ##arg)
pr_info()
就是优先级为 KERN_INFO
的 printk()
。
你可以通过过滤内核日志消息的严重性等级来配置 dmesg
命令的日志级别。日志级别由 klogd
服务管理,属于系统日志基础设施的一部分。
检查当前日志级别:
$ dmesg
(默认显示最严重的日志消息)
实时查看日志:
$ dmesg -w
使用 -n
选项临时设置日志级别,例如只显示警告级别(等级 4)及以上的消息:
$ dmesg -n 4
根据调试或监控需要调整日志级别。
有些内核设置需要允许非 root 用户执行此命令,可用:
$ sudo sysctl -w kernel.dmesg_restrict=0
kernel.printk
sysctl 配置控制 Linux 内核日志消息的详细程度。它包含四个数字值,分别代表不同的日志级别,这些值用于过滤并控制哪些消息显示在系统控制台和日志中。格式如下:
console_loglevel console_loglevel console_loglevel debug_loglevel
各部分可能的值:
-
console_loglevel(控制台消息日志级别) :设置显示在系统控制台的消息日志级别。有效值从 0 到 7,分别对应:
- 0: KERN_EMERG(紧急)
- 1: KERN_ALERT(警报)
- 2: KERN_CRIT(严重)
- 3: KERN_ERR(错误)
- 4: KERN_WARNING(警告)
- 5: KERN_NOTICE(通知)
- 6: KERN_INFO(信息)
- 7: KERN_DEBUG(调试)
-
console_loglevel(控制台日志缓冲区日志级别) :设置记录在控制台日志缓冲区中的消息日志级别,取值范围同上。
-
console_loglevel(控制台显示消息的日志级别) :设置将显示在控制台上的消息日志级别,取值范围同上。
-
debug_loglevel(调试日志级别) :设置调试消息的日志级别,取值范围同上。
动态内核模块支持(DKMS)
这一趋势日益显著。越来越多的厂商为其驱动程序(如 VirtualBox、ndiswrapper、lttng、fglrx 等)提供动态内核模块支持(DKMS),以确保在 GNU/Linux 内核版本以及驱动本身不断演进时,能够保证服务的连续性。
在 Fedora 及其衍生发行版(如 RedHat)中,通常使用 AutoKernel Modules(akmods),这与 DKMS 类似。但这里我们主要介绍更加跨平台且不依赖特定 Linux 发行版的 DKMS。
每当内核版本被包管理器更新时(如通过 RPM、DEB 包的后置安装动作),系统会自动触发相应动作。驱动更新时也会执行相同操作。
因此,驱动需要基于当前内核 API 重新编译,保证一定的一致性。
DKMS 由戴尔公司开发,主要使用 Bash 和 Perl 编写,旨在解决此类驱动兼容和持续编译的问题。它作为开源软件免费发布,遵循 GPLv2 许可协议。
DKMS 架构由以下部分组成:
-
模块源码目录:
/usr/src/<drv_name>-<version>/
-
内核模块树:
/lib/modules/$(uname -r)/
,DKMS 会替换或添加它管理的模块 -
DKMS 工作目录:
/var/lib/dkms
,包含:- 用于构建模块的目录(
/var/lib/dkms/<drv_name>/<version>/build/
) - 保存的原始模块
- DKMS 编译生成的模块
- 用于构建模块的目录(
下图展示了 DKMS 使用的重要路径:
DKMS 主要分为两部分:配置相关部分(位于 /etc/dkms
)和其内部工作部分(位于 /var/lib/dkms/*
)。
第二部分包括驱动源码、按 GNU/Linux 内核版本区分的头文件,以及最终构建出的动态模块。
安装
DKMS 已成为重要组件,且被官方打包进多个 GNU/Linux 发行版:
-
Debian/Ubuntu:
$ sudo apt-get install dkms dh-modaliases
-
OpenSUSE:
$ sudo zypper install dkms
-
Fedora Core:
$ sudo yum install dkms
-
官方 GIT 仓库源码:
$ git clone git://linux.dell.com/dkms.git
安装包的依赖通常包括:make
、linux-headers-generic
、linux-headers
。
DKMS 源码没有提供安装程序,文件分布在文件系统中各处,需手动复制文件,这种方法较为繁琐,不如使用发行版的包管理系统安装方便。
在软件仓库中可以看到支持 DKMS 的内核外驱动列表,且数量逐年增加。
配置
DKMS 核心配置文件为 /etc/dkms/framework.conf
,定义了 DKMS 使用的各路径:
## 该配置文件修改 DKMS 的行为,DKMS 每次运行时都会读取它。
## 源码树位置(默认:/usr/src)
source_tree="/usr/src"
## DKMS 工作目录(默认:/var/lib/dkms)
dkms_tree="/var/lib/dkms"
## 安装目录(默认:/lib/modules)
install_tree="/lib/modules"
## 临时目录(默认:/tmp)
tmp_location="/tmp"
## 详细日志设置(非零值激活详细模式)
# verbose="1"
支持 UEFI Secure Boot 的 DKMS
为了确保自定义内核模块签名有效,能在开启 Secure Boot 时加载,需按以下步骤操作:
- 安装依赖:
$ sudo apt install dkms openssl mokutil linux-headers-$(uname -r)
2. 检查是否启用 Secure Boot:
$ sudo mokutil --sb-state
SecureBoot enabled
若未启用,请先开启 Secure Boot。
- 创建密钥存放目录并进入:
$ mkdir -p /var/lib/dkms/mok
$ cd /var/lib/dkms/mok
4. 方案一:生成私钥和自签名证书:
$ openssl req -new -x509 -newkey rsa:2048 -keyout MOK.priv -outform DER -out MOK.der -nodes -days 36500 -subj "/CN=Custom DKMS Module Signing/"
生成:
MOK.priv
:签名内核模块的私钥。MOK.der
:DER 格式自签名证书,将用mokutil
导入 Secure Boot,并用于 DKMS 在构建后签名驱动。
- 方案二:使用公司证书机构(PKI)验证 DER 证书:
- 生成证书签名请求(CSR):
$ openssl req -new -newkey rsa:2048 -keyout MOK.priv -out MOK.csr -nodes -subj "/CN=Custom DKMS Module Signing/"
- 将 CSR 提交给公司 PKI CA 签名,例如使用本地 CA:
$ openssl x509 -req -in MOK.csr -CA CA.crt -CAkey CA.key -CAcreateserial -out MOK.pem -days 36500 -sha256
- 将签名证书转换为 DER 格式:
$ openssl x509 -in MOK.pem -outform DER -out MOK.der
6. 使证书生效:
$ sudo mokutil --enable-validation
此时会要求设置密码,重启后需再次输入。
- 导入密钥到 Machine Owner Key (MOK) 列表,供内核验证签名模块:
$ sudo mokutil --import MOK.der
验证导入是否成功:
$ sudo mokutil --list-new
根据提示设置密码,重启后确认密钥注册。
- 重启系统并完成密钥注册,按屏幕指示操作,使用之前设置的密码。
可在 UEFI BIOS 中确认新密钥已导入:
$ sudo mokutil --list-enrolled | grep "Custom DKMS Module Signing"
9. 配置 DKMS 使用签名密钥,编辑或创建 /etc/dkms/framework.conf
,添加:
mok_signing_key=/var/lib/dkms/mok/MOK.priv
mok_certificate=/var/lib/dkms/mok/MOK.der
10. 验证模块是否已签名且可在 Secure Boot 下加载:
$ modinfo -F sig_id /lib/modules/$(uname -r)/updates/dkms/example-module.ko
若显示模块已签名,则签名成功。
之后即可正常加载/卸载动态模块:
$ modprobe example-module
$ modprobe -r example-module
若签名有误,会报错提示。
工作原理
DKMS 像一个状态机。第一步是通过配置文件添加驱动。驱动被记录到 DKMS 数据库后,可以请求它针对一个或多个 GNU/Linux 内核版本编译模块,然后安装到 /lib/modules/<kernel-version>/...
。
各步骤独立,可以添加驱动但不立即构建或安装。
安装过程可自动进行,具体取决于配置。
每个配置文件依赖于 DKMS 传递的一组变量:
-
kernelver:该变量包含正在为其构建模块的内核版本。它在定义
MAKE
命令时尤其有用,例如:MAKE[0]="make INCLUDEDIR=/lib/modules/${kernelver}/build/include"
-
dkms_tree:该变量指示本地系统中 DKMS 树的位置。默认值为
/var/lib/dkms
,但不应在dkms.conf
中硬编码此值,以防用户修改了系统配置(通过/etc/dkms/framework.conf
设置或调用时添加--dkmstree
选项)。 -
source_tree:该变量指示 DKMS 在系统上存储模块源码的位置。默认值为
/usr/src
,但同样不应在dkms.conf
中硬编码,以防用户修改(通过/etc/dkms/framework.conf
或调用时添加--sourcetree
选项)。 -
kernel_source_dir:该变量包含构建模块时所使用的内核源码路径。通常为
/lib/modules/$kernelver/build
,除非通过--kernel-sourcedir
选项指定了其他路径。
为驱动添加 DKMS 支持
为了演示如何将外部驱动集成到 DKMS 内核源码管理中,我们以一个简单的字符驱动源码为例。
该驱动包含以下文件,位于 /usr/src/helloworld-0.1
目录:
/usr/src/helloworld-0.1
├── dkms.conf
├── helloworld.c
└── Makefile
首先,在 /usr/src/
下创建一个目录,名称由包名和版本号通过连字符连接组成:
$ sudo mkdir -p /usr/src/helloworld-0.1
然后,在该目录下创建 dkms.conf
文件,用于告诉 DKMS 驱动相关的路径和名称:
$ sudo vim /usr/src/helloworld-0.1/dkms.conf
内容如下:
PACKAGE_NAME="helloworld"
PACKAGE_VERSION="0.1"
MAKE[0]="make -C ${kernel_source_dir} SUBDIRS=${dkms_tree}/${PACKAGE_NAME}/${PACKAGE_VERSION}/build modules"
CLEAN="make -C ${kernel_source_dir} SUBDIRS=${dkms_tree}/${PACKAGE_NAME}/${PACKAGE_VERSION}/build clean"
BUILT_MODULE_NAME[0]= helloworld
BUILT_MODULE_LOCATION[0]=.
DEST_MODULE_LOCATION[0]=/updates
REMAKE_INITRD="no"
AUTOINSTALL="yes"
针对 CLEAN 目标,可以简化为:
CLEAN="rm -f *.*o"
接着,将前述的模块源码文件 helloworld.c
保存至 /usr/src/helloworld-0.1/
目录下。
还需在同路径下创建简单的 Makefile,内容如下:
obj-m := helloworld.o
KDIR := ${kernel_source_dir}
all:
make -C $(KDIR) M=$(shell pwd) modules
install:
make -C $(KDIR) M=$(shell pwd) modules_install
clean:
make -C $(KDIR) M=$(shell pwd) clean
在 /usr/src/helloworld-0.1
目录下,添加模块的 DKMS 支持只需执行以下命令:
$ sudo dkms add -m helloworld -v 0.1
Creating symlink /var/lib/dkms/helloworld/0.1/source -> /usr/src/helloworld-0.1
DKMS: add completed.
如果需要显式指定包含 dkms.conf
配置文件的路径,可加 -c
参数:
$ sudo dkms add -m helloworld -v 0.1 -c /usr/src/helloworld-0.1/dkms.conf
Creating symlink /var/lib/dkms/helloworld/0.1/source -> /usr/src/helloworld-0.1
DKMS: add completed.
默认情况下,DKMS 会使用当前内核版本。若需指定其他版本,可用 -k
参数:
$ sudo dkms add -m helloworld -v 0.1 -k `uname -r`
Creating symlink /var/lib/dkms/helloworld/0.1/source -> /usr/src/helloworld-0.1
DKMS: add completed.
添加后,会在 /var/lib/dkms/helloworld/0.1/
生成如下目录结构:
$ tree /var/lib/dkms/helloworld/0.1/
/var/lib/dkms/helloworld/0.1/
├── build
└── source -> /usr/src/helloworld-0.1
查询 DKMS 中该模块状态:
$ dkms status | grep helloworld
helloworld, 0.1: added
如果想查看与某个内核版本兼容的所有模块,dkms status
支持 -k
参数指定版本:
$ dkms status -k `uname -r`
backfire, 0.73-1: added
helloworld, 0.1, 6.2.0-35-generic, x86_64: installed
vboxhost, 4.3.0, 6.2.0-35-generic, x86_64: installed
如果添加的模块已被 DKMS 识别,则会显示错误提示,防止重复添加:
$ sudo dkms add -m helloworld -v 0.1
DKMS tree already contains: helloworld-0.1
You cannot add the same module/version combo more than once.
为当前内核生成模块
首先查看当前正在运行的 GNU/Linux 内核版本:
$ uname -a
Linux tgayet-DS87D 6.2.0-35-generic #35~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Fri Oct 6 10:23:26 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
$ uname -r
6.2.0-35-generic
列出系统中已安装的内核版本:
$ dpkg --list | grep linux-image
(...)
ii linux-image-6.2.0-34-generic 6.2.0-34.34~22.04.1 amd64 Signed kernel image generic
ii linux-image-6.2.0-35-generic 6.2.0-35.35~22.04.1 amd64 Signed kernel image generic
ii linux-image-generic-hwe-22.04 6.2.0.35.35~22.04.13 amd64 Generic Linux kernel image
作者的内核头文件位于标准目录:
$ ls -al /lib/modules/$(uname -r)/
使用 dkms
命令的 build
参数为当前内核版本生成动态模块:
$ sudo dkms build -m helloworld -v 0.1
Kernel preparation is unnecessary for this kernel. Building module:
cleaning build area....
make KERNELRELEASE=3.8.0-32-generic -C /lib/modules/3.8.0-32-generic/build SUBDIRS=/var/lib/dkms/helloworld/0.1/build modules....
cleaning build area....
DKMS: build completed.
生成模块时,可以指定当前内核(默认)或特定内核版本:
$ sudo dkms build -m helloworld -v 0.1 -k $(uname -r)
Kernel preparation is unnecessary for this kernel. Skipping...
Building module:
cleaning build area....
make KERNELRELEASE=3.8.0-32-generic -C /lib/modules/3.8.0-32-generic/build SUBDIRS=/var/lib/dkms/helloworld/0.1/build modules....
cleaning build area....
DKMS: build completed.
构建完成后会生成如下目录结构:
$ tree /var/lib/dkms/helloworld/0.1/
/var/lib/dkms/helloworld/0.1/
├── 3.8.0-32-generic
│ └── x86_64
│ ├── log
│ │ └── make.log
│ └── module
│ └── helloworld.ko
├── build
│ ├── dkms.conf
│ ├── Makefile
│ └── helloworld.c
└── source -> /usr/src/helloworld-0.1
此步骤中会生成一个日志文件 make.log
,内容示例:
$ cat /var/lib/dkms/helloworld/0.1/$(uname -r)/x86_64/log/make.log
DKMS make.log for helloworld-0.1 for kernel 3.8.0-32-generic (x86_64)
Monday, November 18, 2013, 10:01:14 (UTC+0100)
make: entering directory "/usr/src/linux-headers-3.8.0-32-generic"
CC [M] /var/lib/dkms/helloworld/0.1/build/helloworld.o
/var/lib/dkms/helloworld/0.1/build/helloworld.c: In function ‘__check_buf_size’:
/var/lib/dkms/helloworld/0.1/build/helloworld.c:16:1: warning: return from incompatible pointer type [enabled by default]
/var/lib/dkms/helloworld/0.1/build/helloworld.c: In function ‘k_read’:
/var/lib/dkms/helloworld/0.1/build/helloworld.c:34
生成模块后,可以用 modinfo
查看模块信息:
$ modinfo /var/lib/dkms/helloworld/0.1/$(uname -r)/x86_64/module/helloworld.ko
filename: /var/lib/dkms/helloworld/0.1/6.2.0-35-generic/x86_64/module/helloworld.ko
license: GPL
author: bpb
description: ex-drv-char-dkms
srcversion: 3853BD18B15DD07DE2E4338
depends:
vermagic: 6.2.0-35-generic SMP mod_unload modversions
parm: major:Static major number (none = dynamic) (int)
parm: buf_size:Buffer size (int)
查看该模块在 DKMS 中的状态:
$ dkms status | grep helloworld
helloworld, 0.1, 3.8.0-32-generic, x86_64: built
由于模块已为当前内核生成,可以快速测试加载:
$ sudo insmod /var/lib/dkms/helloworld/0.1/$(uname -r)/x86_64/module/helloworld.ko
$ dmesg -w
(...)
[261032.273926] char: allocated to 64 bytes buffer
[261032.273932] char: successfully loaded with major 250
$ lsmod | grep helloworld
helloworld 12868 0
$ sudo rmmod helloworld.ko
安装由 DKMS 管理的模块
安装 DKMS 模块是通过传递 install
参数来完成的:
$ dkms install -m helloworld -v 0.1
helloworld:
Running module version sanity check.
- Original module
- No original module exists within this kernel
- Facility
- Installing to /lib/modules/3.8.0-32-generic/updates/dkms/
depmod....
DKMS: install completed.
和添加或生成阶段一样,也可以指定特定内核版本而非当前版本:
$ sudo dkms install -m helloworld -v 0.1 -k `uname -r`
helloworld:
Running module version sanity check.
- Original module
- No original module exists within this kernel
- Facility
- Installing to /lib/modules/3.8.0-32-generic/updates/dkms/
depmod....
DKMS: install completed.
安装状态可以通过如下命令检查,状态应显示为已安装(installed):
$ sudo dkms status | grep helloworld
helloworld, 0.1, 3.8.0-32-generic, x86_64: installed
安装后,模块将被放置在当前内核模块目录下的 /updates/dkms/
目录中:
$ ls -al /lib/modules/`uname -r`/updates/dkms/
total 608
drwxr-xr-x 2 root root 4096 Nov 18 10:52 .
drwxr-xr-x 3 root root 4096 Oct 22 12:00 ..
-rw-r--r-- 1 root root 8544 Nov 18 10:52 helloworld.ko
-rw-r--r-- 1 root root 504640 Nov 10 03:21 vboxdrv.ko
-rw-r--r-- 1 root root 14896 Nov 10 03:21 vboxnetadp.ko
-rw-r--r-- 1 root root 38320 Nov 10 03:21 vboxnetflt.ko
-rw-r--r-- 1 root root 36656 Nov 10 03:21 vboxpci.ko
我们注意到安装过程中自动调用了 depmod
命令,更新了 modules.dep
依赖文件:
$ cat /lib/modules/`uname -r`/modules.dep | grep helloworld
updates/dkms/helloworld.ko:
这使得模块可以通过 modprobe
命令动态加载:
$ sudo modprobe helloworld
$ lsmod | grep helloworld
helloworld 12868 0
$ sudo modprobe -r helloworld
如果在构建阶段未完成时请求安装,DKMS 会先自动完成构建再安装:
$ dkms install -m helloworld -v 0.1
Kernel preparation is unnecessary for this kernel. Skipping...
Building module:
cleaning build area....
make KERNELRELEASE=3.8.0-32-generic -C /lib/modules/3.8.0-32-generic/build SUBDIRS=/var/lib/dkms/helloworld/0.1/build modules....
cleaning build area....
DKMS: build completed.
helloworld.ko:
Running module version sanity check.
- Original module
- No original module exists within this kernel
- Facility
- Installing to /lib/modules/3.8.0-32-generic/updates/dkms/
depmod....
DKMS: install completed.
卸载由 DKMS 管理的驱动
顾名思义,卸载操作无需赘述,执行如下命令即可卸载指定内核版本的驱动:
$ sudo dkms uninstall -m helloworld -v 0.1 -k `uname -r`
此步骤仅删除之前安装在 /lib/modules/<kernel-release>/updates/dkms/
下的驱动模块。
删除
如果想移除该驱动在所有内核版本的 DKMS 支持,则操作类似于添加阶段:
$ sudo dkms remove -m helloworld -v 0.1 --all
-------- Uninstall Beginning --------
Module: helloworld
Version: 0.1
Kernel: 6.2.0-35-generic (x86_64)
------------------------------------
Status: This module version was INACTIVE for this kernel.
depmod....
DKMS: uninstall completed.
------------------------------
Deleting module version: 0.1
completely from the DKMS tree.
------------------------------
Done.
卸载后,可以通过以下命令确认驱动状态变为未知(即不再存在):
$ dkms status | grep helloworld
若仅想删除特定内核版本的模块,可加 -k
参数:
$ sudo dkms remove -m helloworld -v 0.1 -k `uname -r`
-------- Uninstall Beginning --------
Module: helloworld
Version: 0.1
Kernel: 3.8.0-32-generic (x86_64)
------------------------------------
Status: Before uninstall, this module version was ACTIVE on this kernel.
helloworld.ko:
- Uninstallation
- Deleting from: /lib/modules/3.8.0-32-generic/updates/dkms/
- Original module
- No original module was found for this module on this kernel.
- Use the dkms install command to reinstall any previous module version.
depmod....
DKMS: uninstall completed.
------------------------------
Deleting module version: 0.1
completely from the DKMS tree.
------------------------------
Done.
我们详细介绍了 DKMS 的使用,因为它是专业且关键的支持工具,尤其在安装新内核或更新驱动时,DKMS 确保驱动能被重新编译,保证版本兼容和系统稳定。
总结
本章我们更详细地讲解了一个基础驱动的实现,涵盖了参数管理、依赖关系处理,以及驱动的编译、加载和卸载方法。这些内容将为本书后续章节的学习打下基础。
下一章,我们将深入研究 Linux 设备模型(LDM),包括 procfs 和 sysfs。
询问 ChatGPT