☰
module_init 宏展开全解析:从函数指针到 initcall 段
2026/9/26 14:22:42 网站建设 项目流程

写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_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,然后写一个标准 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段,基本就能触类旁通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询