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 就是按这个流程来分区的。
每个模块都有明确的职责。比如复位向量与异常处理,它负责捕获 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 的启动流程,看看这些模块是怎么串起来跑的。