内核模块开发:module_init 与模块机制详解
内核模块是 Linux 驱动的载体。这篇深入讲模块的加载卸载机制、模块参数、符号导出、模块依赖、多文件编译,以及常见的模块开发技巧。
大家好,我是黒漂技术佬。
上一篇讲了驱动的基本概念和最简单的 hello world 模块。这篇深入内核模块:加载卸载的完整流程、模块参数怎么传、怎么导出符号给其他模块用、模块依赖、多文件模块怎么编译。
把模块机制搞清楚,后面写驱动才有基础。
一、模块加载和卸载的完整流程
insmod 做了什么?
bash
insmod hello.ko
背后做了这些事:
- 读取 .ko 文件:ELF 格式的目标文件
- 分配内核内存:把模块代码和数据加载到内核空间
- 重定位:修正符号地址,链接到内核
- 解析未定义符号:找内核导出的函数(比如 printk),填上地址
- 执行 module_init 函数:调用我们写的初始化函数
- 加入模块链表:内核记录这个模块已加载
rmmod 做了什么?
bash
rmmod hello
- 检查引用计数:有没有其他模块在用?在用就不让卸载
- 执行 module_exit 函数:调用清理函数
- 释放资源:释放模块占用的内核内存
- 从模块链表移除
查看模块信息
bash
# 查看已加载模块
lsmod
# 查看模块详细信息
modinfo hello.ko
# 查看模块依赖
modprobe --show-depends hello
二、__init 和 __exit 标记
__init
c
static int __init hello_init(void) { ... }
标记初始化函数,特点:
- 模块加载完之后,这段内存会被释放掉(只执行一次,后面不用了)
- 节省内核内存,嵌入式很重要
- 只在初始化阶段用的函数和数据都可以加 __init / __initdata
__exit
c
static void __exit hello_exit(void) { ... }
标记退出函数,特点:
- 只在模块卸载时用
- 如果内核编译成不支持模块卸载(
CONFIG_MODULES=n),这段代码直接不编译进去 - 节省空间
其他相关标记
c
__initdata // 初始化数据,用完释放
__initconst // 初始化常量
__devinit // 设备初始化(老内核用,新内核不用了)
三、模块参数(module_param)
加载模块时可以传参数,类似命令行参数。
基本用法
c
#include <linux/moduleparam.h>
static int baudrate = 9600;
module_param(baudrate, int, 0644);
MODULE_PARM_DESC(baudrate, "串口波特率,默认9600");
static char *port_name = "ttyS0";
module_param(port_name, charp, 0644);
MODULE_PARM_DESC(port_name, "端口名称");
加载时传参:
bash
insmod hello.ko baudrate=115200 port_name=ttyS1
参数类型
| 类型宏 | 对应 C 类型 | 说明 |
|---|---|---|
byte |
unsigned char | 字节 |
short |
short | 短整型 |
ushort |
unsigned short | 无符号短整型 |
int |
int | 整型 |
uint |
unsigned int | 无符号整型 |
long |
long | 长整型 |
ulong |
unsigned long | 无符号长整型 |
charp |
char* | 字符串指针 |
bool |
bool | 布尔值(0/1, y/n) |
invbool |
bool | 反转的布尔 |
权限
第三个参数是 sysfs 中的权限:
0644:root 可写,所有人可读0444:只读0:不在 sysfs 显示
加载后可以在 /sys/module/hello/parameters/ 下看到参数。
数组参数
c
static int irq_list[4] = {0};
static int irq_count = 0;
module_param_array(irq_list, int, &irq_count, 0644);
传参:
bash
insmod hello.ko irq_list=10,20,30
四、导出符号(EXPORT_SYMBOL)
一个模块可以导出函数或变量,给其他模块用。
导出符号
c
// module_a.c
int my_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(my_add); // 导出给所有模块用
另一个模块使用
c
// module_b.c
extern int my_add(int a, int b);
static int __init b_init(void)
{
printk("1+2=%d\n", my_add(1, 2));
return 0;
}
EXPORT_SYMBOL vs EXPORT_SYMBOL_GPL
| 宏 | 说明 |
|---|---|
EXPORT_SYMBOL |
所有模块都能用,不管什么许可证 |
EXPORT_SYMBOL_GPL |
只有 GPL 许可证的模块能用 |
内核里大部分函数都是 GPL 导出的,所以你的模块要加 MODULE_LICENSE("GPL") 才能用。
查看导出的符号
bash
# 内核所有导出的符号
cat /proc/kallsyms | grep my_add
# 模块导出的
modinfo module_a.ko
五、模块依赖
模块 A 用了模块 B 导出的符号,那 A 依赖 B。
modprobe 自动处理依赖
bash
# 会自动先加载依赖的模块
modprobe module_a
前提是模块要放在正确位置,并且执行过 depmod 生成依赖关系文件。
insmod 不会自动加载依赖
bash
insmod module_a.ko # 会失败,提示 unknown symbol
# 得手动先加载依赖
insmod module_b.ko
insmod module_a.ko
声明依赖
代码里可以声明:
c
MODULE_ALIAS("别名");
MODULE_SOFTDEP("pre: module_b"); // 软依赖
一般不用手动写,depmod 会自动分析。
六、多文件模块
一个模块可以由多个 .c 文件编译成一个 .ko。
Makefile 写法
makefile
obj-m := mymodule.o
# mymodule 由多个文件组成
mymodule-objs := main.o util.o hw.o
编译出来还是一个 mymodule.ko,但内部是多个 .o 链接的。
目录结构
mymodule/
├── main.c // 模块入口、file_operations
├── util.c // 工具函数
├── util.h
├── hw.c // 硬件操作
├── hw.h
└── Makefile
大一点的驱动都是多文件组织的。
七、模块的状态信息
MODULE_ 系列宏
c
MODULE_LICENSE("GPL"); // 许可证(必须)
MODULE_AUTHOR("Heipiao"); // 作者
MODULE_DESCRIPTION("xxx 驱动"); // 描述
MODULE_VERSION("1.0"); // 版本
MODULE_ALIAS("xxx"); // 别名
MODULE_DEVICE_TABLE(i2c, xxx_id); // 设备表(总线驱动用)
查看模块信息
bash
modinfo hello.ko
输出类似:
filename: hello.ko
description: Hello World Module
author: Heipiao
license: GPL
srcversion: ...
depends:
vermagic: 5.4.0 SMP mod_unload ARM
parm: baudrate:int
MODULE_LICENSE 的重要性
- 没加或者不是 GPL,很多内核函数用不了(unknown symbol 错误)
- 影响内核的「污染」状态,非 GPL 模块加载内核会标记为 tainted
- 商业驱动可以用
MODULE_LICENSE("Proprietary"),但只能用非 GPL 导出的函数
八、内核打印:printk 详解
日志级别
c
printk(KERN_EMERG "紧急消息\n"); // 0 最高
printk(KERN_ALERT "警报\n"); // 1
printk(KERN_CRIT "严重错误\n"); // 2
printk(KERN_ERR "错误\n"); // 3
printk(KERN_WARNING "警告\n"); // 4
printk(KERN_NOTICE "注意\n"); // 5
printk(KERN_INFO "信息\n"); // 6
printk(KERN_DEBUG "调试\n"); // 7 最低
控制台显示级别
控制台只显示级别高于某个值的消息:
bash
# 查看当前控制台级别
cat /proc/sys/kernel/printk
# 7 4 1 7
# 当前控制台级别 / 默认消息级别 / 最低控制台级别 / 默认启动级别
数字越小级别越高。控制台级别是 4 的话,只有 0-3 级(错误及以上)才会打印到控制台,其他的用 dmesg 看。
pr_ 系列宏
更方便的封装:
c
pr_err("错误: %d\n", err);
pr_warn("警告\n");
pr_info("信息\n");
pr_debug("调试\n");
比 printk 短,自动加级别。驱动里推荐用 pr_ 系列或者 dev_ 系列。
dev_ 系列(设备驱动用)
跟具体设备绑定的打印,会自动带上设备名:
c
dev_err(dev, "初始化失败\n");
dev_info(dev, "probe成功\n");
字符设备、平台设备驱动里常用,方便定位是哪个设备出的问题。
九、常见模块操作命令
加载
bash
insmod xxx.ko # 简单加载,不处理依赖
modprobe xxx # 加载,自动处理依赖
modprobe -r xxx # 卸载
卸载
bash
rmmod xxx # 卸载
modprobe -r xxx # 卸载,连带卸载没用的依赖
查看
bash
lsmod # 列出所有已加载模块
modinfo xxx.ko # 模块信息
cat /proc/modules # 模块列表
cat /sys/module/xxx/ # 模块的 sysfs 目录
依赖管理
bash
depmod # 生成模块依赖关系
depmod -a # 扫描所有模块
十、模块开发注意事项
1. 初始化失败要回滚
初始化过程中某一步失败了,前面分配的资源要释放掉:
c
static int __init xxx_init(void)
{
int ret;
ret = step1();
if (ret) return ret;
ret = step2();
if (ret) goto err_step1;
ret = step3();
if (ret) goto err_step2;
return 0;
err_step2:
undo_step2();
err_step1:
undo_step1();
return ret;
}
内核驱动里 goto 很常用,用来做错误回滚。
2. 退出函数要清理干净
module_exit 里要把 init 里申请的所有资源都释放:
- 注销设备
- 释放内存
- 释放中断
- 注销定时器
- 取消工作队列
漏了就内存泄漏,甚至内核 panic。
3. 静态函数和变量
模块内部的函数和变量尽量 static,避免污染内核命名空间。
4. 注意并发
初始化一般是单线程的,但驱动跑起来后可能多核并发访问,后面讲并发同步的时候细说。
5. 版本魔数(vermagic)
.ko 文件里有版本信息,跟内核不匹配会加载失败:
insmod: ERROR: could not insert module xxx.ko: Invalid module format
原因:编译用的内核版本、配置跟运行的不一致。必须用对应开发板的内核源码来编译。
十一、完整的模块示例
c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/kernel.h>
static int count = 1;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "打印次数,默认1");
static char *message = "Hello";
module_param(message, charp, 0444);
MODULE_PARM_DESC(message, "打印的消息");
static int __init hello_init(void)
{
int i;
for (i = 0; i < count; i++) {
pr_info("%s, Kernel! (%d/%d)\n", message, i+1, count);
}
return 0;
}
static void __exit hello_exit(void)
{
pr_info("Goodbye, Kernel!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Heipiao");
MODULE_DESCRIPTION("带参数的Hello模块");
MODULE_VERSION("1.0");
测试:
bash
insmod hello.ko count=3 message=Hi
dmesg
# Hi, Kernel! (1/3)
# Hi, Kernel! (2/3)
# Hi, Kernel! (3/3)
rmmod hello
十二、本篇小结
- 模块加载:insmod 读取 .ko → 分配内存 → 重定位 → 解析符号 → 执行 module_init
__init/__exit:标记初始化和退出代码,优化内存占用- 模块参数:module_param 传参,支持多种类型,加载时可配置
- 导出符号:EXPORT_SYMBOL / EXPORT_SYMBOL_GPL,模块之间共享函数
- 模块依赖:modprobe 自动处理,insmod 要手动按顺序加载
- 多文件模块:xxx-objs 指定多个 .o,编译成一个 .ko
- printk 有 8 个日志级别,推荐用 pr_ / dev_ 系列宏
- 初始化失败要回滚,退出要清理干净,注意资源释放
- 内核版本不匹配会加载失败(vermagic 错误)
下一篇讲字符设备驱动:file_operations、open/read/write 的实现,以及和用户空间的数据交互。
我是黒漂技术佬。