3、链接脚本深度解析:链接脚本的作用、MEMORY与SECTIONS命令、如何自定义链接脚本实现双区备份

说实话,很多做嵌入式开发的朋友,写了几年C代码,却从来没仔细看过链接脚本。我刚开始带项目时也是这样——编译器帮我搞定了,我干嘛要管?直到有一次,我负责的Bootloader死活跑不起来,调试了整整两天,最后发现是Flash地址映射错了。嗯,从那以后,我再也不敢跳过链接脚本了。

今天咱们就把这块硬骨头啃下来。你想想看,链接脚本其实就是告诉链接器:你的代码该放哪儿,数据该放哪儿,哪些段要保留,哪些可以丢掉。说白了,它就是一张内存布局的施工图。

3.1 链接脚本到底在干什么?

一个嵌入式工程从源码到可执行文件,要经过四个阶段:预处理、编译、汇编、链接。前三个阶段咱们都比较熟,但链接这一步,很多人把它当黑盒。

链接器干的事,我总结为三条:

  • 地址分配:给每个段(.text、.data、.bss等)分配具体的运行地址
  • 符号解析:把函数名、全局变量这些符号,绑定到具体的地址上
  • 段合并:把多个目标文件中的同类型段,合并成一个完整的段

没有链接脚本,链接器就用默认规则。但默认规则往往不适合嵌入式场景——尤其是咱们要做双区备份的Bootloader,必须精确控制每一块Flash的用途。

核心观点:链接脚本不是编译器的附属品,它是嵌入式工程师的"地契"——你声明了这块地归谁,编译器才敢往上盖楼。

3.2 MEMORY命令:定义你的硬件家底

MEMORY命令用来描述目标芯片的存储资源。我习惯把它叫做"盘家底"——你得告诉链接器,你手里有哪些Flash、哪些RAM,它们的起始地址和大小是多少。

基本语法长这样:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

每个存储区域可以指定属性:

属性 含义 典型用途
r 可读 所有存储区域都该有
w 可写 RAM必须,Flash一般不加
x 可执行 存放代码的区域必须加
! 取反 比如"!x"表示不可执行

我在项目中遇到过一个问题:某次把代码段属性写成了"rw",结果芯片上电后程序直接跑飞。后来查了半天才发现,Flash区域没加"x"属性,链接器认为这块区域不可执行,生成的向量表地址全是错的。你说冤不冤?

个人习惯:我一般会在MEMORY里把Flash拆成多个区域——比如BOOT、APP1、APP2、PARAM。这样后面写SECTIONS时,每个段该放哪儿一目了然,不容易出错。

3.3 SECTIONS命令:给代码和数据安排座位

MEMORY定义好了房间,SECTIONS就是安排每个段住哪个房间、住哪个位置。这是链接脚本里最核心、也最灵活的部分。

一个典型的SECTIONS结构:

SECTIONS
{
    .text :
    {
        *(.isr_vector)    /* 中断向量表,必须放在最前面 */
        *(.text*)         /* 所有代码 */
        *(.rodata*)       /* 只读数据 */
        _etext = .;       /* 代码结束地址 */
    } > FLASH

    .data : AT(_etext)
    {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

这里有个关键点:.data段用了AT(_etext)。什么意思?就是说这个段运行时在RAM里,但它的初始值存在Flash里,紧跟在代码段后面。启动代码要做的,就是把这段数据从Flash拷贝到RAM。

我刚开始学的时候,一直搞不懂为什么.data要写两个地址。后来调试一个RAM不够用的项目,才真正理解了——运行时地址和加载地址可以不同,这是嵌入式系统的精髓之一。

注意:_etext、_sdata、_edata这些符号,不是C语言定义的变量,而是链接器生成的地址符号。你在启动代码里要用extern声明它们,然后取地址使用。我曾经见过有人直接拿这些符号当变量用,结果读出来的值全是错的。

3.4 双区备份的链接脚本设计

好了,前面都是基础。现在咱们来点实战的——双区备份Bootloader的链接脚本该怎么写?

双区备份的思路很简单:Flash里放两份应用程序,一份是当前运行的,一份是备份的。升级时,把新固件写到备份区,校验通过后切换启动。这样即使升级过程中断电,系统还能从旧区启动,不会变砖。

先画个内存布局图:

双区备份Flash布局 Bootloader (0x08000000 - 0x08007FFF, 32KB) 应用区A (0x08008000 - 0x08027FFF, 128KB) 当前运行版本 v2.1 包含:中断向量表 + 代码 + 只读数据 应用区B (0x08028000 - 0x08047FFF, 128KB) 备份版本 v2.0 / 升级目标区 新固件下载到此区,校验通过后切换 参数区 (0x08048000 - 0x0804FFFF, 32KB)

对应这个布局,链接脚本的MEMORY部分要这样写:

/* 双区备份Bootloader内存布局 */
MEMORY
{
    BOOT    (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    APP_A   (rx)  : ORIGIN = 0x08008000, LENGTH = 128K
    APP_B   (rx)  : ORIGIN = 0x08028000, LENGTH = 128K
    PARAM   (rw)  : ORIGIN = 0x08048000, LENGTH = 32K
    RAM     (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

然后,Bootloader自己的代码要链接到BOOT区域:

SECTIONS
{
    .text :
    {
        KEEP(*(.isr_vector))
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > BOOT

    /* 其他段省略... */
}

那应用程序的链接脚本呢?注意,应用程序不知道自己将来会跑在A区还是B区。所以它的链接脚本要设计成可配置的:

/* app.ld - 应用程序链接脚本 */
/* 编译时通过 -DAPP_BASE=0x08008000 或 -DAPP_BASE=0x08028000 指定 */

MEMORY
{
    FLASH (rx)  : ORIGIN = APP_BASE, LENGTH = 128K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text :
    {
        KEEP(*(.isr_vector))
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > FLASH

    /* 注意:.data的加载地址紧跟在代码后面 */
    .data : AT(_etext)
    {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

避坑指南:应用程序的中断向量表必须放在它所在区域的起始地址。Bootloader跳转到应用程序前,要先把SCB->VTOR寄存器改成APP_BASE。我见过有人忘了改VTOR,结果中断一触发就跑回Bootloader的向量表,整个系统乱套了。

3.5 双区切换的关键:链接脚本与启动代码的配合

链接脚本写好了,但要让双区备份真正跑起来,还需要启动代码的配合。这里我总结了一个检查清单:

  1. Bootloader启动:读取参数区,判断应该启动A区还是B区
  2. 设置向量表:把SCB->VTOR指向目标应用区的起始地址
  3. 跳转:获取应用区的栈顶指针和复位向量,执行跳转
  4. 应用程序启动:执行自己的启动代码,完成.data和.bss的初始化

跳转代码的核心部分,我习惯用内联汇编写:

/* 跳转到应用程序 */
void jump_to_app(uint32_t app_base)
{
    uint32_t stack_top = *(volatile uint32_t *)app_base;
    uint32_t reset_handler = *(volatile uint32_t *)(app_base + 4);

    /* 设置主栈指针 */
    __set_MSP(stack_top);

    /* 设置中断向量表偏移 */
    SCB->VTOR = app_base;

    /* 跳转到复位向量 */
    void (*app_reset)(void) = (void (*)(void))reset_handler;
    app_reset();
}

曾经踩过的坑:跳转前一定要关掉全局中断,跳转完成后再由应用程序重新初始化中断系统。我有一次没关中断就跳了,结果应用程序的启动代码还没跑完,一个定时器中断就来了,VTOR还没更新,直接飞到了错误的地方。从那以后,我每次跳转前都老老实实关中断。

3.6 链接脚本调试技巧

链接脚本出了问题,不像C代码那么好调试。我分享几个实用技巧:

技巧 方法 适用场景
查看map文件 编译时加-Wl,-Map=output.map 检查各段的实际地址和大小
查看符号表 使用nm或objdump工具 确认某个符号的地址是否正确
反汇编验证 objdump -d查看跳转指令 检查函数调用是否使用了正确的地址
打印链接器符号 在代码中打印_etext等符号的值 验证加载地址和运行地址的关系

我个人最常用的是map文件。每次修改链接脚本后,我都会先看map文件,确认每个段的地址和大小符合预期。尤其是做双区备份时,要确保APP_A和APP_B的代码段没有越界——128K就是128K,多一个字节都不行。

一句话总结:链接脚本是嵌入式工程师的"地契",MEMORY定义了你有什么地,SECTIONS决定了每块地种什么庄稼。双区备份的核心,就是通过精心设计的链接脚本,让两份应用程序在Flash里和平共处,互不干扰。