STM32F411链接脚本精讲:从复位到main的底层启动机制
2026/9/16 9:40:28 网站建设 项目流程

STM32F411 用久了你就知道,真正让你卡的往往不是外设驱动,而是“程序怎么跑起来”这件事。IDE 点到 Run 那一刻,Flash 里被写进去的是什么,CPU 上电后第一条指令在哪,栈指针是谁给的,.data.bss段又是谁负责搬运的——这些全绕不开链接脚本。我见过不少工程师写了三四年固件,还在用 IDE 自动生成的.ld文件,一旦换芯片换工具链,或者想实现自定义 bootloader 分区,立马抓瞎。所以这次我以 STM32F411 为例子,从电路板复位那一个电平跳变开始,一路拆到main()入口,把链接脚本这份“导演脚本”给你彻底讲透。

这个内容适合所有想真正掌握 STM32 底层启动机制的嵌入式工程师,尤其是你准备自己做启动代码、做固件升级、或者想把项目从某款 IDE 迁到纯命令行工具链的时候,会很受益。下面我们直接从芯片复位那一刻开始。

1. 先搞明白:从复位到 main() 到底走了多少步

1.1 复位是一个“电平故事”,不是一句“从头开始”

很多人把复位理解成“程序重新执行一遍”,这个说法在概念上没错,但放到具体硬件上,它是个非常精确的电气过程。STM32F411 的 NRST 引脚拉低之后,内部会触发完整复位流程:CPU 停止当前指令,外设寄存器回到复位值,Flash 接口重新初始化,时钟系统切回 HSI。这些动作完成后,复位信号释放,内核才开始取值执行。

这里有个关键点容易被忽略:CPU 复位的时刻,并不知道你的程序在哪。它不像一台电脑开机会去硬盘某个固定扇区找引导程序,ARM Cortex-M 内核有自己的一套寻址约定——它要从“向量表”里取两个值:第一个值装入 MSP 主栈指针,第二个值装入 PC 程序计数器。向量表最开头这两个地址,构成了 CPU 上电后的第一口“饭”。

也就是说,对 Cortex-M 来说,复位 = 从 Flash 地址 0x08000000 读栈顶地址 + 从 0x08000004 读复位处理函数地址,然后跳过去执行。而这两个地址怎么放到正确的位置,就是链接脚本和启动文件共同完成的编排工作。

1.2 链接脚本在启动链条里的角色定位

如果把启动过程比作拍电影,启动文件是演员,硬件是摄影机,那链接脚本就是导演的剧本——它不负责执行具体动作,但它决定了每个“演员”站在哪个位置,内存这个大舞台怎么划分区域,哪些数据放前台(RAM),哪些放后台(Flash)。

举个直观例子:你在代码里写了一个全局变量uint32_t counter = 100;,这个100并不是烧录时直接写进 RAM 的。Flash 里存的是一份“初始值快照”,芯片上电后需要一段代码把这个100从 Flash 拷贝到 RAM 中对应的地址。链接脚本的任务就是告诉编译器:初始值快照放在 Flash 的哪个偏移,RAM 的目标地址在哪,这些信息以符号的形式暴露给启动代码使用。

所以你看,链接脚本虽然是一堆看起来很啰嗦的文本,但它是整个启动链条的“地理坐标系”。没有它,启动文件和硬件就完全对不上话。

2. 链接脚本的语法并不难,难的是搞懂每个关键字在“安排什么”

2.1 最小可用脚本长什么样,先建立整体印象

这里我先给一份针对 STM32F411 的最小链接脚本,让你有个整体概念。后面我再逐步拆解每一段的含义和取舍逻辑。

ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } _estack = ORIGIN(RAM) + LENGTH(RAM); 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 _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 }

这份脚本看起来短,但它完整地解决了三个核心问题:程序放哪、数据放哪、堆栈放哪。下面我把每个部分掰开揉碎讲。

2.2 ENTRY 和 MEMORY:入口符号与存储地图

ENTRY(Reset_Handler)告诉链接器,整个可执行文件的入口点是启动文件里定义的Reset_Handler函数。这句话在 IDE 自动生成的脚本里经常被省略,因为工具链可以默认从startup文件推断,但当你自己写脚本时建议显式写明,否则某些静态分析工具或者反汇编器会把入口标错位置。

MEMORY块定义的是芯片的“物理可寻址空间”。对 STM32F411 来说,Flash 从0x08000000开始,芯片型号不同容量不同——F411RE 是 512KB,F411CE 是 256KB;对应地,RAM 都是 128KB,起始地址0x20000000。需要注意的是,FLASH 后标注的(rx)表示可读可执行,RAM 的(rwx)表示可读可写可执行。ARM 架构对 RAM 执行没有硬性禁止,但你在实际工程里不应该在 RAM 里跑代码,除非你是在做特殊优化或者 bootloader 搬运。

2.3 SECTIONS 的布局思想:三类数据三种命运

SECTIONS块是整个脚本的核心,它把编译产物(.o文件里的段)映射到 MEMORY 定义的存储区域。

.isr_vector段放的是中断向量表。它有两大特殊之处:一是必须放在 Flash 的最开头(地址0x08000000),这是硬件规定;二是必须用KEEP()包裹,否则链接器会发现向量表“没被任何代码引用”,优化掉不给你放进去,程序一上电就跑飞。我当年第一次自己写脚本就吃过这个亏,烧录后代码死活不跑,反汇编一看 Flash 开头全是 0xFF。

.text段放你写的所有代码。用*(.text)*(.text*)两个通配符组合,是为了同时接住没被编译优化的普通函数和带有子后缀的特殊函数(比如isr中断处理函数经过编译后可能叫xxx.text.HardFault_Handler)。这部分内容要按 4 字节对齐,这是 ARM 架构对指令对齐的最低要求,虽然 M4 支持非对齐访问,但保持对齐对 Flash 读取和缓存效率都有好处。

.rodata段放只读常量。你可能觉得奇怪,为什么不直接合并到.text里?合并完全可以,但分开的好处是你能在链接脚本里单独控制它的位置,比如未来想实现 IAP 升级,把只读常量单独放一个固定区域,校验起来更方便。日常工程合并不合并都不影响功能。

3. 真正决定“数据段命运”的三个符号:_sdata、_edata、_sidata

3.1 数据段为什么要搬移,初始值快照从哪来

嵌入式 C 语言程序的全局变量和静态变量,在编译期间就确定了它们在 RAM 里的虚拟地址。但芯片上电那一刻,RAM 里的内容是随机的——可能是上电残留,也可能是完全不可预知的噪声。所以你必须把 Flash 里保存的“初始值快照”拷贝到 RAM 里去。

问题是,怎么知道初始值快照放在哪、要拷贝多长?这就是_sdata_edata_sidata这三个符号的用途。

看脚本中的这段:

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

.data段后面跟的>RAM AT> FLASH,是链接脚本里最精妙也最容易被忽视的语法。意思说:这个段的运行时地址(VMA)在 RAM 里,但它的加载地址(LMA)在 Flash 里。LOADADDR(.data)就是取这个段的 LMA——也就是“初始值快照存放的 Flash 位置”。

整个流程串起来就是:

  • 芯片上电后,SP 指针指向_estack
  • Reset_Handler 先做时钟初始化
  • 然后执行一段拷贝循环:把 Flash 地址_sidata开始的 N 个字节,拷到 RAM 地址_sdata开始的地方
  • 拷贝长度由_edata - _sdata计算得出

3.2 BSS 段的清零是谁做的

BSS 段存放的是未初始化或被显式初始化为 0 的全局变量。它不占 Flash 空间,因为在 RAM 里直接清零就行,没必要在 Flash 里存一份全零的镜像。

.bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } >RAM

这里有个经常被忽略的符号*(COMMON)COMMON是老式编译器存放未初始化全局变量的段,GCC 默认情况下某些未初始化的全局变量也会被放进 COMMON 块。不写这行,你可能遇到“变量地址重复分配”或者某些变量莫名其妙出现覆盖的诡异问题。写上去之后,所有无处安放的全局变量都会被规划进 BSS 段统一清零,行为就确定多了。

3.3 栈顶地址为什么是 RAM 末尾

脚本里有一句_estack = ORIGIN(RAM) + LENGTH(RAM);,这就是栈顶的地址。Cortex-M 的栈是满递减模式:SP 指针指向栈顶(也就是最后一个被压入的数据),入栈时 SP 先自减再写入数据。所以把栈顶设置在 RAM 最高地址,栈向下生长,就会拥有最大的延伸空间,不会轻易和从低地址向上生长的堆、全局变量区撞车。

有人问,为什么栈顶不直接用0x20020000这个固定数值,非要写ORIGIN(RAM) + LENGTH(RAM)?我的习惯是永远用表达式,因为这样当你在不同型号芯片之间移植时,只改 MEMORY 块,栈顶自动跟着变,不容易手滑写错。

4. 从启动文件的视角,看链接脚本如何“被消费”

4.1 Reset_Handler 的完整生命历程

启动文件里的Reset_Handler是 CPU 复位后真正执行的第一段代码。它要做的事情按顺序是这样:

  1. _estack地址加载到 SP 寄存器
  2. 调用SystemInit()配置时钟和 Flash 等待周期
  3. 搬运.data段、清零.bss
  4. 调用__main(C 库入口,完成 C 运行时环境初始化)
  5. 跳转到main()

有人会问,入口向量表不是已经给 SP 赋过值了吗,为什么 Reset_Handler 里还要再设置一次 SP?原因是:复位时从向量表加载 SP 只是硬件层面的“首次装载”,而 Reset_Handler 里再设置一次,是为了确保即使有人从 bootloader 跳转过来、或者软件复位后执行环境异常的极端情况下,栈指针也是明确有效的。而且__main里面可能会根据堆配置调整栈区域,提前设好栈顶是个保险动作。

4.2 __main 和 main 是两个东西

这是一个特别容易混淆的点。GCC 工具链环境下,C 库的入口函数叫__main,它不是你的main函数。__main做的事情包括:初始化堆(heap)、调用__scatterload完成运行时拷贝(如果你用了 ARM 编译器的 scatter 文件,这里还会更复杂)、调用__rt_entry进入 C 程序环境,最后才调用你的main()

所以你在链接脚本里看到的.data搬运逻辑,其实有两条实现路径:一是你明确在启动文件里手写搬运循环(很多裸机工程师这么干),二是依赖 C 库的__main去自动完成。两种方案各有优劣——手写搬运,代码可控、不依赖 C 库运行环境;依赖 C 库,需要链接时包含标准启动对象,好处是省事,坏处是一旦你做了剪裁优化,某些库行为可能和你预想不一致。

我自己在写 bootloader 和需要精确掌控内存布局的工程时,倾向手写搬运:一个for循环拷数据、一个for循环清零,然后用BL main直接跳进主函数,连__main都不碰。这样做的好处是我完全知道每一步执行了什么,调试时不用猜库函数行为。

4.3 向量表的绝对定位是由链接脚本保证的

启动文件里向量表是这样写的:

.section .isr_vector .align 2 .globl __isr_vector __isr_vector: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler ...

这些.word指令在汇编阶段生成了一个个 4 字节长的常量——注意,_estackReset_Handler的值在汇编阶段并不确定,需要链接器来填。链接器什么时候填?就是它处理到.isr_vector这个段,并且看到_estackReset_Handler这两个符号引用时。

这个机制解释了为什么向量表必须用KEEP()固定下来。如果链接器认为这个段没有被引用,它可能直接不放进去,那么 Flash 从0x08000000开始就是空的,上电后硬件读到的“栈顶地址”和“复位地址”就全是0xFFFFFFFF,程序直接 HardFault。KEEP()就是给链接器下死命令:这个部分不管有没有人引用,都必须给我留在最终镜像里。

5. 实操:写一份适用于 STM32F411 的完整脚本并验证

5.1 按需调整:芯片容量和外设地址映射

STM32F411 的 Flash 容量根据具体型号不同而不同。STM32F411RE标称 512KB,但在工程上我们常把 LENGTH 写成512K,系统会保留最后的 16KB 作为系统存储器(用于出厂 bootloader),不过这部分并不影响你链接脚本的 Flash 描述,因为你的代码烧录进主 Flash 区。

RAM 方面,F411 是 128KB,起始地址固定是0x20000000。你需要留意的是:F411 还有一个 4KB 的 backup SRAM,那部分在 VBAT 域,地址在0x40024000附近,通常不会在常规链接脚本里出现。如果你要做低功耗待机数据保持,那就是另外一套玩法,别和主 SRAM 混在一起。

5.2 在脚本中添加堆区支持

C 库的mallocprintf系列函数用到一个运行时堆(heap)。链接脚本通常要留出一段内存并导出堆的边界符号:

_heap_start = .; . = ALIGN(8); . = . + 4K; /* 预留4KB堆空间 */ _heap_end = .;

这样,C 库的_sbrk函数(GCC 环境下)就能通过_heap_start_heap_end来动态分配内存。堆的大小取决于你的业务场景——如果你大量用printf做日志、用 JSON 库解析数据和消息队列,4KB 可能不够;如果只是一个几个开关量的控制器,1KB 都嫌多。我见过最离谱的工程是把整个剩下来的 RAM 全给了堆,结果栈区被挤到边缘,一旦函数调深一层就栈溢出,问题极其隐蔽。

实际做法建议:先按 RAM 总量的四分之一预留堆,跑一段时间监控堆的使用峰值,再动态调整。调试阶段可以写一个堆水位统计函数,把当前_sbrk分配到的最高地址算出来,通过串口打印出来,你就知道实际堆消耗是多少了。

5.3 链接脚本里的 ALIGN 到底在对齐什么

你会在很多段定义里看到. = ALIGN(4);,这句的意思是把当前地址向上对齐到 4 字节边界。为什么设计成 4 字节?这是由两个因素决定的:一是 ARM 指令定长 4 字节,函数入口不 4 字节对齐的话,某些设备(如 Flash 预取缓冲区)对边界敏感的优化会失效;二是 C 语言中 32 位变量的自然对齐要求,如果一个uint32_t变量被放在非 4 字节对齐的地址上,M4 内核虽然能处理,但会多出额外的访问周期。

如果你对性能有极致要求,可以按 8 字节或 16 字节对齐——这对缓存行友好、对 DMA 传输也友好,代价是中间可能空出几个填充字节,浪费一点点 Flash/RAM。

5.4 用 size 和 objdump 验证脚本的正确性

写完脚本后,第一时间不是烧录调试,而是做两个快速的静态检查。命令行下用编译器编译链接完,执行:

arm-none-eabi-size build/firmware.elf

你会看到类似这样的输出:

text data bss dec hex filename 12344 1080 4040 17464 4438 build/firmware.elf

text是代码和只读数据总和,data是需要在启动时拷贝的已初始化数据,bss是启动时要清零的变量区。这三个数字能帮你快速判断内存预估是否合理。

然后执行:

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

重点检查.isr_vector段的 VMA 是否是0x08000000,LMA 是否是0x08000000(因为向量表不需要搬移);检查.data段的 VMA 是0x20000000开头的某个地址,而 LMA 是0x08000000附近的某个 Flash 地址。这种“VMA 和 LMA 不同”的情况,恰恰说明链接脚本的AT>生效了,启动代码能正确地找到初始值快照并搬运到 RAM。

5.5 反汇编确认第一条指令

再进一步,把启动文件汇编部分反汇编出来:

arm-none-eabi-objdump -d build/firmware.elf | grep -A 20 "<Reset_Handler>:"

你应该看到的第一条指令是向 SP 寄存器写入_estack(一般编译成ldr sp, [pc, #xx]然后ldr pc, [pc, #xx]这种位置无关取值方式),随后跳转到SystemInit和后续拷贝逻辑。这就能确认你的向量表、栈地址、复位入口全部正确连接上了。

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

6.1 烧录后程序完全没跑,怎么定位

症状:下载完成后,按下复位,电流纹丝不动,程序没有任何反应。排查优先级:

  1. 先用objdump -h确认.isr_vector在 Flash 开头,且第一个单词不是0xFFFFFFFF
  2. 确认.text段 LMA 在 Flash 范围之内,如果链接脚本里 TEXT 段不小心指定到 RAM,下载后地址对不上,CPU 取值直接 HardFault
  3. 确认启动文件里.section .isr_vector的段名和链接脚本里的通配符*(.isr_vector)对得上,名字差一个字符都匹配不上
  4. 在线调试时查看 PC 指针停在哪个地址,如果停在0x08000000可能是向量表问题,停在 HardFault_Handler 可能是时钟初始化配置错误或栈溢出

6.2 全局变量初始值不对或无故变化

这是.data段没正确拷贝或者.bss段没正确清零的典型表现。程序能跑,但一个全局变量counter = 100,读出来却是 0 或者随机值。排查时重点看三点:

  1. 启动代码里拷贝源地址是不是用的_sidata而不是_sdata
  2. _sdata_edata之间的长度计算对不对
  3. 链接脚本里.data段的AT>指定的是不是 FLASH

可以故意在启动代码里加一个调试语句,把_sidata_sdata_edata这三个符号的地址通过串口打出来。这三个值只要符合“Flash 区低地址、RAM 区中间、RAM 区中间偏后”的规律,通常问题不大。

6.3 中断不触发或者触发后跑飞

中断向量表的问题多半出在两个环节:一是向量表没对齐到 128 字节边界。Cortex-M 有一个寄存器VTOR用来设置向量表地址,硬件要求向量表必须按 128 字节对齐(有的内核放宽到 32 字节,但保险起见用 128)。如果你的向量表因为某个前面插入了其他数据导致地址偏移,那就完蛋了。

二是.isr_vector段没有被放到 Flash 起始位置。链接器在分配段时,默认按脚本里的顺序来放,如果你在 SECTIONS 里把.text写在.isr_vector前面,那么你的指令就会占据 Flash 最开头,向量表被挤到后面去,硬件根本无法找到正确的复位入口。所以.isr_vector必须是 SECTIONS 里的第一段。

6.4 链接时报错“region FLASH overflowed”

这是最常见也最好解决的问题。含义是:你的代码和只读数据总和超过了 512KB(对 F411RE)。不过,有时候未必是你代码真的写多了,可能是链接脚本把某些大段重复分配了。举个例子,如果你把*(.rodata*)同时放在.text和数据区域,或者在某些支持“自定义段”的库函数中把大数组放进了 Flash 却没规划好位置,Flash 占用会虚高。

arm-none-eabi-nm --size-sort firmware.elf按符号大小排序,找出来哪些符号占的空间最大。如果最大值是一个 100KB 的const数组,你要考虑是否真的需要把这个常量保存在 Flash——可以改成运行时动态生成,或者用压缩算法存起来,用的时候解压。

6.5 HardFault 是启动阶段最常见的“终点站”

如果在SystemInit或者数据搬运阶段触发了 HardFault,最常见的原因有两个。第一,栈溢出:_estack设置错误,或者栈空间预留某段函数调用链太深,在 C 库初始化阶段压栈时就触碰了非法地址。第二,访问了未使能时钟的外设:很多人在SystemInit里直接操作 GPIO 或别的总线外设,但此时 RCC 里对应外设的时钟还没打开,写寄存器就会触发总线错误。

这种问题定位方法比较简单:在 HardFault_Handler 里读取CFSR(可配置故障状态寄存器)和HFSR(硬故障状态寄存器),配合LR寄存器的值,能推算出是从哪条指令跳进来的。再把 PC 指向的地址用addr2line反解析出具体是哪个 C 文件哪一行。

7. 几条实用的经验技巧

经验一:链接脚本里所有导出的符号前加下划线前缀是业内惯例,比如_sdata_estack,但这不是强制的。关键是这些符号在启动文件里要用extern声明,而且注意不要和 C 库内部符号重名,否则链接时可能出现“符号冲突”的报警。

经验二:如果使用 STM32CubeIDE 或者 Keil,尽量理解它自动生成的.ld文件再动手改。很多人直接网上抄一份脚本,结果把 GCC 的__libc_init_array机制破坏掉,全局构造函数和静态对象的初始化全废了,IDE 里还不报错,只有到运行期才暴露。

经验三:无论什么处理器平台,独立写链接脚本时务必准备一份可用的参考脚本。开源项目cortex-m-startup或 ST 官方例程的.ld文件都是不错的起点,但一定要一行一行读懂再改动,理解每一句的后果,然后再往自己工程迁移。

经验四:版本管理时把.ld文件和启动文件一起纳入 review。这两份文件改动造成的问题往往非常隐蔽,而且大部分静态检查工具不覆盖链接脚本。我见过修改 Flash 起始地址导致 bootloader 直接覆盖应用程序头部、系统变砖的事故,所以每次改动前后最好都做一次反汇编对比,确认关键段的位置没有意外漂移。

写链接脚本这件事,在我个人体会里,最大的门槛不是语法,而是你究竟理不理解“单片机程序是如何被装载运行的”。一旦你亲手把这份脚本从空文件写到烧录成功,很多讲不清的底层问题——为什么全局变量有初值、为什么局部变量不能太大、为什么中断表要放在开头——都会跟着通透。希望这次的拆解,能让你少走我当年走过的那些弯路。

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

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

立即咨询