4、U-Boot启动流程分析(下):C语言入口与板级初始化

上一讲我们聊完了汇编阶段的“热身运动”,这一讲终于要进入C语言的世界了。说实话,U-Boot的C语言入口才是真正考验移植功底的地方。我当年第一次看这段代码时,也被那密密麻麻的函数调用搞得晕头转向。别急,我们一步步拆开来看。

4.1 从汇编到C:入口函数board_init_f

汇编阶段最后调用了 board_init_f,这个函数定义在 common/board_f.c 中。它干了什么?说白了就是“初始化那些在重定位之前必须搞定的事情”。

核心要点:board_init_f 运行在Flash或ROM中,此时RAM可能还没完全可用,所以不能做复杂的内存操作。

我习惯把 board_init_f 的工作分成三块:

  • 基础硬件初始化:串口、时钟、定时器——这些是调试的“眼睛”和“心跳”
  • DRAM初始化:板级内存控制器配置,这一步出问题后面全白搭
  • 内存布局规划:计算U-Boot自身要重定位到DRAM的哪个位置

来看一个典型的初始化序列(伪代码):

// common/board_f.c 中的 init_sequence_f 数组
static init_fnc_t init_sequence_f[] = {
    setup_mon_len,        // 计算U-Boot镜像长度
    initf_malloc,         // 初始化malloc池
    initf_console_record, // 控制台记录
    arch_cpu_init,        // CPU架构相关初始化
    initf_dm,             // 驱动模型初始化
    board_early_init_f,   // 板级早期初始化
    timer_init,           // 定时器初始化
    env_init,             // 环境变量初始化
    init_baud_rate,       // 波特率设置
    serial_init,          // 串口初始化
    console_init_f,       // 控制台初始化
    dram_init,            // DRAM大小检测
    setup_dest_addr,      // 计算重定位目标地址
    reserve_xxx,          // 预留各种内存区域
    ...
    reloc_fdt_addr,       // 设备树重定位地址
    setup_reloc,          // 设置重定位参数
    NULL
};

嗯,这里要注意:dram_init 只是获取DRAM大小,真正的内存控制器初始化通常在 board_early_init_f 或更早的汇编阶段就完成了。我在项目中遇到过一块板子,DRAM大小检测总是返回0,查了两天才发现是 dram_init 里读的寄存器地址写错了——这种低级错误最坑人。

4.2 内存布局的艺术:board_init_f 的“预留”逻辑

为什么要在 board_init_f 里做内存预留?因为U-Boot重定位后,需要知道自己该往哪儿搬。搬之前得先把位置算好,就像搬家前得先量好新家的尺寸。

内存布局从高地址向低地址生长,预留顺序如下:

预留项 说明 对齐要求
U-Boot镜像 重定位后的代码段+数据段 4KB对齐
malloc池 动态内存分配区域 16字节对齐
设备树( FDT ) 扁平设备树二进制块 4KB对齐
栈空间 重定位后的栈 16字节对齐
Global Data 全局数据结构gd_t 8字节对齐

我的经验:malloc池大小别设太小,我见过有人设成1KB,结果加载设备树时直接崩了。建议至少留4KB以上,如果用到文件系统,16KB起步。

4.3 重定位:从Flash到RAM的“乾坤大挪移”

board_init_f 执行完毕后,U-Boot会调用 relocate_code 把自己从Flash搬到DRAM中。这一步为什么必要?因为Flash访问速度慢,而且有些功能(比如USB、网络)需要可写的数据段。

重定位流程大致如下:

  1. 从 gd->relocaddr 获取目标地址
  2. 拷贝U-Boot镜像到目标地址
  3. 修复代码中的绝对地址引用(relocation table)
  4. 更新栈指针和全局数据指针
  5. 跳转到RAM中的 board_init_r

这里有个关键点:重定位后的地址修复。U-Boot在编译时生成了一张“重定位表”,记录了所有需要修正的地址。搬完代码后,遍历这张表,把每个地址加上偏移量。我曾经手写过一个板子的重定位代码,忘了处理重定位表,结果跳转后直接跑飞——那感觉,就像你搬了新家但门牌号没改,快递全送错地方了。

4.4 board_init_r:真正的“主战场”

重定位完成后,控制权交给 board_init_r(定义在 common/board_r.c)。此时U-Boot已经在RAM中运行,可以放开手脚做各种初始化了。

board_init_r 的初始化序列比 board_init_f 长得多,我挑几个重点说:

  • 驱动模型初始化:枚举所有设备,绑定驱动
  • 存储设备初始化:MMC、NAND、SPI Flash等
  • 文件系统支持:FAT、ext4、UBIFS等
  • 网络协议栈:如果使能了网络功能
  • 命令行解析器:准备接收用户输入

避坑指南:我曾经在 board_init_r 里加了一个调试打印,结果打印内容被缓冲了没立即输出,导致我误以为系统卡住了。后来发现是控制台缓冲没刷新——加个 serial_flush() 就好了。

4.5 设备树传递:从“硬编码”到“软配置”

设备树(Device Tree)是现代U-Boot的核心配置机制。它让板级描述从代码中分离出来,变成一份独立的数据文件。说白了,以前改个GPIO引脚得改C代码重新编译,现在改dts文件就行。

U-Boot中设备树的传递路径:

  1. SPL(如果使用)加载U-Boot时,同时加载设备树到内存
  2. board_init_f 中通过 reloc_fdt_addr 预留设备树空间
  3. 重定位时,设备树也被搬到RAM中
  4. board_init_r 通过 gd->fdt_blob 访问设备树
  5. 最终启动内核时,把设备树地址传给内核

设备树在内存中的布局:

// 设备树头部结构(简化)
struct fdt_header {
    uint32_t magic;           // 魔数 0xD00DFEED
    uint32_t totalsize;       // 总大小
    uint32_t off_dt_struct;   // 结构块偏移
    uint32_t off_dt_strings;  // 字符串块偏移
    uint32_t off_mem_rsvmap;  // 保留内存映射
    uint32_t version;         // 版本号
    uint32_t size_dt_struct;  // 结构块大小
    uint32_t size_dt_strings; // 字符串块大小
};

实用技巧:在U-Boot命令行下可以用 fdt addr 查看设备树地址,用 fdt list 遍历节点。调试设备树时,这两个命令比看打印日志快多了。

4.6 启动流程全景图

下面这张图把整个第二阶段的流程串起来了,建议你对照代码一起看:

U-Boot第二阶段启动流程 U-Boot 第二阶段启动流程 board_init_f 基础硬件初始化 DRAM初始化 内存布局规划 重定位 relocate_code board_init_r 驱动模型初始化 存储设备初始化 网络协议栈 命令行解析器 启动内核 (bootm) 设备树通过 gd->fdt_blob 在 board_init_f → board_init_r → bootm 之间传递

4.7 几个容易踩的坑

最后分享几个我在实际移植中遇到的典型问题:

  • 栈溢出:board_init_f 阶段栈空间有限,别在里面定义大数组。我见过有人放了个4KB的局部变量,直接盖掉了全局数据区。
  • 设备树地址冲突:设备树加载地址和U-Boot重定位地址重叠,导致设备树被覆盖。检查 CONFIG_SYS_FDT_LOAD_ADDR 和 CONFIG_SYS_TEXT_BASE 是否冲突。
  • 重定位表缺失:某些编译器优化会去掉重定位表,导致搬完代码后地址全错。在链接脚本里检查 .rel.dyn 段是否存在。
  • 串口打印延迟:重定位后串口寄存器地址变了,如果没更新基地址,打印会乱码或没输出。

血的教训:我曾经在 board_init_f 里调用了 printf,但此时控制台还没完全初始化,结果打印内容被缓冲了。重定位后缓冲清空,打印才出来——但那时系统已经跑到 board_init_r 了,调试信息完全对不上时间线。所以早期调试建议直接用 serial_putc 或 putc,别依赖 printf。

好了,U-Boot的第二阶段启动流程就分析到这里。从 board_init_f 的“轻装上阵”到 board_init_r 的“全面展开”,再到设备树的灵活传递,每一步都有它的设计考量。理解了这些,你再看任何板子的U-Boot代码,都不会觉得陌生了。