第2章:链接脚本入门:GNU LD链接脚本语法,SECTIONS命令与内存区域定义

好,咱们进入正题。上一章聊了内存布局的宏观概念,这一章就得动真格的了——写链接脚本。说实话,我刚开始接触Bootloader开发时,最头疼的就是这个链接脚本。看着那些花括号、点号、星号,完全不知道在干什么。后来踩了无数坑才明白,这东西其实就是告诉链接器:「我的代码放哪,数据放哪,别给我乱塞」。

2.1 链接脚本到底是个啥?

说白了,链接脚本就是一张「地图」。编译器把每个C文件编译成目标文件(.o),这些目标文件里有很多段:代码段(.text)、数据段(.data)、BSS段(.bss)等等。链接器的任务就是把它们拼到一起,拼成最终的二进制文件。

但问题来了——拼到一起放哪?是放在0x08000000还是0x20000000?这得由你说了算。链接脚本就是用来干这个的。

我记得第一次写STM32的Bootloader时,没注意链接脚本里的FLASH起始地址,结果代码烧进去直接跑飞。嗯,从那以后我每次改链接脚本都会反复确认三遍地址对不对。

2.2 GNU LD脚本的基本结构

一个典型的GNU LD链接脚本,长这样:

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(Reset_Handler)

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

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

    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;
        *(.data)
        *(.data.*)
        _edata = .;
    } > RAM

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

看着有点复杂?别急,咱们拆开来看。

2.3 MEMORY命令:定义内存区域

MEMORY命令用来声明你的芯片有哪些内存区域,以及它们的地址范围和属性。格式很简单:

MEMORY
{
    名字 (属性) : ORIGIN = 起始地址, LENGTH = 长度
}

属性有几种:

  • r:可读
  • w:可写
  • x:可执行
  • !:取反,比如!rx表示不可读不可执行

我个人习惯把FLASH设为rx(可读可执行),RAM设为rwx(可读可写可执行)。为什么RAM要可执行?因为有时候Bootloader需要把代码拷贝到RAM里跑,比如做在线升级时。

注意: 我曾经犯过一个低级错误——把FLASH的LENGTH写成了128K,但实际芯片只有64K。链接器不会报错,它只会默默地把代码塞到不存在的地址上。结果就是:烧录成功,运行失败。所以一定要核对芯片手册上的内存大小。

2.4 SECTIONS命令:编排输出段

SECTIONS命令是链接脚本的核心。它告诉链接器:每个输入段应该放到哪个输出段,以及这个输出段放在哪个内存区域。

基本语法:

SECTIONS
{
    段名 :
    {
        输入段列表
    } > 内存区域
}

这里的「段名」就是输出段的名字,比如.text、.data、.bss。输入段列表用通配符匹配,比如:

  • *(.text):所有目标文件的.text段
  • *(.text.*):所有目标文件的.text.xxx段(编译器有时会生成这种子段)
  • *(.rodata):只读数据段

你想想看,为什么要有通配符?因为你的工程可能有几十个.c文件,每个都生成一个.text段。用通配符就能一把抓,不用一个一个写。

2.5 位置计数器「.」

在SECTIONS里,你会经常看到一个点号「.」。这个点号叫位置计数器,它表示当前地址。比如:

.text :
{
    *(.vectors)
    *(.text)
    _etext = .;
} > FLASH

这里的_etext = .;意思是:把当前地址赋值给符号_etext。这个符号可以在C代码里用extern声明后直接使用,用来获取代码段的结束地址。

我在项目中经常用这个技巧来计算代码大小,或者确定数据段的加载地址。比如:

extern uint32_t _etext;
// 在代码里可以这样用
uint32_t code_size = (uint32_t)&_etext - 0x08000000;

2.6 加载地址 vs 运行地址

这是新手最容易搞混的地方。看这句:

.data : AT (ADDR(.text) + SIZEOF(.text))

AT (...)指定的是加载地址(LMA),而> RAM指定的是运行地址(VMA)。什么意思呢?

  • 加载地址:数据在烧录文件里存放的位置(通常在FLASH)
  • 运行地址:程序运行时数据实际所在的位置(通常在RAM)

为什么会这样?因为RAM掉电会丢数据,所以初始化数据必须存在FLASH里。上电后,Bootloader要把这些数据从FLASH拷贝到RAM里。这就是.data段需要两个地址的原因。

关键点: 如果你忘了写AT (...),链接器会默认加载地址等于运行地址。对于.data段来说,这意味着数据会被放在RAM里,但烧录文件里却没有它——上电后RAM里全是随机值,程序直接崩掉。我当年就因为这个bug调了整整两天。

2.7 常用符号定义

在链接脚本里定义符号,可以在C代码里直接引用。常用的有:

符号名 含义 典型用途
_etext 代码段结束地址 计算代码大小
_sdata 数据段起始地址 拷贝数据到RAM
_edata 数据段结束地址 判断拷贝是否完成
_sbss BSS段起始地址 清零BSS段
_ebss BSS段结束地址 判断清零范围
_stack_top 栈顶地址 初始化栈指针

这些符号的命名没有强制规定,但业界基本都这么用。我个人习惯再加一个_flash_start_ram_start,方便在代码里获取内存区域的基地址。

2.8 一个完整的Bootloader链接脚本示例

最后,给你看一个我实际项目中用过的链接脚本(简化版):

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(Reset_Handler)

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

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

SECTIONS
{
    .isr_vector :
    {
        *(.isr_vector)
    } > FLASH

    .text :
    {
        *(.text)
        *(.text.*)
        *(.glue_7)
        *(.glue_7t)
        *(.gnu.linkonce.t.*)
    } > FLASH

    .rodata :
    {
        *(.rodata)
        *(.rodata.*)
        *(.gnu.linkonce.r.*)
    } > FLASH

    .ARM.extab : { *(.ARM.extab*) } > FLASH
    .ARM.exidx : { *(.ARM.exidx*) } > FLASH

    _sidata = LOADADDR(.data);

    .data : AT (_sidata)
    {
        _sdata = .;
        *(.data)
        *(.data.*)
        *(.gnu.linkonce.d.*)
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss)
        *(.bss.*)
        *(.gnu.linkonce.b.*)
        *(COMMON)
        _ebss = .;
    } > RAM

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

小技巧: 注意看_sidata = LOADADDR(.data);这一行。LOADADDR()函数可以获取某个段的加载地址。这样就不用手动计算AT的表达式了,代码更清晰,也不容易算错。

嗯,到这里链接脚本的基础语法就讲完了。你可能会觉得内容有点多,但别担心——写链接脚本就像骑自行车,刚开始觉得别扭,多写几次就顺手了。下一章我会讲怎么用这个链接脚本配合启动代码,把Bootloader真正跑起来。