2. 多系统启动流程:从CPU上电复位到操作系统内核启动的完整链路分析
说实话,很多做嵌入式开发的朋友,对启动流程的理解停留在「上电 -> 跑起来」这个层面。但当你开始做多系统分区适配,或者要自己写Bootloader的时候,这条链路里的每一个细节都会卡住你。
我个人习惯把启动流程拆成四个阶段:硬件初始化 -> Bootloader加载 -> 内核解压 -> 根文件系统挂载。今天咱们就顺着这条线,把每个环节掰开揉碎讲清楚。
核心认知:从CPU复位到OS启动,本质上是「从硬到软」的接力过程。每一棒交接时,上一棒都要为下一棒准备好「运行环境」——寄存器状态、内存布局、设备树、页表,缺一不可。
2.1 第一阶段:CPU上电与复位向量
CPU上电那一刻,它其实不知道自己该干嘛。硬件设计决定了它必须去一个固定地址取第一条指令——这就是复位向量。
| 架构 | 复位向量地址 | 说明 |
|---|---|---|
| ARM Cortex-A | 0x00000000 或 0xFFFF0000 | 由硬件引脚或寄存器配置决定 |
| ARM Cortex-M | 0x00000000(向量表基址) | 前4字节存栈顶,后4字节存复位函数地址 |
| RISC-V | 0x80000000(典型值) | 由mtvec寄存器或硬件固定 |
| x86 | 0xFFFFFFF0 | 最后16字节,跳转到BIOS/UEFI入口 |
我在项目中遇到过一个问题:某款ARM芯片的复位向量默认指向片内ROM,但我们的Bootloader放在外部NOR Flash里。结果上电后CPU直接跑进了ROM里的厂方Boot,根本不鸟我们的代码。后来查手册才发现,得通过一个特殊寄存器把复位向量重映射到外部Flash的起始地址。嗯,这种坑,手册不翻到第500页根本看不到。
注意:复位向量指向的存储介质,必须保证上电瞬间就能被CPU访问。片内SRAM、片内ROM、或者已经完成初始化的SDRAM都可以。但如果你把复位向量指向DDR,而DDR控制器还没初始化——那就死锁了。
2.2 第二阶段:Bootloader的加载与自举
CPU从复位向量取到第一条指令后,就开始执行Bootloader。但Bootloader本身也有个加载过程,我把它分成三步:
- 一级Boot(固化Boot):芯片出厂时烧在ROM里的微型加载器。它负责初始化最基本的时钟和存储控制器,然后把二级Boot从Flash搬到SRAM里。
- 二级Boot(SPL/MLO):通常只有几KB到几十KB。它初始化DDR控制器,把完整的Bootloader(如U-Boot)从Flash搬到DDR里。
- 完整Bootloader:U-Boot、GRUB、或你自己写的Loader。它负责加载操作系统内核。
你想想看,为什么不能一步到位?说白了,芯片上电时DDR还没初始化,CPU只能用片内SRAM。而SRAM通常只有几十KB,放不下完整的Bootloader。所以只能分步走——小Loader初始化大内存,大Loader加载内核。
我的经验:调试二级Boot时,我习惯在SPL里加一个GPIO翻转的调试点。如果看到GPIO没翻,说明SPL根本没跑起来;翻了但卡住,那就是DDR初始化有问题。这比串口打印靠谱多了——串口驱动在SPL阶段可能还没初始化好。
2.3 第三阶段:内核加载与设备树传递
Bootloader把内核镜像从Flash(或网络、USB)读到内存后,并不是直接跳过去就完事了。它得先准备好几样东西:
- 内核启动参数(ATAG或DTB):告诉内核内存大小、控制台设备、根文件系统位置等。
- 设备树(Device Tree):描述硬件拓扑结构,让内核知道有哪些外设、它们的地址和中断号。
- 寄存器状态:MMU要关闭(或处于已知状态),中断要关闭,栈指针要有效。
// U-Boot中加载内核的典型命令
// 从NAND Flash读取内核到内存地址0x82000000
nand read 0x82000000 0x200000 0x800000
// 从内存地址0x83000000读取设备树
load mmc 0:1 0x83000000 /boot/am335x-evm.dtb
// 设置启动参数
setenv bootargs 'console=ttyO0,115200 root=/dev/mmcblk0p2 rw'
// 启动内核
bootz 0x82000000 - 0x83000000
我曾经踩过一个坑:设备树里某个外设的中断号写错了,结果内核启动后那个驱动死活不工作。查了两天才发现,是设备树版本和内核版本不匹配。从那以后,我每次更新内核都会同步更新设备树,并且用dtc -I dtb -O dts反编译确认一下。
2.4 第四阶段:内核解压与初始化
内核镜像通常是压缩的(zImage、uImage、Image.gz)。Bootloader跳过去后,内核自带的解压代码会先把自己解压到内存的另一个位置,然后跳转到解压后的入口。
解压完成后,内核开始执行start_kernel(),这是C语言的入口。它会做:
- 初始化CPU子系统(SMP、中断控制器、定时器)
- 初始化内存管理(页表、伙伴系统、slab分配器)
- 初始化进程调度器
- 初始化设备模型(根据设备树创建设备节点)
- 挂载根文件系统
- 启动第一个用户态进程(init)
这里有个关键点:根文件系统挂载。如果内核找不到根文件系统,就会panic。挂载方式可以是:
| 方式 | 典型场景 | 注意事项 |
|---|---|---|
| initramfs | 嵌入式系统、Live CD | 内核内置,无需存储驱动,但占用内存 |
| 块设备(MMC/SATA/NAND) | 大多数嵌入式Linux | 需要对应的驱动编译进内核 |
| 网络文件系统(NFS) | 开发调试阶段 | 需要网络驱动,启动慢但方便 |
避坑指南:我曾经把根文件系统放在一个需要加载驱动的分区上,但那个驱动本身又在根文件系统里。这就成了「先有鸡还是先有蛋」的问题。解决方案是把驱动编译进内核(built-in),而不是作为模块。
2.5 完整启动流程图
下面这张图把整个链路串起来了。我建议你把它打印出来贴在工位上——调试启动问题的时候,对着图看log,定位会快很多。
2.6 多系统场景下的特殊处理
如果你要在同一块芯片上跑多个系统(比如Linux + RTOS,或者双Linux),启动流程会多出几个环节:
- 分区表解析:Bootloader需要读取分区表(GPT/MBR),找到每个系统镜像的位置。
- 系统选择:通过按键、GPIO电平、或存储介质里的标志位,决定启动哪个系统。
- 资源隔离:如果两个系统共享外设,Bootloader需要在跳转前把外设恢复到安全状态。
- 回滚机制:如果一个系统启动失败,Bootloader要能自动切到备份系统。
我的做法:在多系统项目中,我会在Bootloader里维护一个「启动计数器」。每次启动某个系统时加1,如果系统正常启动并通知Bootloader,就把计数器清零。如果计数器超过3次,就自动切到另一个系统。这个机制救过我两次——一次是OTA升级刷了个坏内核,一次是文件系统损坏。
好了,启动链路就讲到这里。你可能会问:「这些细节跟我写应用层代码有什么关系?」关系大了——系统起不来,你连调试的机会都没有。把这条链路刻在脑子里,以后遇到启动问题,你就能快速定位到是哪个环节出了岔子。