2. 嵌入式启动流程基础

芯片上电那一刻,PC指针到底指向哪里?

很多初学者以为程序从main函数开始跑。其实不是。在main之前,MCU已经完成了一大堆初始化工作。我刚开始做嵌入式那会儿,也天真地以为startup.s就是个摆设,直到一次调试死活进不了main,才老老实实把启动文件啃了一遍。

核心认知:MCU启动流程 = 硬件复位 → 引导程序(Boot) → 启动文件(startup.s) → C运行时初始化 → main函数

2.1 复位后的第一件事:向量表

ARM Cortex-M系列复位后,硬件自动做三件事:

  1. 从地址 0x00000000 读取栈顶指针(MSP初始值)
  2. 从地址 0x00000004 读取复位向量(Reset_Handler入口地址)
  3. 跳转到Reset_Handler执行

说白了,这就是一张硬编码的跳转表。芯片出厂就规定好了,谁也别想改。

偏移地址 内容 说明
0x00000000 栈顶地址 初始MSP值,通常指向SRAM末尾
0x00000004 Reset_Handler 复位后第一条指令入口
0x00000008 NMI_Handler 不可屏蔽中断
0x0000000C HardFault_Handler 硬错误处理
... ... 其他异常/中断向量

我曾经踩过的坑:有一次把向量表放到了0x08000000(Flash起始),但忘了配置VTOR寄存器。结果芯片复位后还是从0x00000000取向量,直接跑飞。后来才意识到,Cortex-M3/M4默认从0x00000000取向量,除非你显式重映射。

2.2 启动文件(startup.s)到底干了什么

启动文件是汇编写的,每个MCU厂商都会提供一份。我习惯把它拆成四个阶段来看:

阶段一:定义向量表

用汇编的 .word 伪指令,把各个中断服务函数的地址按顺序排列好。注意,第一个必须是栈顶地址,第二个必须是Reset_Handler。

; startup_stm32f103xe.s 片段
__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI
                DCD     HardFault_Handler     ; 硬错误
                DCD     MemManage_Handler     ; MPU错误
                DCD     BusFault_Handler      ; 总线错误
                DCD     UsageFault_Handler    ; 用法错误
                ; ... 后面还有几十个中断向量

阶段二:堆栈初始化

这里有个细节——堆和栈是两回事。栈用于函数调用、局部变量;堆用于动态内存分配(malloc)。启动文件里会定义两个符号:

; 堆栈大小定义
Stack_Size      EQU     0x00000400           ; 1KB栈
                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

Heap_Size       EQU     0x00000200           ; 512B堆
                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

我个人习惯把栈设大一点,至少2KB起步。堆嘛,如果不用malloc,直接设0就行。省得浪费RAM。

阶段三:数据段和BSS段处理

这是C运行时初始化的核心。启动文件要干三件事:

  • 拷贝.data段: 把Flash里初始化的全局变量搬到SRAM
  • 清零.bss段: 把未初始化的全局变量区域全部填0
  • 初始化堆: 如果用了malloc,需要调用 _init_alloc 或类似函数
; 典型的.data段拷贝循环
    LDR     R0, =_sidata      ; Flash中.data的起始地址
    LDR     R1, =_sdata       ; SRAM中.data的起始地址
    LDR     R2, =_edata       ; SRAM中.data的结束地址
    CMP     R1, R2
    BEQ     LoopCopyDataInit
CopyDataInit:
    LDR     R3, [R0], #4
    STR     R3, [R1], #4
    CMP     R1, R2
    BCC     CopyDataInit
LoopCopyDataInit:

; BSS段清零
    LDR     R1, =__bss_start__
    LDR     R2, =__bss_end__
    MOVS    R3, #0
    CMP     R1, R2
    BEQ     LoopFillZerobss
FillZerobss:
    STR     R3, [R1], #4
    CMP     R1, R2
    BCC     FillZerobss
LoopFillZerobss:

避坑指南:链接脚本(.ld或.sct)里定义的符号名,必须和启动文件里引用的名字完全一致。我见过有人把 _sidata 写成了 _sidate,结果.data段根本没拷贝,全局变量全是乱的。这种bug查起来特别隐蔽。

阶段四:跳转到main

一切准备就绪,最后调用 _start 或直接跳转到main:

    BL      SystemInit        ; 初始化系统时钟、PLL等
    BL      _start            ; C运行时初始化(可选)
    BL      main              ; 终于到用户程序了
    B       .                 ; main返回后死循环

嗯,这里要注意:SystemInit 是CMSIS标准要求的,用来配置系统时钟。有些芯片还会在之前调用 FPU_Init 开启浮点单元。

2.3 完整的启动链路图

我把整个流程画成了一张图,方便你对照理解:

MCU启动完整链路图 MCU启动完整链路图 上电复位 读取向量表(0x00000000) 跳转 Reset_Handler 堆栈初始化 拷贝.data / 清零.bss 跳转 main() 关键说明 • 硬件自动完成前两步 • 启动文件完成3~5步 • 第5步依赖链接脚本符号 常见问题 • 栈溢出 → HardFault • 向量表偏移未配置 → 跑飞 • BSS未清零 → 全局变量随机值

2.4 堆栈初始化的细节

堆栈初始化看似简单,其实有几个容易翻车的地方:

  • 栈生长方向: ARM Cortex-M栈是满递减(Full Descending),即SP从高地址向低地址生长。压栈时SP先减4,再存数据。
  • 栈对齐: 有些外设(如DMA)要求栈8字节对齐。启动文件里 ALIGN=3 就是做8字节对齐。
  • 堆栈溢出检测: 我习惯在栈底放一个魔术字(如0xDEADBEEF),运行时定期检查。如果被覆盖了,说明栈溢出了。

实用技巧:调试阶段可以把栈区全部初始化为0xCC(ARM的断点指令),这样一旦程序跑飞踩到栈,会立刻触发HardFault,方便定位。

2.5 从启动到main的时间线

我用一个表格总结一下各阶段的时间开销(以STM32F103 @72MHz为例):

阶段 执行内容 典型耗时 备注
硬件复位 取向量、跳转 ~1μs 硬件自动完成
Reset_Handler 堆栈初始化 ~0.5μs 汇编指令,极快
拷贝.data 搬运初始化数据 ~10μs/KB 取决于.data段大小
清零.bss 清零未初始化变量 ~5μs/KB 比拷贝快一倍
SystemInit 配置时钟、PLL ~50μs 等待PLL锁定
跳转main 进入用户程序 ~0.1μs 最后一步

你想想看,从复位到main,总共也就几十微秒。但就是这几十微秒,决定了你的程序能不能跑起来。

个人经验:做Bootloader时,我习惯在Reset_Handler开头加一段汇编,检查是否有升级标志。如果有,直接跳转到Flash的APP区域,跳过C运行时初始化。这样可以实现「冷启动快速跳转」,省掉不必要的初始化时间。

2.6 常见启动失败场景

最后列几个我实际遇到过的启动失败案例:

  • 栈顶指针指向无效地址: 链接脚本里RAM地址配错了,__initial_sp指向了未初始化或只读区域。复位后第一条指令就HardFault。
  • Reset_Handler地址bit0为0: ARM Cortex-M要求向量地址的bit0必须是1(表示Thumb模式)。如果链接器没对齐,直接跑飞。
  • 中断向量表被意外修改: 有一次我在调试时不小心写穿了数组,把向量表覆盖了。结果随便按个按键就死机。

嗯,启动流程这块,说白了就是「硬件规定动作 + 软件初始化」的组合。只要把向量表、堆栈、数据段这三个关键点搞明白,后面移植Bootloader就顺了。