3. 硬件接口与总线

大家好,欢迎来到第三章。这一章我们聊聊内核模块的基础。说白了,驱动开发就是跟内核模块打交道。你写一个驱动,最终编译成一个 .ko 文件,然后加载进内核。这个过程看似简单,但里面的门道不少。

我记得刚入行那会儿,第一次写模块,连 module_init 和 module_exit 的顺序都搞反了,结果加载倒是成功了,卸载的时候直接 kernel panic。嗯,从那以后,我对这两个宏就格外上心。

模块加载与卸载:insmod 和 rmmod 背后发生了什么?

我们先看最直观的部分。你写好了模块,编译出 hello.ko,然后执行:

sudo insmod hello.ko
sudo rmmod hello.ko

这两个命令,背后到底干了什么?

  • insmod:调用 init_module() 系统调用。内核会分配内存,把 .ko 文件的内容映射到内核空间,然后解析符号表,解决依赖关系。最后,调用模块里注册的初始化函数。
  • rmmod:调用 delete_module() 系统调用。内核会检查模块的引用计数,如果为 0,就调用模块的清理函数,然后释放内存。

这里有个关键点:引用计数。如果其他模块正在使用你的模块提供的函数,引用计数就不为 0,rmmod 会失败。我遇到过这种情况,调试了半天,发现是另一个驱动在调用我的接口,忘了释放。

小技巧:用 lsmod 可以查看当前加载的模块列表和引用计数。用 modinfo 可以查看模块的详细信息,比如作者、许可证、参数等。

module_init 与 module_exit:入口和出口的约定

每个内核模块都必须有两个核心宏:module_init() 和 module_exit()。它们告诉内核,模块加载时执行哪个函数,卸载时执行哪个函数。

看一个最简单的例子:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel!\n");
}

module_init(hello_init);
module_exit(hello_exit);

注意两个细节:

  • __init 和 __exit 标记。加了 __init 的函数,在初始化完成后,内核会释放它占用的内存。这对节省内存很有帮助,尤其是嵌入式设备。
  • 初始化函数返回 int,返回 0 表示成功,负数表示失败。清理函数返回 void。

为什么会这样设计?你想想看,如果初始化失败,内核需要知道,这样才能回滚操作。我见过有人初始化失败返回 0,结果模块虽然加载了,但功能完全不正常,排查起来非常痛苦。

注意:module_init 和 module_exit 不是函数,是宏。它们会把你指定的函数地址放到特定的 ELF 段里。内核在加载和卸载时,会从这些段里找到对应的函数指针并调用。

模块许可证与作者信息:别让你的模块变成“脏”模块

内核模块必须声明许可证。这不是形式主义,而是法律问题。GPL 许可证保证了内核的自由和开源。如果你的模块使用了 GPL-only 的符号,却没有声明 GPL,内核会拒绝加载。

常用的宏有:

MODULE_LICENSE("GPL");
MODULE_LICENSE("GPL v2");
MODULE_LICENSE("Dual BSD/GPL");
MODULE_LICENSE("Proprietary");

我个人习惯,只要是开源项目,一律用 "GPL"。如果你用的是商业内核,可能需要咨询法务。

除了许可证,还有作者信息和描述:

MODULE_AUTHOR("Your Name <your.email@example.com>");
MODULE_DESCRIPTION("A simple hello world module");
MODULE_VERSION("1.0");

这些信息可以通过 modinfo 查看。别小看这些信息,当你的模块被分发到不同客户手里,出了问题,他们可以通过作者信息找到你。我曾经接手过一个项目,模块里作者信息是空的,出了问题都不知道找谁,最后只能翻 git 历史。

模块参数传递:让驱动更灵活

写驱动的时候,你不可能把所有配置都硬编码。比如,一个串口驱动,波特率可能是 9600 或 115200。这时候,模块参数就派上用场了。

内核提供了 module_param() 宏,让你可以在加载模块时传递参数。

#include <linux/moduleparam.h>

static int debug = 0;
static char *name = "default";

module_param(debug, int, 0644);
module_param(name, charp, 0644);

MODULE_PARM_DESC(debug, "Enable debug output (0=off, 1=on)");
MODULE_PARM_DESC(name, "Device name");

加载时这样用:

sudo insmod mymodule.ko debug=1 name="mydevice"

参数类型支持:byte、short、ushort、int、uint、long、ulong、charp(字符串指针)、bool、invbool。

第三个参数是权限掩码,比如 0644。如果权限不为 0,参数会在 /sys/module/<module_name>/parameters/ 下创建对应的 sysfs 文件,运行时可以修改。

避坑指南:我曾经在调试一个网络驱动时,把参数类型写成了 int,但实际传了一个很大的值,结果发生了整数溢出,导致驱动行为异常。排查了很久才发现是参数类型不匹配。所以,参数类型一定要和实际使用的一致。

知识体系总览

下面这张图,把本章的核心知识点串了起来。你可以看到,一个内核模块从编写到加载、运行、卸载的完整流程,以及每个环节涉及的关键机制。

内核模块生命周期与核心机制 模块编写 .c 源文件 + Makefile 编译 生成 .ko 文件 加载 (insmod) 调用 module_init 运行时状态 模块参数 module_param() 许可证声明 MODULE_LICENSE 作者信息 MODULE_AUTHOR 引用计数 lsmod 卸载 (rmmod) 调用 module_exit 关键机制:__init / __exit 标记 | module_param 类型与权限 | MODULE_LICENSE 合规性 insmod → init_module() 系统调用 → 分配内存 → 解析符号 → 调用 init 函数

总结一下

这一章我们覆盖了内核模块的四个核心方面:

知识点 核心要点 常见坑
加载与卸载 insmod/rmmod 对应 init_module/delete_module 系统调用 引用计数不为 0 时无法卸载
module_init/exit __init/__exit 标记,初始化返回 int,清理返回 void 初始化失败返回 0 会导致模块状态异常
许可证与作者 MODULE_LICENSE 必须声明,否则无法使用 GPL-only 符号 许可证不匹配导致加载失败
模块参数 module_param 支持多种类型,权限掩码控制运行时修改 参数类型与实际值不匹配导致溢出或异常