3. 链接脚本与内存布局:.lds 详解与段配置

链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多做应用层开发的同事第一次看到 .lds 文件,会觉得这玩意儿像天书。但搞 Bootloader,这关必须过。

我刚开始做嵌入式时,也跳过链接脚本,直接用默认配置。结果有一次芯片上电就跑飞了,查了三天,最后发现是中断向量表没对齐到正确地址。嗯,从那以后,我再也不敢不读 .lds 了。

3.1 链接脚本的核心语法

一个典型的 .lds 文件,其实就三部分:内存区域定义、段布局、符号导出。我们拆开看。

3.1.1 MEMORY 命令

先告诉链接器,芯片上有哪些可用的内存区域。比如:

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

这里定义了 Flash 和 RAM 的起始地址和大小。属性 rx 表示可读可执行,rwx 表示可读可写可执行。我个人习惯把 Bootloader 的 Flash 区域单独划一块,避免和应用程序冲突。

注意:ORIGIN 和 LENGTH 必须和芯片手册完全一致。我曾经见过有人把 Flash 起始地址写错一个字节,结果下载器能识别,但代码就是跑不起来。

3.1.2 SECTIONS 命令

这是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段内符号的顺序。

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

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

    .bss :
    {
        _sbss = .;        /* BSS 段起始地址 */
        *(.bss)           /* 未初始化数据 */
        _ebss = .;        /* BSS 段结束地址 */
    } > RAM
}

这里有个关键点:.data 段用了 AT 指令。意思是这个段运行时在 RAM,但它的初始值存储在 Flash 里。启动代码必须把这段数据从 Flash 拷贝到 RAM。这个拷贝操作,就是 Bootloader 的必修课。

3.2 代码段、数据段、BSS 段的布局

搞清楚了语法,我们看看实际项目中这些段是怎么排布的。我画了一张图,帮你理清关系。

典型 Bootloader 内存布局 Flash (0x08000000) .isr_vector (中断向量表) .text (代码段) 启动代码 + 主逻辑 + 驱动 .rodata (只读数据) .data 初始值 (AT 区域) 启动时拷贝 RAM (0x20000000) .data (运行时) .bss (未初始化数据) 全局变量、静态变量 堆 (Heap) malloc / free 动态分配 栈 (Stack) 局部变量、函数调用 栈向下生长,堆向上生长

3.3 栈与堆的配置

栈和堆的配置,是 Bootloader 稳定性的关键。我见过太多因为栈溢出导致的神秘重启,查到最后发现是栈大小设得太抠门。

3.3.1 栈的配置

在链接脚本中,栈通常通过符号定义:

_stack_top = ORIGIN(RAM) + LENGTH(RAM);  /* 栈顶在 RAM 末尾 */
_stack_size = 4K;                         /* 栈大小 4KB */
_stack_bottom = _stack_top - _stack_size; /* 栈底 */

启动代码里,复位向量会初始化 SP 寄存器指向 _stack_top。注意,ARM Cortex-M 系列的栈是满递减栈,SP 初始指向栈顶,压栈时地址减小。

经验之谈:Bootloader 的栈大小建议至少 2KB。如果涉及 USB 或文件系统操作,建议 4KB 起步。我有个项目,Bootloader 要解析 FAT32 文件系统,栈设了 8KB 才稳。

3.3.2 堆的配置

堆用于动态内存分配。在 Bootloader 里,我建议尽量少用堆。为什么?因为堆管理有不确定性,碎片化问题在资源受限的系统里很致命。

如果非要用,可以在链接脚本里定义堆的边界:

_heap_start = _ebss + 4;   /* BSS 段之后 */
_heap_end = _stack_bottom; /* 栈底之前 */

这样堆和栈之间就有了明确的界限。堆向上增长,栈向下增长,两者在中间相遇时就是内存耗尽。

避坑指南:我曾经在一个项目里,堆和栈之间没留足够余量。结果某次升级固件时,堆分配了一块大缓冲区,直接覆盖了栈空间。系统在文件校验时崩溃,查了两天才定位到。从那以后,我都在堆和栈之间留至少 256 字节的「安全隔离带」。

3.4 链接脚本中的符号导出

链接脚本里定义的符号,可以在 C 代码中直接引用。这是 Bootloader 和应用程序之间传递信息的常用手段。

/* 链接脚本中定义 */
_flash_start = ORIGIN(FLASH);
_flash_size = LENGTH(FLASH);
_app_start = 0x08010000;  /* 应用程序起始地址 */

/* C 代码中引用 */
extern uint32_t _flash_start;
extern uint32_t _app_start;

void jump_to_app(void)
{
    uint32_t app_addr = (uint32_t)&_app_start;
    // 跳转到应用程序
}

这里要注意,链接脚本里定义的符号,在 C 代码中要当作地址来用。取地址操作符 & 拿到的是符号的值,也就是那个地址本身。

3.5 实战中的段布局策略

不同场景下,段布局的策略也不同。我整理了一个表格,方便你对照选择。

场景 Flash 布局 RAM 布局 注意事项
单 Bootloader 向量表 + 代码 + 只读数据 数据段 + BSS + 堆 + 栈 最简单,适合小容量芯片
Bootloader + 应用程序 Boot 区 + App 区 + 配置区 分时复用,Boot 和 App 共用 RAM 需要定义跳转协议
双备份升级 Boot 区 + App A + App B 运行时只加载一个 App Flash 容量要求高,可靠性强
带参数存储 代码区 + 参数区(最后几页) 正常布局 + 参数缓存 参数区要按页对齐

3.6 一个完整的 .lds 示例

最后,给你一个我实际项目里用过的链接脚本框架。你可以直接拿来改。

/* bootloader.lds */

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

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

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

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

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

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

    /* 堆定义 */
    .heap :
    {
        _heap_start = .;
        . = . + 2K;
        _heap_end = .;
    } > RAM

    /* 栈定义 */
    .stack :
    {
        _stack_start = .;
        . = . + 2K;
        _stack_end = .;
    } > RAM
}
核心要点:
  • 链接脚本是内存布局的「宪法」,所有段的位置都由它决定
  • .data 段需要从 Flash 拷贝到 RAM,启动代码必须实现这个拷贝
  • .bss 段需要在启动时清零,否则未初始化的全局变量会是随机值
  • 栈和堆的大小要根据实际需求配置,留足余量
  • 链接脚本中定义的符号,C 代码可以通过 extern 引用

链接脚本这东西,初看觉得复杂,用多了就会发现它其实很直观。你只要记住:代码放 Flash,变量放 RAM,栈顶在 RAM 末尾,基本就不会出大问题。至于更高级的段重定位、分散加载,那是后面章节的内容了。