从 bootloader 松手到 shell 提示符出来,中间是内核生命周期里密度最高的两秒钟.这篇顺着这条线走一遍,大部分环节拿实机日志对号入座,initcall 部分在 QEMU 里开了调试参数逐个计时.
这篇回答几件事:
- bootloader 交接那一刻机器处于什么状态
- head.S 那段汇编为什么必须存在,干了什么
- start_kernel 的主干和 initcall 机制怎么运转
- 内核怎么交棒给第一个用户进程,交出去的瞬间发生什么
- init 进程死了会怎样(这个我真测出来了)
先看交接现场.ARM64 的内核启动协议不复杂:bootloader 把 Image 加载到内存(这个 Image 是位置无关的,放哪都行,按 text offset 对齐),然后把 DTB 设备树的物理地址放进 x0,x1 到 x3 清零,MMU 关着,D-cache 关着,自己挑一个核当主核,在 EL1 或 EL2 上跳过去.就这么多.内核醒来第一眼看到的"配置文件"就是 x0 指着的 DTB.
看板上的启动参数会更直观:
console=ttyS2,1500000n8 earlycon=uart8250,mmio32,0xff1a0000,1500000n8,24000000 root=PARTUUID=...
里面最有味道的是 earlycon:内核连串口驱动都没初始化时,想打印只能直接往 MMIO 寄存器地址写.earlycon 参数把串口的物理地址 0xff1a0000 手工喂给内核,绕过整个驱动体系,八个字符一条指令,这是启动早期唯一的 printf,后面 head.S 排查全靠它.
然后是 head.S,arch/arm64/kernel/head.S.为什么内核开头必须是一段汇编:C 世界需要的东西 — 栈,全局变量可达,合法地址 — 一样都还没准备,而这些恰恰是 C 程序自己没法给自己的,只能汇编手工搭台.主流程几个站:
- primary_entry(head.S:85),第一站.preserve_boot_args 把 x0~x3 存进全局,DTB 地址从此不丢
- 查 CurrentEL,EL2 就降到 EL1,1.1 那篇讲过的那个判断就在这
- 建早期页表:恒等映射(物理地址=虚拟地址)加内核映像映射.为什么必须恒等:开 MMU 那一条指令执行时 PC 还是物理地址,MMU 一开同一个 PC 突然要按虚拟地址解释,不提前铺好恒等映射,CPU 直接掉进未定义深渊.这是所有 RISC 内核启动最经典的一个瞬间
- __primary_switch(head.S:508)开 MMU,__primary_switched 换上真正的栈,跳进 C 世界
这段没有一行 printk,教科书式的"黑".想知道它到底跑成什么样,单步调试是唯一直观办法,QEMU 的 gdbstub 正好干这个,下一篇专门说.
进了 C,主角是 start_kernel(init/main.c).这是内核的"总装车间",一百多个函数调用排下去,只挑里程碑说:setup_arch 解析 DTB,登记内存,挂 earlycon;然后中断表,内存管理,调度器挨个就位;console_init 之后串口驱动活了,世界亮了;最后 rest_init(main.c:672)生出两个重要的内核线程 — kthreadd(以后所有内核线程的父进程)和 kernel_init(即将交棒的那个),start_kernel 自己变成 idle 线程,在 CPU0 上原地打盹.板上日志给的时间锚点:
[ 0.036314] Memory: 2026404K/2095104K available (10560K kernel code, ...)
开机 36 毫秒,2G 内存全部登记入册.注意这行还没到 initcall,说明内存探测和 memblock 初始化全在这几十毫秒里完成.
start_kernel 后半段的重头戏是 initcall 机制,这个设计值得单独讲.内核几十个子系统都需要"内核起来之后初始化一下",如果全写成 start_kernel 里的函数调用,main.c 会变成垃圾场.initcall 的做法:每个子系统用一个宏(subsys_initcall,fs_initcall,device_initcall...)把初始化函数登记进专门的链接段,链接器收集,启动时 do_initcall_level(main.c:1293)按级别逐级放枪,函数本体完全散在各自家里.级别顺序是依赖关系的凝固:总线先于设备,核心先于用户.
放枪过程可以逐个计时,加启动参数 initcall_debug 就行.有个坑先说:它的输出是 KERN_DEBUG 级别,默认 console_loglevel 直接吞掉,我第一遍跑参数加了却一条不见,以为没生效,加 ignore_loglevel 才全吐出来.QEMU 里跑出来全景:九个级别,总共 1956 个 initcall:
0.119 early -> 0.346 pure -> 0.353 core -> 0.493 postcore -> 0.534 arch
-> 0.681 subsys -> 0.834 fs -> 1.004 device -> 2.188 late
最慢的几个:
| initcall | 耗时 | 说明 |
|---|---|---|
| jent_mod_init | 155ms | jitter 熵池,反复采样测抖动,慢是功能 |
| of_platform_default_populate_init | 108ms | DT 节点展开成 platform 设备 |
| chr_dev_init | 105ms | 字符设备框架 |
| sock_init | 76ms | 协议栈 |
| loop_init | 52ms | loop 设备 |
of_platform_populate 那条是伏笔 — 设备树怎么变成 /dev 下活生生的设备,3.2 那篇的主角.jent 挺有性格:开机最慢的一步是给自己攒随机数,CPU 时序抖动采样,越测不准越好,一次实测 155 毫秒花得明明白白.
最后交棒.kernel_init 线程(main.c:1434)跑完最后的 initcall,开始找 init 进程,查找顺序:cmdline 指定的 rdinit(initramfs 里的 /init),然后 init= 参数,然后 /sbin/init 一串默认值.板上日志:
[ 1.281589] Freeing unused kernel memory: 1856K
[ 1.282114] Run /sbin/init as init process
1.28 秒交棒,板上走的是 rootfs 的 /sbin/init;QEMU 里是 initramfs,对应行是 "Run /init as init process",查找顺序在同一份代码里看得清清楚楚.那行 Freeing 也别放过:所有 __init 函数住在专门的 .init 段,使命完成后整段回收,板上收回 1856K,和 Memory 行里的 "1856K init" 正好对账 — 链接期收集,启动期执行,交棒后回收,这就是 __init 的一生.
交棒之后有个第一次实验翻车的实录,比成功记录更有价值.QEMU 里我的 init 脚本因为 busybox 没装 applet 符号链接,grep 找不到,脚本 exit 127,然后:
Kernel panic - not syncing: Attempted to kill init! exitcode=0x00007f00
PID 1 是不可替代的:它死了没有谁有权回收它重新调度,内核直接 panic,连正常退出(exit 0)都不行 — 收养孤儿进程是 init 的职责,职责没了系统就没了.顺带解个码:0x7f00 就是 wait status 编码,127 << 8,低八位是信号(这里是 0,即不是被信号杀的,是退出码 127).这给写 initramfs /init 脚本的人一个铁律:最后必须是 exec 一个长命进程或死循环,脚本跑完就等于把系统炸了.
最后留几个容易忽略的细节:
- earlycon 和 console 是两套系统:earlycon 物理地址直写,活到 console_init 就退;真正 console 驱动起来前的窗口期全靠它.板子起不来连 printk 都没有时,先检查 cmdline 里 earlycon 地址对不对.
- initcall 级别选错是驱动初始化顺序 bug 的头号来源:依赖某总线就得比它晚一级,device_initcall 里_probe platform 设备失败,九成可以先查依赖的东西是不是 subsys_initcall.
- 板子 1.28 秒到 init,QEMU 反而 2.53 秒,不是模拟器慢那么简单 — QEMU 里熵池等待,virtio 枚举都要时间;启动耗时排名别只盯 CPU 频率.
- KASLR 两个世界:QEMU 日志明写 "KASLR enabled",panic 时还报了 Kernel Offset;板上 System.map 和 kallsyms 分毫不差,位移为零.同款内核, bootloader 给不给熵源决定两种命运.
- panic=-1 配 no-reboot 是自动化测试标准姿势,panic 立即重启, QEMU 直接退出,脚本好写.
- rest_init 之后 start_kernel 变成的 idle 线程不是废物,CPU 空闲时进的就是它,节能调度那套都从这发起.
- do_initcall_level 每级还会解析该级的内核命令行参数段(initcall_blacklist 排查坏驱动时好用,黑名单一个 initcall 定位"是不是它把启动搞挂的",比二分编译快多了).