尧图精选

嵌入式内存管理进阶:分散加载文件(Scatter File)实战解析

🕒 发布时间:2026/9/12 17:52:59 📁 来源:尧图网络
嵌入式开发越往后做越会发现一个躲不开的命题内存。很多人写单片机程序就是“定义变量、编译器自己安排地址、跑通完事”但一旦工程里出现Bootloader加App、需要外扩SDRAM、或者的RAM连一个数组都放不下的时候你就会发现默认的链接方式根本不堪用。这个时候分散加载文件Scatter File就是那个能让你对内存实现“像素级控制”的工具。这篇内容我结合自己实际项目中的使用经验把分散加载的原理、语法、配置方法和各种坑一次说清楚。1. 分散加载到底在解决什么问题聊分散加载之前先说说没有它会遇到什么麻烦。在我的一个项目里主控MCU是Cortex-M7内核芯片内部SRAM总共只有320KB但现场需要同时跑一个不小的图形界面缓存、两路音频流的DMA缓冲区还要保证Bootloader升级时能把App的固件完整缓存下来。这几个需求加在一起光靠编译器默认的“把所有变量都塞进片上SRAM”策略链接阶段直接就报区域溢出。分散加载文件解决的就是这种“多个内存区域、多段代码数据、多种运行需求”的分配问题。它允许你明确告诉链接器哪段代码放在Flash的哪个地址哪段数据加载到Flash的哪个位置但运行时拷贝到SRAM哪个大数组直接定义在外部SDRAM里哪个区域让给Bootloader使用。从本质上看嵌入式系统内存管理的核心矛盾是“容量不够”和“属性不同”。Flash容量大但访问慢、不能写变量SRAM速度快但容量小外部RAM容量大但挂在总线后面、延迟高。分散加载文件就是让你根据这些存储介质的特性把不同类型的代码和数据“安置”到最合适的位置。所以它不是“高手才用的炫技工具”而是每个涉及复杂内存分配的嵌入式工程师都绕不开的基础能力。搞懂它之后你不再和编译器“商量”变量放哪而是直接“通知”它这个必须放这里、那个必须放那里、这块区域只给谁用。2. Scatter文件的核心语法逐段拆解ARM编译器ARMCC、ARMCLANG以及通过AC6工具链使用的armlink/armclang用的分散加载描述文件是.sct后缀的文本文件。我以MDK中AC5编译器的格式为主AC6下语法基本一样只是有些关键字的行为略有差异。一个标准的分散加载文件长下面这样LR_IROM1 0x08000000 0x00100000 { ER_IROM1 0x08000000 0x00100000 { *(.text*) *(ROData) *(.constdata*) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }这段配置里有两个基础概念加载域Load Region以LR开头和执行域Execution Region以ER或RW开头。加载域描述的是程序烧录后在Flash里的分布情况执行域描述的是程序运行时代码和数据在内存地址空间里的分布情况。加载域的名字可以随便起但它后面的第一个地址和大小必须准确对应芯片Flash的实际起始地址和大小。比如STM32F103系列Flash是0x08000000起大小看你芯片型号STM32H750的Flash虽然实际只有128KB但你可以把外部QSPI Flash通过内存映射方式放到0x90000000这也是它特别适合跑大程序的原因之一。执行域的情况稍微复杂一点。举一个常见的例子含初始化数据的全局变量它们在Flash里有一个“加载地址”Load Address程序启动时需要被拷贝到SRAM里一个“运行地址”Runtime Address。分散加载描述文件会自动生成两个符号Load$$RW_IRAM1$$Base表示加载地址的起点Image$$RW_IRAM1$$Base表示运行地址的起点。启动代码里会用到这一对符号完成数据段搬运。关于ER_IROM1 0x08000000 0x00100000里两个参数的含义第一个是执行域的起始地址第二个是执行域的最大空间。如果你把RW_IRAM1改成RW_IRAM1 0x20000000 UNINIT就表示这块区域初始化时不清零适合存放重启后还要保留的数据比如复位计数或者备份数据块。通配符*和.ANY也有讲究。*(.text*)表示匹配所有输入段里名字含.text的段这是在Keil默认启动文件里最常用的写法。.ANY则是一个优先级较低的通配符它表示“只要前面明确指定的段放完剩下的无论叫什么名字都往我这里塞”。实际工程中用.ANY (RW ZI)来兜底“杂项变量”是非常稳妥的做法避免因为链接顺序调动导致变量溢出。我给你列一个我常用的、带外部内存的分散加载文件模板直接注释好了就能看懂; 加载域内部Flash从0x08000000开始大小512KB LR_IROM1 0x08000000 0x00080000 { ; 执行域1代码和只读数据放在Flash中原地执行 ER_IROM1 0x08000000 0x00080000 { *(.text*) *(ROData) *(.constdata*) } ; 执行域2内部SRAM可读写上电清零 RW_IRAM1 0x20000000 0x00020000 { .ANY (RW ZI) } ; 执行域3外部SDRAM通过FMC总线访问从0xC0000000开始大小8MB RW_SDRAM 0xC0000000 0x00800000 { .ANY (RW ZI) } }要注意执行域里如果放的是“运行时代码拷贝”还得配合LOAD_REGION相关的启动代码配置。很多人在外部SDRAM里放代码时失败就是因为只写了分散加载文件没有改启动代码导致SDRAM里的代码段根本没人去拷贝。3. 按实际场景改出一份可用的内存布局一个分散加载文件不是背下来的模板而是要根据芯片型号、外设资源、项目功能去设计。下面我从三种典型的应用场景入手分别给出配置思路和关键代码。3.1 场景一Bootloader与App分区隔离很多产品需要远程升级解决办法就是在Flash里划分出两个区域一个Boot区一个App区。Boot区代码负责启动自检、校验App区域的数据、必要时执行固件更新App区跑真正的业务逻辑。这种情况下Boot区和App区最好完全隔离开避免互相踩踏。Boot区的分散加载文件可以这样写LR_BOOT 0x08000000 0x00010000 { ER_BOOT 0x08000000 0x00010000 { *(.text*) *(ROData) *(.constdata*) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }这时Boot工程编译出来的镜像最大只有64KB烧录到0x08000000之后的64KB区间内。App区的分散加载文件则需要把起始地址改为0x08010000向量表地址、链接地址也要全部跟随改变LR_APP 0x08010000 0x00070000 { ER_APP 0x08010000 0x00070000 { *(.text*) *(ROData) *(.constdata*) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }不过这里有一个很容易踩的坑App工程编译时编译器生成的向量表默认放在0x08000000而不是0x08010000。你需要把启动文件里SystemInit之后执行的向量表重定位代码加上或者在main函数最早处写SCB-VTOR 0x08010000;并且链接分散加载文件里的ER_APP地址必须和这个VTOR值保持严格一致。否则中断一进来就跳到错误的地方现象就是程序能跑、但一触发中断就死机。当然更严谨的设计还会在App区域起始处放置自定义的固件头部结构体包含固件版本、CRC校验、固件大小、编译时间等。这样Boot程序就能通过读取这些信息判断是否允许跳转也方便做双备份升级。3.2 场景二大数组和帧缓冲放到外部SDRAM做GUI项目的时候最头疼的就是帧缓冲。一个800x480分辨率、ARGB8888格式的界面缓存一帧就要8004804 ≈ 1.5MB。片内SRAM根本装不下必须放到外部SDRAM里。前面的模板里我已经把外部SDRAM的区域命名为RW_SDRAM。接下来需要在C代码里通过__attribute__((section(RW_SDRAM)))让特定变量落在这个区域__attribute__((section(RW_SDRAM))) uint8_t frame_buffer[800*480*4]; __attribute__((section(RW_SDRAM))) uint16_t audio_pingpong[4096];如果你的编译器是AC6ARMCLANG建议用__attribute__((section(.bss.RW_SDRAM)))或直接定义section名。注意各家的语法略有差异AC5里用__attribute__((section(RW_SDRAM)))没问题但如果你用GCC的链接脚本就是另一种写法了后面我会专门提。这种做法的好处是编译器会自动为这个数组分配地址不用手动计算偏移。坏处是在系统启动时如果SDRAM控制器还没初始化执行域里这部分数据就没法访问。好在帧缓冲这类数据在启动早期通常不需要访问你可以把它们归入UNINIT属性避免启动代码在SDRAM里做清零操作时访问失败RW_SDRAM 0xC0000000 0x00800000 UNINIT { *(.bss.RW_SDRAM*) }UNINIT的意思很简单这块区域不自动清零、不拷贝初始值。对于缓冲区来说这很合理反正使用前你会自己填充。使用前只要确保SDRAM的GPIO、时钟、FMC控制器初始化函数在第一次访问帧缓冲之前被调用就行。3.3 场景三需要代码在RAM中运行的加速和升级场景有些场景要求部分代码放到RAM里执行。常见的原因有三种一是Flash读取速度慢高频中断处理函数放Flash可能导致响应延迟二是OTA升级时需要擦写Flash如果正在执行的代码所在的Flash扇区被擦除代码就飞了三是部分芯片支持在RAM中运行代码来降低功耗或规避总线冲突。把某个函数放到RAM里执行在分散加载文件里可以预留一小块执行域比如RW_RAMCODE 0x20070000 0x00008000 { *(.ramcode) }然后在C代码里这样标注__attribute__((section(.ramcode))) void Flash_WriteSector(uint32_t addr, uint32_t *data, uint32_t len) { // 擦写Flash的关键代码必须在RAM里跑 }启动时链接器会为.ramcode这个执行域生成Load地址和运行时地址启动代码会把这段代码从Flash拷贝到RAM里就像拷贝数据段一样。但这里有个容易忽略的细节拷贝代码段的操作是在__main里完成的如果在__main执行之前就需要调用这个RAM函数光靠分散加载文件供给的符号不够你得自己在Reset_Handler里手动拷贝。这属于比较高级的玩法我在做自定义Bootloader时用过一次当时把保持Flash擦写状态的恢复代码放在了SDRAM里因为SDRAM掉电不丢数据只要不断电不刷新控制器。因此建议你在设计RAM运行代码时尽量把这类函数做成“独立无依赖”的风格不要调用其他Flash里的全局函数也不要引用字符串常量否则分散加载文件里就要额外增加对这些只读数据的拷贝配置。4. 分散加载文件涉及到的链接器、启动文件与内存模型的联动细节很多工程加了分散加载文件之后编译不过或者运行异常原因不是scatter文件语法有问题而是它牵扯到的“周边生态”没配好。我按自己的理解拆开讲。4.1 启动文件里隐藏的搬运工作ARM Cortex-M的启动流程一般是这样上电后先从Flash取向量表紧接着执行Reset_Handler。Reset_Handler干三件事初始化栈指针、调用SystemInit初始化时钟、跳转__main。__main这个函数是C库提供的它内部会完成数据段拷贝、ZI段清零、调用__rt_entry最终进入main。分散加载描述文件本身不搬运数据真正的搬运动作发生在__main里。编译器在链接时通过分散加载文件生成了每个执行域对应的符号比如Load$$RW_IRAM1$$BaseRW_IRAM1区域在Flash中的加载地址起点Image$$RW_IRAM1$$BaseRW_IRAM1区域在SRAM中的运行地址起点Image$$RW_IRAM1$$LengthRW_IRAM1区域的长度__main会拿着这些符号完成一次从Flash到SRAM的拷贝循环。如果你在分散加载文件里添加了一个新的执行域但没有正确生成启动代码所需的符号或者启动文件里的SCatterCopy相关逻辑没有覆盖新区域数据就不会被搬运。在MDK环境下只要你用的是ARM标准启动文件startup_xxx.s并且开启了“Use Memory Layout from Target Dialog”或者手动指定了scatter文件__main会自动处理这些。真正出问题的场景是“自定义启动文件”或“OS运行前的早期初始化”。4.2 堆和栈到底该放哪个区堆和栈也是靠分散加载文件定义的。常见写法是在某个执行域后面增加RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) *(.heap) *(.stack) }或者在启动文件里用Heap_Size、Stack_Size这两个EQU值定义配合启动文件里的AREA HEAP、AREA STACK段实现。这两种方式并不冲突只要你分配的区域地址和大小与芯片RAM一致并且栈顶地址能被4或8字节对齐就行。我实际开发中见过最多的错误有两种一是把栈放在外部SDRAM里却忘了SDRAM的初始化时序慢于C库__user_initial_stackheap的初始化结果一压栈就hardfault二是堆大小设的极小而工程里又引用了新的libc函数或RTOS动态申请内存失败后没人排查程序随机崩溃。所以我的建议是栈一定要放在片内SRAM堆可以放外部RAM但要在RTOS运行后再使用。另外一个经验是如果你用RTOS比如RT-Thread、FreeRTOS系统会自己管理每个任务的栈不需要在分散加载文件里给每个任务分配独立区域。你只需要保证系统堆用于创建任务控制块、信号量等空间足够大。4.3 分散加载与GNU链接脚本的互通很多开源库和SDK使用GCC工具链对应的内存布局描述文件不是.sct而是.ld链接脚本。两者的思路是共通的但语法完全不同。我在这里给一个简单对照方便你在不同工具链之间切换时不至于被绕晕。GCC里主要用MEMORY命令描述内存区域再用SECTIONS命令把输入段安排到不同位置MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K SRAM (rwx) : ORIGIN 0x20000000, LENGTH 128K SDRAM (rwx) : ORIGIN 0xC0000000, LENGTH 8M } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) } FLASH .data : { _sdata .; *(.data*) _edata .; } SRAM AT FLASH .bss : { _sbss .; *(.bss*) _ebss .; } SRAM .sdram : { *(.sdram*) } SDRAM }在GCC下外部SDRAM里的大数组用__attribute__((section(.sdram)))来标记效果和ARMCC下用section名标记是一样的。GCC里AT FLASH这个语法很容易被漏掉它表示.data段的加载地址在Flash里但运行地址在SRAM里和ARM的Load/Execution机制是同一个意思。如果你漏了AT链接器就会认为data段只存在于SRAM下载算法就会出错。5. 报错信息与运行时异常的排查实录分散加载文件写起来不大但真正调起问题来报错信息往往让人一头雾水。我把最常见的几类问题整理成速查表方便你对照排查。报错信息原因解决方案L6221E: Execution region ... cannot be copied执行域超出了可用内存范围或地址重叠检查执行域的起始地址和大小是否合理确认芯片实际RAM容量L6314W: ... not executed代码段没有匹配到任何执行域检查通配符*(.text*)是否覆盖了所有需要的输入段确认执行域名称写对L6312W: ... could not be placed某些数据段没能放进去内存溢出调整各执行域大小把大数组迁移到外部SDRAM使用UNINIT避免清零Error: L6406E: No space in execution regions执行域空间不足精简数据增加内存或合理利用外部存储HardFault发生在启动早期访问了未初始化的SDRAM或外扩RAM把SDRAM区域的初始化提前或给相关执行域加上UNINIT中断一触发就死机向量表地址与分散加载地址不匹配设置SCB-VTOR为App的起始地址检查ER_APP起始地址是否与VTOR一致再分享一次实际排障经历。有一块板子在加了外部SDRAM之后程序只要一开GUI就死。我最初以为是SDRAM时序问题反复调FMC的时序参数没用。后来用仿真器查看汇编发现死机位置是在一个清屏函数的循环里。进一步排查发现GUI库的帧缓冲地址虽然是编译过的但启动代码在__main阶段尝试对该区域进行ZI清零这时SDRAM控制器还没初始化一访问就触发BusFault。解决办法有两个思路一个是在SystemInit里提前初始化SDRAM控制器另一个是给帧缓冲执行域加上UNINIT。我最终选择了后者原因是SystemInit是C库启动流程的一部分如果SDRAM初始化代码本身又有依赖条件启动顺序会变得很脆弱。加上UNINIT之后只要保证GUI库在使用帧缓冲前初始化SDRAM即可耦合度更低。另外很多人不知道处理分散加载文件里的绝对地址符号。在调试时我习惯在IDE的map文件里搜索Image$$和Load$$开头的符号确认每个执行域的加载地址、运行地址、长度是否符合预期。map文件相当于分散加载文件执行结果的“证据”排障时优先看它。6. 关于分散加载文件综合设计与后期扩展的几个建议最后说几个我自己总结的工程经验也算是这门“内存魔法”的个人修行笔记。第一分散加载文件命名和存放位置要规范。建议把它放在工程目录下的IDE/armclang/或platform/memory/文件夹里不要和源码混在一起。因为换人或换电脑时只要分散加载文件路径不对Keil会自动回退到使用Target对话框里的配置结果就是内存布局不符合预期。这个坑曾经坑了我一整个下午后来我把所有.atsct文件夹里都放了一份并且在工程描述文件里使用相对路径问题才彻底解决。第二在配置外部RAM区域时一定要和芯片的memory map规范核对。不同系列的单片机外部RAM的地址段不同STM32F429系列FMC的SDRAM银行地址是0xC0000000NXP的某些芯片外部SDRAM可能映射到0x80000000。地址写错会导致链接器能编过但实际运行时访问的全是无效地址。第三多Region环境下注意执行域的起始地址对齐。ARM架构要求执行域的起始地址满足一定对齐条件比如带FIQ中断的向量表需要8字节对齐。如果你把RW数据区放在一个地址没有对齐的位置性能会下降甚至触发UsageFault。通常建议执行域起始地址按4字节对齐。第四分散加载文件对代码性能也有影响。放在外部SDRAM里的数据变量访问速度远低于片内SRAM。把高频访问的全局变量意外放到了SDRAM区域会导致程序性能骤降。我做过一个测试同样的循环累加在片内SRAM和SDRAM上速度差了3-5倍具体取决于MCU的FMC总线宽度和SDRAM时序。所以分配内存时务必想清楚每个变量的访问频率不能只盯着容量。第五如果你在开发中使用了CMSIS-RTOS2接口的RTOS还会遇到线程栈和消息队列内存分配的问题。RTOS一般会从系统堆里动态分配内存而系统堆处于哪个执行域取决于分散加载文件如何放置堆段。如果堆在外部SDRAM且SDRAM未初始化任务创建时就会失败。调试这种问题很折磨人所以我建议RTOS的系统堆放在片内SRAM哪怕小一点也稳。分散加载文件这种“内存魔法”核心思路就是把每一个内存字节都当成你手里的资源去规划而不是交给编译器随便安排。掌握它的语法只需要一天但真正融会贯通需要好几个项目的磨练。希望我这篇文章能帮你少走一些弯路让你在规划嵌入式系统内存布局时既有理论又有落地方法。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →