3. 内核启动流程:从 start_kernel 到 init 进程
说实话,我刚入行那会儿,看内核启动代码就像看天书。一堆函数调用,各种初始化,根本不知道谁先谁后。后来被一个 bug 折磨了整整三天——板子启动到一半就挂了,串口只打印了半行信息。从那以后,我老老实实把 start_kernel 到 init 进程的整个流程啃了一遍。
今天我就带你走一遍这条路。你搞懂了它,以后定位启动问题、裁剪内核、甚至自己写驱动初始化,心里都有底。
3.1 起点:start_kernel 之前的世界
很多人以为内核从 start_kernel 开始。其实不是。在它之前,架构相关的汇编代码已经干了不少活。
以 ARM64 为例,上电后 CPU 先跑在 EL3 或 EL2,经过一段汇编(通常是 stext 或 _start),完成这几件事:
- 设置页表,开启 MMU
- 初始化栈指针
- 清零 BSS 段
- 把设备树(DTB)的地址传给内核
- 跳转到
start_kernel
3.2 start_kernel:一切初始化的总调度
start_kernel 在 init/main.c 里。它不干具体的活,它是个调度员——按顺序调用各个子系统的初始化函数。
我把它分成三个阶段:
| 阶段 | 关键函数 | 干了什么 |
|---|---|---|
| 第一阶段:底层基础 | setup_arch, trap_init, mm_init |
架构相关、中断向量、内存管理 |
| 第二阶段:核心子系统 | sched_init, init_IRQ, softirq_init, time_init |
调度器、中断、定时器 |
| 第三阶段:驱动与文件系统 | console_init, vfs_caches_init, do_initcalls |
控制台、VFS、模块初始化 |
顺序是固定的。为什么?因为后面的依赖前面的。比如没有 mm_init,你连 kmalloc 都用不了。
3.3 第一阶段:先把地基打牢
3.3.1 setup_arch — 架构说了算
这是第一个被调用的 C 函数。它解析设备树、探测物理内存布局、建立内核页表。不同架构的实现完全不同。
我在做 ARM 平台移植时,经常要改 setup_arch 里的内存映射。有一次忘记设置 DMA 区域的属性,结果 DMA 传输一直报错,查了两天才发现是页表属性没配对。
3.3.2 trap_init 与 mm_init
trap_init 设置异常向量表。说白了,就是告诉 CPU:发生中断、缺页、系统调用时,该跳到哪里去。
mm_init 初始化伙伴系统和 slab 分配器。从这之后,内核才能动态分配内存。
mm_init 完成之前,绝对不能调用 kmalloc 或 vmalloc。我曾经见过有人在这之前用了 kmalloc,结果内核直接 panic。想调试都没法调,因为串口还没初始化。
3.4 第二阶段:调度、中断与时间
3.4.1 sched_init — 调度器上线
初始化进程调度器。创建 idle 进程(PID=0),初始化 runqueue。从这之后,内核才有「进程」的概念。
但注意,此时还没有任何用户进程。第一个用户进程要等到后面才诞生。
3.4.2 init_IRQ 与 softirq_init
初始化硬件中断控制器(GIC 等)和软中断机制。中断子系统正式可用。
为什么中断要这么早初始化?因为后面的定时器、控制台、块设备都依赖中断。没有中断,系统就是个轮询的傻子。
3.4.3 time_init — 时间开始流动
初始化系统时钟和定时器。从这之后,jiffies 开始递增,udelay 和 msleep 才能正常工作。
3.5 第三阶段:控制台、文件系统与驱动
3.5.1 console_init — 终于能看见东西了
初始化串口控制台。在这之前,所有打印信息都缓存在一个临时缓冲区里。console_init 之后,printk 的内容才会真正出现在串口上。
这也是为什么你经常看到内核启动时,前面几行打印突然跳出来——它们早就打印了,只是现在才刷到串口。
3.5.2 vfs_caches_init — 文件系统的基础
初始化 VFS(虚拟文件系统)的缓存,包括 dcache 和 inode cache。没有这个,你连根文件系统都挂载不了。
3.5.3 do_initcalls — 驱动初始化的主战场
这是驱动开发者最关心的部分。do_initcalls 会遍历所有 .initcall 段,按优先级依次调用各个模块的初始化函数。
优先级分为 7 个等级:
| 等级 | 宏 | 典型用途 |
|---|---|---|
| 0 | pure_initcall |
纯软件初始化,不依赖硬件 |
| 1 | core_initcall |
核心子系统,如中断控制器 |
| 2 | postcore_initcall |
依赖核心子系统的模块 |
| 3 | arch_initcall |
架构相关驱动 |
| 4 | subsys_initcall |
总线子系统 |
| 5 | fs_initcall |
文件系统 |
| 6 | device_initcall |
普通设备驱动(最常用) |
| 7 | late_initcall |
需要晚点初始化的驱动 |
关键点: 你的驱动用 module_init 注册时,默认就是 device_initcall(等级 6)。如果你的驱动依赖某个总线(比如 I2C、SPI),而总线驱动是 subsys_initcall(等级 4),那你的驱动在等级 6 运行,总线肯定已经准备好了。
反过来,如果你的驱动需要在总线之前初始化,那就得用 arch_initcall 或 core_initcall。但别乱用——等级越低,能用的服务越少。
3.6 从内核态到用户态:init 进程的诞生
do_initcalls 执行完毕后,内核调用 rest_init。这里发生了两件大事:
- 创建 kernel_init 线程——这是 1 号进程的前身
- 创建 kthreadd 线程——这是 2 号进程,所有内核线程的父进程
然后,rest_init 调用 cpu_startup_entry,把 CPU 交给 idle 进程(0 号进程)。从此,调度器开始工作。
kernel_init 线程继续执行,它做三件事:
- 挂载根文件系统(从
root=参数或设备树中获取) - 打开
/dev/console作为标准输入输出 - 执行用户空间的 init 程序(通常是
/sbin/init、/etc/init或/bin/sh)
当 kernel_init 调用 run_init_process 时,内核态切换到用户态。1 号进程诞生了。
/sbin/init。内核找不到 init 程序,直接 panic。所以,做文件系统时一定要确认 init 程序的位置和权限。
3.7 完整启动流程图
下面这张图把整个流程串起来。我建议你把它打印出来贴在工位上。
3.8 总结:记住这几条线
整个启动流程,说白了就是三条线:
- 硬件初始化线: 从汇编到
setup_arch,再到各个子系统 - 驱动初始化线:
do_initcalls按优先级依次调用 - 进程诞生线: 0号(idle) → 1号(init) → 2号(kthreadd)
你只要记住这三条线,以后看启动日志、定位启动问题,脑子里就有地图了。
最后说一句: 我每次接手一个新平台,第一件事就是完整抓一份启动日志,对照着这个流程看一遍。哪个阶段花了多少时间、哪个驱动初始化失败了,一目了然。这个习惯帮我省了无数 debug 的时间。