3. 链接脚本与内存布局:.lds 语法与地址映射

好,咱们进入第三章。链接脚本,说白了就是告诉编译器:「你的代码放哪儿,数据放哪儿,BSS 段又该去哪儿。」很多做应用层开发的同事可能一辈子都不用碰这东西,但做 Bootloader,你绕不开它。

我个人习惯,拿到一个新的 MCU 平台,第一件事不是写代码,而是先把链接脚本捋一遍。为什么?因为内存布局一旦错了,后面所有调试都是白费功夫。我曾经在一个项目里,就因为 .lds 里一个地址写错了,整整排查了两天——后来发现是 RAM 起始地址偏移了 0x200 字节。

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

先看一个最简的 .lds 文件长什么样。我直接贴一段我常用的模板:

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(Reset_Handler)

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

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

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

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

这里有几个关键点,我挨个说一下:

  • MEMORY 区域:定义物理存储器的起始地址和大小。FLASH 是只读可执行,RAM 是读写可执行。
  • SECTIONS 区域:描述各个段怎么放。.text 放代码和只读数据,.data 放已初始化的全局变量,.bss 放未初始化的全局变量。
  • AT> 关键字:这个特别重要。.data 段运行时在 RAM,但初始值存储在 FLASH 里。Bootloader 启动时要把这些数据从 FLASH 拷贝到 RAM。

核心概念:链接脚本的本质,就是给链接器一张「地图」。地图上标明了每个段该去哪,链接器照着这张地图生成最终的二进制文件。

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

咱们把这三个段拆开细讲。你想想看,一个程序跑起来,内存里到底长什么样?

段名 存储位置 运行位置 内容 大小是否固定
.text FLASH FLASH(就地执行) 指令、只读常量、中断向量表 编译时确定
.data FLASH(初始值) RAM 已初始化的全局/静态变量 编译时确定
.bss 无(初始值为零) RAM 未初始化的全局/静态变量 编译时确定
堆(Heap) 无 RAM 动态分配内存 运行时变化
栈(Stack) 无 RAM 局部变量、函数调用上下文 运行时变化

嗯,这里要注意一个细节:.bss 段在 FLASH 里不占空间。链接器只记录它的起始地址和大小,启动代码负责在 RAM 里把它清零。我曾经见过新手把 .bss 段也放到 FLASH 里,结果芯片一上电,全局变量全是乱值——因为 FLASH 里根本没存零。

个人经验:在 Bootloader 的启动汇编代码里,一定要实现 .data 拷贝和 .bss 清零。我习惯用 _sdata、_edata、_sbss、_ebss 这些链接器符号来定位边界,而不是硬编码地址。

3.3 RAM 与 FLASH 地址映射

地址映射,说白了就是「CPU 眼里看到的世界」。不同的 MCU 厂商,地址空间划分差异很大。我拿最常见的 ARM Cortex-M 系列举例:

0x00000000 - 0x1FFFFFFF: 代码区(别名映射)
0x20000000 - 0x3FFFFFFF: SRAM 区
0x40000000 - 0x5FFFFFFF: 外设区
0x60000000 - 0x9FFFFFFF: 外部 RAM / 外部 FLASH
0xE0000000 - 0xE00FFFFF: 系统控制区(NVIC、MPU 等)

但具体到某个芯片,比如 STM32F103,它的 FLASH 实际起始地址是 0x08000000,RAM 是 0x20000000。为什么不是从 0x00000000 开始?因为 Cortex-M3 有一个「别名映射」机制——芯片上电后,会把 0x08000000 映射到 0x00000000,这样中断向量表就能从 0x00000000 读取。

我画了一张图,帮你把整个内存布局串起来:

Bootloader 内存布局与地址映射 Bootloader 内存布局与地址映射 FLASH (0x08000000) 中断向量表 (.isr_vector) 代码段 (.text) 只读数据 (.rodata) .data 初始值(拷贝源) RAM (0x20000000) .data 段(运行时) .bss 段(零初始化) 堆(Heap) 动态分配 栈(Stack) 局部变量、函数调用 Bootloader 保留区 启动时拷贝 .data 关键说明: 1. FLASH 中的 .data 初始值在编译时确定,运行时拷贝到 RAM。 2. .bss 段在 FLASH 中不占空间,启动时由代码清零。 3. 栈和堆的地址由链接脚本中的符号定义,通常在 .bss 之后。 4. Bootloader 保留区用于存放临时数据或升级标志。

避坑指南:我曾经在一个项目中,把栈顶地址写死了,结果芯片换了型号后 RAM 大小变了,栈直接溢出到了 .bss 段。从那以后,我坚持用链接器符号来定义栈顶,比如 _estack = ORIGIN(RAM) + LENGTH(RAM);,而不是硬编码 0x20020000。

3.4 实战:自定义内存布局

有时候,标准的内存布局不够用。比如 Bootloader 需要一块「安全区域」来存放升级标志,或者你想把某个关键数据放在 RAM 的特定地址。这时候就需要自定义段。

看一个例子:

// 在 C 代码中定义自定义段
__attribute__((section(".bootloader_flags")))
volatile uint32_t upgrade_flag = 0;

// 在 .lds 中分配地址
SECTIONS
{
    .bootloader_flags 0x2000F000 :
    {
        *(.bootloader_flags)
    } > RAM
}

这样,upgrade_flag 就被强制放在了 RAM 的 0x2000F000 地址。为什么这么做?因为 Bootloader 和应用程序可能共享这个标志位,固定地址方便双方访问。

小技巧:我习惯在链接脚本里定义一些「哨兵变量」,比如 _flash_size = LENGTH(FLASH);,然后在 C 代码里用 extern 声明引用。这样代码里就不用硬编码任何地址了,换芯片时只需要改 .lds 文件。

3.5 常见问题与排查方法

链接脚本出了问题,症状通常很诡异:程序跑飞、全局变量值不对、函数指针跳转错误。我总结了几种常见情况:

  • .data 段没拷贝:全局变量初始值全是乱的。检查启动代码里有没有 _sdata 到 _edata 的拷贝循环。
  • .bss 段没清零:未初始化的全局变量有随机值。检查清零循环。
  • 栈溢出到 .bss:函数调用深度大时,全局变量被莫名修改。检查栈大小和链接脚本中的栈位置。
  • 中断向量表地址不对:芯片上电后 PC 指针乱跳。检查 ENTRY 和 .isr_vector 段是否在 FLASH 起始地址。

嗯,这里再分享一个我自己的排查方法:编译完成后,用 objdump -h 查看各段的地址和大小,再用 objdump -t 查看符号表。如果发现某个段的地址不在预期范围内,那八成是链接脚本写错了。

好了,这一章的内容就到这里。链接脚本是 Bootloader 开发的基石,你花时间把它吃透了,后面写启动代码、做内存管理都会顺手很多。