FEATURED · 精选文章

深入解析STM32F411链接脚本:从复位到main的完整启动流程

发布时间 / 2026/9/11 11:27:05
来源 / 创域科博编辑部
栏目 / 资讯中心
深入解析STM32F411链接脚本:从复位到main的完整启动流程 1. 从按下复位键到 main()中间到底发生了什么STM32F411 这颗芯片相信搞嵌入式的都不陌生Cortex-M4 内核120MHz 主频512KB Flash128KB 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 → 跳转 __mainC 库初始化→ 最终进入 main()。这里面有三步跟链接脚本强相关向量表排布、__initial_sp的导出、数据段拷贝/清零时的符号引用。后面的实操部分我会逐条对应。2. 内存布局的第一性原则ROM 和 RAM 都是资源段是要分配的地块写链接脚本之前必须先吃透目标芯片的内存地图Memory Map。STM32F411 的 Flash 起始地址是0x08000000SRAM 起始地址是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 的主 SRAM0x20000000开始和 16KB 的附加 SRAM0x10000000开始两者在总线上是并行的都能被 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 规定的 AAPCSARM 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_FORMAT、INCLUDE、PROVIDE这些辅助命令但对裸机项目来说前三个是必需品。先看一个 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 FLASHRAM表示这个输出段的 VMA虚拟地址/运行时地址放在 RAM 区域AT FLASH表示它的 LMA加载地址放在 Flash 区域。翻译成人话代码运行时.data段的内容被期望出现在 RAM 里但链接器在生成烧录文件时会把它的实际二进制内容安排在 Flash 里存放。烧录器把 bin/hex 烧进 Flash 后上电时启动代码再把这个快照从 Flash 拷贝到 RAM。你可能会问既然 RAM 内容掉电就没了为什么不直接在 Flash 里访问.data因为.data里存的是非零初值的全局变量和静态变量比如int counter 100;这类变量经常被写入新值Flash 不支持随意改写变量所以必须拷贝到 RAM。拷贝工作由启动代码完成链接脚本负责提供符号_sidataFlash 中.data初值快照的起始地址LMA_sdataRAM 中.data段的起始地址VMA_edataRAM 中.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_Size从0x400加到0x1000问题立刻消失。嵌入式开发里优化一个全局变量能省下几十字节但一条 UART 中断里的环形缓冲如果没处理好几百字节的栈分分钟被吃光。3.6 异常展开表和 init_arrayC 全局构造函数的选址链接脚本里那几段.ARM.extab、.ARM.exidx、.preinit_array、.init_array、.fini_array看起来像废话但对某些关键功能是必须的。.ARM.extab和.ARM.exidx这是 ARM 异常展开表exception unwinding tableC 异常处理、以及 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而不是__mainC 全局对象初始化就废了。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 具体做什么设置 FLASH 等待周期因为 CPU 频率超过 Flash 的访问上限需要等待周期使能 FPUCortex-M4 单精度浮点单元设置 PWR 电源调节器规模调用 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存放 bootloaderAPP 区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_sp和app_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 文件和关键段都标记了 KEEPmain() 里第一次调用 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输出里的每一行对应一个输出段VMA和LMA两列能直观看出.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\|_sdata6.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 系列倒是有 UIF4 没有这时候你就必须亲手改链接脚本了。我的建议是不要直接改 CubeMX 生成的文件因为重新生成时会覆盖而是复制一份独立的.ld文件然后在 CMake/Makefile 里用-T指定你自己的链接脚本路径保持可追溯。给看完这篇文章的人留一个练手题拿一块 F411 核心板按前面第 3 节的完整链接脚本创建工程把启动文件里.data拷贝循环的ldr r3, [r2], #4改成ldr r3, [r2]不加自增加上add r2, r2, #4。编译运行后你会发现程序完全不跑然后你就能切身体会到链接脚本解决的是东西放在哪启动代码解决的是怎么把东西摆好一个错全盘崩。做嵌入式说到底就是把每一份资源算到明明白白。链接脚本是在代码还没有跑起来之前用最冷静的方式告诉你这块 Flash 有多大这块 RAM 有多小你的程序需要怎么住进去。搞懂它你就从会用单片机跨到了掌控单片机的状态。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻