3. 链接脚本与内存布局:让固件知道自己在哪

说实话,很多做嵌入式开发的朋友,写了几年 C 代码,却从来没仔细看过链接脚本。我刚开始带项目时也是这样——直到有一次,芯片上电后 Bootloader 死活跑不起来,折腾了两天才发现,原来是 .data 段放错了位置。嗯,从那以后,我养成了一个习惯:每次新建工程,第一件事就是打开 .ld 文件,把内存布局看清楚。

3.1 链接脚本到底在干什么?

你想想看,我们写 C 代码时,函数、全局变量、静态变量、常量字符串……这些东西最终都要变成二进制数据,烧进芯片。但芯片的 Flash 和 RAM 是分开的,哪些该放 Flash,哪些该放 RAM,谁在前谁在后——这些规则,就是链接脚本定的。

说白了,链接脚本就是一张「搬家地图」。它告诉链接器:

  • 你的代码(.text)应该放在 Flash 的哪个地址
  • 初始化的全局变量(.data)烧录时在 Flash,运行时必须搬到 RAM
  • 未初始化的全局变量(.bss)在 RAM 里,上电后要清零
  • 堆(Heap)和栈(Stack)各留多大空间
核心观点: 没有正确的链接脚本,再好的代码也跑不起来。它决定了固件的「物理布局」。

3.2 典型的内存分区

以 STM32F103 为例,它的内存空间是这样的:

区域 起始地址 大小 用途
Flash 0x0800 0000 512 KB 存放代码、只读数据、中断向量表
RAM 0x2000 0000 64 KB 存放全局变量、堆、栈
系统存储器 0x1FFF F000 2 KB BootROM(出厂固件)

我在项目中遇到过一个问题:某款芯片的 RAM 只有 32 KB,但同事把 .bss 段和堆栈都塞在一起,结果栈溢出后直接覆盖了全局变量,导致系统随机死机。后来我强制把栈放在 RAM 末尾,堆放在 .bss 之后,中间留了 1 KB 的「哨兵区」——从此再没出过类似问题。

3.3 代码段与数据段的映射

一个典型的链接脚本,会把目标文件中的各种段(Section)映射到具体的地址空间。我们来看一个简化版的 .ld 文件:

/* 简化版链接脚本 - 适用于 Cortex-M3 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS
{
    /* 中断向量表放在 Flash 开头 */
    .isr_vector : {
        KEEP(*(.isr_vector))
    } > FLASH

    /* 代码段 */
    .text : {
        *(.text*)
        *(.rodata*)
    } > FLASH

    /* 初始化数据:烧录时在 Flash,运行时搬到 RAM */
    .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

    /* 未初始化数据:RAM 中,上电清零 */
    .bss : {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM

    /* 堆和栈 */
    .heap : {
        _heap_start = .;
        . = . + 4K;
        _heap_end = .;
    } > RAM

    .stack : {
        _stack_top = .;
        . = . + 1K;
    } > RAM
}
个人习惯: 我通常会在 .data 段前后定义 _sdata / _edata 符号,在 .bss 段前后定义 _sbss / _ebss 符号。这样启动代码里可以直接用这些符号做搬运和清零,不用硬编码地址。

3.4 为什么 .data 要占两份空间?

这个问题我经常被问到。你看上面的脚本,.data 段同时出现在 Flash 和 RAM 中——这不是浪费吗?

其实不是浪费,而是无奈之举。芯片上电时,RAM 里的内容是随机的。但你的全局变量可能有初始值,比如 int count = 100;。这个 100 必须事先存在 Flash 里,等系统启动后,再由启动代码把它从 Flash 拷贝到 RAM。

所以 .data 段在 Flash 里占一份(烧录时),在 RAM 里占一份(运行时)。这就是所谓的「加载时地址(LMA)」和「运行时地址(VMA)」的区别。

避坑指南: 我曾经遇到一个项目,.data 段特别大(几十 KB 的查表数据),结果启动时拷贝时间太长,导致看门狗超时复位。解决方案是把那些只读的大表改成 const 类型,让它们留在 .rodata 段,不用搬运。

3.5 堆和栈的布局策略

堆和栈的布局,直接影响系统的稳定性。常见的布局方式有两种:

布局方式 说明 适用场景
栈在 RAM 末尾,堆在 .bss 之后 栈向下增长,堆向上增长,中间留空 大多数 RTOS 和裸机系统
栈和堆固定大小,互不干扰 栈和堆各自划定固定区域 对实时性要求极高的场景

我个人更推荐第一种。为什么呢?因为栈和堆的使用量往往是动态的,中间留空相当于一个「缓冲区」。如果栈溢出一点,先碰到的是未使用的内存,而不是直接踩到堆里的变量。

但要注意:这个缓冲区不能太大,否则浪费 RAM;也不能太小,否则溢出后照样出事。我一般留 1 KB 到 4 KB,具体看项目。

3.6 链接脚本中的符号与启动代码

链接脚本里定义的符号(比如 _sdata、_ebss),在启动代码里可以直接引用。以 ARM Cortex-M 为例,启动代码通常是这样做的:

; 启动代码片段(汇编)
    ldr   r0, =_sdata      ; RAM 中的 .data 起始地址
    ldr   r1, =_edata      ; RAM 中的 .data 结束地址
    ldr   r2, =_sidata     ; Flash 中的 .data 起始地址(由链接器自动生成)
    bl    memcpy           ; 从 Flash 拷贝到 RAM

    ldr   r0, =_sbss       ; .bss 起始地址
    ldr   r1, =_ebss       ; .bss 结束地址
    mov   r2, #0
    bl    memset           ; 清零 .bss 段

这里有个细节:_sidata 是链接器自动生成的符号,指向 .data 段在 Flash 中的加载地址。你不需要手动定义它,只要在链接脚本里写了 AT> FLASH,链接器就会自动算出来。

关键点: 启动代码必须严格按照链接脚本定义的符号来搬运和清零。如果符号名写错了,或者顺序搞反了,系统启动后全局变量的值就是乱的。

3.7 本章知识体系

下面这张图,把链接脚本、内存分区、段映射的关系画清楚了:

链接脚本与内存布局知识体系 链接脚本与内存布局知识体系 链接脚本 (.ld) 内存分区 段映射 Flash (0x0800 0000) RAM (0x2000 0000) 系统存储器 (BootROM) .isr_vector (中断向量表) .text (代码 + 只读数据) .data (初始化全局变量) .bss (未初始化全局变量) .heap / .stack 核心逻辑 链接脚本定义内存分区 → 链接器将各段映射到对应分区 → 启动代码按符号搬运/清零

3.8 实际项目中的注意事项

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

  1. Flash 和 RAM 的地址范围一定要核对芯片手册。 不同型号的芯片,即使是一个系列,Flash 大小和 RAM 起始地址也可能不同。我见过有人把 STM32F103 的链接脚本直接套到 STM32F407 上,结果芯片直接不启动。
  2. .data 段的 LMA 和 VMA 不要搞反。 如果写成了 > FLASH AT> RAM,链接器会把 .data 的初始值放在 RAM 里——但 RAM 掉电就丢,烧录进去也没用。
  3. 栈大小要留够,但别浪费。 我一般先给一个保守值(比如 1 KB),然后在调试阶段用 __stack_chk_guard 或者填充 0xAA 来监测栈使用峰值,最后再调整。
  4. 链接脚本里的符号名要和启动代码保持一致。 如果启动代码里用的是 _sdata,链接脚本里也必须是 _sdata,大小写都不能错。
一个小技巧: 在链接脚本末尾加上 PROVIDE(__stack_top = .);,然后在启动代码里直接用这个符号设置栈指针。这样如果以后调整了栈大小,不用改启动代码,只改链接脚本就行。