链接脚本与内存布局:从入门到实战

链接脚本,说白了就是告诉编译器「你的代码该往哪儿放」。很多新手觉得这玩意儿可有可无,直接用默认设置就行。我刚开始做嵌入式时也这么想,直到有一次芯片死活跑不起来——查了三天,最后发现是 .bss 段没对齐,中断向量表直接错位了。

嗯,从那以后我再也不敢轻视链接脚本了。今天咱们就把这块硬骨头啃下来。

链接脚本语法详解

链接脚本的基本结构其实不复杂。我习惯把它拆成三块:内存区域定义、段分配、符号处理。先看一个最简模板:

SECTIONS
{
    . = 0x80000000;
    .text : { *(.text) }
    .data : { *(.data) }
    .bss  : { *(.bss) }
}

这段代码干了什么?它把 .text 段放在 0x80000000,后面依次跟 .data 和 .bss。但实际项目中这么写肯定出事——你没考虑对齐,也没处理只读数据段。

注意: 默认链接脚本通常藏在工具链目录里。我建议你第一次写链接脚本时,先用 ld --verbose 把默认脚本导出来,在此基础上改,而不是从零开始。

内存区域定义:MEMORY 命令

MEMORY 命令用来描述芯片的物理内存布局。你想想看,一颗芯片可能有 Flash、SRAM、DDR,它们的地址范围和属性都不一样。用 MEMORY 命令可以清晰地把它们划分出来:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    SRAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
    DDR   (rw)  : ORIGIN = 0x80000000, LENGTH = 64M
}

这里每个区域都有三个属性:起始地址(ORIGIN)、长度(LENGTH)、访问权限(r/w/x)。我在项目中遇到过有人把 Flash 写成 rwx,结果链接器报了一堆警告——Flash 本来就不能写,你给它 w 权限干嘛?

属性 含义 典型用途
r 可读 所有内存区域
w 可写 RAM、DDR
x 可执行 Flash、SRAM(代码段)

段(Section)的分配与对齐

段分配是链接脚本的核心。每个段都有名字、内容和放置规则。常见的段包括:

  • .text:代码段,只读可执行
  • .rodata:只读数据,比如字符串常量
  • .data:已初始化全局变量,可读写
  • .bss:未初始化全局变量,可读写
  • .stack:栈空间(有些架构需要显式定义)

对齐是个容易踩坑的地方。我记得有一次调试一个网络协议栈,数据包总是校验失败。查到最后发现是 .data 段没按 4 字节对齐,结构体成员错位了。来看一个带对齐的段分配:

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

    . = ALIGN(16);
    .rodata : { *(.rodata) } > FLASH

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

这里有个关键点:> SRAM AT> FLASH 表示 .data 段运行时在 SRAM,但初始数据存储在 Flash。启动代码需要把数据从 Flash 拷贝到 SRAM——这个拷贝逻辑你得自己写。

经验之谈: 我习惯在链接脚本里定义 _sdata、_edata 这类符号,然后在启动汇编里直接用。这样代码更清晰,也方便调试时查看内存布局。

符号解析与重定位

符号解析和重定位是链接器干的两件大事。简单说:

  • 符号解析:把每个符号引用找到对应的定义
  • 重定位:把符号的虚拟地址改成实际地址

为什么会需要重定位?因为编译器编译每个 .c 文件时,不知道其他函数在哪儿。它只能生成一个「占位符」,等链接器来填真实地址。这个过程在链接脚本里可以通过 PROVIDE 和 EXTERN 来控制:

PROVIDE(_stack_top = ORIGIN(SRAM) + LENGTH(SRAM));
EXTERN(_start);

PROVIDE 的意思是「如果用户没定义这个符号,我就提供一个默认值」。EXTERN 则是强制链接器把某个符号当作外部引用,即使当前没用到也要保留。

核心概念: 重定位表(.rel.text、.rel.data 等)记录了所有需要修改的位置。链接器遍历这些表,把占位符替换成真实地址。如果你看到链接错误说「relocation truncated to fit」,通常是地址空间不够了。

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

下面是我在一个 STM32 项目里用过的链接脚本,去掉了一些平台相关细节,保留了核心结构:

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

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

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

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

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

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

    ._user_heap_stack :
    {
        . = ALIGN(8);
        PROVIDE(_end = .);
        PROVIDE(_heap_start = .);
        . = . + _heap_size;
        . = . + _stack_size;
        . = ALIGN(8);
        PROVIDE(_heap_end = .);
    } > RAM
}

这个脚本里我特别加了 KEEP 关键字——中断向量表不能被优化掉,否则芯片上电直接跑飞。还有 LOADADDR 用来获取 .data 段在 Flash 里的存储地址,启动代码拷贝时要用。

知识结构图

下面这张图总结了链接脚本的核心知识体系:

链接脚本与内存布局知识体系 链接脚本 MEMORY 命令 SECTIONS 分配 符号与重定位 ORIGIN / LENGTH 访问权限 r/w/x .text / .data / .bss ALIGN / KEEP / AT PROVIDE / EXTERN 重定位表处理 链接脚本是编译流程的最后一道关卡 决定了代码在芯片上的最终布局

常见避坑指南

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

  • 段重叠:两个段放到同一个地址范围,链接器不会报错,但运行时会互相覆盖。我建议每次改完链接脚本都用 readelf -S 检查段地址。
  • 对齐不一致:C 代码里用 __attribute__((aligned(16))),但链接脚本里只做了 4 字节对齐——这种不匹配会导致性能下降甚至崩溃。
  • 启动代码缺失:.data 段的拷贝和 .bss 段的清零必须在 main() 之前完成。我见过有人忘了写这段启动代码,全局变量全是随机值。
  • 符号冲突:两个 .o 文件定义了同名全局变量,链接器不会报错,只会用第一个。用 -Wl,--warn-common 可以提前发现这种问题。
调试技巧: 链接脚本出问题时,先用 ld -M 生成内存映射文件,看看每个段实际放到了哪里。这个文件比任何调试器都直观。

链接脚本看起来只是几行配置,但它决定了整个固件能否正确运行。我每次做新项目,第一件事就是根据芯片手册重写链接脚本——虽然麻烦,但能避免后面很多莫名其妙的 bug。