3. 启动性能关键指标

BootROM 这玩意儿,说白了就是芯片上电后跑的第一段代码。它被固化在芯片内部的只读存储器里,掉电不丢失。很多刚入行的朋友问我:“BootROM 到底有多大?里面都放了些什么?” 嗯,今天我就带大家把它的“肚子”剖开看看。

我个人习惯把 BootROM 的代码结构分成三个维度去理解:存储与加载、代码分区与功能模块、关键数据结构。这三个维度搞清楚了,你基本就能看懂任何一款 SoC 的 BootROM 源码了。

3.1 BootROM 的存储与加载

BootROM 通常放在芯片内部的 Mask ROM 或 OTP ROM 里。容量非常有限,一般就 32KB 到 256KB。为什么这么小?因为 ROM 面积很贵,而且一旦流片就不能改了。

上电后,CPU 会从一个固定的复位向量地址开始执行。这个地址通常指向 BootROM 的起始位置。我记得在某个项目中,芯片复位后第一件事就是通过 AHB 总线去取 BootROM 里的第一条指令。如果总线初始化没做好,CPU 直接“飞”了。

核心要点:BootROM 的加载不是“从硬盘拷贝到内存”,而是 CPU 直接寻址执行。它天然就是 XIP(就地执行)的。

但这里有个坑:BootROM 的执行速度受限于 ROM 的访问延迟。很多 SoC 会在 BootROM 启动初期,把关键代码拷贝到 SRAM 或 Cache 里执行,这叫 Shadow Copy 或 Remap。我曾经遇到过一个性能问题,就是因为 BootROM 里的一段 CRC 校验代码直接在 ROM 里跑,每次读 ROM 都要等 5 个周期,导致整个启动时间多了 200ms。后来改成先拷贝到 SRAM 再执行,问题就解决了。

存储介质 特点 典型容量 执行方式
Mask ROM 掩膜编程,成本低,不可修改 32KB - 128KB XIP 直接执行
OTP ROM 一次性可编程,可定制 4KB - 32KB XIP 直接执行
嵌入式 Flash 可多次编程,但成本高 64KB - 512KB XIP 或拷贝后执行

小技巧:在 BootROM 设计阶段,建议把最频繁执行的代码(如时钟初始化、内存控制器训练)放在 ROM 的低地址区域。因为很多 CPU 对低地址的取指延迟更短。

3.2 代码分区与功能模块

BootROM 虽然小,但五脏俱全。我一般把它分成以下几个功能模块。你想想看,一个芯片上电后要干哪些事?无非就是:初始化硬件、检测启动介质、加载镜像、跳转执行。BootROM 就是按这个流程来分区的。

BootROM 代码分区与功能模块 复位向量与异常处理 硬件初始化模块 启动介质检测 镜像加载与校验 时钟与PLL配置 DDR内存训练 外设控制器初始化 SPI/NAND/NOR检测 USB/SD卡检测 说明:实际项目中,硬件初始化模块往往占 BootROM 代码量的 40% 以上。 启动介质检测模块则是最容易出 Bug 的地方,因为要兼容各种 Flash 和卡设备。 BootROM 主流程 加载 Bootloader 跳转执行 避坑指南:我曾经在一个项目中,BootROM 的 USB 检测模块占用了太多时间,导致整个启动流程超时。 后来加了超时机制和优先级排序,把最常用的 SPI Flash 检测放在最前面,才把启动时间压下来。

每个模块都有明确的职责。比如复位向量与异常处理,它负责捕获 CPU 上电后的第一个异常,然后跳转到主入口。这部分代码通常用汇编写的,非常精简。我建议你写这部分时,不要用 C 语言,因为 C 的运行时环境还没建立起来。

再看硬件初始化模块。这个模块要初始化时钟、PLL、DDR 控制器、Cache 等。为什么 DDR 训练这么重要?因为 DDR 的时序参数如果不配好,后面加载镜像时就会出数据错误。我记得有一次,DDR 的 ODT(片上端接)配置不对,导致 BootROM 加载的 Bootloader 镜像里有一半的数据都是错的,系统根本跑不起来。

警告:硬件初始化模块的执行顺序非常关键。比如,必须先初始化时钟,再初始化 DDR 控制器。如果顺序搞反了,DDR 控制器可能因为时钟没准备好而训练失败。

启动介质检测模块,说白了就是去轮询各种可能的启动设备。常见的顺序是:SPI NOR Flash → SPI NAND Flash → eMMC → SD 卡 → USB。为什么是这个顺序?因为 NOR Flash 启动最快,而且最稳定。我见过有些芯片支持“启动回退”机制:如果从 NOR Flash 加载失败,自动切换到 SD 卡。这个机制在量产烧录时特别有用。

3.3 关键数据结构

BootROM 里用的数据结构,跟应用层软件完全不一样。因为 ROM 空间有限,而且不能有动态内存分配。我常用的数据结构就那么几种:链表、数组、位图。

先说说启动设备描述符。每个支持的启动设备(如 SPI Flash、eMMC)都有一个对应的描述符结构体。里面记录了设备类型、基地址、访问函数指针、超时时间等。为什么用函数指针?因为 BootROM 要支持多种设备,但代码又不能太大。用函数指针可以实现多态,而且只占用一个指针的空间。

/* 启动设备描述符结构体 */
typedef struct boot_device {
    uint32_t        type;           /* 设备类型:SPI, NAND, eMMC, USB */
    uint32_t        base_addr;      /* 设备基地址或片选号 */
    uint32_t        timeout_ms;     /* 设备访问超时时间 */
    int32_t         (*init)(void);  /* 设备初始化函数指针 */
    int32_t         (*read)(uint32_t offset, uint8_t *buf, uint32_t size); /* 读函数指针 */
    int32_t         (*check)(void); /* 设备存在性检查函数指针 */
    struct boot_device *next;       /* 链表指针,用于构建设备链表 */
} boot_device_t;

我个人习惯把所有的启动设备描述符放在一个静态链表里。为什么不用数组?因为链表可以灵活地增删设备节点,而且支持优先级排序。比如,你可以把 SPI Flash 的节点放在链表头部,把 USB 的节点放在尾部。这样轮询时就会优先尝试 SPI Flash。

另一个重要的数据结构是镜像头。BootROM 加载的 Bootloader 或固件镜像,前面都会有一个头部。这个头部告诉 BootROM:镜像有多大、加载到哪个地址、校验和是多少、入口地址在哪。

/* 镜像头结构体 */
typedef struct image_header {
    uint32_t        magic;          /* 魔数,用于识别镜像合法性 */
    uint32_t        image_size;     /* 镜像总大小(含头部) */
    uint32_t        load_addr;      /* 加载到内存的目标地址 */
    uint32_t        entry_point;    /* 镜像入口地址 */
    uint32_t        checksum;       /* 校验和 */
    uint32_t        version;        /* 镜像版本号 */
    uint8_t         reserved[16];   /* 保留字段,用于扩展 */
} image_header_t;

经验之谈:魔数(magic)一定要选一个不容易被误判的值。我见过有人用 0xDEADBEEF,结果 Flash 里全 0xFF 时,偶尔会误判为合法镜像。建议用 0xAA55AA55 或 0x55AA55AA,这种交替位模式不容易被随机数据匹配上。

还有一个容易被忽略的数据结构是启动状态寄存器。BootROM 在执行过程中,会把每一步的执行结果记录在一个全局的状态寄存器里。比如:硬件初始化是否成功、DDR 训练是否通过、从哪个设备加载了镜像。这个寄存器在调试时特别有用。我曾经在排查一个启动失败的问题时,就是通过读取这个状态寄存器,发现是 DDR 训练步骤返回了错误码,才定位到问题的。

数据结构 用途 存储位置 典型大小
启动设备描述符链表 管理所有支持的启动设备 ROM 数据区或 SRAM 每个节点约 32 字节
镜像头 描述待加载镜像的属性 位于镜像文件起始处 32 - 64 字节
启动状态寄存器 记录 BootROM 执行状态 SoC 内部寄存器或 SRAM 4 - 16 字节
内存映射表 记录各外设的地址映射关系 ROM 常量区 视外设数量而定

最后提一下内存映射表。BootROM 需要知道各个外设的寄存器地址在哪里。比如 SPI 控制器的基地址是 0x10001000,DDR 控制器的基地址是 0x10002000。这些信息通常放在一个常量数组里,编译时直接固化到 ROM 中。为什么不用动态配置?因为 ROM 空间有限,而且这些地址在芯片设计阶段就定死了,不会变。

总结一下:BootROM 的代码结构其实不复杂。存储上就是 XIP 执行,分区上按启动流程划分模块,数据结构上多用静态链表和结构体。搞懂这三块,你就能看懂大部分 BootROM 源码了。嗯,下一节我们会深入讨论 BootROM 的启动流程,看看这些模块是怎么串起来跑的。