FEATURED · 精选文章

STM32启动链路解析:从复位向量到main的链接脚本实战

发布时间 / 2026/9/16 9:39:01
来源 / 创域科博编辑部
栏目 / 资讯中心
STM32启动链路解析:从复位向量到main的链接脚本实战 “一块 STM32F411 板子按下复位键到 main() 执行中间隔着的不是运气而是一整套启动链路。我调试过太多‘上电就挂’的问题最后发现根子全在链接脚本上不是栈顶地址给错就是 .data 段没搬运程序连全局变量都还没准备好就冲进了 C 环境。今天这篇我就拿 STM32F411 当样本从复位向量一路讲到 main()帮大家把链接脚本这件事彻底弄明白。这篇内容适合所有从 IDE 模板里走出来、想自己掌控嵌入式工程的人也包括那些被 HardFault 莫名其妙的启动崩溃折磨过、却不知道怎么下手查的开发者。”1. 芯片上电到 main()这中间发生了什么1.1 复位电路与 BOOT 模式先打个底很多人上来就写链接脚本但我建议先把复位这部分理清楚。STM32F411 的复位源有好多路上电复位 POR、引脚复位 NRST、欠压复位 BOR、独立看门狗复位、软件复位等等。外部那颗按钮和 RC 复位电路本质上就是拉低 NRST 引脚让芯片重新走一遍硬件初始化流程。不要小看这个硬件动作RC 参数选得不对板子上电瞬间可能复位不稳后面程序跑飞了都查不到根因。F411 的 BOOT0 引脚决定复位后从哪里取指令。BOOT0 拉低芯片从主 Flash 启动也就是从 0x08000000 开始执行BOOT0 拉高芯片进入系统存储器里的 bootloader这个 bootloader 是出厂固化的可以用来做串口或者 DFU 升级很多做 ymodem 固件升级的方案就是拿它当跳板或者在自己的代码里重新实现一套 bootloader。链接脚本和启动流程最相关的就是主 Flash 启动这种模式下面的讨论也都基于这个前提。这里要插一句Cortex-M4 核本身只认地址 0x00000000 作为向量表的起点但 STM32 通过内部存储映射把 0x00000000 地址区映射到 0x08000000 的 Flash。所以你不写任何代码、不开任何中断CPU 也能直接从 Flash 拿到第一条要执行的信息。这是 STM32 硬件设计上非常巧妙的点也是理解链接脚本为什么要把向量表放到 Flash 起始位置的基础。1.2 CPU 读取向量表的前 8 个字节复位释放后Cortex-M4 内核做的第一件事非常机械从地址 0x00000000 读一个字作为主栈指针 MSP 的初始值再从地址 0x00000004 读一个字作为复位向量地址也就是复位后第一条指令所在的位置。这两个 32 位字的组合就是中断向量表最开始的两项。第一个字正常情况下必须指向 SRAM 的最高地址或接近最高地址的位置比如 0x20020000因为 Cortex-M 系列的栈是向下增长的SP 初始化得越靠上可用的栈空间越大。第二个字就是 Reset_Handler 这个函数的地址。很多新手在这里犯的第一个错就是把第二个字写成 main 的地址觉得“反正都是要进 main 的嘛”。结果复位后一执行SP 还是随机值栈都没建立起来main 里第一条函数调用就直接把返回地址压到一个非法地址当场 HardFault。复位向量必须指向 Reset_Handler由它来把整个 C 环境的运行条件准备好然后才轮到 main。1.3 Reset_Handler 的三大职责Reset_Handler 这个名字你在每个 STM32 工程里的启动文件里都能看到通常是一段汇编代码。它到底干了几件事我理解下来就是三板斧。第一件事设置栈指针把链接脚本里的_estack加载到 SP 寄存器。这一步相当于把 C 语言函数调用的地基打好因为函数调用、局部变量、中断压栈全都要用到栈。第二件事初始化数据段。这里的“数据段”就是 .data 段包括所有你在 C 代码里写了初值的全局变量和静态变量。比如uint32_t counter 42;这个 42 在编译后存放在 Flash 里因为 Flash 掉电不丢但程序运行时它必须存在于 RAM 中才能被快速读写。所以 Reset_Handler 要把这一整块数据从 Flash 的加载地址原样拷贝到 RAM 的运行地址。第三件事清零 .bss 段。所有没有显式初始化、或者只写了零初值的全局变量都被放到 .bss 段C 标准要求它们默认是 0启动代码就得负责把它们清零。这一步不做那些全局变量就会是上次程序运行留下的残值表现出来就是“明明定义成 0一上电却是随机数”这类 bug 极具迷惑性。做完这三板斧程序才会调用 SystemInit 配置时钟然后再进入 main。整个过程链接脚本都在背后提供关键符号_estack、_sdata、_edata、_sidata、_sbss、_ebss这些符号就像地图上的坐标汇编代码按坐标去操作才不会迷路。2. 链接脚本到底在安排什么2.1 两种存储器的分配逻辑要写好链接脚本你脑子里得时刻有“两块地”的概念。一块是 Flash只读、掉电不丢、容量大、速度相对慢另一块是 SRAM读写都快、掉电就丢、容量小。链接脚本的活儿就是把程序里的各种“素材”分类决定哪些放 Flash、哪些放 SRAM并且算清楚每一样东西放在哪个具体地址。这特别像装修房子Flash 是储物间负责长期存放SRAM 是客厅负责日常活动。你不能把所有东西都堆客厅也没法把日常要用的东西全锁在储物间。链接脚本就是那个项目经理给每一类物品分配房间和货架位置最后还要画一幅地图告诉后续的工作人员“什么东西放在哪”。对于 STM32F411 来说Block Diagram 里最常见的型号资源大概是这样Flash 起始地址 0x08000000SRAM 起始地址 0x20000000。具体容量不同芯片不一样有 256KB 的也有 512KB 的SRAM 是 64KB 到 128KB 不等。你写链接脚本第一件事就是把芯片型号对应的容量查出来填进 MEMORY 指令里。2.2 段的本质给程序“分类打包”你写 C 语言时编译器并不知道你某个函数将来要放到哪个地址它只负责把代码翻译成目标文件里的一个个“段”。GNU 工具链里最常见的段有.isr_vector中断向量表绝大多数厂商的启动文件会把向量表单独放一个段。.text所有可执行指令也就是你写的函数代码。.rodata只读数据比如字符串常量、const修饰的全局变量、以及switch跳转表。.data有初值的可读写全局变量初值存在 Flash运行时要复制到 SRAM。.bss无初值或零初值的全局变量运行时在 SRAM 清零。链接脚本的 SECTIONS 部分就是告诉 GNU ld 链接器这些段分别放到哪个存储区域以什么顺序排列以及是否需要在两个地址之间做“搬运”。链接器和编译器各司其职编译器只负责把代码正确翻译成段链接器负责把段排进最终的内存布局。2.3 ENTRY 和全局符号地图上画圈链接脚本开头通常会有ENTRY(Reset_Handler)这样的声明。它的含义是告诉链接器整个可执行文件的入口点在哪里。对于裸机程序来说这个入口就是复位后第一条指令的地址。虽然对于 STM32 来说实际启动并不是通过“跳到入口”的方式而是通过硬件读取向量表但 ENTRY 声明仍然有意义尤其是在使用某些调试器和反汇编工具时它会指导工具找到入点位置。链接脚本里还可以直接用定义符号。最典型的就是_estack ORIGIN(RAM) LENGTH(RAM);这一行把 RAM 的最高地址赋给了符号_estack。还有像_sdata .;、_edata .;这种写法意思是把当前位置地址赋给一个符号。这些符号不是变量不会占用任何存储空间它们只是地址的别名。汇编启动代码通过ldr r0, _sdata这类指令把它们加载到寄存器里实现对特定地址的操作。理解这一点非常关键否则你很容易把链接脚本里的符号和 C 语言的全局变量搞混。3. 手写 STM32F411 链接脚本逐段拆解3.1 先看清你手里的 F411 是哪一款F411 这个家族成员不少典型的有 F411CE、F411RE、F411VE 等后缀里的字母决定引脚数和容量。我这里以比较常见的 STM32F411CEU6 为例Flash 是 512KB地址范围 0x08000000 到 0x0807FFFFSRAM 是 128KB地址范围 0x20000000 到 0x2001FFFF。如果你用的是其他型号只要改 MEMORY 区域里的 LENGTH 就行。下面这张表可以帮你快速对照型号Flash 容量SRAM 容量STM32F411CC256KB128KBSTM32F411RC256KB128KBSTM32F411CE512KB128KBSTM32F411RE512KB128KBSTM32F411VE512KB128KB实际写脚本时建议把 ORIGIN 和 LENGTH 都从芯片参考手册里再核对一遍因为有些开发板虽然丝印是 F411但实际焊接的是不同容量的型号这类问题最容易在链接时报出 region overflow 的时候才暴露到时候再排查浪费的时间可比写脚本花的时间多。3.2 完整脚本与 MEMORY 区设置下面这份脚本以 STM32F411CEU6 为准我做了比较详细的注释。你可以直接把这份内容存成一个 .ld 文件用 GCC 工具链配合使用。/* * STM32F411CEU6 链接脚本 * Flash: 512KB, 起始 0x08000000 * SRAM : 128KB, 起始 0x20000000 */ ENTRY(Reset_Handler) _Min_Heap_Size 0x200; _Min_Stack_Size 0x400; MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } /* 栈顶RAM 最高地址栈从这里向下增长 */ _estack ORIGIN(RAM) LENGTH(RAM); SECTIONS { /* 1. 中断向量表必须放在 Flash 起始处 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH /* 2. 程序代码 */ .text : { . ALIGN(4); *(.text) *(.text*) . ALIGN(4); } FLASH /* 3. 只读数据 */ .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } FLASH /* 4. data 段的加载地址标记在 Flash 中 */ . ALIGN(4); _etext .; _sidata _etext; /* 5. 已初始化全局变量运行在 RAM加载在 Flash */ .data : AT(_sidata) { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM /* 6. 未初始化全局变量清零 */ .bss : { . ALIGN(4); _sbss .; __bss_start__ _sbss; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; __bss_end__ _ebss; } RAM /* 7. 堆区域标记 */ . ALIGN(8); PROVIDE ( end . ); PROVIDE ( _end . ); . . _Min_Heap_Size; /* 8. 栈区域标记栈实际从 _estack 向下增长 */ . ALIGN(8); . . _Min_Stack_Size; }3.3 向量表段与代码段为什么“必须”在前面脚本里第一个段是.isr_vector用的是KEEP(*(.isr_vector))。KEEP 的作用是告诉链接器不管这个段有没有被引用都不许把它回收掉。这一点非常关键因为编译的时候如果开了-ffunction-sections -fdata-sections再配合链接选项--gc-sections没有被引用的段会被自动删除从而减小程序体积。向量表从来没有被任何代码“引用”过但它又是 CPU 启动时必需的数据结构所以必须强制保留。向量表的存放地址必须紧贴 Flash 起点。为什么因为 STM32 复位后从映射地址 0x00000000 开始取向量而这个地址实际上映射到 Flash 的 0x08000000。如果你把向量表放到其他位置CPU 从 Flash 开头读到的就不是向量数据而是你的某段普通代码程序必然跑飞。这也是链接脚本里 FLASH方向指令的意义让.isr_vector段覆盖 Flash 的第一个字节。.text段紧跟其后存放所有编译出来的机器码。这里用*(.text*)带星号是为了匹配所有以.text.开头的子段。开启-ffunction-sections后每个函数会被单独存成一个类似的子段比如.text.main、.text.uart_init星号模式可以一股脑全部包含进来并保持它们在输入文件里的顺序。.rodata段存放的是常量这些常量本来就只读放在 Flash 里既能节省 SRAM也符合只读数据掉电保留的特性。3.4 data 段为什么加载地址和运行地址不一样这里就是链接脚本最核心、也最容易让人困惑的地方了。看第 5 小节.data段有两个地址VMA运行地址是 RAMLMA加载地址是 Flash。AT(_sidata)用来把 LMA 指向 Flash 里的位置而 RAM用来把 VMA 设置在 RAM 中。为什么要把这两个地址分开因为.data里放的是有初值的全局变量比如你在 C 文件里写了一句int flag 1;。这个初值 1 需要被固化到 Flash 里才能在掉电后不丢失。但变量一旦运行起来CPU 每次访问它要去 Flash 读速度太慢而且 Flash 本身不能随意写入真正的读写操作必须发生在 SRAM。所以链接脚本的策略是把初值表存放在 Flash 里的_sidata位置程序启动时再把它复制到 SRAM 的_sdata地址开始的地方直到复制到_edata结束。在启动文件里自然要配套实现这段逻辑正好也印证了前面说的 Reset_Handler 第二大职责。链接脚本通过_sdata、_edata、_sidata三个符号把“从哪来、到哪去、复多长”三个要素全部定义清楚剩下的机械操作交给汇编代码去循环执行。3.5 bss 段与堆栈预留.bss段放在 RAM 中里面存放没有初值或初值为 0 的全局变量。C 标准保证这些变量在 main 执行前一定是 0所以启动代码把它们清零。链接脚本里定义了_sbss和_ebss作为清零的起止地址。注意*(COMMON)这个模式用于匹配一些老的编译工具生成在 COMMON 块里的未初始化全局变量加上它更稳妥。堆和栈的处理方式值得单独解释一下。我这里定义了_Min_Heap_Size和_Min_Stack_Size但并没有真的在内存里画出一块叫.heap或.stack的段而是用当前位置加上一个固定偏移量。为什么因为裸机环境下栈的物理空间由_estack决定SP 初始化为 RAM 最顶端向下增长天然占用了内存高地址区域。堆则通常从.bss结束后开始向上增长。两者相向而行只要总容量不超 RAM 上限编译器就不会报错。这里设置的_Min_Stack_Size和_Min_Heap_Size本质上是一种编译期约束用来提醒你栈或堆的最小保障空间而不是一个严格的分区。如果你调用了 malloc 或者标准库的某些复杂功能堆不够大时自然会出问题如果你有大量局部变量或很深的中断嵌套栈不够大时栈顶会在 RAM 里一路向下冲撞堆区域程序就会跑飞。4. 启动文件配合手动搬运 data、清零 bss4.1 start.S 中的向量表怎么摆链接脚本只是地图真正按地图执行的是启动文件。在 GCC 工具链里这个文件通常叫 startup_stm32f411xe.s或者你自己写的 start.S。它的第一部分就是中断向量表本身直接对应链接脚本里的.isr_vector段。.syntax unified .cpu cortex-m4 .fpu softvfp .thumb .section .isr_vector,a,%progbits .align 0 .globl _estack .globl Reset_Handler _vector_start: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler /* 其余中断向量省略按需要补齐即可 */ .size _vector_start, .-_vector_start这里的.section .isr_vector会创建一个段链接脚本的KEEP(*(.isr_vector))会把它锁定在 Flash 开头。第一个.word _estack把链接脚本定义的栈顶地址填进向量表的第 0 项第二个.word Reset_Handler把复位函数地址填进第 1 项这个顺序绝对不能乱。第二项为何是函数指针而不是直接跳转指令因为 Cortex-M 在硬件上会把向量表第 2 个字作为首个 PC 值加载也就是说它天然用“表驱动跳转”的方式启动跟 x86 那种从固定地址取第一条指令完全不同。这也能解释为什么向量表不低于 4 字节对齐的原因。4.2 Reset_Handler 的拷贝循环下面这段结构非常典型可以说是嵌入式启动代码的灵魂。它先把.data段从 Flash 拷到 RAM然后把.bss段清零最后才跳入 C 环境。.section .text.Reset_Handler .thumb_func .globl Reset_Handler .type Reset_Handler, %function Reset_Handler: ldr sp, _estack /* 拷贝 .data 段从 Flash 的 _sidata 拷到 RAM 的 _sdata */ ldr r0, _sdata ldr r1, _edata ldr r2, _sidata CopyData: cmp r0, r1 beq ZeroBss ldr r3, [r2], #4 str r3, [r0], #4 b CopyData /* 清零 .bss 段 */ ZeroBss: ldr r0, _sbss ldr r1, _ebss movs r2, #0 ZeroLoop: cmp r0, r1 beq JumpToMain str r2, [r0], #4 b ZeroLoop JumpToMain: bl SystemInit bl __libc_init_array bl main b .这段代码的逻辑不复杂但每一个符号都来自链接脚本的约定缺一个就编译不过。R0 和 R1 像两个游标一个从头往后走一个记录终点。R2 是源地址在拷贝循环里不断后移把 Flash 里的 32 位初值内容取出写入 RAM 中当前游标指向的位置直到游标追平终点。清零循环更简单直接把 0 写入_sbss到_ebss之间的每个字。C 语言全局变量的默认清零语义就是在这里兑现的别小看这几行汇编没有它工程里static uint32_t time_count;这种变量的初始值完全不可控。4.3 从 SystemInit 到 main时钟和多读 C 库搬运工作做完程序就可以调用 C 函数了。跳入 main 之前通常还要做两件事。第一是调用 SystemInit这是 CMSIS 标准库里提供的函数用来配置系统时钟比如把 HSI 或 HSE 倍频到 100MHz 的主频。如果你的工程不强依赖系统时钟频率也可以不调用直接用复位后的默认 HS I 16MHz但这样外设定时器、串口波特率计算会麻烦很多一般不建议。第二是调用__libc_init_array。这个名字看起来有点陌生它的用途主要是跑 C 全局构造函数同时对 C 程序来说它也负责初始化标准库的一些内部状态是一个安全的钩子。对于纯裸机 C 工程你甚至可以不用它直接跳到 main 也能跑。但如果你的代码里面用了 newlib 提供的库函数保留它会让很多运行时行为更符合预期。从 Reset_Handler 到 main可以理解为一条完整的“移交”链硬件掌握栈指针和入口启动文件掌握内存初始化和时钟准备最后把 CPU 控制权亲手交给 C 语言世界里的 main 函数。链接脚本则全程负责提供地址坐标让每一步都有据可查。4.4 不使用 C 库时的精简版启动如果你做的是极简裸机工程不想链接任何 C 运行库可以用精简版启动。跳转部分改成JumpToMain: bl SystemInit b main去掉__libc_init_array同时链接的时候加-nostartfiles告诉工具链不要使用默认的 C 启动文件。这样做能显著缩小固件体积但代价是不能使用 malloc、printf 这类依赖运行库的功能。很多资源紧张的小项目尤其是一些做 bootloader 的场景就会用这种方式。启动文件越精简最终可控性越强也越考验你对链接脚本的理解深度。5. 验证与排查如何确认程序真的从复位走到了 main5.1 用 map 文件核对布局写完链接脚本第一件要做的事不是急着下载调试而是打开编译生成的 .map 文件看关键段和符号的地址是否符合预期。map 文件就是链接器的工作台账里面记录了每个段放在哪里、每个符号定义在哪个地址。你需要关注这么几个位置.isr_vector段的起始地址理想情况是0x08000000。.text段的地址范围应该落在 Flash 区域内。.data段的 LMAload address在 FlashVMAVMA在 SRAM会显示两个不同的地址。_estack这个符号的值应当是0x20020000对应 128KB SRAM 的 F411。_sdata、_edata、_sbss、_ebss是否分布在 SRAM 区域。如果发现_estack落在别处比如因为某次改动不小心把ORIGIN(RAM)LENGTH(RAM)写错了那程序很可能一启动就跑飞直接卡在 HardFault。用 map 文件排查效率远高于瞎猜。5.2 调试器打断点观察 SP、PC下载程序后在调试器里设置两个断点一个在 Reset_Handler 入口一个在 main 函数入口。先看第一个断点单步到 Reset_Handler 时寄存器的 SP 应该已经是_estack地址了PC 应该指向0x08000000 偏移的某个位置这个位置属于 Flash 区域。然后在 main 入口停住看看全局变量是否已经初始化。比如你定义了一个int init_flag 88;单步到 main 之前查看init_flag的地址确认它的值确实是 88 而不是随机数。这能验证.data段拷贝是否成功。再看一个无初值的全局变量确认值为 0验证.bss清零是否生效。如果这两个检查都通过说明从复位到 main 的链路基本健康。如果init_flag的值不对优先怀疑 data 搬运用到的_sidata、_sdata、_edata三个符号尤其是_sidata是否指向了 Flash 中的正确位置。5.3 常见问题速查表长期和启动问题打交道我整理了一张问题定位表遇到类似情况可以直接对照排查。现象可能原因检查思路复位后直接进 HardFault向量表第 0 项 SP 非法或第 1 项复位向量被优化掉了在 Reset_Handler 打断点确认能否进入查看 map 文件向量表段首地址是否为 0x08000000全局变量初始值不对.data 段没有搬运或者_sidata、_sdata、_edata定义位置错误在 main 打断点查看变量值检查 map 文件里 .data 段的 LMA 和 VMA无初值全局变量不是 0.bss 段清零循环没执行或链接脚本位置不对检查_sbss、_ebss符号地址是否在 RAM 内启动文件是否跳过了清零段链接时报 region FLASH overflowedFlash 容量参数填小或代码量超过芯片容量核对芯片型号容量检查代码段大小考虑裁剪代码或换大容量芯片链接时报 region RAM overflowedRAM 容量参数填小或 .data/.bss/堆栈总大小超过 SRAM检查全局变量大小缩小堆栈预留确认 MEMORY 区容量正确程序能跑但一调用函数就死栈顶 SP 初始化失败或栈空间不足确认_estack值是否为 RAM 顶端检查启动文件第一条 ldr sp 是否执行改了系统时钟后外设时序不对SystemInit 没调用或时钟配置代码没链接进去在 Reset_Handler 的 bl SystemInit 处打断点确认为何没进来中断不响应向量表没有放到 Flash 起始位置或向量表从 Flash 中被回收检查是否用了 KEEP确认链接脚本排布是否把 .isr_vector 放在第一个段这张表我把它贴在工位边上了很多年每次新做一个 F4 系列的项目都会先对照一遍。启动链路问题有个特点症状五花八门根因往往就在那几行链接脚本和启动汇编之间。所以你排查时不要漫无目的地看中断模式、查外设配置先把启动链路走一遍省时间得多。最后再分享一条经验我在实际项目中碰到的绝大多数启动问题都不是代码逻辑错误而是链接脚本与启动文件之间的符号约定不一致。你换了一版启动文件里面的符号从_sdata改成了__data_start而链接脚本还在用老的命名程序当然跑不起来。所以我强烈建议每当新拿到一块 F411 或类似单片机的时候第一件事就是把启动文件和链接脚本同时打开把用到的符号列一个清单对照 map 文件确认每个符号的真实地址然后再去写业务代码。链接脚本这个东西入门时觉得它神秘用熟了之后就会意识到它其实是嵌入式工程里最诚实的一份文档。它不保证你的业务逻辑正确但把你程序的骨血——哪些代码在 Flash哪些数据在 RAM栈顶在哪堆多大——全部说得很明白。给 STM32F411 写链接脚本表面上是在写一段 ld 语法本质上是在给自己梳理清楚“复位后 CPU 如何抵达 main”的每一步路。搞清楚这条路嵌入式大门的钥匙也就真正攥在手里了。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻