3. 内核模块开发基础:框架、机制与依赖管理

内核模块开发,说白了就是给Linux内核“打补丁”的能力。你不用重新编译整个内核,就能动态加载或卸载一段代码。这个机制在嵌入式领域特别实用——产品量产之后发现需要加个驱动,或者要临时调试某个硬件,直接insmod一个模块就行,不用重启系统。

我个人习惯把内核模块看作“内核的插件系统”。你写一个.ko文件,它就能跑在内核空间,拥有内核的全部权限。但这也意味着——你写崩了,系统也就崩了。嗯,这里要小心。

3.1 内核模块的基本框架

一个标准的内核模块,结构其实很简单。我刚开始学的时候觉得它跟普通C程序差别不大,但有几个关键点完全不同。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init my_module_init(void)
{
    printk(KERN_INFO "Hello, module loaded!\n");
    return 0;
}

static void __exit my_module_exit(void)
{
    printk(KERN_INFO "Goodbye, module unloaded!\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");

你看,这个框架就三个核心部分:入口函数、出口函数、模块信息声明。入口函数用module_init()注册,出口函数用module_exit()注册。加载模块时内核会调用你注册的init函数,卸载时调用exit函数。

关键点: __init和__exit这两个宏,会把函数放到特定的代码段。init函数执行完后,内核会释放它占用的内存。这在嵌入式设备上很实用——省一点是一点。

3.2 module_init / module_exit 机制

这两个宏到底干了什么?说白了,它们把你的函数指针注册到内核的一个全局表中。加载模块时,内核遍历这个表,找到对应的init函数并执行。

我曾经在调试一个SPI驱动时遇到过一个问题:模块加载成功了,但init函数里的硬件初始化没执行。查了半天,发现是module_init()宏写成了小写,编译器没报错,但内核根本找不到入口。嗯,这种坑踩过一次就记住了。

宏 作用 执行时机 返回值
module_init() 注册模块入口函数 insmod时执行 0成功,负值失败
module_exit() 注册模块出口函数 rmmod时执行 void

注意: init函数返回非0值时,内核会认为模块加载失败,自动回滚所有已分配的资源。但exit函数如果出错了……嗯,内核不会给你第二次机会。所以exit函数里一定要做好错误处理。

3.3 模块参数传递

写驱动的时候,你经常需要让用户指定一些参数——比如设备地址、中断号、缓冲区大小。内核模块支持通过module_param()宏来声明参数。

#include <linux/moduleparam.h>

static int irq_num = 10;
static char *device_name = "mydev";
static int debug_level = 0;

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

MODULE_PARM_DESC(irq_num, "IRQ number for the device");
MODULE_PARM_DESC(device_name, "Device name string");
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");

加载时这样传参:

insmod my_module.ko irq_num=17 device_name="spi0" debug_level=2

第三个参数0644是文件权限——参数会在/sys/module/<模块名>/parameters/下暴露为文件,运行时可以读写。我个人习惯把调试开关做成参数,这样产品出问题时,不用重新编译,直接echo写一下就能打开调试日志。

小技巧: 参数类型支持int、long、charp(字符串指针)、bool等。字符串参数要注意,内核模块里不能直接修改字符串常量,否则会触发Oops。我建议用charp时配合kstrdup()做一次拷贝。

3.4 模块依赖关系

模块之间可以互相调用。比如你写了一个I2C核心模块,另一个I2C设备驱动模块需要调用它的API。这时候就需要声明依赖关系。

依赖关系通过符号导出实现。一个模块用EXPORT_SYMBOL()导出函数或变量,其他模块就能直接使用。

// i2c_core.c
int i2c_transfer(struct i2c_msg *msgs, int num)
{
    // 实际传输逻辑
}
EXPORT_SYMBOL(i2c_transfer);

// i2c_device.c
extern int i2c_transfer(struct i2c_msg *msgs, int num);

static int __init device_init(void)
{
    struct i2c_msg msg;
    i2c_transfer(&msg, 1);
    return 0;
}

编译时,依赖关系通过MODULE_DEPEND或Makefile里的KBUILD_EXTRA_SYMBOLS来管理。加载时,insmod会自动解析依赖,先加载被依赖的模块。

机制 说明 使用场景
EXPORT_SYMBOL 导出符号给所有模块 通用API,如I2C、SPI核心
EXPORT_SYMBOL_GPL 只导出给GPL模块 保护知识产权
MODULE_DEPEND 声明依赖关系 模块间强依赖
request_module 运行时动态加载依赖 按需加载,如USB设备插入

我曾经在项目中遇到一个循环依赖的问题:模块A依赖模块B,模块B又依赖模块A。内核加载器会直接报错,拒绝加载。解决办法是把公共部分抽出来做成第三个模块,或者用request_module()在运行时延迟加载。

3.5 知识结构总览

下面这张图把本章的核心知识点串起来了。你从左上角开始看,就能理解一个内核模块从编写到加载、再到卸载的完整流程。

内核模块开发核心知识结构 模块基本框架 module_init / module_exit __init / __exit 段 MODULE_LICENSE / AUTHOR 模块参数传递 module_param 宏 MODULE_PARM_DESC /sys/module/ 运行时修改 模块依赖关系 EXPORT_SYMBOL EXPORT_SYMBOL_GPL request_module 动态加载 开发流程总结 1. 编写 .c 文件:包含 init/exit 函数、参数声明、符号导出 2. 编写 Makefile:使用内核构建系统编译为 .ko 文件 3. insmod 加载:内核执行 init 函数,注册设备/驱动 4. 运行时管理:通过 /sys/module/ 调整参数 5. rmmod 卸载:内核执行 exit 函数,释放资源

3.6 避坑指南

最后分享几个我踩过的坑,希望能帮你省点时间:

  • 不要在内核模块里用标准C库函数——比如printf()、malloc()。内核有自己的API:printk()、kmalloc()。我曾经把printf写进模块,编译通过了,但加载时直接Oops。
  • 注意GPL污染——如果你的模块用了EXPORT_SYMBOL_GPL导出的符号,你的模块也必须声明GPL协议。否则内核会拒绝加载,打印"module uses symbol from GPL-only symbol"。
  • 模块卸载时要释放所有资源——包括内存、中断、定时器。漏掉一个,下次加载时可能就冲突了。我习惯在exit函数里按初始化的逆序释放资源。
  • 调试时多用printk加级别——KERN_DEBUG、KERN_INFO、KERN_ERR。产品发布前关掉DEBUG级别的日志,用参数控制开关,方便现场排查问题。

内核模块开发的门槛其实不高,但细节决定成败。把框架搭对了,参数传明白了,依赖关系理清了,后面写具体驱动的时候就顺畅多了。