2. MCU 启动流程与链接脚本:从复位到 main() 的必经之路
芯片上电那一刻,PC 指针指向哪里?为什么你的代码能跑起来?
很多人觉得启动代码是编译器自动生成的,不用管。我以前也这么想,直到有一次项目里芯片死活不进中断,查了三天才发现是向量表偏移配错了。嗯,从那以后,我每次都会亲手过一遍启动流程。
这一章,我们就来拆解 MCU 从复位到 main() 的完整路径。说白了,就三件事:启动代码、向量表、链接脚本。搞懂它们,你才算真正掌握了嵌入式开发的底层控制权。
2.1 复位后的第一行代码:启动代码 (Startup)
MCU 复位后,硬件自动从 0x00000000(或映射地址)加载栈顶指针 SP,从 0x00000004 加载复位向量,然后跳转执行。这个入口就是启动代码。
启动代码通常用汇编写,因为这时候 C 环境还没准备好。它的核心任务就四个:
- 设置栈指针:为 C 函数调用提供栈空间。
- 初始化 .bss 段:把未初始化的全局变量清零。
- 初始化 .data 段:把 Flash 中的初始值拷贝到 RAM。
- 调用 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 本章知识体系总览
下面这张图把启动流程、向量表、链接脚本和内存分区的关系串起来了。建议你保存下来,调试时对照着看。
2.6 避坑指南:我见过的启动问题
最后分享几个真实案例,你遇到了可以少走弯路:
- 栈溢出导致 HardFault:递归调用太深,栈指针冲到了全局变量区。解决方案:在链接脚本里给栈留足够余量,启动代码中填充栈区为 0xDEAD,运行时定期检查栈底魔数。
- .data 段拷贝错地址:链接脚本中
AT> FLASH写成了> FLASH,导致 .data 段只在 Flash 有,RAM 里全是随机值。全局变量一读就错。 - 向量表对齐问题:某些 MCU 要求向量表 256 字节对齐,否则 VTOR 配置无效。我习惯在链接脚本里加
. = ALIGN(256);确保对齐。