3. 链接脚本深度解析:链接脚本的作用、内存布局定义、段(Section)的划分、符号表的生成

说实话,很多做嵌入式开发的朋友,写了几年 C 代码,却从来没仔细看过链接脚本。我刚开始带项目时也是这样,直到有一次移植 Bootloader 到一颗新芯片上,程序死活跑不起来。折腾了两天,最后发现是链接脚本里 RAM 地址写错了。嗯,从那以后,我再也不敢把链接脚本当「黑盒子」了。

链接脚本,说白了就是告诉编译器:你的代码该放哪儿,数据该放哪儿,堆栈该放哪儿。它决定了最终生成的二进制文件长什么样。今天我们就把它彻底拆开看看。

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

你写好的 C 代码,经过编译变成一堆 .o 目标文件。这些文件里都是零散的代码段、数据段。链接脚本的任务,就是把这些碎片拼成一个完整的、能在芯片上运行的镜像。

我习惯把链接脚本比作「搬家公司的装箱清单」:

  • 哪些东西要搬 —— 代码段、数据段、BSS 段
  • 搬到哪个房间 —— Flash 地址、RAM 地址
  • 怎么摆放 —— 段的顺序、对齐方式
  • 贴什么标签 —— 符号地址、入口点

核心要点:链接脚本不产生任何代码,它只负责「编排」。但编排错了,程序一定跑不起来。

3.2 内存布局定义 —— 你得先知道芯片长什么样

写链接脚本的第一步,是定义目标芯片的内存布局。不同芯片的 Flash 和 RAM 地址范围完全不同。比如 STM32F103 和 S32K144,它们的地址映射天差地别。

我一般会在链接脚本开头用 MEMORY 命令来声明:

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

这里每个参数都有讲究:

参数 含义 我的经验
ORIGIN 起始地址 必须和芯片手册一致,差一个字都不行
LENGTH 可用大小 别写满,留点余量给调试
rx 读+执行权限 Flash 一般只读执行,不能写
rwx 读+写+执行 RAM 可以读写,也能跑代码

注意:我曾经遇到过芯片有多个 RAM 区域(比如 DTCM 和 SRAM),它们的地址不连续。这时候需要分别定义,并在段分配时指定放到哪个区域。否则链接器会报错,或者把数据放到你意想不到的地方。

3.3 段(Section)的划分 —— 代码和数据怎么分家

段是链接脚本的核心概念。每个段都有名字、属性和内容。最常见的几个段:

  • .text —— 代码段,放函数指令
  • .rodata —— 只读数据段,放字符串常量、const 变量
  • .data —— 已初始化全局变量
  • .bss —— 未初始化全局变量(启动时清零)
  • .stack —— 栈空间
  • .heap —— 堆空间

来看看一个典型的段分配写法:

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text :
    {
        *(.isr_vector)    /* 中断向量表必须放最前面 */
        *(.text*)         /* 所有代码 */
        *(.rodata*)       /* 只读数据 */
        . = ALIGN(4);
    } > FLASH

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

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

这里有个关键点:.data 段运行时在 RAM,但初始值存在 Flash。所以用了 AT() 指定加载地址。启动代码需要把数据从 Flash 拷贝到 RAM。这个拷贝逻辑,我一般写在汇编启动文件里。

小技巧:用 . = ALIGN(4) 保证段对齐到 4 字节。ARM Cortex-M 要求中断向量表必须 256 字节对齐,否则硬件会跑飞。我踩过这个坑,真的会跑飞。

3.4 符号表的生成 —— 链接器给你留的「路标」

链接脚本里可以定义符号。这些符号在 C 代码里用 extern 声明就能访问。它们本质上是地址标签,不是变量。

常用的符号有:

  • _sdata、_edata —— 数据段起始和结束地址
  • _sbss、_ebss —— BSS 段起始和结束地址
  • _estack —— 栈顶地址(通常就是 RAM 末尾)
  • _sheap、_eheap —— 堆的边界

举个例子,启动代码里清零 BSS 段:

extern uint32_t _sbss;
extern uint32_t _ebss;

void reset_handler(void)
{
    uint32_t *p = &_sbss;
    while (p < &_ebss) {
        *p++ = 0;
    }
    /* 然后跳转到 main */
}

你看,链接脚本里定义的符号,在 C 里用 extern 就能拿到地址。但要注意:&_sbss 取的是符号的值,不是符号的地址。因为符号本身就是地址。

关键理解:链接脚本符号 = 地址常量。你不能修改它,只能读取它。我见过新手写 _sbss = 0x20000000; 想改地址,结果编译报错。

3.5 一张图看懂链接脚本全流程

下面这张 SVG 图,把链接脚本的核心逻辑串起来了。从输入文件到最终镜像,每一步都离不开链接脚本的编排。

链接脚本核心工作流程 .o 目标文件 .text .data .bss 链接脚本 .ld MEMORY + SECTIONS + 符号 .elf / .bin 完整内存镜像 链接脚本内部处理逻辑 步骤1:解析 MEMORY 确定 Flash/RAM 地址范围 步骤2:编排 SECTIONS 分配段到对应内存区域 步骤3 生成符号表 关键产出:中断向量表地址、栈顶地址、BSS 清零范围、数据拷贝范围 这些符号在启动代码中必须用到,否则芯片无法正常初始化 常见错误排查方向 • 程序跑飞 → 检查中断向量表地址和栈顶地址 • 全局变量值不对 → 检查 .data 段拷贝逻辑

3.6 实战中常见的坑

做 Bootloader 移植时,链接脚本最容易出问题的几个地方:

  1. 中断向量表偏移 —— Bootloader 和 App 的中断向量表不能冲突。我一般用 VECT_TAB_OFFSET 宏配合链接脚本的 .isr_vector 位置来控制。
  2. RAM 分区冲突 —— Bootloader 和 App 共用 RAM 时,必须明确划分各自区域。我曾经没划清楚,App 启动时把 Bootloader 的全局变量覆盖了,导致跳转失败。
  3. 对齐问题 —— ARM 架构要求某些段必须对齐到 4 字节或 8 字节。不对齐的话,访问未对齐地址会触发硬件异常。
  4. 符号未定义 —— 链接脚本里定义的符号,C 代码里用 extern 声明时类型要写对。我习惯用 uint32_t,因为地址就是 32 位无符号整数。

重要提醒:修改链接脚本后,一定要 make clean 再重新编译。增量编译有时不会重新链接,导致你改了半天,烧进去的还是旧版本。我因为这个浪费过一整个下午。

3.7 小结

链接脚本不是那种天天要改的东西,但一旦需要改,往往就是项目最关键的阶段。理解它的三个核心要素 —— 内存布局、段划分、符号生成 —— 你就能掌控整个固件的「骨架」。

下次遇到程序跑不起来,别急着怀疑代码逻辑。先打开 .map 文件,看看各个段是不是在你预期的地方。很多时候,问题就出在链接脚本里。