ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F411链接脚本实战:从复位向量表到Bootloader偏移

STM32F411链接脚本实战:从复位向量表到Bootloader偏移 写链接脚本这事儿说难不算难说简单也真不简单。我最早用STM32的时候.ld文件对我来说就是个摆设IDE一点编译全搞定压根不需要关心。直到后来做Bootloader加App的Ymodem固件升级App要跑到0x08010000去我改了链接脚本之后程序直接HardFault才老老实实回头把链接脚本从头到尾啃了一遍。这篇文章就用STM32F411当例子把从复位向量表到.data段搬运、栈顶地址分配这些细节完整捋一遍适合那些被启动文件折磨过或者想自己写Bootloader的嵌入式工程师收藏。1. 链接脚本到底管了什么先把“复位到main()”的路径画清楚1.1 上电复位后CPU执行的第一条指令很多人觉得单片机一上电就直接跑main了其实不是。以Cortex-M4内核的STM32F411为例硬件复位之后CPU会按照ARM架构的规定先去固定地址取数据地址0x00000000存的应该是栈顶地址地址0x00000004存的应该是复位处理函数的入口地址。这两个地址合在一起组成了中断向量表的最前面两项。这里要插一句STM32F411没有内存映射的那种零等待SRAM它的Flash起始地址是0x08000000。芯片内部有固定的物理映射系统上电后0x08000000会被映射到启动区的0x00000000所以你在Flash里写的向量表实际上就是从0x08000000开始的。上电复位电路让NRST引脚保持低电平一段时间等时钟稳定后释放CPU从Flash开头拿到栈顶指针再从0x08000004拿到Reset_Handler地址一跳就进去了。Reset_Handler里通常会调用SystemInit做时钟初始化然后拷贝.data段、清零.bss段最后才跳到main。这一段汇编代码很多工程直接从STM32CubeIDE生成的启动文件里抄很多人写了好几年代码也不知道这些数据拷贝的地址从哪来。其实那些地址符号全是链接脚本导出的。1.2 链接脚本是启动流程的“总平面图”打个比方链接脚本就像装修时的总平面图。它告诉链接器芯片的Flash有多大、RAM有多长、代码段放在哪里、变量放哪里、栈顶在哪、堆从哪开始。没有这张图编译器生成的机器码根本不知道0x20000000是内存还是外设更不知道0x08000000是Flash起始地址。链接脚本主要做三件事用MEMORY命令描述芯片的物理内存。比如STM32F411的512KB Flash、128KB RAM分别在哪个地址范围。用SECTIONS命令把输入段.isr_vector、.text、.data、.bss等安排到合适的输出段并指定在内存中的分布。通过位置计数器生成一系列符号比如_estack、_sdata、_edata、_sbss、_ebss启动汇编代码就是靠这些符号完成数据搬运的。如果你的工程从来没有手动改过链接脚本那你看到的_estack这些符号多半是从IDE默认的链接脚本里生成的。可一旦你想从0x08000000挪到别的地址跑App或者想自定义RAM分区不懂链接脚本就寸步难行。1.3 与main有关的一个小插曲说到这儿我忍不住想吐槽一下。网上有不少搜索词带“main”字点进去一看全不是那么回事。比如有人搜“编译器未包含main类型”或者“main函数参数”其实链接阶段最常报的错是undefined reference to main这通常不是你没写main而是启动文件里的跳转标签写错了或者链接脚本把包含main的.text段给丢弃了。更搞笑的是搜“error: src refspec main does not match any”这种Git报错也能混进来那是代码仓库分支名不叫main的问题跟单片机链接八竿子打不着。遇到问题先看完整报错别被搜索词带偏。2. 读懂GNU ld语法中的三个关键命令2.1 MEMORY告诉链接器芯片上有什么链接脚本最核心的就是MEMORY块。STM32F411的Flash起始地址是0x08000000大小根据具体型号不同常见的有256KB到512KBRAM统一从0x20000000开始一般是128KB。于是最简单的MEMORY块长这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K }rx表示Flash是可读可执行rwx表示RAM可读可写可执行。属性有没有对错有但一般情况下只要不是特别离谱链接器不会强制你改。真正关键的是ORIGIN和LENGTH一个定起点一个定容量写错一个整个工程的地址布局就全乱了。如果你在做一个Bootloader加App的工程App的Flash起点就不再是0x08000000而是要留出Bootloader的大小。比如Bootloader占用64KBApp的ORIGIN就应该是0x08000000 0x10000也就是0x08010000。这一点后面单独细说。2.2 SECTIONS决定每个段去哪、带什么行李SECTIONS块是链接脚本的主体。它定义了目标文件里的各种段最终怎么摆放。STM32启动文件里至少会有这些段.isr_vector中断向量表必须放在Flash最开头。.text代码段包含所有函数指令。.rodata只读常量一般会跟着代码段放Flash。.data已初始化全局变量/静态变量。初值存放在Flash运行时要拷贝到RAM。.bss未初始化或零初始化的变量运行时要清零。在SECTIONS里可以对这些段做精细控制比如SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text*) *(.rodata*) . ALIGN(4); _etext .; } FLASH }一定要注意KEEP。链接器在优化时可能会把看起来没被引用的节丢弃中断向量表是启动时通过硬件机制访问的普通符号引用体现不出来所以必须用KEEP强制保留。很多初学者把向量表丢了就是因为少了这句。2.3 符号导出让启动文件能找到段的首尾链接脚本里除了安排地址还可以定义变量符号。比如在.data段的开头和结尾各定义一个符号.data : { . ALIGN(4); _sdata .; *(.data*) . ALIGN(4); _edata .; } RAM AT FLASH这里的_sdata就等于.data段在RAM里的起始地址_edata就是结束地址。启动文件里的汇编代码会这样用它们ldr r0, _sdata ldr r1, _edata ldr r2, _etext copy_loop: ldr r3, [r2], #4 str r3, [r0], #4 cmp r0, r1 bcc copy_loop_etext是存在Flash里的初始值区域的起始地址因为.data段的初始值被放在了Flash里AT FLASH后面讲所以拷贝要从Flash读往RAM写。这三个符号少一个启动代码就编不过或者拷出来的数据是错的。3. 手写STM32F411链接脚本从空白文件到能跑起来3.1 第一步确认F411的内存资源在动手之前先把芯片手册翻出来。F411系列最常见的是STM32F411RE主频100MHzCortex-M4F内核带FPUFlash有512KBSRAM有128KB。不同型号可能有差异比如F411CE的Flash是512KBSRAM还是128KB但F411CC的Flash可能会小一些。链接脚本里的容量必须和实际芯片对应写大了链接器不报错但运行时会踩空。有个小坑F411系列没有CCM RAM像F4系列的有些型号有64KB CCM所以RAM就老老实实从0x20000000开始一共128KB别去幻想还有什么隐藏内存。很多从F407转过来的人会惯性找CCMF411真没有。3.2 第二步把中断向量表钉在0x08000000中断向量表必须放在Flash的起始位置这是Cortex-M的硬性规定。启动文件比如startup_stm32f411xe.s开头会这么写.section .isr_vector, a, %progbits .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler ...注意第一项_estack它在链接脚本里被定义为RAM末尾。这个值和链接脚本是绑定的。如果链接脚本没定义_estack这里直接编译报错如果你把_estack定义错了位置程序上电第一脚就踩进非法地址。链接脚本里这样安排.isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH这样向量表就被放在FLASH的起始地址也就是0x08000000。如果ORIGIN改成了0x08010000向量表就自动放到0x08010000。3.3 第三步让.data坐上“火车”运行时落位RAMmain里全局变量的初始值是从哪来的是编译器烧到Flash里的。比如你定义了int count 100;运行时这个count必须在RAM里不然没法修改但RAM掉电就丢所以初值必须先放在Flash里启动后在拷贝到RAM。链接脚本里用AT FLASH实现这个。 RAM定义了运行时地址VMAAT FLASH表示加载地址LMA在Flash。这两者可以不一样灵活性就在这里。.data : { . ALIGN(4); _sdata .; *(.data*) . ALIGN(4); _edata .; } RAM AT FLASH_etext这个符号的赋值一般在.text段末尾. ALIGN(4); _etext .;启动代码就会用_etext指向_sdata的初始值源地址。如果你忘了写AT FLASH链接器会把.data的初始值直接安排在RAM里而Flash里根本没有这段数据上电后RAM里的初始值是不确定的全局变量就直接乱掉。这种问题非常隐蔽编译不报错跑起来全错。3.4 第四步BSS清零和堆栈位置.bss段放未初始化的全局变量和静态变量。它们不需要初值但需要清零否则上电拿到的是随机数据。链接脚本里给出首尾符号.bss : { . ALIGN(4); _sbss .; *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM启动代码通过_sbss到_ebss这段地址一个字节一个字节或者一个字一个字地清零。C库的__main函数也会做类似的事但你如果用标准启动文件就无所谓关键是符号得对。栈顶地址_estack的定义非常直接_estack ORIGIN(RAM) LENGTH(RAM);也就是说栈顶就在最高地址栈向下生长。如果你还想单独划分堆可以定义_heap_start _ebss 0x1000; _heap_end ORIGIN(RAM) LENGTH(RAM) - 0x400;但这只是给C库malloc用的约定不同C库字段名不完全一样。强烈建议嵌入式项目尽量别用动态内存这里不多展开。3.5 完整示例脚本与启动文件配合整理一份工程上能直接用的stm32f411_flash.ldENTRY(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*) *(.rodata*) *(.glue_7t) *(.glue_7) . ALIGN(4); _etext .; } FLASH .ARM.extab : { *(.ARM.extab*) } FLASH .ARM : { __exidx_start .; *(.ARM.exidx*) __exidx_end .; } FLASH .data : { . ALIGN(4); _sdata .; *(.data*) . ALIGN(4); _edata .; } RAM AT FLASH .bss : { . ALIGN(4); _sbss .; *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM . ALIGN(4); end .; }ENTRY(Reset_Handler)不是必须的因为向量表已经指定了入口。但写上能避免一些工具链在某些极端情况下把入口搞错。这里有个小细节end .是很多C库堆的结束符号有些移植好的工程会用到加上无妨。把这个链接脚本放到工程里配合startup_stm32f411xe.s和system_stm32f411xe.c编译通过后可以用下面命令快速验证关键符号arm-none-eabi-nm firmware.elf | grep -E (_estack|_sdata|_edata|_sbss|_ebss|_etext)如果输出里这些地址都落在0x20000000附近或者0x08000000附近基本没问题。4. Bootloader场景下链接脚本要改三处地方4.1 为什么升级固件时链接脚本比代码更重要很多项目要支持Ymodem固件升级Bootloader放在0x08000000起始的Flash区域App放在后面的区域。Bootloader跑起来后用Ymodem协议收新固件写完Flash之后跳到App执行。这个过程里App的链接脚本如果还是从0x08000000开始Bootloader跳过去之后执行的还是Bootloader自己的代码或者向量表直接错乱系统马上死掉。所以App工程的链接脚本必须明确告诉链接器我的Flash起点不在0x08000000而在偏移之后的位置。4.2 App地址偏移改ORIGIN和VECT_TAB_OFFSET假设Bootloader占用64KB也就是0x10000字节那么App的MEMORY块应该这样写FLASH (rx) : ORIGIN 0x08010000, LENGTH 512K - 0x10000 RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K光改这个还不够中断向量表必须跟着变。如果向量表还在0x08000000但代码在0x08010000CPU复位后取的向量还是Bootloader的直接乱套。所以App的链接脚本里.isr_vector段会自然被放到ORIGIN 0x08010000处。与此同时代码里需要给SCB-VTOR设置偏移SCB-VTOR 0x08010000;在STM32HAL库里通常在SystemInit函数里根据宏VECT_TAB_OFFSET设置#define VECT_TAB_OFFSET 0x10000 SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET;这个偏移必须和链接脚本里的Flash起点偏移一致。如果你想在运行时通过别的方式动态设置也行但很多Bootloader跳转前会把中断向量表重新指向App这里面有一个很常见的坑Bootloader跳转时没关全局中断或者没把向量表偏移改过来导致App一进中断就跑飞。可以在跳转前执行__disable_irq(); SysTick-CTRL 0; SCB-VTOR APP_START_ADDR; __set_MSP(*(volatile uint32_t *)APP_START_ADDR); ((void (*)(void))*(volatile uint32_t *)(APP_START_ADDR 4))();这里_estack是App向量表里第一个字也就是App自己定义的栈顶。不同App如果RAM规划不同跳转前重新设MSP是稳妥做法。4.3 Ymodem升级与跳转时需要注意的坑Ymodem升级本身和链接脚本关系不大但一旦Bootloader把固件写入到App的Flash区域App链接脚本里的ORIGIN直接决定这个区域对不对。常见问题有几个ORIGIN没偏移App还是从0x08000000链接Bootloader把App写到0x08020000跳过去就是跑一个链接地址全错的程序。VECT_TAB_OFFSET没改成和ORIGIN一致CPU虽然能跑到main但一进中断就死在半路。向量表里第一项_estack指向的栈顶超出了RAM实际大小写入App后被Bootloader的异常处理给踩了。App的FlashLENGTH没减掉Bootloader区域链接器可能把代码塞到Bootloader占用的Flash里升级时把Bootloader覆盖掉也是一大坑。我自己在调Ymodem升级时最实用的手段是用arm-none-eabi-objdump -h看App的elf里各个段的地址确认.isr_vector、.text、.data的运行地址和加载地址都符合预期。不要等到跑起来才猜链接完看一眼就知道对不对。5. 实践排错链接脚本相关的几个经典翻车现场5.1 复位向量表丢了程序跑飞进HardFault症状上电后调试器定位不到mainPC跑到奇奇怪怪的地址或者直接进HardFault。看反汇编发现0x08000000地址的内容不对甚至全是0xFFFFFFFF。常见原因链接脚本里没有KEEP(*(.isr_vector))链接器把向量表当垃圾段丢了。MEMORY里的ORIGIN被改成别的值向量表跑到另一个地址去了。启动文件里的.section .isr_vector名字被改掉链接脚本匹配不上。排查方法arm-none-eabi-objdump -s -j .isr_vector firmware.elf看0x08000000开头是不是先有一个RAM地址栈顶比如0x2001FFFC然后紧跟一个Flash地址Reset_Handler比如0x08000123末位应为奇数表示Thumb模式。如果都是全F基本就是向量表没放进去。5.2 .data段没拷贝全局变量神秘变0症状工程编译正常烧录后全局变量读出来是0但用调试器看Flash那个地址明明存着正确的初值。排查思路检查_sdata、_edata、_etext三个符号的值。_etext应该落在Flash区域_sdata应该落在RAM区域。如果_etext等于_sdata说明初始值源地址直接指向RAM那Flash里的数据压根没连上。再检查启动代码里拷贝的源地址用的是不是_etext。有时候编译器的启动文件里用的是__data_start__、__data_end__这种符号和链接脚本不一致也会出问题。两个办法要么改链接脚本对齐这些符号名要么改启动文件。5.3 堆栈不够malloc一把梭后疯狂HardFault链接脚本里的_estack ORIGIN(RAM) LENGTH(RAM)看起来很合理但它只保证了你把RAM最高地址当栈顶。如果程序里用了大数组、递归栈会往下增长直到和.bss或堆撞上。链接脚本不会主动检测栈溢出也没有“栈段”这种概念所以很多人以为把_estack调高就很安全其实调高没用因为已经是最高了。经验做法在HardFault_Handler里先看LR和SP然后读MSP或者PSP对比栈指针有没有越过_ebss或堆顶。如果栈指针比堆顶还低十有八九是栈溢出了。更粗暴的办法是启动文件里在栈区填满固定魔数跑一段时间后检查栈区末尾的魔数有没有被改写。5.4 用map文件和nm命令核对结果平时我改完链接脚本第一件事不是烧录而是看map文件。在IDE里编译后生成的.map文件会详细列出每个输出段的VMA、LMA、大小。重点核对.isr_vector的VMA是不是Flash的ORIGIN。.text的VMA应该在Flash区域LMA和VMA一致。.data的VMA在RAMLMA在Flash两个地址不一样才是正常。.bss的VMA在RAM。_estack在RAM末尾。命令行核对可以用arm-none-eabi-nm firmware.elf | sort或者arm-none-eabi-readelf -S firmware.elf看输出段属性特别是有没有把.data段的加载地址标成RAM。真碰到这种情况十有八九是忘了写AT FLASH。另外链接脚本语法很死板一点小错误就会报乱码一样的错。比如漏了分号链接器可能给你报一段“unexpected token”之类的东西别怕从MEMORY块开始逐行看。还有ORIGIN和LENGTH关键字不能拼错符号后面不能带空格。我见过不止一次把AT FLASH写成AT FLASH有些工具链能忍有些不能忍最好统一不加空格。链接脚本这个东西看起来只是几行地址分配但它决定了你整个固件在芯片上是如何“立起来”的。从复位向量表到栈顶从.data段拷贝到Bootloader跳转每一个环节都离不开它。我之前一直觉得IDE自动生成的链接脚本够用直到被Ymodem升级和地址偏移折腾过几回才真正意识到自己亲手写一遍链接脚本是最快理解单片机启动过程的路线。如果你也想把F411的大脑彻底弄明白建议拿个最小工程删掉默认的.ld文件照着这篇文章的示例自己敲一遍然后改改地址看现象比看十篇文档都管用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进