2. 内核模块基础:模块加载/卸载、module_init/module_exit机制、许可证与作者信息
好,咱们正式开始写代码了。不过先别急着敲键盘,内核驱动和普通应用程序不一样——它不是跑在用户空间的进程,而是内核的一部分。说白了,你写的驱动是要住进操作系统内核这个“大房子”里的。那怎么住进去、怎么搬出来?这就是内核模块要解决的问题。
我个人习惯把内核模块理解成“乐高积木”。你需要的时候插上去,不需要的时候拔下来,不用重新编译整个内核。这在实际开发中太重要了。我记得刚入行时,改一个驱动就要重新编译内核、重启机器,一天下来大半时间都在等重启。后来用了模块机制,效率翻了好几倍。
2.1 什么是内核模块
内核模块(Kernel Module)是一段可以在运行时动态加载到内核的代码。它没有main函数,取而代之的是两个特殊的入口和出口函数。
| 概念 | 说明 |
|---|---|
| 模块加载 | 将模块的代码和数据复制到内核空间,执行初始化 |
| 模块卸载 | 执行清理操作,释放资源,从内核空间移除 |
| 动态特性 | 无需重新编译内核,无需重启系统 |
| 内核版本依赖 | 模块必须与当前内核版本匹配,否则无法加载 |
核心要点:模块是内核的一部分,拥有最高权限。写模块时一个空指针解引用,不是段错误——是内核崩溃。嗯,这一点要刻在脑子里。
2.2 最简单的内核模块
来看一个最基础的模块代码。我每次教新人都会先让他们写这个,跑通了再谈别的。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Kernel Module!\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, Kernel Module!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name <your@email.com>");
MODULE_DESCRIPTION("A simple Hello World module");
这段代码虽然短,但五脏俱全。咱们拆开来看。
2.3 module_init 和 module_exit 机制
这两个宏是模块的“身份证”。内核通过它们知道:加载时该调用哪个函数,卸载时该调用哪个函数。
| 宏 | 作用 | 调用时机 |
|---|---|---|
module_init() |
注册模块的初始化函数 | insmod 加载模块时 |
module_exit() |
注册模块的清理函数 | rmmod 卸载模块时 |
你可能会问:为什么用宏而不是直接声明函数?其实这些宏背后做了很多事——它们把函数指针放到特定的ELF段里,内核在加载模块时会扫描这些段,找到入口和出口。说白了,这是一种约定,内核和模块之间通过这个约定来沟通。
小技巧:我习惯在初始化函数里用 __init 标记,在清理函数里用 __exit 标记。这两个标记告诉内核:初始化函数用完后可以释放掉,清理函数在模块被编译进内核时直接忽略。能省一点内存是一点。
2.4 许可证与作者信息
MODULE_LICENSE 不是摆设。它决定了你的模块能不能调用某些内核符号。我曾经在一个项目里遇到过模块加载失败,dmesg一看,提示“module uses symbol from proprietary license”。查了半天,原来是同事忘了加许可证声明。
| 许可证 | 含义 | 能否调用GPL符号 |
|---|---|---|
| "GPL" | GNU General Public License v2 | 可以 |
| "GPL v2" | GPL version 2 | 可以 |
| "Dual BSD/GPL" | 双许可证 | 可以 |
| "Proprietary" | 专有许可证 | 不可以 |
避坑指南:我曾经见过有人把许可证写成 "GPL" 但实际代码是闭源的。这种做法不道德,而且一旦被发现,可能会面临法律风险。内核社区对许可证问题非常敏感,别在这上面耍小聪明。
MODULE_AUTHOR 和 MODULE_DESCRIPTION 虽然不影响功能,但建议写上。出问题的时候,别人能通过 modinfo 找到你。我就在一个维护了五年的老驱动里,通过作者信息找到了当初写代码的人,问清楚了一个诡异的bug。
2.5 模块的编译与加载
写好了模块,怎么编译?需要一个 Makefile。这里我给出一个通用的模板:
obj-m += hello.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
编译命令很简单:
make
编译成功后,你会看到 hello.ko 文件。接下来:
- 加载模块:
sudo insmod hello.ko - 查看模块:
lsmod | grep hello - 查看日志:
dmesg | tail - 卸载模块:
sudo rmmod hello
你想想看,整个过程不需要重启,不需要重新编译内核。这就是模块化的魅力。
2.6 模块加载/卸载的完整流程
为了让你更直观地理解整个过程,我画了一张流程图:
2.7 模块参数与导出符号
模块还可以接收参数。比如你想让模块在加载时指定一个缓冲区大小:
static int buffer_size = 1024;
module_param(buffer_size, int, 0644);
MODULE_PARM_DESC(buffer_size, "Buffer size in bytes");
加载时这样传参:
sudo insmod mymodule.ko buffer_size=2048
另外,模块还可以导出符号给其他模块使用:
int my_function(void)
{
return 42;
}
EXPORT_SYMBOL(my_function);
这样其他模块就能调用 my_function 了。不过要注意,导出的符号会污染内核命名空间。我一般只在确实需要跨模块复用时才导出,能少则少。
2.8 常见问题与调试技巧
- 模块加载失败:先看
dmesg,大部分错误信息都在那里。我遇到过的最多的情况是“Unknown symbol”,通常是依赖的模块没加载或者符号没导出。 - 模块卸载不掉:检查是否有进程在使用模块提供的功能。可以用
lsmod看引用计数,如果不为0,强制卸载会出问题。 - 内核崩溃:如果模块导致内核崩溃,重启后用
dmesg查看崩溃信息。我习惯在关键操作前后加printk,定位问题会快很多。
调试建议:开发阶段多用 printk,但上线前记得去掉或者改成 pr_debug。不然生产环境的内核日志会被你的调试信息刷屏,影响性能。
好了,这一章的内容就到这里。模块基础打牢了,后面写字符设备、平台驱动才能得心应手。记住:模块虽小,五脏俱全。把今天的内容吃透,后面的路就好走了。