4. 链接脚本与内存布局:Bootloader 的骨架

链接脚本,说白了就是告诉编译器:你的代码该放哪儿,数据该放哪儿。我刚开始做 Bootloader 时,总觉得这玩意儿可有可无。直到有一次,芯片死活跑不起来,折腾了两天,最后发现是 BSS 段没清零——嗯,从那以后,我再也不敢小看 .lds 文件了。

4.1 链接脚本结构解析

一个典型的 .lds 文件,其实就三大部分:内存布局定义、段定义、符号导出。我们直接看一个实际例子:

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

/* 段定义 */
SECTIONS
{
    .text :
    {
        _stext = .;
        *(.isr_vector)    /* 中断向量表 */
        *(.text*)         /* 代码段 */
        *(.rodata*)       /* 只读数据 */
        _etext = .;
    } > FLASH

    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;
        *(.data*)         /* 初始化数据 */
        _edata = .;
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss*)          /* 未初始化数据 */
        *(COMMON)
        _ebss = .;
    } > RAM
}

这里有几个关键点,我一个个说。

MEMORY 命令:定义物理存储器的起始地址和大小。FLASH 是只读可执行,RAM 是可读写可执行。注意 ORIGIN 和 LENGTH 必须跟芯片手册一致,否则程序跑飞是迟早的事。

4.2 代码段/数据段/BSS 段布局

这三个段,是 Bootloader 的命根子。我画了张图,帮你理清它们的关系:

Bootloader 内存布局全景图 FLASH (0x08000000) .isr_vector 中断向量表 .text 代码段 (启动代码 + 主逻辑) .rodata 只读数据 .data 初始值 (LMA) 拷贝 RAM (0x20000000) .data 运行时 (VMA) .bss 段 (需清零) .heap 堆 .stack 栈 关键:.data 在 FLASH 存初始值,启动时拷贝到 RAM;.bss 在 RAM 中必须清零

4.3 栈与堆的设置

栈和堆,是 Bootloader 运行时的两大内存消费者。设置不对,轻则死机,重则启动失败。

我的经验:栈大小一般设为 1KB-4KB 就够用。Bootloader 不像应用层,不需要处理大量嵌套调用。堆的话,如果你不用 malloc,直接设 0 都行。我见过有人给 Bootloader 配了 32KB 的堆,纯属浪费。

栈和堆在链接脚本中通常这样定义:

/* 在 SECTIONS 末尾添加 */
.heap (NOLOAD) :
{
    _heap_start = .;
    . = . + HEAP_SIZE;
    _heap_end = .;
} > RAM

.stack (NOLOAD) :
{
    _stack_start = .;
    . = . + STACK_SIZE;
    _stack_end = .;
} > RAM

/* 在脚本开头定义常量 */
STACK_SIZE = 0x1000;   /* 4KB */
HEAP_SIZE  = 0x1000;   /* 4KB */

这里有个坑,我踩过:栈的生长方向。ARM 是满递减栈,栈指针从高地址往低地址走。所以栈的起始地址要设在 RAM 的最高可用地址附近。我曾经把栈底设在 RAM 中间,结果栈溢出直接覆盖了 .data 段,程序跑着跑着就挂了。

避坑指南:

  • 栈顶地址 = RAM 起始 + RAM 大小 - 1(或留 4 字节对齐)
  • 堆和栈之间要留足够空间,别让它们撞上
  • 启动代码中必须手动初始化栈指针 SP
  • BSS 段清零必须在调用任何 C 函数之前完成

4.4 链接脚本中的符号与启动流程

链接脚本里定义的符号,启动代码会直接引用。我列个表,把常用的符号说清楚:

符号名 含义 启动代码用途
_stext 代码段起始地址 无直接用途,调试用
_etext 代码段结束地址 计算 .data 初始值在 FLASH 中的位置
_sdata 数据段起始地址 (RAM) 拷贝目标地址
_edata 数据段结束地址 (RAM) 拷贝结束判断
_sbss BSS 段起始地址 清零起始
_ebss BSS 段结束地址 清零结束
_stack_end 栈底地址 初始化 SP 寄存器

启动代码中,典型的处理流程是这样的:

reset_handler:
    /* 1. 初始化栈指针 */
    ldr sp, =_stack_end

    /* 2. 拷贝 .data 段从 FLASH 到 RAM */
    ldr r0, =_sdata
    ldr r1, =_edata
    ldr r2, =_etext
    bl  copy_data

    /* 3. 清零 .bss 段 */
    ldr r0, =_sbss
    ldr r1, =_ebss
    bl  clear_bss

    /* 4. 跳转到 main */
    bl  main

关键点:第 2 步中,_etext 就是 .data 初始值在 FLASH 中的存放位置。因为 .data 的 LMA(加载地址)紧跟在 .text 后面。这个关系在链接脚本中用 AT (ADDR(.text) + SIZEOF(.text)) 指定了。

4.5 常见链接脚本错误与排查

我这些年调试 Bootloader,链接脚本出问题的情况还真不少。总结几个典型:

  • 段重叠:.text 和 .data 的 LMA 算错了,导致数据覆盖代码。用 objdump -h 查看各段地址就能发现。
  • 符号未定义:启动代码里引用了 _sdata,但链接脚本里写成了 _sdata_。编译能过,链接报错。
  • 栈溢出:栈设得太小,或者堆栈之间没留缓冲。我习惯在栈底放一个魔数(比如 0xDEADBEEF),启动后检查是否被覆盖。
  • 对齐问题:ARM 要求 4 字节对齐,有些外设要求 8 字节。链接脚本里用 . = ALIGN(4); 强制对齐。

调试小技巧:编译后用 arm-none-eabi-objdump -t firmware.elf | grep _sdata 查看符号实际地址。再用 arm-none-eabi-readelf -S firmware.elf 看各段信息。两个对照着看,问题一目了然。

嗯,链接脚本这块,说白了就是给 Bootloader 画好内存地图。地图画错了,后面再怎么折腾也是白搭。我建议你拿到一个新芯片,第一件事就是对着手册把 .lds 写对,然后编译、反汇编、确认地址,再往下走。