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 全景图
下面这张图,是我画的内存布局全景。你看一眼,就能明白代码和数据在芯片里是怎么「住」的。
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 如何验证你的内存布局
链接脚本写完了,怎么知道对不对?我一般做三件事:
- 看 .map 文件:编译时加
-Wl,-Map=output.map,然后搜索_sdata、_ebss这些符号,确认地址落在预期的 RAM 范围内。 - 看反汇编:用
objdump -h查看各段的 VMA 和 LMA,确保 .data 的 LMA 在 Flash,VMA 在 RAM。 - 实际跑一下:在启动代码里加个 GPIO 翻转,分别在搬运前、搬运后、清零后翻转一次,用示波器看时序。这招虽然土,但最可靠。
我个人习惯在工程里保留一份「内存布局检查表」,每次改链接脚本后逐项打勾。别嫌麻烦,这玩意儿出一次问题,够你折腾好几天。
小技巧:如果你用 STM32CubeIDE 或者 Keil,可以在调试模式下打开 Memory 窗口,直接看 0x20000000 区域的内容。启动后如果 .data 段的值和 Flash 里的一致,说明搬运成功了。
好了,链接脚本和内存布局就聊到这儿。你只要记住:.text 在 Flash 里待着,.data 从 Flash 搬到 RAM,.bss 在 RAM 里清零。这三句话,能解决你 90% 的内存布局问题。