第二章 MCU启动流程详解:从复位向量到main函数

芯片上电那一刻,到底发生了什么?

很多人写嵌入式代码好几年,却从没认真想过这个问题。我刚开始做Bootloader时也这样——反正编译器帮我搞定了,我只要写main函数就行。直到有一次,我在一个国产MCU上做OTA升级,发现程序死活跑不起来。查了三天,最后发现是数据段拷贝的地址配错了。

从那以后,我养成了一个习惯:必须把启动流程吃透。今天咱们就把这条链路彻底走一遍。

2.1 复位向量——芯片的第一口气

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

架构 复位向量地址 说明
Cortex-M3/M4 0x00000000 栈顶指针
Cortex-M3/M4 0x00000004 复位中断向量
ARM7/9 0x00000000 直接跳转指令
RISC-V 0x00000000 / 0x80000000 可配置

以Cortex-M系列为例,芯片上电后,硬件自动做两件事:

  1. 从0x00000000读取栈顶指针,写入SP寄存器
  2. 从0x00000004读取复位向量地址,写入PC寄存器

说白了,就是先搭好栈,再跳过去执行。这个设计很巧妙——你想想看,如果连栈都没有,函数怎么调用?

关键点:复位向量里存的不是代码,而是地址。这个地址指向的是Reset_Handler函数。

2.2 Reset_Handler——真正的启动总管

CPU跳进Reset_Handler后,才开始执行我们熟悉的启动代码。这段代码通常由芯片厂商提供,放在启动文件(startup_xxx.s)里。

它的核心任务有三个:

  • 初始化堆栈——给C语言运行时搭好舞台
  • 清零BSS段——把未初始化的全局变量置0
  • 拷贝数据段——把初始化的全局变量从Flash搬到RAM

我见过不少工程师,在BSS段清零上栽过跟头。有一次,一个同事在Bootloader里加了个大数组,没注意BSS段大小,结果清零时把栈区给覆盖了。程序一跑就死,查了两天才找到原因。

避坑指南:我曾经在STM32F103上遇到过一个问题——BSS段和栈区紧挨着,如果BSS段大小算错了,清零操作会直接破坏栈空间。建议大家在链接脚本里给BSS段和栈区之间留点余量。

2.3 堆栈初始化——C语言的生存基础

堆栈初始化,说白了就是告诉CPU:你的栈顶在哪,堆从哪里开始。

在启动文件里,你会看到类似这样的代码:

; 栈配置
Stack_Size      EQU     0x00000400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

; 堆配置
Heap_Size       EQU     0x00000200

                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

这里有几个关键点:

  • __initial_sp——栈顶指针,复位向量里用的就是它
  • __heap_base——堆起始地址
  • __heap_limit——堆结束地址

我个人习惯把栈大小设为至少1KB,堆大小根据实际需求来。如果用到malloc,堆就得留够;如果只用静态分配,堆甚至可以设为0。

小技巧:在调试阶段,可以在栈区填充0xDEADBEEF之类的魔数。程序跑一段时间后检查栈空间,看看有没有被踩到。这个方法帮我抓过好几次栈溢出。

2.4 BSS段清零——别让全局变量带着垃圾值跑

C语言标准规定:未初始化的全局变量和静态变量,默认值为0。这个"默认值0"不是硬件给的,是启动代码清零的结果。

BSS段清零的典型实现:

; 清零BSS段
        LDR     R0, =__bss_start
        LDR     R1, =__bss_end
        MOVS    R2, #0
        CMP     R0, R1
        BEQ     .L_loop_bss_done
.L_loop_bss:
        STR     R2, [R0], #4
        CMP     R0, R1
        BCC     .L_loop_bss
.L_loop_bss_done:

这段代码的逻辑很简单:从__bss_start到__bss_end,每个字(4字节)写0。

但有个坑要注意——如果BSS段很大,清零会占用不少时间。我在一个项目里遇到过BSS段超过100KB的情况,清零花了将近10ms。对于实时性要求高的系统,这个时间不能忍。

避坑指南:我曾经在一个Bootloader项目里,把BSS段清零放在中断使能之后。结果清零过程中来了个中断,中断服务函数里访问了一个还没清零的全局变量……嗯,后果你懂的。建议:先清零,再开中断。

2.5 数据段拷贝——把初始值从Flash搬到RAM

初始化了的全局变量,比如int count = 100;,初始值存在Flash里。但运行时,这些变量必须在RAM里才能读写。所以启动代码要把初始值从Flash拷贝到RAM。

数据段拷贝的典型实现:

; 拷贝数据段
        LDR     R0, =__data_load
        LDR     R1, =__data_start
        LDR     R2, =__data_end
        CMP     R1, R2
        BEQ     .L_loop_data_done
.L_loop_data:
        LDR     R3, [R0], #4
        STR     R3, [R1], #4
        CMP     R1, R2
        BCC     .L_loop_data
.L_loop_data_done:

这里要注意三个符号:

  • __data_load——数据在Flash中的存储位置
  • __data_start——数据在RAM中的目标起始地址
  • __data_end——数据在RAM中的目标结束地址

你想想看,如果__data_load和__data_start指向同一个地址,那就不需要拷贝了。有些链接器支持这种优化,叫"ROM化数据段"。

关键点:数据段拷贝的源地址和目标地址,都是在链接脚本里定义的。链接脚本写错了,拷贝就会出问题。我建议大家在调试时,先打印这三个地址,确认一下。

2.6 完整的启动链路图

下面这张图,把整个启动流程串起来了:

MCU启动完整链路 上电复位 读取复位向量(0x00000000) 跳转到 Reset_Handler 初始化堆栈指针 BSS 段清零 数据段拷贝(Flash→RAM) 跳转到 main() 说明 • 硬件自动完成前两步 • 软件完成后续所有步骤 • 每一步出错都会导致启动失败 常见问题 • 栈溢出:SP设置错误 • BSS覆盖:地址计算错误 • 数据错乱:拷贝方向反了

2.7 链接脚本——启动流程的幕后导演

上面提到的所有符号(__bss_start、__data_load等),都是在链接脚本里定义的。没有链接脚本,启动代码就是无头苍蝇。

一个典型的链接脚本片段:

SECTIONS
{
    .isr_vector : {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    .text : {
        . = ALIGN(4);
        *(.text*)
        . = ALIGN(4);
    } > FLASH

    .data : {
        . = ALIGN(4);
        __data_start = .;
        *(.data*)
        __data_end = .;
    } > RAM AT > FLASH
    __data_load = LOADADDR(.data);

    .bss : {
        . = ALIGN(4);
        __bss_start = .;
        *(.bss*)
        __bss_end = .;
    } > RAM
}

注意.data段后面的AT > FLASH——它告诉链接器:这个段运行时在RAM,但初始数据存在Flash里。链接器会自动生成__data_load这个符号,指向Flash中的存储位置。

小技巧:我习惯在链接脚本里加上__stack_top和__heap_size这样的符号,方便启动代码直接引用。这样改栈大小时,只需要改链接脚本,不用动汇编代码。

2.8 常见启动失败场景与排查方法

现象 可能原因 排查方法
上电后无反应 复位向量地址错误 检查.map文件,确认复位向量位置
程序跑飞 栈指针未正确初始化 调试器查看SP寄存器值
全局变量值不对 数据段拷贝失败 比较Flash和RAM中对应地址的数据
全局变量非零 BSS段未清零 检查__bss_start和__bss_end地址
中断不响应 中断向量表未正确映射 确认VTOR寄存器设置

嗯,这张表基本覆盖了我这些年遇到的大部分启动问题。每次排查,我都会先看复位向量,再看栈指针,最后查数据段和BSS段。按这个顺序来,90%的问题都能定位到。

总结一下:MCU启动流程,说白了就是硬件搭台,软件唱戏。硬件负责把CPU引导到正确的位置,软件负责把C语言的运行环境搭好。每一步都环环相扣,任何一个环节出问题,main函数都跑不起来。

下一章,咱们会深入Bootloader的架构设计。但在此之前,我建议你把本章的启动流程在自己手头的板子上跑一遍,用调试器单步跟踪,亲眼看看每一步是怎么执行的。相信我,这个习惯会让你少踩很多坑。