[内核学习]内核启动流程

/ 0评 / 0赞

从 bootloader 松手到 shell 提示符出来,中间是内核生命周期里密度最高的两秒钟.这篇顺着这条线走一遍,大部分环节拿实机日志对号入座,initcall 部分在 QEMU 里开了调试参数逐个计时.

这篇回答几件事:

先看交接现场.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 程序自己没法给自己的,只能汇编手工搭台.主流程几个站:

这段没有一行 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_init155msjitter 熵池,反复采样测抖动,慢是功能
of_platform_default_populate_init108msDT 节点展开成 platform 设备
chr_dev_init105ms字符设备框架
sock_init76ms协议栈
loop_init52msloop 设备

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 一个长命进程或死循环,脚本跑完就等于把系统炸了.

最后留几个容易忽略的细节:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注