在 STM32 开发中,使用 HAL 或 CubeMX 生成的启动代码虽然方便,却也隐藏了芯片上电后的关键初始化步骤。当我们需要理解复位序列、调试 HardFault 或实现 bootloader 时,手写向量表与链接脚本就成了必修课。本文基于 Cortex-M4(STM32F446RE)与 Cortex-M0(STM32F031K6)两个平台,梳理从向量表放置到内存布局的完整链路,并给出调试时排查问题的 checklist。
Cortex-M 复位序列与向量表结构
STM32 基于 ARM Cortex-M 架构,上电后的启动行为由硬件固定:
- 复位后,处理器从地址
0x00000000读取初始 SP 值 - 再从地址
0x00000004读取初始 PC 值(复位向量) - 实际芯片将
0x08000000(Flash)和0x20000000(SRAM)映射到0x00000000区域
因此,向量表必须位于 Flash 起始地址0x08000000,且布局严格遵循以下顺序:
| 偏移 | 名称 | 说明 |
|---|---|---|
| 0x00 | 初始 SP | _estack,SRAM 末尾 |
| 0x04 | 复位向量 | Reset_Handler地址 |
| 0x08 | NMI | 不可屏蔽中断 |
| 0x0C | HardFault | 硬件错误 |
Cortex-M0 最多有 48 个中断条目,Cortex-M4 更多。典型实现使用.word伪指令按顺序排列,条目不足时填0。每个处理器的中断数不同,可参考 ST 官方启动文件(如startup_stm32f031x6.s)获取完整列表。
链接脚本:MEMORY 与 SECTIONS 的配置逻辑
链接脚本(.ld文件)本质上是与链接器的一份合同,描述三件事:入口点、可用内存、将哪些 section 放到哪里。
MEMORY 块:定义物理地址空间
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里定义了两个区域:
- FLASH:可读可执行(rx),程序代码与常量存放于此
- SRAM:可读可写(rwx),运行时变量与栈空间
对于不同型号芯片需查对应参考手册确认实际大小,例如 STM32F031K6 只有 32KB Flash 和 4KB SRAM。
SECTIONS 块:section 映射规则
SECTIONS {
.text : {
. = ALIGN(4);
*(.isr_vector_table) /* 向量表必须排第一 */
*(.text) *(.text*) *(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
.data : {
. = ALIGN(4);
_sdata = .;
*(.data)
. = ALIGN(4);
_edata = .;
} > SRAM AT > FLASH /* 关键:VMA在SRAM,LMA在FLASH */
.bss : {
. = ALIGN(4);
_sbss = .;
*(.bss) *(COMMON)
. = ALIGN(4);
_ebss = .;
} > SRAM
}
VMA 与 LMA:理解数据段的双地址问题
初始化数据(如static int count = 42;)面临一个矛盾:它们的初始值需要在 Flash 中存储(掉电不丢失),但运行时必须位于 SRAM 中供 CPU 读 / 写。
- VMA(Virtual Memory Address):运行时地址,即
&count返回的地址,必须在 SRAM - LMA(Load Memory Address):存储地址,初始值存放在 Flash
AT > FLASH语法告诉链接器:.data段的 VMA 在 SRAM,但把初始值复制到 Flash 对应位置。链接器会生成符号_etext,标记 Flash 中.data初始值的末尾 —— 这是后续启动代码需要读取的位置。
必需符号定义
链接脚本中应导出以下符号供启动代码使用:
_estack = ORIGIN(SRAM) + LENGTH(SRAM); /* 栈顶,SRAM末尾 */
_sidata = LOADADDR(.data); /* .data初始值在Flash的起始地址 */
_sdata = ADDR(.data); /* .data在SRAM的起始地址 */
_edata = ADDR(.data) + SIZEOF(.data); /* .data在SRAM的结束地址 */
_sbss = ADDR(.bss);
_ebss = ADDR(.bss) + SIZEOF(.bss);
Reset_Handler:从零初始化 C 运行时环境
启动代码的核心任务是Reset_Handler,它在上电后首先运行,负责:
- 设置栈指针
- 将
.data从 Flash 复制到 SRAM - 将
.bss清零 - 调用
main()
C 语言实现的 Reset_Handler
#include <stdint.h>
extern uint32_t _estack;
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
void Reset_Handler(void) {
uint32_t data_size = ((uint32_t)&_edata - (uint32_t)&_sdata) / 4;
uint32_t bss_size = ((uint32_t)&_ebss - (uint32_t)&_sbss) / 4;
/* 复制.data段:从Flash(_etext)到SRAM(_sdata) */
uint32_t *src = (uint32_t *)&_etext;
uint32_t *dst = (uint32_t *)&_sdata;
for (uint32_t i = 0; i < data_size; i++) {
*dst++ = *src++;
}
/* 清零.bss段 */
dst = (uint32_t *)&_sbss;
for (uint32_t i = 0; i < bss_size; i++) {
*dst++ = 0;
}
main();
while (1); /* main返回则死循环 */
}
/4是因为操作单位是uint32_t(4 字节)。如果忘记除以 4,复制量会是实际需要的 4 倍,可能导致踩内存。
汇编实现的 Reset_Handler(Cortex-M0 示例)
reset_handler:
LDR r0, =_estack
MOV sp, r0
/* 复制.data段 */
MOVS r0, #0
LDR r1, =_sdata
LDR r2, =_edata
LDR r3, =_sidata
B copy_loop
copy_sidata:
LDR r4, [r3, r0]
STR r4, [r1, r0]
ADDS r0, r0, #4
copy_loop:
ADDS r4, r0, r1
CMP r4, r2
BCC copy_sidata
/* 清零.bss段 */
MOVS r0, #0
LDR r1, =_sbss
LDR r2, =_ebss
B bss_loop
reset_bss:
STR r0, [r1]
ADDS r1, r1, #4
bss_loop:
CMP r1, r2
BCC reset_bss
B main
两种实现效果相同,C 版本可读性更好,但需要__attribute__((section))等机制配合编译器。
向量表定义:section 属性与 weak 别名
向量表本身是一个数组,第一个元素是_estack(初始 SP),第二个是Reset_Handler:
uint32_t vector_table[] __attribute__((section(".isr_vector_table"))) = {
(uint32_t)&_estack,
(uint32_t)&Reset_Handler,
(uint32_t)&NMI_Handler,
(uint32_t)&HardFault_Handler,
/* ... 其余中断向量 ... */
};
.isr_vector_table这个 section 名称必须与链接脚本中完全一致。常见错误是写成.isr_vector或.vector_table,导致链接器找不到匹配输入而创建孤立输出 section。
中断处理函数的 weak 别名模式
void Default_Handler(void) {
while (1) { } /* 未配置的中断默认进入死循环 */
}
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
weak属性使链接时若存在同名强定义则覆盖默认实现,alias则将函数指针指向同一个目标函数。这保证所有中断都有有效处理器,避免上电后进入未定义指令。
调试清单:用 objdump 验证内存布局
链接完成后,使用arm-none-eabi-objdump -h <elf>检查 section 分布。第一列 VMA(Virtual Memory Address)必须是:
.text(含向量表):0x08000000(Flash 起始).data:0x20000000(SRAM 起始).bss:0x20000000之后的某个位置
常见错误场景
向量表在 SRAM 中:VMA 显示.isr_vector_table在0x20000000,意味着它未被正确合并到.text中。原因通常是 section 名称不匹配。
向量表偏移:向量表虽然也在 Flash 中,但不在起始位置。可能的原因是指令排列顺序导致链接器将其他内容排在前面。
HardFault:上电后立即进入 HardFault(xPSR 显示0x01000003),通常是向量表位置错误导致 SP/PC 读取了垃圾值。
修复后,objdump 输出应类似:
Idx Name Size VMA LMA
0 .text 000000fc 08000000 08000000
1 .data 00000000 20000000 080000fc
2 .bss 00000000 20000000 080000fc
.text从0x08000000开始,大小包含向量表;.data和.bss的 VMA 在 SRAM,LMA 在 Flash 对应位置。
构建与烧录
# 编译(-ffreestanding:不依赖标准库)
arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu23 startup.c -o startup.o -ffreestanding
arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu23 main.c -o main.o -ffreestanding
# 链接(-nostdlib:禁止链接标准库)
arm-none-eabi-gcc -nostdlib -mcpu=cortex-m4 -mthumb -T stm32f446.ld *.o -o firmware.elf
# 验证布局
arm-none-eabi-objdump -h firmware.elf
# 烧录(OpenOCD + GDB)
openocd -f board/st_nucleo_f4.cfg
# GDB中
monitor flash write_image erase firmware.elf
monitor resume
核心参数速查
| 参数 | STM32F446RE | STM32F031K6 |
|---|---|---|
| Flash 起始 | 0x08000000 | 0x08000000 |
| SRAM 起始 | 0x20000000 | 0x20000000 |
| Flash 大小 | 512KB | 32KB |
| SRAM 大小 | 128KB | 4KB |
_estack值 |
0x20020000 | 0x20001000 |
| 编译器 | arm-none-eabi-gcc | arm-none-eabi-gcc |
| 目标架构 | cortex-m4 | cortex-m0 |
关键工程要点
链接脚本是契约:启动代码中的 section 名称必须与链接脚本完全一致,字符差异会导致向量表进入错误内存区域。
objdump 优先于调试器:烧录前先检查 section 地址,可提前发现向量表错位问题,避免在 HardFault 中浪费时间。
VMA/LMA 分离是初始化数据的标准模式:理解> SRAM AT > FLASH语法,才能正确处理带初始值的全局变量。
weak 别名保证健壮性:所有未实现的中断处理器默认进入死循环,优于运行到未知地址。
资料来源:magdaref.com(STM32 bare metal: writing a linker script and startup code from scratch)、vivonomicon.com(Bare Metal STM32 Programming Part 2)
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。