2. Linux内核模块基础
字符设备驱动在Linux中通常以“内核模块”的形态存在。理解模块的加载与卸载机制,是编写、调试和部署字符设备驱动的第一步。内核模块并不是一个独立运行的程序,而是一段可以在系统运行期间动态插入内核地址空间的目标代码。它没有自己的main()函数,而是通过内核提供的注册入口和退出入口与内核交互。本节围绕模块生命周期、入口出口函数、许可证声明以及内核日志输出四个核心点展开。
一、模块加载与卸载的基本流程
用户空间通过insmod、modprobe、rmmod等命令触发模块操作。内核收到请求后,会完成一系列动作:将.ko文件读入内存、解析ELF段、进行符号重定位、检查模块版本与依赖关系,然后调用模块的初始化函数。若初始化成功,模块进入“已加载”状态;若失败,内核会回收已分配资源并返回错误码。
卸载时,内核先检查模块引用计数。如果计数不为零,说明仍有其他模块或内核对象依赖它,卸载会被拒绝。计数归零后,内核调用模块的退出函数,释放资源,最后将模块代码从内核空间移除。
# 加载模块
sudo insmod hello_dev.ko
# 查看已加载模块
lsmod | grep hello_dev
# 查看模块信息
modinfo hello_dev.ko
# 卸载模块
sudo rmmod hello_dev
与insmod相比,modprobe会自动处理模块依赖关系,更适合生产环境。而insmod只加载指定文件,不解决依赖,常用于开发调试阶段。
二、module_init与module_exit
每个可加载模块都必须至少提供初始化入口。最常见的方式是使用module_init()和module_exit()宏来声明入口和出口函数。它们告诉内核:模块被加载时调用哪个函数,被卸载时调用哪个函数。
#include <linux/module.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "hello_dev: module init\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "hello_dev: module exit\n");
}
module_init(hello_init);
module_exit(hello_exit);
入口函数返回int类型:返回0表示加载成功,返回负值表示失败。出口函数返回void,不返回错误码。函数前的__init和__exit是内核优化标记:__init段在初始化完成后可被释放,__exit段在模块被编译进内核时会被丢弃。
三、MODULE_LICENSE与模块元信息
MODULE_LICENSE用于声明模块的许可证。它不仅是法律声明,也直接影响内核行为。如果模块声明为GPL兼容许可证,就可以使用内核中仅对GPL模块开放的符号;否则,加载时可能遇到“Unknown symbol”或“module verification failed”等问题。
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Course Example");
MODULE_DESCRIPTION("A simple char device module");
MODULE_VERSION("1.0");
常见的许可证字符串包括"GPL"、"GPL v2"、"Dual BSD/GPL"、"Proprietary"等。字符设备驱动通常使用"GPL",以便合法调用内核导出符号。除许可证外,MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_VERSION等宏用于补充模块元信息,可通过modinfo查看。
四、printk与内核日志
内核模块不能使用用户空间的printf,而应使用printk输出日志。printk支持日志级别,格式为KERN_XXX,级别越低越紧急。常见级别包括:
| 级别宏 | 数值 | 含义 |
|---|---|---|
| KERN_EMERG | 0 | 系统不可用 |
| KERN_ALERT | 1 | 必须立即处理 |
| KERN_ERR | 3 | 错误条件 |
| KERN_WARNING | 4 | 警告条件 |
| KERN_INFO | 6 | 一般信息 |
| KERN_DEBUG | 7 | 调试信息 |
日志输出后,可通过dmesg、/var/log/kern.log或journalctl -k查看。开发阶段常用dmesg -w实时观察模块加载和卸载时的打印信息。
# 实时查看内核日志
dmesg -w
# 查看最近日志
dmesg | tail -n 20
# 清空日志缓冲区
sudo dmesg -C
\n。另外,printk在中断上下文和高频路径中应谨慎使用,避免日志风暴影响系统性能。五、一个完整的模块骨架与加载验证
下面给出一个最小但完整的字符设备模块骨架,包含入口、出口、许可证和日志输出。它尚未实现具体的file_operations,但足以验证模块生命周期是否正常。
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
static int __init chardev_init(void)
{
printk(KERN_INFO "chardev: init, loading...\n");
return 0;
}
static void __exit chardev_exit(void)
{
printk(KERN_INFO "chardev: exit, unloading...\n");
}
module_init(chardev_init);
module_exit(chardev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Course Example");
MODULE_DESCRIPTION("Char device module skeleton");
对应的Makefile可写成:
obj-m += chardev.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
执行make后生成chardev.ko,再执行sudo insmod chardev.ko,通过dmesg | tail应能看到“chardev: init, loading...”。执行sudo rmmod chardev后,应看到“chardev: exit, unloading...”。
六、常见误区与复盘
- 忘记MODULE_LICENSE:模块可能加载失败或无法使用GPL符号,内核日志会给出明确提示。
- 入口函数返回正值:内核只把0视为成功,非零值会被当作错误处理,导致加载失败。
- 出口函数中访问已释放资源:卸载顺序应与初始化顺序相反,先注册的后释放。
- printk缺少换行:多条日志会粘连在一起,影响阅读和排查。
- 用printf代替printk:内核空间没有用户态C库,链接阶段就会报错。
module_init、module_exit、MODULE_LICENSE和printk,就具备了编写可加载、可卸载、可观测驱动模块的基础能力。