2. 裸机程序启动流程与链接脚本:启动文件分析、向量表、链接脚本(.lds)编写、程序入口与堆栈设置
好,咱们今天聊点实在的。你写了一个 main() 函数,然后点了一下编译按钮,程序就跑起来了。但你想过没有——是谁把 main() 叫醒的?
在 ARM 裸机世界里,没有操作系统帮你打理这些。芯片上电后,第一行代码从哪里开始?全局变量怎么初始化?堆栈指针是谁设的?这些事,全得你自己安排。
我刚开始做裸机开发时,也以为写个 main() 就完事了。结果第一次调试,程序直接跑飞。查了半天才发现——向量表没放对位置。嗯,从那以后,我再也不敢跳过启动流程了。
2.1 芯片上电后,CPU 在干什么?
ARM Cortex-M 系列上电复位后,硬件会自动做两件事:
- 从 0x00000000 地址读取 栈顶指针(MSP),加载到 SP 寄存器。
- 从 0x00000004 地址读取 复位向量,加载到 PC 寄存器。
说白了,芯片一通电,CPU 就盯着这两个地址看。你放对了,程序就跑;放错了,直接 HardFault。
核心要点:ARM Cortex-M 的启动流程完全由硬件决定。向量表的前两个字必须是 MSP 和 Reset_Handler,顺序不能乱。
2.2 向量表——中断的“电话本”
向量表本质上是一个 函数指针数组。每个中断源对应一个表项,存放着该中断服务函数的地址。
以 STM32F103(Cortex-M3)为例,向量表布局如下:
| 偏移地址 | 内容 | 说明 |
|---|---|---|
| 0x0000_0000 | MSP 初始值 | 栈顶指针,由链接脚本提供 |
| 0x0000_0004 | Reset_Handler | 复位中断入口 |
| 0x0000_0008 | NMI_Handler | 不可屏蔽中断 |
| 0x0000_000C | HardFault_Handler | 硬错误处理 |
| 0x0000_0010 | MemManage_Handler | 内存管理错误(M3/M4) |
| 0x0000_0014 | BusFault_Handler | 总线错误 |
| 0x0000_0018 | UsageFault_Handler | 用法错误 |
| … | … | … |
| 0x0000_0040 | SVC_Handler | 系统服务调用 |
| 0x0000_0044 | DebugMon_Handler | 调试监视器 |
| 0x0000_0048 | PendSV_Handler | 可挂起系统调用 |
| 0x0000_004C | SysTick_Handler | 系统滴答定时器 |
| 0x0000_0050 | 外部中断 0 | IRQ0,芯片厂商定义 |
我的习惯:在启动文件中,我会把不用的中断都指向一个 Default_Handler。这样万一触发了未使能的中断,至少能进一个空函数,而不是 HardFault。调试时也能快速定位问题。
2.3 启动文件——真正的“入口”
启动文件(startup_xxx.s)是汇编写的。它负责三件事:
- 定义向量表,把各个中断处理函数的地址按顺序放好。
- 初始化全局变量,把 .data 段从 Flash 拷贝到 RAM,把 .bss 段清零。
- 调用
main(),把控制权交给 C 语言世界。
我贴一段典型的启动文件核心代码,你感受一下:
; 向量表定义
__Vectors DCD __initial_sp ; 栈顶指针
DCD Reset_Handler ; 复位向量
DCD NMI_Handler
DCD HardFault_Handler
; ... 其他中断向量 ...
; 复位处理函数
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
IMPORT SystemInit
LDR R0, =SystemInit
BLX R0 ; 调用系统初始化
LDR R0, =__main
BX R0 ; 跳转到 C 库的 __main
ENDP
注意看,Reset_Handler 先调了 SystemInit(配置时钟、PLL 等),然后跳转到 __main。这个 __main 是 C 库提供的,它会完成 .data 和 .bss 的初始化,最后才调用你的 main()。
我曾经踩过的坑:有一次我忘了在启动文件里调用 SystemInit,结果芯片跑在默认的 HSI 时钟上,频率只有 8MHz。外设怎么调都不对,查了整整一天。后来用示波器一看时钟脚——嗯,8MHz。从那以后,我每次写启动文件都会确认时钟初始化有没有被调用。
2.4 链接脚本——告诉编译器“东西放哪”
链接脚本(.lds 或 .ld)是链接器的“地图”。它告诉链接器:
- 代码段(.text)放在 Flash 的哪个地址。
- 数据段(.data)的 LMA(加载地址)和 VMA(运行地址)分别在哪。
- BSS 段(.bss)从哪开始、多大。
- 堆和栈的边界在哪。
一个典型的 STM32 链接脚本长这样:
/* 内存区域定义 */
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*)
*(.glue_7)
*(.glue_7t)
*(.rodata)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
/* 数据段:LMA 在 Flash,VMA 在 RAM */
_sidata = .;
.data : AT(_sidata)
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
/* BSS 段:在 RAM 中,运行时清零 */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
/* 堆和栈 */
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE ( end = . );
PROVIDE ( _end = . );
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} > RAM
}
这里有几个关键符号:
_sidata:.data 段在 Flash 中的起始地址(LMA)。_sdata/_edata:.data 段在 RAM 中的起始和结束地址(VMA)。_sbss/_ebss:.bss 段的起始和结束地址。_Min_Heap_Size/_Min_Stack_Size:堆和栈的最小尺寸。
我个人习惯:在链接脚本里显式定义 _Min_Stack_Size 和 _Min_Heap_Size,而不是用默认值。这样万一堆栈溢出,至少能通过 map 文件快速定位问题。我一般设栈 1KB、堆 512B,够用就行。
2.5 堆栈设置——别让程序“无家可归”
堆栈的设置分两步:
- 链接脚本:预留堆栈空间,定义栈顶地址
__initial_sp。 - 启动文件:把
__initial_sp放到向量表的第一个位置。
在链接脚本中,栈顶地址通常这样计算:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶在 RAM 的最高地址 */
为什么栈顶要放在 RAM 的最高地址?因为栈是向下生长的。栈顶在最高地址,栈指针递减时不会立即踩到 .data 和 .bss 段。
我曾经遇到的情况:有一次我把栈顶设在了 RAM 的中间位置,结果递归调用一深,栈直接覆盖了全局变量区。程序表现非常诡异——有时候正常,有时候跑飞。后来用 map 文件一查,发现栈和 .bss 段重叠了。嗯,从那以后我坚持把栈顶放在 RAM 末尾。
2.6 完整启动流程——一张图看懂
下面这张图把整个启动流程串起来了:
2.7 常见问题与避坑指南
| 问题 | 现象 | 原因 | 解决方法 |
|---|---|---|---|
| 程序跑飞 | 上电后直接 HardFault | 向量表地址不对,或 MSP 值错误 | 检查链接脚本中 .isr_vector 段的位置 |
| 全局变量值不对 | 变量初始值不是代码中写的 | .data 段未从 Flash 拷贝到 RAM | 检查启动文件中拷贝循环是否正确 |
| 堆栈溢出 | 函数调用深时死机 | 栈空间不足,或栈顶位置不对 | 增大栈空间,确认栈顶在 RAM 末尾 |
| 中断不响应 | 触发中断后无反应 | 中断向量表未正确映射 | 检查中断号与向量表偏移是否匹配 |
调试小技巧:第一次调试裸机程序时,我习惯在 Reset_Handler 的第一行加个 GPIO 翻转。如果上电后 GPIO 没反应,说明连启动文件都没跑进去。这时候别查 C 代码,先查链接脚本和向量表。
好了,启动流程和链接脚本就聊到这儿。这些东西看起来琐碎,但它们是裸机程序的根基。你把这些搞明白了,后面写外设驱动、调中断,心里就有底了。