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 起 外设中断向量 按芯片手册排列

设置向量表有两种方式:

  1. 默认方式:向量表固定在 Flash 起始地址(0x08000000)。这是最常见的做法,Bootloader 和应用程序共用同一张表。
  2. 重映射方式:通过 SCB->VTOR 寄存器把向量表搬到 RAM 或其他地址。我在做 OTA 升级时经常用这个——先把新固件的向量表下载到 RAM,然后修改 VTOR,最后跳转。
关键提醒: 如果你在 Bootloader 里使能了中断,跳转到应用程序前一定要关闭所有中断,并恢复外设到复位状态。否则应用程序一启动,可能触发一个 Bootloader 留下的中断,而向量表已经变了,直接 HardFault。

4.4 知识结构总览

下面这张图把本章三个核心模块的关系画清楚了。你可以把它当作调试启动失败时的“地图”:

Bootloader 入口三要素关系图 Bootloader 入口三要素 链接脚本 定义内存布局 启动汇编 初始化 C 环境 中断向量表 异常入口映射 MEMORY 区域定义 段布局与对齐 设置栈指针 拷贝 .data / 清零 .bss 跳转 main() 固定偏移排列 VTOR 重映射 💡 调试启动失败时的排查顺序: 1. 检查链接脚本中 Flash/RAM 地址是否匹配芯片手册 2. 检查启动汇编中数据拷贝和 BSS 清零的符号是否与链接脚本一致 3. 检查中断向量表第一个字(栈顶)和第二个字(复位向量)是否正确
调试小技巧: 如果你怀疑启动代码有问题,可以在 Reset_Handler 开头加一条 GPIO 翻转指令。上电后用示波器看这个 GPIO 有没有波形——有,说明芯片跑到了启动代码;没有,说明连向量表都没加载成功,问题出在硬件或烧录环节。