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
}
3.4 为什么 .data 要占两份空间?
这个问题我经常被问到。你看上面的脚本,.data 段同时出现在 Flash 和 RAM 中——这不是浪费吗?
其实不是浪费,而是无奈之举。芯片上电时,RAM 里的内容是随机的。但你的全局变量可能有初始值,比如 int count = 100;。这个 100 必须事先存在 Flash 里,等系统启动后,再由启动代码把它从 Flash 拷贝到 RAM。
所以 .data 段在 Flash 里占一份(烧录时),在 RAM 里占一份(运行时)。这就是所谓的「加载时地址(LMA)」和「运行时地址(VMA)」的区别。
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 本章知识体系
下面这张图,把链接脚本、内存分区、段映射的关系画清楚了:
3.8 实际项目中的注意事项
最后,分享几个我在实际项目中踩过的坑:
- Flash 和 RAM 的地址范围一定要核对芯片手册。 不同型号的芯片,即使是一个系列,Flash 大小和 RAM 起始地址也可能不同。我见过有人把 STM32F103 的链接脚本直接套到 STM32F407 上,结果芯片直接不启动。
- .data 段的 LMA 和 VMA 不要搞反。 如果写成了
> FLASH AT> RAM,链接器会把 .data 的初始值放在 RAM 里——但 RAM 掉电就丢,烧录进去也没用。 - 栈大小要留够,但别浪费。 我一般先给一个保守值(比如 1 KB),然后在调试阶段用
__stack_chk_guard或者填充 0xAA 来监测栈使用峰值,最后再调整。 - 链接脚本里的符号名要和启动代码保持一致。 如果启动代码里用的是
_sdata,链接脚本里也必须是_sdata,大小写都不能错。
PROVIDE(__stack_top = .);,然后在启动代码里直接用这个符号设置栈指针。这样如果以后调整了栈大小,不用改启动代码,只改链接脚本就行。