news 2026/9/16 17:44:26

从复位到main():STM32链接脚本与启动文件全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从复位到main():STM32链接脚本与启动文件全解析

很多用 CubeMX 的兄弟,点一下生成就能得到一套能跑的工程,从来没想过一个问题:复位之后,CPU 是怎么一步步走到main()的?又是谁告诉链接器把代码放 Flash、把全局变量放 RAM 的?我自己也是在第一次做 IAP Bootloader、需要把 App 整体搬到偏移地址时,才被链接脚本轮流教育。回头复盘,真正卡人的不是main()里写了什么,而是main()之前那段"看不见的路"——复位向量、启动文件、链接脚本,这三者配合不好,程序要么复位后直接跑飞,要么全局变量全是乱的。这篇文章就以 STM32F411 为对象,从复位到main()完整走一遍,并把链接脚本从头到脚写清楚。

这篇文章适合三类人看:一是刚转 GCC 工具链、对.ld文件一头雾水的嵌入式开发;二是想做 IAP、App 和 Bootloader 分区的朋友;三是程序莫名 HardFault、怀疑跟启动和链接有关的调试党。读完你至少能回答三个问题:为什么链接脚本里FLASH要从0x08000000开始?.data段为什么要写成>RAM AT> FLASH?复位之后启动文件又是依据哪几个符号把全局变量初始化好的。

1. 为什么需要一份链接脚本,它在整个工程里管什么

1.1 从编译到链接,链接脚本到底在干啥

很多人把编译和链接混为一谈。真正的流程是:源码先被编译器翻译成汇编、再变成机器码,但这一步产出的目标文件里,代码和数据的地址还是"相对的"、尚未定下来的。链接器干的活,就是把这些分散的目标文件合并在一起,按你指定的规则,把每段内容放到芯片内存的具体位置上,同时解决跨文件的符号引用。

这时候链接脚本就是链接器的"施工图"。它至少回答三个问题:这块芯片有哪些可用内存区域,分别在什么地址、多大;每类内容(代码、只读常量、全局变量、堆栈)放在哪个区域;哪些符号要固定地址或者暴露给启动代码。没有链接脚本,IDE 也会带一套默认的,所以你觉得"没写也能跑"。可一旦要定制,比如 App 起始地址偏移、想把某个大数组放到特殊 RAM、想严格控制栈大小,默认脚本就完全不够用了。

1.2 谁最需要改链接脚本

我把实际操作中的需求分了几类,你可以对照一下:

  • 做 IAP 或 Bootloader:芯片里要放两个甚至更多固件,App 不能从0x08000000启动,必须把FLASH区域的ORIGIN改成比如0x08010000
  • 内存吃紧或者要"抠"性能:把关键数据段放到 CCM RAM(如果芯片有的话),或者给中断向量表预留特殊空间。
  • 精确控制栈和堆:默认的栈区大小不满足任务嵌套或malloc使用,需要调整_Min_Stack_Size/_Min_Heap_Size
  • 需要把某些数据固化在固定地址:比如存储设备参数、产品序列号的扇区,需要在链接脚本里开辟独立命名段。

我见过不少新人在 App 里没改链接脚本,只把主程序main()函数改了,结果一上电就 HardFault,原因就是代码还在0x08000000,和 Bootloader 重叠了。这不是代码逻辑问题,是链接脚本没跟上需求。

1.3 工具链与启动文件的配合关系

一块芯片上电后,最终从哪里取第一条指令,是由硬件决定的。而这份"从哪取、取什么"的信息,是启动文件和链接脚本一起搭建出来的。在 GCC 工具链里,启动文件是汇编写的startup_stm32f411xe.s,链接脚本则是.ld后缀的文本文件。两者通过特定的符号名沟通:启动文件引用_estackReset_Handler__main_start,链接脚本负责定义或导出这些符号。下面两节,我先把 STM32F411 的内存和启动流程讲清楚,再带你写这份.ld文件。

2. 从复位到 main() 的启动全景:先搞清楚芯片怎么"睁眼"

2.1 Cortex-M4 复位后第一件事:读向量表

STM32F411 内核是 Cortex-M4,它的复位行为很有规律:CPU 复位释放后,硬件会自动从地址0x00000000读取初始栈指针MSP,再从0x00000004读取复位向量,也就是第一条指令的地址。注意,这里的0x00000000不一定是真实的 Flash 地址,它取决于 BOOT 引脚的配置。默认 BOOT0 拉低时,这片地址区域映射到主 Flash,也就是说实际数据存储在0x08000000,但 CPU 也能通过别名访问到。这就是为什么链接脚本必须把向量表放在 Flash 的最开头。

向量表本身就是一串 32 位字:第 0 个字是栈顶地址,第 1 个字是复位向量,后面依次是各类异常和中断的处理函数入口。物理上它必须从启动地址开始,并且每一项对应一个中断。如果这段没放对位置,CPU 取到的栈顶和复位地址就是垃圾值,程序必然跑飞。这也是我一直强调 KEEP 指令必须加在向量表段上的原因——具体在第三节展开。

2.2 哪些复位源会把芯片"拉回起跑线"

很多初学者看到"复位"两个字只想到复位按键,实际上嵌入式的复位源很多。F411 常见的复位源至少包括:

  • 上电复位:电源电压低于阈值时,内部 POR 电路强制复位。
  • 外部复位:NRST 引脚拉低触发,这个引脚内部有上拉,外部再接一个电容到地可以抗干扰。
  • 看门狗复位:独立看门狗或窗口看门狗超时产生复位。
  • 软件复位:通过SCB->AIRCR写入VECTKEYSYSRESETREQ触发。
  • 低功耗复位:从停止/待机模式唤醒时的复位。

不管哪类复位,CPU 都会重新走一遍向量表读取流程。这也是为什么调试单步时,最稳妥的做法是在Reset_Handler入口下断点,而不是在main()下断点——可以清晰看到系统初始化前后的状态变化。我之前踩过一个坑:程序里用了软件看门狗,但喂狗时机设置得不合理,板子表现就是"反复复位、偶尔能跑起来、偶尔又死",看起来像硬件问题,实际是复位源没梳理清楚。

2.3 Reset_Handler、SystemInit、__main 到底按什么顺序执行

有了上面基础,再来看 STM32F411 官方启动文件里Reset_Handler的典型结构(简化版):

Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP

它做的事很简单:先把数据段、BSS 段相关的初始化准备工作交给 C 库,但在此之前调用SystemInit,去配置 Flash 等待周期、时钟树、必要的电源选项。SystemInit是 STM32 标准外设库或 HAL 库提供的函数,不同芯片实现不同,但目标一致:让 CPU 在跳到高级语言代码之前,先把基础时钟环境准备好。

接着跳转__main。这里要提醒新手:__main不是 C 语言的main(),它是 C 运行库提供的入口函数,负责运行环境初始化。那__main到底做了什么?说白了三件事:把已经烧录在 Flash 里的.data段初始值搬运到 RAM 对应位置;把.bss段清零;初始化堆栈和标准库环境,最终才调用你的main()。这三件大事的地址信息,全部来自链接脚本导出的几个下划线符号。如果你用纯 GCC 的 crt0 而不是 ST 官方启动文件,入口可能叫_start,但原理类似。我得强调,本节说的这些名字不是玄学,它们直接影响你写的链接脚本能不能被"读懂"。

3. 手把手写链接脚本:MEMORY 与 SECTIONS 详解

3.1 第一步:用 MEMORY 命令画出芯片内存地图

链接脚本最常见的形式,就是从上往下依次写ENTRYMEMORYSECTIONS。先看内存部分:

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

ENTRY(Reset_Handler)是告诉链接器,程序的入口点是Reset_Handler这个符号。如果不写,GNU ld 默认找_start,但裸机工程里不一定有定义,链接会告警。实际项目中显式写出来最省心。

MEMORY定义了两块区域。FLASH的可读可执行属性rx很好理解——代码从这里读取执行。ORIGIN是起始地址,STM32F411 的主 Flash 从0x08000000开始,512KB 到0x08080000结束。RAM0x20000000开始,属性xrw表示可读可写可执行,理论上你可以让代码在 RAM 里跑,但实际很少这么干。

关于 RAM 大小这里要留意:F411 的 128KB SRAM 有些资料会拆成常规 SRAM 和 CCM RAM 两块,CCM 在另一个地址域。你在参考手册上看到的是整体 SRAM 大小,但链接脚本里写入的LENGTH必须对应你选的芯片并和编译器放置需求匹配。拿到不熟悉的型号,第一件事就是核对参考手册内存映射表,别盲信模板。对 F411 这类常规工程,CubeMX 默认给的值多数时候够用,但你想把全部 RAM 都用上,就可能需要为 CCM 单独划一个MEMORY区域。

3.2 第二步:用 SECTIONS 把段内容放到正确区域

这是整个链接脚本的核心,也是刚开始最容易看晕的部分。我用一份精简但独立的脚本来说明:

SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) *(.text*) . = ALIGN(4); } >FLASH .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } >FLASH .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 }

逐个看。.isr_vector是向量表,放在 Flash 最前面。它必须被KEEP()包住,因为链接器的垃圾回收机制--gc-sections默认会移除没有被引用到的段,而向量表通常没有任何代码显式引用它,不 KEEP 的话可能被整个丢掉。我实际就遇到过一次,某个工程为了减小体积开了垃圾回收,结果复位后直接跑飞,查了半天才发现向量表被链接器"收走"了,那段经历非常酸爽。

.text放编译出来的代码,*(.text*)这种写法能把所有目标文件里的.text变体都归进来,兼容不同编译选项生成的段名。.rodataconst修饰的只读数据,比如字符串字面量、查表常量,它们没必要复制到 RAM,直接从 Flash 读取就好,这也是裸机优化的基础:能放.rodata的别放.data

3.3 第三步:导出符号,让启动文件能找到"交接点"

链接脚本不只是排版工具,它还负责定义很多下划线符号。启动文件里搬运.data、清零.bss时,并不知道 RAM 具体地址,全靠这些符号。

.data段的写法是最讲究的。段内容声明在>RAM AT> FLASH,意思是运行地址在 RAM,但加载地址在 Flash。也就是说,程序烧录后.data的初始值是躺在 Flash 里的,等__main启动后要把它复制到 RAM。复制需要三个地址:源地址、目标地址、结束地址。脚本里_sdata_edata分别表示 RAM 中的目标地址,_sidata表示 Flash 里的源地址。

.data : { . = ALIGN(4); _sdata = .; ... _edata = .; } >RAM AT> FLASH _sidata = LOADADDR(.data);

LOADADDR(.data)返回的是.data段的加载地址,也就是它在 Flash 中的位置。启动代码看到_sidata_sdata_edata后,就知道执行一次memcpy把数据从 Flash 搬到 RAM。.bss段没有初始值,不需要从 Flash 拷贝,只导出_sbss_ebss,启动代码据此清零。很多教程讲到这里都一笔带过,但我建议你记住一个判断标准:看到AT>就是"运行时在右、程序里在左、下载到左"这个关系,理解了这个,链接脚本就通了八成。

4. 从 startup 汇编到 main():链接脚本与启动代码的深层协作

4.1 栈顶地址是怎么"传给"CPU 的

一个很容易忽略但极其关键的点:Cortex-M 系列没有"由代码设置初始栈指针"的过程,栈顶值是 CPU 复位后自动从向量表第一个字读出来的。而第一个字的来源,正是链接脚本导出的符号。常见做法:

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

这行定义放在MEMORY后面、SECTIONS前面。_estack被启动文件引用为向量表的首项。由于 RAM 地址从低到高增长,栈是递减生长方向,所以栈顶放在 RAM 最高地址是合理的,出栈时地址向下递减就不会踩到已分配的变量区域。注意,这要求你规划的堆和栈不要和.data.bss重叠,第四节会讲怎么在链接脚本里为它们预留空间。

如果做多段重定位或者 RTOS 工程,可能还会在启动代码里手动设置MRSMSR操作 PSP,但那属于更高级的话题。至少的标准工程里,_estack的赋值方式和向量表里的DCD _estack就是"链接脚本 + 启动汇编"的第一个交接点。

4.2 __main 的初始化动作与链接脚本的约定

这里展开讲__main的工作,因为它每个步骤都在"找"链接脚本导出的符号:

  1. 设置堆栈:__main会读取启动时从向量表拿到的栈顶,或重新初始化用户栈空间,栈大小对应脚本里_Min_Stack_Size
  2. 搬移 RW 数据:计算_sidata_edata的源,搬到_sdata起的目标区域。
  3. 清零 ZI 数据:从_sbss扫到_ebss,全部写 0。
  4. 调用库初始化:比如__libc_init_array,C++ 的全局构造函数就在这里被调用。
  5. 跳转main()

所以,如果你的链接脚本把_sdata_edata导出得不对,或者忘了给.bss段导出边界符号,程序的表现会非常诡异——全局变量初始值是乱的、const数组内容读出来不对、main()里第一次操作静态变量直接 HardFault。这些都是"链接脚本没有配合好启动文件"的典型症状,不是代码逻辑问题。

4.3 堆和栈:在链接脚本里"圈地"的正确姿势

.bss段完之后,通常还有一个特殊段:

._user_heap_stack : { . = ALIGN(8); PROVIDE ( end = . ); PROVIDE ( _end = . ); . = . + _Min_Heap_Size; . = . + _Min_Stack_Size; . = ALIGN(8); } >RAM

这是工程里常见的"预留堆栈段"。它本身不产生实际数据,只是用符号把内存边界划出来。_Min_Heap_Size_Min_Stack_Size是你在脚本顶部定义的值,常见0x2000x400,单位是字节。PROVIDE ( end = . )表示如果程序里没有定义end,链接器就用这个值;如果定义了,以程序里的为准。这对_sbrk等 C 库函数找堆起点很有用。

堆和栈的空间来自同一个"预留段",实际运行时它们相向生长:堆从低地址往高地址,栈从高地址往低地址。若两边都膨胀,最终会撞上。裸机工程里,我建议把_Min_Heap_Size设小一点,除非你明确知道哪些库函数用了mallocfree。因为 newlib 的printf浮点支持、strtok这类函数在某些配置下会偷偷申请堆内存。我吃过亏:堆设小了printf格式化数字直接异常,栈设小了中断嵌套一深就 HardFault。

4.4 一个简化版启动文件片段,把流程串起来

为了让协作关系更直观,看一个去掉所有外设中断后的启动文件核心片段:

AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD _estack DCD Reset_Handler __Vectors_End __Vectors_Size EQU __Vectors_End - __Vectors

这里DCD _estack就是把_estack符号的值作为第一个字写进向量表。链接完成后,0x08000000处是栈顶,0x08000004处是Reset_Handler的地址。CPU 一上电就按这个流程走,所以启动文件和链接脚本任何一个环节错位,都会表现在"从复位到 main"的路上。

5. 实际项目里最常踩的坑:常见问题与排查实录

5.1 复位后直接 HardFault,八成是向量表和启动入口问题

表现:烧录后程序能下载,但一运行就进 HardFault,或者看起来在空转。排查顺序:

  1. 用调试器看 PC 值卡在哪里。如果 PC 停在0x08000000附近且反汇编是乱码,往往向量表没生效。
  2. 确认isr_vector段是否被 KEEP。开了--gc-sections时特别容易出问题。
  3. 确认_estack是否指向有效 RAM。如果把LENGTH写太大,栈顶地址超出芯片实际 RAM 范围,一压栈就总线错误。
  4. 确认向量表首字和Reset_Handler地址是否匹配。用arm-none-eabi-objdump -s -j .isr_vector firmware.elf看内存内容。

另外,如果代码里用到浮点运算但启动文件没有使能 FPU,也会在进入 float 指令时 HardFault。F411 自带 FPU,但默认复位后 CPACR 可能没打开,纯汇编启动文件里需要手动设置寄存器。链接脚本本身不管这件事,但排查时要想到这一层。

5.2 region overflow:Flash 或 RAM 不够时怎么定位

初学者看到region FLASH overflowed by xxx bytes往往懵。这类报错说明你放不下所有段。要定位是谁占了空间,最直接的办法是看.map文件。链接完成后工程目录下会有 map 文件,包含每个段的起始地址、结束地址和大小。按size从大到小排序,通常能找到大头。

处理手段有几个:把编译器优化等级从-O0提到-Os,体积立竿见影;把大量只读表从.data移到.rodata,避免它们在 RAM 里占两份空间;如果 RAM 满了,看看是否能启用 CCM 区域,但要确认外设是否支持访问它。F411 的 CCM RAM 不能直接被 DMA 访问,这是个容易踩的坑,把 DMA 缓冲区放 CCM 后 DMA 就是不动。

5.3 修改链接脚本做 IAP 偏移,别忘了 VTOR

做 Bootloader + App 时,链接脚本第一行就要改:

FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 448K

但只改链接脚本远远不够。App 中断向量表仍会被放在新的起始地址,可是 CPU 复位后默认从0x08000000找向量表,那是 Bootloader 的地方。所以 App 里必须在main()最早处设置向量表偏移:

SCB->VTOR = 0x08008000;

如果不设,App 里任何中断触发都会去旧位置找向量,结果一片混乱。更隐蔽的是 Flash 扇区对齐问题:F411 的 Flash 扇区大小不一,前几个扇区是 16KB。如果你把 App 放在 16KB 扇区的中间而不是起始边界,擦除旧空间时可能把 App 自身擦掉。所以我做这类分区时,偏移量一般取扇形边界的整倍数,并且至少对齐 16KB。

5.4 用 size、objdump 和 readelf 检查最终产物

链接脚本写得对不对,最终看的不是"能编译过",而是"产物结构对不对"。我常用的检查命令:

arm-none-eabi-size build/firmware.elf arm-none-eabi-objdump -h build/firmware.elf arm-none-eabi-readelf -l build/firmware.elf

size输出 text/data/bss 三个数字,快速判断内存占用。objdump -h能看到每个段名字、LMA、VMA、大小,检查.data的 LMA 在 Flash、VMA 在 RAM,就是验证AT> FLASH写没写对的最直观方法。readelf -l能看到加载视图,确认哪些段真正需要烧录。我每次改动链接脚本后都会跑一遍objdump -h,花十秒钟省掉调半天 bug 的时间。

5.5 一个特殊问题:全局变量初始化看起来"丢了"

症状:上电后某个全局变量应该是非零初值,但实际是 0,改成局部变量或常量又好了。排查方向要回到.data段的三个符号上。如果_sidata_sdata被别的代码或链接脚本覆盖,或者你手动实现了自己的初始化函数却没有正确使用这些符号,__main拷贝时源地址就会出错。还有一种情况:你在链接脚本里新加了一个自定义段,比如放产品序列号的.serial,但它没有定义_sdata之类的边界,后续的段起始地址就错位了。这种问题最容易发生在"手工魔改链接脚本"之后,我建议每次改完都重新生成 map 文件,核对自定义段的 LMA/VMA 和相邻段,不要凭感觉。

写在最后的经验

链接脚本这个东西,说玄也玄,说不玄也不玄。它本质上就是一张内存施工图:告诉链接器芯片有什么资源、每段内容放哪里、把哪些专用符号导出来给启动代码用。只要抓住"向量表必须在 Flash 头、.data有运行地址和加载地址两套、.bss要清零、栈顶来自_estack"这几个核心点,大部分问题都能迎刃而解。

我个人实际操作的体会是:不要怕手工去写.ld,更不要全盘照抄别人的脚本。学习阶段最有效的办法,是打开 STM32CubeMX 生成的链接脚本,对照参考手册一行行读过去,每个>AT>都问一句"为什么要这么写",然后故意改错几个地方,看链接器报什么错、烧录后表现什么现象。踩过几次坑之后,你对"从复位到 main()"这条路的理解,会比背一百遍启动流程都深。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 17:44:13

TinyMCE 5.10.3 集成 PowerPaste 实战:解决 Word 粘贴排版错乱

简介:面向TinyMCE 5.10.3的PowerPaste插件资源包,专为需要从Word、Excel等文档复制内容至在线编辑器的开发者准备,解决粘贴后样式丢失、排版错乱等痛点。压缩包仅117KB,共5个文件,包含3个JavaScript脚本(主…

作者头像 李华
网站建设 2026/9/16 17:39:06

OptiScaler:换超分,中端卡拿到高端帧率

OptiScaler:换超分,中端卡拿到高端帧率 【免费下载链接】OptiScaler OptiScaler bridges upscaling/frame gen across GPUs. Supports DLSS2/XeSS/FSR2 inputs, replaces native upscalers, enables FSR-FG/XeFG on non-FG titles. Supports Nukem mod f…

作者头像 李华
网站建设 2026/9/16 17:38:47

WebView文字选择改造:ActionMode拦截与JSBridge交互实践

简介:面向Android开发者的一套开源源码包,针对WebView组件在网页浏览场景中文字选取范围受限、操作菜单单一的问题,给出了较为完整的增强方案。通过自定义选择器与JavaScript接口,开发者可实现连续或非连续文本的多选,…

作者头像 李华
网站建设 2026/9/16 17:37:42

三相异步电动机MATLAB仿真:从dq建模到SPWM调速

简介:这是一套面向电机控制与电力电子方向学习者的三相异步电动机Matlab/Simulink仿真资源,覆盖异步电机起动、制动、调速、发电机运行及不同坐标系下的仿真建模,适合正在学习电机拖动、从事变频调速研究或进行课程设计的新手与中级开发人员对…

作者头像 李华
网站建设 2026/9/16 17:37:21

猫抓提示媒体已加密?m3u8视频解密与key文件获取全攻略

用猫抓抓m3u8的时候,突然看到插件里弹出“该媒体已加密,请注意下载key文件”,很多人的第一反应是懵的:m3u8不是一个播放列表吗,怎么还有key文件?这玩意儿到底要不要下?下下来又有什么用&#xf…

作者头像 李华