3. 链接脚本深度解析:语法、内存布局与符号绑定

说实话,很多做MCU开发的朋友,写了几年C代码,却从来没碰过链接脚本。我刚开始也是这样——直到有一次,我的Bootloader死活跳不到应用程序,折腾了两天,最后发现是链接脚本里一个段地址配错了。嗯,从那以后,我再也不敢跳过链接脚本了。

链接脚本,说白了就是告诉链接器:你的代码该放哪,数据该放哪,每个符号的最终地址是什么。没有它,编译器生成的.elf文件就是一盘散沙。

3.1 链接脚本的基本语法

GNU LD的链接脚本,扩展名通常是.ld或.lds。它的语法其实不复杂,核心就几个关键字。

关键字 作用 我常用的场景
ENTRY 指定程序入口点 Bootloader的复位向量
MEMORY 定义目标芯片的物理内存区域 Flash、SRAM、ITCM、DTCM
SECTIONS 描述如何将输入段合并到输出段 .text、.data、.bss的布局
PROVIDE 定义符号,如果用户未定义则使用默认值 堆栈顶地址、_sidata等

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

ENTRY(Reset_Handler)

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

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

你看,是不是很直观?MEMORY定义了两块区域,SECTIONS把代码段放Flash,数据段放SRAM。但实际项目里,远没这么简单。

3.2 内存区域定义:别让你的芯片“爆仓”

定义内存区域时,我见过最典型的错误就是地址写错。有一次同事把STM32F103的Flash起始地址写成了0x08001000,结果程序死活跑不起来——因为中断向量表没在正确的位置。

注意:MCU的Flash起始地址通常是0x08000000(STM32系列)或0x00000000(某些ARM核)。但具体要看芯片手册,别想当然。

定义内存区域时,有四个属性要搞清楚:

  • r:可读。几乎所有区域都有。
  • w:可写。RAM必须有,Flash一般没有(除非你要做IAP)。
  • x:可执行。代码段所在的区域必须有。
  • !:取反。比如!x表示不可执行。

我个人习惯把内存区域定义得细一些。比如一个带TCM的MCU,我会这样写:

MEMORY
{
  FLASH   (rx)  : ORIGIN = 0x08000000, LENGTH = 1M
  ITCM    (rwx) : ORIGIN = 0x00000000, LENGTH = 16K
  DTCM    (rw)  : ORIGIN = 0x20000000, LENGTH = 16K
  SRAM1   (rwx) : ORIGIN = 0x20004000, LENGTH = 112K
  SRAM2   (rw)  : ORIGIN = 0x20020000, LENGTH = 64K
}

为什么要分这么细?因为不同的内存有不同的访问速度。中断服务函数放ITCM,全局变量放DTCM,普通代码放Flash——性能能差好几倍。

3.3 段的分配与布局:代码到底放哪?

段(Section)是链接脚本的核心。每个段都有两个地址:加载地址(LMA)和运行地址(VMA)。

  • VMA:程序运行时,这个段在内存中的位置。
  • LMA:这个段在烧录文件(如.hex)中的存放位置。

对于只读的.text段,VMA和LMA通常是同一个地址——都在Flash里。但对于.data段,情况就不同了:它运行时在SRAM(VMA),但初始值却存在Flash里(LMA)。

这就是为什么链接脚本里会有> SRAM AT> FLASH这种写法。我当年第一次看到这个,愣了半天才反应过来。

关键点:启动代码必须负责把.data段从Flash拷贝到SRAM,并把.bss段清零。这个工作通常在Reset_Handler里完成。

一个完整的段布局示例:

SECTIONS
{
  /* 中断向量表,必须放在Flash起始地址 */
  .isr_vector : { *(.isr_vector) } > FLASH

  /* 代码段 */
  .text : { *(.text*) *(.rodata*) } > FLASH

  /* 初始化数据,运行时在SRAM,初始值在Flash */
  .data : 
  {
    _sdata = .;    /* 数据段起始地址 */
    *(.data*)
    _edata = .;    /* 数据段结束地址 */
  } > SRAM AT> FLASH
  _sidata = LOADADDR(.data);  /* 数据段在Flash中的起始地址 */

  /* 未初始化数据,运行时清零 */
  .bss :
  {
    _sbss = .;     /* BSS段起始地址 */
    *(.bss*)
    _ebss = .;     /* BSS段结束地址 */
  } > SRAM

  /* 堆和栈 */
  ._user_heap_stack :
  {
    . = ALIGN(8);
    PROVIDE ( _end = . );
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
  } > SRAM
}

注意看那些_sdata、_edata、_sidata——它们就是符号,启动代码里要用它们来拷贝数据。

3.4 符号表与地址绑定:链接器怎么知道变量在哪?

链接脚本里定义的符号,本质上就是给某个地址起个名字。比如_sdata = .,就是把当前地址赋值给_sdata这个符号。

这些符号在C代码里怎么用?用extern声明一下就行:

extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sidata;

void data_init(void)
{
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
}

这里有个坑:&_sdata取的是符号的地址,而不是符号的值。因为_sdata本身就是一个地址值,所以&_sdata就是它的地址——嗯,有点绕,但用多了就习惯了。

小技巧:我习惯在链接脚本里用PROVIDE来定义符号。这样如果C代码里没定义,就用默认值;如果定义了,就覆盖链接脚本的值。比如堆栈大小,我经常在C代码里重新定义。

3.5 知识结构总览

下面这张图,是我梳理的链接脚本核心知识体系。你看一眼,心里就有谱了。

链接脚本核心知识结构图 链接脚本核心知识结构 链接脚本 (.ld) MEMORY 内存区域 SECTIONS 段布局 符号表与地址绑定 ORIGIN / LENGTH 属性 r/w/x/! .text 代码段 .data 数据段 .bss 零初始化 PROVIDE / 自定义符号 Flash / SRAM / TCM VMA vs LMA 地址 _sdata / _edata 符号 启动代码拷贝/清零 Bootloader跳转地址 链接脚本是连接编译器输出与硬件内存的桥梁, 掌握它才能真正理解MCU程序的加载与运行过程。

3.6 实战:Bootloader的链接脚本要点

做Bootloader时,链接脚本有几个地方要特别小心。我曾经在一个项目里,因为忘了改Flash偏移地址,导致Bootloader和应用程序的中断向量表冲突——两个都指向同一个位置,结果一触发中断就死机。

避坑指南:Bootloader的Flash起始地址通常是0x08000000,但应用程序要偏移。比如Bootloader占64KB,应用程序就从0x08010000开始。链接脚本里必须把ORIGIN改成对应的地址。

一个典型的Bootloader链接脚本片段:

/* Bootloader占用前64KB Flash */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K

/* 应用程序从0x08010000开始 */
/* FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K */

另外,Bootloader里通常需要定义一些特殊符号,比如应用程序的起始地址:

_app_start = 0x08010000;

然后在C代码里,跳转时就用这个地址:

extern uint32_t _app_start;

void jump_to_app(void)
{
    uint32_t app_sp = *(uint32_t *)_app_start;
    uint32_t app_pc = *(uint32_t *)(_app_start + 4);
    
    __set_MSP(app_sp);
    ((void (*)(void))app_pc)();
}
经验之谈:我习惯在链接脚本里把所有关键地址都用符号定义出来,比如_flash_start、_sram_start、_stack_top。这样C代码里直接引用,清晰又安全。别在代码里硬编码地址——改一次链接脚本就够了,不用到处找魔数。

嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:你写的每一行C代码,最终都要通过链接脚本映射到芯片的物理地址上。搞懂了这一点,你就掌握了MCU开发的底层密码。