2. 嵌入式系统启动流程:从复位到操作系统加载
嵌入式系统上电后,CPU 到底干了什么?
很多人觉得启动就是跑个 main 函数。其实没那么简单。从按下电源到操作系统跑起来,中间藏了好几个阶段。每个阶段都有它的使命,也都有坑。
我个人习惯把启动流程拆成五步:CPU 复位向量 → ROM 代码 → 一级 Bootloader(SPL)→ 二级 Bootloader → 操作系统加载。下面咱们一步步拆开看。
核心观点:嵌入式启动不是“一步到位”,而是“层层接力”。每一级都比上一级更复杂,但每一级都要为下一级铺好路。
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 分区,我用了 GPT。这种问题,不看芯片手册根本想不到。
2.3 一级 Bootloader(SPL):小而精的“开路先锋”
SPL 的全称是 Secondary Program Loader,但很多人叫它一级 Bootloader。它被 BootROM 加载到 SRAM 中运行。
为什么需要 SPL?因为 BootROM 太小了,功能有限。而真正的 Bootloader(比如 U-Boot)又太大了,SRAM 装不下。所以中间加一个 SPL 做过渡。
SPL 的核心任务:
- 初始化 DDR——这是最关键的一步。没有 DDR,大程序跑不了。
- 初始化存储控制器——为读取二级 Bootloader 做准备。
- 从存储介质加载二级 Bootloader 到 DDR
- 跳转到 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 为例,流程是这样的:
- 读取设备树(.dtb 文件)到内存
- 读取内核镜像(zImage / Image)到内存
- 设置内核启动参数(ATAG / cmdline)
- 关闭 MMU 和缓存(某些架构需要)
- 跳转到内核入口地址
- 内核接管硬件,初始化子系统
- 挂载根文件系统
- 启动 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 移植的时候,你会反复用到这些知识。