实战:编写自定义 Linker Script:在嵌入式 C++ 开发中精准控制内存布局

各位同仁,各位对嵌入式系统内存管理充满热情的工程师们,欢迎来到今天的专题讲座。我们将深入探讨一个在嵌入式C++开发中至关重要,但又常常被视为“黑魔法”的领域——编写自定义Linker Script(链接器脚本),以实现对内存布局的精准控制。

在嵌入式系统的世界里,内存资源往往是宝贵的,有限的,并且其物理特性(如Flash与RAM的速度、擦写寿命)差异巨大。一个高效、稳定、可扩展的嵌入式应用,其成功的基石之一,就是对内存布局的精妙设计和严格控制。而Linker Script,正是实现这一控制的强大工具。

1. 嵌入式系统内存的本质与挑战

在深入Linker Script之前,我们首先需要理解嵌入式系统中的内存特性及其带来的挑战。

1.1 存储介质的类型与特性

嵌入式系统通常拥有多种存储介质,它们各有特点:

  • 闪存 (Flash Memory):通常是程序的存储位置。它是非易失性的,断电后数据不会丢失。Flash通常分为代码Flash(如内部NAND/NOR Flash)和数据Flash(如外部SPI/QSPI Flash)。Flash的读写速度相对较慢,特别是擦写操作,且有擦写寿命限制。
  • 随机存取存储器 (RAM Memory):用于存储运行时的数据,如变量、堆栈。它是易失性的,断电后数据丢失。RAM的读写速度非常快,是CPU访问最频繁的区域。根据速度和接口,又分为SRAM、DRAM(SDRAM、DDR等)。
  • 特殊功能寄存器 (SFRs / Memory-Mapped Peripherals):这些不是传统的存储器,而是通过内存地址映射来控制外设的寄存器。对这些地址的读写操作实际上是在与硬件外设交互。

1.2 程序的逻辑结构与内存需求

一个编译好的嵌入式程序,从逻辑上可以划分为多个“段”或“节”(Section),每个段都有其特定的内存需求:

  • .text:包含编译后的机器指令代码。这部分代码通常存储在Flash中,并且在运行时直接从Flash执行(或部分加载到RAM中执行)。
  • .rodata:包含只读数据,如字符串常量、const修饰的变量、查找表等。这部分数据也存储在Flash中,且不能在运行时修改。
  • .data:包含已初始化的全局变量和静态变量。这些变量的值在程序启动时就已经确定。在程序映像中,它们存储在Flash中,但在程序运行时,它们的内容必须从Flash复制到RAM中,以便CPU可以读写。
  • .bss:包含未初始化的全局变量和静态变量(或初始化为零的变量)。这部分数据在程序映像中不占用Flash空间,但在程序启动时,其对应的RAM区域必须被清零。
  • .stack:用于存储函数调用帧、局部变量、返回地址等。它通常从RAM的高地址向低地址增长。
  • .heap:用于动态内存分配(如malloc/new)。它通常从RAM的低地址向高地址增长,与栈相对。
  • 中断向量表 (Vector Table):包含CPU中断和异常处理程序的入口地址。这部分通常位于Flash的起始地址(或可重定位到RAM),是CPU启动后第一个查找的地方。
  • 自定义段:根据应用需求,我们可能需要创建额外的段来存储特定类型的数据或代码,例如:
    • 持久化配置数据(需在Flash的特定区域)。
    • 需要高速执行的关键函数(复制到RAM中执行)。
    • 多核系统间的共享数据。

1.3 启动流程中的内存操作

当嵌入式系统上电或复位后,CPU会执行一个预定的启动流程,其中涉及重要的内存操作:

  1. 复位向量:CPU首先跳转到复位向量地址,通常指向启动代码的入口。
  2. 初始化RAM:启动代码(通常是汇编语言编写的crt0.s或等效的C启动文件)会执行以下关键任务:
    • .data段从Flash复制到RAM。
    • .bss段的RAM区域清零。
    • 初始化堆栈指针。
    • (可选)初始化堆。
  3. 跳转到main():完成上述初始化后,启动代码会调用C++程序的main()函数。

理解这些基本概念是编写有效Linker Script的前提。

2. 链接器 (Linker) 的作用与链接器脚本

2.1 链接器的职责

链接器是编译工具链中的一个关键组件,它的主要任务是将一个或多个目标文件(.o文件)、库文件(.a或.so文件)以及Linker Script组合起来,生成一个可执行文件(如.elf文件)。具体来说,链接器完成以下工作:

  1. 符号解析 (Symbol Resolution):将程序中对函数和变量的引用与它们在其他目标文件或库中定义的实际地址关联起来。
  2. 地址分配 (Address Assignment):根据Linker Script的指示,为代码和数据段分配具体的内存地址。这是Linker Script的核心功能。
  3. 重定位 (Relocation):调整代码中的地址引用,使其指向正确的运行时地址。
  4. 段合并 (Section Merging):将来自不同目标文件的相同类型的段(如所有.text段)合并成一个输出段。

2.2 为什么需要自定义Linker Script?

尽管编译器和链接器通常会提供默认的Linker Script,但对于嵌入式系统开发而言,自定义Linker Script几乎是必需的,原因如下:

  • 硬件差异性:不同的微控制器有不同的内存布局(Flash和RAM的起始地址、大小),默认脚本无法通用。
  • 内存优化:为了最大限度地利用有限的内存资源,可能需要将特定的代码或数据放置在内存的特定区域,例如:
    • 将频繁访问的函数或关键中断服务程序放置到高速RAM中,以提升性能。
    • 将大块的只读数据放置到外部大容量Flash中。
  • 功能实现
    • 实现双启动(Bootloader + Application)机制,需要将不同固件放置在独立的Flash区域。
    • 实现固件升级 (OTA),需要保留特定Flash区域用于存储新固件或配置数据。
    • 在多核系统中,需要定义共享内存区域以实现核间通信。
  • 调试与诊断:通过将特定的调试信息或日志缓冲区放置在已知地址,方便调试器访问。

简而言之,Linker Script是您与硬件之间的一座桥梁,让您能够以极高的精度定义程序在内存中的“物理位置”。

3. 解剖一个基础链接器脚本

一个典型的Linker Script(以GNU ld链接器为例)主要由两个核心命令组成:MEMORYSECTIONS

3.1 MEMORY 命令:定义内存区域

MEMORY 命令用于描述目标硬件的物理内存布局。它定义了每个内存区域的名称、起始地址 (ORIGINorg) 和长度 (LENGTHlen)。

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K  /* 内部Flash, 可读可执行 */
  RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 64K   /* 内部RAM, 可读可写可执行 */
  EXT_FLASH (r) : ORIGIN = 0x90000000, LENGTH = 16M /* 外部QSPI Flash, 只读 */
}
  • FLASH, RAM, EXT_FLASH:这是您为内存区域定义的名称,可以是任意有意义的字符串。
  • (rx), (rwx), (r):这些是内存区域的属性。
    • r:可读 (readable)
    • w:可写 (writable)
    • x:可执行 (executable)
    • a:可分配 (allocatable) – 通常隐式包含在其他属性中。
    • i:初始化 (initialized) – 用于指定该区域是否包含已初始化的数据(如Flash)。
  • ORIGIN:该内存区域的起始物理地址。
  • LENGTH:该内存区域的总长度。可以使用K(千字节)、M(兆字节)等后缀。

3.2 SECTIONS 命令:组织输出段

SECTIONS 命令是Linker Script的核心,它定义了如何将输入目标文件中的各种输入段(例如.text, .data, .bss)组合成输出可执行文件中的输出段,并将这些输出段放置在之前定义的MEMORY区域中。

一个输出段的通用结构如下:

SECTIONS
{
  .output_section_name : AT(lma_address) > vma_region
  {
    /* 输入段选择器和符号定义 */
    *(.input_section_name) /* 匹配所有目标文件的 .input_section_name */
    . = ALIGN(4);          /* 对齐地址 */
    _symbol_name = .;      /* 定义符号 */
  } > vma_region_if_not_specified_above = fill_value
}
  • .output_section_name:这是最终可执行文件中将包含的输出段的名称。
  • ::分隔输出段名称和其属性。
  • AT(lma_address):指定输出段的加载内存地址 (LMA)。这是程序烧写到存储器(如Flash)中的实际物理地址。如果省略,LMA默认为VMA。
  • > vma_region:指定输出段的虚拟内存地址 (VMA)。这是程序运行时CPU访问该段的地址。vma_region是之前在MEMORY命令中定义的内存区域名称。如果省略,VMA默认为当前位置计数器'.'的值。
  • { ... }:输出段的内容定义。
  • input_section_selector:用于选择要包含在该输出段中的输入段。
    • *(.text):表示选择所有目标文件中的.text输入段。
    • path/to/file.o(.data):选择特定目标文件中的.data段。
    • KEEP (*(.isr_vector)):强制链接器保留某个输入段,即使它没有被引用。这对于中断向量表等至关重要。
  • . (Location Counter):代表当前输出地址。链接器在处理SECTIONS命令时会维护一个位置计数器。
  • ALIGN(n):将位置计数器对齐到n字节边界。
  • _symbol_name = .:在当前位置定义一个全局符号。这些符号可以在C/C++代码中声明为extern unsigned long _symbol_name;并用于获取段的起始或结束地址。
  • = fill_value:指定未使用的空间填充值(可选,通常用于Flash区域)。

3.3 LMA vs. VMA:理解关键概念

这是理解Linker Script中最容易混淆但又最核心的概念:

  • 加载内存地址 (LMA – Load Memory Address)
    • 指程序或段在非易失性存储器(如Flash)中实际存储的位置。
    • 这是烧录工具将程序映像写入硬件时使用的地址。
    • 对于代码和只读数据 (.text, .rodata),LMA通常与VMA相同,因为它们直接从Flash执行。
    • 对于已初始化数据 (.data),LMA是它在Flash中的存储位置,而VMA是它被复制到RAM后运行时的地址。
  • 虚拟内存地址 (VMA – Virtual Memory Address)
    • 指程序或段在运行时CPU访问的地址
    • 对于RAM中的数据 (.data, .bss, .stack, .heap),VMA是它们在RAM中的实际地址。
    • 对于代码和只读数据,如果它们直接从Flash执行,VMA就是它们在Flash中的地址。

示例:.data 段的处理

.data段是一个典型的LMA与VMA不同的例子。它包含已初始化的全局变量,这些变量在程序启动时需要从Flash复制到RAM。

SECTIONS
{
  /* ... 其他段 ... */

  .data :
  {
    . = ALIGN(4);
    _sdata = .;         /* 定义 .data 段的RAM起始地址 */
    *(.data)            /* 包含所有 .data 输入段 */
    *(.data*)           /* 包含所有以 .data 开头的输入段 (如 .data.xxx) */
    . = ALIGN(4);
    _edata = .;         /* 定义 .data 段的RAM结束地址 */
  } > RAM AT > FLASH  /* VMA 在 RAM, LMA 在 FLASH */

  /* ... 其他段 ... */
}

这里,> RAM 指定了.data段的VMA在RAM区域。而AT > FLASH 指定了其LMA在FLASH区域。这意味着在烧录时,.data段的内容会被写入FLASH,但在运行时,它会被拷贝到RAM中,CPU访问的是RAM中的地址。

在C启动代码中,会使用_sdata_edata(以及_sidata.data段的LMA起始地址,通常由链接器自动计算或通过LOADADDR(.data)获取)来完成这个复制过程。

4. 核心段的链接器脚本配置

让我们来看一个更完整的,针对STM32微控制器(ARM Cortex-M)的典型Linker Script结构。

/*
 * Linker script for STM32F4xx devices
 * FLASH: 0x08000000, 512KB
 * RAM:   0x20000000, 128KB
 */

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

SECTIONS
{
  /* 栈顶地址,通常位于RAM的最高地址 */
  _estack = ORIGIN(RAM) + LENGTH(RAM);

  /* 中断向量表 */
  .isr_vector :
  {
    KEEP(*(.isr_vector)) /* 确保中断向量表被保留 */
    . = ALIGN(4);
  } > FLASH

  /* 代码段 (text) 和只读数据段 (rodata) */
  .text :
  {
    . = ALIGN(4);
    _stext = .;
    *(.text)            /* 所有 .text 段 */
    *(.text.*)          /* 所有以 .text 开头的子段 */
    *(.rodata)          /* 所有 .rodata 段 */
    *(.rodata.*)
    *(.glue_7t)         /* ARM 特有,胶水代码 */
    *(.glue_7)
    *(.ARM.extab)
    *(.ARM)
    *(.init)            /* C++ 构造函数/析构函数 */
    *(.fini)
    . = ALIGN(4);
    _etext = .;
  } > FLASH

  /* C++ 异常处理信息 */
  .ARM.exidx :
  {
    *(.ARM.exidx)
    *(.ARM.exidx.*)
  } > FLASH

  /* 已初始化数据段 (data) - LMA 在 FLASH, VMA 在 RAM */
  .data : AT(LOADADDR(.text) + SIZEOF(.text) + SIZEOF(.ARM.exidx))
  {
    . = ALIGN(4);
    _sdata = .;         /* VMA: .data 段在RAM中的起始地址 */
    *(.data)
    *(.data.*)
    . = ALIGN(4);
    _edata = .;         /* VMA: .data 段在RAM中的结束地址 */
  } > RAM

  /* 未初始化数据段 (bss) - VMA 在 RAM, LMA 不占用空间 */
  .bss :
  {
    . = ALIGN(4);
    _sbss = .;         /* VMA: .bss 段在RAM中的起始地址 */
    *(.bss)
    *(.bss.*)
    *(COMMON)          /* 未初始化全局变量 */
    . = ALIGN(4);
    _ebss = .;         /* VMA: .bss 段在RAM中的结束地址 */
  } > RAM

  /* 堆 (Heap) */
  .heap :
  {
    . = ALIGN(4);
    _sbrk = .;          /* 堆的起始地址 */
    _eheap = ORIGIN(RAM) + LENGTH(RAM) - SIZEOF(.stack); /* 假设堆和栈相对增长 */
  } > RAM

  /* 堆栈空间 (Stack) */
  .stack :
  {
    . = ALIGN(8);
    _sstack = .;
    . = . + 2K; /* 预留 2KB 栈空间,实际栈顶在 _estack */
    _estack_end = .; /* 栈底,低地址 */
  } > RAM

  /* DWARF 调试信息 (通常在调试版本中包含) */
  .debug_arranges  : { *(.debug_arranges) }
  .debug_info     : { *(.debug_info) }
  .debug_abbrev   : { *(.debug_abbrev) }
  .debug_line     : { *(.debug_line) }
  .debug_frame    : { *(.debug_frame) }
  .debug_str      : { *(.debug_str) }
  .debug_loc      : { *(.debug_loc) }
  .debug_ranges   : { *(.debug_ranges) }
  .debug_pubnames : { *(.debug_pubnames) }
  .debug_pubtypes : { *(.debug_pubtypes) }
  .debug_macro    : { *(.debug_macro) }
}

关键符号的含义:

符号名称 含义 用途
_estack 栈顶地址 (End of Stack),通常是RAM的最高地址。 由启动代码用于初始化栈指针 (SP)。
_stext .text 段的起始地址 (Start of Text)。 用于获取代码段的起始。
_etext .text 段的结束地址 (End of Text)。 通常与 .rodata.ARM.exidx 的结束地址一起,计算 .data 的LMA。
_sdata .data 段在RAM中的起始地址 (Start of Data)。 启动代码用于复制 .data 段。
_edata .data 段在RAM中的结束地址 (End of Data)。 启动代码用于复制 .data 段。
_sbss .bss 段在RAM中的起始地址 (Start of BSS)。 启动代码用于清零 .bss 段。
_ebss .bss 段在RAM中的结束地址 (End of BSS)。 启动代码用于清零 .bss 段。
_sbrk 堆的起始地址 (Start of Break),通常是 .bss 段的结束地址之后。 C库中的 sbrk() 函数用于动态内存分配 (malloc/new)。
_eheap 堆的结束地址 (End of Heap)。 用于限制堆的最大大小,防止堆栈溢出。

LOADADDR()SIZEOF() 函数:

  • LOADADDR(section):返回指定段的LMA(加载内存地址)。
  • SIZEOF(section):返回指定段的大小。

.data段的LMA计算中,AT(LOADADDR(.text) + SIZEOF(.text) + SIZEOF(.ARM.exidx)) 就是为了将.data段在Flash中的位置紧随在.text.ARM.exidx之后。

4.1 启动代码 (Startup Code) 与 Linker Script 的协同

Linker Script定义的这些符号(如_sdata, _edata, _sbss, _ebss, _estack)在C启动文件(例如startup_stm32f4xx.s)中被引用,以完成系统初始化。

startup_stm32f4xx.s 伪代码示例:

; 在汇编文件中声明外部符号
    .extern _estack
    .extern _sdata
    .extern _edata
    .extern _sbss
    .extern _ebss

; 复位处理程序
Reset_Handler:
    ldr   sp, =_estack      ; 初始化栈指针到 _estack

; 复制 .data 段
    ldr   r0, =_sdata       ; _sdata 是 .data 在RAM中的起始VMA
    ldr   r1, =_edata       ; _edata 是 .data 在RAM中的结束VMA
    ldr   r2, =_sidata      ; _sidata 是 .data 在FLASH中的起始LMA (由链接器计算)

CopyData:
    cmp   r0, r1
    bge   ClearBSS
    ldr   r3, [r2], #4      ; 从FLASH加载4字节数据
    str   r3, [r0], #4      ; 存储到RAM
    b     CopyData

; 清零 .bss 段
ClearBSS:
    ldr   r0, =_sbss        ; _sbss 是 .bss 在RAM中的起始VMA
    ldr   r1, =_ebss        ; _ebss 是 .bss 在RAM中的结束VMA
    movs  r2, #0            ; 清零值

ZeroBSS:
    cmp   r0, r1
    bge   CallMain
    str   r2, [r0], #4
    b     ZeroBSS

; 调用 main 函数
CallMain:
    bl    SystemInit        ; 调用厂商提供的系统初始化函数 (如时钟、PLL)
    bl    main              ; 调用C++ main函数

; 陷阱:如果 main 函数返回,进入死循环
InfLoop:
    b     InfLoop

这里的_sidata (或_sdata_load) 符号通常由链接器根据.data段的LMA自动生成,或者可以通过更明确的Linker Script语法来定义。

5. 高级链接器脚本技巧与实践

5.1 创建自定义段

在嵌入式开发中,我们经常需要将特定的数据或代码放置到特定的内存区域。

场景 1:持久化配置数据

假设我们有一些需要存储在Flash中,并且在升级固件后仍然保持不变的配置数据。

  1. C++ 代码:

    // config.h
    #ifndef CONFIG_H
    #define CONFIG_H
    
    #include <cstdint>
    
    // 使用 __attribute__((section(".config_data"))) 将结构体放置到自定义段
    struct __attribute__((section(".config_data"))) PersistentConfig {
        uint32_t deviceID;
        uint32_t baudRate;
        char     firmwareVersion[16];
        uint32_t checksum;
    };
    
    extern const PersistentConfig g_appConfig; // 声明为 extern const
    
    #endif // CONFIG_H
    
    // config.cpp
    #include "config.h"
    
    // 在这里初始化默认配置,烧录到Flash
    const PersistentConfig g_appConfig = {
        .deviceID = 0x12345678,
        .baudRate = 115200,
        .firmwareVersion = "V1.0.0",
        .checksum = 0xAAAAAAAA
    };
    
    // main.cpp
    #include "config.h"
    #include <iostream> // 假设有串行输出
    
    int main() {
        // ...
        // 访问配置数据
        // 注意:g_appConfig 位于 Flash,直接读取即可
        std::cout << "Device ID: " << std::hex << g_appConfig.deviceID << std::endl;
        std::cout << "Baud Rate: " << std::dec << g_appConfig.baudRate << std::endl;
        std::cout << "Firmware Version: " << g_appConfig.firmwareVersion << std::endl;
        // ...
        return 0;
    }
    • __attribute__((section(".config_data"))) 是GCC/Clang的扩展,用于将变量或函数放置到指定的段。
  2. Linker Script:
    假设我们想把这些配置数据放在Flash的末尾,留出一些空间。

    MEMORY
    {
      FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
      CONFIG_FLASH (r) : ORIGIN = 0x0807F000, LENGTH = 4K /* Flash末尾的4KB区域 */
      RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 128K
    }
    
    SECTIONS
    {
      /* ... 其他标准段 ... */
    
      /* 配置数据段,放置在 CONFIG_FLASH 区域 */
      .config_data :
      {
        . = ALIGN(4);
        _sconfig_data = .;
        *(.config_data) /* 匹配所有标记为 .config_data 的输入段 */
        . = ALIGN(4);
        _econfig_data = .;
      } > CONFIG_FLASH
    
      /* 确保 .data 段不再包含 .config_data */
      .data : AT(LOADADDR(.text) + SIZEOF(.text) + SIZEOF(.ARM.exidx) + SIZEOF(.config_data)) /* LMA需要调整 */
      {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data.*)
        . = ALIGN(4);
        _edata = .;
      } > RAM
    
      /* ... 其他标准段 ... */
    }
    • 我们在MEMORY中定义了一个名为CONFIG_FLASH的区域。
    • 创建了一个新的输出段.config_data,并将其放置到CONFIG_FLASH区域。
    • 注意 .data 段的 LMA 需要重新计算,以跳过 .config_data 占用的 Flash 空间。

场景 2:将关键函数放置到RAM中执行

某些对时间敏感的函数(如中断服务例程ISR、DSP算法)可能需要从RAM中执行以获得最佳性能,因为RAM通常比Flash快。

  1. C++ 代码:

    // fast_code.h
    #ifndef FAST_CODE_H
    #define FAST_CODE_H
    
    #include <cstdint>
    
    // 使用 __attribute__((section(".fast_code"))) 将函数放置到自定义段
    extern "C" void __attribute__((section(".fast_code"))) critical_function(uint32_t data);
    
    #endif // FAST_CODE_H
    
    // fast_code.cpp
    #include "fast_code.h"
    #include <cstdio> // For printf
    
    void critical_function(uint32_t data) {
        // 模拟一些计算密集型操作
        volatile uint32_t result = data * 2 + 1;
        // 实际应用中可能在这里操作硬件或处理数据
        // printf("Critical function executed with data: %lu, result: %lun", data, result);
    }
    • extern "C" 可以防止C++名称重整,方便在汇编或C代码中直接调用。
  2. Linker Script:
    我们需要将.fast_code段的LMA放在Flash中,VMA放在RAM中,并在启动时将其从Flash复制到RAM。

    MEMORY
    {
      FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
      RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 128K
      RAM_FAST_CODE (rwx) : ORIGIN = 0x20000000 + 120K, LENGTH = 8K /* RAM末尾的8KB区域 */
    }
    
    SECTIONS
    {
      /* ... 其他标准段 ... */
    
      /* 快速执行代码段 */
      .fast_code :
      {
        . = ALIGN(4);
        _sfast_code = .;        /* VMA: RAM中的起始地址 */
        *(.fast_code)           /* 匹配所有标记为 .fast_code 的输入段 */
        . = ALIGN(4);
        _efast_code = .;        /* VMA: RAM中的结束地址 */
      } > RAM_FAST_CODE AT > FLASH /* VMA 在 RAM_FAST_CODE, LMA 在 FLASH */
    
      /* 确保 .data 段的LMA计算考虑 .fast_code */
      .data : AT(LOADADDR(.text) + SIZEOF(.text) + SIZEOF(.ARM.exidx) + SIZEOF(.config_data) + SIZEOF(.fast_code))
      {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data.*)
        . = ALIGN(4);
        _edata = .;
      } > RAM
    
      /* ... 其他标准段 ... */
    }
    • AT > FLASH 表明其LMA仍在Flash中,链接器会将其放在.data段的LMA之前或之后。
    • > RAM_FAST_CODE 表明其VMA在RAM的特定区域。
    • 需要在启动代码中添加额外的复制逻辑,类似于.data段的复制,将.fast_code从Flash的LMA复制到RAM的VMA。

    启动代码伪代码(添加复制.fast_code):

    
    ; ... 定义 _sfast_code, _efast_code, _sfast_code_load (LMA)
    
    ; 复制 .data 段 (如前所示)
    
    ; 复制 .fast_code 段
    ldr   r0, =_sfast_code
    ldr   r1, =_efast_code
    ldr   r2, =_sfast_code_load ; Linker generated LMA for .fast_code

CopyFastCode:
cmp r0, r1
bge ClearBSS
ldr r3, [r2], #4
str r3, [r0], #4
b CopyFastCode

; ... 清零 .bss 段,调用 main ...
```

5.2 Bootloader 与应用程序的分区

在许多嵌入式系统中,Bootloader(引导加载程序)和应用程序是两个独立的固件。Bootloader负责启动系统、执行基本初始化、加载或更新应用程序。它们需要独立地烧录到Flash的不同区域。

Linker Script 示例 (针对应用程序):

假设Bootloader位于0x080000000x0800FFFF(64KB),应用程序从0x08010000开始。

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K /* 应用程序从 0x08010000 开始 */
  RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
  /* 栈顶地址,位于RAM的最高地址 */
  _estack = ORIGIN(RAM) + LENGTH(RAM);

  /* 应用程序的中断向量表。
   * 注意:如果Bootloader和应用程序共享同一个向量表基址(VBAR),
   * 应用程序的向量表需要被Bootloader重映射或由应用程序自身在运行时设置。
   * 这里假设应用程序有自己的向量表,但实际运行时可能需要硬件支持或软件重映射。
   */
  .isr_vector :
  {
    KEEP(*(.isr_vector))
    . = ALIGN(4);
  } > FLASH

  /* ... 其他应用程序的段 (.text, .rodata, .data, .bss, .heap, .stack) 正常放置在 FLASH 和 RAM 中 ... */

  .text :
  {
    . = ALIGN(4);
    *(.text)
    *(.text.*)
    *(.rodata)
    *(.rodata.*)
    . = ALIGN(4);
  } > FLASH

  .data : AT(LOADADDR(.text) + SIZEOF(.text))
  {
    . = ALIGN(4);
    *(.data)
    . = ALIGN(4);
  } > RAM

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

  /* ... 确保所有段都限制在应用程序的 FLASH 和 RAM 区域内 */
}

Bootloader 的 Linker Script 也会类似,但其 FLASH 区域将是 ORIGIN = 0x08000000, LENGTH = 64K

重要提示:

  • 应用程序的_estack和中断向量表需要与Bootloader协调。Bootloader通常会设置初始栈指针和向量表基地址寄存器(VTOR),然后跳转到应用程序。应用程序可能需要重新设置VTOR指向自己的向量表。
  • Bootloader和应用程序不能相互覆盖对方的内存区域。

5.3 外部存储器(如QSPI Flash)的使用

很多现代MCU支持外部QSPI Flash,提供巨大的存储空间用于存储资源、文件系统或额外的代码。

假设外部QSPI Flash映射到地址0x90000000,大小为16MB。

  1. C++ 代码:

    // large_assets.h
    #ifndef LARGE_ASSETS_H
    #define LARGE_ASSETS_H
    
    #include <cstdint>
    
    // 声明一个大数组,使用 __attribute__((section(".ext_flash_data")))
    extern const uint8_t g_largeImage[1024 * 1024]; // 1MB 图片数据
    
    #endif // LARGE_ASSETS_H
    
    // large_assets.cpp
    #include "large_assets.h"
    
    // 实际数据可能来自一个二进制文件,通过 objcopy -I binary -O elf32-littlearm -B arm input.bin output.o
    // 然后在 Linker Script 中引用 output.o 的 .data 段。
    // 或者直接在这里定义,但会使源文件巨大。
    const uint8_t g_largeImage[1024 * 1024] __attribute__((section(".ext_flash_data"))) = {
        // ... 1MB 的图片数据 ...
        0x01, 0x02, 0x03, 0x04, /* ... 填充示例数据 ... */ 0xFF
    };
    
    // main.cpp
    #include "large_assets.h"
    #include <iostream>
    
    int main() {
        // ...
        // 访问外部Flash中的数据
        std::cout << "First byte of image: " << std::hex << (int)g_largeImage[0] << std::endl;
        std::cout << "Last byte of image: " << std::hex << (int)g_largeImage[1024*1024 - 1] << std::endl;
        // ...
        return 0;
    }
  2. Linker Script:

    MEMORY
    {
      FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
      RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 128K
      EXT_QSPI_FLASH (r) : ORIGIN = 0x90000000, LENGTH = 16M /* 外部QSPI Flash */
    }
    
    SECTIONS
    {
      /* ... 标准段 ... */
    
      /* 外部QSPI Flash数据段 */
      .ext_flash_data :
      {
        . = ALIGN(4);
        _sext_flash_data = .;
        *(.ext_flash_data)
        *(.ext_flash_data.*)
        . = ALIGN(4);
        _eext_flash_data = .;
      } > EXT_QSPI_FLASH /* 直接放置在外部Flash区域,LMA和VMA相同 */
    
      /* ... 其他段 ... */
    }
    • 外部Flash数据段的LMA和VMA通常是相同的,因为CPU可以直接访问它(如果MCU的QSPI控制器已正确初始化并映射到内存)。
    • 在启动代码或应用程序中,需要确保QSPI控制器在访问这些数据之前已经被正确初始化。

5.4 共享内存区域 (多核/多进程)

在多核微控制器或需要进程间通信的复杂嵌入式系统中,定义共享内存区域是常见的需求。

  1. C++ 代码:

    // shared_mem.h
    #ifndef SHARED_MEM_H
    #define SHARED_MEM_H
    
    #include <cstdint>
    
    struct __attribute__((section(".shared_ram"))) SharedData {
        volatile uint32_t flag;
        volatile uint32_t value;
        char message[64];
    };
    
    extern SharedData g_sharedData;
    
    #endif // SHARED_MEM_H
    
    // shared_mem.cpp
    #include "shared_mem.h"
    
    // 假设这是 Core1 的代码,Core0 会访问
    SharedData g_sharedData; // 仅在 Core1 的编译中初始化
    
    // main_core1.cpp
    #include "shared_mem.h"
    #include <cstdio>
    
    int main() {
        g_sharedData.flag = 0;
        g_sharedData.value = 123;
        sprintf(g_sharedData.message, "Hello from Core1!");
        g_sharedData.flag = 1; // 标记数据已准备好
        // ...
        return 0;
    }
    
    // main_core0.cpp (在另一个固件中)
    #include "shared_mem.h"
    #include <cstdio>
    
    int main() {
        while (g_sharedData.flag == 0); // 等待 Core1 准备好数据
        printf("Core0 received: Flag=%lu, Value=%lu, Message=%sn",
               g_sharedData.flag, g_sharedData.value, g_sharedData.message);
        // ...
        return 0;
    }
    • volatile 关键字是至关重要的,它告诉编译器不要对这些共享变量进行优化,确保每次都从内存中读取。
  2. Linker Script:
    为共享数据定义一个独立的RAM区域。

    MEMORY
    {
      FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
      RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 128K
      SHARED_RAM (rwx) : ORIGIN = 0x20000000 + 100K, LENGTH = 4K /* RAM中专门的共享区域 */
    }
    
    SECTIONS
    {
      /* ... 标准段 ... */
    
      .shared_ram :
      {
        . = ALIGN(4);
        _sshared_ram = .;
        *(.shared_ram)
        *(.shared_ram.*)
        . = ALIGN(4);
        _eshared_ram = .;
      } > SHARED_RAM /* 直接放置在 RAM 共享区域 */
    
      /* 确保 .data 或 .bss 段不会覆盖 SHARED_RAM 区域 */
      .data : AT(LOADADDR(.text) + SIZEOF(.text))
      {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        . = ALIGN(4);
        _edata = .;
      } > RAM
    
      .bss :
      {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(COMMON)
        . = ALIGN(4);
      } > RAM
    
      /* ... 其他段 ... */
    }
    • 两个核(或固件)都需要使用相同的Linker Script定义SHARED_RAM区域,并引用g_sharedData变量。
    • SHARED_RAM区域不应被 .data.bss 段覆盖。

6. 调试与验证自定义Linker Script

编写完Linker Script后,验证其正确性至关重要。

6.1 生成 .map 文件

链接器在链接过程中可以生成一个 .map 文件,这个文件详细列出了每个段、每个符号的地址和大小。这是调试Linker Script最强大的工具。

通常在GCC/G++编译命令中添加:
arm-none-eabi-g++ -mcpu=cortex-m4 ... -Wl,-Map=output.map -o output.elf

打开 output.map 文件,您可以查找:

  • 内存区域 (Memory Configuration):确认MEMORY命令定义的区域是否正确。
  • 段布局 (Section Layout):检查每个输出段的VMA、LMA和大小,确保它们位于预期的内存区域。
  • 符号地址 (Symbol Table):验证_sdata, _edata等自定义符号的地址是否符合预期。

6.2 使用 objdumpreadelf

这些是GNU Binutils工具链中的实用工具,用于检查生成的ELF文件。

  • objdump -h output.elf:显示ELF文件中所有段的头部信息,包括名称、大小、VMA和LMA。

    Section Headers:
    Idx Name          Size      VMA       LMA       File off  Algn
      0 .isr_vector   00000100  08000000  08000000  00010000  2**2
      1 .text         00001234  08000100  08000100  00010100  2**2
      2 .rodata       00000040  08001334  08001334  00011334  2**2
      3 .config_data  00000020  0807F000  0807F000  00011374  2**2
      4 .fast_code    00000010  2001E800  08001354  00011354  2**2
      5 .data         00000100  20000000  08001384  00011384  2**2
      6 .bss          00000200  20000100  00000000  00011484  2**2
      7 .shared_ram   00000080  20019000  00000000  00011684  2**2

    注意 .fast_code.data 段的 LMA 和 VMA 是不同的。.bss.shared_ram 段没有 LMA (LMA 为 00000000) 因为它们在Flash中不占用空间。

  • readelf -S output.elf:与objdump -h类似,但提供更详细的段信息。

  • arm-none-eabi-nm -n output.elf:按地址顺序列出所有符号,可用于查找自定义符号的地址。

    ...
    20000000 D _sdata
    20000100 B _sbss
    20000300 D _edata
    20000300 B _ebss
    20019000 B _sshared_ram
    20019080 B _eshared_ram
    2001e800 T _sfast_code
    2001e810 T _efast_code
    2001ffff T _estack
    ...

    这里的D表示已初始化数据,B表示未初始化数据,T表示代码。

6.3 使用调试器

通过JTAG/SWD等硬件调试器,您可以连接到目标板,检查特定内存地址的内容。这对于验证Flash中烧录的数据和RAM中运行时的数据是否符合预期非常有帮助。例如,您可以查看_sdata_edata范围内的RAM内容,确认.data段是否被正确复制。

7. 最佳实践与注意事项

  • 从现有脚本开始:不要从头开始编写Linker Script。通常,您的MCU厂商或开发板会提供一个基础的Linker Script。在此基础上进行修改和调整会更高效和安全。
  • 详细注释:Linker Script的语法比较晦涩,务必为每个MEMORY区域和SECTIONS段添加详细注释,解释其目的和布局。
  • 避免内存重叠:这是最常见的错误。仔细检查您的MEMORYSECTIONS定义,确保没有段重叠,特别是当您引入自定义段时。output.map文件是发现重叠的关键工具。
  • 对齐要求:许多嵌入式处理器对内存访问有对齐要求(例如,字访问需要4字节对齐)。始终使用ALIGN(n)命令来满足这些要求,通常是4或8字节对齐。
  • 理解工具链:不同的链接器(如GNU ld、IAR iccarm、Keil ARM_LD)可能有略微不同的Linker Script语法。本文主要基于GNU ld
  • 版本控制:Linker Script是项目的重要组成部分,应纳入版本控制系统。
  • 与C启动代码协调:Linker Script定义的符号必须与C启动代码(crt0.sstartup_*.c)中使用的符号名称和语义保持一致。
  • 小心KEEPKEEP命令用于确保某个段即使没有被代码显式引用也会被保留。中断向量表是KEEP的典型用例。滥用KEEP可能导致生成更大的固件。
  • 考虑平台特性:某些平台可能需要特殊的段,例如FreeRTOS可能需要特定的栈大小信息,或者某些安全模块需要特定的固件头。

8. 总结与展望

Linker Script是嵌入式C++开发中,实现对内存布局精准控制的基石。通过本文的深入探讨,我们了解了内存的物理特性、程序的逻辑结构、链接器的作用,以及如何利用MEMORYSECTIONS命令来构建和定制Linker Script。从基础的.text.data.bss段管理,到高级的自定义段、Bootloader与应用程序分区、外部存储器利用以及共享内存的实现,Linker Script赋予了开发者无与伦比的内存管理能力。掌握这项技能,您将能更好地优化资源利用、提升系统性能、实现复杂功能,并有效地解决内存相关的疑难杂症,从而构建出更加健壮和高效的嵌入式系统。

在未来的嵌入式世界里,随着异构多核架构、复杂安全机制和高级内存技术的不断演进,对内存布局的精细控制只会变得更加重要。深入理解并熟练运用Linker Script,无疑是每位嵌入式专家必备的核心技能。

发表回复

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