3. BootROM与Bootloader的区别
好,咱们今天聊点实在的。BootROM 的代码,说白了就是芯片上电后执行的第一段程序。它不像你平时写的应用层代码,有操作系统给你铺好路。BootROM 是在「裸奔」——没有任何依赖,所有东西都得自己来。
我刚开始接触这块时,也觉得不就是个启动代码嘛,能有多复杂?直到有一次调试一块新芯片,死活跑不进主函数,折腾了两天才发现是栈指针初始化少了一条指令。嗯,从那以后,我对这段代码就再也不敢马虎了。
3.1 入口函数:芯片的第一口气
芯片复位后,PC 指针会跳到一个固定地址。这个地址,就是 复位向量(Reset Vector)。对于 ARM Cortex-M 系列,通常是 0x00000000;对于 RISC-V,可能是 0x80000000 或 0x00000000,取决于具体设计。
入口函数长什么样?我直接给你看一段典型的 Cortex-M 启动代码片段:
; 文件: startup.s
; 目标: STM32F4 系列 BootROM 入口
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位处理函数
DCD NMI_Handler ; NMI 异常
DCD HardFault_Handler ; 硬错误
; ... 其他中断向量表项 ...
__Vectors_End
AREA |.text|, CODE, READONLY
; 复位处理函数 —— 芯片的第一行代码
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
; 1. 关闭所有中断
CPSID I
; 2. 初始化 .data 段(从 Flash 拷贝到 RAM)
LDR r0, =_sdata
LDR r1, =_edata
LDR r2, =_sidata
BL CopyDataInit
; 3. 清零 .bss 段
LDR r0, =_sbss
LDR r1, =_ebss
BL FillZerobss
; 4. 跳转到 C 语言 main 函数
BL __libc_init_array
BL main
B .
ENDP
你看,第一件事不是跑业务逻辑,而是把「家底」先收拾好。我习惯把这段代码叫做「芯片的呼吸机」——它让芯片从死寂状态活过来。
关键点: 入口函数必须放在链接脚本指定的起始地址。如果地址对不上,芯片上电后直接跑飞。我曾经帮一个团队排查问题,他们的 BootROM 死活不跑,最后发现是链接脚本里 ORIGIN 写错了 4 个字节。
3.2 汇编启动阶段:为什么非要用汇编?
你可能会问:为什么不用 C 语言直接写?原因很简单——C 语言运行环境还没建立。栈指针没设、全局变量没初始化、BSS 段还是垃圾值。这时候你调用任何一个 C 函数,都会崩溃。
汇编启动阶段的核心任务,我总结为「三板斧」:
- 设置栈指针(SP) —— 给 C 语言一个能站脚的地方
- 初始化 .data 段 —— 把 Flash 里的初始值搬到 RAM
- 清零 .bss 段 —— 把未初始化的全局变量置零
这三件事做完,C 语言才能安全运行。我画了一张流程图,帮你理清这个顺序:
你看,整个流程是线性的,没有分支。为什么?因为这时候连中断都还没开,任何异常都会导致系统卡死。所以必须「一条道走到黑」,把所有准备工作做完,才能把控制权交给 C 语言。
我的经验: 在调试汇编启动阶段时,我习惯在关键节点插入「GPIO 翻转」指令。比如设置栈指针后,让某个 GPIO 引脚输出高电平。用示波器一看,就知道程序跑到哪一步了。这比 JTAG 单步调试快得多,尤其是在芯片刚上电、调试器还没连上的时候。
3.3 C 语言运行环境初始化
汇编把「地基」打好了,接下来就该 C 语言登场了。但你以为直接跳进 main 就完事了?没那么简单。在 main 之前,还有一堆「家务活」要干。
这部分工作通常由 C 运行时库(CRT,C Runtime)完成。我整理了一张表,把 main 之前发生的事情列清楚:
| 阶段 | 执行内容 | 典型函数 |
|---|---|---|
| 1. 库初始化 | 初始化 C 标准库的内部状态,比如 errno、locale | __libc_init_array |
| 2. 全局构造 | 调用所有全局 C++ 对象的构造函数 | __do_global_ctors |
| 3. 初始化堆 | 设置堆的起始地址和大小,准备 malloc | _sbrk / __heap_init |
| 4. 调用 main | 终于轮到你的业务代码了 | main() |
你想想看,如果跳过这些初始化步骤,直接调用 malloc 会怎样?堆指针是野的,分配出来的地址可能落在代码段上,一写就崩。我见过不少新手在 BootROM 里用 printf 调试,结果发现 printf 本身依赖堆和文件描述符,而这些都还没初始化——死循环了。
3.4 链接脚本:看不见的指挥官
说到初始化,就绕不开链接脚本。它决定了代码段、数据段、BSS 段在内存里的布局。没有正确的链接脚本,汇编启动代码里的 _sdata、_edata 这些符号就是空谈。
我贴一段典型的链接脚本片段,你看它和启动代码是如何配合的:
/* 文件: bootrom.ld */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 32K
}
SECTIONS
{
.text : {
*(.vectors) /* 中断向量表 */
*(.text) /* 代码段 */
*(.rodata) /* 只读数据 */
_etext = .; /* 代码段结束地址 */
} > FLASH
.data : {
_sdata = .; /* .data 起始地址(RAM 中) */
*(.data)
_edata = .; /* .data 结束地址 */
} > RAM AT > FLASH /* 加载地址在 Flash,运行地址在 RAM */
.bss : {
_sbss = .; /* .bss 起始地址 */
*(.bss)
_ebss = .; /* .bss 结束地址 */
} > RAM
_sidata = LOADADDR(.data); /* .data 在 Flash 中的加载地址 */
}
你看,_sdata、_edata、_sbss、_ebss 这些符号,都是链接脚本定义的。汇编启动代码通过 LDR r0, =_sdata 拿到这些地址,然后执行拷贝和清零操作。
避坑指南: 我曾经遇到过一个 case,芯片在低温环境下启动失败。排查到最后,发现是 .data 段拷贝的循环次数算错了——链接脚本里 _edata 和 _sdata 的差值比实际数据大了 4 个字节,多拷贝了一个字,把后面的 .bss 段第一个变量给覆盖了。低温下 RAM 时序变差,这个被覆盖的变量恰好是某个外设的基地址指针,一访问就挂。所以,链接脚本里的段边界一定要反复核对,尤其是当你有多个输入段时。
3.5 总结:从复位到 main 的完整链路
好,我们把整个链路串起来捋一遍:
- 复位 → 硬件加载 PC 到复位向量地址
- 汇编阶段 → 关中断、设栈指针、拷贝 .data、清零 .bss
- CRT 初始化 → 库初始化、全局构造、堆初始化
- 进入 main → 你的代码开始执行
每一步都环环相扣。任何一个环节出错,芯片要么跑飞,要么 HardFault,要么行为诡异。我常说,调试 BootROM 就像在黑暗里走钢丝——没有 printf、没有操作系统、没有文件系统,你唯一的工具就是示波器、逻辑分析仪和 JTAG。
但反过来想,一旦你把这段代码吃透了,你对整个芯片的理解会上一个台阶。因为 BootROM 是芯片最底层的行为,它暴露了硬件最真实的一面。