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)。它做的事:
- 设置页表、开启MMU
- 切换到C语言环境(设置栈指针、BSS段清零)
- 调用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以内。