3. 链接脚本与内存布局:.text, .data, .bss 段是如何规划的?

说到嵌入式固件,很多人第一反应是写代码、调驱动。但有个东西,你绕不开——链接脚本。说白了,它就是告诉编译器:「你的代码放哪,你的数据放哪,你的全局变量又该去哪报到。」

我记得刚入行那会儿,接手一个 STM32 的项目,编译通过,下载成功,板子就是不跑。折腾了两天,最后发现是链接脚本里 RAM 的起始地址写错了。嗯,从那以后,我每次建新工程,第一件事就是打开 .ld 文件,一个字一个字地看。

核心观点:链接脚本是固件工程师的「地契」。它决定了你的程序在芯片上怎么安家。不懂它,你连程序为什么跑飞都查不出来。

3.1 三段式内存布局:.text, .data, .bss

一个编译好的嵌入式程序,最终会被拆成三个主要部分:.text、.data 和 .bss。你想想看,这就像搬家——衣服放衣柜(.text),锅碗瓢盆放厨房(.data),空箱子先堆阳台(.bss)。

段名 存储内容 存放位置 运行位置 初始化
.text 代码指令、常量、只读数据 Flash / ROM Flash / ROM(原地执行) 无需初始化
.data 已初始化的全局/静态变量 Flash / ROM RAM(需搬运) 启动时从 Flash 拷贝到 RAM
.bss 未初始化的全局/静态变量 无(仅占位) RAM 启动时清零

这里有个关键点:.data 段在 Flash 里存了一份初始值,但运行时必须搬到 RAM 里。为什么?因为 Flash 不能随便写,而变量是要被改的。我个人习惯在链接脚本里把 .data 的加载地址(LMA)和运行地址(VMA)分开写,这样编译器会自动生成搬运代码。

小提示:如果你发现程序下载后,全局变量全是乱的,十有八九是 .data 段的搬运没做,或者搬运地址配错了。我曾经在这个坑里摔过两次,后来干脆写了个启动汇编,手动检查 LMA 和 VMA 是否一致。

3.2 链接脚本的核心语法

以 GCC 的链接脚本为例,我挑最常用的几个命令讲。你不用背,但得看得懂。

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

SECTIONS
{
    .text :
    {
        *(.text)        /* 所有 .text 段 */
        *(.rodata)      /* 只读数据也放这里 */
        _etext = .;     /* 标记 .text 结束地址 */
    } > FLASH

    .data : AT(_etext)  /* LMA 紧跟在 .text 后面 */
    {
        _sdata = .;     /* .data 起始地址(RAM 中) */
        *(.data)
        _edata = .;     /* .data 结束地址 */
    } > RAM

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

你看,MEMORY 定义了芯片的物理存储资源,SECTIONS 则描述了每个段怎么放。我个人习惯在 .text 段末尾加一个 _etext 符号,这样启动代码可以直接用它计算 .data 的搬运长度。

注意:千万不要把 .data 的 LMA 和 VMA 搞反。我见过有人把 AT(> RAM) 写成 AT(> FLASH),结果变量初始值全丢了。记住:LMA 在 Flash,VMA 在 RAM。

3.3 启动时的搬运与清零

链接脚本只负责「规划」,真正干活的是启动代码。这部分通常用汇编写,放在复位向量之后。我直接给你看一段典型的启动流程:

; 启动代码片段(ARM Cortex-M 风格)
reset_handler:
    ; 1. 搬运 .data 段:从 Flash 拷贝到 RAM
    ldr   r0, =_sdata      ; RAM 目标地址
    ldr   r1, =_etext      ; Flash 源地址(LMA)
    ldr   r2, =_edata      ; RAM 结束地址
    bl    memcpy           ; 调用内存拷贝

    ; 2. 清零 .bss 段
    ldr   r0, =_sbss
    ldr   r1, =_ebss
    bl    bss_clear        ; 逐字节清零

    ; 3. 跳转到 main
    bl    main

这里有个细节:.bss 段在 Flash 里不占空间,所以链接脚本里不需要给它分配 LMA。启动时直接对 RAM 区域清零就行。我在项目中遇到过一个问题——某个全局变量没初始化,默认值应该是 0,结果上电后是个随机数。查了半天,发现是 .bss 段的清零代码被优化掉了。嗯,从那以后我都在链接脚本里显式声明 KEEP 关键字,防止链接器把启动段丢掉。

3.4 内存布局的 SVG 全景图

下面这张图,是我画的内存布局全景。你看一眼,就能明白代码和数据在芯片里是怎么「住」的。

嵌入式固件内存布局全景图 嵌入式固件内存布局全景图 Flash (非易失) .text 段 代码指令 + 只读常量 .data 初始值 (LMA) 已初始化变量的「备份」 .rodata / 常量池 字符串、查表数据 Flash 剩余空间 (可存放 OTA 备份区) RAM (易失) .data 运行时 (VMA) 启动时从 Flash 搬运过来 .bss 段 启动时清零 堆 (Heap) malloc / new 动态分配 栈 (Stack) 局部变量、函数调用 启动搬运 关键点: .data 段在 Flash 和 RAM 各有一份,启动时由 BootLoader 或启动代码完成搬运;.bss 段只在 RAM 中,启动时清零。 堆和栈的生长方向相反,防止内存碎片过早溢出。

3.5 常见坑与避坑指南

做固件这么多年,我在链接脚本上踩过的坑,能写满一页纸。挑几个典型的说说:

  • 段重叠:.data 的 VMA 和 .bss 的 VMA 重叠了。这通常是因为 RAM 地址算错了。我建议你在链接脚本里加 ASSERT 检查,比如 ASSERT(_edata <= _sbss, "data and bss overlap")。
  • 对齐问题:ARM 芯片要求某些段 4 字节或 8 字节对齐。如果你忘了加 . = ALIGN(4);,可能会触发硬件异常。我习惯在每个段结尾都显式对齐一次。
  • 符号丢失:链接器默认会优化掉未引用的段。如果你有中断向量表或者启动代码,记得加 KEEP。我曾经因为没加 KEEP,导致整个 .isr_vector 段被优化掉,板子直接变砖。

避坑指南:我曾经在一个量产项目里,因为链接脚本的 .bss 段起始地址写错了 4 个字节,导致某个全局变量和栈空间重叠。程序跑起来偶尔死机,查了整整一周。后来用 map 文件对比,才发现地址差了一点点。所以,每次改链接脚本,一定要生成 .map 文件,逐段检查地址范围。

3.6 如何验证你的内存布局

链接脚本写完了,怎么知道对不对?我一般做三件事:

  1. 看 .map 文件:编译时加 -Wl,-Map=output.map,然后搜索 _sdata、_ebss 这些符号,确认地址落在预期的 RAM 范围内。
  2. 看反汇编:用 objdump -h 查看各段的 VMA 和 LMA,确保 .data 的 LMA 在 Flash,VMA 在 RAM。
  3. 实际跑一下:在启动代码里加个 GPIO 翻转,分别在搬运前、搬运后、清零后翻转一次,用示波器看时序。这招虽然土,但最可靠。

我个人习惯在工程里保留一份「内存布局检查表」,每次改链接脚本后逐项打勾。别嫌麻烦,这玩意儿出一次问题,够你折腾好几天。

小技巧:如果你用 STM32CubeIDE 或者 Keil,可以在调试模式下打开 Memory 窗口,直接看 0x20000000 区域的内容。启动后如果 .data 段的值和 Flash 里的一致,说明搬运成功了。

好了,链接脚本和内存布局就聊到这儿。你只要记住:.text 在 Flash 里待着,.data 从 Flash 搬到 RAM,.bss 在 RAM 里清零。这三句话,能解决你 90% 的内存布局问题。