写嵌入式的人大概都有过这个经历:在 Keil 里点一下 Build 就下载跑起来,从来没想过编译器背后把代码“放”到了哪里;直到某一天换成 GCC 工具链,第一次打开.ld文件,满屏的 MEMORY、SECTIONS、ALIGN 看得一头雾水。我第一次用 STM32F411 从零搭 GCC 工程时,就栽在这份链接脚本上——程序烧进去完全不跑,PC 指针停在 0x08000000 附近不动,最后才发现是向量表没有被放到 Flash 开头。这让我彻底明白了一件事:链接脚本不是“编译器自动生成、与我无关”的配置,而是从复位到 main() 这条路上最关键的底层契约。
这篇文章就以 STM32F411 为例,把从 CPU 上电、复位向量取出、到 C 语言 main() 真正执行之间的每一个环节讲透,重点放在如何手写一份可用的链接脚本,以及我在实际调试中踩过的几个坑。内容不深奥,但足够扎实,适合刚转到 GCC 工具链的人、从 IDE 转过来的嵌入式工程师、以及那些想把启动流程彻底弄明白的同学。
1. 为什么链接脚本是一切的起点
1.1 被 IDE“藏起来”的幕后工作者
在 MDK、IAR 这类集成开发环境里,链接脚本通常是自动生成、自动匹配的,你只需要选择芯片型号,编译器就帮你把 Flash、RAM 的布局算好了。这不是没有代价——代价就是你不会去思考自己写的代码到底被安排在了哪里。换到 GCC 工具链之后,这一切都暴露出来了:你用的是arm-none-eabi-gcc,编译出来的是一个一个零散的.o目标文件,没有人告诉你这些文件该放到内存的什么位置、栈顶在哪里、堆开多大。如果没有一份正确的链接脚本,链接器只会报一堆让人摸不着头脑的错误。
链接脚本干的事情,说穿了就是三件事:决定代码段放在哪、决定变量段放在哪、决定栈和堆的位置和大小。对 STM32F411 来说,Flash 在 0x08000000,RAM 在 0x20000000,这个地址是硬件手册里写死的,链接脚本只是把这些信息翻译给链接器听。
1.2 你手里的“内存地图”与“搬家清单”
我习惯把链接脚本理解为两份东西的合体:一份是内存地图,告诉链接器哪里有可用空间、空间有多大、属性是只读还是可读写;另一份是搬家清单,告诉链接器哪些“块”(section)要放到地图的哪个位置。
C 语言源文件经过编译后,产生的不只是机器指令,还有各类带名字的数据块。.text是代码,.rodata是只读常量,.data是已初始化但需要写入 RAM 的变量,.bss是未初始化、启动时应该清零的变量。它们原本是各自独立的,链接脚本负责把它们按顺序铺进 Flash 和 RAM。如果这份清单写错了,程序能编译成功但运行必定跑飞,甚至根本编不过去。
1.3 从源头理解 .ld 文件的价值
有人可能觉得:“反正我用 Makefile 里写好的模板链接脚本就行,为什么要去读它?”我的体会是,如果你只是点点鼠标写写应用逻辑,确实可以不读;但只要你遇到过下面任何一个问题,就必须回去翻链接脚本:
- 程序编译通过,烧录后完全不运行,或者一上电就进 HardFault
- 想给固件做 OTA 升级,第二个 App 不知道该链接到哪个地址
- 想把一个大数组放到外部 RAM
- 想调整堆、栈的大小,却发现程序的崩溃问题可能和它有关
- 移植 RTOS 后任务栈总是不对劲
这些问题的根因,往往都能追溯到链接脚本上。
2. 复位向量表:从零地址到 Reset_Handler 的第一次跳转
2.1 复位不是“从头执行”,而是“查表跳转”
STM32F411 使用的是 Cortex-M4 内核,它的启动方式和很多教科书里讲的 51、AVR 完全不同。51 单片机上电后 PC 直接指向 0x0000,从那里开始执行第一条指令;而 Cortex-M 系列是一个“查表”的过程——CPU 上电后会从地址 0x00000000 取出一个数作为主栈指针(MSP)的初始值,再从地址 0x00000004 取出一个数作为复位向量,也就是 Reset_Handler 的地址,然后跳过去执行。
这个向量表是整个启动流程的起点。程序烧录进 STM32F411 的 Flash 后,由于 Flash 的首地址 0x08000000 会被 Cortex-M 内核映射到地址 0x00000000,所以向量表必须出现在 Flash 的最前面。因此,链接脚本里第一段放的一定是.isr_vector段。
2.2 向量表长什么样
向量表本质上是一串 4 字节对齐的函数指针数组。STM32F411 的启动文件startup_stm32f411xe.s里会定义这样一个表:
| 偏移 | 内容 | 说明 |
|---|---|---|
| 0x00 | __initial_sp | 复位后的主栈指针初始值,通常指向 RAM 末尾 |
| 0x04 | Reset_Handler | 复位后执行的第一段代码 |
| 0x08 | NMI_Handler | 不可屏蔽中断 |
| 0x0C | HardFault_Handler | 硬件错误中断 |
| 0x10 | MemManage_Handler | 内存管理错误 |
| 0x14 | BusFault_Handler | 总线错误 |
| 0x18 | UsageFault_Handler | 未定义指令/异常 |
| ... | ... | 其余外设中断按顺序排 |
第 0 项特别关键,它是栈指针的初始值。如果这里写错了,CPU 一复位就有问题;如果链接脚本没有把向量表放在 Flash 开头,CPU 查表时读到的就是一个随机数,后果不用多说。
2.3 复位的几类来源与“异步复位同步释放”
讲启动流程绕不开“复位”这件事。STM32F411 的复位来源大致分四类:上电复位(POR)、外部复位(NRST 引脚拉低)、看门狗复位(IWDG/WWDG)、软件复位(NVIC_SystemReset())。
这里值得多说一句“异步复位同步释放”。复位信号本身是异步的,但释放时如果恰好和时钟沿靠得很近,内部触发器可能出现亚稳态——也就是说 CPU 收到的复位信号是“犹犹豫豫”的,一会儿高一会儿低,最终状态不确定。芯片内部会把人眼看到的“复位消失”信号再同步到时钟域里,让内核从确定的、干净的复位状态开始运行。这个概念和链接脚本没有直接关系,但理解了它,你就知道为什么不能只靠外部 RC 复位电路就指望 CPU 每次都在同一个状态醒来。
回到地址上:无论哪种复位,CPU 都会回到向量表,重新取 MSP、重新取 PC,所以链接脚本对向量表的安排是绝对不能被破坏的。
2.4 BOOT 引脚决定“从哪个地址映射”
STM32F411 的 BOOT0 引脚决定复位后从哪个存储器启动。BOOT0 拉低,从主 Flash 启动,地址 0x08000000 被映射到 0x00000000;BOOT0 拉高,从系统存储器启动,那里是芯片出厂自带的 bootloader(可以通过串口等方式下载固件)。系统存储器模式下,向量表跑到 bootloader 所在的地址,而不是你写的 Flash 地址。这个知识在调试“程序不跑”时需要特别注意——很多时候不是链接脚本错了,而是 BOOT0 引脚接错。
3. MEMORY 与 SECTIONS:手写 STM32F411 链接脚本的核心骨架
3.1 先给一份可以直接用的脚本
下面是一份针对 STM32F411RE 的精简链接脚本,512KB Flash、128KB RAM,配合startup_stm32f411xe.s和 GCC 工具链可以正常工作。我建议你先复制到工程里跑通,再跟着后面的讲解一点点改。
/* stm32f411re.ld */ 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) . = ALIGN(4); } >FLASH .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } >FLASH _sidata = LOADADDR(.data); .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } >RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } >RAM .heap : { . = ALIGN(8); _sheap = .; . = . + _Min_Heap_Size; . = ALIGN(8); _eheap = .; } >RAM .stack : { . = ALIGN(8); _sstack = .; . = . + _Min_Stack_Size; . = ALIGN(8); } >RAM /DISCARD/ : { libc.a ( * ) libm.a ( * ) libgcc.a ( * ) } }3.2 ENTRY 与 MEMORY:先圈出地盘
第一行ENTRY(Reset_Handler)是告诉链接器,整个程序的入口符号是Reset_Handler。对 Cortex-M 来说,真正的入口其实由向量表里的第二项决定,ENTRY主要影响调试器对“程序入口”的判断,但写上是规范习惯。
MEMORY 命令是整张内存地图的框架:
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K表示 Flash 起始地址 0x08000000,大小为 512KB,属性是r(只读)和x(可执行)RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K表示 SRAM 起始地址 0x20000000,大小为 128KB,属性是xrw(可读可写可执行)
属性不是摆设。如果你试图把一个可执行代码段放进只有rw属性的区域,链接器会有意见;而把只读数据段放到 RAM 里,也不算合理的方案,只是不会报错而已。对于 F411 全系,Flash/RAM 地址都一样,区别只在容量:F411CE/RC/VE 这类后缀 C 的型号 Flash 是 256KB,后缀 E 的型号是 512KB,RAM 全部都是 128KB。所以换型号时,最常改的就是 MEMORY 里的 LENGTH。
3.3 SECTIONS:把段按顺序铺进地盘
SECTIONS 命令内部从上到下就是最终内存布局的顺序。对于 STM32F411 这种从 Flash 启动的 MCU,顺序通常是:
.isr_vector:向量表必须放在 Flash 偏移 0 的位置.text:所有代码.rodata:只读常量,如const修饰的全局变量、字符串字面量.data:已初始化全局变量,运行时在 RAM,初始值在 Flash.bss:未初始化/零初始化全局变量,运行时在 RAM.heap和.stack:堆与栈区域
每个段后面的>FLASH或>RAM表示这个段的 VMA(运行时地址)落在哪个内存区域。.data那行的特殊写法>RAM AT> FLASH在下一章细说。
约等于 C 语言里的“定义”:*(.text)表示把所有.o文件里的.text段收集起来,*(.text*)表示匹配所有以.text开头的段。链接器按顺序填充地址,前面一个段结束的位置就是后一个段开始的位置。
3.4 ALIGN 和 KEEP 这两个“小东西”不能省
ALIGN(4)表示把当前位置向上对齐到 4 字节边界。Cortex-M 的向量表要求 4 字节对齐,ARM 指令也不是 2 字节就是 4 字节对齐,所以每个段之间都补一句ALIGN(4)是非常稳妥的习惯。
KEEP(*(.isr_vector))的意思是:即使编译时使用了--gc-sections做垃圾回收,也要保留这个段。汇编启动文件里的向量表本质上只是一段数据,链接器不知道它有用,不强制保留的话,它很可能被当垃圾回收掉。这个坑我踩过,后面专门讲。
4. 从 Flash 到 RAM 的“搬家公司”:启动代码与 LMA/VMA 的配合
4.1 为什么 .data 非要“搬一次家”
这里要引入两个概念:VMA(Virtual Memory Address,运行时地址)和 LMA(Load Memory Address,加载地址)。
代码段.text在 Flash 里,CPU 直接从 Flash 取指令执行,所以它的 VMA 和 LMA 都是 Flash 地址,不需要搬家。但.data段不一样。
全局变量的特点是有初值、需要在运行时能读能写。Flash 掉电不丢数据,但只能读;RAM 可以随意读写,但掉电清零。这两者结合的结果就是:.data段的初始值必须躺在 Flash 里(LMA 在 Flash),但运行时要被搬运到 RAM 里(VMA 在 RAM)。链接脚本里这句:
.data : { ... } >RAM AT> FLASH>RAM是 VMA,AT> FLASH是 LMA。链接器会把初始值放在 Flash 的某个位置,同时把运行时地址安在 RAM 的某个位置。搬运任务交给启动代码。
用一个生活类比:Flash 是仓库,RAM 是工作台。你有一批乐高零件(已初始化变量),平时装在仓库箱子里(Flash),开工的时候要把箱子搬到工作台上拆开(RAM),才方便拼装。关灯下班后工作台上的零件会消失,但仓库里的箱子还在,第二天重新搬一次就行。
4.2 谁负责执行搬运?
在 ARMCC/Keil 的默认工程里,这个搬运由 C 运行时库的__main完成;在 GCC 工具链中,通常由启动汇编文件startup_stm32f411xe.s里的Reset_Handler完成。逻辑上等价于下面这段 C 代码:
extern uint32_t _sidata; extern uint32_t _sdata; extern uint32_t _edata; extern uint32_t _sbss; extern uint32_t _ebss; void Reset_Handler(void) { // 1. 从 Flash 拷贝 .data 初始值到 RAM uint32_t *src = &_sidata; uint32_t *dst = &_sdata; while (dst < &_edata) { *dst++ = *src++; } // 2. 将 .bss 段清零 for (dst = &_sbss; dst < &_ebss; dst++) { *dst = 0; } // 3. 进入 C 世界 main(); }注意到_sidata、_sdata、_edata这些符号并不是真正的 C 变量,它们只是链接脚本里定义出来的“地址标签”。C 代码里对它们取地址,得到的才是真实地址。链接脚本和启动文件就是靠这些符号“握手”的。
4.3 .bss 段为什么要清零
C 语言标准规定,未初始化的全局变量和静态变量的初始值应为 0。问题是这些变量没有初始值躺在 Flash 里,直接给它们在 RAM 里划一块空间装上即可,不需要 Flash 占地方。但 RAM 上电后的内容是随机垃圾,所以启动过程中必须把它们清零。
如果哪一天_sbss和_ebss符号对不上,或者启动代码里漏掉了清零循环,你会看到全局变量一开始是乱数,而且这个乱数是随编译环境变化而变化的,非常难查。
4.4 栈指针是谁给的
栈指针初始值来自向量表第一项__initial_sp。链接脚本里:
_estack = ORIGIN(RAM) + LENGTH(RAM);定义了一个符号_estack,值等于 RAM 的最末尾地址。启动汇编文件则把这个符号写进向量表第一项:
g_pfnVectors: .word _estack .word Reset_Handler ...也就是说,RAM 末尾是栈顶,生长方向向下。这个初始值由链接脚本给出,再由启动文件放进向量表,最后 CPU 复位时从向量表读出栈指针。一条链路贯穿下来,少了任何一环都不行。
5. 藏在链接脚本里的握手符号:_estack、PROVIDE、KEEP 与对齐规则
5.1 启动代码与链接脚本之间的“暗号”
链接脚本不只是给链接器看的内存地图,它还定义了一批特殊符号,专门供启动文件、库函数和用户代码引用。常见的有:
| 符号 | 含义 |
|---|---|
_estack | 栈顶地址,等于 RAM 最末尾 |
_Min_Stack_Size | 栈最小尺寸 |
_Min_Heap_Size | 堆最小尺寸 |
_sdata/_edata | .data 段的起始/结束地址 |
_sidata | .data 初始值在 Flash 里的起始地址,即 LMA |
_sbss/_ebss | .bss 段的起始/结束地址 |
_sheap/_eheap | 堆的起始/结束地址 |
这些符号看起来像变量,但你用 C 语言访问它们时,标准姿势是取地址:
extern uint32_t _estack; uint32_t stack_top = (uint32_t)&_estack;如果写成uint32_t stack_top = (uint32_t)_estack;,运行时会把“_estack 符号所在地址里存放的值”取出来,那完全是另一回事了。这是我见过很多初学者容易晕的一个点。
5.2 PROVIDE:默认值还是自定义值?
有些链接脚本里会用PROVIDE(_estack = ...)这样的写法。PROVIDE的含义是:如果在某个目标文件中已经定义了同名强符号,就使用目标文件里的;否则才用链接脚本里提供的这个值。
这样设计的好处是:基础脚本给出默认值,但你可以在某个 C/汇编文件中重新定义,实现“按需覆盖”。比如你在启动文件里手动给过一个_estack定义,且它足够满足应用,那么链接脚本里的PROVIDE就不生效。但大多数工程都不会去覆盖它,维持默认就好。
5.3 KEEP 为什么救了我一命
前面说过,KEEP(*(.isr_vector))是防止链接器在垃圾回收时删掉向量表。GCC 工具链中,如果开启了-ffunction-sections -fdata-sections配合--gc-sections,链接器会按 section 粒度剔除“没被引用”的段。向量表在这种机制下很容易被误伤。
我实际遇到的情况是:用arm-none-eabi-size看固件体积一切正常,烧录后程序一动不动,调试器里 PC 停在 0xFFFFFFFF 附近。后来用arm-none-eabi-objdump -s firmware.elf一看,文件开头根本不是向量表。补上KEEP之后,程序首次上电就跑起来了。那次之后我每次写链接脚本都会看一眼.isr_vector段有没有 KEEP。
5.4 对齐:4 字节与 8 字节的门道
处理器对对齐的容忍度各不相同。Cortex-M4 在硬件上支持非对齐访问,但外设寄存器、中断向量表这类特殊场景依旧要求严格对齐,优异地采用非对齐访问也会降低性能。链接脚本里给每个段都补ALIGN(4),就是为了让段与段之间不出现错位的“缝隙”,也让每个符号都落在 4 字节边界上。
栈和堆用ALIGN(8)是另一个原因:AAPCS(ARM 架构过程调用标准)要求在进行函数调用时,栈要满足 8 字节对齐,这样库函数中使用 LDRD、STRD、SIMD 等指令时不会触发对齐异常。把.stack段的起始地址用 8 字节对齐,再配合_estack为 RAM 末尾,就能保证“栈顶 8 字节对齐”这条约束被满足。
6. 我在这块板子上踩过的链接坑位与排查流程
6.1 坑位一:RAM 容量写小一截,程序随机崩溃
有段时间我怀疑 F411 的 RAM 不够用,把链接脚本里的LENGTH = 128K随手改成了96K,抱着“反正我用不到那么多内存”的心态继续开发。结果固件正常编译、正常下载,跑简单任务没事,一旦运行到某个大数组比较密集的逻辑就随机死机。
原因很简单:把 RAM 区域声明成 96K,并不意味着物理 RAM 只剩 96K,链接器只是把前 96K“分配”出去,后面的 32K 不在内存管理范围内。如果某个变量访问越过了所谓的“内存末端”,就是纯裸奔,读写行为完全不可预期。
排查方法:打开.map文件,查所有变量地址,如果发现有数组的地址扎在 0x20018000 附近(即 96K 边界),再去对照物理 RAM 大小,问题就浮现了。老实说,这类问题不一定是链接脚本写错,也可能是你估算的内存需求和实际差距太大,但无论如何先把容量写对。
6.2 坑位二:忘记 KEEP,向量表被垃圾回收
这是最坑的一个。加了-Os -ffunction-sections -fdata-sections --gc-sections之后,整个.isr_vector段被链接器当成垃圾删掉了。现象:程序烧录后复位无反应,调试器全 0xFFFFFFFF,单步都进不去。
排查方式:用arm-none-eabi-objdump -s firmware.elf看文件开头是否有完整的向量表,或者用arm-none-eabi-objdump -h看 ELF 中 section 是否存在。如果发现.isr_vector段不存在,十有八九是漏了KEEP。这是链接脚本和编译选项相互作用的结果,纯靠“看代码”很难发现,必须走工具链排查流程。
6.3 坑位三:.bss 段漏掉*(COMMON),全局变量带垃圾值
早期 GCC 工具链中,某些编译器选项下未初始化全局变量会被放进COMMON块,而不一定放进.bss。如果链接脚本里只写了*(.bss)和*(.bss*),就漏掉了COMMON,导致那部分全局变量在启动时没有被清零,程序一运行就是随机初值。
现在的主流 GCC 默认-fno-common,把常见变量都放进.bss,问题不太容易触发。但为了兼容不同工具链版本、不同库实现,标准链接脚本里一般都会写上*(COMMON)。这个习惯值得保留。
6.4 坑位四:链接脚本符号名与启动文件对不上
有时候你会遇到这种链接错误:undefined symbol: _sdata,或者反过来undefined symbol: __initial_sp。这通常不是链接脚本本身写错了,而是启动汇编文件的符号名和链接脚本不一致。不同版本工具链的启动文件可能使用不同的命名风格(比如带不带下划线),所以从网上复制来的.ld和startup文件必须配套使用,尽量用同一套模板。
6.5 排查流程:不要瞎猜,按步骤来
如果你也遇到了“上电不跑、复位之后没反应”的情况,我建议按这个顺序排查:
- 先用
arm-none-eabi-objdump -h firmware.elf查看所有 section 是否存在、VMA 和 LMA 是否合理 - 用
arm-none-eabi-objdump -s -j .isr_vector firmware.elf确认向量表确实在 Flash 开头,第一项是不是_estack的值 - 用
arm-none-eabi-nm firmware.elf | grep _sdata检查_sdata、_sbss、_estack这些符号是否都被定义 - 用
arm-none-eabi-size firmware.elf查看各段总大小,确认没有超出 Flash/RAM - 最后再上调试器,单步观察复位后 SP 和 PC 是否从向量表正确加载
这五步基本能定位绝大多数与链接脚本相关的启动异常问题。我把它们单列出来,是因为我在实战中发现,很多人一遇到“程序不跑”就习惯性怪芯片坏了、晶振没起、调试器没接好,结果折腾半天,问题往往就藏在一份不起眼的.ld文件里。工具链提供者不会替你背这个锅,链接器给的信息也够多了,只是要做的是静下心去读它。
6.6 链接脚本的“进阶改装”场景
明白基础之后,链接脚本就是你在嵌入式工程里“画地图”的笔。常见的高级玩法包括:
- 加外部 RAM:如果板子上外扩了 SDRAM/SRAM,在 MEMORY 里加一块区域,再新增一个 section 把它分配过去即可
- 改 Flash 偏移做 OTA 固件升级:做 YMODEM 这类串口升级方案时,App 固件通常要链接到
0x08020000这类偏移地址,把 MEMORY 中的 FLASH ORIGIN 改成对应偏移,同时注意设置 SCB->VTOR 重定位中断向量表 - 双区 A/B 升级:两份固件分别链接到不同 Flash 区域,靠 bootloader 决定从哪个区启动,链接脚本里给两个 App 分别准备一套 ORIGIN 参数
- 调整堆栈大小:跑复杂协议栈或 RTOS 时,需要扩大
_Min_Stack_Size和_Min_Heap_Size,直接把数值改大,再配合 map 文件确认不会撞到.bss段
这些改动的底层逻辑,都是“改内存地图”,而地图的骨架就是这篇文章里讲的 MEMORY 和 SECTIONS。把基础打牢,后面的扩展会顺手得多。
最后说一点自己的体会。我刚接触 GCC 工具链时,总觉得链接脚本是一堆晦涩的符号和花括号,能跑就行,不想管。直到有一次程序复位不跑,我耐着性子把链接脚本、启动文件、map 文件三者对着看了一遍,才发现问题不是出在“代码逻辑”,而是出在“代码不知道被放到了哪里”。从那以后,我看待.ld文件的视角就变了:它不是配置,也不是模板,而是 CPU、启动代码、C 运行时和你写的代码之间的一份契约。下次再有程序上电跑飞的情况,不妨先别急着怀疑硬件,打开 ELF 文件看一眼向量表还在不在——很多时候,答案就藏在那份你从未认真看过的链接脚本里。