分散加载文件(.sct)从原理到实战
适用环境:Keil MDK + ARM Compiler 5 / 6(armlink 链接器),示例芯片以 STM32F407(Cortex-M4)为主,其他 Cortex-M 同理。
这篇笔记回答三个问题:分散加载文件(
.sct)到底在描述什么?默认文件每一行是什么意思?在 Bootloader、RAM 函数、保留变量这些实战场景里该怎么改?
分散加载是什么,为什么需要它
MCU 把 Flash、SRAM、外设寄存器都映射到同一个 4 GB 的地址空间里。以 STM32F407 为例:
| 区域 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| 片内 Flash | 0x0800_0000 | 1 MB | 存放代码和常量,上电后从这里启动 |
| SRAM1 + SRAM2 | 0x2000_0000 | 128 KB | 通用 RAM,可执行代码、可被 DMA 访问 |
| CCM RAM | 0x1000_0000 | 64 KB | 只接 D-Bus:不能执行代码,DMA 也访问不到 |
| FSMC / FMC | 0x6000_0000 起 | — | 外扩 SRAM / NOR / SDRAM |
编译器只负责把每个 .c 编译成一堆“节”(section),决定这些节最终放在哪个地址的是链接器 armlink,而告诉链接器怎么放的就是分散加载文件(scatter file,后缀 .sct)。
如果不自己写,Keil 会根据 Options for Target → Target 页面里填的 IROM / IRAM 自动生成一份默认的 .sct。大多数简单工程用默认的就够了,但遇到下面这些需求时就必须自己动手:
- Bootloader + App:同一片 Flash 里放两个独立程序;
- 外扩存储:把代码或大数组放到外部 SDRAM / NOR Flash;
- 函数放到 RAM 里执行:比如擦写 Flash 的函数、对时序敏感的中断;
- 指定地址放数据:固件版本号、配置区、复位后不清零的变量;
- 利用特殊 RAM:把大缓冲区放进 CCM,给主 SRAM 腾空间。
先弄清四种段:Code / RO / RW / ZI
每次编译完,Keil 输出窗口都会打印一行:
Program Size: Code=12840 RO-data=1024 RW-data=120 ZI-data=4200| 段 | 内容 | 例子 | 存在哪 | 运行时在哪 |
|---|---|---|---|---|
| Code | 机器指令 | 函数体 | Flash | Flash |
| RO-data | 只读数据 | const 表、字符串常量 | Flash | Flash |
| RW-data | 有初值的全局 / 静态变量 | int cnt = 5; | Flash(存初值) | RAM |
| ZI-data | 无初值或初值为 0 的全局 / 静态变量,以及栈和堆 | int buf[256]; | 不占 Flash | RAM(启动时清零) |
由此可以直接算出资源占用,这个公式非常实用:
- Flash 占用 = Code + RO-data + RW-data(RW 的初值也要存在 Flash 里)
- RAM 占用 = RW-data + ZI-data
加载域与运行域:同一段数据的两个地址
分散加载里最核心的两个概念:
- 加载域(Load Region):程序烧录后、上电那一刻各个段待在哪里——通常全部在 Flash 里;
- 运行域(Execution Region):程序运行时各个段应该在哪里——RW / ZI 必须在 RAM。
对 Code 和 RO 来说,加载地址 = 运行地址,原地执行即可;但 RW 的初值躺在 Flash 里,运行时却要在 RAM 里读写,所以必须有人在 main() 之前把它搬过去。这个“搬运工”就是 C 库入口 __main:
上电 / 复位
└─ Reset_Handler (startup_stm32f407xx.s)
├─ SystemInit() 时钟、Flash 等待周期、外部存储控制器
└─ __main ARM C 库入口
├─ __scatterload 按 Region$$Table 把 RW 从 Flash 复制到 RAM、
│ 把 ZI 清零、必要时解压数据
└─ __rt_entry 初始化栈 / 堆和 C 库
└─ main()原笔记里总结的“分散加载的根本目的”,其实就是 __main 在做的事:
- 把 RW 段(以及指定要放到 RAM 运行的 RO 段)从片内 Flash 复制到 SRAM;
- 在 SRAM 中为 ZI 段分配空间并全部清零;
- 初始化栈和堆。
推论:
SystemInit()在__main之前执行,所以它里面不能依赖任何全局变量的初值;而如果运行域在外部 SDRAM 上,SDRAM 控制器必须在SystemInit()里就初始化好,否则__scatterload往里复制数据时会直接 HardFault。
逐行读懂 Keil 默认的 .sct
STM32F407 工程自动生成的分散加载文件如下:
; *************************************************************
; *** Scatter-Loading Description File generated by uVision ***
; *************************************************************
LR_IROM1 0x08000000 0x00100000 { ; load region size_region
ER_IROM1 0x08000000 0x00100000 { ; load address = execution address
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
.ANY (+XO)
}
RW_IRAM1 0x20000000 0x00020000 { ; RW data
.ANY (+RW +ZI)
}
}| 行 | 含义 |
|---|---|
LR_IROM1 0x08000000 0x00100000 | 一个加载域,名字叫 LR_IROM1,起始 0x08000000,最大 1 MB |
ER_IROM1 0x08000000 0x00100000 | 加载域里的第一个运行域,运行地址与加载地址相同(原地执行) |
*.o (RESET, +First) | 任意目标文件中名为 RESET 的节(启动文件里的中断向量表)放在最前面——Cortex-M 复位时从 0 偏移读取 MSP 和复位向量,所以它必须是第一个 |
*(InRoot$$Sections) | 把 __main、__scatterload 等“必须留在根区”的库代码放在这里,见下一节 |
.ANY (+RO) | 剩下所有只读段(代码 + 常量)由链接器自由分配到这里 |
.ANY (+XO) | 仅执行(execute-only)代码,AC6 才会用到 |
RW_IRAM1 0x20000000 0x00020000 | 第二个运行域,位于 SRAM,128 KB |
.ANY (+RW +ZI) | 所有可读写数据和清零数据都放进 SRAM |
一个加载域可以包含多个运行域,一个工程也可以有多个加载域。原笔记中的这张图就是两个加载域的例子(LR_IROM1 放在 0x00000000,LR_IROM2 放在 0x10000000):
两个加载域的分散加载示例
可以这样粗略理解:分散加载至少要告诉链接器三件事——从哪里加载程序、在哪里执行代码、在哪里读写变量。所以最常见的结构是“一个加载域 + 两个运行域(RO 一个、RW+ZI 一个)”。
语法速查
分散加载文件既不是 C 也不是汇编,而是 armlink 专用的描述语言;它不能单步调试,写错了只能看链接报错和 .map 文件排查。
; 加载域
load_region_name (base_address | "+" offset) [attribute_list] [max_size]
{
execution_region_description+
}
; 运行域
exec_region_name (base_address | "+" offset) [attribute_list] [max_size | length]
{
input_section_description*
}
; 输入节
module_select_pattern [ "(" input_section_selector ( "," input_section_selector )* ")" ]+offset:紧跟在上一个域后面,常写+0;module_select_pattern:匹配目标文件或库成员名,支持*和?通配,如startup_*.o、*.o、*;;之后是注释。
常用输入节选择器
| 选择器 | 匹配内容 |
|---|---|
+RO | 所有只读段(+RO-CODE + +RO-DATA) |
+RO-CODE / +RO-DATA | 只要代码 / 只要常量 |
+RW / +ZI | 可读写数据 / 清零数据 |
+XO | 仅执行代码 |
+FIRST / +LAST | 放在该运行域的最前 / 最后 |
(节名) | 按名字匹配,如 (RESET)、(.bss.noinit) |
.ANY | 不指定模块,让链接器在多个域之间自动分配剩余段 |
* 和 .ANY 的区别:*.o (+RO) 是“所有目标文件的 RO 都必须放这里”,而 .ANY (+RO) 是“没被其它规则认领的 RO,由链接器挑一个放得下的域”。有多个 .ANY 域时,还可以写 .ANY1、.ANY2 调整优先级。
常用域属性
| 属性 | 作用 |
|---|---|
EMPTY | 预留一段空白区域,常用于栈 / 堆;长度写负数表示向下增长 |
UNINIT | 该域中的 ZI 数据启动时不清零(复位后保留) |
FIXED | 强制运行地址 = 加载地址,必要时用填充补齐 |
NOCOMPRESS | 禁止链接器压缩该域的 RW 初值 |
ALIGN n | 按 n 字节对齐 |
OVERLAY | 多个域共享同一段地址(覆盖执行) |
根区与 InRoot$$Sections
__scatterload 负责把各个域复制到位,但它不能复制自己——所以必须有一个“加载地址 = 运行地址”的 RO 运行域来存放这些搬运代码,这就是根区(root region),也就是默认文件里的 ER_IROM1。
根区里必须包含:
- 复制代码和数据的程序:
__main.o、__scatter*.o(__scatter.o、__scatter_copy.o、__scatter_zi.o等); - 解压缩程序
__dc*.o(链接器会压缩 RW 初值以节省 Flash); Region$$Table节:记录每个需要复制 / 清零 / 解压的区域的地址和长度。
*(InRoot$$Sections) 就是“以上这些全部放进来”的简写,等价于逐个列出:
ER_IROM1 0x08000000 0x00100000 {
*.o (RESET, +First)
__main.o (+RO)
__scatter*.o (+RO)
__dc*.o (+RO)
* (Region$$Table)
.ANY (+RO)
}如果把这些节放到了第二个运行域(比如 ER_IROM2),链接时会报 L6202E: ... cannot be assigned to non-root region。
把单个函数或变量放到指定位置
分散加载的最小单位是节,要单独摆放某个函数或变量,就得先让它拥有自己的节。有三种方式:
- 一个函数一个节:AC5 用
--split_sections,AC6 的 armclang 默认就开启了-ffunction-sections。注意它并不会多占资源,反而能配合链接器删除没被调用的函数,让固件更小; - 指定绝对地址;
- 指定节名,再在
.sct里按名字摆放。
AC5 和 AC6 的写法不同,这是网上老教程最容易踩的坑:
| 需求 | ARM Compiler 5(armcc) | ARM Compiler 6(armclang) |
|---|---|---|
| 变量放到绝对地址 | int v __attribute__((at(0x2001F000))); | int v __attribute__((section(".ARM.__at_0x2001F000"))); |
| 放进命名节 | __attribute__((section("mySection"))) | 同左 |
Keil MDK 5.37 之后默认只带 AC6,所以 __attribute__((at(...))) 在新工程里会直接报错。
绝对地址放置的限制:
- 不同
__at节的地址范围不能重叠; - 地址必须是该节对齐要求的整数倍;
- 不能用于位置无关(PI)的运行域;
__at节会忽略+FIRST/+LAST约束。
命名节的完整例子:
int var __attribute__((section("mySection"))) = 0x55;
__attribute__((section("mySection"))) void GPIO_Init(void)
{
/* ... */
}LR_IROM1 0x00000000 0x00008000 {
ER_IROM1 0x00000000 0x00008000 {
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
ER_mySection 0x10000000 {
main.o (mySection)
}
RW_IRAM1 0x10000000 0x00002000 { ; 原笔记这里少写了一个 0
.ANY (+RW +ZI)
}
ARM_LIB_STACK 0x10002000 EMPTY -0x200 { ; 从 0x10002000 向下预留 512 字节栈
}
}注意上例中
ER_mySection和RW_IRAM1的起始地址相同,实际工程里要错开,否则会报地址重叠。
实战场景
Bootloader + App 分区
STM32F407 的 Flash 扇区大小不均匀:扇区 0–3 各 16 KB,扇区 4 为 64 KB,扇区 5–11 各 128 KB。把前 4 个扇区(64 KB)留给 Bootloader,App 从扇区 4 开始,这样擦写 App 时不会碰到 Bootloader。
| 分区 | 地址范围 | 大小 |
|---|---|---|
| Bootloader | 0x0800_0000 – 0x0800_FFFF | 64 KB |
| App | 0x0801_0000 – 0x080F_FFFF | 960 KB |
Bootloader 工程的 .sct:
LR_BOOT 0x08000000 0x00010000 {
ER_BOOT 0x08000000 0x00010000 {
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
RW_IRAM1 0x20000000 0x00020000 {
.ANY (+RW +ZI)
}
}App 工程的 .sct:
LR_APP 0x08010000 0x000F0000 {
ER_APP 0x08010000 0x000F0000 {
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
RW_IRAM1 0x20000000 0x00020000 {
.ANY (+RW +ZI)
}
}App 还要做一件事:把中断向量表偏移到自己的起始地址,否则中断会跳回 Bootloader 的向量表:
/* system_stm32f4xx.c 中打开 USER_VECT_TAB_ADDRESS 并设置偏移,或者在 main 开头: */
SCB->VTOR = 0x08010000;Bootloader 跳转到 App 的典型写法:
把函数放到 RAM 里执行
擦写片内 Flash 时,CPU 如果还在从同一块 Flash 取指令,就会被卡住直到擦写完成;对时序要求高的中断也可以放进 RAM 减少等待周期。做法是给函数一个节名,再把这个节放进 RAM 运行域——__scatterload 会在启动时自动把它从 Flash 复制到 RAM:
__attribute__((section("RAMCODE"))) void flash_program_word(uint32_t addr, uint32_t data)
{
/* ... */
} RW_IRAM1 0x20000000 0x00020000 {
*.o (RAMCODE) ; 函数的加载地址在 Flash,运行地址在 SRAM
.ANY (+RW +ZI)
}注意 F4 的 CCM RAM 不能执行代码,RAM 函数只能放在 SRAM1 / SRAM2。另外,RAM 函数里调用的其它函数如果还在 Flash 上,照样会被卡住,要一起挪进来。
利用 CCM RAM 存放大缓冲区
CCM 有 64 KB,默认 .sct 完全没用上。把不需要 DMA 访问的大数组放进去,可以给主 SRAM 腾出空间:
static uint8_t big_buffer[32 * 1024] __attribute__((section(".bss.ccmram"))); RW_CCM 0x10000000 0x00010000 {
*(.bss.ccmram)
}节名以 .bss 开头,AC6 会把它当作 ZI 数据,启动时自动清零,也不会占用 Flash。
复位后不清零的变量(UNINIT)
Bootloader 升级流程里常要在“App 收到升级命令 → 软件复位 → Bootloader 接手”之间传递一个标志,普通全局变量会在复位后被 __scatterload 清零,这时就要用 UNINIT:
RW_IRAM1 0x20000000 0x0001F000 { ; 主 RAM 让出最后 4 KB
.ANY (+RW +ZI)
}
RW_NOINIT 0x2001F000 UNINIT 0x00001000 {
*(.bss.noinit)
}#define UPGRADE_MAGIC 0x5A5AA5A5UL
volatile uint32_t upgrade_flag __attribute__((section(".bss.noinit")));上电时这块 RAM 的内容是随机的,所以一定要用一个“魔数”判断标志是否有效,用完立即清掉。Bootloader 和 App 的 .sct 里这块区域的地址要保持一致。
FIXED 与“一个还是多个 bin 文件”
用 fromelf --bin 生成 bin 文件时,每个加载域会生成一个 bin。如果想让中断向量表和代码分在两个运行域、但仍只输出一个 bin,可以用 FIXED 把它们放进同一个加载域:
LR_IROM1 0x00000000 0x00040000 {
ER_IROM1 0x00000000 0x200 {
*.o (RESET, +First)
}
ER_IROM2 0x200 FIXED 0x3FD00 { ; FIXED:运行地址 = 加载地址,与上一个域连成一片
*(InRoot$$Sections)
.ANY (+RO)
}
RW_IRAM1 0x10000000 0x00008000 {
.ANY (+RW +ZI)
}
}代价是两个域之间的空隙会被填充。如果两个运行域地址相距很远(比如片内 Flash 和外部 Flash),填充出来的 bin 会大得离谱,这时应该拆成两个加载域、输出两个 bin 分别烧录。
外扩 SDRAM
以 STM32F429 的 FMC SDRAM(0xC000_0000)为例,想把大数组放进去:
RW_SDRAM 0xC0000000 UNINIT 0x00800000 {
*(.bss.sdram)
}两个关键点:
- SDRAM 控制器必须在
__main之前初始化,也就是放进SystemInit()(ST 的模板里有SystemInit_ExtMemCtl(),打开DATA_IN_ExtSDRAM宏即可); - 加上
UNINIT可以避免启动时清零 8 MB 内存拖慢启动速度,但变量就需要自己初始化。
在代码里使用链接器符号
armlink 会为每个域自动生成一组符号,可以在 C 代码里直接引用,用来计算剩余 RAM、做堆栈检查或自己实现复制逻辑:
| 符号 | 含义 |
|---|---|
Load$$区域名$$Base | 该域的加载地址 |
Image$$区域名$$Base | 该域的运行地址 |
Image$$区域名$$Length | 该域 RO / RW 部分的长度 |
Image$$区域名$$ZI$$Limit | 该域 ZI 部分的结束地址 |
extern uint32_t Image$$RW_IRAM1$$ZI$$Limit;
uint32_t ram_used(void)
{
return (uint32_t)&Image$$RW_IRAM1$$ZI$$Limit - 0x20000000UL;
}常见链接报错
| 报错 | 原因 | 处理 |
|---|---|---|
L6220E: Execution region ER_IROM1 size (xxx bytes) exceeds limit | 域放不下了 | 检查 Code/RO 是否真的超了、打开优化,或调大域的 max_size |
L6406E: No space in execution regions with .ANY selector matching ... | .ANY 找不到能放下该段的域 | 同上,或者给大对象单独指定域 |
L6202E: ... cannot be assigned to non-root region | 库的搬运代码被放进了非根区 | 确保 *(InRoot$$Sections) 在第一个运行域 |
L6236E: No section matches selector - no section to be FIRST/LAST | +First 没匹配到任何节 | 检查启动文件是否加入工程、节名 RESET 是否拼对 |
| 跳转到 App 后进 HardFault | App 没改 VTOR,或 Bootloader 没关中断 | 见上面 Bootloader 一节 |
| 外部 RAM 中的变量值不对 | 用了 UNINIT 却没手动初始化,或外存控制器初始化太晚 | 在 SystemInit() 中初始化外存 |
调试技巧
- 启用自定义
.sct:Options for Target → Linker,取消勾选 Use Memory Layout from Target Dialog,在 Scatter File 中选择自己的文件;旁边的 Edit… 可以直接编辑。 - 看
.map文件:Listing 页勾选 Memory Map。重点看 Memory Map of the image 部分,每个域的地址、大小、包含了哪些节一目了然;Image Symbol Table 可以查到每个函数和变量的最终地址。 - 生成 bin:在 User → After Build 中添加
fromelf --bin --output=@L.bin !L。
小结
.sct描述的是“每个节存在哪里(加载域)、跑在哪里(运行域)”;__main/__scatterload负责在main()之前把 RW 复制到 RAM、把 ZI 清零,它们自己必须待在根区;- Flash 占用 = Code + RO + RW,RAM 占用 = RW + ZI;
- 单独摆放函数或变量要先让它拥有独立的节,AC6 下用
section(".ARM.__at_地址")代替旧的at(); - 改完
.sct一定要看.map文件确认结果。
相关文章
评论
正在读取评论…