写Linux内核模块的人第一行正经代码大概率就是module_init(hello_init)。但我敢打赌很多人写了大半年驱动都没认真想过这句话到底是什么——它不是函数调用不是声明而是一连串宏展开的起点。这篇文章就把module_init(hello_init)这行代码从预处理、编译到链接、启动一层层撕开来看搞清楚它究竟怎么把你的hello_init变成了开机时自动执行的入口函数。这篇文章适合刚开始学内核模块的人也适合写了一段时间驱动但只停留在“照着模板抄”阶段的开发者。读完之后你会明白module_init为什么既能用在编译进内核的驱动上又能用在外插模块上你也能自己通过gcc -E、nm、readelf去验证宏展开的每一步而不是光听别人讲。1. 先认识一下主角hello.c 和 module_init1.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__))作用就是告诉编译器就算你在当前编译单元里没看到任何代码引用这个变量也不要因为它“看起来没用”就把它优化掉。想想就明白如果没用__usedGCC 在-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_initcall0纯初始化不依赖其他子系统core_initcall1核心子系统比如内存、调度postcore_initcall2核心之后arch_initcall3架构相关subsys_initcall4子系统fs_initcall5文件系统device_initcall6设备驱动late_initcall7最晚的常规初始化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然后写一个标准 Makefileobj-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.konm 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是全局符号Thello_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 查它的 sectionreadelf -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段基本就能触类旁通。
