2. 第一个内核模块:Hello World 模块的编写、Makefile 编写、模块的编译与加载(insmod/rmmod)

好,咱们直接动手。这一章的目标很简单——写一个能打印 "Hello World" 的内核模块,然后把它加载进内核,再卸载掉。别小看这一步,这是你进入内核世界的第一个脚印。

我刚开始学内核模块时,觉得不就是个 "Hello World" 嘛,能有多难?结果第一个模块就搞崩了系统,直接黑屏重启。嗯,从那以后我养成了一个习惯:开发机上跑模块,绝对不在生产环境试。这个习惯救过我很多次。

2.1 什么是内核模块?

说白了,内核模块就是一段可以在运行时动态加载到内核的代码。它不像普通程序那样有 main() 函数,而是通过特定的入口和出口函数来告诉内核「我来了」和「我走了」。

你想想看,Linux 内核本身是一个大 monolithic(宏内核),但有了模块机制,你就能按需添加功能,不用每次都重新编译整个内核。这就像给房子加个阳台——不用把整栋楼拆了重建。

内核模块 vs 用户态程序

  • 运行在内核空间,拥有最高权限
  • 没有 main(),只有 module_init() 和 module_exit()
  • 不能调用标准 C 库(如 printf),只能用内核 API(如 printk)
  • 一个 bug 就能让整个系统崩溃——不像用户态程序崩了只是 segfault

2.2 编写 Hello World 模块

先看代码。我习惯把模块文件命名为 hello.c,简单直接。

#include <linux/module.h>      // 所有模块都需要
#include <linux/kernel.h>      // 提供 printk 等内核函数
#include <linux/init.h>        // 提供 __init 和 __exit 宏

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello World from kernel module!\n");
    printk(KERN_INFO "Module loaded successfully.\n");
    return 0;   // 返回 0 表示加载成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, kernel module unloaded.\n");
}

module_init(hello_init);
module_exit(hello_exit);

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

这里有几个关键点,我当年踩过坑:

  • __init 和 __exit 是内核的优化标记。加载完成后,__init 函数占用的内存会被释放掉。我曾经忘了加这个标记,结果模块占着内存不放,被同事吐槽了。
  • printk 是内核版的 printf,输出会写到内核日志里,用 dmesg 查看。KERN_INFO 是日志级别,告诉内核这条消息的优先级。
  • MODULE_LICENSE("GPL") 不是摆设。没有这个声明,内核会认为你的模块是「脏」的,加载时会打 warning。我见过有人因为忘了加这行,导致模块加载后某些内核符号不可用。

2.3 编写 Makefile

写模块的 Makefile 和普通 C 程序完全不同。我第一次写的时候,照着网上的模板抄,结果编译出来一堆莫名其妙的错误。后来才明白——内核模块的编译必须依赖内核源码树的构建系统。

obj-m := hello.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

解释一下这几行的含义:

行 含义
obj-m := hello.o 告诉内核构建系统:把 hello.c 编译成模块(.ko 文件)
KDIR 指向当前运行内核的源码树路径。我习惯用 uname -r 动态获取版本号,这样换内核后不用改 Makefile
-C $(KDIR) 切换到内核源码目录,使用它的 Makefile
M=$(PWD) 告诉内核构建系统:我的模块源码在当前目录

小技巧:如果你没有完整的内核源码,至少需要安装 linux-headers-$(uname -r) 包。Ubuntu 上执行 sudo apt install linux-headers-$(uname -r) 就行。我有一回在精简版容器里编译,忘了装 headers,折腾了半小时才发现。

2.4 编译模块

把 hello.c 和 Makefile 放在同一个目录下,执行:

$ make
make -C /lib/modules/5.15.0-91-generic/build M=/home/user/hello modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic'
  CC [M]  /home/user/hello/hello.o
  MODPOST /home/user/hello/Module.symvers
  LD [M]  /home/user/hello/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'

编译成功后,你会看到生成了 hello.ko 文件。这个 .ko 就是内核模块文件,相当于用户态程序的 .exe。

我遇到过一种情况:编译时提示 No such file or directory,但明明文件就在那里。后来发现是 KDIR 路径写错了,指向了一个不存在的内核版本目录。所以编译前先确认一下:

$ ls /lib/modules/$(uname -r)/build

如果这个目录不存在,说明你没装 headers 或者内核版本不匹配。

2.5 加载与卸载模块

加载模块用 insmod,卸载用 rmmod。这两个命令都需要 root 权限。

$ sudo insmod hello.ko
$ sudo rmmod hello

注意 rmmod 后面跟的是模块名(hello),不是文件名(hello.ko)。模块名默认就是 .c 文件的 basename,除非你在代码里用 MODULE_NAME 宏指定了别的名字。

加载后怎么看输出?用 dmesg 查看内核日志:

$ dmesg | tail -5
[ 1234.567890] Hello World from kernel module!
[ 1234.567891] Module loaded successfully.
[ 5678.901234] Goodbye, kernel module unloaded.

方括号里的数字是时间戳(系统启动后的秒数)。如果你觉得 dmesg 输出太多,可以用 dmesg -w 实时监控,就像 tail -f 一样。

警告:千万不要在生产环境或者重要数据的机器上随意加载未经验证的内核模块。我曾经在开发板上加载了一个有内存泄漏的模块,跑了三天后系统 OOM(内存耗尽),所有进程都被杀掉了。教训深刻。

2.6 查看已加载的模块

用 lsmod 命令可以列出当前所有已加载的内核模块:

$ lsmod | grep hello
hello                  16384  0

输出信息包括:模块名、占用内存大小(字节)、被其他模块引用的次数。如果引用次数不为 0,rmmod 会失败,提示 Module is in use。

我遇到过一种情况:rmmod 报错说模块正在使用,但我明明没有其他程序依赖它。后来发现是另一个内核模块通过符号依赖引用了它。用 lsmod 看一下引用计数就明白了。

2.7 本章知识结构

下面这张图把整个流程串起来了,我建议你对照着看:

Hello World 内核模块完整流程 Hello World 内核模块完整流程 1. 编写源码 hello.c 2. 编写 Makefile obj-m / KDIR 3. 编译 make → hello.ko 4. 加载模块 sudo insmod hello.ko 5. 验证输出 dmesg | tail 6. 卸载模块 sudo rmmod hello 💡 关键文件说明 • hello.c — 模块源码,包含 init/exit 函数和许可证声明 • Makefile — 利用内核构建系统编译出 hello.ko • hello.ko — 最终的内核模块文件,insmod 加载的目标 ⚠️ 常见错误排查 • "Invalid module format" → 内核版本不匹配,重新编译 • "Operation not permitted" → 缺少 root 权限,加 sudo • "Module is in use" → 有依赖模块,先卸载依赖

2.8 常见问题与避坑

我整理了几个新手最容易踩的坑,你遇到了可以直接对照:

  • 编译时报错找不到头文件:大概率是没装 linux-headers 包,或者 KDIR 路径不对。先确认 /lib/modules/$(uname -r)/build 存在。
  • insmod 提示 "Exec format error":模块是用不同版本内核编译的。我有一回在 5.10 内核上编译,拿到 5.15 的机器上加载,直接报错。必须用目标机器的内核源码重新编译。
  • dmesg 看不到输出:检查 printk 的日志级别。如果控制台日志级别设置得高,低优先级的消息可能被过滤掉。试试 printk(KERN_ALERT "...") 强制输出。
  • rmmod 失败说模块不存在:确认模块名是否正确。用 lsmod | grep hello 看看实际加载的名字。

我的习惯:每次写完模块,我都会先跑一遍 make clean && make,确保编译环境干净。然后在测试机上用 insmod 加载,dmesg -w 实时看输出,确认没问题后再 rmmod。这一套流程走下来,基本不会出大问题。

好了,到这里你已经完成了第一个内核模块的编写、编译、加载和卸载。别看它简单,这个流程是所有内核模块开发的基础。后面的章节里,我们会在这个基础上加入参数传递、设备文件操作、中断处理等更复杂的功能。但核心的套路——写代码、写 Makefile、编译、加载、验证、卸载——永远不会变。