3. 链接脚本(.ld)深度解析:内存布局定义、段映射与移植要点

链接脚本,说白了就是告诉编译器:「你的代码该放哪儿,数据该放哪儿」。很多做MCU开发的朋友,写了好几年代码都没碰过.ld文件。但做Bootloader,这关你必须过。

我刚开始移植U-Boot时,就栽在链接脚本上。板子怎么都跑不起来,调试了整整两天,最后发现是中断向量表放错了位置。嗯,从那以后,我每次移植第一件事就是先读透.ld文件。

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

一个MCU的Flash、RAM地址是固定的。你的代码编译出来是一堆二进制段,链接脚本就是告诉链接器:

  • .text(代码段)→ 放到Flash的哪个偏移
  • .rodata(只读数据)→ 紧跟在代码后面
  • .data(初始化数据)→ 运行时从Flash拷贝到RAM
  • .bss(未初始化数据)→ 在RAM中清零

你想想看,如果没有链接脚本,编译器怎么知道你的STM32F4的Flash是从0x08000000开始的?它不知道。所以这个脚本就是MCU的「地址地图」。

3.2 内存布局定义——先画好地盘

一个典型的链接脚本开头,会先定义内存区域。我习惯用MEMORY命令来画地盘:

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

这里定义了:

  • FLASH:起始0x08000000,大小1MB,属性可读可执行
  • RAM:起始0x20000000,大小128KB,属性可读可写可执行
注意:Bootloader和APP的Flash起始地址必须不同。比如Bootloader放在0x08000000,APP就要偏移到0x08010000或更后。我见过有人把两个都放在同一个起始地址,结果一运行就死机。

对于Bootloader场景,我们通常需要预留一块区域给APP。所以内存定义会变成这样:

MEMORY
{
    BOOT_FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    APP_FLASH  (rx)  : ORIGIN = 0x08008000, LENGTH = 992K
    RAM        (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

这样Bootloader和APP的地址空间就彻底分开了,互不干扰。

3.3 段(Section)的映射——把代码和数据放到正确的位置

定义好内存区域后,接下来就是告诉链接器:每个段该放到哪个区域。这是链接脚本的核心部分。

3.3.1 .text段:代码和只读数据

SECTIONS
{
    .text :
    {
        . = ALIGN(4);
        _stext = .;
        *(.isr_vector)    /* 中断向量表 */
        *(.text*)         /* 代码 */
        *(.rodata*)       /* 只读数据 */
        . = ALIGN(4);
        _etext = .;
    } > BOOT_FLASH
}

这里有个关键点:中断向量表必须放在段的最开头。MCU上电后,从Flash的起始地址读取栈指针和复位向量。如果向量表没放在开头,芯片直接跑飞。

我的习惯:在链接脚本里显式定义_stext和_etext这两个符号。这样在C代码里可以用extern声明它们,方便计算代码大小和校验和。做固件升级时特别有用。

3.3.2 .data段:初始化数据

.data段比较特殊。它存储的是全局变量的初始值,这些值在编译时放在Flash里,但运行时必须拷贝到RAM中才能读写。

    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

注意这里的AT指令。它告诉链接器:这个段的加载地址(LMA)在Flash里,紧跟在.text后面;但运行地址(VMA)在RAM里。启动代码需要把数据从LMA拷贝到VMA。

我曾经在一个项目里忘了加AT,结果.data段的LMA和VMA都指向RAM。下载程序后,RAM里的数据是随机的,全局变量全乱套了。排查了好久才发现是链接脚本的问题。

3.3.3 .bss段:未初始化数据

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

.bss段不需要从Flash拷贝,但启动代码必须把它清零。否则全局变量初始值可能是随机的。

3.4 链接脚本移植要点——避坑指南

移植链接脚本时,有几个坑我踩过,分享给你:

要点 说明 我的经验
向量表偏移 APP的向量表必须偏移,否则中断会跑到Bootloader的向量表 记得在APP代码里设置SCB->VTOR寄存器
栈顶地址 链接脚本第一行通常定义栈顶,必须指向RAM末尾 我习惯用_estack = ORIGIN(RAM) + LENGTH(RAM);
对齐问题 ARM Cortex-M要求4字节对齐,否则可能触发硬件错误 每个段前后都加. = ALIGN(4);
LMA与VMA .data段的加载地址和运行地址不同,必须用AT指定 忘了AT的话,启动代码拷贝时会读到错误的数据
符号导出 用PROVIDE导出关键符号,方便C代码引用 比如PROVIDE(_sflash = .);

3.5 一个完整的Bootloader链接脚本示例

下面是我常用的一个Bootloader链接脚本模板,你可以直接参考:

/* 栈顶地址:RAM末尾 */
_estack = ORIGIN(RAM) + LENGTH(RAM);

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

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

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

    .data : AT(ADDR(.text) + SIZEOF(.text))
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}
核心思路:Bootloader的链接脚本要尽量简单。它只负责启动、检测升级条件、跳转到APP。所以代码量很小,32KB的Flash完全够用。不要往Bootloader里塞太多功能,否则容易出问题。

3.6 链接脚本与固件升级的关系

做固件升级时,链接脚本直接决定了升级包的生成方式。我画了一张图来说明它们的关系:

链接脚本与固件升级的关系 链接脚本(.ld) 定义内存布局 Flash起始/大小 RAM起始/大小 段映射 .text / .data / .bss 编译输出(.elf/.bin) 固件升级包 烧录/远程升级 关键点 • 链接脚本决定了APP的起始地址(偏移量) • 升级包必须按照链接脚本定义的地址生成 • Bootloader通过链接脚本的符号知道APP的入口地址

从这张图可以看出,链接脚本是整个固件升级链条的起点。它决定了:

  • APP的起始地址(决定了升级包该烧录到哪个位置)
  • 中断向量表的位置(决定了跳转后中断能否正常工作)
  • RAM的使用情况(决定了Bootloader和APP能否共用RAM)
实用技巧:在链接脚本里定义_app_start和_app_size这样的符号,然后在Bootloader代码里用extern引用。这样即使以后调整了Flash分区,Bootloader代码也不需要修改,只需要改链接脚本就行。

3.7 总结

链接脚本看起来复杂,其实核心就三件事:

  1. 画地盘:用MEMORY定义Flash和RAM的起始地址和大小
  2. 分房间:用SECTIONS把.text、.data、.bss放到正确的位置
  3. 留门牌:用符号导出关键地址,方便C代码引用

做Bootloader移植时,我建议你先从芯片厂商提供的示例链接脚本改起。不要自己从头写,容易漏掉细节。改完之后,用objdump -h查看各段的地址,确认它们都在你期望的位置上。

嗯,链接脚本这块就讲到这里。记住一句话:链接脚本错了,代码写得再好也跑不起来。