Linux 内核模块程序结构:五要素、宏展开,以及教程里常见的四个错误
Linux 内核模块程序结构:五要素、宏展开,以及教程里常见的四个错误
写完第一个 “Hello World” 内核模块之后,大多数人会卡在同一个地方:代码能跑,但不知道为什么能跑。module_init 是怎么让内核找到入口的?__init 到底省了什么?MODULE_LICENSE 不写会怎样?
这篇笔记做两件事:
- 把内核模块的程序结构拆成六个部分,每个部分讲清楚它是什么、内核拿它做什么;
- 往下钻一层——这些宏展开成什么代码、在
.ko里落到哪个 ELF 段。
另外,我在整理过程中发现有几个说法在中文教程里流传很广但是错的,其中一个(init 失败会自动调用 exit)直接关系到资源泄漏。文末有一份订正清单,赶时间可以直接跳到第十节。
一、最小骨架:一个模块的五要素
先看一个能正常编译加载的最小模块:
1 |
|
不到 20 行,包含了五个缺一不可的要素:
| # | 要素 | 作用 |
|---|---|---|
| 1 | 头文件 | 拿到内核侧的宏与函数声明 |
| 2 | 初始化函数 | 加载时执行,申请资源、注册驱动 |
| 3 | 退出函数 | 卸载时执行,反向释放 |
| 4 | 入口/出口声明 | 把两个函数登记给内核 |
| 5 | 许可证声明 | 决定模块能用哪些内核符号,以及是否污染内核 |
再加上两个可选但常用的:模块参数、符号导出。下面逐个拆。
二、头文件:内核模块用的不是标准 C 库
第一个要建立的认知:内核模块不链接 glibc。没有 stdio.h,没有 malloc,没有浮点(默认情况下)。所有东西都来自内核自带的头文件树。
2.1 三个必备头文件
| 头文件 | 提供什么 |
|---|---|
linux/module.h |
module_init、module_exit、MODULE_LICENSE、THIS_MODULE、EXPORT_SYMBOL |
linux/init.h |
__init、__exit、各级 initcall 宏 |
linux/kernel.h |
内核常用工具宏(container_of、ARRAY_SIZE 等) |
一个常见的小误传: 很多教程说 “
printk定义在linux/kernel.h”。严格说,printk及pr_info/pr_err等一族宏定义在linux/printk.h里,kernel.h只是间接包含了它。近年内核在做头文件解耦,kernel.h被持续瘦身,依赖间接包含容易在某些配置下编译失败。显式#include <linux/printk.h>更稳妥。
顺带一提,新代码建议用 pr_info() / pr_err() 而不是裸 printk(KERN_INFO ...),前者会自动带上 pr_fmt 前缀,便于在 dmesg 里定位。
2.2 按功能追加
| 功能 | 头文件 | 典型符号 |
|---|---|---|
| 字符设备 | linux/fs.h、linux/cdev.h |
file_operations、cdev_add |
| 内存分配 | linux/slab.h |
kmalloc / kfree |
| 模块参数 | linux/moduleparam.h |
module_param |
| 硬件中断 | linux/interrupt.h |
request_irq |
| 网络 | linux/netdevice.h、linux/net.h |
net_device |
漏了会直接编译报 “隐式声明”。比如忘了 linux/slab.h,kmalloc 就未定义。
三、初始化函数:模块的出生仪式
1 | static int __init 函数名(void) |
3.1 三个关键字各自在做什么
static —— 限制符号只在本编译单元可见。内核里同时可能加载上千个模块,init 这种名字重名概率极高。加了 static,符号不会进入内核全局符号表。
__init —— 这不是"标记一下"那么简单,它是一个段属性:
1 |
带 __init 的函数被编译器放进 .init.text 段。内核初始化完成后,会把整个 .init.* 区域的物理页释放回伙伴系统。启动日志里那行熟悉的输出就是它:
1 | Freeing unused kernel memory: 2048K |
对可加载模块而言,机制类似:init 执行完毕后,模块的 init 段内存被释放,只保留常驻部分。所以 __init 的真实收益是节省常驻内存——代价是这段代码执行完就没了,任何在运行期还会被调用的函数绝对不能标 __init,否则就是访问已释放内存。
返回值约定 —— 0 成功,负的 errno 失败。这个约定是硬性的:
- 返回负数 → 模块加载失败,不进入已加载列表;
- 返回正数 → 内核会打印一条 warning,类似
mymodule: 'mymodule_init' returned 42, it should follow 0/-E convention,而且模块照样加载成功。这是个很隐蔽的坑:你以为报错了,其实模块已经在内核里跑着。
3.2 初始化函数里该做什么、不该做什么
该做: 申请资源(内存、设备号、中断号)、注册驱动、初始化数据结构、打印加载信息。
不该做: 长时间休眠、大量计算、等待外部事件。模块加载路径持有 module_mutex,init 卡住会连带阻塞其他模块的加载。需要长时间的工作应该丢给 workqueue 或 kthread。
3.3 最重要的一条:错误处理必须自己展开
这是全文我最想强调的一点,也是中文教程里错得最普遍的一点。
❌ 常见错误说法: “初始化函数返回非 0 时,内核会自动调用模块的退出函数来清理。”
✅ 实际行为: 不会。 init 返回负值意味着模块从未成功加载,而 exit 函数的语义是"卸载一个已加载的模块"。既然没装上,就没有卸载这回事,
module_exit注册的函数永远不会被调用。
后果很直接:如果你在 init 里申请了内存、注册了设备号,然后在第三步失败时直接 return ret,前两步的资源就永久泄漏了,而且因为模块没加载成功,你连 rmmod 的机会都没有——只能重启。
正确写法是内核里随处可见的 goto 反向展开模式:
1 | static int __init mydrv_init(void) |
标签的命名和顺序是有讲究的:标签名写"接下来要做什么"(err_unreg_a = 跳到这里要注销 a),顺序与申请顺序相反,这样新增一步只需要在中间插入,不用改动已有分支。
另一个值得注意的反模式:不要在 init 的失败路径里直接调用完整的 exit 函数。因为 exit 会去释放那些"还没来得及申请"的资源,轻则 warning,重则 oops。LKML 上早年讨论过这个设计,结论是模块必须自己负责失败路径的清理,内核核心不做兜底。
四、退出函数:反向释放
1 | static void __exit 函数名(void) |
4.1 __exit 的段语义
和 __init 对称,__exit 把函数放进 .exit.text。但它的收益场景不同:
- 编译成可加载模块(
CONFIG_XXX=m) →.exit.text保留,rmmod时执行; - 编译进内核(
CONFIG_XXX=y) → 内建代码永远不可能被卸载,于是链接器直接丢弃整个.exit.text段,module_exit()宏也展开成空。
这解释了一个新手常困惑的现象:同一份代码,编译成 .ko 和编译进内核,行为不一样。也再次印证了第 3.3 节的结论——内建场景下 exit 代码根本不存在,失败路径的自清理是唯一出路。
4.2 无返回值
exit 函数返回 void。因为卸载要么成功,要么已经把内核搞挂了(Oops),没有"失败了但还能继续"的中间态可供上报。
4.3 反向释放原则
exit 的工作就是把 init 做的事情逐条 undo,顺序严格相反:
1 | static int __init my_init(void) |
注意上面
dev_num的赋值。 我见过不止一份教程的示例写成ret = register_chrdev(0, "mydev", &fops);,只用ret做错误判断,却在 exit 里unregister_chrdev(dev_num, ...)——dev_num从头到尾没被赋过值。register_chrdev(0, ...)传 0 表示让内核动态分配主设备号,并把分配到的号作为返回值返回,这个返回值必须存下来,否则注销的是设备号 0,而真正分配到的主设备号会永久泄漏。
4.4 省略 exit 函数的代价
有些关键驱动不希望被卸载,确实可以只写 init 不写 exit。但要知道代价:没有 exit 函数的模块会被内核标记为 permanent,在 lsmod / /proc/modules 里显示为 [permanent],无法卸载,只能重启。
这是个刻意的设计选择,不是"顺手省掉"的写法。绝大多数模块都应该提供 exit。
五、入口/出口声明:宏到底展开成了什么
1 | module_init(初始化函数名); |
很多人会问:为什么不能直接调用?答案在于内核并不知道你的函数叫什么名字——它是通过固定的符号或段表来找入口的。而 module_init 的展开结果,取决于这份代码是编译成模块还是编译进内核。
5.1 编译成可加载模块时(定义了 MODULE 宏)
1 |
两件事:
__inittest是一个编译期类型检查——如果你的 init 函数签名不是int (*)(void),这里的return initfn;会触发类型不匹配错误。这就是为什么签名写错时报错信息里会出现莫名其妙的__inittest。- 真正的魔法是
alias:给你的函数起了一个叫init_module的别名。init_module是内核加载模块时查找的固定符号名。module_exit同理,别名是cleanup_module。
所以"登记"的本质是:把任意命名的函数,别名成内核约定好的固定符号。
5.2 编译进内核时(未定义 MODULE)
1 |
展开后函数指针被放进 .initcall6.init 段。内核启动时的 do_initcalls() 按等级顺序遍历这些段,依次调用。等级是 Linux 启动顺序的骨架:
1 | 0 pure_initcall 纯数据初始化 |
同一等级内的执行顺序由链接顺序决定,也就是 Makefile 里 obj-y 的排列顺序——这是内建驱动出现"依赖顺序诡异 bug"时的第一个排查点。
理解了这两种展开,就能解释一个现象:同样一句 module_init(foo),在 .ko 里是"注册一个别名",在 vmlinux 里是"往数组里塞一个函数指针"。写驱动时要同时对这两条路径负责。
六、许可证声明:它比你以为的重要
MODULE_LICENSE 看起来只是个字符串,实际有两个硬性后果。
6.1 合法取值
| 字符串 | 含义 | GPL 兼容 |
|---|---|---|
"GPL" |
GPL v2 及以后 | ✅ |
"GPL v2" |
明确 GPL v2 | ✅ |
"GPL and additional rights" |
GPL + 附加授权 | ✅ |
"Dual BSD/GPL" |
BSD / GPL 双许可 | ✅ |
"Dual MIT/GPL" |
MIT / GPL 双许可 | ✅ |
"Dual MPL/GPL" |
MPL / GPL 双许可 | ✅ |
"Proprietary" |
闭源专有 | ❌ |
只有表里的字符串会被识别,写别的等同于未声明。
6.2 后果一:符号可见性
非 GPL 兼容的模块无法链接 EXPORT_SYMBOL_GPL 导出的符号。 内核里大量关键接口是 GPL-only 的,这条限制是实打实的功能阉割,不只是法律声明。
6.3 后果二:污染内核(taint)
不写 MODULE_LICENSE,加载时会看到:
1 | module: module license 'unspecified' taints kernel. |
“taint” 是内核的一个全局状态位,记录在 /proc/sys/kernel/tainted。相关的几个标志:
| 标志 | 含义 |
|---|---|
P |
加载了专有/未声明许可证的模块 |
O |
加载了树外(out-of-tree)模块 |
F |
模块被强制加载(insmod -f) |
E |
加载了未签名模块 |
内核一旦被污染,oops 和 panic 的调用栈里会带上污染标志,社区通常不会受理带 P 标志的崩溃报告——因为无法排除闭源模块的干扰。所以 MODULE_LICENSE("GPL") 的实际意义是"让我的崩溃报告值得被看"。
6.4 其他元信息
1 | MODULE_AUTHOR("Your Name"); |
这些宏统统展开成 MODULE_INFO(tag, info),最终写进 .ko 的 .modinfo ELF 段——一段 key=value\0 拼接的字符串。modinfo 命令做的事情就是读这个段:
1 | modinfo hello.ko |
注意最后两行,它们不是你写的:
depends由 modpost 根据你用到的外部符号自动生成,modprobe靠它做依赖解析;vermagic是内核版本 + 关键配置项的指纹。加载时严格比对,不一致直接拒绝(Invalid module format)。这就是.ko不能跨内核版本复用的原因。
七、模块参数:加载时可配置
7.1 定义
1 |
|
加载时传入:
1 | sudo insmod hello.ko debug_level=2 device_name="testdev" |
7.2 关于权限位:用八进制,别用 S_IRUGO
老教程普遍写 S_IRUGO | S_IWUSR。现在内核的 checkpatch 会直接告警:
1 | WARNING: Symbolic permissions 'S_IRUGO' are not preferred. |
理由是八进制字面量一眼就能看出三组权限位,而符号常量组合需要心算。新代码统一用 0444 / 0644 这种写法。
权限位的实际作用是控制 sysfs 下的文件:
1 | /sys/module/<模块名>/parameters/<参数名> |
两个细节:
- 权限传 0 表示不创建 sysfs 条目,参数只能在加载时指定,之后不可见也不可改;
- 权限里不能带执行位(
S_IXUSR等),内核在编译期用BUILD_BUG_ON拦截。
另外要清楚:即使参数在 sysfs 里可写,写入也只是改了那个变量的值——模块代码不会自动感知。如果需要在参数变化时做动作,得用 module_param_cb() 注册 set/get 回调。
7.3 支持的类型
| 类型 | 说明 |
|---|---|
byte / short / ushort / int / uint / long / ulong |
整数族 |
charp |
字符串指针(内核会复制一份实参) |
bool |
布尔 |
invbool |
布尔取反 |
数组用专门的宏:
1 | static int arr[5]; |
7.4 为什么参数必须是全局变量
参数变量需要在整个模块生命周期内保持有效,而且 module_param 宏要在文件作用域生成一个描述结构体(放进 .modinfo 和 __param 段),里面存的是该变量的地址。局部变量在函数返回后栈帧就没了,地址无意义。所以必须是全局或 static 全局变量。
八、符号导出:模块之间怎么共享代码
8.1 两个宏
1 | void my_shared_function(int param) /* 注意:不能是 static */ |
EXPORT_SYMBOL 的本质是往 .ko 里的专门 ELF 段写一条记录:
| 段 | 内容 |
|---|---|
__ksymtab |
普通导出符号的 {地址, 名字} 表 |
__ksymtab_gpl |
GPL-only 导出符号表 |
__ksymtab_strings |
符号名字符串池 |
__kcrctab |
符号 CRC 校验(启用 CONFIG_MODVERSIONS 时) |
加载模块时,内核遍历该模块的未定义符号,到 __ksymtab* 里查找并重定位。查 __ksymtab_gpl 前会先检查请求方的许可证——这就是 §6.2 那条限制的实现位置。
8.2 ⚠️ 一个流传很广的错误示例
我见过不少教程(包括这次整理的这篇)的"完整示例"里写着:
1 | /* ❌ 这段是错的 */ |
static 和 EXPORT_SYMBOL 在语义上直接冲突。 static 的作用就是把符号限制在当前编译单元内、不产生外部链接;而 EXPORT_SYMBOL 的目的恰恰是让别的模块能链接到它。两者放在一起是自相矛盾的——现代内核的 modpost / 编译器会对此报错或告警,即便勉强通过,导出的也是一个别人无法正确引用的符号。
正确写法:去掉 static,并在配套的头文件里提供声明:
1 | /* mymod.h —— 给使用方 include */ |
顺带两点:
- 优先导出函数而不是变量。 导出变量把内部状态直接暴露给其他模块,没有任何访问控制,后续想加锁或改表示法就会破坏 ABI。
- 使用方应该 include 头文件,而不是自己写
extern声明。 手写extern一旦与定义处签名不一致,编译期查不出来,运行期直接崩。
8.3 依赖关系的连锁反应
一旦模块 B 用了模块 A 导出的符号:
- A 必须先于 B 加载,否则 B 加载时符号解析失败;
- A 的引用计数 +1,A 无法在 B 卸载前卸载;
- 这层依赖由
depmod写进modules.dep,modprobe读取后自动按序加载——insmod不做依赖解析,这是两者最主要的区别。
所以原则是尽量少导出。每个导出符号都是一条对外承诺,增加耦合,也增加卸载时的顺序约束。
查看系统里所有可用符号:
1 | sudo cat /proc/kallsyms | grep my_shared_function |
(需要 root,普通用户看到的地址会被 kptr_restrict 全部置零。)
九、.ko 的 ELF 视角:把前面的内容串起来
前面每一节都提到了段,汇总起来就是一个 .ko 的全貌:
1 | hello.ko (ELF relocatable, ET_REL) |
.ko 是 ET_REL(可重定位目标文件),不是可执行文件。insmod 把整个文件读进内核,由内核里的 ELF 加载器完成符号解析和重定位——内核自己就是一个链接器。
THIS_MODULE 指向 .gnu.linkonce.this_module 段里的 struct module 实例,它是模块的运行期句柄:引用计数、状态、参数列表、内存布局全在里面。file_operations.owner = THIS_MODULE 之所以重要,就是因为它让 VFS 在有人打开设备时能对模块 try_module_get(),防止设备正在使用时被 rmmod。
十、订正清单:四个流传很广的错误说法
整理这个主题时对照了几份中文教程,以下四条出现频率很高,但都不准确。
❌ 1. “init 返回非 0,内核会自动调用 exit 函数清理”
不会。 init 失败意味着模块从未加载成功,exit 函数不会被调用。已申请的资源必须在 init 的失败路径里用 goto 反向展开自行释放,否则永久泄漏且无法 rmmod。详见 §3.3。
这是四条里后果最严重的一条。
❌ 2. “EXPORT_SYMBOL 可以导出 static 符号”
static 与 EXPORT_SYMBOL 语义直接冲突,导出的符号对外不可用。要导出就不能 static,并通过头文件对外提供声明。详见 §8.2。
❌ 3. “卸载后模块仍存在(lsmod 的 Used by 不为 0),说明资源没释放干净”
这句话把两件事混在了一起:
- 引用计数不为 0 时,
rmmod会直接失败(Module xxx is in use),不存在"卸载后仍然存在"这个状态。引用计数反映的是有人正在用(设备被打开、被其他模块依赖),不是内存泄漏。 - exit 里漏掉
kfree这类真正的内存泄漏,完全不会体现在引用计数上。模块能干干净净卸载,内存却回不来。查这类问题要靠CONFIG_DEBUG_KMEMLEAK(/sys/kernel/debug/kmemleak)或 KASAN。
另外 §4.4 提过的 [permanent] 状态也不是泄漏,是因为模块没定义 exit 函数。
❌ 4. “调不到未导出的函数时,可以用 kallsyms_lookup_name 动态查找”
这个建议已经过时。kallsyms_lookup_name() 在 Linux 5.7 起就不再导出给模块使用了,正是为了堵住这条绕过导出约束的路径。在 5.7 及之后的内核上,模块直接调用它会在加载时符号解析失败。
更根本的是,这个做法从一开始就不该被推荐:内核不导出某个函数,是明确的设计表态——那是内部实现细节,随时可能在小版本间改变签名或消失。绕过去写出来的模块,一次内核升级就会失效,甚至以更糟的方式失效(签名变了但符号名没变)。
正确的思路是:如果确实需要某个能力而内核没暴露,应该去社区讨论、提交补丁把接口正规化,而不是从符号表里硬捞。
十一、修正版完整示例
把前面所有内容整合,并修掉原示例里的问题:
1 | // ---------- 1. 头文件 ---------- |
配套的最小 Kbuild Makefile:
1 | obj-m += demo.o |
验证一遍:
1 | make |
小结
内核模块的程序结构是一套严格的"建筑规范",核心要点:
- 五要素:头文件、init、exit、入口出口声明、许可证,缺一不可;
__init/__exit是段属性不是注释,直接决定代码在内存里的去留,也决定了内建与模块两种编译方式的行为差异;- init 失败必须自行 goto 展开清理,内核不会替你调 exit——这是最容易踩且后果最重的一条;
- 许可证不只是法律声明,它同时决定符号可见性和内核污染状态;
- 参数与符号导出让模块更灵活,但代价是 sysfs 权限语义和模块间依赖顺序;
- 一切最终落到
.ko的 ELF 段上——.modinfo、__param、__ksymtab把源码里的宏变成内核加载器能读懂的数据。
把这几条吃透,再去写字符设备驱动、platform driver 或简单文件系统,遇到的就基本只剩业务逻辑问题了。
参考
- Linux 内核源码:
include/linux/module.h、include/linux/init.h、include/linux/moduleparam.h、kernel/module/main.c - The Linux Kernel Module Programming Guide — The
__initand__exitMacros - Linux Kernel Workbook — Kernel Modules
- Module Init and Initcalls — Linux Kernel Internals
- init_module(2) — Linux manual page
- The Kernel Newbie Corner: Loadable Kernel Modules, Coming and Going
- 基础结构部分参考了腾讯云开发者社区《【Linux内核模块】Linux内核模块程序结构》(byte轻骑兵),宏展开、ELF 段与第十节订正内容为本文补充。


