3. 第一个字符设备驱动
在 Linux 内核中,字符设备驱动通常以内核模块的形式存在。模块是内核在运行时可以动态加载和卸载的一段代码,无需重新编译整个内核或重启系统。本章将带领你编写第一个完整的字符设备驱动模块,重点掌握模块的加载/卸载机制、入口/出口函数声明以及模块信息的规范填写。
3.1 内核模块的基本结构
一个最简单的内核模块至少包含两个核心函数:模块初始化函数(在模块加载时执行)和模块清理函数(在模块卸载时执行)。这两个函数通过特定的宏与内核框架绑定。
| 宏 / 函数 | 作用 | 执行时机 |
|---|---|---|
module_init() |
指定模块加载时调用的入口函数 | insmod 或内核自动加载时 |
module_exit() |
指定模块卸载时调用的出口函数 | rmmod 或内核自动卸载时 |
__init 标记 |
将函数放入 .init.text 段,加载完成后释放内存 |
仅初始化阶段 |
__exit 标记 |
将函数放入 .exit.text 段,模块编译进内核时忽略 |
仅卸载阶段 |
关键概念:
module_init() 和 module_exit() 是宏定义,它们将函数指针注册到内核模块表中。内核在加载模块时会查找 module_init 指定的函数地址并执行;卸载时则执行 module_exit 指定的函数。
3.2 第一个模块代码:hello_module
下面是一个完整的字符设备驱动模块框架,它不操作硬件,仅演示模块的加载与卸载流程。
#include <linux/module.h> // 包含 module_init / module_exit 宏
#include <linux/kernel.h> // 包含 printk 等内核打印函数
#include <linux/init.h> // 包含 __init / __exit 标记
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, char device module loaded!\n");
return 0; // 返回 0 表示初始化成功
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, char device module unloaded!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name <your.email@example.com>");
MODULE_DESCRIPTION("A simple character device module example");
MODULE_VERSION("1.0");
代码要点:
__init和__exit标记帮助内核优化内存布局,非强制但推荐使用。printk是内核空间的打印函数,KERN_INFO指定日志级别。- 初始化函数返回
0表示成功,返回负数表示失败,模块将无法加载。 - 清理函数返回类型为
void,不允许失败。
3.3 模块信息与 LICENSE 规范
每个内核模块必须声明许可证信息,否则内核会认为该模块是“污染”的,加载时打印警告。常用的模块信息宏如下:
| 宏 | 含义 | 示例值 |
|---|---|---|
MODULE_LICENSE() |
声明模块许可证 | "GPL", "GPL v2", "BSD", "Proprietary" |
MODULE_AUTHOR() |
作者信息 | "Zhang San <zhangsan@example.com>" |
MODULE_DESCRIPTION() |
模块功能简述 | "A simple char driver" |
MODULE_VERSION() |
模块版本号 | "1.0" |
MODULE_ALIAS() |
模块别名(用于自动加载) | "char-device-example" |
许可证警告: 如果模块使用非 GPL 兼容许可证(如
"Proprietary"),内核将禁止调用标记为 EXPORT_SYMBOL_GPL 的符号。对于字符设备驱动开发,强烈建议使用 "GPL" 或 "GPL v2"。
3.4 模块加载与卸载流程
下图展示了从用户空间执行 insmod 和 rmmod 命令时,内核模块的生命周期变化:
流程说明:
- UNLOADED → LOADING: 执行
insmod后,内核加载器将模块二进制插入内存,调用module_init指定的函数。 - LOADING → ACTIVE: 初始化函数返回 0,模块进入活跃状态,可以被用户空间访问。
- LOADING → UNLOADED: 初始化函数返回负数,模块自动卸载,回到未加载状态。
- ACTIVE → UNLOADING: 执行
rmmod,内核调用module_exit指定的函数。 - UNLOADING → UNLOADED: 清理函数执行完毕,模块从内核中移除。
3.5 编译与测试
使用 Makefile 编译内核模块是标准做法。以下是一个适用于本示例的 Makefile:
obj-m := hello_module.o
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
编译完成后,执行以下命令验证模块行为:
# 加载模块
sudo insmod hello_module.ko
# 查看内核日志,确认加载信息
dmesg | tail -5
# 输出示例: [ 1234.5678] Hello, char device module loaded!
# 查看已加载模块列表
lsmod | grep hello_module
# 卸载模块
sudo rmmod hello_module
# 再次查看内核日志,确认卸载信息
dmesg | tail -5
# 输出示例: [ 1234.9876] Goodbye, char device module unloaded!
常见问题排查:
- insmod: ERROR: could not insert module: Operation not permitted → 需要使用
sudo或以 root 身份执行。 - insmod: ERROR: could not insert module: Invalid module format → 内核版本或配置不匹配,重新编译模块。
- rmmod: ERROR: Module hello_module is in use → 有其他进程正在使用该模块,先关闭相关应用。
- dmesg 无输出 → 检查
printk日志级别,或使用dmesg -w实时监控。
3.6 模块加载/卸载的完整时序
下表总结了模块从加载到卸载过程中,内核与模块函数之间的交互时序:
| 步骤 | 用户操作 | 内核行为 | 模块函数 |
|---|---|---|---|
| 1 | insmod hello.ko |
读取 ELF 文件,分配内核内存 | — |
| 2 | — | 解析模块依赖,解决符号引用 | — |
| 3 | — | 调用 module_init 注册的函数 |
hello_init() |
| 4 | — | 检查返回值:0 成功,负数回滚 | 返回 0 |
| 5 | — | 模块加入内核模块链表 | — |
| 6 | rmmod hello |
检查模块引用计数是否为 0 | — |
| 7 | — | 调用 module_exit 注册的函数 |
hello_exit() |
| 8 | — | 释放模块占用的内核内存 | — |
引用计数机制: 内核通过
try_module_get() 和 module_put() 维护模块的引用计数。只有当引用计数为 0 时,rmmod 才能成功卸载模块。在后续章节中,当设备文件被打开时,驱动需要主动增加引用计数以防止模块被意外卸载。
3.7 本章小结
通过本章的学习,你已经掌握了 Linux 内核模块的基本骨架:
- 使用
module_init()和module_exit()注册模块的入口和出口函数。 - 使用
__init和__exit标记优化内存布局。 - 规范填写
MODULE_LICENSE、MODULE_AUTHOR等模块信息。 - 理解模块从加载到卸载的完整生命周期状态机。
- 能够编写 Makefile 并编译、加载、卸载一个真实的内核模块。