裸机环境下的内存布局:链接脚本深度解析
说实话,很多嵌入式工程师写了几年代码,却从来没打开过 .ld 文件。我刚开始做项目时也是这样——反正代码能跑,管它怎么排布的?直到有一次,我在 STM32F103 上做 OTA 升级,发现新固件死活写不进去。折腾了两天,最后发现是链接脚本里 Flash 分区设错了,代码段和数据段重叠了。
嗯,从那以后,我养成了一个习惯:每个新项目,第一件事就是打开链接脚本,把内存布局看清楚。
链接脚本到底在干什么?
链接脚本,说白了就是告诉编译器:你的代码、全局变量、静态变量、堆栈,分别该放在哪里。它是一张「地址地图」。
裸机环境下,没有 MMU,没有虚拟内存。你写的每一行代码,每一个变量,最终都要落在物理地址上。这个地址怎么分配?就是链接脚本说了算。
一个典型的 .ld 文件,核心就三件事:
- 定义内存区域——Flash 多大?RAM 多大?起始地址在哪?
- 定义输出段——.text 放哪?.data 放哪?.bss 放哪?
- 定义符号——堆栈顶在哪?堆起始在哪?
你想想看,如果这些搞错了,程序跑飞是迟早的事。
典型的内存区域定义
以 STM32F407 为例,它的链接脚本开头通常是这样的:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里定义了两种内存:
- Flash:只读+可执行,起始 0x08000000,大小 1MB。代码和只读数据放这里。
- RAM:读写+可执行,起始 0x20000000,大小 128KB。变量和堆栈放这里。
我在项目中遇到过一个问题:某款国产芯片的 RAM 只有 64KB,但链接脚本里写的是 128KB。结果程序在特定条件下访问了超出范围的地址,直接 HardFault。查了两天才发现是脚本参数错了。
代码段、数据段、BSS 段的实际排布
链接脚本里最核心的部分,就是 SECTIONS 块。它定义了各个段怎么排布。我画了一张图,帮你直观理解:
这张图里,我特意把 Flash 和 RAM 并排放置。你注意看:.data 段在 Flash 里有一份镜像(LMA),在 RAM 里有一份运行时副本(VMA)。为什么?因为全局变量的初始值必须保存在非易失的 Flash 里,但运行时 CPU 只能从 RAM 里读写它们。
启动代码会做两件事:
- 把 .data 从 Flash 拷贝到 RAM
- 把 .bss 区域全部清零
这两步如果没做对,你的全局变量初始值就是乱的。我见过一个同事,在移植 FreeRTOS 时忘了在启动文件里调用清零函数,结果任务栈里的随机数据被当成了优先级,系统一启动就崩溃。
堆和栈的位置与大小
堆和栈是 RAM 里最后两个区域。它们的排布方式,直接影响程序稳定性。
| 区域 | 方向 | 典型大小 | 注意事项 |
|---|---|---|---|
| 栈(Stack) | 从高地址向低地址增长 | 1KB ~ 8KB | 局部变量、函数调用、中断嵌套 |
| 堆(Heap) | 从低地址向高地址增长 | 512B ~ 16KB | malloc/free、动态对象 |
这里有个关键点:栈是向下生长的,堆是向上生长的。如果堆和栈之间没有足够的间隙,它们就会撞在一起——这就是经典的「堆栈碰撞」问题。
链接脚本中的关键符号
链接脚本里定义的那些符号,启动代码会用到。我挑几个最重要的:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶 = RAM 最高地址 */
_sheap = _ebss; /* 堆起始 = BSS 结束 */
_eheap = _estack - 0x400; /* 堆结束 = 栈底 - 安全区 */
这些符号在启动文件里会被引用:
ldr r0, =_estack
msr MSP, r0 ; 设置主栈指针
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_la_data
bl memcpy ; 拷贝 .data
ldr r0, =_sbss
ldr r1, =_ebss
bl memset_zero ; 清零 .bss
你看,启动代码其实就是在做「搬家」和「打扫」的工作。没有链接脚本提供的这些符号,启动代码根本不知道从哪里搬、搬多少。
常见坑与避坑指南
这些年我踩过的坑,总结成几条:
- 栈大小设太小——递归调用或者中断嵌套一多,栈就溢出了。我曾经在一个项目中,把栈设成 512 字节,结果一个函数里声明了 128 字节的局部数组,再加三层函数调用,直接爆栈。
- 堆和栈顺序搞反——有些芯片的 RAM 地址是反的,栈应该放在低地址。一定要看芯片手册。
- 忘了对齐——ARM 要求栈指针 8 字节对齐。如果不对齐,浮点运算或者某些库函数会出问题。
- .data 段 LMA 和 VMA 混淆——如果你在链接脚本里把 LMA 和 VMA 写反了,启动代码会从错误的地方拷贝数据。
arm-none-eabi-objdump -h 查看各段的地址和大小。如果 .data 的 LMA 和 VMA 不一样,说明有拷贝操作;如果一样,说明你的数据段直接运行在 Flash 里(某些场景下可以这样优化)。
实际项目中的内存布局调整
不同的项目,内存布局需求不一样。我列几种常见场景:
| 场景 | 调整方式 | 原因 |
|---|---|---|
| Bootloader + App | Flash 分区,App 偏移 0x10000 | Bootloader 和 App 不能重叠 |
| 大量使用 malloc | 增大堆,减小栈 | 避免堆空间不足 |
| 深度函数调用 | 增大栈,减小堆 | 避免栈溢出 |
| RTOS 多任务 | 每个任务独立栈,总栈空间增大 | 任务切换需要保存上下文 |
我个人习惯是:在链接脚本里把堆和栈的大小定义成宏,方便调整。比如:
STACK_SIZE = 0x1000; /* 4KB 栈 */
HEAP_SIZE = 0x0800; /* 2KB 堆 */
这样改起来方便,不用到处翻代码。
嗯,关于裸机下的内存布局,核心就是这些。你只要把链接脚本里的 MEMORY、SECTIONS、符号定义这三块搞明白,再结合启动代码的拷贝和清零逻辑,整个内存排布就清清楚楚了。下次遇到程序跑飞、变量值莫名其妙被改、malloc 返回 NULL 这类问题,先别急着调代码——打开 .map 文件,看看各段地址对不对,往往能省下半天时间。