2. MCU 启动流程与链接脚本:从复位到 main() 的必经之路

芯片上电那一刻,PC 指针指向哪里?为什么你的代码能跑起来?

很多人觉得启动代码是编译器自动生成的,不用管。我以前也这么想,直到有一次项目里芯片死活不进中断,查了三天才发现是向量表偏移配错了。嗯,从那以后,我每次都会亲手过一遍启动流程。

这一章,我们就来拆解 MCU 从复位到 main() 的完整路径。说白了,就三件事:启动代码、向量表、链接脚本。搞懂它们,你才算真正掌握了嵌入式开发的底层控制权。

2.1 复位后的第一行代码:启动代码 (Startup)

MCU 复位后,硬件自动从 0x00000000(或映射地址)加载栈顶指针 SP,从 0x00000004 加载复位向量,然后跳转执行。这个入口就是启动代码。

启动代码通常用汇编写,因为这时候 C 环境还没准备好。它的核心任务就四个:

  1. 设置栈指针:为 C 函数调用提供栈空间。
  2. 初始化 .bss 段:把未初始化的全局变量清零。
  3. 初始化 .data 段:把 Flash 中的初始值拷贝到 RAM。
  4. 调用 main():进入 C 世界。

核心原则:启动代码必须在任何 C 代码执行前完成内存初始化。否则全局变量值不确定,程序行为就是随机的。

下面是一个典型的 ARM Cortex-M 启动代码片段(简化版):

    .syntax unified
    .cpu cortex-m4
    .thumb

    .global Reset_Handler
    .section .text.Reset_Handler

Reset_Handler:
    /* 1. 设置栈指针 */
    ldr sp, =_estack

    /* 2. 清零 .bss 段 */
    ldr r0, =_sbss
    ldr r1, =_ebss
    mov r2, #0
bss_loop:
    cmp r0, r1
    itt lt
    strlt r2, [r0], #4
    blt bss_loop

    /* 3. 拷贝 .data 段 */
    ldr r0, =_sdata
    ldr r1, =_edata
    ldr r2, =_sidata
data_loop:
    cmp r0, r1
    itt lt
    ldrlt r3, [r2], #4
    strlt r3, [r0], #4
    blt data_loop

    /* 4. 跳转到 main */
    bl main
    b .

注意看,_sbss、_ebss、_sdata 这些符号哪里来的?它们不是代码定义的,而是链接脚本导出的地址。这就是启动代码和链接脚本的配合点。

我的习惯:在启动代码末尾加一个 bl main 后的死循环 b .。万一 main() 返回了,系统不会跑飞,而是停在这里等你调试。

2.2 向量表配置:中断的入口地图

向量表就是一张地址表,每个中断源对应一个入口地址。CPU 响应中断时,硬件自动从向量表取出对应的处理函数地址并跳转。

在 ARM Cortex-M 中,向量表的前 16 个是系统异常,后面才是外设中断。典型布局如下:

偏移地址 异常/中断号 描述
0x0000 - 栈顶指针 (SP 初始值)
0x0004 1 复位向量 (Reset_Handler)
0x0008 2 NMI (不可屏蔽中断)
0x000C 3 HardFault (硬错误)
0x0010 4 MemManage (内存管理)
0x0014 5 BusFault (总线错误)
0x0018 6 UsageFault (用法错误)
... ... ...
0x0040 16 外设中断 #0 (如 TIM1)

向量表在代码中通常用 __attribute__((section(".isr_vector"))) 放到指定段:

__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    (void (*)(void))(&_estack),   // 0x0000: 栈顶
    Reset_Handler,                // 0x0004: 复位
    NMI_Handler,                  // 0x0008: NMI
    HardFault_Handler,            // 0x000C: HardFault
    MemManage_Handler,            // 0x0010: MemManage
    BusFault_Handler,             // 0x0014: BusFault
    UsageFault_Handler,           // 0x0018: UsageFault
    0, 0, 0, 0,                  // 保留
    SVC_Handler,                  // 0x002C: SVC
    DebugMon_Handler,             // 0x0030: DebugMon
    0,                            // 保留
    PendSV_Handler,               // 0x0038: PendSV
    SysTick_Handler,              // 0x003C: SysTick
    /* 外设中断从这开始 */
    TIM1_IRQHandler,              // 0x0040: TIM1
    TIM2_IRQHandler,              // 0x0044: TIM2
    /* ... */
};

我曾经踩过的坑:在 Bootloader 项目中,我把向量表放在 0x08020000,但忘了设置 SCB->VTOR 寄存器。结果中断一来,CPU 还是去 0x00000000 找向量表,直接 HardFault。记住:如果代码不在 0x00000000 运行,必须用 VTOR 重映射向量表。

2.3 链接脚本 (.lsl / .ld):内存的施工图纸

链接脚本告诉链接器:你的代码放哪,数据放哪,栈有多大。没有它,编译器不知道 _sdata 该是多少。

以 GCC 的 .ld 脚本为例,核心结构如下:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .isr_vector : {
        KEEP(*(.isr_vector))
    } > FLASH

    .text : {
        *(.text*)
        *(.rodata*)
    } > FLASH

    .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

    .bss : {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } > RAM

    _estack = ORIGIN(RAM) + LENGTH(RAM);
}

这里有几个关键点:

  • MEMORY 块:定义物理内存的起始地址和大小。Flash 只读可执行,RAM 可读写。
  • .isr_vector:向量表必须放在 Flash 开头,且用 KEEP 防止被优化掉。
  • .data 的 AT> FLASH:表示 .data 段运行时在 RAM,但初始值存储在 Flash。启动代码负责拷贝。
  • _estack:栈顶指向 RAM 的最高地址,栈向下生长。

我建议:在链接脚本里显式定义 _sstack 和 _estack,并在启动代码中检查栈溢出。比如在栈底放一个魔数,main 循环里定期检查它是否被覆盖。

2.4 RAM 与 Flash 分区:谁该放哪?

Flash 和 RAM 各有分工,选错了地方轻则效率低,重则跑不起来。

存储区域 特点 典型存放内容
Flash 非易失、读快写慢、按页擦除 代码、常量、初始值、Bootloader
RAM 易失、读写都快、按字节访问 栈、堆、全局变量、中断上下文

实际项目中,Flash 和 RAM 还会进一步分区:

  • Flash 分区:Bootloader 区(如 0x08000000~0x0801FFFF)、应用区(0x08020000~)、配置参数区(最后几页)。
  • RAM 分区:栈区(高地址向下)、堆区(低地址向上)、全局变量区、DMA 缓冲区(需对齐)。

你想想看,如果把一个 4KB 的查找表放在 RAM 里,每次上电都要从 Flash 拷贝,浪费启动时间。不如直接用 const 放在 Flash,用指针访问。反过来,频繁修改的变量如果放在 Flash,每次写都要擦除整页,性能直接崩掉。

分区原则:不变的数据放 Flash,变的数据放 RAM。大块只读数据用 const 强制放 Flash,频繁修改的小变量用 __attribute__((section(".fast_data"))) 放到 RAM 特定区域。

2.5 本章知识体系总览

下面这张图把启动流程、向量表、链接脚本和内存分区的关系串起来了。建议你保存下来,调试时对照着看。

MCU 启动流程与链接脚本知识体系 MCU 复位 硬件加载向量表 (SP + PC) 启动代码 (Reset_Handler) 设置栈指针 SP 清零 .bss 段 拷贝 .data 段 跳转到 main() 🔑 关键依赖: 链接脚本定义 _sdata, _edata, _sbss, _ebss, _estack 等符号 向量表必须放在 .isr_vector 段,且位于 Flash 起始地址

2.6 避坑指南:我见过的启动问题

最后分享几个真实案例,你遇到了可以少走弯路:

  • 栈溢出导致 HardFault:递归调用太深,栈指针冲到了全局变量区。解决方案:在链接脚本里给栈留足够余量,启动代码中填充栈区为 0xDEAD,运行时定期检查栈底魔数。
  • .data 段拷贝错地址:链接脚本中 AT> FLASH 写成了 > FLASH,导致 .data 段只在 Flash 有,RAM 里全是随机值。全局变量一读就错。
  • 向量表对齐问题:某些 MCU 要求向量表 256 字节对齐,否则 VTOR 配置无效。我习惯在链接脚本里加 . = ALIGN(256); 确保对齐。