链接脚本深入:.ld 文件如何掌控你的内存布局

说实话,很多做嵌入式的新手,第一次看到链接脚本(.ld 文件)时,都会觉得它像天书。一堆花括号、点号、大于号,看着就头疼。我当年也一样,第一次接触 STM32 的链接脚本,直接复制粘贴就用了,结果程序跑飞了都不知道为什么。

后来我才明白,链接脚本其实就是告诉链接器:你的芯片内存长什么样,代码该放哪,数据该放哪。说白了,它就是一张内存地图。今天我们就把它彻底讲透。

链接脚本到底在干什么?

你写好的 C 代码,经过编译,变成了一个个目标文件(.o 文件)。这些目标文件里,有代码段(.text)、已初始化数据段(.data)、未初始化数据段(.bss)等等。链接器的工作,就是把这些散落的段,按照链接脚本的指示,安排到芯片内存的指定位置。

我习惯把链接脚本比作一个「搬家公司的调度员」。你有一堆家具(代码和数据),调度员告诉你:沙发放客厅,床放卧室,书桌放书房。链接脚本就是那个调度员,它告诉链接器:.text 放 Flash,.data 放 RAM,.bss 也放 RAM。

核心作用总结:

  • 定义芯片的内存布局(Flash 从哪开始,RAM 从哪开始)
  • 指定各个段的存放位置(代码放 Flash,变量放 RAM)
  • 提供符号地址(比如 _sdata、_ebss,供启动代码使用)

内存布局定义:你得先告诉链接器你的芯片长什么样

每个芯片的内存映射都不一样。比如 STM32F103C8T6,Flash 从 0x08000000 开始,大小 64KB;RAM 从 0x20000000 开始,大小 20KB。这些信息必须在链接脚本里先声明。

看一个典型的声明:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
}

这里我解释一下:FLASH (rx) 表示这块内存是只读(r)且可执行(x)的。RAM (xrw) 表示可读可写可执行。ORIGIN 是起始地址,LENGTH 是大小。

嗯,这里要注意:LENGTH 的单位是 K 或 M,别写错了。我曾经见过有人把 64K 写成 64,结果链接器只分配了 64 字节,程序直接跑飞。

段的分配规则:.text、.data、.bss 各归其位

有了内存区域,接下来就是分配段了。这是链接脚本的核心部分,也是最容易出坑的地方。

.text 段:代码和只读数据

.text 段包含你的程序指令、常量字符串、函数表等。它必须放在 Flash 里,因为 Flash 是非易失性的,掉电不丢失。

SECTIONS
{
    .text :
    {
        *(.text)           /* 所有目标文件的 .text 段 */
        *(.text.*)         /* 所有 .text.xxx 子段 */
        *(.rodata)         /* 只读数据,比如 const 变量 */
        *(.rodata.*)
        _etext = .;        /* 标记 .text 结束地址 */
    } > FLASH
}

注意那个 _etext = .,这里的 . 表示当前地址计数器。这行代码把 .text 段的结束地址保存到一个符号里,启动代码后面会用到它。

我的经验: 如果你用了 C++ 的全局对象构造函数,或者用了 __attribute__((section(".text.init"))),记得在 .text 段里加上对应的输入段。不然链接器会报 undefined reference 错误。

.data 段:已初始化的全局变量

.data 段存放的是你定义时赋了初值的全局变量和静态变量。比如 int a = 10;,这个 10 就存在 .data 段里。

但有个问题:.data 段的内容在程序运行前必须存在,而 Flash 是只读的,RAM 是可读写的。所以实际做法是:.data 段在 Flash 里存储初始值,启动时由 bootloader 或 CRT 代码复制到 RAM 中。

    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;        /* .data 起始地址(RAM 中) */
        *(.data)
        *(.data.*)
        _edata = .;        /* .data 结束地址(RAM 中) */
    } > RAM AT > FLASH

这里有个关键语法:> RAM AT > FLASH。意思是:运行时放在 RAM 里,但加载地址在 Flash 里。链接器会自动计算 Flash 中 .data 的存储位置,并生成符号供启动代码使用。

我曾经踩过的坑: 有一次我忘了写 AT > FLASH,结果链接器把 .data 直接放在 RAM 的起始地址,但 RAM 掉电就丢数据。上电后 .data 里的初始值全是随机数,程序直接崩溃。这个坑我花了整整一天才找到。

.bss 段:未初始化的全局变量

.bss 段存放的是未初始化或初始化为 0 的全局变量。比如 int b; 或 int c = 0;。这些变量在 Flash 里不需要占用空间,只需要在 RAM 里预留位置,然后在启动时清零。

    .bss :
    {
        _sbss = .;         /* .bss 起始地址 */
        *(.bss)
        *(.bss.*)
        *(COMMON)          /* 通用符号 */
        _ebss = .;         /* .bss 结束地址 */
    } > RAM

注意 .bss 没有 AT > FLASH,因为它不需要从 Flash 复制数据,只需要在 RAM 里清零即可。

完整的链接脚本示例

把上面几段拼起来,就是一个完整的链接脚本了。我贴一个我常用的模板:

ENTRY(Reset_Handler)

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    .text :
    {
        *(.isr_vector)     /* 中断向量表 */
        *(.text)
        *(.text.*)
        *(.rodata)
        *(.rodata.*)
        _etext = .;
    } > FLASH

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

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

这个脚本定义了中断向量表、代码段、只读数据段、已初始化数据段和未初始化数据段。启动代码会利用 _sdata、_edata、_sbss、_ebss 这些符号来完成 .data 的复制和 .bss 的清零。

链接脚本中的符号:启动代码的桥梁

你可能会问:启动代码怎么知道 .data 要复制多少字节?.bss 要从哪开始清零?答案就是链接脚本里定义的符号。

符号名 含义 启动代码用途
_sdata .data 段在 RAM 中的起始地址 复制目标地址
_edata .data 段在 RAM 中的结束地址 计算复制长度
_etext .text 段结束地址(即 .data 在 Flash 中的存储位置) 复制源地址
_sbss .bss 段起始地址 清零起始地址
_ebss .bss 段结束地址 清零结束地址

启动代码里通常是这样用的:

extern uint32_t _sdata, _edata, _etext;
extern uint32_t _sbss, _ebss;

void Reset_Handler(void)
{
    uint32_t *src, *dst;

    /* 复制 .data 段 */
    src = &_etext;
    dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }

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

    /* 跳转到 main */
    main();
}

你看,链接脚本和启动代码就是这么配合的。链接脚本提供地址信息,启动代码执行实际操作。

用 SVG 理清整个流程

说了这么多,我画张图帮你把整个流程串起来。这张图展示了从编译到链接,再到启动时内存布局的完整过程。

链接脚本与内存布局流程 链接脚本控制的内存布局流程 C 源文件 (.c) 编译 → 目标文件 (.o) 链接器 (ld) 链接脚本 (.ld) 链接输出:可执行文件 (.elf / .bin / .hex) Flash 0x08000000 .text (代码 + 只读数据) .data 初始值 (加载地址) RAM 0x20000000 .data (运行时) .bss (清零后) 启动时操作 ① 复制 .data 到 RAM ② 清零 .bss 💡 关键理解: • .text 段直接运行在 Flash 中,不需要复制 • .data 段在 Flash 中存初始值,启动时复制到 RAM • .bss 段不占 Flash 空间,启动时在 RAM 中清零

常见问题与避坑指南

最后,我分享几个我在项目中实际遇到过的问题,希望能帮你少走弯路。

问题 1:.data 段复制后程序还是跑飞

我曾经在移植一个 bootloader 时,发现 .data 段复制后,全局变量还是不对。查了半天,发现是链接脚本里 AT > FLASH 写成了 AT > RAM。结果链接器把 .data 的加载地址也放在了 RAM 里,上电后 RAM 里的初始值全是垃圾。记住:.data 的加载地址必须在 Flash 里。

问题 2:中断向量表地址不对

有一次我写了一个 bootloader,需要把应用程序的中断向量表偏移到 0x08010000。结果我忘了在链接脚本里设置 VECT_TAB_OFFSET,导致中断响应全乱套了。后来我在 .text 段里显式指定了中断向量表的位置:KEEP(*(.isr_vector)),并在代码里调用了 SCB->VTOR = FLASH_BASE | 0x10000; 才解决。

问题 3:链接脚本里忘了处理 COMMON 段

如果你在多个源文件里定义了未初始化的全局变量,链接器可能会把它们放在 COMMON 段里。如果 .bss 段里没包含 *(COMMON),这些变量就不会被清零,初始值就是随机的。我建议在 .bss 段里始终加上 *(COMMON),避免这种隐晦的 bug。

好了,链接脚本的核心内容就这些。你只要理解了 MEMORY 和 SECTIONS 这两个关键字,再搞清楚 .text、.data、.bss 的分配规则,就能自己写链接脚本了。下次遇到程序跑飞,别忘了先检查一下 .ld 文件——很多时候,问题就出在这张「内存地图」上。