链接脚本:ld链接脚本语法,内存区域定义,节区放置规则

说到链接脚本,我得先坦白一件事。我刚开始学嵌入式那会儿,觉得这东西就是个黑盒子。编译器把 .o 文件扔进去,出来一个 .bin,中间发生了什么?说实话,我用了两年 GNU 工具链,都没认真看过默认的 ld 脚本长什么样。

直到有一次,我自己做一块 STM32 的板子,RAM 只有 64K,Flash 256K。默认链接脚本把 .rodata 扔到了 RAM 里,程序一跑就崩。我查了三天,最后发现是链接脚本没指定只读数据放到 Flash 段。嗯,从那以后,我再也不敢跳过 ld 脚本了。

说白了,链接脚本就是告诉链接器:你的代码该放哪儿,数据该放哪儿,堆栈怎么安排。今天咱们就把这东西彻底讲透。

1. 链接脚本的基本结构

一个典型的 ld 脚本,长这样:

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)

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

SECTIONS
{
    .text : {
        *(.text)
        *(.rodata)
    } > FLASH

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

    .bss : {
        *(.bss)
    } > RAM
}

你看,核心就三块:OUTPUT 设置MEMORY 定义SECTIONS 规则。咱们一个一个拆开讲。

2. MEMORY:内存区域定义

MEMORY 块用来描述目标芯片的物理内存布局。每个区域有四个属性:名字、权限、起始地址、长度。

属性 含义 示例
名字 自定义标识符 FLASH, RAM, SRAM1
权限 r=读, w=写, x=执行 (rx), (rwx), (rw)
ORIGIN 起始地址 0x08000000
LENGTH 区域大小 512K, 128M, 0x10000

我个人习惯把 MEMORY 定义放在脚本最前面。为什么?因为后面的 SECTIONS 要引用这些区域,先定义清楚,后面写起来才顺手。

注意:ORIGIN 和 LENGTH 的单位是字节。如果你写 LENGTH = 128,那就是 128 字节,不是 128K。我见过有人把 128K 写成 128,结果链接出来的镜像只有 128 字节,烧进去直接跑飞。

3. SECTIONS:节区放置规则

SECTIONS 是链接脚本的核心。它定义了每个节区(section)该放到哪个内存区域,以及节区之间的顺序。

基本语法是这样的:

SECTIONS
{
    .节区名 : {
        输入文件列表
    } > 内存区域
}

举个例子:

.text : {
    *(.text)       /* 所有 .o 文件的 .text 段 */
    *(.rodata)     /* 所有 .o 文件的 .rodata 段 */
    _etext = .;    /* 记录 .text 结束地址 */
} > FLASH

这里有个小技巧:_etext = .; 这行代码定义了一个符号,它的值等于当前地址计数器的值。你可以在 C 代码里用 extern uint32_t _etext; 来获取这个地址。我在做启动代码时经常用这招,用来计算初始化数据的长度。

4. 地址计数器:点号(.)的秘密

点号 . 是链接脚本里的地址计数器。它表示当前正在处理的虚拟地址。每次往节区里放数据,点号就会自动增加。

. = ALIGN(4);   /* 对齐到 4 字节 */
.data : {
    *(.data)
} > RAM

为什么要对齐?因为 ARM 处理器访问未对齐的数据会触发异常。我踩过这个坑:结构体里有个 uint32_t 成员,地址没对齐,程序跑着跑着就进 HardFault 了。查了半天,最后发现是链接脚本里忘了加 ALIGN。

建议:每个节区开始前都加一句 . = ALIGN(4); 或者 . = ALIGN(8);。这不会浪费多少空间,但能避免很多奇怪的崩溃问题。

5. AT 关键字:加载地址 vs 运行地址

这是链接脚本里最容易让人迷糊的地方。一个节区有两个地址:

  • 加载地址(LMA):程序烧录时存放的位置,通常是 Flash
  • 运行地址(VMA):程序运行时实际的位置,通常是 RAM

看这个例子:

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

意思是:.data 段运行时在 RAM(VMA = RAM),但它的初始值存储在 Flash 里(LMA = FLASH)。启动代码需要把这段数据从 Flash 拷贝到 RAM。

我记得第一次写裸机程序时,没加 AT> FLASH,结果 .data 段直接烧到了 RAM 里。掉电重启后,所有全局变量都变成了随机值。嗯,那次教训挺深刻的。

6. 实战:一个完整的链接脚本

下面是我个人常用的一个模板,适用于 Cortex-M3/M4 芯片:

ENTRY(Reset_Handler)

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

SECTIONS
{
    . = ALIGN(4);
    .text : {
        KEEP(*(.isr_vector))    /* 中断向量表,必须保留 */
        *(.text)
        *(.text*)
        *(.rodata)
        *(.rodata*)
        _etext = .;
    } > FLASH

    . = ALIGN(4);
    .data : {
        _sdata = .;
        *(.data)
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

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

    . = ALIGN(8);
    _estack = ORIGIN(RAM) + LENGTH(RAM);
}

这个脚本里我做了几件事:

  • KEEP 保护中断向量表,防止被优化掉
  • .data 段定义了 _sdata_edata,方便启动代码做拷贝
  • .bss 段定义了 _sbss_ebss,用于清零
  • 栈顶 _estack 直接算出来,放在 RAM 末尾

7. 知识体系总览

下面这张图总结了链接脚本的核心逻辑:

链接脚本核心结构 链接脚本 (.ld) OUTPUT 设置 MEMORY 定义 SECTIONS 规则 OUTPUT_FORMAT / ARCH / ENTRY ORIGIN / LENGTH / 权限 节区名 / 输入文件 / > 区域 加载地址 (LMA) vs 运行地址 (VMA) 地址计数器 (.) 与 ALIGN 对齐 启动代码搬运数据 避免未对齐访问异常

8. 避坑指南

最后,分享几个我踩过的坑:

  • 忘记 KEEP 中断向量表:链接器觉得 .isr_vector 没被引用,直接优化掉了。结果芯片上电后找不到复位入口,直接死机。
  • AT 和 > 写反> FLASH AT> RAM> RAM AT> FLASH 完全是两回事。写反了数据就放错地方了。
  • 栈顶地址算错:我见过有人把栈顶写在 RAM 起始地址,结果一压栈就把代码数据覆盖了。
  • 节区顺序搞乱:.text 必须在最前面,因为 Flash 的起始地址就是复位向量表。如果把 .data 放前面,芯片上电后找不到入口。

链接脚本这东西,说白了就是给芯片画地图。地图画对了,程序跑起来就顺;画错了,各种奇怪问题都会冒出来。我建议你每次新建项目时,都从芯片厂商提供的默认脚本开始改,不要自己从头写。等用熟了,再慢慢调整优化。

核心要点回顾:

  • MEMORY 定义物理内存,SECTIONS 定义逻辑布局
  • 地址计数器 . 代表当前位置,ALIGN 保证对齐
  • AT 关键字区分加载地址和运行地址
  • KEEP 保护关键节区不被优化
  • 启动代码负责搬运 .data 和清零 .bss

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