☰
单片机程序在Flash和RAM中如何存储?从段到启动代码全解析
2026/10/21 8:21:27 网站建设 项目流程

领导突然问一句“单片机程序在Flash和RAM里到底怎么存的”,很多人会有一种“感觉自己知道,但真要讲又讲不清楚”的尴尬。代码里明明见过.text、.data、.bss这些名字,可真要画一张图出来,把每个段落在哪、由谁负责搬运、上电之后是怎么一步步跑起来的,往往就卡住了。

这篇文章就以我自己的理解路径来把这件事彻底捋一遍,从段的概念、Flash和RAM的分工,到启动代码、链接脚本和实际排查工具,一条线讲透。读完你至少能在被问到时,随手画得出存储分布图,讲得清楚启动流程,还能对着 MAP 文件排查几个常见坑。

1. 先搞清楚:一个编译完的程序,到底被切成了几块

1.1 五个常见段的角色

编译器把源代码变成目标文件时,并不是一个“整块”的二进制扔给你,而是按属性把内容拆成了好几个区段。最常见的五个段,名字和作用如下:

  • .text:代码段,存放所有函数编译后的机器指令,还包括字符串字面量里那些需要执行的跳转表之类的只读内容。
  • .rodata:只读数据段,存放const修饰的常量、字符串字面量等。严格说有些编译器会把字符串丢在.text里,要看具体实现。
  • .data:已初始化全局变量段。比如全局变量int g_cnt = 100;,这个100的初值需要找个地方存着。
  • .bss:未初始化或显式初始化为 0 的全局变量段,运行时全为 0。
  • .heap和.stack:堆和栈,运行时动态分配和函数调用使用的空间。

这几个段,加上中断向量表、启动代码,基本就是一个嵌入式程序的全貌。

1.2 为什么不能把变量和代码全都塞在 Flash 里

很多人第一反应是——既然 Flash 断电不丢数据,而且现在动辄几百 KB,为什么不把所有东西全放 Flash?

原因是 Flash 本质上是只读的,虽然现代 MCU 支持在应用内编程(IAP),但写入需要擦除扇区,速度慢、次数有限,而且Flash 无法被 CPU 像普通内存那样直接随机读写变量。变量在运行时需要频繁修改,频率可能是每秒几万次甚至更高,让变量直接在 Flash 上操作,会对 Flash 寿命造成毁灭性打击。

所以几乎所有单片机系统的共识是:

  • 固化不变的东西放 Flash(代码、常量、初始值镜像)。
  • 运行时变化的东西放 RAM(变量、栈、堆)。
  • 需要初始化值的变量,在 Flash 里存一份初值“镜像”,上电后由启动代码搬运到 RAM。

这就像搬家:贵重但日常不用的物品装进储藏室(Flash),随身要用的日用品放在背包里(RAM)。背包空间有限,所以只放真正需要频繁取用的东西。

1.3 目标文件里的段和最终烧录的段,不是一回事

这里有一个很多人混淆的概念。编译器产生的“目标文件段”和最终“烧录进 Flash 的镜像”并非一一对应。

比如.data段。在目标文件里,它表示“运行时要放在 RAM 里的数据”。但在最终烧录镜像里,Flash 中存的是这些变量的初始值,不是变量本身。链接器会把这个“初始值镜像”安排在 Flash 的某个区域,同时告诉启动代码:要把这段数据从 Flash 的哪个地址,拷贝到 RAM 的哪个地址。

这意味着,Flash 的布局和 RAM 的布局是两个独立的空间视图,由链接脚本把它们对应起来。理解这一点,后面看 MAP 文件才不会晕。

2. 存储映射:Flash 到底放什么,RAM 又放什么

2.1 典型 MCU 存储布局

拿一块常见的 Cortex-M 内核 MCU 举例,假设它内部有 512 KB Flash 和 128 KB RAM,一个典型的内存分布如下:

区域地址范围存放内容
Flash 起始0x0800_0000中断向量表
Flash 代码区0x0800_0040 起.text 代码段、.rodata 常量
Flash 数据镜像区高地址区域.data 初始值镜像,可能还有 EEPROM 模拟区
RAM 起始0x2000_0000.data 运行时副本、.bss 段
RAM 中段0x2000_0000 + data + bss堆(heap)
RAM 末尾0x2000_1FFFC 附近栈(stack),通常从 RAM 最高地址向下生长
系统控制块0xE000_ED00内核心外设寄存器(不属于用户 RAM)

这张表和具体 MCU 型号有关,但思路是一致的。需要特别注意的是,中断向量表的起始地址在 Cortex-M 里必须是 Flash 的起始地址(除非配置了 BOOT 引脚或 Remap),因为芯片上电后硬件会自动从 0x0000_0000 或 0x0800_0000 读取初始栈指针和复位向量。

2.2 为什么栈放在 RAM 最高地址

Cortex-M 内核的栈是向下生长的,也就是压栈时 SP 寄存器递减。把栈顶初始化到 RAM 的最高地址,可以最大化利用 RAM 空间,避免栈和堆之间的碎片化冲突。堆向上生长,栈向下生长,两者在中间相遇时才真正发生内存不足。

一个常被忽略的点是:栈顶初始值不是链接器随便定的,而是在链接脚本里通过__initial_sp或类似符号指定,启动代码在向量表第一个字写入这个值。如果这个值写错,芯片上电后第一个PUSH指令就会把数据写到非法地址,直接进 HardFault。

2.3 只读数据和常量的去向

字符串字面量和const数组,一般会被编译器放进.rodata段。这个段在最终镜像里位于 Flash,运行时 CPU 也能正常读取,因为 Flash 是映射在统一寻址空间里的,读 Flash 和读 RAM 对 CPU 来说都是合法的总线访问。

有人会问,既然const变量只是“读”,为什么定义一个大数组做查表时,有时候 RAM 占用率还是上去了?两个常见原因:

  • 编译器把某些大的const数组从 Flash 拷贝到了 RAM,通常是出于访问性能考虑,或者代码里有非 const 指针指向了它。
  • 你用的链接脚本把.rodata段错误地放到了 RAM 区域,这种事情在移植工程时最容易发生。

排查方式很简单,看 MAP 文件里.rodata的地址落在哪个区间。如果落在0x2000_0000开始的地址段,那说明它确实被安排在 RAM 了。

3. 从复位到 main:启动代码到底做了什么

3.1 上电瞬间的硬件行为,不是从 main 开始的

单片机复位后,硬件做的第一件事是:

  1. 从向量表偏移 0 处加载初始栈指针__initial_sp,写入 SP 寄存器。
  2. 从向量表偏移 4 处加载复位向量Reset_Handler地址,写入 PC 寄存器。
  3. 跳转到Reset_Handler执行。

这一步由芯片硬件完成,不依赖任何软件代码。向量表的布局由链接脚本保证,如果你修改了 Flash 起始地址,却忘了同步修改链接脚本里的向量表位置,整个程序连第一步都走不过去。

3.2 Reset_Handler 里的三段关键操作

Reset_Handler通常是启动文件startup_xxx.s中的一小段汇编代码。它做的事情可以分成三步:

第一步,调用SystemInit函数。这个函数负责配置系统时钟、Flash 等待周期、电源管理等。时钟不配置好,CPU 可能运行在内部低速 RC 振荡器上,外设频率全乱套。

第二步,搬运数据。

; 伪代码逻辑 LDR r0, =__etext ; Flash 中 .data 镜像的起始地址(Load 地址) LDR r1, =_sdata ; RAM 中 .data 运行时的起始地址 LDR r2, =_edata ; RAM 中 .data 运行时的结束地址 Loop: CMP r1, r2 BCC CopyLoop ; 如果 r1 < r2 则继续复制 LDRB r3, [r0], #1 ; 从 Flash 读一个字节 STRB r3, [r1], #1 ; 写入 RAM B Loop CopyLoop: ; 继续...

第三步,清零.bss段。

extern uint32_t _sbss, _ebss; uint32_t *dest = &_sbss; while (dest < &_ebss) { *dest++ = 0; }

这三步完成之后,才会调用__libc_init_array(C++ 全局构造函数)和main。

3.3 一个微妙但重要的点:.data 镜像位置由链接脚本决定

在上面的汇编里,有一个关键符号__etext。它代表.data初始值镜像在 Flash 中的结束地址。链接脚本会生成这个符号,它的值不是随意设定的,而是根据 Flash 区域中前面代码和数据占用的长度自动计算出来的。

所以,启动代码能正确搬运数据的前提是链接脚本正确。你可以把所有段都写进链接脚本,但符号名必须与启动文件里的引用对应。不同厂商的启动文件,符号命名习惯不一样,有的用__data_load__、__data_start__,有的用_sdata、_edata,移植时稍不留神就会不匹配。常见的症状是:程序能编译过,但一运行,全局变量初始值全是乱的,像“上一次运行的残留数据”,排查起来非常隐蔽。

4. 亲手验证一把:用 MAP 文件、链接脚本和调试器看清真相

4.1 链接脚本里的关键语句

以常见 GCC 工具链的链接脚本为例,核心段落长这样:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) __etext = .; } > FLASH .data : { _sdata = .; *(.data*) _edata = .; } > RAM AT> FLASH .bss : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > RAM }

注意.data段那一行的写法:> RAM AT> FLASH。意思是运行地址在 RAM,加载地址在 Flash。这就是“初始值镜像”的链接器表达方式。__etext定义在.text段的末尾,恰好就是.data镜像在 Flash 中的起始位置。

4.2 用 MAP 文件核对段地址

编译生成.map文件后,重点搜索这几个值:

__etext 0x0801a3b4 _sdata 0x20000000 _edata 0x20000314 _sbss 0x20000314 _ebss 0x20001820 __initial_sp 0x20020000

从这些数字可以立刻还原出全貌:

  • .data段大小 =0x20000314 - 0x20000000= 0x314 = 788 字节。这部分在 Flash 中占用0x0801a3b4附近的空间。
  • .bss段大小 =0x20001820 - 0x20000314= 0x150C = 5388 字节。
  • 栈顶在0x20020000,即 RAM 最高地址。

如果发现_edata和_sbss的值不一致,说明.data和.bss之间存在空洞,通常是段对齐填充导致的,正常现象。

4.3 在调试器中直接观察搬运过程

如果你在调试器里把断点设在Reset_Handler的第一行,然后单步执行,真实观察 Flash 地址和 RAM 地址的变化,会比看任何文档都直观。

我自己实测过一个 1000 字节的.data段,在 72 MHz 主频下,逐字节拷贝大约需要几十微秒,这个时间对大多数应用无感,但如果你的.data段有几十 KB,启动时间就变成毫秒级了。某些对快速启动有要求的设备(比如需要在上电后几毫秒内响应外部信号的场景),就得考虑压缩.data段大小,或者改用“按需初始化”的方式。

5. 常见问题与排查技巧实录

5.1 全局变量初始值全乱,但编译无报错

症状:程序跑起来,某个全局变量初始值不是代码里写的那个值,而且每次上电还不一样。

排查方向:启动代码和数据搬运环节。第一件事,确认链接脚本里的 SECTIONS 区域与启动文件里引用的符号是否一致。我遇到过把某厂商的 startup 文件换成另一个厂商的,符号名不匹配,链接器没有报错,因为它把__data_start解析为了 0,启动代码从 Flash 地址 0 开始拷贝数据,而下发的数据也不是真正的镜像,最终变量值完全随机。

解决思路:不要混用不同工具链版本的启动文件和链接脚本。换启动文件时,检查符号表。

5.2 程序能跑,但一 debug 发现 const 数组被放到了 RAM

现象:用const定义了一个 16 KB 的查表数组,MAP 文件里它的地址落在 RAM 区间,RAM 空间被浪费了 16 KB。

典型原因:某处代码用非 const 指针操作了这个数组,或者某些编译器优化选项会把只读数据搬进 RAM 以加速访问。

处理办法:MAP 文件里搜数组名,看它所在的段名。如果是.data而不是.rodata,说明有非 const 引用。检查源码中是否对该数组做了强制类型转换,比如(uint8_t *)table。另外,GCC 有一个编译选项-fno-jump-tables等,可能影响段放置,但最常见的还是源码层面的指针类型问题。

5.3 栈溢出,却不知道栈到哪里了

症状:程序运行不稳定,时而死机,时而函数返回地址被篡改。

嵌入式里栈溢出的排查向来麻烦。我的建议是:

  • 在链接脚本里给栈区域预留一个专门的段,比如.stack,并在该段末尾放一个“哨兵”值,如0xDEADBEEF。
  • 定时检查哨兵值是否被覆盖。
  • 或者使用调试器的“硬件断点”功能,在 RAM 的某个临界地址设置访问断点,一旦 SP 越过该地址就触发停止。

很多 IDE 自带栈使用率统计,但需要通过调试器采集数据,覆盖率有限。最笨但最有效的办法是:编译后在 MAP 文件里看_estack的值,然后在程序里周期性地读 SP 寄存器的值,两者相减,就是当前栈深度的近似值。

5.4 .data 段太大,导致启动时间超标

如果你把一个 100 KB 的初始化数组定义为非 const 全局变量,那么每次上电,启动代码都要从 Flash 把 100 KB 数据搬到 RAM,在 72 MHz 主频下可能耗费几百微秒到几毫秒不等。

解决思路:

  • 能定义为const的尽量定义为const,让数据留在 Flash。
  • 如果数据内容固定,直接存 Flash 并用指针访问。
  • 如果必须放 RAM,考虑压缩后存储,运行时解压。

5.5 MAP 文件里看不到某个段的身影

比如你定义了一个大数组,但 MAP 文件里没有任何段包含它。很可能是编译优化把它优化掉了,因为代码里没有任何地方引用它,或者引用的部分在编译期就求值了。

这不是存储布局问题,但会让你误以为 Flash 或 RAM 的某个地址被占用了。排查方法:把该变量的地址打印出来,或者用volatile修饰,强制编译器保留。

5.6 Flash 和 RAM 地址交叉导致的 HardFault

有些工程师在修改链接脚本时,把.data的 VMA(运行地址)写进了 Flash 区域,把.bss写进了外设寄存器区,程序一运行直接进 HardFault。这类错误通常可以从 MAP 文件里一眼看出:段的地址区间不在预期范围内。

判断标准很简单:Flash 的合法地址区间由硬件决定,RAM 同理,超出就是配置错误。

6. 结尾

把我自己的理解路径做一个收束。最核心的一句话:程序在编译后被拆分成多个段,Flash 存“不改的东西”和“初始值镜像”,RAM 存“运行时要改的东西”,启动代码负责在上电后把镜像拷到 RAM、把 BSS 清零,然后才进入我们熟悉的main。这套机制在所有主流 MCU 上都大同小异,区别主要在链接脚本的写法、段符号命名和启动文件的实现细节。

我个人实际操作中的体会是,单纯背概念记不牢,最好的方式是把一个最小工程编译出来,然后对着 MAP 文件把每个段的地址手动画一遍。画完一次,你对“程序是怎么跑起来的”这句话的理解会彻底不一样。遇到段搬移问题、启动异常问题时,排查速度也会快很多。

最后再分享一个小技巧:很多嵌入式开发环境支持在编译后自动生成.map文件和.lst文件,在.lst文件里你可以看到每条 C 语句对应的汇编指令和地址。如果想彻底搞懂启动代码到底做了什么,试着反汇编一下Reset_Handler,对照着链接脚本读一遍,收获会比看十篇博客都大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询