4. Bootloader的启动流程:从复位向量到main函数的执行路径

这一章,我们来聊聊Bootloader最核心的启动流程。说白了,就是芯片上电后,从哪条指令开始跑,怎么一步步走到我们的main函数。

很多刚入行的朋友觉得这步很简单——不就是跳转到main吗?其实不然。我见过不少项目,因为启动流程没理清楚,板子死活跑不起来。最后查出来,要么是堆栈指针没设对,要么是中断向量表地址配错了。

4.1 复位向量:芯片的第一口呼吸

芯片上电或复位后,CPU会去一个固定地址取第一条指令。这个地址,就叫复位向量。

对于ARM Cortex-M系列,复位向量在0x00000000。对于ARM7/9,通常在0x00000000或0xFFFF0000。不同的架构,规矩不一样。

关键点:复位向量里存的不是指令,而是地址。

  • Cortex-M:0x00000000存的是栈顶地址,0x00000004存的是复位中断服务函数地址。
  • ARM7/9:0x00000000直接放一条跳转指令,比如B Reset_Handler。

我个人习惯在链接脚本里,把复位向量段放在最前面。这样生成的bin文件,烧进去就能从正确位置启动。

/* 链接脚本示例:Cortex-M的向量表放置 */
SECTIONS
{
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))   /* 中断向量表 */
        . = ALIGN(4);
    } > FLASH

    .text :
    {
        /* 代码段 */
    } > FLASH
}

4.2 堆栈指针(SP)的初始化

堆栈指针,就是CPU用来记录栈顶位置的寄存器。没有它,函数调用、局部变量、中断嵌套全得崩。

在Cortex-M上,SP的初始化是硬件自动完成的。复位后,CPU直接从0x00000000读取值,写入SP寄存器。所以,你必须在那个地址放一个合法的栈顶地址。

我的经验:栈顶地址怎么算?

假设你的RAM从0x20000000开始,大小64KB。栈顶就是0x20000000 + 0x10000 = 0x20010000。注意,栈是向下生长的,所以栈顶在最高地址。

对于ARM7/9这类架构,SP需要你在复位处理函数里手动初始化:

Reset_Handler:
    LDR SP, =_stack_top    /* 加载栈顶地址 */
    BL main                /* 跳转到main */

我曾经在一个项目里,因为栈顶地址算错了8字节,导致程序跑一会儿就莫名其妙地死机。查了两天才发现,是栈溢出把关键数据踩了。嗯,从那以后,我每次都会在链接脚本里显式定义栈顶符号,并在启动代码里做栈边界检查。

4.3 程序计数器(PC)的初始化

PC就是程序计数器,指向CPU当前正在执行的指令地址。复位后,PC会被设置为复位向量的值。

在Cortex-M上,CPU从0x00000004读取复位处理函数的地址,然后写入PC。注意,这个地址的最低位必须是1,表示Thumb模式。如果写成0,CPU会进入错误状态。

避坑指南:我曾经见过一个同事,把函数地址直接赋给PC,忘了置位最低位。结果CPU一启动就进HardFault。折腾了一下午才发现是这个问题。

对于ARM7/9,复位向量里放一条跳转指令,CPU执行这条指令后,PC就跳转到目标地址了。

; ARM7/9 复位向量示例
AREA RESET, CODE, READONLY
ENTRY
    B Reset_Handler       ; 复位向量
    B Undefined_Handler   ; 未定义指令
    B SWI_Handler         ; 软中断
    B Prefetch_Handler    ; 预取中止
    B Data_Handler        ; 数据中止
    NOP                   ; 保留
    B IRQ_Handler         ; 中断
    B FIQ_Handler         ; 快速中断

4.4 从复位到main的完整路径

好了,我们把整个流程串起来。你想想看,芯片上电后到底发生了什么?

  1. 硬件复位:CPU内部寄存器复位到默认值。
  2. 读取复位向量:CPU去固定地址取栈顶地址和复位函数地址。
  3. 初始化SP:栈顶地址写入SP寄存器。
  4. 初始化PC:复位函数地址写入PC,开始执行。
  5. 执行复位处理函数:做必要的硬件初始化,比如时钟、内存、中断控制器。
  6. 跳转到main:最后调用main函数,进入主程序。

下面这张图,把整个流程画清楚了。

Bootloader启动流程 Bootloader启动流程:从复位到main 芯片上电 / 复位 读取复位向量(0x00000000) 初始化堆栈指针(SP) 初始化程序计数器(PC) 执行 Reset_Handler 跳转到 main() 说明: Cortex-M:硬件自动完成SP/PC初始化 ARM7/9:需在复位处理函数中手动设置 PC最低位必须为1(Thumb模式)

4.5 复位处理函数里该做什么

复位处理函数,是芯片启动后执行的第一段软件代码。它负责把硬件带到可运行状态。

我个人习惯,在Reset_Handler里至少做这几件事:

步骤 操作 说明
1 初始化系统时钟 配置PLL、分频器,让CPU跑在目标频率
2 初始化内存控制器 如果使用SDRAM或外部Flash,需要先配好时序
3 复制数据段 将初始化数据从Flash复制到RAM
4 清零BSS段 将未初始化全局变量区域清零
5 设置中断向量表 将向量表重定位到RAM(如果需要)
6 调用main 跳转到主程序

注意:在复制数据段和清零BSS段之前,绝对不能访问全局变量。因为此时它们还在Flash里,或者内容是随机的。我见过有人在这之前就调用了printf,结果打印出一堆乱码。

4.6 一个完整的启动代码示例

下面是一个Cortex-M的启动代码片段。我把它简化了,但核心逻辑都在。

; startup.s - Cortex-M启动代码
    AREA    RESET, CODE, READONLY
    EXPORT  __Vectors
    EXPORT  Reset_Handler

; 中断向量表
__Vectors
    DCD     _stack_top          ; 栈顶地址
    DCD     Reset_Handler       ; 复位处理函数
    DCD     NMI_Handler         ; NMI
    DCD     HardFault_Handler   ; HardFault
    ; ... 其他中断向量

; 复位处理函数
Reset_Handler
    ; 1. 初始化系统时钟
    BL      SystemInit

    ; 2. 复制数据段
    LDR     R0, =_sdata
    LDR     R1, =_edata
    LDR     R2, =_la_data
    BL      copy_data

    ; 3. 清零BSS段
    LDR     R0, =_sbss
    LDR     R1, =_ebss
    BL      zero_bss

    ; 4. 设置中断向量表(如果需要)
    ; ...

    ; 5. 跳转到main
    BL      main

    ; 死循环(main不应该返回)
    B       .

; 数据复制函数
copy_data
    CMP     R0, R1
    IT      LT
    LDR     R3, [R2], #4
    STR     R3, [R0], #4
    BLT     copy_data
    BX      LR

; BSS清零函数
zero_bss
    MOV     R3, #0
    CMP     R0, R1
    IT      LT
    STR     R3, [R0], #4
    BLT     zero_bss
    BX      LR

    END

这段代码里,_stack_top、_sdata、_edata这些符号,都是在链接脚本里定义的。它们告诉启动代码,数据段从哪里开始、到哪里结束。

我的建议:链接脚本里的符号命名要规范。我习惯用_sdata表示数据段起始,_ebss表示BSS段结束。这样启动代码和链接脚本一一对应,不容易出错。

4.7 常见问题与避坑

做Bootloader启动这块,有几个坑我踩过,分享给你:

  • 栈顶地址不对齐:Cortex-M要求栈顶地址8字节对齐。不对齐的话,某些指令会异常。
  • 中断向量表放在RAM里忘了重定位:如果你在运行时修改了中断向量表,记得把VTOR寄存器指向新地址。
  • 复位处理函数里用了全局变量:在BSS清零之前,全局变量是随机值。用了就出问题。
  • 忘了使能FPU:如果芯片带浮点单元,复位后默认是禁用的。需要手动使能,否则浮点运算会进HardFault。