这个系列写到第五篇,终于要碰一个看起来最简单、实际上暗坑不少的话题:Linux 内核模块的 Hello World。也许你会觉得,Hello World 嘛,随便写两行就完了,但用户态和内核态完全是两回事。这篇文章会以一个常见的欧拉系开源发行版作为演示环境,从环境准备、源码编写、Makefile、编译加载到常见报错排查,把整个最小闭环从头到尾过一遍。无论你是刚接触 Linux 内核的学生,还是想入门驱动开发的工程师,把这里的第一步踩实了,后面看真实驱动代码都会更有底气。
1. 为什么内核模块要从 Hello World 开始
1.1 用户态和内核态:这不是同一个“打印”
Hello World 在几乎任何语言里都是第一课。在内核里,它不再是你熟悉的 printf 或者 cout,而是 printk,而且 printk 的输出默认不会出现在终端上,而是出现在内核日志缓冲区。第一次接触的时候,你大概率会经历这样的过程:insmod 明明成功了,屏幕上什么都没有,然后你开始怀疑自己是不是搞错了。直到别人告诉你去看 dmesg,你才恍然大悟——原来内核模块的“Hello World”从来就不是写给终端看的,它写给的是系统日志。这个认知的转变,比代码本身更有价值。
用户态程序拥有独立的虚拟地址空间,有标准库帮你做内存管理、文件操作、错误处理。内核模块没有这些便利,它直接运行在内核空间中,运行级别更高,出错就是 panic 或者 oops,连个堆栈都很吓人。所以我们写第一个内核模块时,目标是先跑通“装载→执行初始化函数→卸载”这条路径,在这个最简单循环里理解内核模块的生命周期。
1.2 模块机制解决了什么
如果每次想加个功能都要重新编译整个内核,那你大概会疯掉。Linux 用模块机制把这个问题拆开:内核本体保持精简,可选的驱动和功能以 .ko 文件存在,需要时 insmod / modprobe 装进去,不需要时 rmmod 卸掉。模块和内核之间靠 __init、__exit、module_init、module_exit 这些宏建立联系。
这里有个很多人没意识到的点:模块一旦被加载,它就变成内核的一部分,享有跟内核一样的权限。你可以访问内核导出的符号、操作设备、拦截系统调用(虽然现在不建议随便拦)。权限大了,风险也大了。所以内核模块被很多人当成学习的“试金石”——能写出并稳定加载一个模块,说明你对编译系统、环境依赖、日志机制、模块格式这些底层细节有了一定感觉。
2. 动手前的环境准备与概念拆解
2.1 内核模块的固定骨架
内核模块的 C 代码不像普通程序,它没有 main 函数,替代者是两个关键入口:初始化函数和退出函数。初始化函数在模块加载时执行,退出函数在模块卸载时执行。代码里必须用 module_init 和 module_exit 告诉内核这两个入口分别在哪里。我见过不少初学者把 module_init 写在函数定义之前或者干脆漏了,编译报错不说,加载时内核直接不认。这不是语法问题,是理解问题。
标准的骨架大概是这样的:头文件用 <linux/init.h> 和 <linux/module.h>,前者声明初始化相关宏,后者声明模块标准接口。另外一般会加 <linux/kernel.h> 以使用 printk 等函数。MODULE_LICENSE 必须写,否则加载时有个“module license 'unspecified' taints kernel”的警告,虽然不影响加载,但会在内核日志里留下“内核被污染”的记录。MODULE_AUTHOR、MODULE_DESCRIPTION 属于元信息,modinfo 能看到,建议写全。
初始化和退出函数不是随便命名的,它们需要配合宏使用。常见的命名是 hello_init、hello_exit,但也可以叫别的,只要 module_init 括号里写对就行。值得留意的是,初始化函数返回 int,退出函数返回 void。初始化返回 0 表示装载成功,返回负数表示失败,内核对返回值非常敏感。
2.2 Makefile 里的三个关键点:obj-m、KDIR、ARCH
内核模块的构建不是普通的 gcc 编译,而是借助内核源码树中的 Kbuild 系统。最常见的 Makefile 看起来只有三行,但每一行背后都有原因:
obj-m := hello.o KDIR := /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) cleanobj-m 表示目标文件应该以可加载模块的方式编译;KDIR 指向当前运行内核对应的构建目录;make -C 的意思是切换到内核源码目录执行构建,M 告诉 Kbuild 当前模块源码在哪里。不要试图自己手动调 gcc 编译 .o,因为模块编译依赖内核源码树里的一大堆头文件和生成文件,脱离 Kbuild 几乎是给自己挖坑。
有个细节:很多发行版默认把内核源码树安装在 /usr/src/kernels 或 /usr/src/linux-headers-xxx,但 /lib/modules/$(uname -r)/build 通常是一个软链接。这个软链接存在,说明开发包装好了。检查这个软链接比看目录大小靠谱得多。
ARCH 变量也要提前了解。在 x86 机器上通常可以忽略,但如果你在 ARM 设备或者做交叉编译,ARCH 和 CROSS_COMPILE 就必须写清楚。后面你拿开发板做实验时,就会发现这个变量有多重要。
2.3 环境准备:先确认三件事
在敲 make 之前,我建议你先确认三件事。
第一,内核版本。uname -r 得到的版本必须和内核开发包版本一致。如果你运行的是 5.10 内核,却装了 5.15 的开发包,编译出来的模块加载时大概率会报“版本魔法不一致”,也就是 vermagic 不匹配。模块格式里带了一个版本魔法字符串,内核加载时会做严格校验。
第二,开发工具。gcc、make 肯定跑不掉;另外还需要内核头文件和 build 目录。在某个欧拉系开源发行版上,对应的包名一般是 kernel-devel、kernel-headers,装完再看 /lib/modules 下是否有对应目录。如果没有,用包管理器装上就行。不同发行版包名大同小异,查一下最稳妥。
第三,权限。insmod 和 rmmod 需要 root 权限,普通用户会直接得到 Permission denied。更不要图方便随便 chmod 777,直接 sudo 就好。如果你在用带安全启动的 UEFI 环境,还需要考虑模块签名问题,这个放在第 4 节细说。
3. 完整实操:从源码到模块加载
3.1 编写 hello.c
这里直接给一份能跑通的版本:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init hello_init(void) { printk(KERN_INFO "Hello, kernel!\n"); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "Goodbye, kernel!\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name <your@email.example>"); MODULE_DESCRIPTION("A simple Hello World kernel module");注意几个细节。初始化函数返回值是 int,0 表示成功,负值表示失败。如果返回非 0,insmod 会报错,而且不会打印你 printk 的内容,因为模块加载流程中途被中断了。退出函数返回类型是 void,这个别写错。__init 和 __exit 是优化宏,__init 修饰的函数在初始化完成后,内核可以把这段内存释放掉,节省一点内存;__exit 在编译进内核时会被忽略。这些细节你一开始可以不深究,但要知道它们不是装饰品。
有一点容易误解:init 函数里 printk 输出的日志级别是 KERN_INFO。它在 dmesg 里能看到,但不一定会直接显示在控制台上,因为控制台默认只显示更严重的日志级别。所以“屏幕没字”不代表失败,先看 dmesg 再下结论。
3.2 编写 Makefile
把 Makefile 放在和 hello.c 同一目录。我推荐在里面加一行 ARCH 变量,虽然 x86 场景一般用不到,但如果你在交叉编译或者跑 ARM 设备,这行能救命:
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) cleanobj-m := hello.o 会把 hello.c 编译并链接成 hello.ko。如果你写的是 hello.c,Kbuild 会自动处理。不要手动把 .o 改成 .ko,这些都由构建系统完成。make 的时候会输出一堆编译命令,看到 CC hello.o、Building modules、MODPOST 这些字样,才算正常。
有些新手喜欢在 Makefile 里写 -I 指定头文件路径,其实没必要。Kbuild 会自动把内核源码的 include 目录加上。你真正需要担心的头文件是 /lib/modules/$(uname -r)/build/include 下的 generated 目录是否存在,新人最容易栽在这里,提示找不到 generated/autoconf.h。这通常是 kernel-devel 没装好,而不是代码问题。
3.3 编译与加载
一切准备好之后,执行:
make成功后会生成 hello.ko。可以用 file 命令查看:
file hello.ko会显示 ELF relocatable 之类的信息,说明格式对。接着加载:
sudo insmod hello.koinsmod 是“install module”的缩写,加载时需要给完整文件路径。如果当前目录没有 hello.ko,会提示找不到文件。加载完用 lsmod 看看:
lsmod | grep hello这里应该能看到 hello 模块。lsmod 的信息实际上来自 /proc/modules,这个文件除了模块名,还记录了模块大小、被谁引用、加载地址等信息。我经常在一开始把 insmod 和 dmesg 连着敲:
sudo insmod ./hello.ko && dmesg | tail -3这样能在第一时间看到内核日志,省得加载完再去翻。
3.4 查看输出与卸载模块
执行完 dmesg | tail -3,你大概会看到类似信息:
[12345.678901] Hello, kernel!前面的时间戳是从开机到现在的秒数,不是真实时间。如果你想看实时内核日志,可以用 dmesg -w 或者 journalctl -k -f,看发行版而定。接着卸载:
sudo rmmod hellormmod 后同样去 dmesg 看退出信息,应该能看到 Goodbye。这里有个小坑:卸载时模块名不要带 .ko 后缀,rmmod hello 而不是 rmmod hello.ko。insmod 和 rmmod 的习惯不一样,前者要完整文件路径,后者只要模块名。用 modprobe 则两个都只需要名字,但 modprobe 会自动处理依赖,更适合管理有依赖关系的模块。
4. 常见问题与排查技巧实录
4.1 高频报错速查表
写内核模块,踩坑几乎是必经之路。我把过去遇到的高频问题整理成了一张速查表,方便你出问题时直接对照:
| 报错信息 | 可能原因 | 处理办法 |
|---|---|---|
| Operation not permitted | 权限不足,或安全启动限制未签名模块 | 改用 root/sudo,或检查签名、配置安全启动 |
| Exec format error | 架构不对,或编译产物损坏 | 检查 file hello.ko,确认 ARCH 与运行内核一致 |
| Unknown symbol | 模块引用了内核没有导出的符号 | 用 grep /proc/kallsyms 查符号是否存在 |
| Invalid module format | 内核版本不一致或 vermagic 不匹配 | 确认 KDIR 指向当前内核的 build 目录 |
| No space left on device | 内核模块内存区或临时文件不够 | 查看内存使用、/tmp 空间,勿滥用模块 |
| Module hello already found | 模块名重复,已存在同名模块 | lsmod 确认后用 rmmod 卸载 |
| Cannot allocate memory | 模块过大或内存不足 | 检查系统内存,精简模块 |
这些报错里,Exec format error 和 Invalid module format 最容易迷惑人。它们表面像是文件格式坏了,十有八九是编译环境和运行环境不一致。比如你在 x86 笔记本上编了个 x86_64 模块,想塞到 ARM 开发板上,那就是 Exec format error。再比如运行内核升级了,但 /lib/modules/$(uname -r)/build 还指向旧版本,加载旧模块也会报错。
4.2 调试内核模块的三个小技巧
第一个技巧是学会看 dmesg 的日志级别。printk 可以带数字级别,比如 printk(KERN_ALERT ...) 比 KERN_INFO 更容易在控制台显示。你可以临时把级别调低来调试,但别在产品代码里留一堆高优级打印。
第二个技巧是用 modinfo 查看模块元信息。加载前先执行 modinfo hello.ko,能看到模块描述、作者、依赖、vermagic 等字段。vermagic 字段尤其有用,它直接暴露了模块编译针对的内核版本和编译器版本,一旦跟当前内核不一致,你就不用再瞎猜了。
第三个技巧是善用 /proc/kallsyms。如果你把模块做成非 GPL 或者引用了某些未导出符号,insmod 会有 Unknown symbol 的提示。去 /proc/kallsyms 里 grep 一下那个符号,如果找不到,说明它没有导出,模块没法引用。很多人绕不过这一关,就试图去改内核源码重新编译,其实大多数情况换个思路就绕开了。
4.3 关于安全启动和模块签名的避坑
很多新机器默认开启 UEFI 安全启动,这时候加载未签名模块会遇到 Operation not permitted 或者证书错误。不要第一时间关安全启动,可以先看看发行版有没有提供模块签名工具。内核配置里有个 CONFIG_MODULE_SIG,开启后所有模块都需要有合法签名。
实操上有两种方式:一种是用已有的内核签名私钥对 .ko 签名,另一种是使用发行版的某个官方工具生成自己的密钥并导入主板固件。后者比较麻烦,但也更符合安全原则。对我个人来说,学习阶段最简单的方式是临时关闭安全启动,等真正需要发布模块时再处理签名。这个选择要根据自己的环境来,别盲目照搬。
这里多提醒一句:模块签名不是代码正确性的替代品。就算签了名,模块写的有 bug,照样可能把系统搞挂。内核模块是跑在最高特权级的代码,签名只是证明了来源,不能证明质量。
5. 从 Hello World 到正经模块的必经之路
5.1 模块参数、多文件与自动加载
Hello World 只是热身,接下来有几个方向特别值得深入。
第一个是模块参数。用 module_param 可以让你在 insmod 时传参数进去,比如 insmod hello.ko name=xx,模块内部通过参数变量读取。这对驱动配置很有用,比如指定中断号、IO 地址。注意参数类型要匹配,字符串参数需要指定缓冲区大小,否则容易出现溢出。
第二个是多文件模块。当一个模块拆成几个 .c 文件时,Makefile 里不能只写 obj-m := xxx.o,而是:
obj-m := mymodule.o mymodule-objs := main.o helper.o这样 Kbuild 会把两个文件链接成一个 .ko。这个模式在实际项目中非常常见,也是很多新手卡住的地方。没有 mymodule-objs 这行,make 会找不到规则,或者只编译一个文件,链接时符号缺失。
第三个是自动加载。把模块放到 /lib/modules/$(uname -r)/extra 或对应目录,执行 depmod,然后用 modprobe hello 加载。modprobe 会读取 modules.dep 依赖文件,自动把依赖的模块一起装好。再进一步,配合设备 ID 表(比如 MODULE_DEVICE_TABLE),内核在识别到硬件时就能自动装载驱动。这才是驱动开发的真正入口。
5.2 下一步可以玩什么
在你跑通 Hello World 后,我建议你做三件事,都能加深理解。
第一,用 readelf 打开 hello.ko,看看重定位表和符号表。.ko 不是普通可执行 ELF,它有很多未解析符号,需要加载时由内核动态链接。这一步你会直观理解“模块还不是完整程序”这句话。
第二,写一个简单的字符设备模块。注册一个 miscdevice,提供 open/release/read/write 接口,配合 udev 创建设备节点,你就能在用户态打开 /dev/xxx 和内核模块交互。这是从“打印 Hello World”到“真正干活”的质变。
第三,去内核源码里找一个真实驱动,比如某个简单的 GPIO 驱动或虚拟网卡驱动,跟着它的 Makefile 和 Kconfig 走一遍。你会发现我们这个小例子里的一切,在真实驱动里都出现了,只是规模大得多。能把 Hello World 的每一步都讲清楚,再看真实驱动就不会那么慌了。
结尾
这个例子我在不同环境里跑过很多次,最简单的反而是最容易出问题的。第一次在某欧拉系发行版上编译内核模块,我卡在 kernel-devel 没装好,make 一直提示找不到头文件。后来发现不是代码问题,而是环境问题。建议你把 Makefile、源码、环境检查顺序固定下来,养成“先看环境再看代码”的习惯,能省很多时间。
还有一个小技巧一直用到现在:insmod 和 dmesg 用 && 串在一起,能让模块调试反馈非常紧凑。等你把 Hello World 跑通了,往后再写驱动、写安全分析、做内核热补丁,这个最小闭环都是地基。踏踏实实把第一步做好,后面会顺畅很多。