3. 链接脚本详解:链接脚本(.ld)的作用、内存布局定义、段(text/data/bss)的分配

大家好,我是你们的Bootloader讲师。今天我们来聊聊链接脚本——这个让很多嵌入式开发者头疼,但又绕不开的东西。

说实话,我刚开始做嵌入式那会儿,也觉得链接脚本就是个“黑盒子”。编译器帮我搞定了,我干嘛要管它?直到有一次,我写的一个Bootloader死活跑不起来,调试了整整两天,最后发现是某个段的地址没对齐。嗯,从那以后,我再也不敢轻视这个.ld文件了。

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

简单来说,链接脚本就是告诉链接器:你的代码该放哪儿,数据该放哪儿,哪些东西要排在一起。你可以把它想象成一张“搬家地图”——编译器把各种家具(函数、变量、常量)打包好,链接脚本告诉搬家公司(链接器)每件家具该放哪个房间(内存地址)。

我个人的习惯是,每开始一个新项目,第一件事就是先把链接脚本搭好。为什么?因为内存布局一旦定下来,后面改起来特别麻烦。你想想看,如果代码段和数据段的位置没规划好,轻则性能下降,重则直接跑飞。

核心要点: 链接脚本定义了目标文件中的各个段(Section)在最终可执行文件中的排列顺序和加载地址。它直接决定了你的程序在内存中的“长相”。

3.2 内存布局定义——你的芯片长什么样?

每个芯片都有自己的内存映射。比如STM32F103,Flash从0x08000000开始,SRAM从0x20000000开始。这些地址不是随便写的,是芯片厂商定死的。链接脚本里,我们通常用MEMORY命令来描述这些区域。

来看一个典型的例子:

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

这里我定义了两种内存区域:

  • FLASH:只读+可执行(rx),起始地址0x08000000,大小512KB。Bootloader的代码就放在这里。
  • SRAM:读写+可执行(rwx),起始地址0x20000000,大小128KB。运行时数据、堆栈都在这里。

我曾经遇到过一个坑:某个芯片的SRAM其实分成了两段,一段是TCM,一段是普通SRAM。如果链接脚本里只定义了一个连续区域,链接器会把所有数据都塞到TCM里,结果TCM溢出,程序跑着跑着就挂了。所以,一定要仔细看芯片手册的内存映射图,别想当然。

注意: MEMORY命令中的属性(rx/rw/rwx)不是摆设。如果你把数据段放到了只读区域,链接器会报错。反过来,如果你把代码段放到了不可执行区域,程序会直接HardFault。

3.3 段的分配——text、data、bss各就各位

链接脚本的核心工作,就是把输入文件中的各种段,安排到输出文件的指定位置。最常见的三个段是:

段名 内容 存放位置 特点
.text 程序代码、常量、只读数据 Flash(非易失) 只读,可执行
.data 已初始化的全局/静态变量 运行时在SRAM,初始值在Flash 读写,需要从Flash拷贝到SRAM
.bss 未初始化的全局/静态变量 SRAM 读写,启动时需清零

这里有个关键点:.data段比较特殊。它的初始值存在Flash里,但运行时必须搬到SRAM才能读写。这个搬运工作,就是Bootloader启动代码的一部分。我见过不少新手,链接脚本写好了,但忘了在启动代码里实现.data段的拷贝,结果全局变量全是乱的。

下面是一个完整的SECTIONS定义示例:

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text : {
        *(.isr_vector)      /* 中断向量表 */
        *(.text*)           /* 程序代码 */
        *(.rodata*)         /* 只读数据 */
        . = ALIGN(4);
        _etext = .;         /* text段结束地址 */
    } > FLASH
    
    .data : {
        _sdata = .;         /* data段起始地址 */
        *(.data*)
        . = ALIGN(4);
        _edata = .;         /* data段结束地址 */
    } > SRAM AT> FLASH
    
    .bss : {
        _sbss = .;          /* bss段起始地址 */
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;          /* bss段结束地址 */
    } > SRAM
    
    .stack : {
        . = . + 0x4000;     /* 16KB栈空间 */
        _estack = .;
    } > SRAM
}

注意看.data段那行:> SRAM AT> FLASH。这个语法表示:运行时放在SRAM,但加载地址在Flash。链接器会自动生成_sdata、_edata这些符号,启动代码就可以用它们来搬运数据。

我的经验: 在Bootloader里,我习惯把.isr_vector放在最前面,因为芯片复位后第一个取指地址就是向量表的起始地址。如果向量表没对齐(通常是256字节对齐),中断响应会出问题。我曾经因为这个原因,浪费了一整天。

3.4 链接脚本中的符号——链接器帮你算好的“坐标”

链接脚本里定义的符号(比如_sdata、_ebss),在C代码里可以直接用extern声明后访问。这是Bootloader启动代码和链接脚本之间的“通信协议”。

举个例子,启动代码里清零bss段的逻辑通常是这样的:

extern uint32_t _sbss, _ebss;

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

这里有个细节:_sbss和_ebss是链接器生成的地址,不是变量本身。所以声明成extern uint32_t,然后用&取地址。我见过有人直接写extern uint32_t _sbss然后赋值_sbss = 0,结果把链接器符号本身给改了——程序当然跑飞了。

3.5 链接脚本的“坑”与避坑指南

做Bootloader这么多年,我在链接脚本上踩过的坑,可以列个小清单:

  • 对齐问题:ARM Cortex-M要求中断向量表必须256字节对齐。如果链接脚本里忘了. = ALIGN(256),中断响应会异常。
  • 段顺序:有些芯片要求特定段放在固定地址。比如NXP的i.MX RT系列,IVT(Image Vector Table)必须放在偏移0x1000处。顺序错了,芯片就不认。
  • 符号冲突:如果两个目标文件定义了同名的全局变量,链接器会报错。但如果你用了COMMON段,链接器可能会“宽容”地合并它们——这往往不是你想要的。
  • 栈位置:我习惯把栈放在SRAM的末尾,这样栈向下增长时不容易和堆数据冲突。但如果你把栈放在了SRAM中间,一旦栈溢出,就会覆盖其他数据,调试起来非常痛苦。
避坑指南: 我曾经在一个项目里,把.data段的加载地址(LMA)和运行地址(VMA)搞反了。结果程序启动时,启动代码把Flash里的数据拷贝到了Flash的另一个位置——当然,写不进去。芯片直接卡死在拷贝循环里。所以,一定要区分LMA和VMA,尤其是.data段。

3.6 用SVG图理解链接脚本的工作流程

说了这么多,不如一张图来得直观。下面这张图展示了从源文件到最终内存布局的完整流程:

链接脚本工作流程 源文件 (.c/.s) 编译器 目标文件 (.o) 链接器 (ld) 可执行文件 (.elf) 内存布局 烧录文件 (.bin/.hex) 链接脚本 (.ld) 说明: 链接脚本是链接器的“指挥棒”。它告诉链接器: 1. 目标文件中的各个段应该放在内存的什么位置 2. 哪些段需要从Flash拷贝到SRAM(如.data段) 3. 生成哪些符号(如_sdata、_ebss)供启动代码使用

从这张图可以看出,链接脚本在整个构建流程中扮演着“总导演”的角色。没有它,编译器生成的目标文件就是一堆散沙,无法形成可执行的程序。

3.7 小结

链接脚本虽然看起来像天书,但说白了就是三件事:定义内存区域、分配各个段、生成辅助符号。掌握了这三个核心,你就能看懂大部分链接脚本了。

我个人建议,初学者可以先从修改现成的链接脚本开始,比如改改栈大小、调整一下段的顺序。等遇到问题了,再回头查手册。这样学起来,比死记硬背语法要快得多。

嗯,链接脚本的内容就讲到这里。记住,链接脚本是Bootloader的“地基”,地基没打好,上面的房子再漂亮也白搭。