4、内核模块编程入门:模块的编写、编译(Makefile)、加载与卸载(insmod/modprobe/rmmod)、模块参数与模块依赖
Linux内核采用宏内核架构,但通过可加载模块(Loadable Kernel Module, LKM)机制实现了运行时的功能扩展。模块编程是Linux驱动开发的基石,驱动工程师需要掌握模块的编写规范、编译体系、加载/卸载流程以及模块间的协作方式。本章从零开始构建一个完整的字符设备模块,深入剖析Makefile的编写逻辑,对比insmod与modprobe的差异,并通过模块参数与依赖管理建立对内核模块生命周期的系统认知。
4.1 内核模块的核心原理与文件结构
内核模块本质上是一个ELF格式的目标文件(通常为 .ko 后缀),它在运行时被动态链接到内核地址空间。与用户态程序不同,模块没有 main() 函数入口,而是通过两个关键宏来声明生命周期回调:module_init() 指定模块加载时的初始化函数,module_exit() 指定卸载时的清理函数。这两个宏在 <linux/module.h> 中定义,编译器会将它们放入特定的ELF段(.init.text 和 .exit.text),内核在加载和卸载时通过段地址找到对应函数。
一个最小模块的源码结构如下:
// hello.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, kernel module!\n");
return 0; // 0表示成功,负数表示失败
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL"); // 必须声明许可证
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple hello module");
MODULE_VERSION("1.0");
其中 __init 和 __exit 是内核提供的属性标记。带有 __init 的函数在模块加载完成后,其占用的内存会被释放(如果模块编译进内核而非动态加载,则初始化完成后整个 .init 段都会被回收)。MODULE_LICENSE("GPL") 不是可选项——如果缺失或声明非GPL兼容许可证,内核将拒绝使用某些 EXPORT_SYMBOL_GPL 导出的符号,并且加载时会出现 tainted 标记。
printk 配合 KERN_INFO 级别输出调试信息。查看模块日志使用 dmesg 命令,或 tail -f /var/log/kern.log(Ubuntu/Debian)。生产环境应使用 pr_info/pr_err 等封装宏,它们会自动添加模块名前缀。
4.2 Makefile 编写与 Kbuild 编译体系
Linux内核模块的编译不直接使用 gcc,而是通过内核源码树中的 Kbuild 系统。Kbuild 负责处理内核头文件路径、编译选项、符号版本信息(CONFIG_MODVERSIONS)以及模块间依赖。一个标准的模块 Makefile 如下:
# Makefile
obj-m := hello.o # 目标模块名(去掉.ko后缀)
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
关键点解析:
- obj-m:告诉Kbuild将
hello.o编译为可加载模块(obj-y表示编译进内核镜像)。 - KDIR:指向当前运行内核的构建目录。如果安装了
linux-headers-$(uname -r)包,该路径即存在。 - -C $(KDIR):切换到内核源码目录执行make,M=$(PWD) 指定模块源码所在目录。
- modules:Kbuild的目标,表示生成所有
obj-m指定的模块。
当模块由多个源文件组成时,需要使用 模块名-objs 变量:
obj-m := mydriver.o
mydriver-objs := main.o helper.o io.o
此时Kbuild会先将 main.c、helper.c、io.c 分别编译为目标文件,再链接为 mydriver.ko。如果模块需要传递额外的编译选项(如定义宏),使用 ccflags-y:
ccflags-y := -DDEBUG -I$(src)/include
| 编译场景 | Makefile 关键配置 | 说明 |
|---|---|---|
| 单文件模块 | obj-m := hello.o |
最简单形式,适用于入门 |
| 多文件模块 | obj-m := mydrv.omydrv-objs := a.o b.o |
按功能拆分源码,便于维护 |
| 跨平台编译 | ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- |
交叉编译时在make命令行传入 |
| 外部头文件 | ccflags-y += -I$(src)/include |
模块自带私有头文件目录 |
error: unknown type name 'bool' 或找不到 linux/module.h,通常是因为KDIR路径错误或未安装内核头文件。在Ubuntu上执行 sudo apt install linux-headers-$(uname -r) 即可解决。另外,不要在Makefile中直接使用 -I/usr/include,那会引入用户态头文件导致冲突。
4.3 模块加载与卸载:insmod、modprobe、rmmod
模块编译完成后生成 .ko 文件,接下来需要将其加载到内核中。Linux提供了三个核心命令:insmod、modprobe 和 rmmod。它们的使用场景和底层机制有显著差异。
insmod 是最直接的加载命令,它通过 init_module() 系统调用将指定的 .ko 文件加载到内核。insmod 不会自动处理依赖关系——如果模块引用了其他模块导出的符号,必须先手动加载被依赖的模块。此外,insmod 需要指定完整的文件路径(除非当前目录下能找到)。
modprobe 是更智能的加载工具,它依赖 depmod 生成的依赖数据库(/lib/modules/$(uname -r)/modules.dep)。当执行 modprobe hello 时,它会:
- 在标准模块目录中查找
hello.ko(无需指定路径); - 解析
modules.dep文件,检查该模块依赖的其他模块; - 按依赖顺序依次加载所有依赖模块;
- 最后加载目标模块。
rmmod 用于卸载模块,对应 delete_module() 系统调用。卸载时内核会检查模块的引用计数(refcnt),如果模块正被其他模块或进程使用(例如设备文件被打开),卸载将失败并返回 EBUSY。可以使用 lsmod 查看当前已加载模块及其引用计数。
# 加载模块(方式一:insmod,需指定路径)
sudo insmod ./hello.ko
# 加载模块(方式二:modprobe,自动处理依赖)
sudo modprobe hello
# 查看模块信息
lsmod | grep hello
modinfo hello.ko # 显示模块的license、author、参数等信息
# 卸载模块
sudo rmmod hello
# 查看内核日志
dmesg | tail -20
i2c_sensor.ko 模块,它依赖内核中的 i2c-core 模块。如果使用 insmod i2c_sensor.ko,系统会报错 Unknown symbol in module。正确做法是:1. 先执行
sudo modprobe i2c-dev 加载I2C核心模块;2. 再
sudo insmod i2c_sensor.ko;或者直接
sudo modprobe i2c_sensor(前提是已将模块安装到标准目录并运行 sudo depmod -a 更新依赖数据库)。
4.4 模块参数:向内核模块传递配置
模块参数允许在加载时向模块传递配置值,避免硬编码。内核提供了 module_param() 宏来声明参数:
// param_demo.c
#include <linux/moduleparam.h>
static int count = 1;
static char *name = "default";
static int arr[4] = {0, 0, 0, 0};
static int arr_argc = 0;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "Number of iterations");
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Device name");
module_param_array(arr, int, &arr_argc, 0644);
MODULE_PARM_DESC(arr, "An array of integers");
// 在初始化函数中使用这些参数
static int __init param_init(void)
{
int i;
pr_info("count = %d, name = %s\n", count, name);
for (i = 0; i < arr_argc; i++)
pr_info("arr[%d] = %d\n", i, arr[i]);
return 0;
}
加载时通过 参数名=值 的方式传递:
sudo insmod param_demo.ko count=5 name="mydev" arr=1,2,3,4
module_param 的第三个参数是sysfs权限位。如果设置为 0644,模块加载后会在 /sys/module/param_demo/parameters/ 目录下生成对应的属性文件,允许在运行时查看甚至修改参数值(取决于权限)。设置为 0 则不在sysfs中暴露。
| 参数类型 | 宏 | 示例 | 注意事项 |
|---|---|---|---|
| 基本类型 | module_param(name, type, perm) |
int, bool, charp, long |
charp 表示字符指针,内核会复制字符串 |
| 数组 | module_param_array(name, type, &num, perm) |
int arr[4] |
第三个参数返回实际传入的元素个数 |
| 字符串 | module_param_string(name, buf, len, perm) |
char buf[64] |
固定长度缓冲区,更安全 |
| 描述 | MODULE_PARM_DESC(name, desc) |
— | 在 modinfo 中显示 |
int 类型,加载时传入非法值(如字符串)会导致加载失败。更严重的是,如果参数用于数组索引或内存大小计算,未经验证的参数值可能引发内核崩溃。生产代码中务必对参数进行范围检查,并在 module_init 中返回 -EINVAL 拒绝非法配置。
4.5 模块依赖与符号导出
模块之间可以通过 符号导出 实现依赖。当一个模块需要调用另一个模块提供的函数时,被依赖模块必须使用 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL 导出该符号。内核维护一个全局符号表,模块加载时通过 resolve_symbol 机制解析未定义的符号。
// exporter.c
#include <linux/module.h>
int my_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(my_add); // 导出符号,所有模块可用
int my_mul(int a, int b)
{
return a * b;
}
EXPORT_SYMBOL_GPL(my_mul); // 仅GPL模块可用
module_init(exporter_init);
module_exit(exporter_exit);
MODULE_LICENSE("GPL");
// importer.c
extern int my_add(int a, int b); // 声明外部符号
static int __init importer_init(void)
{
pr_info("my_add(3, 4) = %d\n", my_add(3, 4));
return 0;
}
module_init(importer_init);
module_exit(importer_exit);
MODULE_LICENSE("GPL");
加载顺序必须保证:先加载 exporter.ko,再加载 importer.ko。如果使用 modprobe,需要先运行 sudo depmod -a 生成依赖关系,modprobe 会自动按顺序加载。查看模块间的依赖关系:
# 查看已加载模块的依赖
lsmod | grep importer
# 查看模块的符号表
nm exporter.ko | grep my_add
# 查看内核中已导出的符号
cat /proc/kallsyms | grep my_add
tmp117.ko,它依赖I2C核心模块和GPIO模块。初始现象:直接
insmod tmp117.ko 报错 Unknown symbol i2c_transfer。分析过程:通过
nm tmp117.ko | grep U 查看未定义符号,发现 i2c_transfer 和 gpio_set_value 未解析。使用 grep i2c_transfer /proc/kallsyms 确认该符号在内核中已导出,但当前未加载对应模块。解决步骤:
1.
sudo modprobe i2c-core 加载I2C核心;2.
sudo modprobe gpio-mockup 加载GPIO模拟模块(或实际GPIO驱动);3.
sudo insmod tmp117.ko 成功加载。复盘:使用
modprobe 替代 insmod 可以自动处理依赖,但前提是模块已安装到 /lib/modules/$(uname -r)/extra/ 或 updates/ 目录,并执行 depmod -a 更新依赖数据库。
4.6 常见误区与调试技巧
模块编程初学者常犯以下错误,这里逐一剖析:
- 忘记声明MODULE_LICENSE:内核会标记模块为
tainted,并且无法使用EXPORT_SYMBOL_GPL导出的符号。某些内核功能(如kprobes)也会拒绝在tainted模块上工作。 - 在模块中使用用户态库函数:如
printf、malloc、strcpy等。内核空间没有这些函数,应使用printk、kmalloc、strscpy等内核API。 - 忽略返回值检查:
module_init返回非零值表示初始化失败,内核会卸载模块并释放资源。但如果在初始化过程中已经注册了设备或申请了资源,必须在返回错误前手动清理。 - 卸载时未释放资源:
module_exit中必须释放所有申请的资源(内存、中断、设备号、IO区域等),否则会造成资源泄漏,甚至导致内核崩溃。 - 并发访问未加锁:模块中的全局变量可能被多个进程或中断上下文同时访问,需要使用
spinlock或mutex保护。
1. 使用
printk 时加上 KERN_DEBUG 级别,并通过 echo 8 > /proc/sys/kernel/printk 临时提高内核日志级别;2. 使用
objdump -d hello.ko 查看模块反汇编,确认代码段正确;3. 使用
strace insmod hello.ko 跟踪系统调用,确认加载失败的具体原因;4. 在
/var/log/messages 或 journalctl -k 中查看内核日志,定位 Unknown symbol 或 Invalid module format 等错误。
模块编程是Linux驱动开发的第一道门槛。掌握模块的编写规范、Makefile的Kbuild体系、加载/卸载工具链、参数传递和依赖管理,是后续编写字符设备、平台驱动、中断处理等复杂驱动的基础。建议读者在虚拟机中反复练习本章的示例,并尝试修改参数、添加依赖,观察内核日志的变化,逐步建立对内核模块运行机制的直觉。