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 文件。接下来:

  1. 加载模块:sudo insmod hello.ko
  2. 查看模块:lsmod | grep hello
  3. 查看日志:dmesg | tail
  4. 卸载模块:sudo rmmod hello

你想想看,整个过程不需要重启,不需要重新编译内核。这就是模块化的魅力。

2.6 模块加载/卸载的完整流程

为了让你更直观地理解整个过程,我画了一张流程图:

内核模块加载/卸载完整流程 用户执行 insmod 内核接收模块文件 符号解析与依赖检查 调用 module_init() 初始化成功,模块注册 用户执行 rmmod 调用 module_exit() 并卸载 关键点 模块必须与内核版本匹配 许可证决定符号访问权限 初始化失败则自动回滚 常用命令 insmod / rmmod / lsmod modinfo / modprobe dmesg 查看内核日志

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。不然生产环境的内核日志会被你的调试信息刷屏,影响性能。

好了,这一章的内容就到这里。模块基础打牢了,后面写字符设备、平台驱动才能得心应手。记住:模块虽小,五脏俱全。把今天的内容吃透,后面的路就好走了。