链接脚本与内存布局:手动分配代码、数据、BSS段

说实话,很多做MCU开发的工程师,写了好几年代码,都没仔细看过链接脚本。我刚开始也是这样——编译器帮我搞定一切,我何必自找麻烦?直到有一次,我做一个音频处理项目,RAM快爆了,但Flash还剩一大半。那时候我才意识到:不懂链接脚本,你根本没法精细控制内存。

这一章,咱们就把链接脚本(.ld)彻底讲透。你想想看,代码怎么放、数据怎么放、哪些该放RAM、哪些该放Flash,这些全由链接脚本说了算。搞懂了它,你才算真正掌握了MCU内存的"方向盘"。

什么是VMA和LMA?

这两个概念,是理解链接脚本的基石。我当年第一次看到它们时,也绕了半天。

  • VMA(Virtual Memory Address):运行时地址。程序执行时,段在内存中的实际位置。
  • LMA(Load Memory Address):加载地址。程序烧录时,段在非易失性存储器(如Flash)中的存放位置。

大多数情况下,VMA等于LMA。但有一个例外——初始化数据段(.data)。这个段在Flash里躺着(LMA),但运行时必须搬到RAM里(VMA)。为什么?因为MCU的Flash是只读的,而.data里的全局变量需要读写。

核心要点:.data段在Flash中存储初始值(LMA),在RAM中运行(VMA)。链接脚本必须同时指定这两个地址,启动代码才能完成拷贝。

一个典型的MCU链接脚本长什么样?

以STM32为例,我带你拆解一个实际可用的链接脚本。别怕,其实结构很清晰。

/* 内存区域定义 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段布局 */
SECTIONS
{
    /* 中断向量表:必须放在Flash开头 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    /* 代码段 */
    .text :
    {
        . = ALIGN(4);
        *(.text)
        *(.text*)
        *(.glue_7)
        *(.glue_7t)
        *(.rodata)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > FLASH

    /* 初始化数据段:LMA在Flash,VMA在RAM */
    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    /* BSS段:零初始化 */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}

这段脚本里,有几个关键符号:_sdata、_edata、_sbss、_ebss。启动代码就是靠它们来搬运数据和清零BSS的。我个人习惯在脚本里把这些符号命名得清晰一点,方便调试时查看。

手动分配:把大数组放到指定地址

实际项目中,经常需要把某个大缓冲区放到特定RAM区域。比如,有些MCU有紧耦合内存(TCM),访问速度更快。

怎么做?两步走:

  1. 在链接脚本里新增一个段
  2. 在C代码里用属性指定

先看链接脚本:

/* 在SECTIONS里添加 */
.audio_buf (NOLOAD) :
{
    . = ALIGN(32);
    _saudio_buf = .;
    *(.audio_buf)
    *(.audio_buf*)
    . = ALIGN(32);
    _eaudio_buf = .;
} > RAM

再看C代码:

/* 告诉编译器,这个数组放到.audio_buf段 */
int32_t audio_buffer[1024] __attribute__((section(".audio_buf")));

嗯,这里要注意:NOLOAD关键字表示这个段不需要初始化,上电后直接使用。适合用作临时缓冲区。

小技巧:如果你想把关键数据放到特定地址(比如备份SRAM),可以直接指定VMA:

.backup_data (NOLOAD) : AT (0x40024000)
{
    *(.backup_data)
} > RAM

这样就能精确控制物理位置,适合做掉电保持的数据。

启动代码如何配合链接脚本?

链接脚本只是"地图",真正执行搬运的是启动代码(startup_xxx.s)。我见过不少新手,改了链接脚本但忘了改启动代码,结果程序跑飞。

典型的启动流程:

  1. 复位后,从Flash的0x08000000取栈指针
  2. 跳转到Reset_Handler
  3. 拷贝.data段:从_sidata(LMA)到_sdata(VMA),长度_edata - _sdata
  4. 清零.bss段:从_sbss到_ebss
  5. 调用main()

其中_sidata是.data在Flash中的起始地址。注意,这个符号在链接脚本里通常这样定义:

/* 在.data段之前定义加载地址 */
_sidata = LOADADDR(.data);

我曾经踩过的坑:有一次我修改了链接脚本,把.data段放到了一个新的内存区域,但忘了更新_sidata的赋值。结果启动代码从错误的Flash地址拷贝数据,全局变量全乱了。排查了整整半天才找到问题。所以,每次改链接脚本,一定要检查启动代码里的符号是否匹配。

内存布局的可视化

光说理论不够,我画一张图,让你一眼看明白Flash和RAM里到底怎么分布的。

MCU内存布局:Flash与RAM的段分配 Flash (LMA) 0x08000000 ~ 0x0807FFFF .isr_vector 中断向量表(固定0x08000000) .text 代码 + 只读数据(.rodata) _etext 标记结束 .data (初始值) _sidata 指向这里 剩余Flash空间 可存放自定义段、配置数据 启动时拷贝 RAM (VMA) 0x20000000 ~ 0x2001FFFF .data (运行时) _sdata ~ _edata .bss _sbss ~ _ebss(清零) 堆(Heap) malloc/free使用 栈(Stack) 从高地址向下生长 自定义段 如.audio_buf、.backup_data

这张图把Flash和RAM的对应关系画得很清楚。你看,.data段在Flash里存了一份初始值,启动时搬到RAM里。而.bss段只在RAM里存在,上电后清零。这就是VMA和LMA最典型的应用场景。

常见优化技巧

搞懂了基础,咱们来点实战技巧。这些是我在多个项目里总结出来的:

技巧 做法 效果
常量放Flash 用const修饰大表格、字符串 节省RAM,.rodata自动进Flash
临时缓冲去BSS 大数组不加初始值,放.bss段 避免占用Flash空间存储初始值
自定义段隔离 把关键数据放到独立段 便于调试和内存统计
栈大小调整 在链接脚本里显式设置_estack 防止栈溢出覆盖堆数据

我的习惯:每次编译完,我都会在map文件里查看各段的大小。用arm-none-eabi-size命令或者IDE的编译输出,确认.text、.data、.bss的占用。如果发现.data段异常大,八成是某个大数组忘了加const。

避坑指南:链接脚本常见错误

说实话,链接脚本写错了,问题非常隐蔽。程序能编译通过,但运行起来就是不对。我整理了几个高频坑:

  • 段重叠:两个段指定了相同的VMA地址,数据互相覆盖。检查map文件里的地址范围。
  • 对齐问题:ARM Cortex-M要求4字节对齐,中断向量表必须对齐到256字节边界。用. = ALIGN(4)强制对齐。
  • 符号未定义:启动代码引用了_sdata,但链接脚本里没定义。编译能过,链接报错。
  • NOLOAD误用:该初始化的段加了NOLOAD,导致全局变量值随机。只有纯缓冲段才用NOLOAD。

我曾经遇到最诡异的问题:一个产品在低温环境下偶尔死机。查了两个月,最后发现是链接脚本里栈段和堆段挨得太近,堆分配时越界覆盖了栈空间。从那以后,我都在链接脚本里给栈和堆之间留至少256字节的"隔离带"。

嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解VMA和LMA的本质区别,以及启动代码如何配合。你只要亲手改一次链接脚本,把一个大数组从默认RAM挪到自定义段,再验证一下运行结果,就全通了。