1. 从一次内核启动故障说起:为什么内核要把大把函数“登记在册”
先说个我实际遇到过的案例。前几年调试一块ARM平台板卡,外设驱动在内核启动阶段死活不工作,日志打到一半就停住,而且每次停的位置还不完全一样。查了一整天,最后问题落在一个很隐蔽的地方:某个驱动模块用late_initcall注册了初始化函数,但该初始化函数依赖的外设时钟源,却要等到subsys_initcall阶段才被配置。这个依赖关系在代码层面完全看不出来,只有在内核完整的 initcall 调用顺序表里才能发现端倪。
当时同事问我:你怎么知道这两个函数一个在late_initcall一个在subsys_initcall?我说看System.map文件就行,里面每个 initcall 函数都被链接器安排到了内核镜像专门的段里,启动时内核会按照段的前后顺序依次调用。那次调试之后,我算是把 initcall 机制从头到尾啃了一遍。
这篇文章就是想把 linux-initcall 机制整个拆开来讲。它解决的核心问题是:内核庞大、驱动众多,如何让不同子系统在启动阶段按既定优先级有序初始化,而不是靠一堆散落的函数调用把start_kernel撑成一锅粥。
如果你正准备深入内核源码,或者在做嵌入式驱动开发时需要搞明白“我的函数到底什么时候被调用、能不能提前、能不能靠后”,这篇文章会讲清楚以下这些事:
- initcall 机制为什么而存在,它解决了什么结构性问题
- 链接器、链接脚本、宏定义之间是怎样配合的
- 从
start_kernel到do_initcalls的完整调用链 - 七个级别优先级背后的设计逻辑
- 驱动开发中最常见的误用和排查思路
2. 为什么要有 initcall:从“调用者主动呼叫”到“被调用者主动登记”
2.1 没有 initcall 的世界:一个函数调一个函数,最后全都挤在 start_kernel
想象一下没有 initcall 机制的局面。
你要初始化一个 PCI 子系统,再初始化 USB 子系统,再初始化网络协议栈。最直接、最简单的写法是:在start_kernel这个函数里,按顺序调用pci_init()、usb_init()、net_init()。哪个先执行、哪个后执行,就看你代码调用顺序怎么写。
最开始的内核,其实在一定程度上就是这么干的。但这种办法在早期内核规模小的时候还凑合,等内核长到几十万行、几百万行的时候问题就来了:
内核是“全量编译”的,但具体板卡上用到哪些驱动,只有运行的时候才知道。你把网卡驱动直接写死在start_kernel的调用链里,那这块板卡没有网卡怎么办?不能跑。你把触摸屏驱动写进核心调用链,那服务器板卡没有触摸屏,也浪费。
所以需要一种机制,让各个驱动、各个子系统“自告奋勇”地把自己的初始化函数贴到一张“登记表”上。内核启动到某个阶段时,统一把登记表从头到尾翻一遍,依次调用这些函数。
这就是 initcall 机制的出发点:由被调用者主动登记,由内核统一调度。
2.2 initcall 的正确读音与名字由来
initcall,全称是 initialization call,也就是“初始化调用”。在 Linux 内核源码里,几乎所有相关宏都以*_initcall结尾,例如core_initcall、arch_initcall、subsys_initcall、device_initcall等。
每个宏在编译时做的事,本质上是把开发者写的初始化函数塞进一个专属的 ELF section(段)。链接器最终会把所有同级别的 initcall 函数,按照链接顺序连续安排在同一段内存区域里。内核启动时再根据段地址范围,从低地址到高地址逐个执行。
注意:这里的“段”是 ELF 可执行文件格式里的 section,比如
.initcall1.init、.initcall2.init,它们在链接时会被统一布局到.init段区域内。init 段里的数据在内核启动完成后会被释放掉,所以 initcall 函数的生命周期仅限于启动阶段。
2.3 与设备驱动模型的分工:Bus、Driver、Device 三者的配合
讲到 initcall,很多人会想到设备模型。这里需要先分清一个概念:initcall 机制和设备模型不是一回事,但它们有明确的分工。
- initcall 机制:负责“子系统自身”的初始化,比如 PCI 总线框架的初始化、USB 核心的初始化、驱动框架本身(driver model)的初始化。
- 设备模型:在子系统初始化完成后,负责“设备”和“驱动”的匹配、probe(探测)、bind(绑定)。
实际启动时往往是这样的顺序:subsys_initcall里先把总线系统(如 PCI host controller)初始化好,等总线就绪之后,device_initcall里的驱动才可能被 probe。驱动 probe 依赖总线的存在,这种依赖关系无法靠驱动自己判断,只能靠优先级分层隔离开。
所以 initcall 机制本质上是一个层次化依赖解决器。内核把初始化函数分成了七个阶段,每个阶段代表一个依赖层级。低层级的初始化先跑,高层级的后跑,依赖关系就天然解耦了。
3. 七个优先级级别拆解:core、postcore、arch、subsys、fs、device、late
3.1 七级优先级的名称与宏定义
内核头文件include/linux/init.h中定义了七级优先级的 initcall 宏:
| 优先级 | 宏名 | 对应段名 | 典型用途 |
|---|---|---|---|
| 0 级 | core_initcall | .initcall1.init | 内核最核心的子系统,如内存管理、调度器、中断控制器 |
| 1 级 | postcore_initcall | .initcall2.init | 依赖核心子系统但又不属于最底层的基础模块 |
| 2 级 | arch_initcall | .initcall3.init | 架构相关的初始化,如 arm 架构的 arch 初始化和特定平台的早期设备 |
| 3 级 | subsys_initcall | .initcall4.init | 总线系统、驱动框架、IOMMU 等子系统级初始化 |
| 4 级 | fs_initcall | .initcall5.init | 文件系统相关初始化,如 VFS、ext4 等 |
| 5 级 | device_initcall | .initcall6.init | 大部分设备驱动的初始化入口,在module_init展开后落在这里 |
| 6 级 | late_initcall | .initcall7.init | 依赖设备就绪后的最后一轮初始化 |
这里有个非常重要的知识点:驱动开发里最常用的module_init宏,其实是device_initcall的别名。具体来说,module_init(x)最终会展开到__define_initcall(x, 6),落进.initcall6.init段。你在一个驱动文件里写:
module_init(my_driver_init);它和直接写:
device_initcall(my_driver_init);在效果上完全一样。
3.2 从宏定义看编译细节
看include/linux/init.h中的核心定义:
#define __define_initcall(fn, id) \ static initcall_t __initcall_##fn##id __used \ __attribute__((__section__(".initcall" #id ".init"))) = fn;以core_initcall(foo_init)为例,它展开后大概是:
static initcall_t __initcall_foo_init1 __used \ __attribute__((__section__(".initcall1.init"))) = foo_init;这行声明的含义是:
- 定义一个静态变量,名字叫
__initcall_foo_init1 - 这个变量的类型是
initcall_t,本质上是一个函数指针——int (*)(void) - 变量初始值就是
foo_init这个函数的地址 - 整个变量被塞到
.initcall1.init这个 ELF section 里
所以,initcall 的“登记”动作在编译阶段就完成了:函数地址被固化进了一个专门的段。你不需要在源码里手动调用它,链接器会自动把成千上万个.initcallN.init段里的变量收集到一起。
3.3 段的前后顺序由谁决定
内核对每个.initcallN.init段的排列顺序,是在include/asm-generic/vmlinux.lds.h中的链接脚本里指定的。
链接脚本里会有一个近似如下的布局:
.init.initcall : { __initcall_start = .; *(.initcall1.init) *(.initcall2.init) *(.initcall3.init) *(.initcall4.init) *(.initcall5.init) *(.initcall6.init) *(.initcall7.init) __initcall_end = .; }链接器从.initcall1.init到.initcall7.init的顺序安排变量,__initcall_start和__initcall_end两个符号分别记录了整段起始地址和结束地址。
这就是整个 initcall 机制的骨架:宏负责把函数地址写成静态变量放进专属段,链接脚本负责把段收集起来按序排布,内核启动代码负责遍历这段地址空间并逐一调用。
4. 内核启动时到底怎么调用 initcall:从汇编到 C 的完整链路
4.1 入口:start_kernel 中的 rest_init 与 kernel_init
内核启动流程经过汇编阶段的stext(或_start)进入 C 语言入口start_kernel。start_kernel做了一堆基础初始化后,会调用rest_init,在rest_init里创建第一个内核线程kernel_init。
kernel_init线程后续会承担两个关键任务:
- 调用
kernel_init_freeable - 调用
kernel_init_fini,最终尝试执行根文件系统的/sbin/init
initcall 机制的启动执行,就发生在kernel_init_freeable里。
4.2 do_initcalls 的遍历逻辑
kernel_init_freeable中调用:
do_basic_setup();do_basic_setup的定义(位于init/main.c):
static void __init do_basic_setup(void) { usermodehelper_enable(); driver_init(); init_irq_proc(); do_ctors(); usermodehelper_enable(); do_initcalls(); }其中driver_init()负责初始化设备驱动模型的核心结构(bus 类型、device 类型、驱动注册系统等),之后do_initcalls()才开始真正遍历那一大串登记好的函数。
do_initcalls的实现:
static void __init do_initcalls(void) { int level; for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++) { initcall_levels[level](); } }每个initcall_levels[level]对应一个遍历函数,例如do_initcall_level1到do_initcall_level7:
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); }initcall_levels数组本身来自于一个精巧的宏技巧:
#define __initcall_start(fn) \ static initcall_t *__initcall_start_##fn[] = { fn }; #define __initcall_end(fn) \ static initcall_t *__initcall_end_##fn[] = { fn }; #define initcall_levels \ __initcall_start(__initcall1_start), \ __initcall_start(__initcall2_start), \ ...这里的核心思路是:链接脚本定义了每个 initcall 段的起始地址符号(如__initcall1_start、__initcall2_start),C 代码把它们一个个取地址,构造成一个数组。遍历时,level 1的段就是__initcall1_start到__initcall2_start之间的全部函数。相邻段之间首尾相接,没有空洞,遍历起来非常干净。
有人问:为什么用
initcall_t *数组而不是直接遍历符号?因为链接器符号的地址需要用取地址语法才能变成 C 的可读变量,这里用“数组包着一个指针”的方法来把符号地址常量化,属于内核里常见的“链接器符号转 C 指针”惯用法。
4.3 do_one_initcall:每一个函数执行时的保护逻辑
遍历过程中,真正逐个执行函数的是do_one_initcall。它在init/main.c中实现,核心代码如下(简化版):
static __initcall_func_t __init do_one_initcall(initcall_t fn) { int ret; if (initcall_debug) printk("call %pS\n", fn); ret = fn(); if (ret && ret != -ENODEV && ret != -EOPNOTSUPP) msgbuf[0] = 0; return ret; }几个关键点:
initcall_debug:内核启动参数加上initcall_debug后,每次调用前后都会打印函数名与耗时,这是排查启动性能问题最直接的武器。- 返回值处理:initcall 函数本身返回
int,但传统约定是除非有明确的错误需要上报,否则尽量返回 0。-ENODEV 和 -EOPNOTSUPP 这种“设备不存在”的返回值会被静默处理。 - 实际实现中,
do_one_initcall还会做msgbuf的错误信息格式化,比如initcall my_driver_init+0x0/0x10 returned 1这类日志,就是在这里生成的。
还有一个容易被忽略的细节:initcall 函数的执行是在关闭了部分内核抢占的情况下进行的,但并不是绝对原子。某些 initcall 可能会睡眠、等待中断,这些行为在单核启动阶段是允许的,因为此时其他 CPU 还没有完全上线,调度器处于早期状态。这也是为什么某些驱动不能在 initcall 里做太长时间的阻塞操作——它会把整个启动流程卡住。
5. 链接脚本里是怎么做手脚的:vmlinux.lds.S 的深入视角
5.1 一个真实片段的解读
看include/asm-generic/vmlinux.lds.h中与 initcall 相关的核心片段:
#define INIT_CALLS \ VMLINUX_SYMBOL(__initcall_start) = .; \ KEEP(*(.initcall0.init)) \ KEEP(*(.initcall1.init)) \ KEEP(*(.initcall2.init)) \ KEEP(*(.initcall3.init)) \ KEEP(*(.initcall4.init)) \ KEEP(*(.initcall5.init)) \ KEEP(*(.initcall6.init)) \ KEEP(*(.initcall7.init)) \ VMLINUX_SYMBOL(__initcall_end) = .;这段内容用到了两个链接器指令:
. = .:表示当前位置,把当前位置赋给__initcall_start符号。KEEP(...):告诉链接器即使某些 section 没有被直接引用,也要保留下来,不能做垃圾回收(--gc-sections 会误删这些变量)。如果少了 KEEP,某些 initcall 可能在链接时被裁掉,启动时对应的初始化函数就永远不被执行了,这是非常隐蔽的坑。
关于 initcall0 段:实际内核代码里并没有名字叫.initcall0.init的宏直接对应它。纯__define_initcall的编号是从 1 到 7。但链接脚本里保留了.initcall0.init,它主要用于存放一些由其他特殊宏产生的内容。.initcall0.init的起始地址和.initcall1.init的起始地址往往相同,或者.initcall0.init是空的,这样__initcall0_start和__initcall1_start在数值上相等,遍历第一级时不会出现问题。
5.2 System.map 中如何验证
编译完内核后,在根目录可以用:
grep "__initcall" System.map | head -30你会看到形如下面的地址行:
ffffffc000100000 t __initcall_start ffffffc000100000 t __initcall0_start ffffffc000100000 t __initcall1_start ffffffc000102000 t __initcall2_start ffffffc000105000 t __initcall3_start ... ffffffc00012d000 t __initcall_end看出门道了吗?这里__initcall_start、__initcall0_start、__initcall1_start在数值上完全相等,而__initcall2_start比__initcall1_start高了 0x2000 字节,意味着第一级 initcall 总共占用了 8192 字节。每个函数指针占 8 字节(64 位平台),所以第一级里有大约 1024 个函数指针。这只是示例数值,具体取决于内核配置。
这个文件在调试 initcall 问题时是金子,后面会专门讲怎么用。
5.3 为什么要设计成“函数指针数组连续排布”
很多人会问:不连续排布行不行?比如用某种哈希表或链表?答案是:可以,但没必要。
连续排布的好处:
- 遍历成本极低,就是内存地址递增,没有链表的指针跳转
- 排序顺序天然确定,不需要额外保存一个 order 字段
- 在启动早期(MMU 初始化完毕、但各种锁和动态内存未必完全就绪)就能实现高效的遍历
- 段位置恰恰是代码段和数据段之外的独立区域,启用
CONFIG_DEBUG_PAGEALLOC或STRICT_KERNEL_RWX时也可以针对该区域做只读保护
而且在启动结束后,整个 init 段所在的内存页会被一次性释放。如果 initcall 是用链表组织的,你得先遍历链表删除节点再释放内存,远不如现在“整段去映射、整页回收”来得干脆。
6. 实际开发中最常见的 initcall 误用:依赖倒置、死锁与启动卡死
6.1 “我的驱动初始化了,但设备还没注册”——依赖倒置问题
开发板调试时最常遇到的一种情况:
驱动 A 写的是device_initcall,它要操作设备 B 提供的服务,但设备 B 的初始化函数用的是subsys_initcall。理论上subsys_initcall先执行,一致在依赖关系上没问题。但有时驱动 B 是编译进内核的,而它内部真正的“创建设备”动作不是发生在 B 的 initcall 函数本身,而是发生在一个异步回调或者工作队列里。
比如 B 的 initcall 只是注册了一个 platform driver,实际创建设备的时机取决于设备树匹配。这样 A 的device_initcall执行时,B 的设备可能还没创建好,A 去platform_get_resource拿到 NULL。
这种问题的排查思路:
- 打开
initcall_debug看两个函数的实际执行顺序。 - 在 A 的 initcall 执行时打印当前已注册设备列表。
- 调整依赖级别。如果 A 确实依赖 B 的异步动作,就不能再靠 initcall 级别来硬卡顺序了,应当改用
deferred probe,让 A 的 probe 函数返回-EPROBE_DEFER,等待内核稍后重试。
6.2 “我在 initcall 里等了 10 秒”——启动卡死的根源
很多人写驱动时图省事,在 initcall 里直接来个msleep(10000)等硬件就绪。短时间可能没事,但一旦多个驱动都这么干,启动耗时就会直线上升。
更危险的情况是,在 initcall 里等待一个“需要另一个 CPU 上线后才会触发”的事件。内核早期启动阶段,SMP 的 secondary CPU 可能还没 boot,等待条件永远不会成立,直接死锁。
经验法则是:
- initcall 里只做快速配置、资源申请、驱动注册
- 耗时操作放到 workqueue 或延迟 probe 里
- 需要和硬件交互的等待,加超时保护
6.3 “函数写了,但启动日志里根本没有它”
这是最让人抓狂的,也是 5.1 节里 KEEP 指令相关问题的现实版。常见原因:
- 宏写错了。
module_init和late_initcall同时出现在同一个文件里,且module_init被放在#ifdef MODULE保护之外。构建为内建时,module_init展开为device_initcall;构建为模块时,module_init展开为-1(表示模块加载入口),如果两个宏都生效,可能把段搞乱。 - 链接器做了垃圾回收。如果链接参数包含
--gc-sections且没有KEEP,未被引用的 initcall 变量可能被丢弃。 - 函数被优化器判定为“无用”。
__used属性通常会阻止这种情况,但某些特殊配置下仍可能出问题。
排查方法最直接:编译后grep一下 System.map,看你的函数符号在不在。如果不在,说明链接阶段就丢了;如果在,但启动日志里没调用记录,说明段遍历没到你那里,多半是__initcall_start到__initcall_end的布局出了问题。
7. 把 initcall_debug 玩明白:启动时间和调用顺序的观测
7.1 加启动参数
在 bootloader(如 U-Boot)里给内核传参:
initcall_debug启动后,dmesg里会出现类似这样的输出:
[ 0.123456] initcall core_initcall_init+0x0/0x40 returned 0 after 100 usecs [ 0.123789] initcall early_platform_init+0x0/0x20 returned 0 after 50 usecs每一行的字段意思是:
- 函数名(通过
%pS解析符号) - 返回结果
- 执行耗时
7.2 怎么分析耗时
如果某个 initcall 耗时超过几毫秒,值得注意。但不用急着下结论,因为:
- 有些 initcall 会故意等待外部硬件,比如等待 DDR 训练完成、等待 PHY 自协商完成
- 日志时间戳可能不准,早期时钟源还没完全初始化
通常我会把dmesg导出来:
dmesg | grep initcall > initcall_log.txt然后按耗时排序:
awk '{print $(NF-2), $0}' initcall_log.txt | sort -n这样能快速定位到启动耗时大户。在嵌入式产品优化启动时间时,这一步几乎是必做动作。
7.3 如何按需打印某个具体调用的参数
再分享一个进阶用法。想打印某个 initcall 内部执行到了哪一步,不想全程initcall_debug刷屏,可以在驱动里单独加:
#define DEBUG #include <linux/kernel.h>并使用pr_debug加dynamic_debug控制。但更粗暴有效的是,临时在do_one_initcall里加打印。内核源码里本来就有类似工具:
if (initcall_debug) printk(KERN_DEBUG "calling %pS @ %i\n", fn, smp_processor_id());改完重新编一下内核,就能拿到精确到 CPU 编号的调用顺序。尤其在 SMP 系统上,你会看到不同 CPU 可能同时执行不同的 initcall,但内核有锁保护,整体顺序仍是有序的。
8. 从 System.map 到启动日志:一次横向对比就能看清依赖链
8.1 手动整理完整调用顺序表
我们经常需要在开发过程中确认“我关心的函数排在哪个位置”。这里给你一个我自己常用的命令组合:
grep -E "__initcall[0-9]_start|__initcall_end|my_driver_init" System.map比如输出:
ffffffc000100000 t __initcall1_start ffffffc000102000 t __initcall2_start ffffffc000105000 t __initcall3_start ffffffc000107000 t __initcall4_start ffffffc00011a000 t __initcall5_start ffffffc00012d000 t __initcall6_start ffffffc000134000 t __initcall7_start ffffffc000137000 t __initcall_end加上你自己的驱动:
ffffffc000130abc t __initcall_my_driver_init6对照表可以看到它落在.initcall6.init段内(__initcall6_start到__initcall7_start之间)。假如你的驱动错误地用了subsys_initcall,它会落到__initcall4_start到__initcall5_start区间,那么驱动的初始化就会比fs_initcall阶段更早执行,从而可能依赖一个尚未初始化的 VFS 子系统。
8.2 什么是“隐形依赖”,怎么靠优先级破
驱动开发中通常不会显式写“我需要 VFS 就绪”,但如果驱动要在初始化阶段创建设备节点、注册 proc 接口,就隐式依赖 VFS。
内核的依赖设计原则是:
- 不依赖 VFS/文件系统的驱动,用 2 级或 3 级
- 依赖 VFS 的驱动,用
fs_initcall或device_initcall - 依赖其他驱动 probe 过的设备的驱动,统统用
module_init+-EPROBE_DEFER
这里特别推荐一种策略:不要试图把优先级调到比依赖者更低来强行解决问题。正确做法是把驱动拆成两个部分:一个“基础设施注册”部分(用低优先级 initcall,只注册 bus/driver),一个“业务逻辑”部分(用 module_init + probe 机制)。拆分之后再梳理顺序,脑子就清晰多了。
9. 与模块加载的关系:编译进内核和编译成模块的区别
9.1 module_init 的双重身份
module_init这个宏很有意思,它在不同场景下展开为不同内容:
- 编译进内核(
obj-y)时,等价于device_initcall,最终落到.initcall6.init - 编译成模块(
obj-m)时,展开为-1,也就是一个占位值,此时模块入口函数由模块加载器在 insmod/modprobe 时调用
这个设计很符合直觉:同一个驱动源码,既能静态编入内核,也能动态加载,开发者不需要改任何代码。
如果你自己看了include/linux/module.h,会发现这样的定义:
#define module_init(x) __initcall(x);而__initcall(x)又等于device_initcall(x)。这才是真正的入口:所有不显式指定优先级的驱动,默认就是 device_initcall(5 级,即.initcall6)。
9.2 模块方式加载时的顺序扩张
用模块加载时,可能又会遇到另一层问题:A 模块依赖 B 模块,但modprobe A时 B 还没加载。
解决方案有:
- 在 A 的模块描述符里用
MODULE_SOFTDEP("pre: b")声明依赖 - 用
request_module("b")主动请求加载 - 让 A 的 probe 返回
-EPROBE_DEFER,等待 B 模块加载完成后再触发重试
需要注意的是,initcall 的优先级体系并不会给动态加载模块排序。模块加载顺序由 udev/kmod 依据依赖关系与设备事件决定,和内核启动阶段 initcall 段没关系。这也是不少人从模块开发转到底层内建驱动开发后最容易混淆的概念。
10. 几个经常被问到的细节问题
10.1 initcall 函数为什么要返回 int
initcall_t的定义是:
typedef int (*initcall_t)(void);让它返回 int 而不是 void,目的是给内核一个“反馈”的渠道。虽然返回值在启动日志里可能被忽略,但某些场景(比如 fault 检查、kprobe 挂接)仍能捕获异常返回值。在CONFIG_DEBUG_RODATA等配置下,若 initcall 试图写只读区域,还会触发 fault 并被do_one_initcall里的异常处理捕获。
10.2 initcall 真的能“失败”吗
严格来说,initcall 失败不像 driver probe 失败那样有完整的重试机制。do_one_initcall里遇到非零返回值会打印日志,但不会因此中止后续 initcall。整个启动流程会继续走下去,代价是某个子系统可能没有就绪。
这里有一个很容易误导新手的点:某个驱动 initcall 返回 -ENODEV 通常被静默,因为内核认为这只是“设备不存在”,不是严重问题。但如果你在 initcall 里返回一个看起来很合理的错误码比如 -EIO,它会被打印出来但不会停止后续调用,所以不要以为“返回错误就会被重试”。
10.3 initcall 在启动早期并发执行吗
在 SMP 系统上,do_initcalls本身是顺序遍历的,不是并发散弹式执行。但是不同 CPU 可能在同一时刻跑到其他初始化流程(比如中断子系统初始化、RCU 辅助线程启动),所以不能说“整个内核在 initcall 期间是单线程的”。
不过有一个总原则:initcall 遍历期间,调度器、内存管理、锁系统都必须基本可用,否则系统根本走不到do_initcalls。这也是为什么级别越靠前的 initcall,越不能使用高端内存分配器或页表修改类操作,因为那些子系统可能还没升级到完整功能状态。
10.4 如何让某个 initcall 在末尾执行
如果你想让一个函数在所有正常 initcall 之后执行,有两个选择:
late_initcall:在所有 6 级之后,7 级段执行arch_initcall用于特定体系结构相关,但如果你只是想要全局兜底,late_initcall是最常见的答案
假如你真的需要在 7 级之后再挂一个更晚的阶段,那得自己扩展机制。早期内核里确实有一些特殊的fs_initcall_sync、device_initcall_sync同步点,不过现在 5.10+ 内核已将它们整合进了各个级别的 end 位置。看最新源码时留意一下initcall_levels数组中是否存在分号段位,别被老文章误导。
11. 我踩过的一个真实坑:同一个文件里混用 module_init 与 arch_initcall
最后分享一个我印象很深的翻车经历。
当时写一个网络 PHY 驱动,本意是让 PHY 初始化尽早执行,于是文件里写了arch_initcall(phy_register_init)。但同时又因为代码要兼容模块编译,在文件里保留了module_init(phy_register_init)。
问题来了:内核把module_init展开为device_initcall,宏内部会用变量名做拼接。两个宏同时存在,导致产生了__initcall_phy_register_init3和__initcall_phy_register_init6两个变量,同一个函数被登记了两次,分别在第三级和第六级段里。
启动日志里看起来一切正常,但 PHY 驱动在第三级就被初始化了一次,第六级又初始化了一次。第二次执行时,重复注册导致了资源泄漏和竞态,在压力测试下频繁出现诡异的内核 panic。
排查到最终原因时,我后背都凉了。内核宏体系的变量名拼接逻辑,不会因为你写了同一个函数就自动去重。
注意:不管是用
module_init、device_initcall还是arch_initcall,同一个初始化函数在一个编译单元里只能登记一次。如果你需要同时支持“内建时提前注册”和“模块加载时注册”,标准做法是:#ifdef MODULE module_init(phy_register_init); #else arch_initcall(phy_register_init); #endif
这个细节在官方文档里很少提到,但它实打实让我在调试上浪费了两天。
最后说点实在的
initcall 机制看起来就是“宏 + 链接脚本 + 遍历函数”三件套,理解起来并不难,但真正踩到坑的时候,往往是因为忽略了一个事实:函数的执行顺序在你写完宏的那一刻就已经被链接器写死了,一旦编译完成,源码里任何调用顺序都改变不了它。
日常开发里我建议你养成两个习惯:一是每次拿到一个新内核版本或新板卡,启动时开启initcall_debug跑一遍,把日志存档,后续排查问题时可以做横向对比;二是每次改动驱动的初始化级别后,立刻查一下 System.map,确认函数确实落在你期望的段里,不要只看源码里写了什么宏就完事。
内核启动流程里,像 initcall 这样“靠宏生成段布局”的机制还有很多,比如__init、__devinit、__exit等属性也都是在玩链接器把戏。如果这篇文章读完之后,你能摸到“只要看到内核里有 section 操作,就去翻链接脚本”的门路,那离看懂内核底层就真不远了。