先从一个真实场景开始。我刚学单片机那会儿,拿到一份 STC89C52 的数据手册,翻到内存那一页,上面写着“8KB Flash、256B RAM”。我当时第一反应是:8KB 能干嘛?一首 MP3 都放不下。现在的同学问的问题也差不多:“这芯片内存有 64KB,够不够我跑一个图像识别?”其实这两个问题背后是同一种误解——把单片机里的“内存”当成电脑里那种单一的内存条。
这篇文章就干三件事:把主存(片内 Flash + SRAM)讲透,把外部内存扩展的原理捋清楚,再把地址空间这张“地图”一点点拆开。看完之后,你至少能达成三个目标:能读懂 datasheet 里的 Memory Map 章节,能在选型时大概估算容量够不够,能在程序跑飞、数组越界的时候有个明确的排查方向。文章不挑芯片型号,51、STM32、GD32、ESP32 的思路都一样,适用于所有正在学单片机编程、准备走嵌入式路线的读者。
1. 先拨开一层迷雾:单片机里的“内存”不是一块东西
1.1 Flash、SRAM、寄存器,三个角色完全不同
很多人把单片机的内存理解成电脑里的内存条,这是第一个要纠正的认知。单片机内部其实有至少三类存储设备:
- Flash(闪存):存放编译好的程序代码和只读数据,掉电不丢失,8KB、64KB、512KB 说的都是它。
- SRAM(静态随机存储):存放程序运行时的全局变量、局部变量、栈和堆,掉电就清零,1KB、20KB、520KB 说的是它。
- 寄存器:包括 CPU 内核的通用寄存器和芯片厂商做的特殊功能寄存器(SFR),是 CPU 和存储器、外设之间的“特等座”,读写速度比 SRAM 还快。
这三者的关系,可以类比成一家餐厅:Flash 是仓库,菜谱和原料囤在仓库里;SRAM 是后厨操作台,厨师(CPU)只把马上要用的食材拿到操作台上处理;寄存器是厨师手边那几个调料瓶,拿起来就倒,根本不走路。程序要运行,代码不可能在 Flash 里原地“执行”,而是通过取指(Fetch)一条一条读进来;变量则必须住在 SRAM 里,才能被随心所欲地读写。搞不清这个差异,后面看链接脚本、看启动文件都会一头雾水。
1.2 哈佛结构决定了“程序”和“数据”分家
这里必须提一下架构区别。经典的 51 单片机、AVR、STM32 都采用哈佛结构或改进型哈佛结构,程序总线(I-Bus)和数据总线(D-Bus)是分开的。这意味着 Flash 和 SRAM 各有各的地址空间,说人话就是:代码住一个小区,变量住另一个小区,两个小区门牌号互不干扰。
而电脑的 x86 用的是冯·诺依曼结构,程序和数据共用同一条总线和同一个地址空间,所以你可以把程序当数据读,也能动态修改指令。在单片机上这样做就要小心了——Cortex-M 内核虽然允许通过系统总线统一访问 Flash 和 SRAM,但程序一般直接在 Flash 里执行(XIP,片上执行),变量在 SRAM 里,两者天然分开。这个区别会在后面讲地址空间的时候反复出现,先记住结论:单片机的代码区和数据区是物理隔离的,搞混了就容易烧出“程序根本跑不起来”的怪毛病。
2. 主存逐个拆解:Flash、SRAM、寄存器到底在忙什么
2.1 Flash:程序的家,掉电也不搬走
单片机片内 Flash 基本上都是 NOR Flash 类型,特点是支持随机读取,CPU 可以直接在 Flash 地址上取指执行,这就是所谓 XIP。但它的写操作很麻烦,不能像 SRAM 那样按字节随手写,必须“先擦后写”,而且擦除的最小单位是一个扇区(Sector)或者页(Page),不是一个字节。这个特性决定了我们平时开发的流程:代码烧进去之后就尽量别动,运行时对 Flash 的写入要专门设计搬移和擦写策略,比如做 IAP(在应用编程)或者 OTA 升级时,必须先规划好哪些扇区放 bootloader、哪些放 app、哪些放升级固件。
Flash 有两个参数,新手特别容易忽略。第一个是擦写寿命,一般 1 万次到 10 万次。日常烧录 1 万次听起来很多,但如果把 Flash 当普通存储频繁记录日志、频繁掉电保存参数,寿命很快见底,产品用几个月就出现保存失败的状况。第二个是读速度。Nor Flash 内部有预取缓存,代码顺序执行时速度接近零等待,一旦代码大量使用函数指针、跳转表、复杂的 switch-case,预取就会频繁失效,程序变慢。所以“代码放 Flash 执行和放 RAM 执行谁更快”不绝对,但在复杂算法里把关键函数拷贝到 SRAM 跑,通常时序更稳定,这也是很多电机控制代码的做法。
2.2 SRAM:运行时的工作台,一掉电就清空
SRAM 存的是全局变量、静态变量、局部变量、栈、堆。只要程序在跑,它就在忙。SRAM 的特点是读写速度快、可以按字节随机访问、无需擦除,所以它才是 CPU 真正的“工作台”。
但 SRAM 有个物理缺陷:它靠触发器保持状态,只要电源一断,数据立刻全部消失。所以掉电保存数据、复位后保留参数这种事,必须交给 Flash 或者外部 EEPROM 去做,不能指望 SRAM。这也是为什么很多低功耗芯片文档里专门提“备份寄存器”“低功耗 SRAM”——就是为了在待机模式下维持一小块 RAM 不掉电。设计产品时,凡是需要断电保持的变量,从一开始就要放进掉电保护区或者存 Flash,别等项目做完了再反过来补。
2.3 寄存器:贴着 CPU 的“特等座”
寄存器分两类。一类是内核通用寄存器,比如 Cortex-M 的 R0-R12、SP、LR、PC,数量少、速度最快,用于运算和跳转;另一类是特殊功能寄存器(SFR),配置 GPIO 模式、打开 UART、设置定时器预分频,都是通过读写这些寄存器完成的。值得注意的是,寄存器在地址空间里通常也被“映射”成普通地址,比如 STM32 的 GPIOB->ODR 地址是 0x48000414。从 CPU 视角看,读写寄存器和读写 SRAM 都是访存指令,没有本质区别,这也是后面要讲的“统一编址”的基础。
但从运行时效看,寄存器访问往往没有等待周期,实时性要求高的地方(比如中断里翻转 IO)大家都是直接操作寄存器,而不是绕道 SRAM 变量。很多 HAL 库操作看着方便,内部其实多做了几层判断,在高频中断里会有可感知的延迟,这就是为什么老工程师的代码里总有那么多“凭空出现”的寄存器赋值。
2.4 用一张表看懂主存参数
| 特性 | Flash(片内 NOR) | SRAM | 寄存器 |
|---|---|---|---|
| 掉电数据 | 保留 | 丢失 | 丢失 |
| 写方式 | 先擦后写,按页/扇区 | 任意字节写 | 任意字节写 |
| 访问速度 | 较快,取指可能有等待 | 快 | 最快 |
| 典型用途 | 程序、只读数据、参数备份 | 变量、栈、堆 | 外设控制、内核工作状态 |
判断一个数据该放哪,就一句话:要长期保存、不能丢,放 Flash;要频繁读写、生命周期只在运行期,放 SRAM;要控制硬件动作,找对应的寄存器。这张表可以打印出来贴在工位上,比死记 datasheet 有用得多。
3. 外部内存扩展:什么时候必须上,总线怎么接
3.1 先过一个坎:片内内存真的不够用吗
扩展外部内存之前,先问自己三个问题。第一,是 RAM 不够,还是代码占的 Flash 不够?很多人说“内存不够”,实际是编译出来的固件超过 Flash 容量了,这俩根本不是一回事。第二,是局部变量瞬时栈需求大,还是需要长期保存的全局缓冲区大?前者可以通过减小数组、避免深递归解决,后者才需要考虑外扩。第三,是不是算法本身可以优化?一个 320x240 的 RGB565 图像缓冲是 150KB,但如果只需要局部处理,完全可以用块式搬运把缓冲压到十几 KB。
很多项目的外扩存储需求,都是“拍脑袋”定出来的。我第一年做项目时,老工程师让我先算数据量再决定加不加外部 RAM,我当时嫌麻烦,直接上了一颗大封装芯片,后面布线引脚打架,悔得肠子都青了。抠清楚需求再扩存储,是我职业生涯里最早学到的一课。
3.2 并行外部存储与 FSMC/FMC 总线
如果确认需要外扩大容量 RAM(比如做 GUI 缓冲区、跑摄像头帧缓存),并行接口是最快的方式。STM32F1 系列有 FSMC,F4 系列升级成 FMC,它把外部存储映射到内部地址空间的固定区域,CPU 直接按内存地址访问外部芯片,不需要手动搬数据。接一片 IS62WV51216(512KB SRAM),地址线 A0-A18、数据线 D0-D15 都挂到 FSMC 上,再用片选信号选中 CS,剩下的就是把它当普通内存用。
这个方案的优势是快、开发简单、程序不用大改。缺点是引脚占用非常多,至少 20 根以上。所以在画 PCB 之前必须盘点引脚资源够不够,否则会为了内存牺牲掉两个 UART,得不偿失。另外要注意,外扩 SRAM 的时序不是默认就能跑的,FSMC 里有一套 NOR/SRAM 时序参数,地址建立时间、数据建立时间都要按外部芯片的 datasheet 配置,配快了读不稳定,配慢了性能差。
3.3 SPI 串行存储:牺牲速度换容量
如果只是为了存数据,不要求随机访问,SPI Flash 是性价比之王。W25Q64、W25Q128 这类芯片,64Mbit/128Mbit 容量,价格几块钱,接口只要 4 根线。它和并行 SRAM 的本质区别是:你没法把变量定义在 SPI Flash 里,它只能做“块读写”,要当成文件系统(比如 LittleFS)、存字库、图片、日志、OTA 固件来用。
这里有一个新手最容易犯的错误:想用 SPI Flash “扩展运行内存”。不行。CPU 不能直接执行 SPI Flash 里的代码,也不能直接寻址访问它,你必须先把数据读到 SRAM 里才能用。所以 SPI Flash 解决的是“装得下”的问题,解决不了“跑得快”的问题。一个典型的正确用法:系统启动时把校准参数从 SPI Flash 读进 SRAM 的全局结构体,运行期间只改 SRAM 里的副本,需要保存时再整块写回。
3.4 51 单片机的外部 RAM:16 位地址的精髓
51 单片机片内 SRAM 非常小,STC89C52 只有 256B,增强型一般也就 1KB 左右,所以很早就引入了外部 RAM 扩展机制。思路很经典:P0 口分时复用,先输出低 8 位地址,用 74HC373 锁存器锁住,再由 P2 口输出高 8 位地址,于是形成一个 16 位地址总线,最多可以访问 64KB 外部 RAM。这个模式下的外部 RAM 用 MOVX 指令访问,和内部 RAM 的 MOV 指令在汇编层面就分开了。
对初学者来说,理解 51 外部 RAM 的意义不在于是不是真要去扩展,而在于理解“地址总线、数据总线、控制总线”这三总线模型。P0 口一次只能发 8 位数据,所以地址要分两次发,先发低 8 位再用锁存器锁住。这个“分时复用”的概念,后来读任何芯片手册都会遇到,包括现在很多接口芯片的时序图,本质都是在讲“哪根线先拉高、哪根线后采样”。把 51 这套捋通了,后面看 FSMC、看 I2C、看 SPI,都是一通百通。
4. 地址空间全解析:从编译链接到 CPU 寻址
4.1 地址空间是一张地图,不是真实容量
很多新手的第二个误区是:认为地址空间的大小等于物理存储的大小。其实完全不是。32 位地址空间有 4GB 的寻址范围(0x00000000-0xFFFFFFFF),但 STM32F103C8T6 的物理 Flash 只有 64KB、SRAM 只有 20KB。地址空间是 CPU 能“看到的地图”,地图上的编号只是门牌号,房子才是真正的存储介质。在 STM32 上,不同地址区段被固定分配了不同用途:
- 0x00000000-0x1FFFFFFF:Code 区,用来映射 Flash、System Memory(内置 bootloader)、SRAM 的别名。
- 0x20000000-0x200FFFFF:SRAM 区,变量住这里。
- 0x40000000-0x5FFFFFFF:外设区,所有外设寄存器都在这里。
- 0x60000000-0x9FFFFFFF:外部存储区(FSMC/FMC 映射外部 SRAM、NOR、NAND、LCD)。
以 STM32F103C8T6 为例,Flash 物理地址是 0x08000000,SRAM 是 0x20000000,程序烧进去之后 CPU 从 0x08000000 开始取指,中断向量表也在 Flash 头部。这些数值不需要背,但看见它们不再眼生,是阅读启动文件、链接脚本、bootloader 代码的基本功。你写的第一份链接脚本、调的第一份 .sct 分散加载文件,都是在跟这套地址约定打交道。
4.2 统一编址与独立编址
CPU 访问外设寄存器有两种流派。ARM、51、AVR 单片机的做法是统一编址:寄存器和内存放在同一张地址地图里,对寄存器读写和对内存读写用同一条指令(例如 LDR/STR,或者 MOVX)。而 x86 是独立编址:内存用 MOV 指令,外设寄存器用专用的 IN/OUT 指令,地址空间完全分开。
所以嵌入式里写驱动,本质就是“往特定地址写特定的值”。统一编址有个听起来吓人、理解后非常爽的推论:你可以用指向寄存器的指针直接操作外设,C 语言里*(volatile unsigned int *)0x48000414就是操作某个寄存器,宏封装之后就是 HAL 库里的GPIOB->ODR。反过来,如果你在 PC 上写惯了 Windows 驱动,第一次接触单片机时那种“为什么寄存器像内存一样能用指针访问”的困惑,就是统一编址带来的。
4.3 地址译码与片选:为什么 CPU 知道该找谁
4GB 的地址空间不可能每个地址对应一颗芯片,所以硬件上有一层“地址译码”逻辑。地址译码器根据地址总线的高位判断当前访问落在哪个区域,然后拉低对应设备的片选引脚(CS/CE),同时在数据总线上连接该设备。最经典的入门电路是用 74LS138 三八译码器做片选:两根或三根高位地址线决定 8 个区间,每个区间对应一个外设。
理解片选之后,很多现象就非常合理了。比如你写了一个“看起来合法”但实际没有映射任何设备的地址,CPU 访问时会返回总线错误,Cortex-M3/M4 会触发 HardFault。而有些外设片选错误或映射重叠,就可能出现读出的数据“串台”——读到另一块设备的寄存器值。这些不是玄学,都是译码和片选的锅。排查这类问题,先看访问地址落在哪个区,再看那个区的片选信号是否拉对,基本能定位八成问题。
4.4 链接脚本:把代码和变量摆到正确门牌上
在 PC 上写程序,链接器把代码和变量安排到虚拟地址上,你基本不用管。在单片机上,程序最终住在 Flash 的 0x08000000,变量住在 SRAM 的 0x20000000,这个安排不是编译器自由的,而是由链接脚本显式或隐式决定的。Keil 用分散加载文件 .sct,GCC 用链接脚本 .ld,里面写清楚了哪个段放哪个地址区间。
举个例子,GCC 的 .ld 里常见这么一段:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K }意思就是告诉链接器:Flash 从 0x08000000 开始可用 64KB,RAM 从 0x20000000 开始可用 20KB。程序里的 .text 段放 Flash,.data 段初值放 Flash、运行时拷贝到 RAM,.bss 段直接在 RAM 里清零。如果你改了芯片但没改链接脚本,最常见的结果就是:编译能过,下载也能过,程序跑起来立刻 HardFault,因为代码或变量被放到了不存在的地址上。这也是“为什么我换了颗大容量芯片还是程序崩溃”的常见原因之一。
5. 内存问题排查实战:越界、爆栈、程序超出 Flash
5.1 .map 文件才是内存真相
编译器编译完之后,最权威的内存占用报告不是“编译成功的绿勾”,而是 .map 文件。Keil 编译信息里的 Program Size 行有 Code、RO-data、RW-data、ZI-data 四个数值,含义分别是:
- Code:程序代码占用的 Flash 空间。
- RO-data:只读常量,比如 const 字符串、查表数组,占 Flash。
- RW-data:初始化过的全局变量,初值存 Flash,运行时拷贝到 SRAM。
- ZI-data:零初始化变量以及栈和堆,只占 SRAM,不占 Flash。
所以 Flash 占用量 = Code + RO-data + RW-data,SRAM 占用量 = RW-data + ZI-data。很多人把 Program Size 全部当成 Flash 占用,或者把 ZI-data 当成 Flash 占用,都会得出错误的容量结论。比如 Keil 显示 Code=18000、RO-data=2000、RW-data=500、ZI-data=15000,那 Flash 要占 20.5KB,SRAM 要占 15.5KB,如果芯片是 64KB Flash、20KB SRAM,算下来才准确。
5.2 判断程序有没有超出 Flash(以 STC 为例)
网上经常看到有人问“STC 单片机如何判断程序超出内存”,我用实际经验回答。用 Keil 编译 STC 工程时,如果程序确实超过编译器配置文件里的容量,编译输出区会出现L6220E: Execution region ... size exceeds limit这类错误。但麻烦在于:很多人用的是通用 Device 型号,Keil 并不知道芯片真实容量,于是在 Keil 里编译通过,下载时却卡住。
最稳的判断方法是看生成的 .hex 文件地址范围。用任意文本编辑器打开 .hex,最后一条记录的地址如果接近或者超过芯片 Flash 上限,就说明超了。更直观的做法是用 STC-ISP 下载软件,勾选程序文件后它会显示当前程序占用空间,超出芯片容量会直接提示。我的习惯是:每次烧录前都扫一眼这个数字,虽然 STC 下载软件做得不精致,但它的容量判断比 Keil 可靠得多,因为它按实际选择的芯片型号检查。
5.3 栈溢出:最隐蔽的“跑飞”凶手
变量越界、栈溢出,这类内存问题有个共同表现:程序有时正常、有时跑飞;跑飞位置完全随机;单步 Debug 正常,全速运行就死。原因在于栈和全局变量共享 SRAM,栈向下增长,全局变量向上分配,二者在某个临界点相遇,互相覆盖。
排查思路有两条。第一条,启动文件里给 Stack 和 Heap 分配了固定大小,STM32 的 startup 文件默认是 Stack 0x400、Heap 0x200,如果局部变量多、递归深,马上把 Stack 增大到 0x1000 再试。第二条,用“栈填充标记法”,在初始化时把整块 SRAM 填成 0xAA,跑完功能后检查栈区最近的 0xAA 变化位置,就能估算实际栈峰值。这个方法我在实际项目中用过很多次,比纯理论分析快得多。
注意:如果芯片的启动文件把栈顶
__initial_sp定义在 0x20000000 + SRAM 大小处,而你在链接脚本里分配的栈区并不在内存末尾,那么栈溢出时不会触发硬件异常,只会静默地踩坏其他变量。这类 bug 最需要靠栈填充标记法去盯。
5.4 数组越界:不报错的高级 bug
数组越界最坑的地方在于编译器不报错,运行时不报错,只在某次操作写到关键变量或寄存器相邻地址后才“随机”报错。我踩过一个典型:函数里定义了一个 100 字节的局部数组,循环里写到了第 105 字节,结果把相邻的返回地址低字节覆盖了,函数返回后 PC 跳到乱地址,程序瞬间 HardFault。排查时靠检查循环边界、在 Debug 里查看数组地址和相邻变量地址之间的距离才定位。
预防手段从设计上做:尽量不定义超大局部数组,必要的话放到全局区(ZI 区),因为全局区地址固定,越界后更容易和 .map 文件对比;给关键变量前后加“哨兵值”常量,定期检查哨兵有没有被改,这是嵌入式行业工程上常用的内存完整性检测手段。加哨兵虽然浪费几个字节,但换来的是崩溃时可定位,非常值。
6. 内存规划与选型:给项目配多少内存才算够
6.1 RAM 用量的实用估算公式
选型阶段,我一般按下面的方式粗算 RAM 需求。第一步,把所有全局变量和静态变量的大小加起来,编译后看 .map 里的 RW-data + ZI-data。第二步,估算最深处函数调用时栈的峰值,Cortex-M0/M3 这种 MCU 一般留 4-8KB 比较稳,如果用到 FatFS、TLS、复杂加密库,再往上加。第三步,看堆(Heap),如果用到 malloc,保守估算最大分配总量;如果不用动态内存,直接把堆设成 0,全局静态方式申请内存更可控。
第四步,加外设动态缓冲区,DMA 缓冲、串口 FIFO、USB 描述符都按实际协议需求算,串口 FIFO 通常 1KB 起步。把这四项相加,再留出 20%-30% 的余量,才是你真正该选的 SRAM 容量。Flash 同理,把 Code + RO + RW 初值算出来,加上 bootloader 分区、OTA 双备份(如果需要)、字库图片等资源,乘上 1.3 的系数,再选 Flash。这套粗算方法不精确,但足够在立项阶段避免“芯片选小了”或者“芯片选贵了”两个极端。
6.2 常见入门级单片机内存规格对照
下面这张表是我在教学和项目中常见的芯片配置,可以作为参考:
| 芯片 | Flash | SRAM | 典型应用 |
|---|---|---|---|
| STC89C52 | 8KB | 256B | 教学、简单控制逻辑 |
| STM32F103C8T6 | 64KB | 20KB | 电机控制、简单物联网节点 |
| GD32F103C8T6 | 64KB | 20KB | STM32 的国产替代方案 |
| ESP32 系列 | 4MB(外挂 Flash) | 520KB | Wi-Fi/蓝牙、复杂物联网 |
| STM32H743 | 2MB | 1MB | 高端 GUI、数字信号处理 |
这里有个提醒:入门学习不建议一上来就挑旗舰芯片。内存大意味着引脚多、封装复杂,焊接和调试难度翻倍。把 STM32F103 或 GD32F103 吃透,再用“内存不够”作为选型升级的理由,才是在积累真正的选型能力。反过来,如果只是想要大内存又嫌 STM32 贵,GD32 和国产系列是很好的平替,但移植时要注意外设寄存器偏移和库文件差异,不是所有代码都能无缝拷贝。
6.3 内存不够时的三板斧
遇到 Flash 或 SRAM 不够用,按照性价比从高到低,我有三个常用手段。第一板斧是优化编译选项:Keil 选 -Os(优化尺寸),GCC 用 -Os 并关掉用不到的异常支持;把体积大的日志字符串从普通字符串改成 Flash 地址间接引用,能省一大截 SRAM。第二板斧是代码瘦身:重复代码抽函数、去掉没用到的库、能查表就不实时计算、大数组从普通全局改成按需加载到缓冲。
第三板斧才是外扩存储:SPI Flash 存资源,并行 SRAM 扩展运行内存,或者干脆换更大容量芯片。顺序不能反,因为大多数项目优化完前两步,第三板斧根本用不上。我见过一个项目原本计划加 512KB 外部 SRAM,后来只是把三处重复的 UUID 字符串改成 Flash 常量引用、把两层嵌套的大数组改成分段处理,直接省掉了外扩芯片和 20 根引脚。
7. 调试三年攒下的几条存储心得
最后分享几条我做嵌入式这些年的零散经验,比官方文档好使。
第一,看数据手册先看 Memory Map 和 Pinout,其他章节用到再查。每颗芯片的 datasheet 动辄几百上千页,一页页啃三个月,不如带着问题查十次。第二,所有涉及地址的宏定义,一律加 volatile,并且写成*(volatile unsigned int *)这种形式,否则编译器优化会“好心”帮倒忙,把读寄存器结果缓存住,导致明明外设值变了,程序还读旧值。第三,任何大数组定义,先问自己一句:它能不能是 const 放在 Flash?很多查表、波形表、菜单文本,加上 const 后既省 SRAM,又提升启动速度。
第四,给每个函数的局部变量设上限,局部数组尽量不超过几百字节。超过这个值,优先改全局静态,再配合 .map 文件检查放置位置。第五,如果你的产品要做掉电保护,千万不要只在 RAM 里做状态标志位,掉电瞬间 SRAM 全是不可靠的,必须把关键状态写进 Flash 或者带后备电源的备份寄存器。第六,换芯片型号后第一件事就是检查链接脚本里的存储器范围,八成“换芯片就崩溃”的问题都是链接脚本还在按旧芯片分配地址。
这些经验看着琐碎,但几乎每一个都是从半夜调试的崩溃现场换来的。单片机存储这件事,理论不难,真正的门槛在于你把地址空间、内存分配、芯片物理限制这三条线串起来的那一刻。串起来之后,后面再看 RTOS 的内存管理、bootloader 的 Flash 分区、GUI 的帧缓冲设计,都会轻松很多。