什么是 ‘Linker Scripts’ (链接脚本)?控制 C++ 段(.text, .data, .bss)在物理内存中的布局

各位编程领域的同仁们,大家好!

今天,我们将深入探讨一个在 C++ 开发,尤其是在嵌入式系统、操作系统内核或任何需要精细内存控制的场景中至关重要的主题——链接脚本(Linker Scripts)。你是否曾好奇,当你编写的 C++ 代码编译链接后,那些 .text.data.bss 段最终是如何被放置到物理内存中的特定位置的?我们通常将代码视为抽象的逻辑单元,但最终,它们必须在实际的硬件上找到自己的归宿。链接脚本正是这座桥梁,它赋予我们对程序内存布局的终极控制权。

1. 引言:为什么我们需要链接脚本?

在软件开发中,我们通常将代码编译成目标文件(Object Files),然后由链接器将这些目标文件以及库文件组合成最终的可执行程序。在这个过程中,链接器不仅仅是简单地将各个部分拼接起来,它还要完成符号解析、地址重定位,以及最重要的——决定程序在内存中的布局

对于大多数桌面应用程序,操作系统的虚拟内存管理系统为我们提供了一个抽象且相对宽松的环境,我们很少需要关心代码和数据在物理内存中的精确位置。然而,在以下场景中,这种精细控制变得不可或缺:

  • 嵌入式系统开发: 微控制器通常具有不同类型的内存区域,例如只读的闪存(Flash ROM)用于存储程序代码和常量数据,以及可读写的RAM用于存储变量和堆栈。我们需要精确地将代码、初始化的数据、未初始化的数据分别放置到这些特定的内存区域。
  • 操作系统内核开发: 内核需要直接管理物理内存,其自身的代码和数据必须放置在预定义的、固定的物理地址上,以确保启动和运行的正确性。
  • 高性能计算和内存优化: 为了最大化缓存命中率或利用特定内存区域的访问速度优势,可能需要将关键代码或数据放置在特定的物理地址。
  • 内存映射I/O(Memory-Mapped I/O): 硬件寄存器通常映射到特定的物理内存地址。链接脚本可以帮助我们将 C++ 变量或结构体直接定位到这些地址,从而方便地通过指针访问硬件。
  • 安全考虑: 将某些敏感代码或数据放置在受保护的内存区域,或者将不同的模块隔离开来,以增强系统的安全性。

链接脚本就是为满足这些需求而生。它是一种由链接器(通常是 GNU ld)解析的特殊指令文件,用于描述输出文件的内存布局。通过编写链接脚本,我们可以精确定义各种程序段(Sections)在内存中的起始地址、大小以及它们之间的相对顺序。

2. 编译与链接的流程概览

在深入链接脚本之前,让我们快速回顾一下 C++ 代码从源代码到可执行文件的基本流程。这有助于我们理解链接器在整个链条中的位置和作用。

  1. 预处理(Preprocessing): 编译器首先运行预处理器,处理 #include#define 等指令,生成一个“纯净”的 C++ 源代码文件。
  2. 编译(Compilation): 编译器将预处理后的 C++ 代码翻译成汇编代码。
  3. 汇编(Assembly): 汇编器将汇编代码翻译成机器码,并将其打包成目标文件(Object File)。目标文件通常是 ELF (Executable and Linkable Format) 格式(在 Linux/Unix 系统上)或 PE (Portable Executable) 格式(在 Windows 系统上)。
  4. 链接(Linking): 链接器将一个或多个目标文件以及任何必要的库文件(静态库或共享库)组合成一个最终的可执行文件或共享库。

我们的关注点主要在链接阶段。链接器接收目标文件作为输入,并根据链接脚本(如果提供了的话)生成最终的输出文件。

2.1 目标文件(Object File)的内部结构

为了更好地理解链接脚本,我们需要对目标文件(特别是 ELF 格式)的内部结构有一个基本认识。一个典型的 ELF 目标文件包含以下关键部分:

  • ELF Header: 描述文件的整体信息,如文件类型(可重定位文件、可执行文件等)、目标架构、入口点地址等。
  • Section Header Table (SHT): 描述文件中所有节(Section)的信息,包括节的名称、类型、大小、在文件中的偏移量、内存中的对齐要求等。
  • Program Header Table (PHT): 仅存在于可执行文件和共享库中,描述如何将文件中的节加载到内存中以创建进程映像。
  • Sections: 这是我们关注的核心,它们包含了程序的代码、数据、符号表、重定位信息等。常见的节包括:
    • .text:包含可执行的机器码。
    • .data:包含已初始化的全局变量和静态变量。
    • .rodata:包含只读数据,如字符串字面量、const 修饰的全局变量。
    • .bss:包含未初始化的全局变量和静态变量。这些变量在程序启动时由加载器或启动代码清零。
    • .symtab:符号表,包含程序中定义和引用的所有符号(函数名、变量名)。
    • .strtab:字符串表,存储符号名和其他字符串。
    • .rel.text, .rel.data:重定位表,记录需要链接器修正的地址引用。
    • .init, .fini:包含构造函数和析构函数的代码。
    • .ctors, .dtors:C++ 构造函数和析构函数指针列表。
    • .eh_frame:异常处理帧信息。
    • .debug_*:调试信息,如 .debug_info, .debug_line 等。

重要概念:

  • 输入节(Input Section): 来自目标文件或库文件的节。
  • 输出节(Output Section): 链接器将多个输入节合并后在最终可执行文件中形成的节。链接脚本就是定义这些输出节如何布局的。

3. 链接脚本的基础语法与核心指令

链接脚本使用一种类似于 C 语言的语法,但其目的完全不同。它不是用来编写程序逻辑,而是用来指导链接器如何组织内存。

一个链接脚本通常包含以下几个核心指令:

  • ENTRY():定义程序的入口点。
  • MEMORY{}:定义目标系统的内存区域。
  • SECTIONS{}:定义输出文件的节布局,这是链接脚本最核心的部分。

让我们逐一剖析这些指令。

3.1 ENTRY(symbol):定义程序入口点

ENTRY 命令用于指定程序执行的起始点。这通常是一个函数的名称,例如 ENTRY(_start)。在大多数 C/C++ 程序中,_start 是由 C 运行时库(CRT)提供的,它会进行一些初始化工作,然后调用 main 函数。

ENTRY(_start)

3.2 MEMORY{}:定义内存区域

MEMORY 命令用于定义目标系统可用的物理内存区域。每个内存区域都有一个起始地址(origin, orgo和一个长度(length, lenl。这些区域可以是不同类型(如 Flash、RAM)或具有不同访问属性的内存。

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K  /* 闪存区域,可读可执行 */
  RAM (rwx)  : ORIGIN = 0x20000000, LENGTH = 128K   /* 内存区域,可读可写可执行 */
}
  • FLASHRAM:这是你为内存区域定义的名称,可以是任何你喜欢的标识符。
  • (rx)(rwx):这是区域的属性标志。
    • r:可读
    • w:可写
    • x:可执行
    • a:可分配(默认)
    • i:初始化(默认)
  • ORIGINorgo:区域的起始物理地址。
  • LENGTHlenl:区域的总长度。

3.3 SECTIONS{}:定义输出节布局

SECTIONS 命令是链接脚本的核心,它定义了最终输出文件中的各个节(Output Sections)如何组织,以及它们将包含哪些来自输入文件(Input Sections)的内容。

SECTIONS 块内部由一系列的输出节定义组成。每个输出节定义都指定了其名称、属性以及它包含的输入节。

SECTIONS
{
  /* 输出节定义 */
  .text :
  {
    /* 输入节描述 */
    KEEP(*(.text.startup)) /* 保留特定的启动代码 */
    *(.text)               /* 将所有输入文件中的 .text 节放入这里 */
    *(.text.*)             /* 包含所有以 .text. 开头的子节 */
  } > FLASH AT> FLASH      /* 运行时和加载时地址都在 FLASH 区域 */

  .data :
  {
    _sdata = .;            /* 定义符号 _sdata 为 .data 节的起始地址 */
    *(.data)
  } > RAM AT> FLASH        /* 运行时在 RAM,但加载时在 FLASH */

  .bss (NOLOAD) :
  {
    _sbss = .;
    *(.bss)
    *(COMMON)              /* 处理 COMMON 符号 */
    _ebss = .;
  } > RAM                  /* 运行时在 RAM,不从文件加载 (NOLOAD) */

  /* ... 其他节 ... */
}

3.3.1 . (点) – 位置计数器

SECTIONS 块内部,. 符号是一个特殊变量,表示当前位置计数器(Location Counter)。它表示当前输出节中下一个字节将被放置的地址。你可以在链接脚本中使用 . 来计算地址、设置对齐或者定义符号。

. = ALIGN(4);  /* 将位置计数器对齐到4字节边界 */
_my_symbol = .; /* 定义一个符号 _my_symbol 为当前地址 */

3.3.2 输出节的属性

每个输出节可以有以下重要属性:

  • > region_name:运行时地址(VMA – Virtual Memory Address)
    指定该输出节将放置在哪个 MEMORY 区域。这是程序运行时访问该节的地址。
    例如:.text > FLASH 表示 .text 节将在 FLASH 区域中运行。

  • AT > region_name:加载时地址(LMA – Load Memory Address)
    指定该输出节在可执行文件中的存储位置,以及加载器将其加载到内存中的位置。对于嵌入式系统,特别是 .data 节,VMA 和 LMA 通常是不同的。
    例如:.data > RAM AT > FLASH 表示 .data 节的初始化值存储在 FLASH 中(LMA),但在程序启动时会被复制到 RAM 中运行(VMA)。

  • (NOLOAD):不加载
    此属性表示该节不需要在程序启动时从文件中加载到内存。它通常用于 .bss 节,因为 .bss 节只包含未初始化的数据,加载器或启动代码会将其清零,而不是从文件中复制内容。

  • ALIGN(alignment):对齐
    强制输出节的起始地址对齐到指定的字节边界。

3.3.3 输入节描述

在输出节内部,我们使用模式匹配来指定要包含哪些输入节。

  • *`(.text):** 匹配所有输入文件中的.text节。*` 是通配符,表示所有输入文件。
  • filename.o(.data) 仅匹配 filename.o 文件中的 .data 节。
  • *(.text .text.*) 匹配所有输入文件中的 .text 节和所有以 .text. 开头的子节(如 .text.startup)。
  • KEEP() 用于防止链接器对指定的输入节进行垃圾回收(garbage collection)。即使该节没有被显式引用,它也会被保留。这对于一些特殊的启动代码或中断向量表非常有用。
  • SORT() 用于对匹配到的输入节进行排序。例如 SORT(.text.*) 可以按字母顺序排列所有 .text.* 节。
  • COMMON 这是一个特殊的关键字,用于处理 C 语言中的“common”符号(未初始化的全局变量,其定义可能分散在多个文件中)。链接器会为它们分配空间。

3.3.4 定义符号

你可以在链接脚本中使用 symbol = expression; 的形式来定义符号。这些符号可以在 C/C++ 代码中通过 extern 声明来引用,从而获取特定节的起始地址、结束地址或大小。

SECTIONS
{
  .data :
  {
    _sdata = .;          /* .data 节的起始地址 */
    *(.data)
    _edata = .;          /* .data 节的结束地址 */
  } > RAM AT > FLASH

  .bss (NOLOAD) :
  {
    _sbss = .;           /* .bss 节的起始地址 */
    *(.bss)
    *(COMMON)
    _ebss = .;           /* .bss 节的结束地址 */
  } > RAM
}

在 C++ 代码中:

extern char _sdata;
extern char _edata;
extern char _sbss;
extern char _ebss;

void startup_init() {
    // 复制 .data 段
    char *src = &_sdata; // LMA for .data is _sdata
    char *dest = &_sdata; // VMA for .data is also _sdata here,
                          // but in embedded systems, src would be LMA and dest would be VMA.
                          // Let's refine this example later for clarity.

    // For now, let's assume LMA == VMA to simplify, but the concept holds.
    // The actual copy loop would look like this for LMA != VMA:
    // extern char __data_load_start__; // LMA start
    // extern char __data_start__;      // VMA start
    // extern char __data_end__;        // VMA end
    // for (char *s = &__data_load_start__, *d = &__data_start__; d < &__data_end__; ++s, ++d) {
    //     *d = *s;
    // }

    // 清零 .bss 段
    for (char *p = &_sbss; p < &_ebss; ++p) {
        *p = 0;
    }
}

4. 实际案例分析与代码实践

现在,让我们通过几个具体的例子来演示链接脚本的强大功能。

4.1 案例一:简单的桌面程序内存布局

对于一个运行在操作系统之上的桌面程序,我们通常不需要复杂的内存映射,因为操作系统会处理大部分细节。但为了理解基础,我们可以看一个简单的链接脚本,它将 .text.rodata.data.bss 顺序放置。

假设我们有一个 main.cpp 文件:

// main.cpp
#include <iostream>

const char* message = "Hello, Linker Scripts!"; // .rodata
int global_initialized_var = 123;             // .data
int global_uninitialized_var;                 // .bss

void print_message() {
    std::cout << message << std::endl;
}

int main() {
    print_message();
    global_uninitialized_var = 456;
    std::cout << "Initialized var: " << global_initialized_var << std::endl;
    std::cout << "Uninitialized var (now 456): " << global_uninitialized_var << std::endl;
    return 0;
}

simple_app.ld (链接脚本):

/* simple_app.ld */

ENTRY(_start) /* 假设由C运行时库提供 */

/* 定义一个单一的内存区域,通常是RAM,因为OS会处理虚拟内存到物理内存的映射 */
MEMORY
{
  RAM (rwx) : ORIGIN = 0x00001000, LENGTH = 256M
}

SECTIONS
{
  /* 将所有代码段 .text 放置在 RAM 区域 */
  .text :
  {
    KEEP(*(.text.startup))
    *(.text)
    *(.text.*)
    *(.glue_7t) *(.glue_7)
    *(.vfp11_veneer)
    *(.ARM.extab) *(.ARM.exidx)
  } > RAM

  /* 将所有只读数据段 .rodata 放置在 .text 之后,也在 RAM 区域 */
  .rodata :
  {
    *(.rodata)
    *(.rodata*)
  } > RAM

  /* 将所有初始化数据段 .data 放置在 .rodata 之后,在 RAM 区域 */
  .data :
  {
    *(.data)
    *(.data.*)
  } > RAM

  /* 将所有未初始化数据段 .bss 放置在 .data 之后,在 RAM 区域 */
  .bss :
  {
    *(.bss)
    *(.bss.*)
    *(COMMON) /* 处理 COMMON 符号 */
  } > RAM

  /* 其他常见的节,例如堆和栈 */
  . = ALIGN(4); /* 对齐到4字节 */
  .stack_dummy :
  {
    *(.stack)
  } > RAM

  .heap_dummy :
  {
    *(.heap)
  } > RAM

  /* 丢弃一些不需要的调试或链接器内部节 */
  /DISCARD/ :
  {
    *(.comment)
    *(.ARM.attributes)
    *(.note.GNU-stack)
    *(.gnu.attributes)
    *(.pdr)
    *(.debug*)
  }
}

编译和链接:

g++ -c main.cpp -o main.o
ld -o simple_app -T simple_app.ld main.o -lc -lgcc # -lc -lgcc 链接标准库

使用 objdump -h simple_appreadelf -S simple_app 可以查看最终的可执行文件节信息,验证其布局是否符合链接脚本的定义。

$ readelf -S simple_app
There are 15 section headers, starting at offset 0x3640:

Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            0000000000000000 000000 000000 00     0   0  0
  [ 1] .text             PROGBITS        0000000000001000 001000 0001b3 00  AX  0   0  4
  [ 2] .rodata           PROGBITS        00000000000011b4 0011b4 000019 00   A  0   0  4
  [ 3] .data             PROGBITS        00000000000011d0 0011d0 000004 00  WA  0   0  4
  [ 4] .bss              NOBITS          00000000000011d4 0011d4 000004 00  WA  0   0  4
  # ... 其他省略 ...

从输出可以看到,.text0x1000 开始,.rodata 紧随其后,然后是 .data,最后是 .bss。这正是链接脚本所期望的。

4.2 案例二:嵌入式系统中的 Flash/RAM 分离

这是链接脚本最典型的应用场景。微控制器通常将代码和常量数据存储在非易失性存储器(如 Flash)中,而变量和堆栈则存储在易失性存储器(如 RAM)中。

关键概念:加载地址 (LMA) vs. 运行地址 (VMA)

  • LMA (Load Memory Address): 节在非易失性存储器(如 Flash)中的物理位置。当程序烧录到设备中时,节就位于这些地址。
  • VMA (Virtual Memory Address): 节在程序运行时实际占用的内存地址。对于 Flash 中的代码和只读数据,LMA 和 VMA 通常是相同的。但对于初始化的数据 (.data),它的初始值存储在 Flash 中(LMA),但在程序启动时需要被复制到 RAM 中运行(VMA)。未初始化的数据 (.bss) 只存在于 RAM 中(VMA),没有 LMA。

假设我们有一个简单的微控制器,其内存布局如下:

  • Flash: 起始地址 0x08000000,大小 128KB
  • RAM: 起始地址 0x20000000,大小 32KB

embedded.ld (链接脚本):

/* embedded.ld */

ENTRY(_start) /* 程序的入口点,通常在启动文件中定义 */

/* 定义内存区域 */
MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
  RAM (rwx)  : ORIGIN = 0x20000000, LENGTH = 32K
}

SECTIONS
{
  /* 中断向量表 (通常在 Flash 的起始位置) */
  .isr_vector :
  {
    KEEP(*(.isr_vector)) /* 确保中断向量表不被优化掉 */
  } > FLASH

  /* 代码段:运行时和加载时都在 FLASH */
  .text :
  {
    . = ALIGN(4); /* 对齐 */
    *(.text)
    *(.text.*)
    *(.glue_7t) *(.glue_7)
    *(.vfp11_veneer)
    *(.ARM.extab) *(.ARM.exidx) /* 异常处理信息 */
    *(.init)
    *(.fini)
    PROVIDE_HIDDEN (__preinit_array_start = .);
    KEEP (*(.preinit_array))
    PROVIDE_HIDDEN (__preinit_array_end = .);
    PROVIDE_HIDDEN (__init_array_start = .);
    KEEP (*(SORT(.init_array.*)))
    KEEP (*(.init_array))
    PROVIDE_HIDDEN (__init_array_end = .);
    PROVIDE_HIDDEN (__fini_array_start = .);
    KEEP (*(SORT(.fini_array.*)))
    KEEP (*(.fini_array))
    PROVIDE_HIDDEN (__fini_array_end = .);
    KEEP (*crtbegin.o(.ctors))
    KEEP (*(EXCLUDE_FILE(*crtend.o) .ctors))
    KEEP (*(SORT(.ctors.*)))
    KEEP (*crtend.o(.ctors))
    KEEP (*crtbegin.o(.dtors))
    KEEP (*(EXCLUDE_FILE(*crtend.o) .dtors))
    KEEP (*(SORT(.dtors.*)))
    KEEP (*crtend.o(.dtors))
    *(.eh_frame) /* 异常帧 */

    . = ALIGN(4);
    _etext = .; /* 定义 _etext 符号,表示代码段结束地址 */
  } > FLASH

  /* 只读数据段:运行时和加载时都在 FLASH */
  .rodata :
  {
    . = ALIGN(4);
    *(.rodata)
    *(.rodata*)
    . = ALIGN(4);
  } > FLASH

  /* 初始化的数据段 (.data) */
  /* 加载时在 FLASH,运行时在 RAM */
  .data : AT (ADDR(.rodata) + SIZEOF(.rodata)) /* LMA在rodata之后 */
  {
    . = ALIGN(4);
    _sdata = .;         /* _sdata 是 .data 节在 RAM 中的 VMA 起始地址 */
    *(.data)
    *(.data.*)
    . = ALIGN(4);
    _edata = .;         /* _edata 是 .data 节在 RAM 中的 VMA 结束地址 */
  } > RAM

  /* 未初始化的数据段 (.bss):只在 RAM 中,不从 FLASH 加载 */
  .bss (NOLOAD) :
  {
    . = ALIGN(4);
    _sbss = .;          /* _sbss 是 .bss 节在 RAM 中的 VMA 起始地址 */
    *(.bss)
    *(.bss.*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;          /* _ebss 是 .bss 节在 RAM 中的 VMA 结束地址 */
  } > RAM

  /* 堆栈段:在 RAM 中 */
  .stack_start :
  {
    . = ALIGN(8); /* 通常堆栈需要较大对齐 */
    _stack_bottom = .; /* 栈底 */
    . = . + 0x400;     /* 假设分配 1KB 栈空间 */
    _stack_top = .;    /* 栈顶 */
  } > RAM

  /* 堆段:在 RAM 中,紧随堆栈之后或在 RAM 剩余空间中 */
  .heap_start :
  {
    . = ALIGN(8);
    _heap_start = .;
  } > RAM

  .heap_end :
  {
    _heap_end = .;
  } > RAM

  /* 丢弃不需要的调试信息和属性节 */
  /DISCARD/ :
  {
    *(.comment)
    *(.ARM.attributes)
    *(.note.GNU-stack)
    *(.gnu.attributes)
    *(.pdr)
    *(.debug*)
  }
}

C++ 启动代码 (startup.cpp 或 startup.s):

为了让上述链接脚本正确工作,我们需要一段启动代码(通常是汇编或精简 C++)来执行以下任务:

  1. 复制 .data 段:.data 节从 Flash(LMA)复制到 RAM(VMA)。
  2. 清零 .bss 段:.bss 节在 RAM 中清零。
  3. 设置堆栈指针: 初始化主堆栈指针。
  4. 调用 C++ 构造函数: 执行全局对象的构造函数(.init_array)。
  5. 跳转到 main() 调用应用程序的 main 函数。
// startup_code.cpp (简化版,仅展示 .data 和 .bss 处理)

extern "C" {
    // 这些符号由链接脚本提供
    extern unsigned int _etext;       // .text 节的结束地址 (LMA & VMA in FLASH)
    extern unsigned int _sdata;       // .data 节在 RAM 中的 VMA 起始地址
    extern unsigned int _edata;       // .data 节在 RAM 中的 VMA 结束地址
    extern unsigned int _sbss;        // .bss 节在 RAM 中的 VMA 起始地址
    extern unsigned int _ebss;        // .bss 节在 RAM 中的 VMA 结束地址
    extern unsigned int _stack_top;   // 栈顶地址

    // C++ main 函数声明
    int main();

    // 全局构造函数和析构函数数组
    extern void (*__preinit_array_start [])(void) __attribute__((weak));
    extern void (*__preinit_array_end   [])(void) __attribute__((weak));
    extern void (*__init_array_start    [])(void) __attribute__((weak));
    extern void (*__init_array_end      [])(void) __attribute__((weak));
    extern void (*__fini_array_start    [])(void) __attribute__((weak));
    extern void (*__fini_array_end      [])(void) __attribute__((weak));

    // 默认中断处理函数
    void Default_Handler() {
        while(1); // 无限循环
    }

    // 复位处理函数,作为程序的入口点
    void Reset_Handler() __attribute__((noreturn));
    void Reset_Handler() {
        // 1. 复制 .data 段
        // _etext 是 Flash 中 .text 节的结束地址,紧随其后是 .data 的 LMA
        unsigned int *src = &_etext; // LMA of .data
        unsigned int *dest = &_sdata; // VMA of .data

        while (dest < &_edata) {
            *dest++ = *src++;
        }

        // 2. 清零 .bss 段
        dest = &_sbss;
        while (dest < &_ebss) {
            *dest++ = 0;
        }

        // 3. 调用 C++ 全局构造函数
        // 按照链接脚本中的顺序,先是 preinit_array,然后是 init_array
        unsigned int i;
        unsigned int count;

        count = (unsigned int)__preinit_array_end - (unsigned int)__preinit_array_start;
        for (i = 0; i < count; i++) {
            __preinit_array_start[i]();
        }

        count = (unsigned int)__init_array_end - (unsigned int)__init_array_start;
        for (i = 0; i < count; i++) {
            __init_array_start[i]();
        }

        // 4. 调用 main 函数
        main();

        // 如果 main 返回,则进入无限循环
        while(1);
    }
} // extern "C"

// 假设我们有一个简单的 main 函数
int main() {
    // 应用程序代码
    volatile int x = 10;
    static int y = 20; // .data
    static int z;      // .bss

    y++;
    z = x + y;

    // 假设有一些硬件初始化和主循环
    while(1) {
        // ...
    }
}

// 定义中断向量表 (假设是 Cortex-M 微控制器)
// 通常这部分会在汇编启动文件中完成,这里用C++伪代码表示
// _stack_top 是由链接脚本定义的栈顶地址
// Reset_Handler 是复位向量
// Default_Handler 是其他中断的默认处理
void (* const g_pfnVectors[])(void) __attribute__ ((section(".isr_vector"))) = {
    (void (*)(void))&_stack_top, // 栈顶地址
    Reset_Handler,               // 复位向量
    Default_Handler,             // NMI
    Default_Handler,             // HardFault
    // ... 其他中断向量 ...
};

编译和链接 (示例命令):

# 假设使用 ARM GCC 工具链
arm-none-eabi-g++ -c -mcpu=cortex-m4 -mthumb -g -O0 startup_code.cpp -o startup_code.o
arm-none-eabi-g++ -c -mcpu=cortex-m4 -mthumb -g -O0 main.cpp -o main.o
arm-none-eabi-g++ -mcpu=cortex-m4 -mthumb -g -O0 -nostdlib -T embedded.ld startup_code.o main.o -o firmware.elf
  • -nostdlib:不链接标准库,因为我们自己提供了 _startReset_Handler)和内存初始化。
  • -T embedded.ld:指定链接脚本。

通过 arm-none-eabi-objdump -h firmware.elfarm-none-eabi-readelf -S firmware.elf 检查 firmware.elf,你会看到 .text.rodata 的 LMA 和 VMA 都在 0x0800xxxx (Flash),而 .data 的 LMA 在 0x0800xxxx (Flash) 且 VMA 在 0x2000xxxx (RAM),.bss 只有 VMA 在 0x2000xxxx (RAM)。

4.3 案例三:自定义节和内存映射 I/O

有时,我们需要将特定的变量或数据结构放置在固定的、由硬件定义的内存地址上,例如微控制器的外设寄存器。

假设 GPIO 端口 A 的数据寄存器位于地址 0x40020010

gpio_access.ld (链接脚本片段):

/* ... */
MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
  RAM (rwx)  : ORIGIN = 0x20000000, LENGTH = 32K
  PERIPHERALS (rwx) : ORIGIN = 0x40020000, LENGTH = 0x10000 /* 假设外设区域 */
}

SECTIONS
{
  /* ... 其他节 ... */

  /* 自定义节,用于放置 GPIOA_DR 变量 */
  .gpioa_dr_section :
  {
    KEEP(*(.gpioa_dr)) /* 确保这个节不会被垃圾回收 */
  } > PERIPHERALS AT > PERIPHERALS /* 加载和运行都在外设区域 */

  /* ... */
}

C++ 代码 (main.cpp):

#include <cstdint>

// 定义一个结构体来表示 GPIO 端口 A 的寄存器
// 这里的地址是 GPIOA_BASE (0x40020000)
// DR 寄存器通常在基址 + 0x10 的偏移处
struct GPIOA_Registers {
    volatile uint32_t MODER;   // 0x00
    volatile uint32_t OTYPER;  // 0x04
    volatile uint32_t OSPEEDR; // 0x08
    volatile uint32_t PUPDR;   // 0x0C
    volatile uint32_t IDR;     // 0x10 (Input Data Register)
    volatile uint32_t ODR;     // 0x14 (Output Data Register)
    // ... 其他寄存器
};

// 使用 __attribute__((section(".gpioa_dr"))) 将变量放置到自定义节中
// 并将其地址设置为 GPIOA_Registers 的基址
// 注意:这里需要配合链接脚本将 .gpioa_dr 节的 VMA 设定为 0x40020000
// 或者更常见的方式是直接使用指针强制转换
GPIOA_Registers *const GPIOA = reinterpret_cast<GPIOA_Registers*>(0x40020000);

// 如果是单个寄存器,可以直接定义
// volatile uint32_t GPIOA_ODR __attribute__((section(".gpioa_odr_section"))) = 0;
// 并在链接脚本中 .gpioa_odr_section : { *(.gpioa_odr_section) } > PERIPHERALS AT 0x40020014

int main() {
    // 直接通过指针访问 GPIO 寄存器
    // 例如,设置 GPIOA 的第 5 位为高电平 (假设 ODR 控制输出)
    GPIOA->ODR |= (1 << 5);

    // 例如,读取 GPIOA 的输入数据寄存器
    uint32_t input_state = GPIOA->IDR;

    while(1) {
        // ...
    }
    return 0;
}

说明:
对于内存映射 I/O,更常见和安全的做法是直接使用 reinterpret_cast 将基地址强制转换为指向寄存器结构体的指针,而不是通过链接脚本来放置 C++ 变量。因为编译器可能会对变量进行优化,或者变量本身的大小和布局可能与硬件寄存器不完全匹配。

然而,链接脚本仍然可以在以下场景发挥作用:

  • 强制特定数据结构在某个固定地址: 如果你有一个特殊的查找表或配置数据,必须位于某个预设的物理地址,链接脚本可以确保这一点。
  • 分配未使用的内存区域为自定义用途: 将一部分 RAM 定义为特殊的缓冲区,通过链接脚本将其命名,并在 C++ 中通过符号访问其起始和结束地址。

4.4 案例四:丢弃不需要的节

在最终的可执行文件中,有时会包含一些我们不希望保留的节,例如调试信息 (.debug_*)、编译器或链接器内部使用的属性节 (.comment, .ARM.attributes)。使用 /DISCARD/ 命令可以将这些节从输出文件中移除,从而减小文件大小。

SECTIONS
{
  /* ... 其他节 ... */

  /DISCARD/ :
  {
    *(.comment)
    *(.ARM.attributes)
    *(.note.GNU-stack)
    *(.gnu.attributes)
    *(.pdr)
    *(.debug_info)
    *(.debug_aranges)
    *(.debug_pubnames)
    *(.debug_pubtypes)
    *(.debug_abbrev)
    *(.debug_line)
    *(.debug_str)
    *(.debug_loc)
    *(.debug_frame)
    *(.debug_macinfo)
    *(.debug_weaknames)
    *(.debug_funcnames)
    *(.debug_isinfo)
    *(.debug_ranges)
    *(.debug_types)
    *(.debug_macro)
  }
}

这样做可以生成更小的固件文件,这在存储空间有限的嵌入式系统中非常有用。

5. 链接脚本的进阶特性

除了上述核心指令,链接脚本还提供了一些高级功能,以满足更复杂的内存布局需求。

5.1 PROVIDE()PROVIDE_HIDDEN()

这两个函数用于在链接脚本中定义符号。PROVIDE() 定义的符号是全局可见的,而 PROVIDE_HIDDEN() 定义的符号是隐藏的,这意味着它们不会在动态链接时导出,但可以在静态链接时被引用。它们通常用于标记节的起始和结束地址。

SECTIONS
{
  .text :
  {
    _text_start = .;
    *(.text)
    _text_end = .;
  } > FLASH
}

等价于:

SECTIONS
{
  .text :
  {
    PROVIDE(_text_start = .);
    *(.text)
    PROVIDE(_text_end = .);
  } > FLASH
}

5.2 ALIGN()ALIGN_WITH_INPUT()

  • ALIGN(expression):将当前位置计数器对齐到 expression 指定的地址边界。例如 ALIGN(4) 将地址对齐到4字节。
  • ALIGN_WITH_INPUT():根据下一个输入节的对齐要求来对齐当前位置计数器。

5.3 KEEP()

我们已经提到过 KEEP(),它强制链接器保留指定的输入节,即使没有代码显式引用它。这对于中断向量表、特殊的启动代码或者需要固定位置的数据块非常重要。

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

5.4 SORT()

SORT() 函数用于对匹配到的输入节进行排序。这在某些情况下有助于优化代码或数据布局。

  • SORT(.text.*):按字母顺序排列所有以 .text. 开头的输入节。
  • SORT_BY_NAME(.init_array.*):按名称排序。
  • SORT_BY_ALIGNMENT(COMMON):按对齐要求排序。
  • SORT_BY_INIT_PRIORITY(.init_array.*):按初始化优先级排序(C++11)。

5.5 ASSERT(expression, message)

ASSERT 命令用于在链接时进行断言检查。如果 expression 求值为假,链接器将输出 message 并终止链接。这对于验证内存区域是否足够大、地址是否正确对齐等非常有用。

MEMORY
{
  RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 32K
}

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

  ASSERT((_ebss - ORIGIN(RAM)) <= LENGTH(RAM), "RAM memory overflow for .bss section!");
}

5.6 OUTPUT_FORMATOUTPUT_ARCH

  • OUTPUT_FORMAT(bfdname):指定输出文件的格式,如 elf32-littlearmbinary 等。
  • OUTPUT_ARCH(arch):指定输出文件的目标架构,如 armi386 等。

这些通常在文件开头定义,用于指导链接器生成特定格式和架构的文件。

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

/* ... */

6. 与 C/C++ 代码的交互

链接脚本中定义的符号,例如 _sdata_edata 等,可以在 C/C++ 代码中通过 extern 关键字进行访问。它们在 C/C++ 中被视为 char 类型的指针(或 unsigned int,取决于你的习惯),其值就是链接器分配的内存地址。

// 在 C++ 代码中
extern "C" { // 确保 C++ 编译器不对这些符号进行名称修饰
    extern char _sdata;
    extern char _edata;
    extern char _sbss;
    extern char _ebss;
}

void initialize_memory() {
    // 复制 .data 段
    // 假设 __data_load_start__ 是 .data 节在 Flash 中的 LMA
    // _sdata 是 .data 节在 RAM 中的 VMA
    // 这需要链接脚本中定义 __data_load_start__
    extern char __data_load_start__;
    char *src = &__data_load_start__;
    char *dest = &_sdata;
    while (dest < &_edata) {
        *dest++ = *src++;
    }

    // 清零 .bss 段
    dest = &_sbss;
    while (dest < &_ebss) {
        *dest++ = 0;
    }
}

注意 extern "C" C++ 编译器会对函数名和变量名进行“名称修饰”(name mangling)以支持函数重载等特性。然而,链接脚本定义的符号是纯 C 风格的,没有修饰。因此,在 C++ 代码中引用这些符号时,必须使用 extern "C" 来告诉编译器不要修饰这些名称,以便链接器能够正确解析它们。

7. 调试链接脚本问题

调试链接脚本可能是一个挑战,但有一些工具和技巧可以帮助你。

  • 生成链接器映射文件 (.map 文件): 这是调试链接脚本最重要的工具。使用 ld -M -o output_file -T linker_script.ld ... > output.map 命令,链接器会生成一个详细的映射文件,其中列出了所有输入文件、它们贡献的节、输出节的地址和大小,以及所有符号的地址。仔细检查 .map 文件可以发现内存重叠、节放置错误、地址不符合预期等问题。

  • objdump -hreadelf -S 这些工具可以显示可执行文件或目标文件中的所有节的名称、类型、地址、大小和属性。用于验证链接脚本是否按预期生成了输出节。

  • nm 列出目标文件或可执行文件中的所有符号及其地址。可以用来检查链接脚本中定义的符号是否正确导出。

  • ld --verbose 打印链接器使用的内置链接脚本和搜索路径。

  • ASSERT() 在链接脚本中加入 ASSERT() 语句,可以在链接时进行一些基本检查,提前发现问题。

  • 逐步简化: 如果你的链接脚本很复杂,遇到问题时可以尝试将其简化到一个最小的工作版本,然后逐步添加功能,定位问题所在。

8. 最佳实践和注意事项

  • 了解目标硬件: 在编写链接脚本之前,务必彻底了解目标微控制器的内存映射、Flash 和 RAM 的起始地址、大小以及任何特殊的外设内存区域。
  • 从模板开始: 不要从零开始编写链接脚本。许多微控制器厂商或开发环境会提供基于其硬件的默认链接脚本,你可以以此为基础进行修改。
  • 使用有意义的名称: 为内存区域、节和符号使用清晰、描述性的名称,提高可读性。
  • 注释: 详细注释你的链接脚本,解释每个部分的目的,特别是复杂的地址计算或特殊处理。
  • 版本控制: 将链接脚本纳入你的版本控制系统,与源代码一起管理。
  • 避免硬编码: 尽量使用链接脚本提供的函数(如 ORIGIN(), LENGTH(), SIZEOF(), ADDR())进行地址和大小的计算,而不是硬编码数字,这能提高脚本的可维护性。
  • 对齐: 始终注意节的对齐要求。不正确的对齐可能导致性能下降,甚至在某些架构上引发硬件错误。
  • 内存溢出检查: 结合 ASSERT().map 文件,确保你的代码和数据不会超出可用的内存区域。
  • 理解 LMA 和 VMA: 这是嵌入式系统中最重要的概念之一。确保你清楚哪些节需要 LMA != VMA,以及启动代码如何处理它们。

9. 链接脚本的精髓

链接脚本是连接高级编程语言世界与底层硬件世界的桥梁。它赋予了开发者对程序内存布局的极致控制,这在资源受限或需要特定硬件交互的场景中是不可或缺的。从简单的桌面程序到复杂的嵌入式系统和操作系统内核,理解和掌握链接脚本是成为一名真正系统级程序员的关键一步。它不仅仅是配置工具,更是一种深入理解计算机体系结构和程序执行机制的强大思维模型。通过精心设计的链接脚本,我们可以优化性能、增强安全性、实现灵活的内存管理,并最终打造出高效、可靠的软件系统。

希望这次讲座能帮助大家深入理解链接脚本的奥秘。谢谢!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注