第二章 启动流程与链接脚本: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 通常完成以下工作:
- 拷贝 .data 段:从 Flash 复制到 RAM。
- 清零 .bss 段:将未初始化全局变量区域全部写 0。
- 初始化堆:如果使用了 malloc,需要设置堆的起始和结束地址。
- 调用 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 整体启动流程一览
为了让你更直观地理解整个流程,我画了一张图:
从这张图你可以看到,从复位到 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 开发的基石,你花时间把它们吃透了,后面做外设驱动、系统移植都会顺手很多。