写一个最简单的 Linux 内核模块:从代码到加载卸载

内核模块是什么,为什么需要它

Linux 内核是一个庞大的整体程序。如果每加一个驱动、每支持一种文件系统都要把代码编译进内核、然后重启换内核,那会非常笨重。内核模块(Loadable Kernel Module,LKM)就是为了解决这个问题:它是一段可以在系统运行时动态加载进内核、也能动态卸载的代码,加载后运行在内核态,拥有访问硬件和内核数据结构的权限。

我们平时见到的大量设备驱动、文件系统(比如前面提到的 ntfs-3g 背后的内核支持)、网络协议等,很多都是以内核模块的形式存在的。用 lsmod 就能看到当前系统加载了哪些模块。

下面写一个最小的「Hello World」模块,把整个流程跑通。

编写模块代码

创建一个文件夹命名为 hello,在里面创建 hello.c

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

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

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

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

module_init(hello_init);
module_exit(hello_exit);

逐部分理解这段代码:

  • 三个头文件都来自内核源码(linux/ 而非 C 标准库)。内核模块不能用 glibc 那套用户态库,只能用内核提供的 API,所以这里没有 stdio.h,打印也不是 printf
  • MODULE_LICENSE("GPL") 必须有。内核会检查模块许可证,非 GPL 兼容的模块会让内核被标记为「污染(tainted)」,并且无法使用一些仅对 GPL 模块开放的内核符号。
  • hello_init加载时被调用的入口函数,返回 0 表示初始化成功(返回非零会导致加载失败)。hello_exit卸载时调用的清理函数。
  • __init / __exit 是给内核的提示宏:__init 标记的函数在初始化完成后其占用的内存可被释放,__exit 标记的代码只在支持卸载时才保留。
  • printk 是内核版的 printf,但它不打印到终端,而是写入内核日志缓冲区KERN_INFO 是日志级别(还有 KERN_WARNINGKERN_ERR 等),决定这条消息的重要程度。
  • 最后两行 module_init / module_exit 把上面两个函数注册为模块的入口和出口,内核靠它们知道加载/卸载时该调用谁。

编写 Makefile

内核模块不能用普通 gcc 直接编译,必须借助内核的构建系统(Kbuild)。在 hello 目录下创建 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

说明:

  • obj-m += hello.o 告诉 Kbuild:要把 hello.c 编译成一个模块-m 即 module;如果是编译进内核则是 obj-y)。
  • -C /lib/modules/$(uname -r)/build 切换到当前运行内核的构建目录——编译模块需要和当前内核版本匹配的头文件和配置,这个目录由 linux-headers 包提供(所以编译前通常要先装 linux-headers-$(uname -r))。
  • M=$(PWD) 告诉内核构建系统:模块源码在当前这个外部目录(这叫 out-of-tree 编译)。

编译

进入 hello 文件夹执行:

make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

(如果写好了上面的 Makefile,直接 make 即可。)编译成功后会生成一个 hello.ko 文件——.ko 即 kernel object,就是最终的内核模块。

加载、查看与卸载

加载模块(需要 root):

sudo insmod hello.ko

查看内核日志,确认 hello_init 被执行了:

dmesg

能在末尾看到 Hello, world! 的输出。再用 lsmod | grep hello 可以确认模块已经在列表里,modinfo hello.ko 则能看到我们写的那些 MODULE_* 元信息。

卸载模块:

sudo rmmod hello

卸载后再看 dmesg,会多出 Goodbye, world!——说明 hello_exit 在卸载时被调用了。

至此,一个内核模块从编写、编译到加载卸载的完整生命周期就跑通了。真实的驱动无非是在这个骨架上,于 init 里注册设备 / 中断 / 文件操作,在 exit 里把它们一一注销而已。