深入解析STM32F411链接脚本:从复位到main的完整启动流程
2026/9/11 11:26:55 网站建设 项目流程

1. 从按下复位键到 main(),中间到底发生了什么

STM32F411 这颗芯片相信搞嵌入式的都不陌生,Cortex-M4 内核,120MHz 主频,512KB Flash,128KB SRAM,在无人机飞控、小型工控板、电机驱动板里出镜率极高。我最近在做一个 F411 平台上的 ymodem 固件升级功能,需要在 BOOT 和 APP 之间做地址重映射,被链接脚本狠狠教育了一顿,索性把从复位到 main() 的完整链路彻底捋了一遍,这才发现很多人对链接脚本的理解停留在"改改起始地址"的层面,完全没有意识到它才是决定程序能不能跑起来的第一道关卡。

先说结论:链接脚本(Linker Script)不只是告诉编译器代码放在哪里,它直接决定了复位后芯片找不找得到入口、栈指针从哪里取、各种数据段怎么搬运。它不参与运算、不执行任何指令,但整个启动过程就像一台舞台剧,链接脚本是幕后总导演。你要真把它的段地址填错,轻则变量初始值全是乱的,重则设备一上电就进 HardFault。

这篇文章我会用 STM32F411 的标准启动过程串联起整个知识点,从"什么时候执行第一条指令"开始,讲到.data.bss这些段如何被初始化,再手把手给出一份完整可用的 F411 链接脚本,并把 BOOT 和 APP 跳转场景下的地址偏移问题一并解决。内容不难,但信息密度很大,建议收藏后边看边跟着在工程里验证。

先给还不熟悉的朋友补一个硬件启动的逻辑闭环:STM32F411 上电后,硬件电路保证复位引脚(NRST)有一个低电平脉冲,等电源稳定后释放,芯片开始从 Flash 的起始地址读取内容。Cortex-M4 内核规定,地址0x00000000处必须放初始栈指针(MSP 值),地址0x00000004处必须放复位向量(Reset_Handler 的地址)。这两个值在哪定义、以什么顺序排布,全部由链接脚本和启动文件配合完成。

很多初学者有个误解,以为 main() 是程序运行的起点。实际上从 CPU 视角看,它经历的是这样一条链路:

复位信号释放 → 读取向量表首两个字 → 跳转到 Reset_Handler → 初始化系统时钟 → 拷贝.data段 → 清零.bss段 → 调用 SystemInit → 跳转 __main(C 库初始化)→ 最终进入 main()。

这里面有三步跟链接脚本强相关:向量表排布__initial_sp的导出数据段拷贝/清零时的符号引用。后面的实操部分我会逐条对应。

2. 内存布局的第一性原则:ROM 和 RAM 都是资源,段是要分配的地块

写链接脚本之前,必须先吃透目标芯片的内存地图(Memory Map)。STM32F411 的 Flash 起始地址是0x08000000,SRAM 起始地址是0x20000000。前者是掉电不丢的只读存储,后者是掉电清零的高速易失存储。

这里牵扯出一个很多新手绕不过去的弯:链接脚本里的 MEMORY 命令,本质上是在给这两块物理存储划分区域并起名字。你可以把 MEMORY 理解成城市规划局给土地划红线,FLASH是一块建设用地,RAM是另一块,程序里的各种"建筑"(代码段、数据段)最终要被安排进这些地块里,而安排的方式由 SECTIONS 命令规定。

F411 的具体规划如下:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }

rx表示这块区域可读可执行,rwx表示可读可写可执行。这玩意不是写着好看的,链接器会根据段的属性去匹配可用的内存区域:代码段.text是只读的,能放进 FLASH;.bss段没初值,运行时需要读写,必须放 RAM;.data段虽然有初值,但初值快照在 Flash 里,运行地址却在 RAM 里,这就引出了"加载地址与运行地址不一致"的问题。

我在实际工程中见过最典型的错误,就是把某个段强制放到一个物理上不存在的地址。比如有人给RAM的 ORIGIN 写成了0x20000000但 LENGTH 写了 256K,超过了 128K,链接器不会报错,因为链接器只负责逻辑上放得下,后面的段被排在物理地址之外,下载后一运行就出事。这种问题查起来极其恶心,因为编译不报错,只有跑起来才发现变量互相覆盖。

F411 的内核是 Cortex-M4,它没有 MMU,所有地址都是物理地址,访问超范围地址会触发总线错误,直接进 HardFault。所以内存区域的规划必须严格对照参考手册第 4 章的内存映射表,别凭感觉写。RAM 的可用尾部通常还有一段 16KB 的备份域 SRAM,但普通应用尽量别碰,留给低功耗唤醒场景更合适。

2.1 除了 Flash 和 RAM,你还得知道 SRAM 的算力限制

在链接脚本里有一个很容易被人忽略的点:SRAM 的访问速度。Cortex-M4 内核访问紧耦合的 SRAM 通常是零等待,但如果你把代码段放在 RAM 里执行(有些 bootloader 会把升级代码拷贝到 RAM 再跑),性能和功耗要考虑清楚。

F411 的 SRAM 分两个部分:112KB 的主 SRAM(0x20000000开始)和 16KB 的附加 SRAM(0x10000000开始),两者在总线上是并行的,都能被 DMA 访问,但它们之间的访问延迟略有差异。对大多数项目来说,启用的就是0x20000000起始的 128KB 连续区域。链接脚本里 128K 的 LENGTH 写的是连续的、内核零等待访问的部分。这块区域不仅要放变量,还要放栈和堆,所以 128K 看着挺大,规划不好一样爆。

我在写链接脚本时习惯把 RAM 区域再细分成两个逻辑段:一个是变量区、一个栈区。不是必须这么做,但分开后每个段都有明确的归属,排查溢出问题方便。当然这是后话,SECTIONS 阶段再展开。

2.2 ARM 处理器的对齐要求和 8 字节栈对齐这个坑

链接脚本里还有一类细节,初次接触的人很容易忽略:对齐属性。GNU ld 的.点号表示当前位置计数器,ALIGN(4)就是让当前位置向 4 字节边界对齐后继续。这个 4 在 Cortex-M 系列默认是够用的——ARM 架构规定代码和数据的自然对齐就是 4 字节,但当你用到 Cortex-M4 的单精度浮点、双精度浮点、以及 EABI 规定的 AAPCS(ARM Architecture Procedure Call Standard)时,必须保证栈是 8 字节连续对齐

以前我在做音频处理算法时栽过这个跟头:程序在调试版本跑得好好的,开了 -O2 优化后一调用printf就卡死。最后定位到原因是栈指针在函数调用过程中没有被正确维护到 8 字节对齐,浮点参数传参直接崩了。链接脚本里__initial_sp的值必须是 8 的倍数,并且启动代码拿到这个值后要再做一次对齐处理,否则后续调用任何使用 FPU 的库函数都可能翻车。

所以你会在很多官方链接脚本里看到下面这种写法:

_estack = ORIGIN(RAM) + LENGTH(RAM) - 8;

减掉 8 字节是给 Cortex-M 内核的栈指针预留一个安全尾部,防止栈溢出后直接把 RAM 末尾的校验字冲掉。这个细节在 STM32L4、F4 系列上尤其重要,因为很多 bootloader 会检查 RAM 末尾数据是否被篡改来判断是否进入固件升级模式。

3. 链接脚本的骨架:MEMORY、ENTRY、SECTIONS 一场戏

链接脚本的核心骨架就三块命令:MEMORY(内存规划)、ENTRY(程序入口)、SECTIONS(输出段定义)。有些脚本还会用OUTPUT_FORMATINCLUDEPROVIDE这些辅助命令,但对裸机项目来说,前三个是必需品。

先看一个 F411 的完整链接脚本,后面逐个解释:

/* STM32F411CEU6 链接脚本示例 */ ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } _estack = ORIGIN(RAM) + LENGTH(RAM) - 8; _Min_Heap_Size = 0x200; _Min_Stack_Size = 0x400; SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) *(.text*) *(.glue_7) *(.glue_7t) *(.eh_frame) KEEP(*(.init)) KEEP(*(.fini)) . = ALIGN(4); _etext = .; } >FLASH .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } >FLASH .ARM.extab : { *(.ARM.extab* .gnu.linkonce.armextab.*) } >FLASH .ARM : { __exidx_start = .; *(.ARM.exidx*) __exidx_end = .; } >FLASH .preinit_array : { PROVIDE_HIDDEN(__preinit_array_start = .); KEEP(*(.preinit_array*)) PROVIDE_HIDDEN(__preinit_array_end = .); } >FLASH .init_array : { PROVIDE_HIDDEN(__init_array_start = .); KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array*)) PROVIDE_HIDDEN(__init_array_end = .); } >FLASH .fini_array : { PROVIDE_HIDDEN(__fini_array_start = .); KEEP(*(SORT(.fini_array*))) KEEP(*(.fini_array*)) PROVIDE_HIDDEN(__fini_array_end = .); } >FLASH _sidata = LOADADDR(.data); .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } >RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; __bss_start__ = _sbss; *(.bss) *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; __bss_end__ = _ebss; } >RAM ._user_heap_stack : { . = ALIGN(8); PROVIDE(end = .); PROVIDE(_end = .); . = . + _Min_Heap_Size; . = . + _Min_Stack_Size; . = ALIGN(8); } >RAM /DISCARD/ : { libc.a(*) libm.a(*) libgcc.a(*) } }

这个脚本可能和你平时在 STM32CubeMX 里自动生成的那个长得很像,因为很多商用工具链的模板就是从 GNU 官方的示例脚本改过来的。但像归像,里面的每一个符号、每一行 KEEP 都有它的存在意义,接下来逐块拆。

3.1 ENTRY(Reset_Handler) 和向量表段为什么必须 KEEP

先看ENTRY(Reset_Handler)。这个命令告诉链接器"程序的入口是 Reset_Handler",它的作用有两个:一是确认入口符号存在,如果找不到会直接报错;二是指导链接器做垃圾回收时不要把 Reset_Handler 丢掉。

这里有个容易混淆的知识点:ENTRY指定的入口并不直接决定硬件从哪里执行。Cortex-M 硬件复位后不是去找ENTRY,而是去0x00000004地址读向量表的第二项,也就是 Reset_Handler 的地址。ENTRY主要是给链接器一个逻辑上的"起点"参考,真正决定跳转的是向量表本身。但两个东西必须保持一致,如果ENTRY(Reset_Handler)写的是 A 函数,而向量表第一项放的是 B 函数,链接器倒不会报错,但运行结果就全看天意了。

接下来是.isr_vector段和KEEP(*(.isr_vector))KEEP的意思是:即使链接器在做--gc-sections(垃圾回收)时,也强制保留这个输入段。向量表不是普通代码,它是被硬件"直接寻址"的数据结构,编译器和链接器的垃圾回收机制无法从代码引用关系里推断出"它被硬件用到",所以必须显式 KEEP。

我在准备要一份自定义的链接脚本给项目里做固件升级时,曾在 BOOT 和 APP 中分别定义了两套中断向量表。APP 那边为了省 Flash,没用默认的 startup 文件,自己写了个只有 16 个向量的小表,结果忘了在链接脚本里 KEEP,调试时一打开优化,整个向量表被链接器当成无用段给裁了,CPU 一进中断就飞。排查这个问题花了整整一个下午,教训就是:但凡关系到中断的东西,KEEP 就完事了,别犹豫。

3.2.text段、_etext和各段符号的作用

.text段放的是真正的机器码,也就是所有 C 函数、内联汇编、库函数编译后的结果。链接脚本里*(.text)*(.text*)的区别很微妙:*(.text)匹配的是名为.text的输入段,而*(.text*)是一个通配符模式,匹配所有以.text开头的输入段名,比如.text.startup.text.main

编译器有各种奇奇怪怪的输出段命名习惯,不加通配符很容易漏掉某些函数。我建议两条都写上,顺序不要反:先精确后通配。链接器处理输入段时是按照脚本里出现的顺序来布局的,虽然一般情况下顺序对结果影响不大,但在需要精确控制地址的场景(比如把某个启动代码固定在特定偏移处)就比较重要了。

_etext这个符号非常关键,它标记了.text段的结束地址。后面启动代码会用_etext找到 Flash 中.data段初值快照的起点。但注意:.data的初值快照并不紧跟在.text后面,.data前面还夹着.rodata和一堆初始化数组段,真正紧跟.data快照的是由LOADADDR(.data)计算出的_sidata我在实际写启动代码时,直接取_sidata作为源地址,取_sdata作为目标地址,不清楚的人很容易把_etext当成源地址导致拷贝错乱。

3.3.data段的双区生活和AT>语法

.data段是链接脚本中最考验理解力的部分。它在 Flash 里有一份初值的快照(加载地址,LMA),程序运行时又必须在 RAM 里展开一份(运行地址,VMA)。这个"落花有意流水无情"的分离生活由下面这行代码实现:

.data : { ... } >RAM AT> FLASH

>RAM表示这个输出段的 VMA(虚拟地址/运行时地址)放在 RAM 区域,AT> FLASH表示它的 LMA(加载地址)放在 Flash 区域。翻译成人话:代码运行时,.data段的内容被期望出现在 RAM 里;但链接器在生成烧录文件时,会把它的实际二进制内容安排在 Flash 里存放。烧录器把 bin/hex 烧进 Flash 后,上电时启动代码再把这个快照从 Flash 拷贝到 RAM。

你可能会问:既然 RAM 内容掉电就没了,为什么不直接在 Flash 里访问.data?因为.data里存的是非零初值的全局变量和静态变量,比如int counter = 100;,这类变量经常被写入新值,Flash 不支持随意改写变量,所以必须拷贝到 RAM。

拷贝工作由启动代码完成,链接脚本负责提供符号:

  • _sidata:Flash 中.data初值快照的起始地址(LMA)
  • _sdata:RAM 中.data段的起始地址(VMA)
  • _edata:RAM 中.data段的结束地址(VMA)

启动代码拿到这三个符号后,执行一个简单的循环:

extern uint32_t _sidata; extern uint32_t _sdata; extern uint32_t _edata; void copy_data(void) { uint32_t *src = &_sidata; uint32_t *dst = &_sdata; while (dst < &_edata) { *dst++ = *src++; } }

这里有个很容易踩的坑:C 语言里取一个链接脚本符号的地址时,写&_sdata,这个符号本身代表的是地址值,而不是一个变量。如果你写_sdata = x或者把_sdata当变量读取,都是在拿地址本身当数据,结果完全不是预期。

3.4.bss段清零和COMMON符号的处理

.bss段存的是零初值的全局变量和静态变量,比如int flag;不初始化或者显式初始化为 0 时(规范上初始化 0 的变量编译器会优化放 .data,实际上很多编译器仍放 .data),它的特点是:不需要在 Flash 里存任何快照,直接清零就行。

链接脚本里清.bss的相关符号是_sbss_ebss,启动代码遍历这两个地址之间,逐个字(或者逐字节)写 0。有些老工程为了省时间,只按字长清零,如果段边界没有 4 字节对齐,尾部会残留下没清干净的区域,这就会导致某些"半初始化"的全局变量产生了随机值。

我推荐在链接脚本里对.bss的边界也做一次ALIGN(4)。事实上我上面的脚本里.bss前后都写了ALIGN(4),但严谨的说还要警惕*(COMMON)COMMON代表"公共块",是 GCC 工具链处理未初始化全局变量的一种旧式遗留,它的地址可能不连续,也可能有编译器特有的对齐要求。在裸机环境中,*(COMMON)必须显式放在.bss段里,否则未初始化的全局变量可能被放到一个行为不确定的地址,跟静态变量抢内存。

我见过一个坑:某产品固件里用了一个开源的 fatfs 文件系统库,源码里定义了一个未初始化的全局变量当作文件系统缓冲区,结果没有在链接脚本里包含*(COMMON),GCC 把它分配到别处,恰好盖住了栈空间,跑一段时间就随机崩溃。解决方式就是上面脚本里那样,*(COMMON)放进.bss并做好 4 字节对齐。

3.5 栈和堆:_estack_Min_Heap_Size_Min_Stack_Size

栈和堆的规划,是链接脚本里最"玄学"的地方,经常有人搞不明白。先明确原理:在裸机系统中,栈就是 RAM 顶端向下生长的一块区域,堆是 RAM 中段向上生长的一块区域,它俩是 RAM 空间的"天敌"——谁都能占据剩余空间,一旦互相侵蚀,程序行为就完全不可预测。

链接脚本里定义:

_estack = ORIGIN(RAM) + LENGTH(RAM) - 8;

这是栈的初始指针,也就是复位后从向量表第一项读到的值。Cortex-M 的栈是满递减型(Full Descending),即栈指针指向最后一个有效数据,压栈时先减地址再写数据。因此栈顶必须在 RAM 的最高可用地址,也就是_estack

减 8 字节的原因和前面的对齐讨论一致:ARM AAPCS 要求栈指针在"接口处"保持 8 字节对齐,栈顶数值必须是 8 的倍数。RAM 末地址0x2001FFFF的奇偶性质导致直接减 0,结果可能不是 8 的倍数,所以减 8 后作出一个合理的对齐值。同时很多 bootloader 会在最后一个字存储一些校验信息,减 8 也自然隔开了这些保留字。

_Min_Heap_Size = 0x200;_Min_Stack_Size = 0x400;是给堆和栈预留的最小尺寸。它俩在 SECTIONS 里通过"占位"的方式生效:

. = . + _Min_Heap_Size; . = . + _Min_Stack_Size;

这行代码不是真的在这些地址分配了变量,而是在链接器的当前位置计数上增加了一个距离,相当于把 RAM 空间"预留"出来。链接器会对这个区域做一次边界检查:如果前面的.data+.bss已经超出了预留后的范围,它就会报 "region RAM overflowed" 错误。

这个机制的本质是:链接阶段就暴露栈堆与数据的空间冲突。如果你用-fstack-usage编译,还会生成每个函数的栈用量文件,可以根据链接脚本预留的栈大小反推是否够用。我在调试一个带有 Modbus 协议栈和 FreeRTOS 的项目时,发现栈溢出后程序疯狂的进 HardFault,后面把_Min_Stack_Size0x400加到0x1000,问题立刻消失。嵌入式开发里,优化一个全局变量能省下几十字节,但一条 UART 中断里的环形缓冲如果没处理好,几百字节的栈分分钟被吃光。

3.6 异常展开表和 init_array:C++ 全局构造函数的选址

链接脚本里那几段.ARM.extab.ARM.exidx.preinit_array.init_array.fini_array看起来像废话,但对某些关键功能是必须的。

  • .ARM.extab.ARM.exidx:这是 ARM 异常展开表(exception unwinding table),C++ 异常处理、以及 GDB 调试时的函数栈回卷都依赖它。如果你用的是 C 语言,没有异常和栈回卷需求,理论上可以不管它们,但最好还是保留。
  • .preinit_array.init_array.fini_array:这是C/C++ 运行时初始化器,它们指向一堆函数指针,在程序进入 main() 之前、以及退出 main() 之后,由 C 库负责依次调用。最典型的就是 C++ 全局对象的构造函数(全局对象在 main 之前就要构造出来),还有 GCC 的__attribute__((constructor))标记的函数。

如果在链接脚本里漏掉了.init_array段,C++ 静态对象根本不会被构造——一个看起来"没初始化"的全局对象会在你第一次调用它的成员函数时崩溃。而链接器不做任何警告。

我看到过有人用 C 语言的 GCC 插件来注册自动初始化函数,本质上就是利用了这个数组,效果非常优雅。链接脚本里用SORT(.init_array.*)来排序构造函数指针,这保证了__attribute__((constructor(101)))constructor(102)之间的优先级顺序。

4. 启动文件与链接脚本的联调:手把手把 Reset_Handler 写明白

链接脚本只是地图和符号清单,真正干活的是启动文件。F411 的启动文件通常叫startup_stm32f411xe.s,里面用汇编实现了从复位向量到 main() 的完整过程。我们要做的事,就是把之前定义的链接脚本符号(_sidata_sdata_edata_sbss_ebss_estack)全部用上。

一个精简的 Reset_Handler 核心代码如下:

.syntax unified .cpu cortex-m4 .fpu softvfp .thumb .global Reset_Handler .thumb_func Reset_Handler: ldr sp, =_estack ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sidata b copy_data_done copy_data_loop: ldr r3, [r2], #4 str r3, [r0], #4 copy_data_done: cmp r0, r1 blt copy_data_loop ldr r0, =_sbss ldr r1, =_ebss movs r2, #0 b zero_bss_done zero_bss_loop: str r2, [r0], #4 zero_bss_done: cmp r0, r1 blt zero_bss_loop bl SystemInit bl __main loop: b loop

逐行解释几个容易出 bug 的地方:

第一行ldr sp, =_estack:把链接脚本里算出来的栈顶地址加载到栈指针寄存器。这里必须用ldr配合=伪指令,不是mov,因为地址数值通常超过mov立即数能表达的 12 位范围。新手常常在这里写成mov sp, #0x20020000,编译能过但下载后必定 HardFault。

后面三段是标准的三段式数据初始化:拷贝.data、清零.bss、调用 SystemInit 和 __main。其中的指令ldr r3, [r2], #4是 "先读取再自增" 的后变址寻址,它是 Cortex-M4 的单指令操作,比分开的 load 和 add 效率高得多。

注意__main前面有个下划线,这是 ARM 编译器 C 库(armclang/armcc)或 GCC 库里的一个特殊入口函数,它的职责包括:再次拷贝.data(如果前面已经拷贝过了这里会检测跳过)、调用__rt_entry、跳转到 main()。如果你用的是 GCC 工具链(比如 arm-none-eabi-gcc),__main来自 libc 的初始化,它还会处理静态构造函数(也就是我们前面说的.init_array)。没有调用__main或者用了main而不是__main,C++ 全局对象初始化就废了。

4.1 向量表的两种组织和链接脚本的配合姿势

向量表里有两种常见组织方式:一是在启动文件里放一个.isr_vector段(官方标准做法),另一种是纯 C 语言数组(有些轻量框架的做法)。无论哪种,链接脚本都要确保.isr_vector被放在 Flash 的起始地址。

官方启动文件里向量表长得像这样:

.section .isr_vector, "a", %progbits .type g_pfnVectors, %object .size g_pfnVectors, .-g_pfnVectors g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler ...

注意第一项是_estack,第二项是Reset_Handler。这正好呼应了文章开头说的硬件行为——复位后 CPU 从向量表第一项读栈指针、第二项读复位向量入口。_estack这个词在汇编里被声明成 external 并在链接阶段由链接脚本解析,这就是汇编与链接脚本之间的"握手协议"。你不需要在汇编里写死地址数值,链接脚本改了、汇编自然跟着变。

如果用 C 语言数组定义向量表,链接脚本也需要 KEEP 住它,并且防患于未然:__attribute__((section(".isr_vector")))是 GCC 支持的写法,配合链接脚本里的.isr_vector输出段,效果与汇编一致。

4.2 SystemInit、SystemCoreClock 和系统时钟树的影响

从 Reset_Handler 调用SystemInit,这一步是在 main() 之前把系统时钟初始化好。STM32F411 的SystemInit是 STM32Cube HAL 库或标准外设库提供的 C 函数,它在system_stm32f4xx.c里。SystemInit 具体做什么:

  1. 设置 FLASH 等待周期(因为 CPU 频率超过 Flash 的访问上限,需要等待周期)
  2. 使能 FPU(Cortex-M4 单精度浮点单元)
  3. 设置 PWR 电源调节器规模
  4. 调用 SystemCoreClockUpdate 更新全局时钟变量

有人问"为什么 SystemInit 要在 main 之前执行",答案简单粗暴:因为 C 语言运行环境要求某些系统寄存器在 main 里的第一行代码执行前就处于确定正确的状态。比如你在 main() 里使用小延时函数HAL_Delay(100),它底层依赖 SysTick 的时钟计数,而 SysTick 用的时钟来自系统时钟,如果 SystemInit 没跑,系统时钟可能还是 16MHz 的 HSI,与工程配置的 96MHz 不一致,延时就全乱了。

链接脚本对这一步的影响在于:SystemInit 本身是一个 C 函数,它的机器码如果被链接器排到 Flash 之外的地址,或者被垃圾回收误删,启动直接崩。好在 KEEP 指令只影响段不删逻辑,函数只要在.text里一般没事。但如果你开启了-ffunction-sections,每个函数都会被放进独立的段,垃圾回收时可能把 SystemInit 丢掉,这时候启动文件里引用它的bl SystemInit会在链接阶段报 undefined reference 或者最终 Binary 里出现地址 0 的跳转。解决方法是在链接脚本里额外加一行KEEP(*(.text.SystemInit)),或者关掉函数级 section 选项。

4.3 __main 和 C 库初始化:为什么换个工具链就崩

__main与 main() 的关系,是很多从 IDE 图形化配置环境转投 GCC 的工程师最容易踩坑的点。在 MDK-ARM 的 AC5/AC6 环境下,编译器会自己插入一段__main(注意是双下划线)函数标签,它由 ARM 的 C 库提供,进入 main() 前完成所有初始化。在 arm-none-eabi-gcc 下,__main同样由 libc 提供,但它是一个组合入口,底层经过__rt_entry_main__fp_init等步骤。

如果你把 CubeMX 生成的工程手动迁移到 GCC 环境,却发现 main() 不执行或者全局变量初始值错乱,先查启动文件里调用的到底是__main还是main一个很典型的错误:启动文件写的是bl main,跳转到 main 函数本身,但跳转过去前没有初始化 C 运行环境,未初始化全局变量不归零、C++ 构造函数不执行。更糟的是,在裸机环境下 main 函数被错误地当作普通函数调用,如果 main 里写了一个死循环还好,万一 return 了,程序就飞了。

5. BOOT 和 APP 双区启动的特殊场景:F411 ymodem 升级实战

前面聊了那么多基础,现在回到我最初做的 ymodem 固件升级项目。这个需求很典型:产品需要一个 bootloader,通过串口把 APP 固件烧到 Flash 的另一个区域,然后跳转执行。这几乎触及链接脚本的进阶分水岭——地址偏移

常规规划如下:

  • BOOT 区:0x08000000,大小 32KB,存放 bootloader
  • APP 区:0x08008000,大小 480KB,存放应用固件
  • 备份寄存器区或 Flash 末尾:存放固件升级标志

那么 APP 的链接脚本内存规划要改成:

MEMORY { FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }

只改 ORIGIN 是不够的,向量表也要重新定位。Cortex-M 硬件就像一个强迫症患者,无论你程序在哪跑,它永远从0x00000000映射区读取向量表。APP 运行时也依赖这个机制。所以 APP 在上电后必须在 main() 之前自己"挪一下"向量表到0x08008000。这一般由 SDK 的宏或启动代码完成(比如 SystemInit 里的VECT_TAB_OFFSET,或者显式的SCB->VTOR = FLASH_BASE | offset)。但注意:这个赋值指令如果用 C 代码写在 main() 里,那么进入 main 之前发生的任何中断都会错误地跳转到 BOOT 区的向量表。最稳妥的办法是在 Reset_Handler 里汇编完成。在 GCC 下可以用构造函数 +__attribute__((section(".text")))的方式把向量表重定位代码强制放在启动文件之后、第一个中断产生之前。

更隐蔽的问题是:中断服务函数、中断向量表里的地址,全是链接器在生成固件时按照链接脚本的地址计算出来的。如果 APP 的链接脚本没有设置正确的 ORIGIN,而代码里又用了 SCB->VTOR 指过去,中断向量表里的每一个地址都是错的。这也是为什么很多自制 bootloader 跳转后"不跑还好,一按按键就死机"——不是跳转有问题,是链接脚本地址错了。

5.1 跳转时栈指针和 VTOR 的原子操作

从 BOOT 跳转到 APP,标准跳转代码如下:

typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; pFunction app_reset = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); __disable_irq(); SCB->VTOR = app_addr; __set_MSP(app_sp); app_reset(); }

这段代码配合一个前提条件:app_addr 处必须是 APP 向量表起始地址。如果我们把 APP 区的 ORIGIN 设置为0x08008000,那么 APP 的.isr_vector段会被放在0x08008000,这个地址就是 app_addr。

跳转代码里有两处关键细节:

第一,app_spapp_reset是两个直接内存访问,分别读取 APP 向量表的头两个字。__set_MSP(app_sp)是 Cortex 的 CMSIS 函数,它会直接把主栈指针(MSP)设成新值。为什么能在用户模式下操作?因为复位时 CPU 处于线程模式,使用 MSP,权限足够。

第二,__disable_irq()放在设置 VTOR 和 MSP 之前。跳转后时机非常微妙:中断系统可能还处于 BOOT 的配置状态,如果 APP 的中断优先级分组与 BOOT 不一致,任何中断请求过来都会造成不可预期行为。在跳转的瞬间关闭全局中断,直到 APP 的 main() 里重新配置并打开中断,是工程上最保险的做法。

5.2 链接脚本偏移后的一个隐藏杀手:常量表的绝对寻址

很多人在 BOOT/APP 双区方案里改完 Flash ORIGIN 就不管了。等着他们的第二个坑:链接脚本修改后,代码里某些常量的地址可能是绝对地址。比如一个湖南省地图显示程序里存了一张 200KB 的字库表格,定义为const uint8_t font_table[200000] = {...},链接器会把它放进.rodata段,随后通过ldr r0, =font_table这样的伪指令加载它的地址。如果链接脚本把 APP 的 Flash ORIGIN 改到0x08008000,而代码里通过非偏移方式(比如 bootloader 里直接写死0x08000000)去访问这个表,在全片擦除后就会读到全 0xFF。这个错很难排查,因为不完全是一片空白——链接脚本确实生成了新地址,但你读取数据源的代码没跟着改。

解决办法只有两个方向:要么所有跨区访问都通过符号引用,不写死地址;要么把共享数据(比如字库)单独做成一个固定地址的库,在链接脚本中用. = ORIGIN(FLASH);下令把数据段固定在某个绝对值。一句话:绝对地址写在代码里是万恶之源,能用宏定义或符号就不要手写数值。

6. 常见问题与排查技巧实录

把这些年遇到的、包括最近做 bootloader 时碰到的和链接脚本相关的问题整理成一张速查表,遇到类似现象直接按表索引。

现象可能原因排查/解决办法
程序上电后不进入 main(),卡在 HardFault向量表第一项栈指针不对,或复位向量被垃圾回收误删检查.isr_vector是否 KEEP,检查_estack是否 8 字节对齐
全局变量初值全是乱的(显示为 0xFF 或随机值).data段没有被正确拷贝检查启动代码中_sidata_sdata_edata三个符号是否声明 external
变量初始值为 0 但运行中突然变成旧值.bss清零循环未执行或清零未包含 COMMON确认_sbss_ebss的循环里包含*(COMMON)
加优化后中断不执行向量表或中断服务函数被 --gc-sections 裁剪确认 start 文件和关键段都标记了 KEEP
main() 里第一次调用 printf 崩溃栈 8 字节对齐问题检查链接脚本_estack是否为 8 的倍数,启动代码里是否做了 8 字节对齐
BOOT 跳转 APP 后设备一按按键就死机APP 向量表地址错误或 VTOR 设置时机不对检查 APP 链接脚本 ORIGIN,跳转代码里在设置 VTOR 前关闭中断
C++ 全局对象没有构造链接脚本缺少.init_array段,或__main未被调用确认启动代码调用的是__main不是main
编译通过、烧录后 Flash 中找不到向量表输出格式没有调整,或段地址被覆盖用 objdump 查看各段 LMA/VMA,确认.isr_vector在 Flash 起始处
RAM overflowed 报错.data+.bss+ 堆栈预留超过 128KB减小静态缓冲区或堆栈;检查是否在链接脚本里多写了一个大数组段

这个表里每一条都能扩展出一整篇排查文章,但原理相通:先确认链接脚本符号没问题,再检查启动代码里对这些符号的使用方式,最后看编译输出的 map 文件。map 文件是排查链接问题的金矿,它会列出每一个段的起始、结束、大小、属性和归属输入文件。遇到诡异问题,第一件事不是改代码,而是打开.map文件看.data.bss.isr_vector各在哪个地址、符号_estack是不是你期望的值。

6.1 如何用 objdump 快速验证链接结果

命令行里直接查看固件布局:

arm-none-eabi-objdump -h build/app.elf

输出里的每一行对应一个输出段,VMALMA两列能直观看出.data段的双区状态。比如:

10 .data 00000080 20000000 08008000 ...

表示.data段在 RAM 的 VMA 是0x20000000,在 Flash 的 LMA 是0x08008000,大小 0x80 字节。如果你发现 LMA 和 VMA 相同,说明AT>没写对,全局变量初值拷贝会出问题。

想要更细粒度地查看某个符号的地址:

arm-none-eabi-nm build/app.elf | grep 'Reset_Handler\|_estack\|_sdata'

6.2 链接脚本里值得记住的 3 个"潜规则"

第一个潜规则:符号赋值不是定义变量。链接脚本里的_sdata = .;只创建了一个符号,不占用任何存储空间。在 C 代码里要拿它的值,必须写&_sdata。很多人第一次用都是栽在这一步。

第二个潜规则:链接脚本里KEEP不是性能优化,是安全护栏。现代 GCC 工具链默认开启-ffunction-sections -fdata-sections,配合--gc-sections做死代码剔除,好处是能大幅减小固件体积,坏处是它不认识"硬件直接引用"的数据结构。向量表、启动代码、C 库初始化入口,全靠 KEEP 保命。

第三个潜规则:链接脚本的处理顺序影响片段布局,但不影响正确性,除非你有特殊地址要求。ARM 官方推荐的所有段顺序是:.isr_vector.text.rodata.data(含 LMA)→.bss→ 堆栈区。我见过为了"省空间"把.rodata塞进 RAM 的骚操作,短期能用,一改 Flash 起始地址就全乱,不值得学。

6.3 用 map 文件追踪一次具体的栈溢出

我在一次调试 FreeRTOS 任务栈溢出时,先用-fstack-usage编译出具每个函数的栈帧大小,然后在链接脚本的._user_heap_stack区域最小栈值从0x400改到0x800后问题消失。但这里有一个非常容易误导的陷阱:FreeRTOS 的任务栈不在链接脚本的_Min_Stack_Size里,它是通过xTaskCreate动态分配的。链接脚本里的栈是特权模式/中断模式的栈,与任务栈是完全独立的。如果你发现"栈溢出的位置和链接脚本改哪里没关系",那大概率是任务栈设置得不够大,而不是链接脚本里_Min_Stack_Size不够。

怎么判断是哪种栈溢出?看 HardFault 现场的栈指针(MSP 还是 PSP)。Cortex-M 用 PSP(进程栈指针)跑线程级任务,用 MSP(主栈指针)跑中断和异常。如果崩溃时用的是 MSP,检查链接脚本里的栈区;如果用的是 PSP,检查 FreeRTOS 任务栈大小。这个"谁在用什么栈"的区分在 F411 上同样适用,很多老工程师一看到 HardFault 时就翻开链接脚本调栈大小,其实他们应该先看 LR 和 PSP 的值。

7. 让链接脚本真正成为你的工具而不是麻烦

链接脚本这个东西,刚开始接触时觉得是工具链塞给你的一个"必须存在但不需要理解"的配置文件,直到某个午夜在调试一个变量初始化错乱或者 BOOT 跳转失败的问题时,才猛然发现原来它才是整个嵌入式工程里最基础、最致命的一环。

我在实际项目里积累的一个习惯是:每次新建工程,哪怕只是开发板测试例程,也会先打开链接脚本,花五分钟确认内存规划、向量表段、数据段拷贝符号、栈顶地址这四个关键点。这五分钟能在后续调试中节省几小时。很多软件工程师愿意花几个小时折腾一个编译警告,却不愿意看两页 ld 文件,这笔账怎么算都不划算。

另外对于使用 STM32CubeMX 自动生成工程的用户,CubeMX 生成的链接脚本其实已经经过了千锤百炼,基本可以放心用。但你要做 bootloader 时,就会发现在 CubeMX 的图形界面里根本没有"改 Flash 偏移"的选项(新版 H7 系列倒是有 UI,F4 没有),这时候你就必须亲手改链接脚本了。我的建议是:不要直接改 CubeMX 生成的文件(因为重新生成时会覆盖),而是复制一份独立的.ld文件,然后在 CMake/Makefile 里用-T指定你自己的链接脚本路径,保持可追溯。

给看完这篇文章的人留一个练手题:拿一块 F411 核心板,按前面第 3 节的完整链接脚本创建工程,把启动文件里.data拷贝循环的ldr r3, [r2], #4改成ldr r3, [r2](不加自增),加上add r2, r2, #4。编译运行后你会发现程序完全不跑,然后你就能切身体会到:链接脚本解决的是"东西放在哪",启动代码解决的是"怎么把东西摆好",一个错,全盘崩。

做嵌入式,说到底就是把每一份资源算到明明白白。链接脚本是在代码还没有跑起来之前,用最冷静的方式告诉你:这块 Flash 有多大,这块 RAM 有多小,你的程序需要怎么住进去。搞懂它,你就从"会用单片机"跨到了"掌控单片机"的状态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询