凌晨两点,产线上报回来一台设备死机。
你抓回设备,连上调试器,看到栈指针 SP 飞到了一个非法地址,PC 寄存器停在一串看不懂的汇编上。打开工程,盯着 MAP 文件发懵——这个符号在哪个段?为什么 RAM 用满了?栈到底从哪开始生长的?
这种时候你用不了任何花哨的架构技巧,能救你的只有一样东西:对编译、链接、内存布局的底层认知。
我做嵌入式这些年,越来越觉得一件事。很多人把"底层"当成可选项,觉得会用 HAL 库、会调 RTOS API 就够了。可真遇到 HardFault、真要抠那几 KB 的 RAM、真要改链接脚本加个自定义段的时候,底层功底就是最后一道防线。今天我把这条线从头到尾捋一遍,争取让你看完之后,再面对 MAP 文件和链接脚本不再发怵。
一、编译链接四部曲:从 .c 到可执行文件
先说最基础的。你敲一行 arm-none-eabi-gcc main.c -o firmware.elf,背后其实跑了四个阶段,不是一个动作。
| 预处理 | gcc -E | .c → .i | 宏展开、头文件包含、条件编译、删注释 |
| 编译 | gcc -S | .i → .s | C 翻译成汇编,含语法分析和优化 |
| 汇编 | gcc -c | .s → .o | 汇编翻译成机器码,输出目标文件 |
| 链接 | gcc -o | .o → elf | 符号解析、段合并、地址重定位 |

为什么要拆这么细?因为每个阶段都能帮你定位一类问题。
宏展开出问题(比如一个宏里多打了个分号),用 gcc -E main.c -o main.i 看预处理后的结果,比你在源码里瞪眼强一百倍。想看编译器把你的 C 优化成了什么鬼样子,gcc -S main.c -o main.s 直接出汇编。我经常用这招确认 volatile 有没有生效:加了 volatile 的变量,汇编里每次都是从内存重新 ldr;没加的会被优化进寄存器,一望便知。
最后那个链接阶段,是嵌入式和 PC 程序分道扬镳的地方。PC 上的链接器默认给你拼一个能在操作系统里跑的程序,操作系统帮你加载。嵌入式没有操作系统帮你加载,你得自己告诉链接器:代码放 Flash 的哪个地址、变量放 RAM 的哪个地址。这就是链接脚本(Linker Script)存在的理由。
二、一个 .o 文件里到底装了什么
汇编阶段吐出来的 .o 叫目标文件,是个 ELF 格式的容器。你不用把 ELF 规范背下来,但得知道它肚子里大概分几块。
最核心的是段(Section)。一个 .o 里常见的段有这么几个:
- .text —— 编译出来的机器码
- .rodata —— 只读常量,比如字符串字面量、const 全局变量
- .data —— 已初始化的全局变量和静态变量,且初值非 0
- .bss —— 未初始化的、或初始化为 0 的全局变量和静态变量
除了段,还有两样东西很关键:符号表和重定位表。
符号表记录"我这个 .o 里定义了哪些符号、引用了哪些外部符号"。比如 main.o 里定义了 main,引用了别处的 printf。重定位表则记着"这些引用的外部符号,等链接的时候得把真实地址填进来"。

这里有个新手容易迷糊的点:链接之前,每个 .o 里的地址都是占位的。main.o 不知道自己将来会被放到 Flash 的 0x08000000 还是别的地方,所以它内部的符号地址一开始都是 0 或者相对偏移。链接器的工作,就是把这些 .o 拼到一起,给每个符号分配最终地址,然后回头把重定位表里那些占位地址全部改成真实地址。
理解了这一步,你才能理解为什么链接脚本能决定一切。因为最终地址就是链接器在这个阶段分配的,而它分配的依据,就是你给它的链接脚本。
三、内存布局全景:Flash 和 RAM 怎么分家
嵌入式芯片的存储就两块:Flash(掉电不丢,只读运行)和 RAM(掉电就没了,可读写)。你的程序得在这两块地方合理安家。

规律其实很简单,一句话:需要掉电保存的放 Flash,运行时要读写的放 RAM。
.text 是代码,掉电不能丢,放 Flash。运行时 CPU 从 Flash 取指令,不需要改它。
.rodata 是只读常量,放 Flash。注意"只读"在这里有两层意思:逻辑上你不该改它,物理上它在 Flash 里你也改不了,写 Flash 要走擦除流程。
.data 是已初始化变量,运行时要读写,必须放 RAM。但它的初值呢?初值不能丢,得存在 Flash 里,上电时再搬到 RAM。这就引出了下一节的重点。
.bss 是初始化为 0 的变量,放 RAM。它有个好处:初值全是 0,所以根本不用在 Flash 里存这些 0,启动时把对应 RAM 区域清零就行。这就是为什么 .bss 不占 Flash 空间。你声明一个 int buf[1000] 和一个 int buf[1000] = {0},Flash 占用几乎一样;但如果你写成 int buf[1000] = {1},那 4000 字节的初值就得老老实实存进 Flash。
RAM 里除了 .data 和 .bss,还有两块:堆和栈。
栈用来保存函数调用现场、局部变量。绝大多数 ARM Cortex-M 上,栈是向下生长的,SP 从高地址往低地址减。堆则相反,向上生长。两者从 RAM 的两端往中间挤,中间那片空闲区就是它们的安全距离。一旦栈和堆撞上了,就是栈溢出,程序行为开始不可预测。
四、.data 段的"两地分居":LMA 与 VMA
这一节是嵌入式和 PC 程序最大的区别,也是很多人卡住的地方。
一个已初始化的全局变量 int count = 100;,它其实有两个地址。
VMA(Virtual Memory Address,运行地址):程序运行时这个变量在 RAM 里的地址。CPU 访问 count 时去的就是这里。
LMA(Load Memory Address,加载地址):这个变量的初值 100 存放在 Flash 里的地址。

为什么要有两个地址?变量运行时在 RAM(要读写),但初值掉电不能丢,只能存 Flash。上电那一刻 RAM 里全是随机值,所以必须有人把初值从 Flash 的 LMA 搬到 RAM 的 VMA。这个"人"就是启动代码。
不理解 LMA/VMA,你就看不懂链接脚本里那个奇怪的 AT> 语法,也理解不了启动代码里为什么要有一段搬运 .data 的循环。这两个东西是一套的,缺一不可。
PC 程序不用操心这个,操作系统的程序加载器(loader)会在每次运行时帮你把 .data 从可执行文件搬到内存。MCU 没有这种外部运行时加载器——加载逻辑得你自己写进固件,就是启动代码里那段搬 .data 的循环。bootloader也不替你干这个,它跳转到你的 Reset_Handler 之后,活还是你自己的。没有外部运行时加载服务,加载逻辑要么你写进启动代码,要么芯片 boot 基础设施替你做(risc-v)。
五、链接脚本:代码最终落在哪,它说了算
链接脚本是纯文本文件,后缀 .ld。它干两件事:声明物理内存区域,安排各段位置。
先看 MEMORY 命令,它声明芯片有哪些物理内存、各多大:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
括号里的 rx、rwx 是属性标志(读/写/执行),主要给链接器做检查用。ORIGIN 是起始地址,LENGTH 是大小。这一段写错,后面全错。
再看 SECTIONS 命令,它安排每个段落到哪个内存区域:
SECTIONS
{
.text : { *(.text*) } > FLASH
.rodata : { *(.rodata*) } > FLASH
.data : {
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT> FLASH
.bss : {
_sbss = .;
*(.bss*) *(COMMON)
_ebss = .;
} > RAM
}
重点看 .data 那行:> RAM AT> FLASH。
> RAM 说的是 VMA,运行时在 RAM。AT> FLASH 说的是 LMA,初值存 Flash。这一个语法就把"两地分居"说清楚了。.text 和 .rodata 只有 > FLASH,因为它们运行时也在 Flash,不需要搬。.bss 只有 > RAM,因为它没有初值要存。
还有那几个下划线开头的符号:_sdata、_edata、_sbss、_ebss。这是链接脚本里用 .(当前地址计数器)算出来的边界符号,分别表示 data 段起始/结束、bss 段起始/结束。启动代码就靠这几个符号知道"从哪搬到哪""从哪清零到哪"。链接脚本和启动代码是配套的,符号名必须对上。
六、启动代码:上电到 main() 之前发生了什么
很多人以为上电就直接跑 main() 了,错。在 main() 之前,有一段启动代码(通常叫 Reset_Handler)悄悄干了三件大事。

第一件,搬运 .data 段。从 Flash 的 LMA 把初值复制到 RAM 的 VMA。没有这一步,你那个 int count = 100 上电后是随机值,不是 100。
第二件,清零 .bss 段。把 bss 区域全部写成 0。C 标准规定未初始化的全局变量初值是 0,但这不是天上掉下来的,是启动代码给你清的。不清零的话,你的 int flag; 上电后是个随机值,程序行为全乱。
第三件,调用 SystemInit() 和 main()。SystemInit 通常配置时钟系统,把 CPU 跑到目标频率,然后才进 main。
这段启动代码必须放在向量表的第一个入口(复位向量),因为芯片上电就是从向量表第一项取栈指针、第二项取复位向量地址,然后跳过去执行。
我见过有人手贱把启动代码里搬 .data 那段循环注释掉,理由是"看起来没用"。结果全局变量初值全错,查了两天才想起来。启动代码里没有一行是多余的,每一行都对应一个 C 语言运行环境的约定。
七、实战:算清楚 Flash 和 RAM 还剩多少
讲了一堆原理,回到工程里最实际的问题:我的固件到底占了多少 Flash、多少 RAM?
用 size 命令一秒出结果:
arm-none-eabi-size firmware.elf
输出大概长这样:
text data bss dec hex filename
45832 1232 8192 55256 d818 firmware.elf

关键是怎么算。两个公式:
Flash 占用 = text + rodata + data。为什么 data 也算进 Flash?因为 data 的初值存在 Flash 里(LMA),得占 Flash 空间。size 命令的 text 列其实把 .text 和 .rodata 合并显示了,所以直接 text + data 就行。上面这个例子,Flash 占 45832 + 1232 = 47064 字节。
RAM 占用 = data + bss。data 运行时在 RAM,bss 也在 RAM。text 和 rodata 不在 RAM,所以不算。这个例子 RAM 占 1232 + 8192 = 9424 字节。拿芯片总容量一减,就知道还剩多少。
想看每个符号具体落在哪、占多大,翻 MAP 文件。MAP 文件是链接器吐出来的"账本",里面每个符号的地址、大小、所属段都列得清清楚楚。排查"这个变量到底在哪""为什么 RAM 溢出了"全靠它。
八、几个我踩过的坑
坑一:栈溢出。 一个函数里开了个大数组当局部变量,比如 char buf[2048],栈直接被撑爆。栈溢出最恶心的是它不一定立刻崩,可能等到后面某次调用才发作,表现就是莫名其妙的 HardFault。调试阶段一定要用 RTOS 的栈水位查询 API,或者手动给栈区填个魔术字(比如 0xAA),跑一圈看被覆盖到哪,就知道栈用了多深。
坑二:.data 没搬。 前面说过,启动代码搬 .data 的循环被删了,全局变量初值全是垃圾。这种 bug 的表现是"程序能跑,但行为随机",特别难查。养成习惯:看到全局变量初值不对,先怀疑启动代码。
坑三:链接脚本地址写错。 把 RAM 的 ORIGIN 写成了别的芯片的地址,烧进去直接跑飞或者 HardFault。换芯片时链接脚本一定要核对,这是最容易遗漏的地方。
坑四:const 没加。 一个大的查找表忘了加 const,结果它被当成 .data 段,初值占 Flash、运行时还占 RAM,双倍消耗。加上 const 后它进 .rodata,只在 Flash,省下一大块 RAM。这种优化零成本,就是加个关键字的事。
写在最后
回头看这条线:编译、汇编、链接、段、内存布局、链接脚本、启动代码。它们不是孤立的考点,而是一条完整的因果链。你写的每一行 C 代码,最终都会经过这条链变成芯片里的字节。
理解了这条链,你才能在 HardFault 时看懂栈回溯,在 RAM 紧张时知道去哪抠字节,在加自定义段时知道链接脚本怎么改。这些能力不会让你写代码更快,但能让你在出问题的时候不至于两眼一黑。
底层功底这东西,平时看着没用,关键时刻就是救命稻草。
有用的话点个在看,让更多嵌入式工程师看到。你有没有遇到过跟内存布局相关的诡异 bug?欢迎在评论区聊聊。
本文从编译链接四部曲讲到 ELF 段、Flash/RAM 内存布局、LMA/VMA 两地分居、链接脚本、启动代码,配真实 size 命令输出与 MAP 文件解读,附 4 个实战踩坑排查思路。
标签: 嵌入式 编译链接 内存布局 链接脚本 ELF MAP文件


