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

链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。很多做应用层开发的同事第一次看到 .lds 文件时,会觉得这玩意儿跟天书似的。但我跟你说,搞 Bootloader 如果不懂链接脚本,那就像开车不看仪表盘——能跑,但迟早要出事。

我记得刚入行那会儿,接手一个 STM32 的 Bootloader 项目。代码写好了,编译通过,下载运行——直接死机。查了两天,最后发现是链接脚本里 RAM 起始地址写错了。从那以后,我养成了一个习惯:拿到一个新平台,第一件事就是先把 .lds 文件从头到尾读一遍。

4.1 链接脚本的核心语法

GNU LD 的链接脚本,语法其实不复杂。核心就几个关键字:ENTRY、MEMORY、SECTIONS。咱们一个一个说。

4.1.1 ENTRY:程序入口

这个最简单。告诉链接器,程序的第一条指令在哪儿。对于 Bootloader 来说,入口通常是复位向量:

ENTRY(Reset_Handler)

嗯,这里要注意:ENTRY 只是给链接器一个提示。真正决定 CPU 从哪里开始执行的,是芯片硬件规定的启动地址。比如 Cortex-M 系列,上电后从 0x00000000 取栈指针,从 0x00000004 取复位向量。所以 ENTRY 更多是给调试器和反汇编工具看的。

4.1.2 MEMORY:定义内存区域

这个就关键了。你得告诉链接器,芯片上有哪些内存区域,它们的起始地址和大小是多少。

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

属性 rx 表示可读可执行,rwx 表示可读可写可执行。我见过有人把 FLASH 写成 rwx,虽然也能编译通过,但总觉得心里不踏实。

注意:Bootloader 和应用程序通常共用同一片 FLASH 和 RAM。你需要给 Bootloader 划分专用的区域,不能越界。我曾经见过一个项目,Bootloader 写到了应用程序的区域,结果一升级就把自己给覆盖了——嗯,那场面,挺尴尬的。

4.1.3 SECTIONS:段布局

这才是重头戏。SECTIONS 命令定义了各个段(.text、.data、.bss 等)如何放置到 MEMORY 定义的内存区域中。

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text :
    {
        KEEP(*(.isr_vector))
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > FLASH
    
    .data : AT(_etext)
    {
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM
    
    .bss :
    {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}

这段代码,我闭着眼睛都能写出来。但每个符号的含义,你得搞清楚:

  • .:当前位置计数器。你可以手动赋值,比如 . = 0x20000000。
  • KEEP:告诉链接器,这个段即使没人引用也要保留。中断向量表就是典型的例子。
  • ALIGN(4):4 字节对齐。ARM 架构下,大部分指令和数据都需要对齐,否则可能触发异常。
  • _etext、_sdata 等:这些是链接器导出的符号,C 代码里可以用 extern 声明后直接使用。

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

咱们把这三个段拆开来看。每个段都有它的脾气。

4.2.1 .text:代码段

存放程序指令和只读常量。在 Bootloader 里,这部分通常放在 FLASH 中。为什么?因为 FLASH 是非易失性的,掉电不丢失。

我个人习惯把中断向量表放在最前面,紧跟着是复位处理函数,然后是其他代码。这样布局清晰,调试的时候也容易定位问题。

关键点:.text 段在 FLASH 中运行。虽然 Cortex-M 支持从 RAM 执行代码,但 Bootloader 一般不需要这么干——除非你要做在线升级时的代码覆盖。

4.2.2 .data:已初始化数据段

这个段有点特殊。它存储的是有初始值的全局变量和静态变量。比如:

int g_counter = 100;  // 这个变量在 .data 段
static int s_flag = 1; // 这个也在 .data 段

问题是:这些初始值在编译时就已经确定了,它们被存放在 FLASH 中。但变量本身需要在 RAM 中才能读写。所以链接脚本做了个巧妙的设计:

  • 加载地址(LMA):在 FLASH 中,紧跟在 .text 后面。
  • 运行地址(VMA):在 RAM 中。

启动代码必须负责把 .data 段从 FLASH 拷贝到 RAM。这就是为什么你会在汇编启动文件里看到一段循环拷贝的代码。

// 启动代码中的 .data 段拷贝
extern uint32_t _sdata, _edata, _etext;
uint32_t *src = &_etext;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
    *dst++ = *src++;
}

嗯,这里有个坑:如果你忘了拷贝 .data 段,或者拷贝的地址算错了,那所有带初始值的全局变量都是垃圾值。我调试过的一个项目,串口打印乱码,查了半天发现是 .data 段没拷贝——波特率配置变量是 0。

4.2.3 .bss:未初始化数据段

存放未初始化或初始化为 0 的全局变量和静态变量。比如:

int g_buffer[1024];    // 在 .bss 段
static int s_count;    // 也在 .bss 段

.bss 段不占用 FLASH 空间,只在 RAM 中占位置。启动代码需要把它清零:

extern uint32_t _sbss, _ebss;
uint32_t *dst = &_sbss;
while (dst < &_ebss) {
    *dst++ = 0;
}
小技巧:有些芯片的 RAM 上电后默认就是 0,但千万别依赖这个。不同芯片、不同温度下,RAM 的初始值可能不一样。老老实实写清零代码,别偷懒。

4.3 栈与堆的配置

栈和堆,这两个概念搞嵌入式的都不陌生。但在链接脚本里,它们是怎么配置的?

4.3.1 栈(Stack)

栈用于函数调用、局部变量、中断现场保存。Cortex-M 使用满递减栈,栈指针 SP 指向栈顶。

在链接脚本中,栈通常不直接定义,而是在汇编启动文件中设置:

; 汇编启动文件中的栈定义
Stack_Size      EQU     0x00001000

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

但链接脚本需要确保栈所在的 RAM 区域足够大,并且不与 .data 和 .bss 段冲突。

我个人习惯把栈放在 RAM 的最高地址。为什么?因为栈是向下生长的,放在最高处可以最大化可用空间。而且如果栈溢出,它会先覆盖到未使用的区域,而不是直接踩到全局变量。

避坑指南:我曾经在一个项目中,把栈大小设成了 256 字节。结果函数嵌套调用一深,栈就溢出了,程序跑飞。后来用 __builtin_frame_address 调试才发现。建议 Bootloader 的栈至少 1KB,如果用到文件系统或网络协议栈,4KB 起步。

4.3.2 堆(Heap)

堆用于动态内存分配(malloc/free)。在 Bootloader 中,我建议尽量少用堆。原因很简单:

  • 堆管理有开销,影响实时性。
  • 碎片问题难以预测。
  • 如果 malloc 失败,错误处理很麻烦。

如果非要用,可以在链接脚本中定义堆的起始和结束地址:

_heap_start = .;
_heap_end = ORIGIN(RAM) + LENGTH(RAM);

然后在 C 代码中实现 _sbrk 函数:

extern uint32_t _heap_start, _heap_end;
void *_sbrk(int incr) {
    static uint32_t *heap_ptr = NULL;
    if (heap_ptr == NULL) heap_ptr = &_heap_start;
    uint32_t *prev = heap_ptr;
    heap_ptr += incr;
    if (heap_ptr > &_heap_end) return (void*)-1;
    return prev;
}

4.4 完整的内存布局图

说了这么多,咱们用一张图来总结一下典型的 Bootloader 内存布局:

Bootloader 典型内存布局 FLASH (0x08000000) .isr_vector (中断向量表) .text (代码 + 只读数据) .data 初始值 (LMA) RAM (0x20000000) .data (运行时拷贝) .bss (清零) 堆 (Heap) 栈 (Stack) - 向下生长 启动时拷贝 启动时清零 关键说明: 1. FLASH 起始地址 0x08000000 是 STM32 的典型值,不同芯片可能不同。 2. 中断向量表必须放在 FLASH 开头,这是硬件规定的。 3. .data 段在 FLASH 中存初始值,启动时拷贝到 RAM。 4. 栈放在 RAM 末尾,向下生长,避免与堆和全局变量冲突。 5. 堆和栈的总大小不能超过 RAM 剩余空间,否则启动时就会溢出。

4.5 实战:自定义链接脚本的注意事项

最后,分享几个我在实际项目中踩过的坑:

  1. 对齐问题:ARM 架构要求某些段必须对齐到 4 字节或 8 字节边界。忘了写 ALIGN,可能触发硬件异常。
  2. 符号冲突:不同目标文件中的同名段会被合并。如果你有两个 .text 段,链接器会报错。
  3. 内存重叠:检查 MEMORY 定义是否有重叠区域。我见过有人把 FLASH 和 RAM 的地址写反了,编译通过,运行直接 HardFault。
  4. 栈大小估算:Bootloader 的栈大小可以通过分析函数调用深度来估算。保守起见,先设大一点,然后通过 __stack_chk_guard 或填充模式来检测实际使用量。
调试技巧:编译后查看 .map 文件,里面详细记录了每个段的起始地址、大小和链接过程。我每次修改链接脚本后,第一件事就是检查 .map 文件,确认布局是否符合预期。

链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:代码放哪儿,数据放哪儿,启动时怎么初始化。搞清楚了这些,你就能随心所欲地控制 Bootloader 的内存布局了。