3. 链接脚本深度解析:.lds 的结构、内存布局与段映射

链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多做嵌入式开发的朋友,写了几年 C 代码,却从来没碰过 .lds 文件。嗯,这其实挺危险的。我记得刚入行那会儿,自己写了个 BootLoader,烧进去就是不跑,折腾了两天才发现——是链接脚本里 RAM 地址写错了。

今天我们就把它彻底讲透。你想想看,搞懂了链接脚本,你就能精确控制每一段代码、每一个全局变量在 Flash 和 RAM 里的位置。这对 BootLoader 的 OTA 升级来说,是基本功。

3.1 链接脚本的基本结构

一个标准的 .lds 文件,通常由三大部分组成:内存布局定义、段映射规则、符号导出。我习惯把它看作一张「搬家地图」——告诉链接器什么东西搬到哪个房间。

核心三要素:

  • MEMORY:描述芯片有哪些物理存储区域(Flash、RAM、ROM 等)
  • SECTIONS:定义如何把输入段(.text、.data、.bss)放到 MEMORY 中
  • 符号赋值:提供链接时地址常量,供代码运行时使用

来看一个最简的示例,我把它叫做「Hello World 版链接脚本」:

/* 最简单的链接脚本示例 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

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

    .data :
    {
        *(.data*)
    } > RAM AT> FLASH

    .bss :
    {
        *(.bss*)
    } > RAM
}

这段脚本虽然短,但包含了所有关键概念。我们逐行拆解。

3.2 MEMORY 命令:定义物理存储

MEMORY 块用来描述目标芯片的物理存储资源。每个区域需要指定:名字、属性、起始地址、长度。

属性 含义 典型用途
r 可读 所有存储区域都应有
w 可写 RAM 必须包含
x 可执行 Flash 存放代码时需要
! 反转属性 很少用,但某些场景下有用

我的经验: 属性写错是新手常犯的坑。比如把 Flash 写成 rw,链接器不会报错,但运行时写 Flash 会触发 HardFault。我曾经排查过一整天,最后发现是属性少了个 x。

ORIGIN 和 LENGTH 的值取决于具体芯片。以 STM32F103 为例,Flash 从 0x08000000 开始,大小 512KB;RAM 从 0x20000000 开始,大小 128KB。不同型号要查数据手册,千万别想当然。

3.3 SECTIONS 命令:段映射的核心

SECTIONS 是链接脚本的灵魂。它定义了每个输出段从哪里来、到哪里去。我习惯把它分成三类:只读段、可读写段、零初始化段。

3.3.1 .text 段:代码与只读数据

.text 段包含你的程序指令和常量数据。它通常放在 Flash 里,因为 Flash 是只读可执行的。

.text :
{
    /* 中断向量表必须放在最前面 */
    . = ALIGN(4);
    _stext = .;
    KEEP(*(.isr_vector))
    
    /* 用户代码 */
    *(.text*)
    *(.text.*)
    
    /* 只读数据 */
    *(.rodata*)
    *(.rodata.*)
    
    . = ALIGN(4);
    _etext = .;
} > FLASH

这里有几个细节要注意:

  • KEEP:告诉链接器不要优化掉这个段。中断向量表必须保留,否则芯片上电后找不到入口。
  • ALIGN(4):4 字节对齐。ARM Cortex-M 要求代码对齐,否则可能触发异常。
  • _stext / _etext:我习惯定义这些符号,方便在代码里获取段的起始和结束地址。

3.3.2 .data 段:已初始化的全局变量

.data 段存放已初始化的全局变量和静态变量。这些变量在编译时就有初始值,但初始值必须存在 Flash 里,运行时再拷贝到 RAM。

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

注意 > RAM AT> FLASH 这个语法:

  • > RAM:运行时,.data 段在 RAM 中
  • AT> FLASH:但它的初始值存储在 Flash 中

这就是为什么 BootLoader 启动代码里要有一段「搬运」逻辑——把 .data 从 Flash 拷贝到 RAM。我见过有人忘记实现这个拷贝,结果全局变量全是乱的,程序跑飞都不知道为什么。

避坑指南: 我曾经接手过一个项目,.data 段搬运代码写错了拷贝长度,导致部分全局变量初始值不对。排查了三天,最后用 map 文件对比才发现 _sdata 和 _edata 的差值算错了。所以,一定要在 map 文件里确认段的大小。

3.3.3 .bss 段:未初始化的全局变量

.bss 段存放未初始化或初始化为 0 的全局变量。它只占 RAM 空间,不占 Flash 空间。启动时由代码清零。

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

(NOLOAD) 告诉链接器:这个段不需要加载初始值。链接器不会在生成的二进制文件中为 .bss 分配空间。启动代码只需要把从 _sbss 到 _ebss 的区域清零即可。

3.4 内存布局的完整视图

为了让你更直观地理解,我画了一张内存布局图。它展示了 BootLoader 在 Flash 和 RAM 中的典型分布。

BootLoader 典型内存布局 Flash (0x08000000) 中断向量表 (.isr_vector) 0x08000000 - 0x080000C0 代码段 (.text) BootLoader 主逻辑 OTA 升级函数 只读数据 (.rodata) 字符串、常量表 .data 初始值 (LMA) 运行时拷贝到 RAM 拷贝 RAM (0x20000000) .data 运行时 (VMA) 已初始化的全局变量 .bss 段 未初始化全局变量 启动时清零 堆 (Heap) malloc / free 使用 栈 (Stack) 局部变量、函数调用 向下增长 LMA = 加载地址(Flash 中位置),VMA = 运行地址(RAM 中位置)

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

链接脚本定义的符号,在启动代码中会被直接引用。以 ARM Cortex-M 为例,典型的启动流程是这样的:

  1. 从 Flash 0x00000000 取栈顶指针(由链接脚本提供)
  2. 从 Flash 0x00000004 取复位向量,跳转执行
  3. 在 Reset_Handler 中:
  4. 拷贝 .data 段:从 Flash 的 LMA 地址拷贝到 RAM 的 VMA 地址
  5. 清零 .bss 段:从 _sbss 到 _ebss 全部写 0
  6. 跳转到 main()

对应的启动代码片段:

/* 链接脚本导出的符号 */
extern uint32_t _sdata, _edata;   /* .data 在 RAM 中的起止 */
extern uint32_t _sidata;          /* .data 在 Flash 中的起始 */
extern uint32_t _sbss, _ebss;     /* .bss 段的起止 */

void Reset_Handler(void)
{
    uint32_t *src, *dst;
    
    /* 1. 拷贝 .data 段 */
    src = &_sidata;
    dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    
    /* 2. 清零 .bss 段 */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }
    
    /* 3. 跳转到主函数 */
    main();
}

我的习惯: 我会在链接脚本里额外定义一个 _sstack 和 _estack,用来在启动时检查栈是否溢出。这在调试 OTA 升级时的栈溢出问题特别有用。你可以在 main 函数开头加一段栈填充和校验逻辑。

3.6 常见陷阱与调试技巧

搞链接脚本,说白了就是和地址打交道。我踩过的坑,列出来给你参考:

问题 现象 原因 解决方法
程序跑飞 上电后无反应 中断向量表未放在开头 用 KEEP 保留 .isr_vector
全局变量值不对 变量初始值混乱 .data 拷贝未执行或拷贝长度错误 检查 _sdata/_edata 符号
HardFault 访问地址异常 段未对齐或越界 检查 ALIGN 和 LENGTH
OTA 升级后不启动 跳转到新固件失败 新固件的链接脚本地址冲突 确认 Flash 分区地址不重叠

避坑指南: 我曾经在 OTA 项目中,把 BootLoader 和 App 的链接脚本写成了完全相同的 Flash 起始地址。结果升级后,BootLoader 把自己覆盖了——板子直接变砖。从那以后,我强制要求每个固件版本都单独维护一份链接脚本,并在编译时打印出内存布局。

调试链接脚本最有效的工具是 map 文件。编译时加上 -Wl,-Map=output.map,就能生成详细的映射信息。我每次修改 .lds 后,第一件事就是打开 map 文件,确认每个段的地址和大小是否符合预期。

嗯,链接脚本的内容就讲到这里。你可能会觉得它有点枯燥,但相信我——当你亲手调试过一个因为链接脚本错误导致的启动失败后,你就会明白,这 30 分钟的投入,值。