4. Bootloader 代码入口分析:链接脚本、启动汇编与中断向量表
这一章,我们直接切入 Bootloader 最底层的三块基石:链接脚本、启动汇编、中断向量表。说白了,芯片上电后第一脚踩在哪里、代码怎么布局、异常来了往哪跳,全由这三样决定。我见过太多工程师在应用层调得风生水起,一碰到启动失败就抓瞎——其实就是没搞懂入口处的这几百行代码。
核心认知: Bootloader 的入口不是 main(),而是复位向量指向的那条指令。从那里开始,到跳进 C 环境,每一步都写在链接脚本和启动文件里。
4.1 链接脚本(Linker Script)—— 代码的“施工图纸”
链接脚本告诉链接器:你的代码段放哪、数据段放哪、栈顶在哪。没有它,编译器生成的 .o 文件就是一盘散沙。
我个人习惯用 GNU LD 的链接脚本语法。下面是一个典型的 Cortex-M 系列 Bootloader 链接脚本片段:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 8K
}
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text :
{
. = ALIGN(4);
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > FLASH
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
}
这里有几个关键点,我逐一拆开讲:
- ENTRY(Reset_Handler):指定入口点。芯片复位后,PC 指针会跳到这里。
- MEMORY 区域:定义物理存储。Flash 从 0x08000000 开始,RAM 从 0x20000000 开始。注意 Bootloader 的 Flash 大小通常只有 32KB 或更小。
- .isr_vector 段:中断向量表必须放在 Flash 起始位置,且用
KEEP()防止链接器优化掉。 - .data 段的加载与运行地址:
> RAM AT> FLASH表示运行时在 RAM,但初始值存储在 Flash。启动代码需要把这段数据从 Flash 拷贝到 RAM。
我的经验: 有一次我调试一个 Bootloader,发现全局变量全是乱的。查了半天,原来是链接脚本里
_sdata 和 _edata 的符号没导出,启动汇编里拷贝循环根本不知道从哪里拷到哪里。记住:链接脚本里的符号名要和启动汇编里的一致。
4.2 启动汇编代码(Startup.S)逐行分析
启动汇编是芯片上电后执行的第一段代码。它干三件事:设置栈指针 → 拷贝数据段 → 跳转 main()。下面我逐行拆解一个典型的 Cortex-M 启动文件:
; 中断向量表起始
.section .isr_vector, "a"
.align 2
.globl __isr_vector
__isr_vector:
.word _estack ; 栈顶地址
.word Reset_Handler ; 复位向量
.word NMI_Handler ; NMI 异常
.word HardFault_Handler ; 硬错误
; ... 其他中断向量省略
; 复位处理函数
.section .text.Reset_Handler
.weak Reset_Handler
.type Reset_Handler, %function
Reset_Handler:
; 1. 设置栈指针(其实硬件已经做了,但再设一次更安全)
ldr sp, =_estack
; 2. 拷贝 .data 段从 Flash 到 RAM
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
movs r3, #0
b LoopCopyDataInit
CopyDataInit:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3
cmp r4, r1
bcc CopyDataInit
; 3. 清零 .bss 段
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
b LoopFillZerobss
FillZerobss:
str r2, [r0]
adds r0, r0, #4
LoopFillZerobss:
cmp r0, r1
bcc FillZerobss
; 4. 跳转到 C 语言的 main()
bl main
b .
逐行解读:
- 第 1-8 行:定义中断向量表。
.word _estack是栈顶地址,硬件复位后会自动加载到 SP 寄存器。.word Reset_Handler是复位后 PC 跳转的目标。 - 第 12 行:
ldr sp, =_estack。虽然 Cortex-M 硬件会自动从向量表加载 SP,但我习惯再显式设置一次——防止某些调试器或 bootrom 没做这个动作。 - 第 15-25 行:数据段拷贝。从
_sidata(Flash 中的初始值)拷贝到_sdata(RAM 中的运行地址),直到_edata。这里用了一个小循环,每次拷贝 4 字节。 - 第 28-36 行:BSS 段清零。把
_sbss到_ebss之间的 RAM 全部写 0。 - 第 39 行:
bl main。跳转到 C 环境。注意这里用的是bl,如果 main() 返回了,会执行b .死循环。
我曾经踩过的坑: 有一次我写了一个 Bootloader,在跳转到 main() 之前忘了初始化系统时钟。结果 main() 里配置外设时,外设时钟根本没开,所有寄存器写不进去。调试了整整两天才发现问题。所以,启动汇编里最好加上系统时钟初始化的调用,或者确保 main() 第一件事就是配时钟。
4.3 中断向量表设置
中断向量表是芯片的“紧急联络簿”。每个中断源对应一个入口地址,硬件发生异常或中断时,自动从这张表里取出地址跳转。
Cortex-M 的向量表格式非常固定:
| 偏移地址 | 内容 | 说明 |
|---|---|---|
| 0x0000_0000 | 栈顶地址(_estack) | 复位后 SP 自动加载此值 |
| 0x0000_0004 | 复位向量(Reset_Handler) | 复位后 PC 跳转至此 |
| 0x0000_0008 | NMI 异常入口 | 不可屏蔽中断 |
| 0x0000_000C | HardFault 入口 | 硬错误处理 |
| 0x0000_0010 | MemManage 入口 | 内存管理异常(可选) |
| 0x0000_0014 | BusFault 入口 | 总线错误 |
| 0x0000_0018 | UsageFault 入口 | 用法错误 |
| 0x0000_0040 | SVCall 入口 | 系统服务调用 |
| 0x0000_0044 | PendSV 入口 | 可挂起系统服务 |
| 0x0000_0048 | SysTick 入口 | 系统滴答定时器 |
| 0x0000_004C 起 | 外设中断向量 | 按芯片手册排列 |
设置向量表有两种方式:
- 默认方式:向量表固定在 Flash 起始地址(0x08000000)。这是最常见的做法,Bootloader 和应用程序共用同一张表。
- 重映射方式:通过 SCB->VTOR 寄存器把向量表搬到 RAM 或其他地址。我在做 OTA 升级时经常用这个——先把新固件的向量表下载到 RAM,然后修改 VTOR,最后跳转。
关键提醒: 如果你在 Bootloader 里使能了中断,跳转到应用程序前一定要关闭所有中断,并恢复外设到复位状态。否则应用程序一启动,可能触发一个 Bootloader 留下的中断,而向量表已经变了,直接 HardFault。
4.4 知识结构总览
下面这张图把本章三个核心模块的关系画清楚了。你可以把它当作调试启动失败时的“地图”:
调试小技巧: 如果你怀疑启动代码有问题,可以在 Reset_Handler 开头加一条 GPIO 翻转指令。上电后用示波器看这个 GPIO 有没有波形——有,说明芯片跑到了启动代码;没有,说明连向量表都没加载成功,问题出在硬件或烧录环节。