4、静态配置与链接脚本:链接脚本(.ld)编写实战、段(section)布局策略、符号表与地址绑定、启动代码配置

说实话,很多嵌入式工程师写了三五年代码,对链接脚本还是一知半解。我见过不少人把 .ld 文件当成「祖传代码」,从来不敢动,也不敢删。其实这东西没那么玄乎。说白了,链接脚本就是告诉链接器:你的代码放哪,数据放哪,堆栈放哪。

今天咱们就把这层窗户纸捅破。我会结合我实际项目中踩过的坑,带你手写一个能用的链接脚本。

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

先想一个问题:你写了个 int a = 0x55;,编译器把它变成了机器码。但机器码不知道这个变量该放在地址 0x20000000 还是 0x20001000。谁来决定?链接器。链接器依据什么?链接脚本。

链接脚本的核心职责就三条:

  • 定义内存布局——告诉链接器芯片有哪些 RAM、哪些 Flash,地址范围是多少。
  • 分配段(Section)——把 .text、.data、.bss 放到对应的内存区域。
  • 绑定符号地址——让全局变量、函数入口有确定的地址。

一句话总结:没有链接脚本,你的程序就是一盘散沙,不知道往哪放。

4.2 手写一个 .ld 文件——从零开始

我习惯用 STM32F103 举例,因为它够典型。Flash 从 0x08000000 开始,RAM 从 0x20000000 开始。咱们一步步来。

4.2.1 入口与内存定义

ENTRY(Reset_Handler)

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

这里 ENTRY 指定了程序入口——复位后第一条指令从 Reset_Handler 开始。MEMORY 块定义了两块物理内存。注意属性:Flash 是只读可执行(rx),RAM 是读写可执行(rwx)。

小经验:我刚开始写链接脚本时,把 Flash 写成了 rwx,链接器没报错,但下载后程序跑飞了。因为 Cortex-M3 从 Flash 取指令时,如果属性不对,总线 fault。所以属性别乱写。

4.2.2 段布局——.text、.data、.bss

接下来是重头戏。咱们把各个段安排到对应的内存区域。

SECTIONS
{
    /* 中断向量表 + 代码段,放在 Flash */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

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

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

    /* BSS 段,零初始化 */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}

这里有几个关键点:

  • .isr_vector 必须放在最前面,且用 KEEP 防止链接器优化掉。
  • .data 的加载地址(LMA)在 Flash,运行地址(VMA)在 RAM。启动代码需要把数据从 Flash 拷贝到 RAM。
  • _sdata、_edata、_sbss、_ebss 这些符号,启动代码会用到。

注意:很多人忘记给 .data 段指定加载地址。如果不写 AT (...),链接器默认加载地址等于运行地址,那你的初始化数据就没了来源。我见过一个项目,产品偶尔启动异常,查了两周才发现是 .data 段没拷贝完整。

4.3 符号表与地址绑定——链接器在背后做了什么?

你写了个全局变量 uint32_t sys_tick = 1000;,链接器怎么知道它放在哪?

过程是这样的:

  1. 编译器生成目标文件(.o),里面记录了符号名和它在段内的偏移。
  2. 链接器读取所有 .o 文件,合并同名的段。
  3. 根据链接脚本,给每个段分配基地址。
  4. 符号的最终地址 = 段基地址 + 段内偏移。

你可以用 objdump -t 或 nm 查看符号表。比如:

$ arm-none-eabi-nm firmware.elf
08000100 T Reset_Handler
20000000 D sys_tick
20000004 B heap_start

看到没?sys_tick 被分配到了 RAM 起始地址 0x20000000。这就是链接器根据链接脚本算出来的。

实用技巧:我习惯在链接脚本里定义一些辅助符号,比如 _stack_end = ORIGIN(RAM) + LENGTH(RAM);,然后在启动代码里直接用。这样改内存大小只需要改一处。

4.4 启动代码配置——从复位到 main()

链接脚本和启动代码是「搭档」。启动代码负责执行链接脚本定义的布局。典型的启动流程:

  1. 从 .isr_vector 取出栈指针和 Reset_Handler 地址。
  2. 跳转到 Reset_Handler。
  3. 拷贝 .data 段从 Flash 到 RAM。
  4. 清零 .bss 段。
  5. 调用 SystemInit(时钟配置等)。
  6. 跳转到 main()。

下面是一段精简的启动代码(汇编):

Reset_Handler:
    /* 拷贝 .data 段 */
    ldr r0, =_sdata
    ldr r1, =_edata
    ldr r2, =_sidata
    movs r3, #0
    b LoopCopyDataInit

CopyDataInit:
    ldr r4, [r2, r3]
    str r4, [r0, r3]
    adds r3, r3, #4

LoopCopyDataInit:
    adds r4, r0, r3
    cmp r4, r1
    bcc CopyDataInit

    /* 清零 .bss 段 */
    ldr r0, =_sbss
    ldr r1, =_ebss
    movs r2, #0
    b LoopFillZerobss

FillZerobss:
    str r2, [r0]
    adds r0, r0, #4

LoopFillZerobss:
    cmp r0, r1
    bcc FillZerobss

    /* 跳转到 main */
    bl main
    b .

注意 _sidata 这个符号——它指向 .data 段在 Flash 中的加载地址。这个符号需要在链接脚本里定义:

_sidata = LOADADDR(.data);

我的习惯:我会在启动代码里加一个「看门狗喂狗」的占位,防止调试时看门狗复位。另外,我建议在跳转到 main 之前把全局中断关掉,等 main 里初始化完再开。不然中断来了,外设还没配好,容易出问题。

4.5 段布局策略——什么时候需要自定义段?

默认的 .text、.data、.bss 够用吗?大部分场景够。但有些情况你必须自定义段:

场景 做法 原因
关键函数放 RAM 执行 定义 .ramfunc 段,放在 RAM Flash 读取慢,或需要实时擦写 Flash
掉电保存参数 定义 .noinit 段,不初始化 复位后保留上次运行的数据
Bootloader + App 定义 .app_vector 段,偏移地址 两个程序共存,中断向量表不冲突
DMA 缓冲区对齐 定义 .dma_buffer 段,指定对齐 某些 DMA 要求缓冲区地址按 1KB 对齐

举个例子,把某个函数放到 RAM:

/* 链接脚本中 */
.ramfunc : AT (ADDR(.text) + SIZEOF(.text))
{
    . = ALIGN(4);
    _sramfunc = .;
    *(.ramfunc)
    *(.ramfunc*)
    . = ALIGN(4);
    _eramfunc = .;
} > RAM

/* C 代码中 */
__attribute__((section(".ramfunc")))
void flash_erase(void)
{
    /* 擦除 Flash 的操作 */
}

这样 flash_erase 函数就被放到了 RAM 里执行。为什么?因为你在擦除 Flash 时,不能从 Flash 取指令——指令总线会冲突。放到 RAM 执行就避免了这个问题。

我曾经踩过的坑:有一次我把 .ramfunc 段的加载地址写错了,导致启动时拷贝的数据不对。函数执行到一半就 HardFault。后来我在启动代码里加了段校验——拷贝完后比较 CRC,不一致就报错。这个习惯我一直保留到现在。

4.6 知识结构总览

下面这张图把本章的核心逻辑串起来了。你可以把它当作一个「链接脚本设计检查清单」。

链接脚本与启动配置知识结构 链接脚本 (.ld) 内存布局定义 段分配策略 符号地址绑定 FLASH / RAM 范围 ORIGIN / LENGTH .text / .data / .bss 自定义段 (.ramfunc) 全局变量地址 函数入口地址 启动代码 (Reset_Handler) ① 拷贝 .data 段 (Flash → RAM) ② 清零 .bss 段

从这张图可以看得很清楚:链接脚本定义了「放什么、放哪」,启动代码负责「搬过去、清干净」。两者配合,程序才能正确运行。

4.7 避坑指南——我替你试过的错

最后分享几个我实际项目中遇到的坑,你遇到了可以少走弯路:

  • 段对齐问题:ARM 要求中断向量表 4 字节对齐,某些 DMA 缓冲区要求 1KB 对齐。忘了写 ALIGN,程序可能莫名其妙跑飞。
  • 符号重复定义:两个 .c 文件定义了同名的全局变量,链接器会报错。但如果你用了 WEAK 属性,链接器会默默覆盖。我建议在链接脚本里用 PROVIDE 定义弱符号,避免意外覆盖。
  • 栈溢出检测:在 .bss 段后面留一个「哨兵区域」,初始化为固定模式(比如 0xDEADBEEF)。启动后检查哨兵是否被改写,就能判断栈是否溢出。
  • 链接顺序:链接器按输入文件的顺序处理段。如果你有多个库,顺序错了可能导致符号未定义。我习惯把标准库放在最后。

一个小技巧:每次修改链接脚本后,用 objdump -h firmware.elf 查看段布局,确认每个段的起始地址和大小符合预期。这个习惯帮我发现过好几次地址重叠的问题。

好了,链接脚本这块咱们就聊到这。你只要动手写一次,把启动代码和 .ld 文件配合跑通,后面再遇到类似问题就不会慌了。记住:链接脚本不是「祖传代码」,它是你程序的地基。地基稳了,上面盖多高都不怕。