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
我个人的习惯: 遇到启动早期挂死,先查汇编阶段的串口输出。如果连 "Booting Linux..." 都没看到,问题大概率在汇编部分,跟 C 代码无关。

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。这里发生了两件大事:

  1. 创建 kernel_init 线程——这是 1 号进程的前身
  2. 创建 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 号进程诞生了。

避坑指南: 我曾经在调试一块板子时,内核启动到 "VFS: Mounted root" 之后就没动静了。查了半天,发现是根文件系统里没有 /sbin/init。内核找不到 init 程序,直接 panic。所以,做文件系统时一定要确认 init 程序的位置和权限。

3.7 完整启动流程图

下面这张图把整个流程串起来。我建议你把它打印出来贴在工位上。

内核启动流程:从 start_kernel 到 init 进程 内核启动流程总览 汇编阶段:stext / _start start_kernel 第一阶段:底层基础 setup_arch → trap_init → mm_init start_kernel 第二阶段:核心子系统 sched_init → init_IRQ → time_init start_kernel 第三阶段:驱动与文件系统 console_init → vfs_caches_init → do_initcalls rest_init kernel_init 线程 (1号进程) kthreadd 线程 (2号进程) 挂载根文件系统 执行 /sbin/init (用户态) 关键说明 • 0号进程:idle • 1号进程:init (用户态) • 2号进程:kthreadd

3.8 总结:记住这几条线

整个启动流程,说白了就是三条线:

  • 硬件初始化线: 从汇编到 setup_arch,再到各个子系统
  • 驱动初始化线: do_initcalls 按优先级依次调用
  • 进程诞生线: 0号(idle) → 1号(init) → 2号(kthreadd)

你只要记住这三条线,以后看启动日志、定位启动问题,脑子里就有地图了。

最后说一句: 我每次接手一个新平台,第一件事就是完整抓一份启动日志,对照着这个流程看一遍。哪个阶段花了多少时间、哪个驱动初始化失败了,一目了然。这个习惯帮我省了无数 debug 的时间。