3. 硬件接口与通信协议

内核模块,说白了就是一段可以动态插拔的内核代码。你不用重新编译整个内核,就能给系统加功能。我刚开始接触时也觉得挺神奇——一个 .ko 文件,insmod 一下,驱动就跑起来了。但这里面的门道,远不止敲几个命令那么简单。

核心要点:内核模块不是普通程序。它没有 main 函数,运行在内核空间,出错就直接 panic。写模块,得先懂它的骨架。

模块的基本结构

每个内核模块都有两个必须的入口:初始化和退出。就像你开门进屋,走的时候得关门一样。

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

static int __init my_driver_init(void)
{
    printk(KERN_INFO "Hello, this is my driver loaded\n");
    return 0;
}

static void __exit my_driver_exit(void)
{
    printk(KERN_INFO "Goodbye, my driver 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 这两个宏,其实是在告诉内核:初始化函数用完后,占的内存可以回收了。我在项目中遇到过有人忘了加 __exit,结果模块卸载时函数指针乱飞,直接 oops。

小技巧:printk 的日志级别很重要。KERN_INFO 只是普通信息,调试时用 KERN_DEBUG,配合 dmesg 查看,别在生产环境乱刷日志。

模块加载与卸载

加载卸载看似简单,但背后内核做了不少事。我习惯用下面这套命令组合:

操作 命令 说明
加载模块 insmod my_driver.ko 直接插入,不处理依赖
加载模块(带依赖) modprobe my_driver 自动解决依赖关系
卸载模块 rmmod my_driver 调用 exit 函数
查看已加载模块 lsmod 显示模块列表及使用计数
查看模块信息 modinfo my_driver.ko 显示作者、描述、参数等

你想想看,为什么有时候 rmmod 会提示 "Module is in use"?那是因为有其他模块或进程在引用它。我曾经调试一个网卡驱动,死活卸载不了,最后发现是网络服务还在占用接口。先停服务,再卸载,问题解决。

注意:千万不要在模块 exit 函数里留下未释放的资源。内存泄漏在内核空间是致命的,系统不会帮你回收。我见过有人忘了 kfree,结果模块反复加载卸载几次后,系统直接 OOM。

模块参数传递

写死的驱动不够灵活。比如你想让用户指定中断号或缓冲区大小,怎么办?内核模块支持参数传递,用 module_param 宏就行。

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

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

MODULE_PARM_DESC(irq_num, "IRQ number for the device");
MODULE_PARM_DESC(device_name, "Device name string");
MODULE_PARM_DESC(debug_mode, "Enable debug mode (0=off, 1=on)");

加载时这样传参:

insmod my_driver.ko irq_num=7 device_name="eth0" debug_mode=1

参数权限 0644 表示可以在 /sys/module/ 下动态修改。嗯,这里要注意,charp 类型的参数如果传字符串,内核会拷贝一份,别担心指针失效。我个人的习惯是,所有可调参数都加上 MODULE_PARM_DESC,方便后来人看 modinfo 就知道怎么用。

模块依赖与符号导出

模块之间可以互相调用。A 模块导出函数,B 模块就能用。这靠的是 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL。

// 在模块 A 中
int my_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(my_add);

// 在模块 B 中
extern int my_add(int a, int b);

static int __init b_init(void)
{
    int result = my_add(3, 5);
    printk("Result from module A: %d\n", result);
    return 0;
}

EXPORT_SYMBOL_GPL 更严格,只有声明了 GPL 协议的模块才能调用。我在项目中遇到过一个问题:第三方闭源驱动想调用我们导出的函数,结果链接时报错——因为我们用的是 EXPORT_SYMBOL_GPL。后来协商后改成了 EXPORT_SYMBOL,才解决。

依赖关系可以用 modprobe 自动处理,它会读取 /lib/modules/$(uname -r)/modules.dep 文件。你也可以手动查看:

depmod          # 生成依赖文件
modprobe -r my_driver   # 卸载时连带卸载依赖模块

建议:写模块时尽量少导出符号。导出的符号越多,模块间的耦合越重,维护起来越头疼。我一般只在核心层导出必要接口,具体驱动都通过回调函数注册。

知识体系总览

下面这张图把本章的核心脉络串起来了。从模块骨架到加载卸载,再到参数和依赖,每一步都有对应的内核机制。

Linux 内核模块开发核心知识体系 模块结构 module_init / module_exit 加载与卸载 insmod / rmmod / modprobe 参数传递 module_param / sysfs 依赖与符号导出 EXPORT_SYMBOL / depmod 关键流程 编写模块代码 → 编译生成 .ko → insmod 加载 → 参数配置 → 功能运行 → rmmod 卸载 依赖关系由 depmod 维护,modprobe 自动处理 编译环境 Kbuild / Makefile 调试手段 printk / dmesg / /proc

避坑指南

  • 模块卸载不干净:我曾经在 exit 函数里忘了销毁 workqueue,结果模块卸载后内核线程还在跑,系统日志疯狂报错。解决方案:在 exit 里按初始化的逆序释放所有资源。
  • 参数类型不匹配:module_param 的第三个参数是权限,不是默认值。我见过有人写成 module_param(irq_num, int, 10),结果 irq_num 始终是 0。正确写法:module_param(irq_num, int, 0644)。
  • 符号冲突:两个模块导出同名函数,加载第二个时会报错。我建议给自己的符号加独特前缀,比如 mydrv_xxx,避免和内核或其他模块重名。
  • Makefile 写错:内核模块的 Makefile 有固定套路。我刚开始总是忘记定义 CONFIG_MODULE_SIG,结果编译出来的模块在安全启动的机器上加载不了。

严重警告:不要在模块 init 函数里做耗时操作。内核模块初始化时持有模块锁,如果卡住,整个模块加载机制都会阻塞。我见过有人在 init 里用 msleep(5000),结果系统 hang 了 5 秒。如果需要延时,请用 workqueue 或 timer。