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

链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多做应用层开发的同事第一次看到 .lds 文件,会觉得像天书。我刚开始接触时也一样,觉得这东西不就是个地址映射吗?直到有一次,我写的 Bootloader 在 QSPI Flash 上死活跑不起来,才发现是 .lds 里一个段地址配错了。

嗯,今天我们就把它彻底讲透。

3.1 链接脚本的核心语法

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

3.1.1 MEMORY 命令

先告诉链接器,芯片里有哪些物理内存区域,它们的起始地址和大小是多少。

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

这里 rx 表示读+执行,rwx 表示读+写+执行。我个人习惯把 Bootloader 的 Flash 区域单独划一小块出来,比如 64KB,剩下的留给应用。这样万一 Bootloader 要升级,不会误伤应用区。

注意: ORIGIN 和 LENGTH 必须和芯片手册严格一致。我曾经见过有人把 STM32F103 的 Flash 起始地址写成 0x08001000,结果前 4KB 的启动向量表直接飞了。

3.1.2 SECTIONS 命令

这是链接脚本的正文。它定义了每个段(.text、.data、.bss 等)该放到哪个内存区域,以及段之间的顺序。

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text*)         /* 代码段 */
        *(.rodata*)       /* 只读数据 */
        _etext = .;       /* 代码段结束地址 */
    } > FLASH
    
    .data : AT(_etext)
    {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM
    
    .bss :
    {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

这里有个关键点:.data 段用了 AT(_etext)。什么意思?就是说 .data 段运行时在 RAM 里,但它的初始值存储在 Flash 里,紧跟在代码段后面。Bootloader 启动时,需要把这段数据从 Flash 拷贝到 RAM。

核心概念: LMA(加载地址)和 VMA(运行地址)。.data 的 LMA 在 Flash,VMA 在 RAM。Bootloader 必须自己完成这个拷贝动作。

3.2 代码段 / 数据段 / BSS 段的放置策略

你想想看,Bootloader 的代码段为什么一定要放在 Flash 开头?因为芯片上电后,PC 指针会从复位向量指向的地址开始执行。如果复位向量不在 Flash 起始位置,CPU 就找不到入口。

段名 存放位置 内容 初始化方式
.isr_vector Flash 起始 中断向量表 硬件自动读取
.text Flash 可执行指令 + 常量 无需初始化
.rodata Flash 只读数据(字符串等) 无需初始化
.data RAM(初始值在 Flash) 已初始化的全局/静态变量 启动时从 Flash 拷贝
.bss RAM 未初始化的全局/静态变量 启动时清零
堆 / 栈 RAM 末尾 动态内存 / 函数调用栈 设置 SP 指针 + 堆初始化

我在项目中遇到过一个问题:某个 Bootloader 在跳转到应用前,忘了把 .bss 段清零。结果应用的全局变量里残留了 Bootloader 的旧数据,导致通信协议解析出错。排查了两天才找到原因。所以,.bss 清零是启动流程里绝对不能省的一步。

3.3 栈与堆的初始化

栈和堆的初始化,是 Bootloader 进入 C 环境前的最后一步。说白了,就是给 SP 寄存器赋值,然后告诉 malloc 函数堆从哪里开始、有多大。

3.3.1 栈指针设置

在链接脚本里,我们通常会定义栈顶地址:

_estack = ORIGIN(RAM) + LENGTH(RAM);  /* 栈顶在 RAM 末尾 */

然后在启动汇编里:

ldr sp, =_estack

为什么栈要放在 RAM 末尾?因为栈是向下生长的。如果放在 RAM 开头,栈溢出时会直接踩到 .data 和 .bss 段,后果不堪设想。

个人建议: 在栈底(RAM 末尾往下 1KB 左右)放一个固定的魔数,比如 0xDEADBEEF。然后在 Bootloader 主循环里定期检查这个魔数有没有被覆盖。如果变了,说明栈溢出了,赶紧报警。

3.3.2 堆初始化

堆的初始化相对简单。在链接脚本里定义堆的起始和结束地址:

_heap_start = _ebss + 0x100;  /* BSS 段后面留 256 字节 */
_heap_end   = _estack - 0x400; /* 栈底往下留 1KB 给栈 */

然后在 C 代码里,用 __heap_start 和 __heap_end 这两个符号初始化堆管理器。如果你用的是 newlib,需要实现 _sbrk 函数:

extern char _heap_start;
extern char _heap_end;

void *_sbrk(int incr)
{
    static char *heap_ptr = &_heap_start;
    char *prev = heap_ptr;
    
    if (heap_ptr + incr > &_heap_end) {
        return (void *)-1;  /* 堆溢出 */
    }
    heap_ptr += incr;
    return prev;
}

这里要注意:堆和栈是相向生长的。堆向上,栈向下。如果中间没有足够的空隙,它们就会撞在一起。我一般会在堆顶和栈底之间留至少 2KB 的 guard zone。

避坑指南: 我曾经在一个资源紧张的 MCU 上做 Bootloader,把堆设得太大,结果栈和堆在运行时重叠了。现象很诡异——函数调用正常,但只要一 malloc 就死机。后来用 map 文件一算,发现堆顶已经超过了栈底。从那以后,我养成了习惯:在链接脚本里加一个 ASSERT,检查堆和栈是否有重叠。

3.4 链接脚本与内存布局的完整关系图

下面这张图,把整个内存布局串起来了。你可以对照着看,每个段在 Flash 和 RAM 里是怎么摆放的。

Bootloader 内存布局全景图 Flash (LMA) .isr_vector (中断向量表) .text (代码段) .rodata (只读数据) .data 初始值 (LMA) 应用固件区 启动时拷贝 RAM (VMA) .data (运行时) .bss (清零) 堆 (向上生长) 栈 (向下生长) _estack = RAM 末尾 注意:堆和栈相向生长,中间必须保留 guard zone,防止重叠

3.5 实战:一个完整的 Bootloader .lds 示例

最后,我给你一个我实际项目里用过的链接脚本模板。它包含了 Bootloader 特有的分区设计:

/* Bootloader 专用链接脚本 */
MEMORY
{
    BOOT_FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    APP_FLASH  (rx)  : ORIGIN = 0x08008000, LENGTH = 480K
    RAM        (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    . = ORIGIN(BOOT_FLASH);
    
    .text :
    {
        KEEP(*(.isr_vector))
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > BOOT_FLASH
    
    .data : AT(_etext)
    {
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM
    
    .bss :
    {
        _sbss = .;
        *(.bss*)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
    
    /* 堆定义 */
    .heap (NOLOAD) :
    {
        . = ALIGN(8);
        _heap_start = .;
        . += 4K;  /* 4KB 堆 */
        _heap_end = .;
    } > RAM
    
    /* 栈定义 */
    .stack (NOLOAD) :
    {
        . = ALIGN(8);
        . += 2K;  /* 2KB 栈 */
        _stack_end = .;
    } > RAM
    
    _estack = ORIGIN(RAM) + LENGTH(RAM);
    
    /* 安全检查:堆不能超过栈底 */
    ASSERT(_heap_end <= _estack - 2K, "Heap overflow into stack guard zone!")
}

这个脚本里,我把 Bootloader 限制在 32KB 的 Flash 空间内,剩下的 480KB 留给应用。堆和栈之间留了 2KB 的 guard zone,并且在链接时就用 ASSERT 做了静态检查。这样,编译阶段就能发现内存布局问题,而不是等到运行时崩溃。

小技巧: 编译完成后,记得查看 .map 文件。我每次都会 grep 一下 _sdata、_edata、_sbss、_ebss 这几个符号的地址,确认它们没有越界。养成这个习惯,能省掉很多调试时间。