内核模块编程基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出
说实话,很多刚接触嵌入式Linux驱动开发的朋友,第一个拦路虎就是「内核模块」。大家总觉得内核是个神秘的东西,模块加载卸载更是玄学。其实没那么复杂——内核模块说白了就是一段可以在运行时动态插入内核的代码。我当年第一次写Hello World模块时,看着insmod之后终端打印出那行字,心里还挺激动的。
一、模块的加载与卸载
先看一个最基础的模块框架。你写驱动,本质上就是实现两个函数:入口和出口。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "Hello, kernel module loaded!\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module unloaded!\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");
这里有几个关键点,我重点说一下:
__init和__exit宏:这两个宏会把函数放到特定的内存段。__init标记的函数在模块加载完成后会被内核释放掉,省内存。我在项目里见过有人把大数组也放在__init段里,结果模块加载完数据就丢了——嗯,这是个坑。module_init和module_exit:这是告诉内核「我的入口和出口在哪儿」。注意,不是函数名匹配,而是通过这两个宏注册的。MODULE_LICENSE:不写这个,内核会报「tainted kernel」警告。我个人习惯一律写"GPL",省事。
编译完成后,你会得到 .ko 文件。操作命令很简单:
| 操作 | 命令 | 说明 |
|---|---|---|
| 加载模块 | insmod my_module.ko |
直接插入,不处理依赖 |
| 卸载模块 | rmmod my_module |
按模块名卸载 |
| 查看已加载模块 | lsmod |
显示模块列表及依赖关系 |
| 带依赖加载 | modprobe my_module |
自动处理依赖,推荐使用 |
避坑提醒:我曾经在调试一个I2C驱动时,用
insmod 加载后系统直接panic。后来发现是模块的init函数里访问了一个未初始化的指针。记住:init函数里一定要做错误检查,返回非0值表示加载失败,内核会帮你回滚。
二、模块参数传递
你写驱动时,有些配置不想写死在代码里——比如设备地址、缓冲区大小、调试开关。这时候就需要模块参数。
内核提供了 module_param 和 module_param_array 宏,用法很直接:
#include <linux/moduleparam.h>
static int debug_level = 0;
static char *device_name = "default_dev";
static int gpio_pins[4] = {17, 18, 19, 20};
static int gpio_count;
module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);
module_param_array(gpio_pins, int, &gpio_count, 0644);
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");
MODULE_PARM_DESC(gpio_pins, "GPIO pin numbers array");
加载时传参:
insmod my_module.ko debug_level=2 device_name="my_i2c_dev" gpio_pins=21,22,23,24
这里有几个细节:
- 权限位
0644:表示这个参数在/sys/module/<模块名>/parameters/下可见,用户可以通过sysfs在运行时修改。如果不想暴露,传0就行。 charp:这是内核为字符串参数定义的类型,本质是char *。注意,传进来的字符串是内核分配的,你不需要手动释放。module_param_array:第三个参数是数组长度变量的指针,内核会把实际传入的元素个数写进去。我遇到过有人忘了传这个指针,结果数组越界——嗯,调试了一下午。
我的习惯:调试阶段把所有参数权限都设成
0644,这样可以在 /sys 下直接 echo 修改,不用反复卸载加载模块。等代码稳定了再改成 0。
三、模块依赖与符号导出
实际项目中,很少有模块是孤立的。比如你写一个GPIO子系统驱动,上层的外设驱动需要调用你提供的接口。这时候就需要符号导出。
内核用 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 来导出符号:
// gpio_ctrl.c
#include <linux/module.h>
int gpio_set_value(int pin, int value)
{
// 实际硬件操作
printk(KERN_DEBUG "GPIO %d set to %d\n", pin, value);
return 0;
}
EXPORT_SYMBOL(gpio_set_value);
int gpio_get_value(int pin)
{
return 1; // 模拟读取
}
EXPORT_SYMBOL_GPL(gpio_get_value);
另一个模块就可以直接调用:
// led_driver.c
extern int gpio_set_value(int pin, int value);
static int __init led_init(void)
{
gpio_set_value(18, 1);
return 0;
}
模块依赖关系通过 depmod 工具管理,modprobe 加载时会自动先加载依赖模块。你可以在 /lib/modules/$(uname -r)/modules.dep 里看到依赖关系。
| 导出宏 | 适用范围 | 说明 |
|---|---|---|
EXPORT_SYMBOL |
所有模块 | 任何模块都可以使用 |
EXPORT_SYMBOL_GPL |
仅GPL模块 | 非GPL模块调用会报错 |
我曾经踩过的坑:在一个商业项目中,我们导出了一个核心函数用
EXPORT_SYMBOL,结果第三方模块直接调用了我们的内部接口,导致系统不稳定。后来全部改成 EXPORT_SYMBOL_GPL,并在代码里加了调用者检查。记住:符号导出就是你的API边界,设计时要想清楚哪些是公开的,哪些是内部的。
四、知识体系总览
下面这张图把本章的核心逻辑串起来了。你写驱动时,心里要有这个流程:
核心要点回顾:
- 每个模块必须有
module_init和module_exit,这是内核与模块的契约 - 模块参数让驱动配置灵活,调试阶段建议开放sysfs权限
- 符号导出要谨慎,能用
EXPORT_SYMBOL_GPL就别用EXPORT_SYMBOL - 依赖管理用
modprobe+depmod,别手动insmod一堆模块
好了,这一章的内容就这些。你先把这几个基础操作练熟,后面写字符设备驱动、中断处理、并发控制时,这些知识会反复用到。记住:模块编程是驱动开发的基石,基础打牢了,后面才能走得稳。