4. 链接脚本与内存布局

链接脚本(Linker Script)是 Bootloader 开发中连接编译器输出与硬件物理内存的桥梁。它决定了代码段、数据段、BSS 段在 Flash 和 RAM 中的最终位置,并直接影响启动流程中栈与堆的初始化。本章从语法入手,逐步深入到布局设计与初始化实现。

4.1 链接脚本(.lds)语法基础

GNU LD 链接器使用 .lds 文件描述输入段的合并规则与输出段的地址映射。一个典型的链接脚本包含以下核心元素:

语法元素 作用 示例
ENTRY 指定程序入口点(第一条指令地址) ENTRY(Reset_Handler)
MEMORY 定义目标硬件的物理内存区域(名称、起始地址、长度、属性) FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SECTIONS 描述输出段如何从输入段中提取并放置到指定内存区域 .text : { *(.text) } > FLASH
PROVIDE 在脚本中定义符号,供 C 代码引用(如栈顶地址) PROVIDE(_estack = ORIGIN(RAM) + LENGTH(RAM));
关键点: MEMORY 命令定义硬件可用资源,SECTIONS 命令定义软件如何占用这些资源。两者配合才能生成正确的二进制映像。

以下是一个面向 ARM Cortex-M 的 Bootloader 最小链接脚本框架:

ENTRY(Reset_Handler)

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

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

    .text :
    {
        *(.text*)
        *(.rodata*)
    } > FLASH

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

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

    PROVIDE(_estack = ORIGIN(RAM) + LENGTH(RAM));
    PROVIDE(_heap_start = _ebss);
    PROVIDE(_heap_end = ORIGIN(RAM) + LENGTH(RAM) - 1K);
}

4.2 代码段、数据段与 BSS 段布局

Bootloader 的运行时内存布局必须精确匹配链接脚本的描述。三个关键段各有不同的生命周期和存放位置:

  • .text(代码段 + 只读数据):包含可执行指令和常量。存放在 Flash 中,CPU 通过指令总线直接读取。Bootloader 的启动代码、Flash 驱动、通信协议栈均在此段。
  • .data(已初始化数据段):包含全局变量和静态变量的初始值。运行时位于 RAM,但初始值存储在 Flash 中。启动代码必须将初始值从 Flash 复制到 RAM 中对应的地址。
  • .bss(未初始化数据段):包含全局变量和静态变量(默认值为 0)。运行时位于 RAM,启动代码必须将其清零。
避坑提示: 很多 Bootloader 崩溃的原因是 .data 段复制或 .bss 段清零被跳过。务必在进入 main() 之前完成这两步操作,且复制/清零的地址范围必须与链接脚本中定义的 _sdata/_edata/_sbss/_ebss 符号严格一致。

下图展示了 Bootloader 在 Flash 和 RAM 中的典型布局关系:

Bootloader 内存布局:Flash → RAM 映射 Flash 存储器 0x08000000 ~ 0x08007FFF .isr_vector (中断向量表) .text (代码 + 只读数据) Bootloader 主逻辑 Flash 驱动 / 通信协议 .data 初始值 (LMA) RAM 存储器 0x20000000 ~ 0x20001FFF .data (运行时 VMA) .bss (零初始化) 堆 (Heap) 栈 (Stack) 向下增长 启动时复制 (由 _sdata/_edata 控制) _estack LMA = 加载地址(Flash),VMA = 运行时地址(RAM)。启动代码负责将 .data 从 LMA 复制到 VMA,并将 .bss 清零。

4.3 栈与堆的初始化设置

栈(Stack)和堆(Heap)是 Bootloader 运行时的两个关键内存区域。栈用于函数调用、局部变量和中断嵌套;堆用于动态内存分配(如通信缓冲区)。它们的初始化必须在进入 main() 之前完成。

4.3.1 栈指针(SP)初始化

ARM Cortex-M 系列处理器在复位后会自动从 Flash 起始地址(0x00000000 或 0x08000000)加载栈指针。链接脚本中通过 PROVIDE(_estack = ...) 定义栈顶地址,并由汇编启动代码写入 MSP(主栈指针):

; 汇编启动代码片段(reset_handler)
ldr   r0, =_estack      ; 加载栈顶地址
msr   msp, r0           ; 写入主栈指针
bl    main              ; 跳转到 C 主函数
常见错误: 栈顶地址必须对齐到 8 字节(ARM 架构要求),且不能超出 RAM 范围。如果 _estack 指向 RAM 末尾 + 1,务必确保 RAM 末尾地址是 8 字节对齐的。

4.3.2 堆初始化与符号导出

堆的起始地址通常紧接在 .bss 段之后。链接脚本中通过 _heap_start = _ebss 和 _heap_end 定义堆边界。C 运行时库(如 newlib)依赖这些符号来管理 malloc/free:

/* 在 C 启动代码中声明外部符号 */
extern uint32_t _estack;
extern uint32_t _heap_start;
extern uint32_t _heap_end;

/* 堆初始化函数(供 _sbrk 使用) */
caddr_t _sbrk(int incr)
{
    static uint32_t *heap_ptr = NULL;
    uint32_t *prev_heap_ptr;

    if (heap_ptr == NULL)
        heap_ptr = (uint32_t *)&_heap_start;

    prev_heap_ptr = heap_ptr;
    heap_ptr += incr;

    if (heap_ptr > (uint32_t *)&_heap_end)
        return (caddr_t)-1;  /* 堆溢出 */

    return (caddr_t)prev_heap_ptr;
}

4.3.3 栈与堆的冲突避免

栈向下增长,堆向上增长。两者在 RAM 中相向而行,必须预留安全间隙。下表总结了典型配置策略:

内存区域 起始地址 大小 增长方向
.data RAM 起始(0x20000000) 由链接脚本计算 —
.bss .data 结束 由链接脚本计算 —
堆 .bss 结束(_heap_start) 通常 1KB ~ 4KB 向上(高地址)
栈 RAM 末尾(_estack) 通常 1KB ~ 2KB 向下(低地址)
工程建议: 在链接脚本中为栈预留至少 1KB 的“安全保护区”(即 _heap_end 与 _estack 之间留出 256~512 字节)。在调试阶段,可以用填充模式(如 0xDEADBEEF)初始化整个 RAM,然后周期性检查栈保护区是否被覆盖,以检测栈溢出。

下图展示了栈与堆在 RAM 中的相向增长关系及安全保护区的位置:

RAM 内部:栈与堆的相向增长 RAM 地址空间(低 → 高) .data .bss 堆 向上增长 _heap_start 安全 保护区 栈 向下增长 _estack 堆增长方向 栈增长方向 安全保护区防止栈向下增长时覆盖堆顶数据,建议至少保留 256 字节。

4.4 启动代码中的段初始化流程

Bootloader 的启动代码(Reset_Handler)必须按以下顺序完成内存初始化,才能安全进入 C 环境:

  1. 加载栈指针:从链接符号 _estack 加载并写入 MSP。
  2. 复制 .data 段:从 Flash(LMA)复制到 RAM(VMA),长度由 _edata - _sdata 计算。
  3. 清零 .bss 段:将 _sbss 到 _ebss 之间的内存全部写 0。
  4. 初始化堆:设置 _sbrk 的堆基址(如果使用动态内存)。
  5. 调用 main():此时全局变量已就绪,栈指针正确,可以执行 C 代码。
验证方法: 在调试器中查看复位后的反汇编,确认 _sdata、_edata、_sbss、_ebss 四个符号的值是否与链接脚本中的地址一致。任何偏差都会导致全局变量初始化失败。