2. 嵌入式系统启动流程:从复位到操作系统加载

嵌入式系统上电后,CPU 到底干了什么?

很多人觉得启动就是跑个 main 函数。其实没那么简单。从按下电源到操作系统跑起来,中间藏了好几个阶段。每个阶段都有它的使命,也都有坑。

我个人习惯把启动流程拆成五步:CPU 复位向量 → ROM 代码 → 一级 Bootloader(SPL)→ 二级 Bootloader → 操作系统加载。下面咱们一步步拆开看。

核心观点:嵌入式启动不是“一步到位”,而是“层层接力”。每一级都比上一级更复杂,但每一级都要为下一级铺好路。

嵌入式系统启动流程总览 CPU 复位向量 0x00000000 ROM 代码 固化 BootROM 一级 Bootloader SPL / MLO 二级 Bootloader U-Boot / GRUB 操作系统 Linux Kernel 各阶段核心职责 • 复位向量:CPU 上电后第一条指令的入口地址 • ROM 代码:芯片内部固化的启动代码,负责初始化时钟、DDR、存储控制器 • SPL:从 NAND/eMMC/SD 卡加载到 SRAM,初始化 DDR • 二级 Bootloader:从 DDR 运行,加载设备树、内核镜像到内存 • 操作系统:接管硬件,挂载根文件系统,启动 init 进程

2.1 CPU 复位向量:一切从这里开始

CPU 上电后,PC 指针会指向一个固定地址。这个地址就是复位向量。

对于 ARM Cortex-A 系列,复位向量通常是 0x00000000 或 0xFFFF0000。对于 x86,是 0xFFFFFFF0。不同架构不一样,但逻辑一样——CPU 从这里取第一条指令。

我的经验:有一次调试一块新板子,上电后 CPU 死活不跑。示波器量了时钟、电源都正常。最后发现复位向量指向的 Flash 区域是空的——芯片根本没烧录 BootROM。嗯,这种低级错误我犯过一次就再也没忘过。

复位向量处放的通常是一条跳转指令,比如 ARM 下的 B Reset_Handler。这条指令会跳到真正的初始化代码入口。

; ARM 复位向量示例
.section .vectors
.word   _estack          ; 栈顶地址
.word   Reset_Handler    ; 复位向量
.word   NMI_Handler      ; NMI 中断
.word   HardFault_Handler ; 硬错误中断
; ... 其他中断向量

你想想看,如果这里放错了地址,整个系统就废了。所以向量表的位置和内容是硬件调试时首先要确认的。

2.2 ROM 代码:芯片出厂就有的“小管家”

ROM 代码也叫 BootROM。它是芯片出厂时固化在内部 ROM 里的程序。用户改不了。

BootROM 的任务很简单:初始化最基本的硬件,然后从外部存储加载下一级代码。

具体来说,BootROM 会做这几件事:

  • 初始化 CPU 内部寄存器
  • 配置系统时钟(PLL、分频器)
  • 初始化存储控制器(NAND、eMMC、SD 卡接口)
  • 根据启动模式(Boot Mode)选择存储介质
  • 从存储介质读取固定大小的代码到内部 SRAM
  • 跳转到 SRAM 执行

注意:BootROM 通常只支持有限的存储介质和文件系统。比如有些芯片只支持从 NAND Flash 的固定偏移读取,不支持 FAT32 文件系统。这意味着你的 SPL 镜像必须放在特定位置,否则 BootROM 找不到它。

我记得有一次项目里,BootROM 从 SD 卡读取 SPL 时总是失败。查了半天,发现 SD 卡的分区表格式不对。BootROM 要求 MBR 分区,我用了 。这种问题,不看芯片手册根本想不到。

2.3 一级 Bootloader(SPL):小而精的“开路先锋”

SPL 的全称是 Secondary Program Loader,但很多人叫它一级 Bootloader。它被 BootROM 加载到 SRAM 中运行。

为什么需要 SPL?因为 BootROM 太小了,功能有限。而真正的 Bootloader(比如 U-Boot)又太大了,SRAM 装不下。所以中间加一个 SPL 做过渡。

SPL 的核心任务:

  1. 初始化 DDR——这是最关键的一步。没有 DDR,大程序跑不了。
  2. 初始化存储控制器——为读取二级 Bootloader 做准备。
  3. 从存储介质加载二级 Bootloader 到 DDR
  4. 跳转到 DDR 执行二级 Bootloader

避坑指南:我曾经在 SPL 里初始化 DDR 时序参数配错了。结果 SPL 能跑,但加载二级 Bootloader 时 DDR 数据出错,系统随机崩溃。这种问题很难复现,因为不是每次都错。后来用示波器量了 DQS 和 CLK 的相位关系才发现问题。

SPL 的代码量通常控制在 4KB 到 64KB 之间。具体大小取决于芯片的 SRAM 容量。比如 TI 的 AM335x 系列,SRAM 只有 64KB,SPL 必须精打细算。

芯片系列 SRAM 大小 SPL 典型大小 存储介质
TI AM335x 64KB ~30KB NAND / MMC
NXP i.MX6 96KB ~40KB SD / eMMC
Allwinner H3 32KB ~24KB NAND / SPI NOR
Rockchip RK3288 128KB ~50KB eMMC / SD

2.4 二级 Bootloader:功能完整的“总指挥”

二级 Bootloader 就是我们常说的 U-Boot、GRUB、RedBoot 这些。它运行在 DDR 里,有充足的内存空间。

二级 Bootloader 的功能比 SPL 丰富得多:

  • 完整的控制台(串口交互)
  • 网络协议栈(TFTP、NFS 下载)
  • 文件系统支持(FAT、EXT4、UBIFS)
  • 设备树(Device Tree)加载和修改
  • 内核镜像校验(签名、哈希)
  • 多分区、多内核选择

我个人习惯在 U-Boot 里加一个启动计数器。如果连续三次启动失败,就自动切换到备份内核。这个功能在生产环境中特别有用。

小技巧:调试二级 Bootloader 时,我建议先关掉缓存和 MMU。因为缓存会把你的调试信息搞乱——你看到的打印顺序和实际执行顺序可能不一样。等 Bootloader 稳定了再开缓存。

2.5 操作系统加载:最后一步

二级 Bootloader 准备好一切后,就会加载操作系统内核。以 Linux 为例,流程是这样的:

  1. 读取设备树(.dtb 文件)到内存
  2. 读取内核镜像(zImage / Image)到内存
  3. 设置内核启动参数(ATAG / cmdline)
  4. 关闭 MMU 和缓存(某些架构需要)
  5. 跳转到内核入口地址
  6. 内核接管硬件,初始化子系统
  7. 挂载根文件系统
  8. 启动 init 进程(PID 1)
// U-Boot 启动 Linux 的命令示例
setenv bootargs 'console=ttyS0,115200 root=/dev/mmcblk0p2 rw'
fatload mmc 0:1 0x80000000 zImage
fatload mmc 0:1 0x83000000 imx6q-sabresd.dtb
bootz 0x80000000 - 0x83000000

这里有个细节:设备树地址和内核地址不能重叠。我曾经把设备树加载到内核镜像的地址上,结果内核解压时直接把设备树覆盖了。系统启动到一半就崩溃,连错误信息都看不到。

重要提醒:内核启动后,Bootloader 就“死”了。它的代码段、数据段所在的内存会被内核回收。所以不要在 Bootloader 里放需要在内核运行时保留的数据——除非你用特定的内存预留机制。

2.6 启动流程中的常见问题

做 Bootloader 开发这些年,我总结了几类高频问题:

阶段 常见问题 排查方向
复位向量 CPU 不执行任何代码 检查复位引脚电平、电源时序、时钟输出
BootROM 无法加载 SPL 检查启动模式引脚、存储介质格式、镜像偏移
SPL DDR 初始化失败 检查 DDR 时序参数、电压、PCB 布线
二级 Bootloader 无法加载内核 检查分区表、文件系统、内核镜像格式
操作系统 内核 panic 检查设备树、驱动、根文件系统

说白了,启动流程就是层层递进、逐级放大的过程。每一级都只做自己该做的事,然后把接力棒交给下一级。理解了这个逻辑,你调试启动问题的时候就知道该从哪里下手了。

嗯,这一章的内容就到这里。启动流程是 Bootloader 开发的地基,后面讲 SPL 和 U-Boot 移植的时候,你会反复用到这些知识。