16、分散加载文件(Scatter File)与链接脚本的对比
做嵌入式开发这么多年,我接触过不少工具链。ARM的分散加载文件(Scatter File)和GNU的链接脚本(Linker Script),说白了就是干同一件事——告诉链接器:你的代码该放哪,数据该放哪。但这两兄弟的脾气秉性,差别还真不小。
我记得刚入行那会儿,从Keil MDK转到GCC,第一件事就是被链接脚本给整懵了。明明在分散加载文件里几句话就能搞定的事,到了链接脚本里怎么这么绕?反过来,用惯GCC的朋友第一次看分散加载文件,也会觉得这语法怎么这么「ARM」?
今天我就把这两个东西掰开揉碎了讲讲。你想想看,搞懂它们,你就能真正掌控程序在内存里的布局——这本事,在搞Bootloader、RTOS移植、或者做资源受限的MCU项目时,太重要了。
16.1 它们到底在解决什么问题?
先别管语法,咱们先想清楚本质问题。
编译器把C代码翻译成目标文件(.o),里面全是符号和段。段有三种:.text(代码)、.data(已初始化数据)、.bss(未初始化数据)。链接器的任务,就是把这些段安排到具体的地址上。
但嵌入式系统不是PC。你的Flash可能从0x08000000开始,RAM从0x20000000开始。有些数据要放在RAM里运行(比如中断向量表),有些代码要拷贝到RAM里执行(比如高速算法)。这些需求,靠编译器自己可搞不定。
分散加载文件和链接脚本,就是用来描述这种「内存布局策略」的配置文件。
核心区别一句话:分散加载文件是ARM工具链的「声明式」配置,链接脚本是GNU工具链的「命令式」脚本。一个像填表格,一个像写程序。
16.2 分散加载文件(Scatter File)—— ARM的优雅方案
分散加载文件的后缀通常是.scf,在Keil MDK和ARMCC编译器里用。它的语法非常直观,我头一回看到就觉得:嗯,这很ARM,很工程化。
来看一个典型的例子:
; 一个典型的STM32分散加载文件
LOAD_ROM 0x08000000 0x100000 ; 加载域:Flash,起始0x08000000,大小1MB
{
; 运行时域:中断向量表,必须放在Flash开头
VECTORS 0x08000000 0x400
{
*(.vectors) ; 所有目标文件的.vectors段
}
; 运行时域:代码段
CODE 0x08000400 0xFC000
{
*(.text) ; 代码
*(.rodata) ; 只读数据
}
; 运行时域:RAM中的数据
RAM 0x20000000 0x30000
{
*(.data) ; 已初始化数据(运行时在RAM)
*(.bss) ; 未初始化数据
*(.heap) ; 堆
*(.stack) ; 栈
}
}
你看,结构很清晰:加载域(Load Region)描述程序烧录在哪(通常是Flash),运行时域(Execution Region)描述程序运行时各段在哪。如果加载域和运行时域地址不同,启动代码会自动做拷贝——比如把.data从Flash拷贝到RAM。
我个人习惯把分散加载文件分成三块:加载域定义、运行时域定义、段选择器。这样写出来的文件,别人一看就懂。
小技巧:在分散加载文件里,你可以用+0表示紧接着上一个域的末尾地址。比如在CODE域后面写RAM +0,链接器会自动把RAM域放在CODE域后面。这比手动算地址省心多了。
16.3 链接脚本(Linker Script)—— GNU的灵活利器
链接脚本的后缀是.ld或.lds,在GCC工具链里用。它的语法更像一种领域特定语言(DSL),有变量、有函数、有赋值语句。
同样的STM32项目,用链接脚本写出来是这样的:
/* 一个典型的STM32链接脚本 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 192K
}
SECTIONS
{
.vectors : ALIGN(4)
{
*(.vectors)
} > FLASH
.text : ALIGN(4)
{
*(.text)
*(.rodata)
} > FLASH
.data : ALIGN(4)
{
_sdata = .; /* 数据起始地址(RAM中) */
*(.data)
_edata = .; /* 数据结束地址(RAM中) */
} > RAM AT> FLASH /* 加载在Flash,运行时在RAM */
.bss : ALIGN(4)
{
_sbss = .;
*(.bss)
_ebss = .;
} > RAM
}
这里有个关键点:> RAM AT> FLASH。意思是这个段运行时在RAM,但加载时在Flash。链接器会生成符号_sdata、_edata,启动代码里用它们把数据从Flash拷贝到RAM。
我曾经在调试一个启动异常时,发现数据没被正确初始化。查了半天,原来是链接脚本里忘了写AT> FLASH,导致链接器以为数据直接就在RAM里——烧录时当然不会把数据写进Flash,一上电RAM里全是随机值。
注意:链接脚本里定义的符号(比如_sdata),在C代码里要用extern声明才能访问。而且要注意,这些符号是地址,不是变量值。很多人刚接触时容易搞混,写成uint32_t *p = &_sdata;才对。
16.4 核心差异对比表
我把两个方案的关键差异整理成了表格,方便你对照:
| 对比维度 | 分散加载文件(.scf) | 链接脚本(.ld) |
|---|---|---|
| 所属工具链 | ARMCC / Keil MDK | GCC / GNU LD |
| 语法风格 | 声明式,类似配置文件 | 命令式,类似脚本语言 |
| 内存区域定义 | 加载域 + 运行时域 | MEMORY命令 + SECTIONS命令 |
| 数据拷贝控制 | 自动处理(地址不同即拷贝) | 手动定义符号,启动代码里拷贝 |
| 段选择语法 | *(.text) 通配符 |
*(.text) 通配符,更灵活 |
| 变量/符号支持 | 有限(主要用地址常量) | 丰富(可定义变量、计算表达式) |
| 条件判断 | 不支持 | 支持(通过预处理或脚本逻辑) |
| 调试信息 | 生成加载视图和运行视图 | 生成符号表,需额外处理 |
| 典型应用场景 | ARM Cortex-M系列MCU | Linux、RTOS、复杂嵌入式系统 |
16.5 一个实际项目中的对比
我在做一个需要OTA升级的项目时,深刻体会到了两者的差异。当时需要在Flash里保留两个程序区:一个Bootloader区,一个App区。App区又分两个备份,用于远程升级。
用分散加载文件写:
LOAD_ROM 0x08000000 0x100000
{
BOOT 0x08000000 0x10000
{
*boot.o (+RO) ; 只包含boot模块
}
APP_1 0x08010000 0x70000
{
*app1.o (+RO)
}
APP_2 0x08080000 0x70000
{
*app2.o (+RO)
}
SHARED 0x080F0000 0x10000
{
*shared.o (+RO +RW)
}
}
你看,每个域指定了起始地址和大小,段选择器用+RO(只读)和+RW(读写)来筛选。简洁明了。
用链接脚本写:
MEMORY
{
BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 64K
APP_1 (rx) : ORIGIN = 0x08010000, LENGTH = 448K
APP_2 (rx) : ORIGIN = 0x08080000, LENGTH = 448K
SHARED (rw) : ORIGIN = 0x080F0000, LENGTH = 64K
}
SECTIONS
{
.boot_text : { *boot.o(.text) } > BOOT
.app1_text : { *app1.o(.text) } > APP_1
.app2_text : { *app2.o(.text) } > APP_2
.shared : { *shared.o(.text) *shared.o(.data) } > SHARED
}
链接脚本里,每个段都要明确指定放在哪个内存区域。而且要注意,如果某个目标文件没被任何段匹配,链接器会报错或把它丢到未定义区域。这一点上,分散加载文件更宽容一些——它会自动把未匹配的段放到默认域里。
16.6 启动代码中的配合
不管用哪种方案,最终启动代码(Reset_Handler)都要配合完成初始化。这里有个关键区别:
分散加载文件:ARM的启动代码(比如startup_xxx.s)会自动解析分散加载文件生成的信息,完成.data拷贝和.bss清零。你基本不用操心。
链接脚本:GCC的启动代码需要你手动实现拷贝逻辑。通常是这样:
void Reset_Handler(void)
{
// 拷贝.data段从Flash到RAM
extern uint32_t _sdata, _edata, _sidata;
for (uint32_t *src = &_sidata, *dst = &_sdata;
dst < &_edata;
src++, dst++)
{
*dst = *src;
}
// 清零.bss段
extern uint32_t _sbss, _ebss;
for (uint32_t *p = &_sbss; p < &_ebss; p++)
{
*p = 0;
}
// 跳转到main
main();
}
这里_sidata是Flash中.data的加载地址,_sdata和_edata是RAM中的运行地址。这些符号都是在链接脚本里定义的。
避坑指南:我曾经在移植一个FreeRTOS项目时,忘了在链接脚本里定义_sidata符号。结果启动代码里_sidata的值是0,拷贝时从地址0读数据——直接触发HardFault。调试了一下午才发现。所以,链接脚本里定义的每个符号,都要确保在启动代码里正确使用。
16.7 知识体系结构图
下面这张图,帮你理清分散加载文件和链接脚本在整个编译-链接-启动流程中的位置:
从这张图可以看得很清楚:分散加载文件更「自动化」,链接脚本更「手动化」。ARM帮你做了数据拷贝的脏活累活,GNU把控制权完全交给你——代价就是你要自己写拷贝代码。
16.8 我的选择建议
说了这么多,到底该用哪个?我的建议很简单:
- 如果你用Keil MDK、IAR、或者ARMCC:就用分散加载文件。它和工具链配合得最好,启动代码自动生成,省心。
- 如果你用GCC、或者做Linux/RTOS开发:就用链接脚本。它的灵活性是分散加载文件比不了的,尤其是在需要复杂内存布局的场景。
- 如果你在做跨平台项目:我建议你两个都学。因为说不定哪天就要从ARMCC切到GCC,或者反过来。理解了本质,切换起来就是语法翻译的问题。
嗯,最后说一句:不管用哪个,一定要仔细检查内存地址有没有重叠。我见过太多人把代码段和数据段放在同一个地址范围,结果程序跑飞了还不知道为什么。链接器一般会报错,但有些工具链的检查并不严格——尤其是当你用了+0或者ALIGN这类相对地址时。
好了,关于分散加载文件和链接脚本的对比,就聊到这儿。希望下次你看到.scf或.ld文件时,能一眼看穿它背后的内存布局逻辑。
公众号:蓝海资料掘金营,微信deep3321