4. 常见分区方案解析

嵌入式系统上电后,第一段跑的程序不是 Linux 内核,而是 Bootloader。很多人觉得 Bootloader 就是个「跳板」,跳完就没了。其实不然——它负责初始化硬件、加载分区表、校验镜像、传递启动参数。说白了,没有 Bootloader,内核连 DDR 都摸不到。

我个人习惯把 Bootloader 比作「系统助产士」。它不参与后续的业务逻辑,但少了它,整个系统就生不出来。这一章我们就聊聊 U-Boot——嵌入式领域最主流的 Bootloader。

4.1 Bootloader 到底干了什么

Bootloader 的核心职责就三件事:

  • 硬件初始化:时钟、DDR、串口、存储控制器。让 CPU 能跑、内存能用、外设能访问。
  • 加载镜像:从 Flash、SD 卡、网络等介质把内核和设备树读到内存。
  • 传递控制权:设置好启动参数,跳转到内核入口。

我在项目中遇到过一块板子,上电后串口死活没输出。查了两天,发现是 Bootloader 里 DDR 时序参数配错了。内核镜像加载到错误地址,直接跑飞。嗯,从那以后我每次改 DDR 参数都会反复对数据手册。

Bootloader 的典型工作流

  1. 上电复位,CPU 从固定地址取指令(通常是 ROM 或 SPI Flash 起始地址)
  2. 执行一级 Bootloader(固化在 ROM 里,不可改)
  3. 一级 Bootloader 加载二级 Bootloader(如 U-Boot SPL)到 SRAM
  4. SPL 初始化 DDR,加载完整的 U-Boot 到 DDR
  5. U-Boot 解析分区表,加载内核和设备树
  6. 跳转到内核,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 从分区加载内核的完整流程:

  1. U-Boot 启动,读取环境变量
  2. 执行 bootcmd,调用 distro_bootcmd 或自定义脚本
  3. 扫描存储设备,找到分区表
  4. 根据分区表偏移,读取内核分区到 loadaddr
  5. 读取设备树分区到 fdt_addr
  6. 设置 bootargs,指定根文件系统分区
  7. 调用 bootz 或 bootm 启动内核

我曾经在一个项目里遇到分区表损坏的情况。U-Boot 读不到内核分区,直接卡死在 Loading Kernel...。后来我在 U-Boot 里加了分区表校验,如果校验失败就回滚到备份分区。这个机制后来救了我们好几次。

4.5 U-Boot 启动流程总览

下面这张图把整个流程串起来了。从 ROM 代码开始,到内核跑起来为止。我建议你把它存下来,调试的时候对照着看。

U-Boot 启动流程总览 U-Boot 启动流程总览 阶段一:ROM Code(一级 Bootloader) 阶段二:SPL(二级 Bootloader) 阶段三:U-Boot 主程序(完整初始化) 读取环境变量 解析分区表 加载内核 + DTB 阶段四:跳转到内核入口 Linux 内核运行 说明:SPL 和 U-Boot 主程序之间通常有 DDR 初始化,这是整个流程中最容易出问题的环节

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 是系统启动的基石,它不复杂,但容错率极低。每一个参数、每一个地址、每一个时序,都值得你反复确认。