裸机环境下的内存布局:链接脚本深度解析

说实话,很多嵌入式工程师写了几年代码,却从来没打开过 .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。查了两天才发现是脚本参数错了。

注意:LENGTH 参数必须和芯片手册一致。多写一个字,程序就可能踩到未映射的地址空间。

代码段、数据段、BSS 段的实际排布

链接脚本里最核心的部分,就是 SECTIONS 块。它定义了各个段怎么排布。我画了一张图,帮你直观理解:

裸机内存布局全景图 Flash (0x08000000) .isr_vector 中断向量表 .text 代码段(函数指令) .rodata(只读数据) .ARM.exidx(异常表) .data (LMA) 数据段初始值(镜像) 启动时拷贝 RAM (0x20000000) .data (VMA) 已初始化全局变量 .bss 未初始化/零初始化变量 .heap 堆(动态分配) .stack 栈(局部变量/函数调用) 关键符号 _sdata / _edata:数据段起止 _sbss / _ebss:BSS段起止 _estack:栈顶(SP初始值)

这张图里,我特意把 Flash 和 RAM 并排放置。你注意看:.data 段在 Flash 里有一份镜像(LMA),在 RAM 里有一份运行时副本(VMA)。为什么?因为全局变量的初始值必须保存在非易失的 Flash 里,但运行时 CPU 只能从 RAM 里读写它们。

启动代码会做两件事:

  1. 把 .data 从 Flash 拷贝到 RAM
  2. 把 .bss 区域全部清零

这两步如果没做对,你的全局变量初始值就是乱的。我见过一个同事,在移植 FreeRTOS 时忘了在启动文件里调用清零函数,结果任务栈里的随机数据被当成了优先级,系统一启动就崩溃。

堆和栈的位置与大小

堆和栈是 RAM 里最后两个区域。它们的排布方式,直接影响程序稳定性。

区域 方向 典型大小 注意事项
栈(Stack) 从高地址向低地址增长 1KB ~ 8KB 局部变量、函数调用、中断嵌套
堆(Heap) 从低地址向高地址增长 512B ~ 16KB malloc/free、动态对象

这里有个关键点:栈是向下生长的,堆是向上生长的。如果堆和栈之间没有足够的间隙,它们就会撞在一起——这就是经典的「堆栈碰撞」问题。

我的经验:在链接脚本里,我会在堆和栈之间预留至少 256 字节的「安全区」。然后在启动代码里,用一段循环把安全区填上 0xAA。程序运行一段时间后,检查这些 0xAA 有没有被改写——如果有,说明堆栈已经碰撞了。

链接脚本中的关键符号

链接脚本里定义的那些符号,启动代码会用到。我挑几个最重要的:

_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 文件,看看各段地址对不对,往往能省下半天时间。