资讯动态

STM32链接脚本详解:从变量地址到OTA分区与RAM优化

发布时间:2026/9/19 10:56:09 来源:尧图企业网站定制
1. 从一次掉电丢数据说起为什么变量需要“地址”很多人第一次接触STM32这类MCU的时候都会有一个很自然的疑问我写了一个全局变量程序跑起来它就在那儿读写都正常可一旦断电再上电这个变量的值就没了回到初始值甚至变成随机数。更奇怪的是有些变量断电后居然还在比如你烧录进去的常量表、字库、配置参数。同样是“变量”凭什么有的能扛住断电有的不行答案其实不在C代码里而在一个大多数人平时根本不会打开的文件——.ld链接脚本。这个文件决定了你的代码和数据最终被放到芯片的哪个物理区域是放在掉电就丢的RAM里还是放在掉电不丢的Flash里还是放在两者之间的某个“搬运中转站”。你写的每一行C代码编译之后都会变成一段段数据而.ld文件就是这些数据的“户口本”规定谁住哪儿、从哪儿搬到哪儿、搬家的时候谁先谁后。这篇文章就是围绕这个核心问题展开的。我会从链接脚本的基本结构讲起把STM32的Flash和RAM布局、.data/.bss/.rodata这些段的来龙去脉、启动文件里的搬运逻辑、以及实际项目中怎么改.ld来做RAM优化和OTA分区全部拆开讲清楚。适合已经会点灯、会写GPIO、但一看到.ld就头大的嵌入式开发者也适合想搞清楚“变量到底住在哪”的进阶学习者。看完你至少能做到自己看懂一个STM32的链接脚本知道改哪一行会影响什么遇到RAM不够或者Flash分区需求时不再瞎猜。2. 链接脚本到底在干什么把C代码翻译成物理地址2.1 编译器和链接器的分工先理清楚要理解.ld得先知道它在整个工具链里的位置。你写的.c文件经过编译器处理变成.o目标文件这时候每个函数和变量还只是“符号”地址是未知的编译器只负责语法和局部优化。真正决定“这个变量放在0x20000000还是0x08001000”的是链接器。链接器读取所有.o文件按照.ld脚本里定义的规则把各个段section拼装到具体的地址区间里最后生成.elf和.bin/.hex。所以.ld文件本质是一份“内存地图装配说明书”。它告诉链接器芯片有哪些可用的内存区域、每个区域的起始地址和大小、哪些段应该放进哪个区域、以及段与段之间的对齐和顺序。STM32的官方固件库、HAL库、CubeMX生成的工程里都会带一个默认的.ld通常叫STM32F103C8Tx_FLASH.ld这种名字很多人从来没打开过它但它一直在默默决定你程序的生死。2.2 MEMORY和SECTIONS链接脚本的两大块一个典型的STM32链接脚本结构上就两大块。第一块是MEMORY声明芯片的物理内存区域。以STM32F103C8T6为例典型写法是这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }这里FLASH的rx表示可读可执行RAM的xrw表示可执行可读可写。ORIGIN是起始地址LENGTH是长度。这两个数字不是随便写的它们来自芯片的数据手册。F103C8T6的Flash是64KBSRAM是20KB写错了链接器要么报溢出要么生成一个跑不起来的固件。第二块是SECTIONS这是重头戏。它把输入段映射到输出段并指定放到哪个MEMORY区域。最常见的几个段.text代码段放机器指令通常在Flash里。.rodata只读数据比如const修饰的数组、字符串常量也在Flash里。.data已初始化的全局/静态变量初值存在Flash运行时搬到RAM。.bss未初始化或初始化为0的全局/静态变量不占Flash空间运行时在RAM里清零。.stack和.heap栈和堆也在RAM里。理解这几个段的归属是理解“断电后变量为什么还在”的关键。.text和.rodata天然在Flash断电不丢.data和.bss在RAM断电就丢。但.data有个特殊之处它的初值必须存在Flash里否则上电后RAM里是空的变量就没有初值了。这就引出了启动时的“搬运”过程。2.3 为什么要有“加载地址”和“运行地址”两个概念这是链接脚本里最容易被忽略、但最重要的一个设计。.data段的变量运行时必须在RAM里因为要可读可写。但它的初值比如int g_count 100;里的100在断电后必须保留所以初值必须存在Flash里。于是链接器给.data段分配了两个地址**加载地址LMALoad Memory Address**在Flash**运行地址VMAVirtual Memory Address**在RAM。上电后启动文件startup_stm32f103xb.s里的复位处理程序会执行一段搬运代码把.data段从Flash的LMA复制到RAM的VMA然后把.bss段在RAM里清零。这段代码通常是用汇编写的核心逻辑就是根据链接脚本里生成的_sidata、_sdata、_edata、_sbss、_ebss这些符号来操作。这些符号不是C语言里定义的而是链接脚本通过.位置计数器和PROVIDE导出的。所以“断电了变量还在”这个说法要分两种情况如果你说的是.data变量的初值它确实“在”但存在Flash里上电后被搬进RAM如果你说的是运行过程中修改过的值那它只在RAM里断电就没了。想让运行时的值也保留得手动写Flash或者用备份寄存器/外部存储这是另一个话题。3. STM32的Flash和RAM布局从0x08000000说起3.1 Flash的物理结构和访问特性STM32的Flash不是RAM那种可以按字节随意写的存储器。它的写入有硬性约束必须先擦除整个扇区或页擦除后位变成1写入只能把1变成0。这意味着你不能像改RAM变量那样直接g_count 200;就完事得走“解锁-擦除-编程-上锁”的流程。这也是为什么.data的初值只能“烧录时写一次”运行时修改必须走专门的Flash操作函数。Flash的起始地址在STM32上通常是0x08000000这是芯片设计决定的不是链接脚本随便定的。中断向量表默认放在这个地址的最前面因为Cortex-M内核复位后会从0x08000000取第一个字作为初始栈指针第二个字作为复位向量。所以链接脚本里.text段必须从Flash起始地址开始且向量表必须放在最前面这就是为什么.isr_vector段总是排在SECTIONS的第一位。3.2 RAM的分布和栈的生长方向STM32的SRAM起始地址通常是0x20000000。Cortex-M的栈是“满递减”的也就是栈指针从高地址往低地址生长。链接脚本里通常把栈顶设在RAM的最高地址比如20KB RAM就是0x20000000 0x5000 0x20005000。栈往下生长堆往上生长中间是.data和.bss。如果栈溢出会直接踩到.bss或者堆这种bug非常隐蔽因为不会立刻报错而是某个变量莫名其妙被改了。这里有个实操经验很多人RAM不够的时候第一反应是加大栈但其实.bss里可能有一大堆没用的缓冲区。我见过一个项目RAM用了18KB其中8KB是一个从没被调用的调试缓冲区。把那个缓冲区删掉或者改小比调栈有用得多。链接脚本生成的.map文件在Keil里叫.map在GCC里也是.map会列出每个符号占了多少空间这是排查RAM占用的第一手资料。3.3 不同芯片系列的布局差异F103、F407、H743这些系列的Flash和RAM布局差别很大。F103C8T6是64K Flash 20K RAMF407ZGT6是1M Flash 192K RAM还有CCM RAMH743更是有多个RAM区域DTCM、AXI SRAM、SRAM1/2/3/4。链接脚本必须针对具体芯片写不能通用。CubeMX生成工程时会根据你选的芯片自动生成对应的.ld但如果你手动移植工程这一步很容易出错。比如F407有CCM RAMCore Coupled Memory地址在0x10000000它只能被CPU访问不能被DMA访问。如果你把DMA缓冲区放到CCM里DMA会直接失败。这时候链接脚本里就得单独声明一个CCM区域并把特定的段放进去。这种细节在数据手册的“Memory mapping”章节里有详细说明做RAM优化时必须对着看。4. 启动文件里的搬运逻辑上电后发生了什么4.1 复位处理程序的执行顺序上电或复位后Cortex-M内核从0x08000000取初始栈指针从0x08000004取复位向量跳转到Reset_Handler。这个函数在启动文件里通常做四件事设置栈指针其实硬件已经做了、调用SystemInit配置时钟、搬运.data、清零.bss最后跳转到main。搬运.data的汇编代码大概长这样以GCC为例ldr r0, _sdata ldr r1, _edata ldr r2, _sidata movs r3, #0 b LoopCopyDataInit CopyDataInit: ldr r4, [r2, r3] str r4, [r0, r3] adds r3, r3, #4 LoopCopyDataInit: adds r4, r0, r3 cmp r4, r1 bcc CopyDataInit逻辑很直白从_sidataFlash里的加载地址逐个字复制到_sdata到_edataRAM里的运行地址。_sidata、_sdata、_edata这些符号就是链接脚本里通过.位置计数器导出的。如果你改了链接脚本里.data的排列这些符号的值会跟着变搬运逻辑不用改这就是链接脚本和启动文件配合的精妙之处。4.2 .bss清零为什么不能省.bss段在Flash里不占空间因为它的初值全是0没必要存一堆0。但上电后RAM里的内容是随机的尤其是冷启动所以必须在搬运完.data之后把.bss区域全部清零。如果不清零那些“未初始化”的全局变量就会是随机值程序行为完全不可预测。我踩过一次坑某个标志位变量没初始化在调试器里复位后恰好是0程序正常但断电冷启动后变成1直接进了错误分支。查了半天才发现是启动文件被裁剪过.bss清零那段被删了。4.3 链接脚本里那些“看不见”的符号除了_sidata这些链接脚本还会导出_estack栈顶、_Min_Heap_Size、_Min_Stack_Size等。_estack在启动文件的向量表里被引用作为初始栈指针。堆和栈的大小检查也是在链接时完成的如果.bss加上堆栈超过了RAM大小链接器会报错。这个检查机制很有用但前提是链接脚本里的LENGTH写对了。如果LENGTH写大了链接器不报错但运行时栈会溢出到不存在的内存直接HardFault。5. 实战改链接脚本做RAM优化和OTA分区5.1 用.map文件定位RAM大户RAM不够的时候别急着换芯片先看.map文件。以GCC为例编译后会生成.map里面有一段“Memory Configuration”和“Linker script and memory map”。找到.bss段按大小排序通常前几名就是罪魁祸首。常见的大户有大数组缓冲区、RTOS的任务栈、文件系统的缓存、LCD的显存。我做过一个项目RAM只剩2KB看.map发现一个uint8_t file_buf[4096]从来没被用过删掉直接省出4KB。如果确实需要大缓冲区可以考虑放到外部SRAM或者用内存池动态分配。但动态分配在MCU上要小心碎片问题嵌入式里更推荐静态分配加内存池。5.2 把常量搬到Flashconst的正确用法很多人不知道const修饰的全局数组默认会放在.rodata也就是Flash里不占RAM。但如果你写成const uint8_t table[] {...};放在函数内部它可能被放到栈上取决于编译器优化反而占RAM。正确的做法是加static const确保它进.rodata。我见过一个字库项目把字库数组定义在函数里结果RAM直接爆了改成static const之后RAM省了十几KB。还有一种情况初始化时需要的配置表运行时只读。这种表如果放在.data里既占Flash存初值又占RAM运行时副本纯属浪费。改成const之后只占Flash一举两得。5.3 OTA分区把Flash切成两块做OTA升级的时候链接脚本的作用就更明显了。通常需要把Flash分成Bootloader区、App A区、App B区。Bootloader的链接脚本里Flash起始地址还是0x08000000但长度只给Bootloader自己用。App的链接脚本里ORIGIN要改成App区的起始地址比如0x08008000LENGTH改成App区的大小。这里有个关键点中断向量表的偏移。Cortex-M的中断向量表默认在0x08000000App如果不在这个地址必须调用SCB-VTOR 0x08008000;把向量表重定位否则中断会跳到Bootloader的向量表里直接跑飞。这个操作通常在SystemInit之后、main之前做。链接脚本里还要确保.isr_vector段放在App区的开头这样VTOR指向的地址才是对的。5.4 一个可复现的链接脚本修改示例假设你用STM32F103C8T6想把一个1KB的缓冲区从RAM搬到Flash只读同时把栈从1KB加到2KB。原始链接脚本里RAM是20K栈顶在0x20005000。修改步骤第一步把缓冲区改成static const uint8_t buf[1024] {...};这样它自动进.rodata不占RAM。第二步在链接脚本的_Min_Stack_Size那里把0x400改成0x800。这个符号通常在SECTIONS之前定义_Min_Stack_Size 0x800;第三步重新编译看.map确认.bss减少了1KB栈空间增加了1KB。如果链接报错说RAM溢出说明改得太多得再优化别的地方。这个流程我走过很多次核心就是“先看.map再改.ld再验证”。不要凭感觉改每次改完都要看.map确认效果。6. 常见问题与排查技巧实录6.1 链接报错“region RAM overflowed”怎么查这个错误说明.data .bss heap stack超过了RAM的LENGTH。排查顺序先看.map里.bss最大的几个符号看能不能改成const或者减小再看堆栈设置是不是过大最后看有没有重复定义的全局数组。有时候是两个.c文件里定义了同名的全局数组链接器把它们合并了导致RAM翻倍。用nm命令或者.map里的符号表可以查出来。6.2 程序跑飞但编译没问题栈溢出的隐蔽性栈溢出是嵌入式里最恶心的bug之一。编译链接都正常运行起来偶尔HardFault或者某个变量莫名其妙变了。排查方法在链接脚本里把栈区域用特定模式填充比如0xDEADBEEF运行一段时间后检查栈底附近的值有没有被改写。或者用调试器看栈指针有没有超出预期范围。更简单的办法是在.map里看栈顶地址和.bss结束地址之间的间隙如果间隙很小说明栈很可能踩到.bss了。6.3 Flash下载失败和链接脚本的关系“error: flash download failed - target dll has been cancelled”这个报错很常见但通常和链接脚本无关而是调试器配置、芯片读保护、或者Flash算法没选对。不过有一种情况确实和链接脚本有关如果你把Flash的ORIGIN改错了比如改成了0x08010000但芯片实际只有64KB Flash那下载算法会去访问不存在的地址直接失败。所以改链接脚本之后如果下载失败先检查ORIGIN LENGTH有没有超出芯片实际范围。6.4 常见问题速查表现象可能原因排查方法断电后变量值丢失变量在RAM的.data/.bss正常现象需写Flash或备份寄存器上电后变量初值不对.data搬运失败或.bss未清零检查启动文件搬运代码和链接脚本符号RAM溢出报错.bss过大或堆栈设置过大看.map文件优化大数组中断跳转到错误地址向量表未重定位检查VTOR设置和.isr_vector位置下载失败Flash地址超出范围检查ORIGINLENGTH是否超出芯片容量栈溢出导致随机崩溃栈太小或大数组在栈上加大栈把大数组改成static/const6.5 几个我踩过的坑第一个坑在Keil里手动改.sctKeil的分散加载文件作用类似.ld之后忘了同步改启动文件里的栈顶符号结果栈顶指向了一个错误地址程序一上电就HardFault。后来养成习惯改链接脚本必看启动文件。第二个坑把一个大数组定义成const之后以为它一定在Flash结果编译器优化把它放到了栈上因为函数内const数组可能被当作局部变量。加static之后才稳定进.rodata。第三个坑做OTA时App的链接脚本改了ORIGIN但忘了改VTOR结果App能跑但一进中断就跳回Bootloader。这个bug查了一整天最后用调试器看VTOR寄存器才发现是0x08000000而不是0x08008000。7. 把链接脚本当成项目的一部分来管理很多人把.ld当成“自动生成、不用管”的文件但实际项目里它值得被纳入版本管理并且加上注释。比如在MEMORY区域旁边注明“根据F103C8T6数据手册Flash 64KRAM 20K”在OTA分区的地方注明“App A区起始0x08008000大小48K”。这样下次别人接手或者自己过几个月再看能立刻明白为什么这么写。另外不同编译器的链接脚本格式不一样。GCC用.ldKeil用.sctIAR用.icf。移植工程的时候这三个文件要对应转换不能直接复制。我一般会在项目里同时保留GCC和Keil两份用宏或者构建脚本切换。CubeMX生成的工程默认用GCC的.ld如果你用Keil打开它会用.sct两者要保证内存布局一致否则会出现“GCC编译正常Keil下载跑飞”的情况。最后分享一个实用技巧在链接脚本里用ASSERT做编译期检查。比如ASSERT(_edata _sdata 0x5000, RAM overflow)这样一旦RAM超了编译直接报错比运行时HardFault好排查得多。这个技巧在大型项目里特别有用能提前拦住很多内存问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价