4. 常见分区方案解析
嵌入式系统上电后,第一段跑的程序不是 Linux 内核,而是 Bootloader。很多人觉得 Bootloader 就是个「跳板」,跳完就没了。其实不然——它负责初始化硬件、加载分区表、校验镜像、传递启动参数。说白了,没有 Bootloader,内核连 DDR 都摸不到。
我个人习惯把 Bootloader 比作「系统助产士」。它不参与后续的业务逻辑,但少了它,整个系统就生不出来。这一章我们就聊聊 U-Boot——嵌入式领域最主流的 Bootloader。
4.1 Bootloader 到底干了什么
Bootloader 的核心职责就三件事:
- 硬件初始化:时钟、DDR、串口、存储控制器。让 CPU 能跑、内存能用、外设能访问。
- 加载镜像:从 Flash、SD 卡、网络等介质把内核和设备树读到内存。
- 传递控制权:设置好启动参数,跳转到内核入口。
我在项目中遇到过一块板子,上电后串口死活没输出。查了两天,发现是 Bootloader 里 DDR 时序参数配错了。内核镜像加载到错误地址,直接跑飞。嗯,从那以后我每次改 DDR 参数都会反复对数据手册。
Bootloader 的典型工作流
- 上电复位,CPU 从固定地址取指令(通常是 ROM 或 SPI Flash 起始地址)
- 执行一级 Bootloader(固化在 ROM 里,不可改)
- 一级 Bootloader 加载二级 Bootloader(如 U-Boot SPL)到 SRAM
- SPL 初始化 DDR,加载完整的 U-Boot 到 DDR
- U-Boot 解析分区表,加载内核和设备树
- 跳转到内核,Bootloader 使命完成
4.2 U-Boot 启动流程拆解
U-Boot 的启动流程,我习惯分成三个阶段来讲。你想想看,一个 Bootloader 从几百字节的 ROM 代码,最终膨胀到几百 KB 的完整镜像,中间必然有层次。
4.2.1 第一阶段:SPL(Secondary Program Loader)
SPL 是 U-Boot 的精简版。它体积小,通常只有 16KB~64KB,放在 SRAM 里跑。SPL 的任务很简单:
- 初始化最基本的硬件(时钟、DDR 控制器)
- 从存储介质加载完整的 U-Boot 到 DDR
- 跳转到 U-Boot 入口
为什么需要 SPL?因为 DDR 初始化之前,CPU 只能用片内 SRAM。SRAM 太小,装不下完整的 U-Boot。所以先跑一个小家伙把 DDR 搞起来,再请大块头进场。
避坑指南:我曾经在 SPL 阶段踩过一个坑——SPL 里使能了 Cache,但 DDR 还没完全稳定。结果 Cache 回写时把错误数据写进了 DDR,U-Boot 加载后校验失败。解决方案很简单:在 DDR 完全初始化之前,关掉 Cache。
4.2.2 第二阶段:U-Boot 主程序
U-Boot 主程序加载到 DDR 后,会做更完整的初始化:
- 初始化更多外设(网卡、USB、MMC)
- 解析环境变量
- 执行 bootcmd 命令
- 加载内核和设备树
这里有个关键点:U-Boot 会读取分区表。分区表通常放在存储介质的固定偏移位置,比如 eMMC 的 GPT 分区表在 LBA 1。U-Boot 通过分区表找到内核分区、设备树分区、根文件系统分区的位置。
4.2.3 第三阶段:加载内核
U-Boot 加载内核的典型命令序列:
# 从 MMC 分区加载内核到内存地址 0x80008000
mmc dev 0
fatload mmc 0:1 0x80008000 zImage
# 加载设备树
fatload mmc 0:1 0x83000000 imx6ull.dtb
# 设置启动参数
setenv bootargs console=ttymxc0,115200 root=/dev/mmcblk0p2 rw
# 启动内核
bootz 0x80008000 - 0x83000000
我个人习惯把内核加载地址固定下来,写在环境变量里。这样每次烧录新内核,只需要改分区内容,不用改启动脚本。
4.3 U-Boot 环境变量:Bootloader 的「配置文件」
U-Boot 环境变量是存储在 Flash 或 eMMC 里的一组键值对。它决定了 U-Boot 的行为。你想想看,如果没有环境变量,每次改启动参数都要重新编译 U-Boot,那得多痛苦。
| 环境变量 | 作用 | 典型值 |
|---|---|---|
bootcmd |
自动启动时执行的命令 | run distro_bootcmd |
bootargs |
传递给内核的启动参数 | console=ttyS0,115200 root=/dev/mmcblk0p2 |
loadaddr |
内核加载地址 | 0x80008000 |
fdt_addr |
设备树加载地址 | 0x83000000 |
bootdelay |
自动启动前的等待秒数 | 3 |
ethaddr |
MAC 地址 | 00:11:22:33:44:55 |
环境变量的操作命令很简单:
# 查看所有环境变量
printenv
# 设置环境变量
setenv bootdelay 5
# 保存到存储介质
saveenv
注意:setenv 只修改内存中的值,saveenv 才会写入 Flash。我见过有人调试时改了环境变量,没保存就断电重启,结果又回到旧配置。嗯,这种错误犯一次就够了。
4.4 从分区加载内核:实战流程
实际项目中,内核、设备树、根文件系统都放在不同的分区。U-Boot 需要知道每个分区的位置和大小。下面是一个典型的 eMMC 分区布局:
| 分区 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| bootloader | 0x0 | 2MB | U-Boot 镜像 |
| env | 0x200000 | 128KB | 环境变量 |
| kernel | 0x220000 | 8MB | zImage |
| dtb | 0xA20000 | 1MB | 设备树 |
| rootfs | 0xB20000 | 剩余空间 | 根文件系统 |
U-Boot 从分区加载内核的完整流程:
- U-Boot 启动,读取环境变量
- 执行
bootcmd,调用distro_bootcmd或自定义脚本 - 扫描存储设备,找到分区表
- 根据分区表偏移,读取内核分区到
loadaddr - 读取设备树分区到
fdt_addr - 设置
bootargs,指定根文件系统分区 - 调用
bootz或bootm启动内核
我曾经在一个项目里遇到分区表损坏的情况。U-Boot 读不到内核分区,直接卡死在 Loading Kernel...。后来我在 U-Boot 里加了分区表校验,如果校验失败就回滚到备份分区。这个机制后来救了我们好几次。
4.5 U-Boot 启动流程总览
下面这张图把整个流程串起来了。从 ROM 代码开始,到内核跑起来为止。我建议你把它存下来,调试的时候对照着看。
4.6 常见问题与调试技巧
调试 Bootloader 最痛苦的地方在于——它出问题时,系统连串口输出都没有。我总结了几条经验:
- 串口没输出:先检查时钟配置,再检查 GPIO 复用。我遇到过 PinMux 配错导致 TX 脚没功能的案例。
- 内核加载后没反应:大概率是加载地址不对。U-Boot 和内核约定的加载地址必须一致。
- 环境变量保存失败:检查存储介质的分区偏移。我曾经把环境变量分区和内核分区重叠了,一保存就把内核覆盖了。
- 分区表读不到:确认 GPT 或 MBR 格式是否正确。U-Boot 默认只支持 GPT 和 MBR 两种格式。
调试小技巧:在 U-Boot 里加一个 while(1) 循环,配合 GPIO 翻转来定位死机位置。用示波器看 GPIO 波形,比猜代码快多了。我习惯在 SPL 初始化 DDR 前后各翻转一次 GPIO,这样一眼就能看出 DDR 初始化是否卡住。
嗯,Bootloader 这部分内容就到这里。记住一句话:Bootloader 是系统启动的基石,它不复杂,但容错率极低。每一个参数、每一个地址、每一个时序,都值得你反复确认。