尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32F411链接脚本与启动流程深度解析:从复位向量到main()的完整指南

STM32F411链接脚本与启动流程深度解析:从复位向量到main()的完整指南 不知道你有没有过这种经历STM32F411 的工程编译下载完成复位一下程序没有按照预期跑起来而是反复进入 HardFault或者连 main() 里的第一行断点都打不到。最初我习惯怀疑时钟配置、怀疑 Keil/JLink 设置但后来发现很多这种“症状”的根源并不在 C 代码而在复位到 main() 之间那几十行汇编和一段链接脚本。给 STM32F411 写链接脚本这种事看起来是编译工具链的杂活实际上决定了向量表放在哪、栈顶从哪开始、.data 段从哪里搬运、.bss 段从哪里清零。可以说CPU 复位后的每一步都在按链接脚本画好的地图走。这篇文章就把我手动维护 STM32F411 链接脚本的过程掰开讲一遍。从硬件复位的取指逻辑讲起到 MEMORY、SECTIONS 的逐行含义再到启动代码如何配合 .data/.bss 段完成“初始化”最后聊聊实际调试时遇到过的问题和排查方法。适合打算彻底搞懂编译—链接—启动链路的人也适合想从 CubeMX 生成工程里跳出来、手动掌控内存布局的人。1. STM32F411 复位后的行为本质上是按向量表“导航”这里先不急着打开链接器脚本我想先回顾一下 Cortex-M4 内核复位后的动作。很多人对启动的理解停留在“从 main 开始执行”这是进入 STM32F411 开发后第一个要纠正的直觉。复位信号释放后CPU 不是直接跳到一个函数入口而是先做两件事从地址 0x00000000 读取初始主栈指针 MSP再从地址 0x00000004 读取复位向量也就是 Reset_Handler 的地址。这两个地址在开发板上通常又被映射到 Flash 0x08000000 和 0x08000004。所以从复位到 main()第一条路线图就是向量表的前两个 word。1.1 Cortex-M4 只认两件事初始 SP 和 Reset_HandlerEFM32、STM32、nRF52 这些基于 Cortex-M4 的芯片启动逻辑高度一致但 STM32F411 有自己的存储映射。芯片出厂时会根据 BOOT0 引脚和选项字节把 0x00000000 这个“别名地址”映射到 Flash、SRAM 或者系统存储器。默认从 Flash 启动时0x00000000 就对应 0x08000000。Cortex-M4 的取指单元上电后直接读取该位置的头两个 word然后把 PC 设置到第二个 word 的值。这里的第一个 word也就是_estack通常是 SRAM 的最高地址第二个 word也就是Reset_Handler位于编译后的代码段。这两个符号都不应该由 C 语言文件定义而是由链接脚本定义关键地址再由汇编启动文件引用。链接脚本在这里的第一项任务就很明确了要有一个.isr_vector段并且把它安排在 Flash 起始地址 0x08000000。如果.isr_vector没有放在开头或者被编译器优化掉了那复位后 CPU 就完全不知所措。1.2 链接脚本其实是“项目地图”启动代码只是“执行者”很多初学阶段的项目直接把 CubeMX 生成的链接脚本当黑盒改一下 RAM 大小就算完事。但链接脚本不是普通的附加文件它决定了最终 ELF 文件里每个段加载地址和执行地址之间的差异。比如.data段在启动时存放在 Flash链接脚本需要把它标注为AT FLASH同时在运行阶段希望它被放到 RAM还要让启动代码知道源地址、目的地址和长度。这就好比你要坐火车从 A 站到 B 站启动代码是司机而链接脚本是时刻表和铁轨图。司机只知道按信号开车但哪个站台停、哪条轨道走、行李从哪里装车必须由地图来决定。STM32F411 的内存就是这样一张地图Flash 512KBSRAM 128KB外围寄存器区域也有固定范围。如果地图给错了司机再努力也到不了 main()。2. 地址地图先画对STM32F411 的 Flash、SRAM 和外设区我一直觉得写链接脚本之前必须自己在纸上把内存图画一遍。STM32F411 的内存分配其实很规整查阅数据手册里的 memory map 就能得到准确范围。对于常规的 STM32F411CEU6/STM32F411VEU6 类芯片最常用配置是 512KB Flash 和 128KB SRAM。不同型号可能 Flash 大小不一样但起步地址都是固定的。区域起始地址大小链接脚本中的用途Flash0x08000000512KB存放 .isr_vector、.text、.rodata以及 .data 的加载镜像SRAM0x20000000128KB存放 .data 运行副本、.bss、堆、栈AHB/APB 外设0x40000000视外设而定C 语言访问寄存器不参与链接脚本分配内核私有外设0xE0000000视外设而定NVIC、SysTick、FPU 等也不需要链接脚本管理2.1 为什么 Flash 里会有两份“.data”一个最常见的误解是.data段在运行前就存在于 RAM。事实并非如此。STM32F411 上电以后 SRAM 是随机值Flash 才是非易失存储。因此所有带初始值的全局变量比如int count 100;初始值100必须预先放在 Flash 里面启动代码再把100拷贝到 RAM 中变量所在的地址。链接脚本里会看到这样的设计.data段的 VMA虚拟地址或者说运行时地址在 RAM而 LMA加载地址在 Flash。这一点非常重要。AT FLASH或者AT(_sidata)的作用就是把 LMA 放到 Flash_sidata记录这个源地址_sdata和_edata记录运行时的目标范围。启动代码里的数据复制循环就是在搬这一段。2.2 栈放在 SRAM 末尾的理由与 8 字节对齐ARM Cortex-M 的栈是满递减栈也就是栈指针指向最后一个已压入的数据入栈时会先减地址再写入。为了让栈空间尽量不和普通变量冲突主流做法是把初始 MSP 指向 SRAM 最高地址。链接脚本里常见写法是_estack ORIGIN(RAM) LENGTH(RAM);。STM32F411 的 RAM 从 0x20000000 开始长度 128KB所以_estack就是 0x20020000。这个地址还需要满足 AAPCS 的 8 字节对齐要求。0x20020000 天然是 8 的倍数所以可以直接用。如果你手动修改了 RAM 的起始地址或者长度切记要让_estack按 8 字节对齐否则在某些使用 LDRD/STRD 指令或浮点上下文的场合会出现对齐问题严重时直接进 HardFault。3. 逐段拆解一份可用的 STM32F411 链接脚本下面这份链接脚本是我在 GCC 工具链下给 STM32F411 常规板子用的完整版本。它不算花哨但很稳定覆盖了向量表、代码、只读数据、初始化数据、BSS、堆栈保护区。建议不要直接复制粘贴完事而是跟着注释理解每一行。/* STM32F411, 512KB Flash, 128KB RAM */ ENTRY(Reset_Handler) _estack ORIGIN(RAM) LENGTH(RAM); /* 堆和栈的最小保留空间 */ _Min_Heap_Size 0x200; _Min_Stack_Size 0x400; MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } 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 . ALIGN(4); _sidata .; .data : AT(_sidata) { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM . ALIGN(4); .bss : { _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(*) } }3.1 ENTRY(Reset_Handler) 不是万能的ENTRY(Reset_Handler)告诉链接器 ELF 文件的入口点是 Reset_Handler。这个设置对最终生成的二进制文件烧录不是决定性作用因为 Cortex-M 复位后并不看 ELF 头部的 entry而是看向量表第二个 word。但它影响调试器的初始 PC也方便工具链报错时定位到启动文件所以保留。真正决定入口的是.isr_vector段里的Reset_Handler地址。KEEP(*(.isr_vector))是必须的因为如果没有KEEP链接器在--gc-sections开启时可能把向量表当成无用段直接丢弃。一旦向量表丢了STM32F411 复位后读到 0xFFFFFFFF直接就卡死了。3.2 MEMORY 区域的名字和权限MEMORY 命令里有FLASH (rx)和RAM (xrw)。括号里的属性不是摆样子。r代表可读w代表可写x代表可执行。Flash 设置成不可写是因为正常情况下链接器不应该把变量放到 Flash 运行时地址上去RAM 设置成可读可写可执行是因为某些情况下可能需要放到 RAM 里执行的代码。一个常见错误是把 RAM 的起始地址写成 0x20000000 开头但在带 CCM RAM 的芯片上不小心多写了一块不存在的区域。STM32F411 没有独立 CCM所以 128KB RAM 就是从 0x20000000 开始的连续空间。如果你用的是 F407、F429需要自己排查是否分成了主 RAM 和 CCM RAMCCM 不能做 DMA链接脚本里也要区别对待。3.3 .text、.rodata、.init_array 各管什么.text保存所有编译生成的机器码包括启动代码、C 函数、中断处理函数。.rodata保存const修饰的只读数据比如常量字符串、查表数组。.init_array保存 C 全局构造函数指针以及 GCC 风格需要在 main 之前初始化的函数。.init_array段很容易被忽略因为面向纯 C 工程时它几乎是空的。但如果你在某天给 STM32F411 工程引入了 C 代码或者用了需要构造函数的结构体那么启动代码必须调用__libc_init_array否则全局对象不会构建程序即使进入 main()跑出来的结果也是错的。链接脚本的责任是把这些数组完整保留下来所以对应位置的KEEP和PROVIDE_HIDDEN不能删。3.4 对齐为什么老是 ALIGN(4) 和 ALIGN(8)Cortex-M4 的 LDR/STR 对普通 Word 访问要求 4 字节对齐所以绝大多数段在切换时都使用. ALIGN(4)。但 AAPCS 又规定调用入口处也就是函数调用时的栈指针必须 8 字节对齐因此_estack、堆栈保留区都会使用 ALIGN(8)。这里我吃过一次亏为了省 4 字节把.data段边界写成. ALIGN(2)结果某个函数里用了 64 位 union导致 LDRD 指令访问未对齐地址。F411 虽然支持部分未对齐访问但在某些总线组合下会进入 HardFault。后来我统一做到“每个段边界至少 4 字节对齐栈区 8 字节对齐”再也没有因为对齐问题翻过车。4. 汇编启动文件怎么和链接脚本里的符号“会合”链接脚本定义了一堆符号比如_sdata、_edata、_sidata、_sbss、_ebss。这些符号并不分配内存本质上只是地址常量。启动代码用ldr r0, _sdata把这些常量的地址加载到寄存器然后开始搬数据。这是汇编器和链接器之间的接口编译器不管这些符号在哪里定义链接器会在最终重定位时把它们替换成具体地址。以下是精简后的 STM32F411 启动文件核心流程去掉了大段中断向量只保留关键逻辑.syntax unified .cpu cortex-m4 .thumb .section .isr_vector, a, %progbits .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler /* 其余系统异常和 STM32F411 外设中断向量在这里按顺序补齐 */ .section .text .thumb_func .global Reset_Handler Reset_Handler: ldr r0, _sdata ldr r1, _edata ldr r2, _sidata b .L_copy_data_done .L_copy_data_loop: ldr r3, [r2], #4 str r3, [r0], #4 .L_copy_data_done: cmp r0, r1 blt .L_copy_data_loop ldr r0, _sbss ldr r1, _ebss movs r2, #0 b .L_clear_bss_done .L_clear_bss_loop: str r2, [r0], #4 .L_clear_bss_done: cmp r0, r1 blt .L_clear_bss_loop bl SystemInit bl __libc_init_array bl main bkpt4.1 为什么这里要复制 .data、清零 .bss.data里的变量在 Flash 有初始值但运行地址在 RAM所以启动代码必须把初始值复制到对应 RAM 地址。.bss里的变量没有初始值或者说初始值就是 0它们不会被输出到烧录文件里但上电后 RAM 内容不确定所以启动代码必须把它们清零。这个动作如果漏掉直接后果就是int flag 0;在复位后不一定等于 0程序里所有依赖“初始状态”的判断都会失控。很多人调了很久的 bug最后发现只是链接脚本和启动代码的复制/清零没做或者条件比较反了。4.2 SystemInit 和 main 之间发生了什么在进入用户 main() 之前启动代码通常还会调用SystemInit它的作用是配置 Flash 等待周期、电源电压、时钟源等最基础的系统环境。STM32 标准库和 HAL 里都有这个函数在system_stm32f4xx.c里实现。它的名称和链接脚本没有直接关系但必须在 Reset_Handler 里被调用。随后调用__libc_init_array这会执行.init_array里的函数指针。纯 C 工程可调用可不调用但如果链接脚本保留了这个段而启动代码不调用后期有人引入 C 时就会带来“构造函数不执行”的隐蔽问题。所以我的建议是写复位启动代码时就把__libc_init_array放进去和链接脚本的 init_array 段保持配套。4.3 main() 只是另一个普通符号从链接器视角看main() 并没有特殊地位它只是一个被启动代码调用的普通函数。真正在 ELF 里标记为入口点的是 Reset_Handler。这也解释了“编译器未包含 main 类型”这类提示如果搞错启动流程把入口写到 main 本身或者漏掉了初始化 C 库程序在硬件复位后的行为会非常奇怪。在编写裸机链接脚本时我会留意一个细节Reset_Handler之前的.thumb_func不能漏。这告诉汇编器这是一个 Thumb 函数接下来的地址会设置 Thumb 位否则bx或向量加载时地址低位的 Thumb 位不置位CPU 会触发 fault。链接脚本本身管不到这一点但它和入口地址的生成强相关。5. 实测排雷链接脚本导致启动故障的典型场景都知道链接脚本重要但人不踩坑就很难真正重视。下面整理几个我在 STM32F411 调试中实际遇到过的启动故障都是链接脚本或启动代码层面能定位到的问题。5.1 复位后直接 HardFault第一步查向量表现象是程序烧录成功全速运行立刻进入 HardFault。当时我一度怀疑是硬件问题后来用调试器读 0x08000000 处的数据发现第一个 word 不是预期的栈顶地址而是 0xFFFFFFFF。原因就是启动文件里的.isr_vector段被链接器丢弃了或者链接脚本没有把FLASH的起始地址放对。排查方式很简单打开编译生成的.map文件搜索.isr_vector。正常情况下它应该出现在最前面地址在 0x08000000。如果看到该段被标记为 DISCARD 或地址跑到别处优先检查KEEP(*(.isr_vector))和启动文件的段名拼写。5.2 RAM 越界region RAM overflowed这个错误最直观链接器会直接报告但问题在于“谁把 RAM 用爆了”。不要只盯着全局变量数组还要看堆栈保护区。假设_Min_Heap_Size写得太小而代码里 malloc 了大量内存运行时不会立刻崩而是在堆和栈相遇后踩出一片乱码。链接脚本中的._user_heap_stack字段本质上是在 RAM 里预留一块空间。如果.data和.bss把 RAM 用得所剩无几而堆栈保留区又太大链接器就会报 overflow。调大 RAM 地址并不会绕过问题只有真正看.map文件、确认各部分占用后再平衡_Min_Heap_Size与_Min_Stack_Size。5.3 .data 复制方向写反变量值全部不对我之前有一次觉得“自己很懂”把数据复制循环从_sdata往_sidata复制结果所有带初始值的全局变量都乱了。这个现象比较隐蔽因为程序能跑main() 也能进但所有状态全不对。检查思路是跟踪_sidata、_sdata、_edata三个值。记住一个规律_sidata在 Flash 区域_sdata在 RAM 区域_edata在 RAM 末尾复制方向是从_sidata到_sdata直到_sdata追上_edata。类似地BSS 清零方向是从_sbss到_ebss用 0 填充。5.4 忘记把启动文件加进编译选项有些 IDE 工程生成之后默认只编译 C 源文件启动汇编文件不在编译列表里。这种情况下链接器也能链接成功因为工程里没有符号冲突但最终没有.isr_vector。烧录后自然无法运行。处理方法是确保包含 startup_stm32f411xe.s 或你自写的启动文件并让它出现在编译选项中。如果用的是 GCC Makefile可以在SRCS里加startup_stm32f411.s注意汇编器的.section asm选项避免生成的段被意外合并。5.5 用 .map 文件反向检查符号地址一个很实用的排错习惯程序跑飞时先查.map文件搜_estack、_sdata、_edata、_sbss、_ebss这些关键符号。它们应该满足_estack 0x20020000_sdata在 0x20000000 之后_edata在_sdata之后_sidata在 Flash 区域也就是 0x08000000 到 0x08080000 之间_sbss紧跟在_edata之后如果这些符号不在预期位置说明链接脚本里FLASH、RAM的属性配错了。如果符号存在但地址乱掉基本可以确定启动文件里加载符号时使用了错误的汇编伪指令比如忘记ldr r0, symbol的等号。我后来固定了一套工作流每新建一个 STM32F411 裸机工程先不写任何业务代码用一个点亮 LED 的最小工程验证启动链路是否正常确认 LED 点亮之后再逐步增加外设驱动。这样每次踩坑都能快速定位到链接脚本还是外设配置而不是把两者搅在一起。链接脚本这东西写一次可能觉得麻烦但真正吃透之后后续 F4 系列换芯片基本就是改改 MEMORY 区域长度而已。
返回列表