4. 内核模块编程:模块的加载与卸载、模块参数、模块依赖、内核符号导出
好,咱们进入第四章。前面几章我们把开发环境搭好了,也编译了内核。但说实话,真正的驱动开发,大部分时间是在写内核模块,而不是直接往内核源码里塞代码。
内核模块,说白了就是一段可以在运行时动态加载进内核的代码。不用重新编译整个内核,不用重启机器。我刚开始学的时候觉得这玩意儿挺神奇的——后来自己写多了才发现,它其实就是内核的“插件机制”。
4.1 模块的加载与卸载
先看一个最基础的内核模块长什么样。我习惯把它叫做“Hello World”的升级版。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_module_init(void)
{
printk(KERN_INFO "Hello, kernel module loaded!\n");
return 0;
}
static void __exit my_module_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module unloaded!\n");
}
module_init(my_module_init);
module_exit(my_module_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple kernel module example");
这个结构其实很固定:一个入口函数 module_init(),一个出口函数 module_exit()。加载时内核会调用你注册的 init 函数,卸载时调用 exit 函数。
__init 和 __exit 这两个宏不是摆设。加了 __init 的代码,在模块加载完成后会被内核回收掉,省内存。我在项目里见过有人没加,结果模块占着内存不放,虽然不影响功能,但总觉得不专业。
编译这个模块,你需要一个 Makefile。嗯,这里有个坑,我踩过好几次。
obj-m := my_module.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
编译完成后,你会得到一个 .ko 文件。操作命令很简单:
| 操作 | 命令 | 说明 |
|---|---|---|
| 加载模块 | insmod my_module.ko |
直接插入,不处理依赖 |
| 卸载模块 | rmmod my_module |
按名字卸载,不加 .ko |
| 查看已加载模块 | lsmod |
列出所有模块及其依赖 |
| 查看模块信息 | modinfo my_module.ko |
显示作者、描述、参数等 |
insmod 去加载一个依赖没满足的模块。我曾经在调试一个 USB 驱动时,忘了先加载 USB 核心模块,结果 insmod 直接报错“Unknown symbol”。后来养成了习惯,先用 modprobe 试试。
4.2 模块参数
写驱动的时候,你经常需要让用户在加载模块时传一些参数进来。比如指定设备号、缓冲区大小、调试级别等。内核模块支持通过 module_param 宏来声明参数。
#include <linux/moduleparam.h>
static int debug_level = 0;
static char *device_name = "default_dev";
module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");
加载时这样传参:
insmod my_module.ko debug_level=2 device_name="my_device"
参数类型支持 int、long、charp(字符串指针)、bool 等。第三个参数是文件权限,如果设为 0644,模块加载后你可以在 /sys/module/<模块名>/parameters/ 下看到这些参数,甚至可以在运行时修改它们。
echo 3 > /sys/module/my_drv/parameters/debug_level 就能打开全量日志,不用重新编译模块。这个技巧帮我省了好几次出差。
4.3 模块依赖
模块之间是有依赖关系的。比如你写了一个 I2C 温度传感器驱动,它肯定依赖 I2C 核心模块。内核通过 MODULE_DEPEND 或者符号引用来管理这种关系。
看一个实际例子:
// i2c_temp_sensor.c
#include <linux/i2c.h>
static struct i2c_driver temp_sensor_driver = {
.driver = {
.name = "tmp102",
},
.id_table = temp_sensor_id,
.probe = temp_sensor_probe,
.remove = temp_sensor_remove,
};
module_i2c_driver(temp_sensor_driver);
这个模块引用了 I2C 子系统的符号(比如 i2c_add_driver)。当你用 insmod 加载它时,如果 I2C 核心模块没加载,就会报“Unknown symbol”。
正确的做法是用 modprobe:
modprobe i2c_temp_sensor
modprobe 会自动解析 depmod 生成的依赖文件 /lib/modules/$(uname -r)/modules.dep,先把 I2C 核心模块加载上,再加载你的传感器模块。
| 工具 | 特点 | 适用场景 |
|---|---|---|
insmod |
简单直接,不处理依赖 | 调试单个模块,已知依赖已满足 |
modprobe |
自动处理依赖,支持别名 | 生产环境,模块有复杂依赖 |
rmmod |
卸载单个模块 | 模块无被引用 |
modprobe -r |
递归卸载依赖模块 | 卸载整个模块树 |
rmmod 卸载一个正在被其他模块引用的模块,结果内核直接 panic 了。所以卸载前一定用 lsmod 看看 Used by 那一列是不是 0。
4.4 内核符号导出
有时候你写了一个模块,里面的函数想给其他模块用。比如你实现了一个通用的 GPIO 操作库,别的驱动想直接调用你的 gpio_set_value_custom()。这时候就需要导出符号。
内核提供了两个宏:
// 导出给所有模块
EXPORT_SYMBOL(gpio_set_value_custom);
// 只导出给 GPL 许可的模块
EXPORT_SYMBOL_GPL(gpio_set_value_custom);
用法很简单,在函数定义后面加上宏就行:
// gpio_lib.c
#include <linux/module.h>
int gpio_set_value_custom(unsigned int gpio, int value)
{
// 自定义 GPIO 操作
return 0;
}
EXPORT_SYMBOL_GPL(gpio_set_value_custom);
其他模块只要在编译时声明这个函数为外部函数,链接时就能找到它:
// my_driver.c
extern int gpio_set_value_custom(unsigned int gpio, int value);
static int __init my_init(void)
{
gpio_set_value_custom(17, 1);
return 0;
}
加载顺序很重要——必须先加载导出符号的模块,再加载使用它的模块。否则内核会报“Unknown symbol”。
EXPORT_SYMBOL_GPL 就别用 EXPORT_SYMBOL。一方面遵守 GPL 协议,另一方面也提醒自己——这个接口是给内核内部模块用的,不是给用户态程序调的。
4.5 知识结构图
下面这张图把本章的核心知识点串起来了。你可以看到模块从编写、编译到加载、卸载的完整流程,以及参数、依赖、符号导出在其中的位置。
4.6 本章小结
内核模块编程是驱动开发的基本功。你想想看,掌握了模块的加载卸载、参数传递、依赖管理和符号导出,基本上就能应对大部分模块化开发的场景了。
- 加载卸载:
insmod和rmmod是最直接的,但生产环境推荐modprobe。 - 模块参数:灵活配置驱动行为,调试时特别好用。
- 模块依赖:用
depmod生成依赖树,modprobe自动处理。 - 符号导出:模块间共享函数,注意协议合规。