写Linux内核模块的人,第一行正经代码大概率就是module_init(hello_init)。但我敢打赌,很多人写了大半年驱动,都没认真想过这句话到底是什么——它不是函数调用,不是声明,而是一连串宏展开的起点。这篇文章就把module_init(hello_init)这行代码从预处理、编译到链接、启动,一层层撕开来看,搞清楚它究竟怎么把你的hello_init变成了开机时自动执行的入口函数。
这篇文章适合刚开始学内核模块的人,也适合写了一段时间驱动但只停留在“照着模板抄”阶段的开发者。读完之后,你会明白module_init为什么既能用在编译进内核的驱动上,又能用在外插模块上;你也能自己通过gcc -E、nm、readelf去验证宏展开的每一步,而不是光听别人讲。
1. 先认识一下主角:hello.c 和 module_init
1.1 一个再常见不过的hello模块
几乎所有内核编程入门教程的第一个实验都是这个:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> 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); MODULE_LICENSE("GPL");这段代码有一点很反直觉:module_init(hello_init)后面带着分号,看起来像函数调用。但module_init是一个宏,hello_init在这里也不是“参数”,而是被宏拿去拼字符串、拼变量名的“原材料”。真正被当成函数调用的是你定义的那两个函数——hello_init和hello_exit,它们分别是初始化和卸载时的入口。搞清楚这个,后面的展开逻辑就顺了。
1.2 同一行宏,两种完全不同的命运
module_init的展开结果取决于你的代码最终以什么形态存在:是直接编译进内核镜像(built-in),还是编译成一个.ko模块(module)。这两种情况下,这行宏展开后的代码完全不同,连背后的调用机制都是两套。
在include/linux/module.h里,module_init的定义大致长这样:
#ifndef MODULE #define module_init(x) __initcall(x); #else #define module_init(x) init_module(x) #endif也就是说:
- 编进内核时,
module_init(hello_init)变成__initcall(hello_init); - 编成模块时,
module_init(hello_init)变成init_module(hello_init);
区别为什么这么大?因为编进内核的驱动不需要“模块加载”这个过程,它的入口函数必须在系统启动早期被遍历调用;而模块是在系统跑起来之后,通过insmod加载时由模块加载器直接调用的,模块加载器只认传统的固定入口名init_module。同一个宏要同时服务两套机制,所以必须在预处理阶段分流。
我见过不少人被这一步绕晕,其实只要记住:编译时有没有-DMODULE,直接决定了module_init走哪条路。下面我分开讲,先讲最核心、也最能体现宏展开魅力的 built-in 路径。
2. 逐层撕开 module_init:从一行宏到一个ELF段
2.1 第一层:__initcall的简单接力
先看 built-in 路径。module_init(hello_init);预处理后变成:
__initcall(hello_init);紧接着,__initcall定义在include/linux/init.h里:
#define __initcall(fn) __define_initcall(fn, 6)这一步没有太多花活,就是把函数名和你没直接见过的一个数字“6”绑定在了一起。为什么是6?因为内核把初始化过程分成了从0到7多个等级,6对应设备驱动这一层,后面第三部分我细讲。现在只需要知道,hello_init被打上了“设备驱动级初始化函数”的标签。
2.2 第二层:__define_initcall开始动真格
真正的魔术发生在__define_initcall。它在include/linux/init.h中的核心定义是:
#define __define_initcall(fn, id) \ static initcall_t __initcall_##fn##id __used \ __attribute__((__section__(".initcall" #id ".init"))) = fn;先把宏展开写出来,__define_initcall(hello_init, 6)会变成:
static initcall_t __initcall_hello_init6 __used __attribute__((__section__(".initcall6.init"))) = hello_init;这一步干了两件事。第一,声明并定义了一个static变量,名字叫__initcall_hello_init6,它的类型是initcall_t。这个类型在前面也有定义:
typedef int (*initcall_t)(void);所以__initcall_hello_init6本质上是一个函数指针变量,初始值就是hello_init的函数地址。第二,这个变量被强制放进了名字叫.initcall6.init的 ELF section 里。
你可能会问:搞个函数指针变量放在特殊段里,图什么?图的是链接器能把这些散落在各个文件里的函数指针,按照段名统一收集到一段连续的内存区域。不同驱动文件里的module_init(xxx)都会在自己的.o文件里生成一个名为__initcall_xxx_init6的指针变量,并且都会被放进.initcall6.init段。链接的时候,这些指针会被连续排列在一起,形成一个函数指针数组。关键是:没有任何代码直接调用__initcall_hello_init6,都是通过段的起始地址和结束地址来遍历。这就是“数据驱动初始化”的核心思路。
2.3 第三层:__used和__section__背后的两个坑
这一步里有几个容易忽略的细节,第一个是__used。它展开后是__attribute__((__used__)),作用就是告诉编译器:就算你在当前编译单元里没看到任何代码引用这个变量,也不要因为它“看起来没用”就把它优化掉。
想想就明白,如果没用__used,GCC 在-O2下发现__initcall_hello_init6只被赋值、从未被读取,完全可以直接把这个变量从输出里删掉。可它偏偏是要靠段收集机制被启动代码间接引用的,编译器根本感知不到这种引用。所以内核必须用__used强制保留。新手自己写类似机制时最容易踩的就是这个坑——辛辛苦苦把函数指针放进自定义段,一开优化,段里空荡荡,全是优化器的功劳。
第二个细节是__section__(".initcall6.init")。注意字符串的拼接:宏里#id会把id变成字符串"6",然后和".initcall"、".init"拼在一起,最终是".initcall6.init"。如果你传入的id是两位数,或者带其他内容,section 名也会跟着变,但命名规则始终是.initcall{level}.init。
到这里,module_init(hello_init)在 built-in 路径下的完整展开链条就清楚了:
module_init(hello_init); → __initcall(hello_init); → __define_initcall(hello_init, 6); → static initcall_t __initcall_hello_init6 __used __attribute__((__section__(".initcall6.init"))) = hello_init;本质上,你每写一次module_init(fn),就等于往.initcall6.init这个“登记表”里新增了一行,登记内容就是你的fn函数地址。
3. initcall 机制:这些函数指针到底被谁执行
3.1 链接脚本定边界:start 和 end 符号
上一节说所有__initcall_xxx_init6会被链接器收集到.initcall6.init段里,那系统怎么知道这个段的起点和终点?靠的是内核链接脚本,也就是类似arch/x86/kernel/vmlinux.lds.S这种文件。
链接脚本里有一段专门划拨 initcall 区域,大意如下:
__initcall_start = .; *(.initcallearly.init) __initcall0_start = .; *(.initcall0.init) __initcall0s.init = .; // 简化示意 __initcall1_start = .; *(.initcall1.init) ... __initcall6_start = .; *(.initcall6.init) ... __initcall7_start = .; *(.initcall7.init) __initcall_end = .;链接器在生成 vmlinux 时,遇到这些位置计数器(.)和起始符号,就会把对应的输入段按序排列,并记录下每个层级的起始地址。链接完成之后,__initcall6_start指向的就是.initcall6.init段里第一个函数指针的地址,__initcall7_start指向的是下一个段的起始地址。所以遍历.initcall6.init段的代码不需要知道里面到底有多少个函数指针,只需要从__initcall6_start一直走到__initcall7_start之前就行。
这里还有一点值得注意:链接脚本里往往用了KEEP()之类的指令,防止链接器做垃圾收集时把这些“没人直接引用”的函数指针段丢掉。这和源码里__used的用意是一脉相承的——一个在编译期防优化器,一个在链接期防链接器。
3.2 do_initcalls:开机时的一次集体点名
内核启动时,start_kernel走到rest_init之后,会创建内核线程执行kernel_init,再一路调用到do_initcalls。do_initcalls的逻辑不复杂,大致是:
static void __init do_initcalls(void) { int level; for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++) do_initcall_level(level); }initcall_levels是一个数组,里面存的正是链接脚本定义的那些 start 符号:
static initcall_t *initcall_levels[] __initdata = { __initcall0_start, __initcall1_start, __initcall2_start, __initcall3_start, __initcall4_start, __initcall5_start, __initcall6_start, __initcall7_start, };do_initcall_level(6)做的事情就是遍历从__initcall6_start到__initcall7_start之间的每一个函数指针,逐个调用:
static void __init do_initcall_level(int level) { initcall_t *fn; for (fn = initcall_levels[level]; fn < initcall_levels[level + 1]; fn++) do_one_initcall(*fn); }do_one_initcall会真正执行(*fn)(),也就是调用你的hello_init,然后检查返回值、打印日志。如果hello_init返回了非 0 值,内核会把这个 initcall 的名字和返回值记录下来,但不会因为一个驱动初始化失败就让整个系统停机。这也是为什么module_init注册的函数签名必须是int (*)(void)——内核要靠这个返回值判断初始化是否成功。
3.3 level 编号:为什么设备驱动偏偏是6
前面一直说__define_initcall(fn, 6)里的 6 代表设备驱动层。实际上内核里面不止有module_init,还提供了一整套按优先级排列的 initcall 宏:
| 宏 | level | 说明 |
|---|---|---|
pure_initcall | 0 | 纯初始化,不依赖其他子系统 |
core_initcall | 1 | 核心子系统,比如内存、调度 |
postcore_initcall | 2 | 核心之后 |
arch_initcall | 3 | 架构相关 |
subsys_initcall | 4 | 子系统 |
fs_initcall | 5 | 文件系统 |
device_initcall | 6 | 设备驱动 |
late_initcall | 7 | 最晚的常规初始化 |
module_init在 built-in 情况下展开成的__initcall(fn)就是__define_initcall(fn, 6),所以它和device_initcall(fn)是等价的。这也解释了一个常见的疑问:为什么驱动代码里有人写module_init(...),有人写device_initcall(...),看起来风马牛不相及,实际效果却差不多——因为 built-in 时,module_init最终就是device_initcall。
这个层级设计很简单也很实用:内核启动时很多子系统有依赖关系,比如你得先有内存管理,才谈得上注册块设备驱动。所以 initcall 不是一股脑全跑,而是按 level 从 0 到 7 分段执行,每一段内的函数指针只是按链接顺序依次调用。设备驱动被安排在 6 这个比较靠后的位置,就是希望它依赖的核心机制已经就绪。
4. 实操验证:把 hello_init 的宏展开挖出来看
4.1 先编译:让 Kbuild 帮我们干活
讲了这么多理论,不如亲手验证一下。准备一个最小hello.c,然后写一个标准 Makefile:
obj-m += hello.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean直接make,它会按模块方式编译,生成hello.o和最终的hello.ko。如果你还想看 built-in 路径下的展开,最简单的办法是把obj-m改成obj-y,然后在某个内核配置里把它编进内核,或者临时用编译命令加-U MODULE把模块宏去掉。不过对大多数人来说,同一个源文件两种路径都编译一遍,对比产物,是最直观的。
4.2 用 nm 和 readelf 把符号和段挖出来
先看模块方式编译出来的hello.ko:
nm hello.ko | grep -E "init_module|hello_init|cleanup_module"你会看到类似这样的输出:
0000000000000000 T init_module 0000000000000000 t hello_init 0000000000000030 T cleanup_module 0000000000000030 t hello_exit注意看:init_module和cleanup_module是全局符号T,hello_init和hello_exit是局部符号t。这说明模块模式下,宏确实帮你把入口函数绑定到了模块加载器约定的固定符号名上。实际加载模块时,内核就是通过.gnu.linkonce.this_module段里的struct module找到init和exit函数指针的,而这两个指针在生成hello.mod.c时引用的正是init_module和cleanup_module。所以你在.ko里能看到这两个全局符号。
再看 built-in 方式编译出来的普通目标文件。假设你通过某种方式编译出了 built-in 版本的hello.o,用 readelf 查它的 section:
readelf -S hello.o | grep initcall输出里会有一行:
[..] .initcall6.init PROGBITS ...这就证实了指针变量确实被放进了.initcall6.init段。再用 nm 查符号:
nm hello.o | grep initcall你会看到一个奇怪的名字:
0000000000000000 R __initcall_hello_init6这个变量就是宏展开的产物。它是只读数据段里的一个函数指针变量,值等于hello_init的地址。用objdump -dr hello.o还能看到这个指针指向的重定位目标:
objdump -dr hello.o | grep initcall大致会显示类似__initcall_hello_init6这个符号的重定位条目指向hello_init。到了这一步,宏展开了什么、段放在哪里、指针指向谁,全都能用工具亲眼确认,不需要猜。
4.3 模块模式对比:符号表里的差异
模块模式为什么不一样?因为模块是系统跑起来之后才加载的,它不可能在编译时就放进 vmlinux 的 initcall 段里。模块加载器需要的是一个固定的入口符号。老版本内核的处理方式非常直白,直接用 GCC 的 alias 属性:
#define module_init(x) int init_module(void) __attribute__((alias(#x)));展开后相当于给hello_init起了一个别名init_module。新版本内核的形式虽然改成init_module(x)这种旧式声明,但最终在 ELF 符号表里呈现的效果仍然是:模块必须解析出init_module这个全局符号作为初始化入口。
所以实操中判断一个模块入口是否被正确绑定,最直接的方法就是nm hello.ko:看到T init_module就说明入口符号有了;如果只有t hello_init而没有T init_module,那这个模块装进去肯定找不到初始化函数,加载时会报类似 unknown symbol 的错误。写自定义构建脚本的人经常在这个地方翻车,因为我见过太多人自己手搓链接流程,结果漏掉了入口符号绑定。
5. 常见问题与排查技巧实录
5.1 同一个文件里写了两个 module_init
内核要求一个文件只能有一个module_init。如果你好奇心强,写了两个:
module_init(hello_init); module_init(hello_init2);built-in 编译时,最终链接的.initcall6.init段里会同时存在__initcall_hello_init6和__initcall_hello_init2_6两个指针,启动时两个函数都会被调用。如果两个函数都同名,那就直接产生重复定义错误。更重要的是,模块模式下,入口符号只能叫init_module,你写两个module_init,等于试图给同一个符号绑定两个别名,编译链接阶段就会打架。记住:每个编译单元最多一个初始化入口,这是硬性规定。
5.2 函数签名不对,编译警告满天飞
initcall_t是int (*)(void),所以传给module_init的函数必须是返回 int、参数为空的形式。如果你写的函数是void hello_init(void),在一些严格的配置下会得到类型不匹配的警告,甚至直接报错。我见过不少人把__init忘了、把返回类型写错,然后在do_initcall阶段看到诡异的启动日志。排查思路很简单:module_init(x)的x一定是int (*)(void)类型的函数,别让它变成其他形状。
5.3 函数被优化器扔掉:__used的来由
前面提过,__initcall_hello_init6这个指针变量在编译单元内只被赋值、从未被直接读取。如果不加__used,-O2下它可能会被优化掉。内核源码里,__define_initcall展开结果自带__used,所以正常情况下不会出问题。但如果你自己写类似的“放函数指针进自定义段”的机制,一定记得加上这个属性。这是我实际踩过的坑:自己搞了一套初始化表,没用__used,结果 release 版和 debug 版行为不一样,查了半天才发现是优化器把表项删了。
5.4 initcall 的运行环境比你想的简陋
built-in 驱动的hello_init在内核启动很早期就被调用了,那时候很多子系统还没准备好。你在这个函数里访问的设备、注册的中断、分配的内存,可能依赖某个比你 level 更晚的子系统。如果依赖没满足,常见现象是启动日志里出现 initcall 失败,但系统继续跑,直到你真正使用这个设备时才报错。排这种问题,先确认你的初始化要依赖什么,再选择一个合适的 level。比如你的驱动需要文件系统能力,就别放 6 之前的层;如果你的模块是个纯软件功能,不碰设备,也可以用late_initcall把它往后放,降低依赖风险。
5.5 常见问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
module_init重复定义 | 一个编译单元里出现多个初始化入口 | 检查每个.c文件是否只调用一次module_init |
| 编译警告 parameter names without types | 模块模式下宏展开成旧式声明 | 确认入口函数签名符合int init(void),可暂时忽略该警告 |
nm hello.ko看不到init_module | 入口符号被漏掉或链接脚本错误 | 检查是否走了标准 Kbuild,不要手搓链接命令 |
| 启动日志显示某 initcall 返回负数 | 初始化函数返回非 0 | 用initcall_debug启动参数打开更详细日志,或直接 printk 定位 |
| 入口函数没执行 | 函数被优化器丢弃或段没被收集 | 检查__used属性、链接脚本是否有KEEP段 |
| same symbol 冲突 | 多个文件定义了同名静态函数但宏展开变量名撞车 | 不同模块函数名尽量起得具体一点 |
最后再分享一个我自己常用的排查技巧:内核启动参数里加一个initcall_debug,启动日志会打印每个 initcall 的名字、执行耗时和返回值。如果怀疑某个驱动没跑起来,grep 一下日志里的hello_init,几秒钟就能定位问题,比你反复加 printk 然后重新编译内核高效得多。另一个技巧是,写这种链接进内核的程序时,多利用nm和readelf而不是只盯着源码看——宏展开的结果到底进了哪个段、生成了什么符号,工具比人脑可靠。我个人在实际操作中体会最深的就是:module_init这层宏的魔力不在于它写了多少代码,而在于它把“数据收集”这件事变成了“段收集”,用编译器、链接器和启动代码三方协作,把初始化函数的注册和调用彻底解耦了。理解这一步,再看内核里各种__initcall、.init段,基本就能触类旁通。