链接脚本与内存布局:手动分配代码、数据、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),访问速度更快。
怎么做?两步走:
- 在链接脚本里新增一个段
- 在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)。我见过不少新手,改了链接脚本但忘了改启动代码,结果程序跑飞。
典型的启动流程:
- 复位后,从Flash的0x08000000取栈指针
- 跳转到Reset_Handler
- 拷贝.data段:从
_sidata(LMA)到_sdata(VMA),长度_edata - _sdata - 清零.bss段:从
_sbss到_ebss - 调用main()
其中_sidata是.data在Flash中的起始地址。注意,这个符号在链接脚本里通常这样定义:
/* 在.data段之前定义加载地址 */
_sidata = LOADADDR(.data);
我曾经踩过的坑:有一次我修改了链接脚本,把.data段放到了一个新的内存区域,但忘了更新_sidata的赋值。结果启动代码从错误的Flash地址拷贝数据,全局变量全乱了。排查了整整半天才找到问题。所以,每次改链接脚本,一定要检查启动代码里的符号是否匹配。
内存布局的可视化
光说理论不够,我画一张图,让你一眼看明白Flash和RAM里到底怎么分布的。
这张图把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挪到自定义段,再验证一下运行结果,就全通了。