3. 链接脚本深入:.ld 文件结构解析与内存布局设计

链接脚本,说白了就是告诉链接器:你的代码该往哪儿放,数据该搁在哪个地址。很多做应用层开发的同事觉得这东西可有可无,但做 Bootloader 和 RTOS 移植,你绕不开它。我刚开始接触 .ld 文件时也一头雾水,直到有一次把中断向量表放错了位置,板子死活不启动……嗯,从那以后我老老实实把链接脚本啃透了。

3.1 链接脚本的核心结构

一个标准的 GNU LD 链接脚本,通常由三大部分组成:内存布局定义、段(Section)划分、符号与重定位规则。我个人习惯把 .ld 文件看作一张“搬家清单”——告诉链接器每个“箱子”(段)应该搬到哪个“房间”(内存区域)。

核心语法速览:

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

SECTIONS
{
    .text : { *(.text) } > FLASH
    .data : { *(.data) } > RAM AT > FLASH
    .bss  : { *(.bss)  } > RAM
}

你看,结构其实很清晰。MEMORY 定义硬件有哪些可用内存区域,SECTIONS 定义每个段该放哪儿。这里有个关键点:.data 段用了 > RAM AT > FLASH 这种写法,意思是运行时在 RAM,但初始数据存储在 FLASH 里——这就是 Bootloader 启动时要把数据从 Flash 拷贝到 RAM 的原因。

3.2 内存布局定义:MEMORY 命令详解

MEMORY 命令定义的是目标芯片的物理内存资源。每个区域需要指定:名字、属性、起始地址、长度。

属性 含义 典型用途
r 只读 Flash、ROM
w 可写 RAM
x 可执行 代码段所在区域
! 取反属性 排除特定权限

我的经验:定义内存区域时,建议把 Bootloader 和应用程序的 Flash 区域分开。我曾经在一个项目里把 Bootloader 和 App 的 Flash 区域重叠了,结果升级一次砖一次……后来我习惯这样写:

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

3.3 段的划分与重定位:SECTIONS 命令实战

SECTIONS 是链接脚本的核心。每个段定义了三件事:输入哪些目标文件的内容、输出段的排列顺序、输出段放在哪个内存区域。

3.3.1 标准段划分

一个典型的嵌入式项目,至少需要这几个段:

  • .text:代码段,包含函数指令和只读常量
  • .rodata:只读数据段,字符串常量、const 变量
  • .data:已初始化全局变量,运行时在 RAM,初始值在 Flash
  • .bss:未初始化全局变量,启动时需清零
  • .stack:栈空间(有些芯片需要显式定义)
  • .heap:堆空间(malloc 使用)

避坑指南:我曾经遇到一个 Bug,.bss 段没有在启动代码里清零,结果全局变量初始值全是随机数。排查了两天才发现是链接脚本里 .bss 段的 AT> 写错了,导致链接器没有生成 .bss 的加载地址信息。记住:.bss 段不需要 AT 属性,它只在 RAM 中存在,没有 Flash 备份。

3.3.2 重定位机制:为什么需要 AT 属性

你想想看,芯片上电时 RAM 的内容是随机的。但 .data 段里的全局变量是有初始值的,比如 int count = 100;。这个 100 必须事先存在 Flash 里,启动时再拷贝到 RAM。这就是 AT> 的作用——告诉链接器:这个段的运行时地址在 RAM,但加载地址在 Flash。

链接器会生成两个符号:

  • _sdata / _edata:RAM 中 .data 段的起始和结束地址
  • _la_data:Flash 中 .data 初始值的存放地址

启动代码里就靠这三个符号完成拷贝:

// 伪代码示意
extern uint32_t _sdata, _edata, _la_data;
for (dst = &_sdata; dst < &_edata; dst++, src++)
    *dst = *src;

3.4 链接脚本中的符号与对齐

链接脚本里可以定义符号,这些符号在 C 代码里用 extern 声明就能访问。我个人常用的几个:

SECTIONS
{
    . = ALIGN(4);  /* 4字节对齐 */
    _sdata = .;    /* 当前地址赋值给符号 */
    .data : { *(.data) } > RAM AT > FLASH
    _edata = .;
    
    . = ALIGN(4);
    _sbss = .;
    .bss : { *(.bss) } > RAM
    _ebss = .;
}

这里 ALIGN(4) 很重要。ARM Cortex-M 系列要求中断向量表必须 4 字节对齐,有些外设寄存器也要求对齐访问。我见过有人忘了加 ALIGN,结果 .data 段起始地址没对齐,程序跑起来偶尔死机——这种问题最难查。

3.5 链接脚本知识体系总览

下面这张图是我自己总结的链接脚本知识结构,每次写 .ld 文件前我都会对照着检查一遍:

链接脚本(.ld)知识体系 链接脚本 .ld MEMORY 内存定义 SECTIONS 段划分 符号与对齐规则 ORIGIN LENGTH 属性: r / w / x / ! .text .data .bss AT > 加载地址重定位 ALIGN(n) 对齐 符号定义: _sdata 关键要点 1. MEMORY 定义硬件资源,SECTIONS 定义软件布局 2. .data 段需要 AT 属性实现 Flash→RAM 拷贝 3. .bss 段不需要 AT,启动时直接清零 4. ALIGN 对齐是嵌入式稳定运行的基本保障

3.6 实战:一个完整的 Bootloader 链接脚本

最后,我分享一个实际项目中用过的链接脚本模板。这个脚本支持 Bootloader 和 App 分区,包含了中断向量表重定位:

/* bootloader.ld - Bootloader 专用链接脚本 */
MEMORY
{
    BOOT_FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    APP_FLASH  (rx)  : ORIGIN = 0x08008000, LENGTH = 480K
    RAM        (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    /* 中断向量表必须放在起始地址 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > BOOT_FLASH

    /* 代码段 */
    .text :
    {
        . = ALIGN(4);
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > BOOT_FLASH

    /* 已初始化数据 - 运行时在RAM,加载在Flash */
    .data : AT(ADDR(.text) + SIZEOF(.text))
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    /* 未初始化数据 - 仅RAM */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM

    /* 堆栈定义 */
    .stack (NOLOAD) :
    {
        . = ALIGN(8);
        _sstack = .;
        . = . + 0x1000;  /* 4KB 栈空间 */
        . = ALIGN(8);
        _estack = .;
    } > RAM
}

个人建议:写链接脚本时,每次修改后都用 arm-none-eabi-objdump -h 查看输出文件的段信息,确认每个段的 VMA(运行时地址)和 LMA(加载地址)是否符合预期。我每次移植芯片都会跑一遍这个检查,能避免 90% 的启动异常问题。