第3章:链接脚本深度解析——.ld语法与段布局

链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。我当年刚入行时,觉得这玩意儿不就是个配置文件嘛,随便写写就行。结果呢?全局变量全乱套了——明明赋了初值的变量,上电后全是0;该在RAM里跑的数据,跑到了Flash里。那次调试花了我整整两天,最后发现是链接脚本里一个段地址写错了。

嗯,从那以后,我养成了一个习惯:每次写链接脚本,都把它当成核心设计文档来对待。今天咱们就把这玩意儿彻底讲透。

3.1 GCC链接脚本语法速览

GCC用的链接脚本,后缀是.ld。它的语法其实不复杂,核心就几个关键字:

关键字 作用 我常用的写法
ENTRY 指定程序入口点 ENTRY(Reset_Handler)
MEMORY 定义芯片的物理存储区域 ROM起始0x08000000,RAM起始0x20000000
SECTIONS 描述各段如何分配到MEMORY中 核心部分,90%的配置都在这里
PROVIDE 提供符号供C代码引用 比如PROVIDE(_estack = ...)

一个典型的链接脚本结构长这样:

ENTRY(Reset_Handler)

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

SECTIONS
{
  .text :
  {
    *(.isr_vector)
    *(.text)
    *(.text.*)
    *(.rodata)
    _etext = .;
  } > FLASH

  .data : AT(_etext)
  {
    _sdata = .;
    *(.data)
    _edata = .;
  } > RAM

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

关键点:.data段后面的AT(_etext),表示它的加载地址在Flash里,但运行地址在RAM里。这个"双地址"机制,是嵌入式链接脚本最核心的概念。

3.2 ROM/RAM分区——你的芯片地图

每个芯片都有它的"地图"。STM32F103的Flash从0x08000000开始,RAM从0x20000000开始。你想想看,如果链接脚本里把RAM的起始地址写成了0x20001000,那前4KB就永远用不上了——这种坑我踩过。

分区时要注意三点:

  • ROM(Flash):存放只读数据——代码、常量、初始化值。掉电不丢失。
  • RAM:存放运行时数据——全局变量、堆栈。掉电就丢。
  • 加载地址 vs 运行地址:.data段在Flash里存着初值,但运行时必须搬到RAM里。

我个人习惯在MEMORY块里把区域分得细一点:

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
  CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K   /* 有些芯片有CCM RAM */
}

这样后面SECTIONS里就能灵活指定:哪个段放哪个区域。

3.3 三大段:.text / .data / .bss

这三个段,是嵌入式程序的"三驾马车"。搞懂它们,链接脚本就掌握了一半。

.text段——你的代码和常量

.text段包含:中断向量表、函数代码、只读常量(const修饰的变量、字符串字面量)。它必须放在Flash里,因为CPU上电后直接从Flash取指令。

我见过有人把.rodata(只读数据)单独分一个段,其实没必要——放在.text里就行,反正都是只读的。

.data段——有初值的全局变量

这个段最坑。为什么?因为它在Flash里存着初值,但运行时必须在RAM里。链接脚本里必须写清楚:

  • 加载地址(LMA):在Flash里,紧跟在.text后面
  • 运行地址(VMA):在RAM里,从某个地址开始

启动代码里会有一段搬运程序,把.data从Flash拷到RAM。如果链接脚本写错了,搬运地址对不上——全局变量就全乱套了。

我曾经踩过的坑:有一次我把AT(_etext)写成了AT(LOADADDR(.text)),结果链接器把.data的加载地址算到了.text的起始位置,而不是结束位置。搬运时把代码覆盖了,程序直接跑飞。从那以后,我每次写完链接脚本都会用objdump -h检查各段的地址。

.bss段——零初始化的全局变量

.bss段存放未初始化或初始化为0的全局变量。它在Flash里不占空间,只在RAM里占位置。启动代码需要把它清零。

这里有个细节:_sbss和_ebss这两个符号,是链接器自动生成的,分别表示.bss段的起始和结束地址。启动代码里用它们来算长度:

/* 启动代码中的bss清零 */
ldr r0, =_sbss
ldr r1, =_ebss
mov r2, #0
bss_loop:
  cmp r0, r1
  itt lo
  strlo r2, [r0], #4
  blo bss_loop

3.4 段布局的完整流程

咱们用一张图把整个过程串起来:

链接脚本段布局与启动流程 Flash (ROM) - 0x08000000 .isr_vector (中断向量表) .text + .rodata (代码+常量) .data初始值 (LMA) RAM - 0x20000000 .data (VMA - 运行时) .bss (零初始化) 堆 + 栈 启动代码搬运.data 启动流程 1. 上电 → CPU从0x08000000取复位向量 2. 跳转到Reset_Handler(在.text段) 3. 搬运.data:从Flash(LMA) → RAM(VMA) 4. 清零.bss:从_sbss到_ebss写0 5. 初始化堆栈 → 跳转到main()

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

下面是我个人常用的模板,适用于STM32F4系列。你拿去改改芯片的Flash/RAM大小就能用:

/* stm32f407.ld */
ENTRY(Reset_Handler)

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

_estack = ORIGIN(RAM) + LENGTH(RAM);

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

  .text :
  {
    . = ALIGN(4);
    *(.text)
    *(.text.*)
    *(.glue_7)
    *(.glue_7t)
    *(.eh_frame)
    . = 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_start__ = _sbss;
    *(.bss)
    *(.bss.*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
    __bss_end__ = _ebss;
  } > RAM

  ._user_heap_stack :
  {
    . = ALIGN(8);
    PROVIDE ( end = . );
    PROVIDE ( _end = . );
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
  } > RAM
}

小技巧:_sidata这个符号,我用来记录.data段在Flash里的起始地址。启动代码里搬运时,源地址用_sidata,目标地址用_sdata,长度用_edata - _sdata。这样不管链接脚本怎么改,搬运代码都不用动。

3.6 常见错误与避坑指南

  • 段地址重叠:.text和.data的加载地址算重了。用objdump -h检查各段地址范围。
  • 搬运长度算错:_edata - _sdata是字节数,不是字(word)数。搬运循环里如果用ldr/str一次搬4字节,循环次数要除以4。
  • BSS清零漏了:有些启动代码只清了.bss,没清COMMON段。在.bss段里加上*(COMMON)就能解决。
  • 栈顶地址不对:_estack必须指向RAM的末尾地址,而且要对齐到8字节。我习惯写成_estack = ORIGIN(RAM) + LENGTH(RAM);,简单直接。

我曾经犯过的错:有一次我把.data : { ... } > RAM AT> FLASH写成了.data : { ... } > FLASH AT> RAM。结果链接器把.data段放到了Flash里,但运行地址也指向Flash。全局变量写不进去,程序一跑就死。这个错误在链接阶段不会报错,只有运行了才知道——所以写完链接脚本,一定要用readelf -S或objdump -h确认每个段的LMA和VMA。

链接脚本这东西,说难不难,说简单也不简单。关键是要理解"加载地址"和"运行地址"这两个概念,以及启动代码是怎么配合链接脚本工作的。你把这个搞懂了,后面做Bootloader、做RTOS移植,都会轻松很多。