1、启动流程全景:从BootROM到用户空间的完整启动链路分析

嵌入式Linux启动,说白了就是一场接力赛。

从芯片上电那一刻起,BootROM先跑,然后交给Bootloader,再交给内核,最后交给用户空间的init进程。每一棒都不能掉链子。我做了这么多年嵌入式,见过太多启动卡死在某个环节的案例——嗯,今天我们就来把这根链条从头到尾捋一遍。

1.1 第一棒:BootROM——芯片出厂就写好的“铁代码”

芯片一上电,CPU的PC指针会指向一个固定的地址。这个地址里存的是什么?就是BootROM。

BootROM是芯片出厂时固化的只读代码,你改不了它。它的任务很简单:

  • 初始化最基本的硬件(时钟、DDR控制器、存储控制器)
  • 从指定的启动介质读取下一级代码(比如从SD卡、eMMC、NAND Flash、SPI NOR等)
  • 验证签名(如果使能了安全启动)
  • 跳转到下一级代码

我个人习惯把BootROM叫做“芯片的胎教”——它决定了芯片出生后第一口奶从哪里吃。

关键点:BootROM的大小通常只有几十KB到几百KB。它必须足够小,因为它是硬编码在芯片内部的。

举个例子,i.MX6系列的BootROM会检查BOOT_CFG引脚的电平,来决定从SD卡还是NAND启动。我曾在项目中遇到过客户焊错了BOOT_CFG电阻,结果芯片死活起不来——查了两天才发现是这个问题。

1.2 第二棒:SPL/TPL——Bootloader的“前戏”

BootROM加载完SPL(Secondary Program Loader)后,就把控制权交给了它。

SPL是啥?说白了就是U-Boot的精简版。它太小了,小到只能做几件事:

  • 初始化DDR内存(这一步很关键,因为BootROM阶段DDR可能还没完全配好)
  • 初始化存储控制器
  • 从存储介质加载完整的U-Boot
  • 跳转到U-Boot

有些芯片还有TPL(Tertiary Program Loader),比如TI的AM335x系列。TPL夹在SPL和U-Boot之间,负责更精细的硬件初始化。你想想看,为什么需要这么多级?因为BootROM太小了,干不了复杂的事,只能一点一点来。

避坑指南:我曾经在调试一块新板子时,SPL加载U-Boot后总是死机。后来发现是DDR时序参数没配对——SPL阶段DDR初始化成功,但U-Boot重新初始化DDR时把参数覆盖了。解决办法是在U-Boot里跳过DDR的重复初始化。

1.3 第三棒:U-Boot——真正的Bootloader

U-Boot加载完成后,系统才真正有了“交互能力”。

U-Boot做的事包括:

  • 完整的硬件初始化(网卡、USB、显示等)
  • 解析设备树(DTB)
  • 从存储介质加载内核镜像(zImage/Image)
  • 设置内核启动参数(bootargs)
  • 跳转到内核入口

这里有个细节很多人会忽略:U-Boot会把自己从DDR的高地址区域挪走,给内核腾出空间。我刚开始做Yocto时,就因为这个地址重叠问题,内核解压时把U-Boot覆盖了,导致系统崩溃。

注意:U-Boot的环境变量(env)默认保存在存储介质的固定分区。如果env损坏,U-Boot会使用默认参数启动。我曾经遇到过eMMC坏块导致env丢失,板子每次启动都进不了内核——最后在U-Boot里加了坏块检查和备份env的逻辑才解决。

1.4 第四棒:内核启动——从汇编到C的华丽转身

U-Boot跳转到内核入口时,会传递三个参数:

  • r0:0
  • r1:机器类型ID(老内核用,新内核已废弃)
  • r2:设备树(DTB)在内存中的地址

内核入口代码是汇编写的,叫stext(ARM架构)或start_kernel(x86)。它做的事:

  1. 设置页表、开启MMU
  2. 切换到C语言环境(设置栈指针、BSS段清零)
  3. 调用start_kernel()函数

start_kernel()是内核初始化的核心函数。它按顺序调用各种子系统的初始化:

  • trap_init():设置中断向量表
  • mm_init():内存管理初始化
  • sched_init():调度器初始化
  • init_IRQ():中断控制器初始化
  • time_init():时钟系统初始化
  • console_init():控制台初始化(终于能看到打印信息了)
  • ... 还有几十个init调用

最后,start_kernel()会调用rest_init(),创建两个内核线程:

  • kernel_init:负责执行所有的设备驱动初始化(do_initcalls)
  • kthreadd:负责管理其他内核线程

关键点:从start_kernel()到控制台能打印信息,中间大概有几百毫秒的“静默期”。如果系统死在这里,你连个错误提示都看不到。我调试过一块板子,就是卡在mm_init()里——原因是DMA内存区域配置冲突。

1.5 第五棒:设备驱动初始化——do_initcalls的“排队”艺术

内核启动过程中,所有的设备驱动都通过do_initcalls()来初始化。这些驱动按照优先级排队:

优先级 宏定义 说明
0 pure_initcall 最早期,纯软件初始化
1 core_initcall 核心子系统,如中断控制器
2 postcore_initcall 核心之后的初始化
3 arch_initcall 架构相关初始化
4 subsys_initcall 总线子系统
5 fs_initcall 文件系统
6 device_initcall 普通设备驱动
7 late_initcall 最晚,如网络设备

为什么要分优先级?因为驱动之间有依赖关系。比如I2C控制器必须在I2C设备之前初始化。如果顺序错了,系统就会崩溃。

我曾在项目中遇到过一个问题:某个GPIO驱动被标记为device_initcall,但它依赖的时钟驱动是core_initcall。按理说没问题,但时钟驱动里有个延时函数依赖定时器,而定时器是postcore_initcall——结果GPIO驱动初始化时,定时器还没好,直接卡死。最后我把GPIO驱动改成了late_initcall才解决。

1.6 第六棒:根文件系统挂载——从内核态到用户态的“桥梁”

所有驱动初始化完成后,kernel_init线程会尝试挂载根文件系统。挂载方式取决于内核启动参数中的root=:

  • root=/dev/mmcblk0p2:从eMMC分区挂载
  • root=/dev/nfs:从网络挂载NFS文件系统(调试时常用)
  • root=31:0:通过设备号指定

挂载成功后,内核会执行根文件系统里的init程序。这个程序通常是:

  • /sbin/init(最常用)
  • /etc/init
  • /bin/init
  • /bin/sh(如果以上都找不到)

避坑指南:我曾经在制作Yocto镜像时,忘记把init程序打包进根文件系统。结果内核启动到最后,打印了一句“No init found. Try passing init= option to kernel.”——然后系统就挂在那了。排查了半天才发现是init脚本的权限没设置可执行。

1.7 第七棒:用户空间初始化——从init到你的应用程序

init程序启动后,系统正式进入用户空间。嵌入式Linux常用的init系统有三种:

init系统 特点 适用场景
BusyBox init 轻量级,配置简单 资源受限的嵌入式设备
System V init 传统方式,基于运行级别 兼容性要求高的系统
systemd 功能强大,并行启动 性能较强的设备(如树莓派)

以BusyBox init为例,它会读取/etc/inittab文件,按顺序启动:

  • 系统初始化脚本(/etc/init.d/rcS)
  • 各运行级别的服务
  • getty(串口登录)
  • 你的应用程序

嗯,这里要注意:很多嵌入式工程师喜欢把应用程序直接放在rcS脚本里启动。这样做虽然简单,但会导致启动顺序混乱、依赖管理困难。我建议用init.d脚本配合启动优先级(S01xxx、S02xxx这样的命名方式),这样更可控。

1.8 启动时间分布——哪里最耗时?

我统计过一个典型嵌入式Linux系统的启动时间分布(基于i.MX6ULL,256MB DDR,eMMC):

阶段 耗时 占比
BootROM + SPL ~200ms 5%
U-Boot ~800ms 20%
内核启动(含驱动初始化) ~1.5s 37%
根文件系统挂载 ~300ms 7%
用户空间初始化 ~1.2s 30%

看到没?内核和用户空间初始化占了将近70%的时间。这也是为什么启动优化的重点通常放在这两个阶段。

总结:整个启动链路就像一条流水线。每个环节都有优化的空间,但前提是你得先搞清楚瓶颈在哪。我见过有人花了两周优化U-Boot启动时间,结果只省了100ms——而内核驱动里一个不必要的延时函数就占了500ms。所以,先测量,再优化,别瞎忙活。

好了,启动流程全景就讲到这里。下一章我们会深入U-Boot的启动优化,看看怎么把那800ms砍到300ms以内。