3. Linux内核模块编程基础:模块加载与卸载、模块参数传递、模块依赖与符号导出、printk调试技巧
内核模块,说白了就是一段可以在系统运行时动态加载进内核的代码。不用重新编译整个内核,不用重启机器,就能给内核加功能。我刚开始接触嵌入式Linux时,觉得这玩意儿特别神奇——一个.ko文件,insmod一下,系统就多了个驱动。后来自己写模块踩了不少坑,才慢慢摸清楚里面的门道。
3.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 World!\n");
return 0;
}
static void __exit my_module_exit(void)
{
printk(KERN_INFO "Goodbye, Kernel World!\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:这两个标记告诉内核,初始化函数和退出函数在完成后可以释放内存。我在项目中见过有人忘了加,结果模块占着内存不放。
- MODULE_LICENSE:不写这个,内核会报“tainted kernel”警告。我曾经因为漏了这一行,被同事追着问“你的模块是不是闭源的?”
编译生成.ko文件后,操作很简单:
| 操作 | 命令 | 说明 |
|---|---|---|
| 加载模块 | insmod my_module.ko | 直接插入内核 |
| 查看已加载模块 | lsmod | 列出所有模块及其依赖 |
| 卸载模块 | rmmod my_module | 从内核移除 |
| 查看模块信息 | modinfo my_module.ko | 显示作者、描述、参数等 |
注意:rmmod卸载时,内核会检查模块的引用计数。如果其他模块或进程正在使用它,卸载会失败。我曾经在调试一个USB驱动时,忘了拔设备就直接rmmod,结果内核直接Oops了。
3.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");
static int __init my_module_init(void)
{
printk(KERN_INFO "Debug level: %d\n", debug_level);
printk(KERN_INFO "Device name: %s\n", device_name);
return 0;
}
加载时传参:
insmod my_module.ko debug_level=2 device_name="my_uart"
参数类型支持:
| 类型宏 | C类型 | 说明 |
|---|---|---|
module_param(name, int, perm) | int | 整型参数 |
module_param(name, charp, perm) | char * | 字符串指针 |
module_param(name, bool, perm) | bool | 布尔值(y/n, 1/0) |
module_param_array(name, type, num, perm) | 数组 | 数组参数,num返回元素个数 |
那个perm参数是文件权限,如果设为0,参数就不会出现在/sys/module/下。我个人习惯设成0644,这样可以在运行时通过sysfs查看和修改。
小技巧:调试阶段,我经常加一个debug_level参数。上线前把默认值设成0,调试时insmod传个3,所有调试信息全开。省得来回改代码。
3.3 模块依赖与符号导出:模块之间怎么协作
一个模块可能需要另一个模块提供的函数。比如你的字符设备驱动要用到某个核心模块的辅助函数。这时候就需要符号导出。
先看导出方:
// 在模块A中
#include <linux/module.h>
#include <linux/kernel.h>
int my_helper_function(int data)
{
return data * 2;
}
EXPORT_SYMBOL(my_helper_function);
// 也可以导出GPL-only的符号
void my_gpl_function(void)
{
printk(KERN_INFO "This is GPL-only\n");
}
EXPORT_SYMBOL_GPL(my_gpl_function);
再看使用方:
// 在模块B中
extern int my_helper_function(int data);
static int __init module_b_init(void)
{
int result = my_helper_function(10);
printk(KERN_INFO "Result: %d\n", result);
return 0;
}
加载顺序很重要。必须先加载模块A,再加载模块B。否则B会报“Unknown symbol”错误。
核心概念:
- EXPORT_SYMBOL:导出符号,任何模块都能用
- EXPORT_SYMBOL_GPL:只允许GPL许可的模块使用
- 符号表:通过
/proc/kallsyms查看所有内核符号 - 依赖关系:
lsmod会显示模块间的依赖链
我曾经在一个项目中,两个模块互相依赖,结果加载时死锁了。后来改成单向依赖,才解决问题。记住:内核模块不允许循环依赖。
3.4 printk调试技巧:内核里的printf
printk是内核模块最常用的调试手段。它和用户空间的printf很像,但有几个关键区别。
先看日志级别:
| 宏 | 数值 | 含义 | 使用场景 |
|---|---|---|---|
KERN_EMERG | 0 | 紧急消息 | 系统崩溃前 |
KERN_ALERT | 1 | 需要立即处理 | 严重错误 |
KERN_CRIT | 2 | 临界条件 | 硬件故障 |
KERN_ERR | 3 | 错误条件 | 驱动错误 |
KERN_WARNING | 4 | 警告 | 非致命问题 |
KERN_NOTICE | 5 | 正常但重要 | 启动信息 |
KERN_INFO | 6 | 信息 | 一般调试 |
KERN_DEBUG | 7 | 调试信息 | 开发阶段 |
使用方式:
printk(KERN_ERR "DMA transfer failed, status: 0x%x\n", status);
printk(KERN_DEBUG "Entering interrupt handler, irq=%d\n", irq);
查看日志:
dmesg:查看内核环形缓冲区dmesg -w:实时跟踪新日志cat /proc/kmsg:直接读取内核消息(需要root)dmesg -n 3:只显示级别≤3的消息(KERN_ERR及以上)
避坑指南:我曾经在中断处理函数里用了printk,结果系统直接卡死。为什么?因为printk可能触发调度,而中断上下文不允许睡眠。记住:中断处理函数里用printk要极其小心,最好用printk_ratelimited或者干脆不用。
几个实用技巧:
- 动态控制日志级别:通过
/proc/sys/kernel/printk文件,可以运行时调整控制台日志级别 - printk_ratelimited:限制打印频率,防止刷屏
- pr_devel/pr_debug:只在DEBUG配置下生效,上线后自动消失
- hex_dump:打印二进制数据,调试协议栈时特别好用
// 限制频率的printk
if (printk_ratelimit()) {
printk(KERN_WARNING "Too many interrupts!\n");
}
// 打印二进制数据
print_hex_dump(KERN_DEBUG, "rx_data: ", DUMP_PREFIX_OFFSET,
16, 1, buffer, len, true);
3.5 本章知识体系
下面这张图把模块编程的核心流程串起来了:
这张图把模块从加载到卸载的完整生命周期画清楚了。你写模块时,脑子里要有这个流程:加载时传了什么参数?依赖的符号有没有先加载?初始化成功了吗?卸载时资源释放干净了吗?每一步都可能出问题。
我的习惯:每个模块的init函数里,我都会加一个printk(KERN_INFO "Module loaded with debug_level=%d\n", debug_level);。这样dmesg一看就知道模块有没有加载成功,参数传对了没有。省得怀疑人生。