2. MCU启动流程与链接脚本:复位向量、启动代码、堆栈初始化、C运行时环境建立、链接脚本解析与自定义

各位同学,今天我们来聊聊MCU上电后,到底发生了什么。

很多人写嵌入式代码,上来就是main()函数。但你想过没有——在main()跑起来之前,芯片在干嘛?

说白了,MCU也是个“死脑筋”。它得有人告诉它:从哪里开始跑、堆栈放哪里、全局变量怎么初始化。这些脏活累活,就是启动流程和链接脚本干的。

我个人习惯,每接触一个新平台,第一件事就是看它的启动文件和链接脚本。这就像你去一个新城市,先看地图一样。不看?后面踩坑了都不知道怎么死的。

2.1 复位向量——芯片的“第一口奶”

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

以ARM Cortex-M系列为例,复位向量位于地址0x00000000(或者通过映射机制重映射后的地址)。这个向量表里,第一个字(4字节)是栈顶指针(MSP),第二个字是复位中断服务函数的入口地址。

嗯,这里要注意:不是直接跳转到main()。而是先跳转到Reset_Handler,一个用汇编写的启动函数。

核心要点:

  • 向量表第一项:栈顶地址(MSP初始值)
  • 向量表第二项:Reset_Handler入口
  • 其他中断向量依次排列

我在项目中遇到过,有人把向量表放错了位置,结果芯片一上电就跑飞了。查了两天才发现,是链接脚本里向量表的加载地址和运行地址没对齐。你说冤不冤?

2.2 启动代码(Startup.S)——从汇编到C的桥梁

启动代码通常用汇编写,文件名常见的有startup.s、startup_stm32.s等。它干的事,说白了就三件:

  1. 设置堆栈指针:把向量表里的栈顶地址加载到SP寄存器
  2. 初始化数据段:把全局变量、静态变量从Flash拷贝到RAM
  3. 跳转到main():调用C库的__main(或直接跳main)

我贴一段典型的启动代码片段,你感受一下:

; 启动代码片段(ARM Cortex-M)
Reset_Handler   PROC
                EXPORT  Reset_Handler
                IMPORT  __main
                IMPORT  SystemInit

                LDR     R0, =SystemInit
                BLX     R0          ; 调用系统时钟初始化
                
                LDR     R0, =__main
                BX      R0          ; 跳转到C运行时环境
                ENDP

你看,这里调了SystemInit(),然后才去__main。__main是C库函数,它负责完成数据段拷贝、BSS段清零、堆栈初始化等。之后才调用你的main()。

我的经验: 有些同学喜欢在SystemInit里做很多事,比如配置PLL、初始化外设。我个人建议,SystemInit只做最基本的时钟配置,其他外设初始化放到main()里。否则一旦SystemInit出问题,你连调试的机会都没有。

2.3 堆栈初始化——别让局部变量“无家可归”

堆栈是C语言的命根子。局部变量、函数调用、中断嵌套,全得靠它。

启动代码里,堆栈初始化主要做两件事:

  • 设置栈顶指针:从向量表取MSP初始值
  • 设置堆空间:为malloc等动态内存分配预留空间

我曾经在一个项目中,因为堆栈设置太小,导致函数递归调用时栈溢出,程序莫名其妙地跑飞。查了三天,最后用JTAG单步跟踪才发现SP指针已经跑到RAM外面去了。

所以,我建议你:

  • 栈大小至少给1KB(简单应用)到4KB(复杂应用)
  • 堆大小根据是否使用malloc来定,不用就给0
  • 在链接脚本里明确指定堆栈区域

2.4 C运行时环境建立——让全局变量“活过来”

C运行时环境,说白了就是让全局变量、静态变量、函数指针这些东西能正常工作。

具体包括:

任务 说明
拷贝数据段 将Flash中已初始化的全局变量拷贝到RAM
清零BSS段 将未初始化的全局变量(默认0)所在RAM区域清零
初始化堆栈 设置堆和栈的边界
调用构造函数 C++全局对象的构造函数(C语言一般没有)

这些工作,通常由C库的__main或_start函数完成。你不需要自己写,但必须理解它做了什么。

警告: 如果你的链接脚本里,数据段的加载地址(LMA)和运行地址(VMA)不一致,但启动代码里没有对应的拷贝逻辑,那么全局变量的初始值就是错的。我曾经见过一个产品,上电后某个全局变量总是0,但明明初始化成了1。最后发现是链接脚本改了,但启动代码没更新。

2.5 链接脚本(.ld/.lsl)——给代码“分房子”

链接脚本,就是告诉链接器:你的代码、数据、堆栈分别放在哪里。

以GCC的.ld文件为例,它的核心结构是这样的:

/* 简单的链接脚本示例 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text)          /* 代码段 */
        *(.rodata)        /* 只读数据 */
        _etext = .;       /* 代码结束地址 */
    } > FLASH

    .data : AT(_etext)
    {
        _sdata = .;
        *(.data)          /* 已初始化数据 */
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss)           /* 未初始化数据 */
        _ebss = .;
    } > RAM
}

你看,MEMORY定义了物理存储区域,SECTIONS定义了各个段放在哪里。

这里有个关键点:.data : AT(_etext)。它表示.data段的加载地址(LMA)在Flash的_etext处,但运行地址(VMA)在RAM中。启动代码就是根据这个,把数据从Flash拷贝到RAM。

自定义链接脚本的常见场景:

  • 需要把某个函数放在RAM中运行(如Flash擦写函数)
  • 需要保留一段RAM给Bootloader
  • 需要将常量数据放在特定地址(如校验和)

我建议你,拿到一个新芯片,先读它的链接脚本。看看Flash和RAM的地址范围、各段的大小、堆栈的位置。这些信息,比任何文档都实在。

2.6 避坑指南——我踩过的那些坑

做嵌入式十几年,启动这块我踩过的坑,够写一本书了。挑几个典型的说说:

  • 向量表对齐问题:Cortex-M要求向量表按256字节对齐。我曾经把向量表放在一个未对齐的地址,结果中断一进来就死机。
  • 堆栈溢出检测:很多芯片没有硬件栈溢出检测。我习惯在栈底放一个特殊值(如0xDEADBEEF),然后在主循环里检查这个值是否被覆盖。
  • BSS段未清零:如果启动代码里忘了清零BSS段,全局变量的初始值就是随机的。这会导致程序行为不可预测。
  • 链接脚本与启动代码不匹配:改了链接脚本,但启动代码里的拷贝逻辑没同步更新。这是最常见的坑。

嗯,说到这,我想起一个项目。当时我们做一款车载控制器,量产了1000台,有3台上电后死机。查了两个月,最后发现是某批次芯片的RAM地址有微小差异,而链接脚本里硬编码了RAM起始地址。从那以后,我所有项目的链接脚本都从芯片头文件里读取地址宏,绝不硬编码。

2.7 小结

启动流程和链接脚本,是嵌入式开发的“地基”。地基不稳,上面盖的楼再漂亮也没用。

你想想看,如果你不理解启动代码,你怎么知道全局变量什么时候被初始化?如果你不理解链接脚本,你怎么把代码放到指定的内存位置?

我个人建议,每个嵌入式工程师都应该亲手写一遍启动代码和链接脚本。哪怕只是最简单的Cortex-M0芯片,这个过程也能让你对MCU的理解提升一个档次。

下一章,我们会深入GPIO的底层驱动开发。到时候你会发现,理解了启动流程,GPIO的初始化代码看起来就像白开水一样简单。