4. 链接脚本精讲:GCC与Tasking链接脚本语法、内存布局与段重定位

链接脚本,说白了就是告诉编译器:「你的代码和数据,应该放在硬件的哪个位置」。很多做应用层开发的同事觉得这东西可有可无,但在车载ECU开发里,链接脚本写错了,轻则程序跑飞,重则直接变砖。我刚开始做Bootloader时,就因为RAM区地址算错了一位,导致整车上电后ECU死锁,最后只能拆壳子用编程器救回来。嗯,从那以后我再也不敢轻视链接脚本了。

4.1 为什么需要链接脚本?

你想想看,一个MCU里既有Flash(存放代码和常量),又有RAM(存放变量和堆栈),还有EEPROM(存放标定参数和故障码)。编译器生成的.o目标文件里,每个段(Section)都只是相对地址。链接脚本的作用,就是把这些相对地址「映射」到真实的物理地址上。

核心任务: 告诉链接器——.text段放Flash起始地址0x8000000,.data段初始值放Flash,运行时搬到RAM,.bss段在RAM清零。

4.2 GCC链接脚本语法(LD脚本)

GCC用的链接脚本,扩展名通常是.ld或.lds。我习惯把脚本分成三块:内存布局定义、段定义、以及符号导出。

4.2.1 内存布局:MEMORY命令

MEMORY命令用来描述目标芯片的物理存储资源。每个区域都要指定名字、起始地址、长度和属性。

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1M
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
    EEPROM (rw) : ORIGIN = 0x08080000, LENGTH = 16K
}
属性 含义 常见用途
r 可读 所有区域基本都有
w 可写 RAM、EEPROM
x 可执行 Flash中的代码段
! 取反 如!x表示不可执行

个人习惯: 我会在MEMORY块里把EEPROM单独列出来,而不是塞进Flash末尾。这样链接器能自动检查EEPROM区是否溢出,避免标定数据把代码覆盖掉。

4.2.2 段定义:SECTIONS命令

SECTIONS是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段之间的排列顺序。

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

    . = ORIGIN(RAM);
    .data : {
        _sdata = .;             /* 数据段起始地址(RAM中) */
        *(.data*)
        _edata = .;             /* 数据段结束地址 */
    } > RAM AT > FLASH          /* 加载地址在Flash,运行地址在RAM */

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

这里有个关键点:> RAM AT > FLASH。意思是.data段运行时在RAM,但初始值存储在Flash里。启动代码必须把这段数据从Flash拷贝到RAM。我曾经见过一个项目,工程师忘了加AT > FLASH,结果.data段直接烧在RAM里,一断电全丢了。

4.2.3 符号导出与启动代码配合

链接脚本里定义的符号(如_sdata、_edata),可以在C代码或汇编启动代码里直接引用:

extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

void startup(void)
{
    uint32_t *src, *dst;
    // 拷贝.data段
    src = &_sidata;   // Flash中的加载地址
    dst = &_sdata;    // RAM中的运行地址
    while (dst < &_edata) *dst++ = *src++;

    // 清零.bss段
    dst = &_sbss;
    while (dst < &_ebss) *dst++ = 0;

    main();
}

避坑指南: 我曾经在某个项目里,把_sidata和_sdata的定义搞反了,结果启动时.data段拷贝到了错误的位置,全局变量全部乱掉。排查了整整两天才发现是链接脚本里符号名写错了。建议你在链接脚本里给符号加注释,比如/* Flash中数据起始 */。

4.3 Tasking链接脚本语法(LSL脚本)

Tasking编译器在英飞凌TC3xx系列里用得很多。它的链接脚本叫LSL(Linker Script Language),语法和GCC差别挺大。我第一次从GCC切到Tasking时,光看语法就懵了半天。

4.3.1 内存与总线描述

Tasking的LSL用memory关键字定义物理存储,用bus定义总线映射:

memory flash
{
    mau = 8;
    size = 1M;
    type = rom;
    map (dest=bus:tc_bus, dest_offset=0x80000000, size=1M);
}

memory ram
{
    mau = 8;
    size = 128K;
    type = ram;
    map (dest=bus:tc_bus, dest_offset=0x70000000, size=128K);
}

注意这里的map语句,它把物理内存映射到系统总线的地址空间。TC3xx的Flash物理地址和CPU访问地址可能不同,LSL通过map来建立映射关系。

4.3.2 段定义与分组

Tasking用section_setup和section来管理段:

section_setup :: ".text"
{
    group (ordered, contiguous, run_addr=mem:flash)
    {
        section "*.text" (attributes=rx);
        section "*.rodata" (attributes=r);
    }
}

section_setup :: ".data"
{
    group (ordered, contiguous, run_addr=mem:ram, copy)
    {
        section "*.data" (attributes=rw);
    }
}

copy关键字告诉链接器:这个段需要从加载地址拷贝到运行地址。Tasking会自动生成拷贝表,启动代码里调用__init_copy_table即可完成搬运。

4.3.3 中断向量表与保留区

在TC3xx里,中断向量表(BTV)必须放在特定地址。LSL里可以这样保留空间:

section_setup :: ".inttab"
{
    group (ordered, contiguous, run_addr=0x80000000)
    {
        section "*.inttab" (attributes=rx, size=0x200);
    }
}

我的经验: Tasking的LSL脚本里,group的ordered和contiguous属性一定要加。不加ordered的话,链接器可能打乱段顺序,导致中断向量表不在最前面。不加contiguous,段之间可能插入填充,浪费Flash空间。

4.4 内存布局实战:一个典型的车载ECU分区

下面是我在一个实际项目里用过的Flash分区方案。这个ECU需要支持Bootloader和APP双区启动:

MEMORY
{
    /* Bootloader区:64KB */
    BOOT_FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K

    /* APP区:896KB */
    APP_FLASH  (rx)  : ORIGIN = 0x08010000, LENGTH = 896K

    /* EEPROM模拟区:16KB,用Flash模拟 */
    EEPROM_FLASH (rw): ORIGIN = 0x080F0000, LENGTH = 16K

    /* RAM区:128KB */
    RAM (rwx)        : ORIGIN = 0x20000000, LENGTH = 128K
}

对应的段分配策略:

  • Bootloader代码:放在BOOT_FLASH,只包含启动、校验、跳转逻辑
  • APP代码:放在APP_FLASH,包含应用层和标定数据
  • EEPROM数据:放在EEPROM_FLASH,用Flash模拟EEPROM,按页擦写
  • 变量和堆栈:放在RAM,注意Bootloader和APP共用RAM时要避开冲突区

重要提醒: Bootloader和APP的链接脚本里,RAM的起始地址和大小必须一致。我曾经见过一个项目,Bootloader的RAM用了0x20000000-0x20001FFF,APP的RAM却从0x20000000-0x20003FFF,结果跳转后堆栈指针指向了未初始化的区域,直接HardFault。

4.5 段重定位:让代码在任意地址运行

段重定位是Bootloader的核心能力之一。说白了,就是让一段代码能在Flash的不同位置执行。最常见的场景是:Bootloader把APP从CAN总线收到的固件数据,写入到APP_FLASH区域,然后跳转过去。

GCC里实现重定位,主要靠位置无关代码(PIC)和链接脚本符号:

/* 在APP的链接脚本中,定义APP的起始地址 */
APP_START = 0x08010000;

SECTIONS
{
    .text APP_START : {
        *(.text*)
    } > APP_FLASH

    /* 导出一个跳转地址符号 */
    _app_entry = APP_START + 4;  /* 跳过中断向量表的前4字节(栈指针) */
}

Bootloader跳转时,只需要读取APP的中断向量表,取出栈指针和复位地址:

typedef void (*app_func_t)(void);

void jump_to_app(uint32_t app_addr)
{
    uint32_t msp = *(uint32_t*)app_addr;        // 栈指针
    uint32_t reset = *(uint32_t*)(app_addr + 4); // 复位地址

    __set_MSP(msp);                              // 切换栈
    app_func_t app = (app_func_t)reset;
    app();                                       // 跳转
}

关键点: 跳转前必须关闭全局中断、禁用外设时钟、清理外设状态。否则APP启动时可能因为外设处于异常状态而卡死。我习惯在跳转前调用一个DeInitAllPeripherals()函数。

4.6 链接脚本常见错误与调试技巧

错误现象 可能原因 排查方法
程序跑飞,PC指向0xFFFFFFFF 中断向量表地址不对,或跳转地址错误 查看.map文件,确认.text段起始地址
全局变量值不对 .data段拷贝未执行,或拷贝源地址错误 检查_sidata和_sdata的值是否匹配
链接时报"region overflow" 段大小超过内存区域长度 优化代码大小,或调整分区大小
函数指针调用异常 函数被链接到了错误的内存区域 用__attribute__((section(".text")))强制指定

调试技巧: 每次修改链接脚本后,第一件事就是看生成的.map文件。我通常会搜索"__text_start"、"__data_start"等符号,确认地址和预期一致。另外,用objdump -h查看elf文件的段信息,也能快速定位问题。

4.7 本章知识体系

下面这张图总结了链接脚本的核心要素和它们之间的关系:

链接脚本知识体系 链接脚本知识体系 内存布局 (MEMORY) 段定义 (SECTIONS) 符号导出 Flash / RAM / EEPROM 起始地址 + 长度 属性 (rx/rwx/rw) .text / .data / .bss 加载地址 vs 运行地址 段对齐与排序 _sdata / _edata _sbss / _ebss 启动代码引用 应用:Bootloader跳转 / 双区启动 / EEPROM模拟 💡 说明: GCC用MEMORY+SECTIONS,Tasking用memory+section_setup+group。 核心思想一致:定义硬件资源 → 分配段 → 导出符号供启动代码使用。

链接脚本是嵌入式开发的「地图」。地图画错了,代码跑得再快也没用。我建议你每次新建项目时,先花半小时把链接脚本写清楚,把.map文件看懂。这半小时,能帮你省下后面几天的调试时间。