2、链接脚本深度解析:GNU LD语法、内存区域定义、段布局

链接脚本,说白了就是告诉链接器:你的芯片内存长什么样,代码该放哪,数据该放哪。很多做应用开发的同事可能几年都碰不到这东西,但做Bootloader和底层固件,这是绕不过去的坎。

我记得刚入行那会儿,接手一个老项目,编译出来的固件死活跑不起来。折腾了两天,最后发现是链接脚本里RAM的起始地址写错了。嗯,从那以后,我养成了一个习惯——拿到一个新平台,第一件事就是把链接脚本从头到尾读一遍。

2.1 GNU LD语法速览

GNU LD的链接脚本,语法其实不复杂。核心就几个关键字:ENTRY、MEMORY、SECTIONS。我个人习惯把链接脚本分成三大块来写:

  • 入口点声明:告诉CPU第一条指令从哪开始执行
  • 内存区域定义:描述芯片的物理内存布局
  • 段布局规则:把各种段(.text、.data、.bss)安排到对应的内存区域

一个最简的链接脚本骨架长这样:

ENTRY(Reset_Handler)

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

SECTIONS
{
    .text : { *(.text*) } > FLASH
    .data : { *(.data*) } > RAM AT> FLASH
    .bss  : { *(.bss*)  } > RAM
}

你可能会问:为什么.data段后面有个AT> FLASH?这个问题我后面会细讲,先记住一个结论——初始化数据在运行时必须待在RAM里,但它的初始值得从Flash拷贝过来。

小技巧:写链接脚本时,我习惯在每段后面加注释,标注这个段的用途和所属模块。半年后回头看,你会感谢自己的。

2.2 内存区域定义:MEMORY命令

MEMORY命令用来描述目标芯片的物理内存。每个区域需要指定:名字、属性、起始地址、长度。

属性这块,常见的有:

属性 含义 典型用途
r 只读 Flash、ROM
w 可写 RAM
x 可执行 存放代码的区域
rwx 可读可写可执行 内部SRAM(某些场景)

我曾经在一个项目里踩过坑:把RAM的属性写成了rw,忘了加x。结果代码里有个函数被放到了RAM里执行,一运行就进HardFault。查了半天才发现是属性没给对。

实际项目中,内存区域往往不止两个。比如有些芯片有TCM、SRAM、DDR等多个区域:

MEMORY
{
    ITCM (rwx) : ORIGIN = 0x00000000, LENGTH = 16K
    DTCM (rw)  : ORIGIN = 0x20000000, LENGTH = 16K
    SRAM (rw)  : ORIGIN = 0x20004000, LENGTH = 112K
    FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
}
注意:起始地址和长度一定要和芯片手册严格对应。差一个字节,链接器不会报错,但运行结果可能完全不可控。

2.3 段布局:SECTIONS命令

SECTIONS是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段与段之间的顺序。

2.3.1 .text段:代码和只读数据

.text段存放的是程序代码、字符串常量、跳转表等只读数据。它通常被放在Flash里。

.text :
{
    . = ALIGN(4);
    _stext = .;
    *(.isr_vector)    /* 中断向量表 */
    *(.text*)         /* 程序代码 */
    *(.rodata*)       /* 只读数据 */
    . = ALIGN(4);
    _etext = .;
} > FLASH

这里有几个细节要注意:

  • . = ALIGN(4):保证段起始地址4字节对齐。ARM Cortex-M要求中断向量表必须对齐到256字节边界,但一般段内对齐4字节就够了。
  • _stext和_etext:这两个符号是给启动代码用的。启动代码需要知道.text段的起始和结束地址,用来做校验或者拷贝。
  • *(.isr_vector):中断向量表必须放在最前面,这是CPU硬件决定的。

2.3.2 .data段:已初始化的全局变量

.data段存放的是有初始值的全局变量和静态变量。这些变量在运行时必须在RAM里,但初始值得从Flash拷贝过来。

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

注意这个AT> FLASH,它表示:这个段的运行时地址在RAM,但加载地址在Flash。链接器会在Flash里给.data的初始值预留空间,启动代码再把这些值从Flash拷贝到RAM。

我曾经见过一个同事,写链接脚本时忘了加AT> FLASH。结果编译出来的固件,.data段的初始值全丢了,全局变量上电全是随机值。排查了整整一天。

2.3.3 .bss段:未初始化的全局变量

.bss段存放的是没有初始值或者初始值为0的全局变量。它只需要在RAM里占空间,不需要在Flash里存初始值。

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

(NOLOAD)告诉链接器:这个段不需要加载到目标文件里,运行时直接清零就行。启动代码里会用_sbss和_ebss来遍历清零。

核心要点:.bss段不占用Flash空间,但会占用RAM空间。如果你的芯片RAM很小,注意控制全局变量的数量。

2.4 段布局全景图

下面这张图展示了典型的嵌入式固件在Flash和RAM中的布局关系:

嵌入式固件段布局全景图 Flash (0x08000000) .isr_vector 中断向量表 (256B对齐) .text 程序代码 + 只读数据 (rodata, 字符串常量) .data (加载视图) 初始值备份 (LMA) .ARM.exidx 异常展开表 (C++必备) 启动时拷贝 RAM (0x20000000) .data (运行时视图) 已初始化全局变量 (VMA) .bss 未初始化/零初始化全局变量 (启动时清零) .heap 堆 (malloc/free) .stack 栈 (局部变量/函数调用) VMA = 运行时地址(RAM),LMA = 加载地址(Flash),启动代码负责从LMA拷贝到VMA

2.5 链接脚本中的符号导出

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

/* 在C代码中访问链接脚本导出的符号 */
extern uint32_t _stext;
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

void SystemInit(void)
{
    /* 拷贝.data段 */
    uint32_t *src = &_sidata;  /* 注意:这个符号需要在链接脚本里额外定义 */
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    
    /* 清零.bss段 */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }
}
常见错误:很多新手会把&_sdata直接当成地址值来用。记住,链接脚本里定义的符号,在C语言里取地址才能拿到它的值。直接使用_sdata得到的是地址本身,不是地址里的内容。

2.6 进阶技巧:自定义段

有时候我们需要把特定的函数或变量放到指定的地址。比如Bootloader里的升级标志位,必须放在Flash的固定位置。

在C代码里用__attribute__指定段:

/* 强制放到Flash的0x0800FF00位置 */
const upgrade_flag_t __attribute__((section(".upgrade_flag"))) upgrade_flag = {
    .magic = 0xA5A5A5A5,
    .version = 0x0100
};

然后在链接脚本里安排这个段:

.upgrade_flag : ALIGN(4)
{
    KEEP(*(.upgrade_flag))
} > FLASH

/* 或者指定绝对地址 */
.upgrade_flag 0x0800FF00 :
{
    KEEP(*(.upgrade_flag))
} > FLASH

KEEP关键字很重要。链接器默认会优化掉没有被引用的段,但升级标志位这种数据,没有代码直接引用它,不加KEEP就会被丢掉。

个人经验:我习惯在链接脚本里预留一小块区域专门放版本号、校验和、升级标志。这样上位机工具可以直接读取固定地址获取固件信息,不用解析整个ELF文件。

2.7 避坑指南

做链接脚本这些年,我总结了几条血泪教训:

  • 对齐问题:ARM芯片要求中断向量表256字节对齐,否则上电直接跑飞。我建议所有段都至少4字节对齐。
  • 段顺序:中断向量表必须放在Flash的最开头。如果前面插了别的段,CPU会从错误的地方开始执行。
  • NOLOAD别漏:.bss段一定要加(NOLOAD),否则链接器会在生成的二进制文件里给.bss段填充0,白白增加固件体积。
  • 栈和堆的位置:栈通常放在RAM的末尾(向下生长),堆放在.bss之后(向上生长)。两者之间留够余量,防止碰撞。

嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑——链接器只是按照你的规则,把各种段安排到内存里。规则写对了,固件就能跑;规则写错了,调试能让你怀疑人生。