3. U-Boot 源码结构
U-Boot 启动,说白了就是一段从死板硬件到灵活交互的蜕变过程。芯片上电那一刻,CPU 还懵懵懂懂,只知道去固定地址取指令。而我们要做的,就是引导它一步步初始化硬件、搬运代码、加载系统,最后把控制权交给你——通过命令行。
我个人习惯把启动流程拆成三个阶段:汇编阶段、板级初始化阶段、命令行交互阶段。下面我们顺着这条线,把每个关键节点掰开揉碎了讲。
核心观点:U-Boot 启动的本质是「从固定入口到动态交互」的渐进式初始化。理解这条链路,你才能精准定位移植中的死机、串口无输出、无法加载内核等问题。
3.1 第一站:reset 向量——CPU 醒来后的第一件事
CPU 上电或复位后,会从 reset vector(复位向量)开始执行。这个地址通常是 0x00000000(ARM 架构)或 0xFFFF0000(某些 Cortex-M),具体取决于 SoC 设计。
在 U-Boot 中,reset 向量定义在 arch/arm/cpu/armv7/start.S(以 ARMv7 为例)。代码长这样:
_start:
b reset
ldr pc, _undefined_instruction
ldr pc, _software_interrupt
ldr pc, _prefetch_abort
ldr pc, _data_abort
ldr pc, _not_used
ldr pc, _irq
ldr pc, _fiq
第一条指令 b reset 直接跳转到 reset 标签。为什么不用 ldr pc, =reset?因为此时 CPU 可能还在 ROM 或 SRAM 里运行,地址空间还没完全初始化,用相对跳转最安全。
我的经验:移植时如果发现上电后完全没反应,先检查 reset 向量是否被正确链接到 SoC 要求的起始地址。我曾经在一个 i.MX6 项目上,因为链接脚本里 .text 段偏移算错了一个字节,CPU 直接飞了——串口连个 'U' 都没吐出来。
3.2 汇编阶段:从异常模式到 C 环境
进入 reset 标签后,U-Boot 会做几件关键的事。我按顺序列出来:
- 设置 CPU 为 SVC32 模式——关中断、清 CPSR,确保后续操作不受异常干扰。
- 关闭 MMU 和 Cache——此时地址映射还没建立,开 MMU 等于找死。
- 设置栈指针——为调用 C 函数做准备。栈通常放在当前可用的 SRAM 顶部。
- 清零 BSS 段——全局变量和静态变量必须初始化为 0。
- 调用 board_init_f()——这是第一个 C 函数,负责早期板级初始化。
嗯,这里要注意:board_init_f() 是在 SPL(Secondary Program Loader)或 Tiny U-Boot 阶段执行的。如果你的 SoC 支持 SPL,那么 reset 向量其实属于 SPL,而不是完整的 U-Boot。
| 阶段 | 入口 | 主要任务 | 运行位置 |
|---|---|---|---|
| SPL | reset 向量 | 初始化时钟、DDR、加载完整 U-Boot | 内部 SRAM |
| 完整 U-Boot | _main(SPL 跳转过来) | 板级初始化、设备树解析、命令行 | DDR |
为什么要有 SPL?说白了就是「鸡生蛋蛋生鸡」的问题——DDR 初始化需要代码运行,但代码又需要 DDR 才能跑起来。SPL 体积小,放在内部 SRAM 里,先把 DDR 初始化好,再把完整 U-Boot 从 Flash 搬到 DDR 里执行。
3.3 board_init_f():板级早期初始化
这个函数在 common/board_f.c 里,是一个巨大的初始化函数数组。我挑几个关键步骤说说:
- init_sequence_f[] 数组里注册了所有早期初始化函数,按顺序执行。
- timer_init()——初始化系统定时器,为延时和超时提供基础。
- env_init()——环境变量区域的早期定位,此时还没完整解析。
- init_baud_rate()——从环境变量或默认值获取串口波特率。
- serial_init()——初始化串口控制器。这一步做完,你就能看到串口输出第一个字符了。
- dram_init()——检测并初始化 DDR 控制器,获取内存大小。
避坑指南:我曾经在移植时,serial_init() 返回了成功,但串口就是没输出。查了两天才发现是 GPIO 复用配置没做——UART 的 TX/RX 引脚被默认配成了 GPIO 功能。所以移植时一定要确认 SoC 的引脚复用寄存器。
board_init_f() 执行完后,U-Boot 会做一次 relocation(重定位),把自己从 Flash 或 SRAM 搬到 DDR 里。这是通过 relocate_code() 汇编函数完成的。搬完后,代码在 DDR 里跑,速度更快,空间也更大。
3.4 board_init_r():板级后期初始化
重定位完成后,调用 board_init_r()(在 common/board_r.c)。这个阶段的任务更丰富:
- board_init()——板级特定初始化,比如设置 LED、配置 I2C 总线、初始化以太网 PHY。
- stdio_init()——初始化标准输入输出设备(串口、LCD、USB 键盘等)。
- initr_env()——完整加载和解析环境变量,从存储设备(Flash、EMMC、NFS)读取。
- initr_net()——初始化网络子系统,为 TFTP 下载内核做准备。
- initr_mmc()——初始化 MMC/SD 卡控制器。
这个阶段做完,U-Boot 已经具备了加载内核的能力。但还没完——它需要进入命令行,等待你的指令。
3.5 main_loop():命令行的诞生
最后一步,调用 main_loop()(在 common/main.c)。这个函数的核心逻辑是:
- 检查环境变量
bootdelay,决定等待用户输入的时间。 - 如果在倒计时内按下任意键,进入命令行交互模式(shell)。
- 如果超时,执行
bootcmd环境变量里的命令(通常是加载并启动内核)。
main_loop() 的伪代码大致如下:
void main_loop(void)
{
char *s;
int bootdelay = getenv_ulong("bootdelay", 10);
s = getenv("bootcmd");
if (bootdelay >= 0 && s && !abortboot(bootdelay)) {
// 倒计时结束,自动执行 bootcmd
run_command_list(s, -1, 0);
}
// 进入命令行循环
for (;;) {
len = readline("U-Boot> ");
if (len > 0)
run_command(line, 0);
}
}
看到没?abortboot() 函数负责检测按键。如果用户在倒计时内按了任意键,返回 1,跳过自动启动,进入命令行。否则执行 bootcmd。
我的习惯:调试阶段我会把 bootdelay 设成 0,这样上电直接进命令行,省得每次按回车。量产时再改回 3 秒,给用户一个中断的机会。
3.6 完整链路图
下面这张 SVG 图把整个流程串起来了。你可以把它当作调试时的路线图:
3.7 常见问题与排查思路
移植过程中,启动流程的每个阶段都可能出问题。我总结了几种典型场景:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 上电后完全无输出 | reset 向量地址错误 / 时钟未初始化 / 串口引脚复用不对 | 检查链接脚本、SoC 启动模式、GPIO 配置 |
| 输出到某个字符后卡死 | DDR 初始化失败 / 重定位地址冲突 | 检查 DDR 时序参数、重定位基地址 |
| 能进命令行但无法加载内核 | 网络 / MMC 驱动未正确初始化 | 检查 board_init_r() 中的设备初始化顺序 |
| 倒计时不等待直接启动 | bootdelay 环境变量未设置或为 0 | 检查默认环境变量配置 |
我曾经踩过的坑:有一次移植到新板子,串口输出了 "U-Boot 2023.04" 后就卡死了。用 JTAG 单步跟踪发现,board_init_f() 里的 dram_init() 返回了 0。原因是 DDR 芯片的型号和代码里配置的参数不匹配——CAS 延迟差了 1 个 cycle。改完参数后,启动就正常了。