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 知识体系结构图

下面这张图,帮你理清分散加载文件和链接脚本在整个编译-链接-启动流程中的位置:

分散加载文件 vs 链接脚本 — 知识体系结构 ARM 分散加载文件 (.scf) 加载域 (Load Region) 运行时域 (Execution Region) 段选择器 (Section Selector) 自动生成拷贝表 (Scatter Copy) GNU 链接脚本 (.ld) MEMORY 命令 SECTIONS 命令 符号定义 (Symbols) 手动实现拷贝逻辑 (Startup Code) 对比

从这张图可以看得很清楚:分散加载文件更「自动化」,链接脚本更「手动化」。ARM帮你做了数据拷贝的脏活累活,GNU把控制权完全交给你——代价就是你要自己写拷贝代码。

16.8 我的选择建议

说了这么多,到底该用哪个?我的建议很简单:

  • 如果你用Keil MDK、IAR、或者ARMCC:就用分散加载文件。它和工具链配合得最好,启动代码自动生成,省心。
  • 如果你用GCC、或者做Linux/RTOS开发:就用链接脚本。它的灵活性是分散加载文件比不了的,尤其是在需要复杂内存布局的场景。
  • 如果你在做跨平台项目:我建议你两个都学。因为说不定哪天就要从ARMCC切到GCC,或者反过来。理解了本质,切换起来就是语法翻译的问题。

嗯,最后说一句:不管用哪个,一定要仔细检查内存地址有没有重叠。我见过太多人把代码段和数据段放在同一个地址范围,结果程序跑飞了还不知道为什么。链接器一般会报错,但有些工具链的检查并不严格——尤其是当你用了+0或者ALIGN这类相对地址时。

好了,关于分散加载文件和链接脚本的对比,就聊到这儿。希望下次你看到.scf.ld文件时,能一眼看穿它背后的内存布局逻辑。


公众号:蓝海资料掘金营,微信deep3321