4、内核模块开发基础:模块编程框架与调试手段

内核模块开发,说白了就是给Linux内核“打补丁”或者“加插件”。你不需要重新编译整个内核,就能动态加载一段代码进去。我刚开始接触时也觉得挺神奇——一个.ko文件,insmod一下,内核就多了个新功能。这背后的核心就是module_init和module_exit这两个宏。

4.1 模块编程框架:入口与出口

每个内核模块都必须有两个基本函数:一个初始化函数,一个退出函数。它们通过宏注册到内核框架中。

#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标记的函数第二次就不见了——嗯,它已经被内核回收了。
  • module_init告诉内核:加载模块时调用这个函数。module_exit则告诉内核:卸载模块时调用这个函数。
  • 返回值必须小心。初始化成功返回0,失败返回负数错误码。我曾经犯过一个低级错误:初始化函数返回了1,结果insmod显示“Operation not permitted”,查了半天才发现是返回值不对。

核心原则:初始化函数必须保证原子性——要么全部成功,要么全部回滚。如果中途失败,之前申请的资源必须全部释放。

4.2 模块参数传递

你写了一个驱动,但不同硬件平台的寄存器地址不一样。难道每次都要改源码重新编译?不用。内核模块支持参数传递,加载时通过命令行指定。

#include <linux/moduleparam.h>

static int irq_num = 10;
static char *device_name = "my_device";

module_param(irq_num, int, 0644);
module_param(device_name, charp, 0644);

MODULE_PARM_DESC(irq_num, "IRQ number for the device");
MODULE_PARM_DESC(device_name, "Device name string");

加载时这样用:

insmod my_driver.ko irq_num=33 device_name="uart0"

参数类型支持int、long、charp(字符串指针)、数组等。第三个参数是文件权限——如果为0,表示参数在sysfs中不可见;如果设为0644,你可以在/sys/module/你的模块名/parameters/下看到并修改它。

我的习惯:调试阶段把参数权限设为0644,这样可以在运行时通过echo命令动态调整参数值,不用反复卸载加载模块。但产品发布前,我会改成0,避免用户误操作。

4.3 模块依赖与符号导出

模块之间可以互相调用。比如你写了一个核心算法模块,另一个模块想用它的函数。这就涉及到符号导出。

// 在模块A中
int calculate_crc(const char *data, int len)
{
    // CRC计算逻辑
    return result;
}
EXPORT_SYMBOL(calculate_crc);

模块B只需要声明这个函数,链接时就能找到它。但要注意:

  • 模块B加载前,模块A必须已经加载。否则内核会报“Unknown symbol”错误。
  • 使用EXPORT_SYMBOL_GPL可以限制只有GPL许可的模块才能调用。

查看模块依赖关系,用lsmod命令:

lsmod | grep my_module

它会显示模块的引用计数和被哪些模块使用。我曾经在调试一个网络驱动时,发现模块卸载不掉,引用计数一直是1。查了半天,原来是另一个模块持有了它的符号引用,却没有释放。

避坑指南:模块卸载时,一定要确保没有其他模块在使用你导出的符号。否则内核会panic。我遇到过生产环境因为这个问题导致系统重启——从那以后,我每次卸载模块前都会用lsmod确认引用计数为0。

4.4 内核打印(printk)与日志级别

printk是内核模块的printf。但它比printf复杂——它有日志级别,用来控制消息的紧急程度和显示位置。

宏定义 数值 含义 典型场景
KERN_EMERG 0 紧急事件 系统崩溃前
KERN_ALERT 1 必须立即处理 严重硬件故障
KERN_CRIT 2 临界条件 驱动初始化失败
KERN_ERR 3 错误 I/O操作失败
KERN_WARNING 4 警告 参数超出范围
KERN_NOTICE 5 正常但重要 模块加载成功
KERN_INFO 6 信息 调试信息
KERN_DEBUG 7 调试 详细日志

日志级别决定了消息是否显示在控制台。内核有一个console_loglevel变量,只有级别低于它的消息才会打印到控制台。查看当前控制台级别:

cat /proc/sys/kernel/printk
# 输出示例:4 4 1 7
# 分别表示:控制台级别、默认级别、最低级别、启动时级别

如果你想看到所有调试信息,可以临时调高级别:

echo 8 > /proc/sys/kernel/printk

我的调试习惯:开发阶段把printk级别设为KERN_DEBUG,同时把控制台级别调到8。这样所有日志都能看到。但产品发布时,我会把调试信息全部去掉或用pr_debug替代——pr_debug在非调试内核中会被编译掉,不影响性能。

printk的输出可以通过dmesg命令查看。它会把内核日志环缓冲区的内容打印出来。注意:环缓冲区大小有限,如果日志太多,早期的消息会被覆盖。

dmesg | tail -20   # 查看最近20条内核日志
dmesg -c           # 查看并清空日志缓冲区

4.5 知识结构总览

下面这张图把本章的核心知识点串了起来。你可以把它当作模块开发的“地图”——从编写代码到加载运行,每一步都有对应的机制。

内核模块开发核心框架 module_init / module_exit 初始化函数(__init) 退出函数(__exit) 模块参数传递 符号导出(EXPORT_SYMBOL) printk 日志级别 参数类型 int / charp / array sysfs 运行时修改 依赖关系 lsmod 查看引用计数 EXPORT_SYMBOL_GPL 限制 日志级别 0-7 console_loglevel 控制 dmesg 查看环缓冲区 insmod → 初始化 → 运行 → rmmod → 退出

4.6 避坑总结

最后,我把这些年踩过的坑整理成一张表,你写模块时对照着检查,能省不少时间。

常见问题 现象 根因 解决方案
模块加载失败 insmod返回错误 初始化函数返回非0值 检查返回值,确保成功返回0
模块卸载不掉 rmmod提示“Module in use” 引用计数不为0 用lsmod查看谁在使用,先卸载依赖模块
Unknown symbol 加载时报符号未定义 依赖模块未加载或符号未导出 先加载依赖模块,或检查EXPORT_SYMBOL
printk不显示 控制台看不到日志 日志级别高于console_loglevel 调高console_loglevel或用dmesg查看
模块参数无效 insmod传参后值不对 参数类型不匹配或权限为0 检查module_param类型和权限设置

最后提醒一句:内核模块运行在特权级,一个空指针就能让整个系统挂掉。我建议你在开发机上先测试,确认无误后再部署到目标设备。调试时多用printk,但上线前记得清理——不然生产环境的内核日志会被你的调试信息刷爆。