第二章 启动流程与链接脚本:MCU 的“第一行代码”从哪开始?

很多刚入行的朋友问我:“芯片上电后,第一行代码到底在哪?”
说实话,这个问题要是没搞透,后面做 BSP 移植、调试启动异常,基本就是两眼一抹黑。
我个人习惯把 MCU 的启动流程比作“起床仪式”——你得先穿衣服、洗漱、吃早饭,然后才能开始干活。芯片也一样,上电后得先走一套固定的流程,才能跑到你的 main 函数。

这一章,我们就来拆解这个“起床仪式”。

2.1 从 Reset Handler 说起

MCU 上电后,CPU 会从 复位向量表 中取出复位中断服务函数的地址,然后跳过去执行。这个函数就是 Reset_Handler。

你想想看,这时候 RAM 还没初始化,全局变量都是随机值,C 语言的环境根本没建立起来。所以 Reset_Handler 里做的第一件事,往往是用汇编写的——因为汇编不需要栈,不需要全局变量。

核心要点: Reset_Handler 是 MCU 上电后执行的第一个函数,它负责完成“从 0 到 1”的初始化工作。

我在项目中遇到过好几次这样的问题:芯片上电后直接跑飞,或者卡在某个中断里出不来。查到最后,都是 Reset_Handler 里某个初始化步骤没做对。所以,这个函数值得你花时间仔细看。

2.1.1 向量表的结构

向量表(Vector Table)通常放在 Flash 的最开头,也就是地址 0x00000000(或者 0x08000000,取决于芯片)。它的结构是固定的:

偏移地址 内容 说明
0x0000 初始栈顶指针 (MSP) 上电后 CPU 自动加载到 SP 寄存器
0x0004 Reset_Handler 地址 复位后第一条指令的入口
0x0008 NMI_Handler 地址 不可屏蔽中断
0x000C HardFault_Handler 地址 硬件错误中断
... ... 其他中断向量依次排列

嗯,这里要注意:向量表的第一个字必须是栈顶指针,第二个字才是 Reset_Handler。CPU 上电后硬件会自动完成这两步加载,不需要你写代码干预。

2.2 链接脚本(.ld 文件)—— 芯片的“城市规划图”

链接脚本,说白了就是告诉链接器:“你的代码该放哪,数据该放哪,堆栈留多大。”
没有它,编译器生成的 .o 文件就是一盘散沙,链接器不知道该怎么把它们拼成一个完整的镜像。

我刚开始做 BSP 的时候,觉得 .ld 文件是工具链自动生成的,不用管。结果有一次移植到一颗新芯片上,程序死活跑不起来——后来发现是 RAM 的起始地址写错了。从那以后,我每次都会亲手检查 .ld 文件里的每一个地址。

2.2.1 一个典型的 .ld 文件结构

下面是一个基于 ARM Cortex-M 的典型链接脚本片段:

/* 内存布局定义 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段定义 */
SECTIONS
{
    /* 中断向量表,必须放在 Flash 开头 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    /* 代码段 */
    .text :
    {
        . = ALIGN(4);
        *(.text)
        *(.text*)
        . = ALIGN(4);
    } > FLASH

    /* 只读数据段 */
    .rodata :
    {
        . = ALIGN(4);
        *(.rodata)
        *(.rodata*)
        . = ALIGN(4);
    } > FLASH

    /* 已初始化数据段(运行时在 RAM,初始值在 Flash) */
    .data :
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

    /* BSS 段(未初始化数据,运行时清零) */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } > RAM

    /* 堆和栈 */
    ._user_heap_stack :
    {
        . = ALIGN(8);
        PROVIDE ( end = . );
        PROVIDE ( _end = . );
        . = . + _Min_Heap_Size;
        . = . + _Min_Stack_Size;
        . = ALIGN(8);
    } > RAM
}

个人经验: 我习惯在 .ld 文件里用 PROVIDE 定义一些符号,比如 _sdata、_ebss,这样在 C 代码里可以直接用 extern 引用这些地址,方便做数据段拷贝和 BSS 清零。

2.2.2 关键段的作用

  • .isr_vector:中断向量表,必须放在 Flash 开头,且 4 字节对齐。
  • .text:代码段,存放所有函数指令。
  • .rodata:只读数据,比如字符串常量、const 变量。
  • .data:已初始化的全局变量。运行时在 RAM,但初始值存储在 Flash 中,启动时由 Reset_Handler 拷贝到 RAM。
  • .bss:未初始化的全局变量。启动时由 Reset_Handler 清零。
  • ._user_heap_stack:堆和栈的预留空间。链接器不会自动分配,只是预留地址范围。

2.3 堆栈设置与入口函数分析

堆栈设置,是启动流程里最容易出问题的地方之一。
我曾经接手过一个项目,程序跑着跑着就莫名其妙地死机。查了三天,最后发现是栈空间太小,函数调用层级一深就把栈给踩爆了。

2.3.1 栈顶指针的确定

栈顶指针(MSP)的值,是在链接脚本里算出来的。通常我们会把栈放在 RAM 的末尾,这样栈向下增长时不会轻易踩到数据段。

在 .ld 文件中,栈顶地址一般通过 PROVIDE 导出:

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

然后在启动汇编代码中,把这个值加载到 SP 寄存器:

ldr   r0, =_estack
msr   msp, r0

避坑指南: 我曾经见过有人把栈顶指针写死成一个固定地址,结果换了颗 RAM 大小不同的芯片后,栈直接跑到未映射的地址空间去了。记住:栈顶地址一定要从链接脚本里取,不要硬编码。

2.3.2 入口函数 _start 做了什么?

Reset_Handler 最终会调用 _start(或者直接调用 main,取决于启动文件的设计)。_start 通常完成以下工作:

  1. 拷贝 .data 段:从 Flash 复制到 RAM。
  2. 清零 .bss 段:将未初始化全局变量区域全部写 0。
  3. 初始化堆:如果使用了 malloc,需要设置堆的起始和结束地址。
  4. 调用 main:跳转到用户主函数。

下面是一个简化的 _start 实现:

void _start(void)
{
    extern uint32_t _sdata, _edata, _sidata;
    extern uint32_t _sbss, _ebss;

    /* 拷贝 .data 段 */
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }

    /* 清零 .bss 段 */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }

    /* 调用 main */
    main();

    /* 如果 main 返回,进入死循环 */
    while (1);
}

你可能会问:为什么 .data 段要拷贝?
因为全局变量的初始值在 Flash 里,但变量本身在 RAM 里运行。上电后 RAM 的内容是随机的,所以必须把初始值从 Flash 复制过去。

2.4 整体启动流程一览

为了让你更直观地理解整个流程,我画了一张图:

MCU 启动流程总览 1. 上电复位 2. 加载向量表(MSP + Reset) 3. 跳转 Reset_Handler 4. 系统初始化(时钟、外设) 5. _start(拷贝 .data / 清零 .bss) 6. 进入 main() 说明: 步骤 1-3 由硬件自动完成 步骤 4-5 由启动代码完成 步骤 6 进入用户程序

从这张图你可以看到,从复位到 main 函数,中间经历了 6 个关键步骤。其中步骤 2 和步骤 3 是硬件自动完成的,你不需要写任何代码。但从步骤 4 开始,就完全由你的启动文件和链接脚本来控制了。

2.5 常见问题与避坑指南

问题 1:程序跑飞,卡在 HardFault_Handler
我曾经调试过一个案例,现象是上电后直接进入 HardFault。查了半天,发现是向量表里 Reset_Handler 的地址写错了——低 2 位没有按照 ARM 的要求置 1(Thumb 模式要求地址最低位为 1)。
解决方法: 检查 .ld 文件中 .isr_vector 段的链接地址,以及启动文件中函数声明的 __attribute__((naked)) 是否正确。

问题 2:全局变量初始值不对
如果你发现某个全局变量上电后的值不是你在代码里赋的初值,十有八九是 .data 段拷贝没做对。检查 _sdata、_edata、_sidata 这三个符号在链接脚本里的定义是否一致,以及拷贝循环的边界条件是否正确。

问题 3:栈溢出导致程序异常
栈溢出是最隐蔽的问题之一。我个人的习惯是:在 .ld 文件中给栈多留 20% 的余量,同时在启动代码里对栈区域填充一个固定模式(比如 0xDEADBEEF),然后在运行时定期检查栈底是否被改写。这样能第一时间发现栈溢出。

好了,这一章的内容就到这里。启动流程和链接脚本是 BSP 开发的基石,你花时间把它们吃透了,后面做外设驱动、系统移植都会顺手很多。