调试嵌入式程序的时候,经常有人问我:“STM32 的 RAM 是不是就是电脑内存条那种东西?不够用能不能像台式机一样插一个?”这个问题看似基础,但如果没把 RAM 的硬件结构、内存分区、访问总线这些东西理顺,后面遇到 HardFault、程序跑飞、栈溢出,很容易一头雾水。我写这篇东西,就是想从硬件层面把 STM32 RAM 这件事彻底拆开:它到底存在芯片里哪个位置,怎么被 CPU 和 DMA 访问,为什么和 PC 内存条完全是两码事,以及怎么在工程里把有限的 RAM 空间用到极致。适合刚入门、准备拿 STM32 做项目,或者已经被内存优化折磨过的朋友参考。
1. 先别急着“加内存条”:STM32 的 RAM 到底是什么
1.1 两个核心区别:DRAM 与 SRAM
PC 里的内存条用的是 DRAM,也就是动态随机存储器。动态的意思是它靠电容上的电荷保存数据,电容会漏电,所以需要每隔一段时间刷新一次,把电荷重新补满,否则数据就丢了。这个刷新操作是硬件自动做的,但本质上是持续耗电、持续工作的。
STM32 内部集成的是 SRAM,静态随机存储器。静态意味着它不需要刷新,只要供电不中断,数据就可以一直保持,读写速度也更快。代价是 SRAM 的单元结构比 DRAM 复杂,每个 bit 往往需要 6 个晶体管,而 DRAM 一个 bit 只要 1 个晶体管加 1 个电容。这就是为什么 SRAM 容量做不大、成本做不低,单片机上给几十 KB、几百 KB 已经算不错了,电脑内存条动辄 8GB、16GB,在物理原理上就不是一个量级的东西。
1.2 编址方式:统一编址与无 MMU
PC 上 CPU 访问内存,中间要经过内存控制器,而且现代 PC 操作系统开启了虚拟内存,进程看到的是虚拟地址,真正落到物理内存是 MMU(内存管理单元)在管。STM32 这类 Cortex-M 内核芯片绝大多数没有 MMU,也没有虚拟内存的概念,所有地址都是纯物理地址。
Cortex-M 内核采用的是统一编址的存储映射方式,4GB 的地址空间被设计成固定的功能分区。你写 C 代码里一个指针指向 0x20000000,那它就是访问 SRAM;指向 0x40000000,访问的就是外设寄存器。CPU 拿到地址直接走总线去访问,没有中间层。这种设计对嵌入式非常合适,因为你要的就是确定性:这个地址能访问到什么,时序如何,用起来简单直接。
1.3 为什么单片机 RAM 普遍那么小
很多人觉得 STM32 RAM 太小,其实要结合芯片定位和成本看。STM32 是面向控制领域的,大部分应用不需要跑到 GB 级内存,用 64KB RAM 配合 DMA、中断,做电机控制、传感器采集、物联网上报完全够用。把 RAM 做大,芯片面积、功耗、成本都会明显上涨,对于大批量产品来说非常不划算。
所以你在选型时就要有“RAM 是稀缺资源”的觉悟。很多项目翻车不是因为 Flash 不够,而是 RAM 被无意中吃光了。尤其用了大数组、缓冲池、RTOS 任务栈,一下就能吃掉十几 KB。想省事只有两个方向:一是精打细算每一种 RAM 的用法,二是遇到大缓存需求就选带 FMC 外部存储接口的型号,外挂 SRAM 或者直接上串行 PSRAM,但那已经是另一个话题了。
2. STM32 RAM 硬件结构:总线、存储映射与系列差异
2.1 一张地址表看懂存储映射
Cortex-M 内核定义了 4GB 地址空间,半导体厂商在指定位置放自己的外设和存储器。以最常见的 STM32F103ZET6 为例,核心地址分区是:
| 地址范围 | 用途 | 大小 |
|---|---|---|
| 0x00000000 - 0x0000FFFF | 别名区/启动区映射 | 64KB |
| 0x08000000 - 0x0807FFFF | 主 Flash | 512KB |
| 0x1FFFF000 - 0x1FFFF7FF | 系统存储器(Bootloader) | 2KB |
| 0x20000000 - 0x2000FFFF | SRAM | 64KB |
| 0x40000000 - 0x5FFFFFFF | 外设寄存器区 | 512MB |
| 0xE0000000 - 0xFFFFFFFF | Cortex-M 内核私有外设 | 512MB |
注意 SRAM 基地址是 0x20000000,这是所有主流 STM32 的默认安排。启动时 BOOT0 和 BOOT1 引脚决定从哪里取指,如果从 SRAM 启动,那 0x20000000 这个区域会被映射到启动区,CPU 直接从 RAM 里取第一条指令。这个功能调试时偶有用到,但实际项目基本都用 Flash 启动。
F4 系列在 0x20000000 之外多了一个 0x10000000 起始的 CCM RAM,H7 系列更复杂,有 DTCM、ITCM、AXI SRAM、多个 SRAM 块,后面我会专门讲。
2.2 总线矩阵:谁在访问 RAM
STM32 内核对 RAM 的访问不是“一根线直连”,而是经过总线矩阵调度的。Cortex-M3/M4 内核有指令总线(I-Bus)、数据总线(D-Bus)、系统总线(S-Bus),DMA 控制器也有自己的总线。这些总线通过一个 AHB 总线矩阵连接 Flash、SRAM、外设等从设备。
这意味着什么?CPU 取指令走 I-Bus,读写数据走 D-Bus,DMA 搬运走 DMA 总线,从硬件层面看这些通道可以并行工作。所以你在用 DMA 搬运 ADC 数据的时候,CPU 同时去 Flash 取指令、去 SRAM 读写变量,只要路径不冲突,性能就不会互相拖死。这也是嵌入式系统能实现“高实时性”的硬件基础。
不过并行不是无限并行。如果 CPU 的 D-Bus 和一个 DMA 通道同时访问 SRAM 的同一个 Bank,总线矩阵就会做仲裁,某一方要等待。虽然这种等待通常是几个周期的事,但高负载下确实会影响实时性。工程上的对策是把 DMA 缓冲区放在一个 SRAM 块,把 CPU 高频访问的变量放另一个 SRAM 块,这在 H7 这种多块 SRAM 的芯片上非常有用。
2.3 F1、F4、H7 RAM 资源差异
不同系列的 RAM 布局差别非常大。很多人以为“STM32 内存都是 0x20000000 开始”,这在 F1 上没错,但到了 F4、H7 就不完全对了。
| 系列 | 典型型号 | RAM 资源 | 特点 |
|---|---|---|---|
| F1 | F103ZET6 | 64KB SRAM | 单一 SRAM 块,地址 0x20000000,简单直接 |
| F4 | F407ZET6 | 128KB SRAM(SRAM1 112KB + SRAM2 16KB)+ 64KB CCM | CCM 在 0x10000000,DMA 无法访问 |
| H7 | H743 | DTCM 128KB + ITCM 64KB + AXI SRAM 512KB + SRAM1/2/3/4 等 | 多块 RAM 分散在不同地址,总线配置复杂 |
F1 的 RAM 就是个单块连续地址,编译器把所有变量排进 0x20000000 到 0x2000FFFF 就行,不用关心哪块是哪块。F4 的 CCM RAM 是个坑,它虽然离 CPU 更近、访问速度快,但是挂在 D-Bus 上,DMA 控制器没法访问它。你把 DMA 缓冲区放到 CCM RAM,数据根本搬不动,这是很多新手踩过的最典型问题。
H7 的内存系统最复杂,Cortex-M7 有 TCM 接口、AXI 接口、AHB 接口。DTCM 和 ITCM 是紧耦合内存,CPU 访问零等待,但 DMA 访问不到;AXI SRAM 才是主内存区域。用 H7 时,链接脚本如果没配好,内存分布乱七八糟,性能也发挥不出来。选型阶段就要确认好 RAM 布局,不能拿着 F1 的思路去套 H7。
3. 程序眼中的 RAM 分区:堆、栈、数据段的运行时布局
3.1 编译产物里 RW 和 ZI 去哪了
在 Keil 的 Build Output 窗口里,每次编译会显示 Code、RO-data、RW-data、ZI-data 几项,很多人看不懂。我用最简单的说法解释:
- Code:程序指令,烧录到 Flash。
- RO-data:const 常量、字符串字面量,也在 Flash。
- RW-data:有初始值的全局变量和静态变量,初值存 Flash,上电后由启动代码复制到 RAM。
- ZI-data:没有初始值或者初始化为 0 的全局变量、静态变量,上电后启动代码直接在 RAM 里清零。
所以你评估 Flash 占用,要看 Code + RO-data + RW-data;评估 RAM 占用,要看 RW-data + ZI-data。很多时候你觉得“我这个程序很小”,实际一查 ZI-data 里躺着几个大数组,RAM 直接就爆了。
3.2 堆栈到底怎么分配
启动文件 startup_stm32f103xe.s 开头有 Stack_Size 和 Heap_Size 两个定义:
Stack_Size EQU 0x400 Heap_Size EQU 0x200Stack_Size 是栈大小,默认一般是 1KB。程序里的局部变量、函数调用返回地址、中断现场都压栈,1KB 对复杂中断嵌套确实不太够。Heap_Size 是堆大小,给 malloc/free 用的,如果你工程里从头到尾不用 malloc,堆可以设为 0,省下的 RAM 全部留给栈和全局变量。
栈的生长方向是向下的,从高地址往低地址长;堆从低地址往高地址长。一旦栈长到和堆碰头,程序基本就记了,表现是莫名其妙跑飞、HardFault、函数返回地址被改得乱七八糟。我实际做项目时,栈大小宁多勿少,至少给 2KB 到 4KB,如果用了 RTOS,每个任务还要单独分配任务栈,那个耗起来更快。
3.3 CCM、TCM、备份 SRAM 这些特殊区域到底怎么用
F4 的 CCM RAM 是一块 CPU 私有内存,在链接脚本里需要单独划分,Keil 下用 scatter 文件指定地址 0x10000000。它速度比普通 SRAM 快,适合放中断服务程序里高频访问的变量、临界区数据。但是 DMA 访问不到,这是硬限制,不能指望绕过。
H7 里 DTCM 也是同样逻辑,适合放栈和热点变量,ITCM 一般放代码,让 CPU 以零等待取指。不过我在实际项目里很少把代码放 ITCM,因为代码大部分时间在 Flash 里跑也不算慢,配合 ART 加速器性能已经够了。
备份 SRAM 在 STM32 的 VBAT 供电域里,主电源掉电后由纽扣电池继续供电,数据可以保持。比如 RTC 闹钟、掉电标志、设备校准数据,放备份 SRAM 比放外挂 EEPROM 快得多,又不占用普通 RAM。要注意的是访问备份 SRAM 之前一般要先把 PWR 时钟和备份域访问使能打开,否则写不进去。
4. RAM 空间优化实操:从看报告到动手省内存
4.1 先学会读内存占用报告
拿到一个工程,第一步是打开 Keil 编译,看 Build Output 里的 Total RW Size、Total ZI Size 这两行,对应你 RAM 的总需求。然后打开 .map 文件,搜索 Image Symbol Table,能看到每个全局符号分配的地址和大小。这个方法能快速找出“RAM 大头”到底是谁。
举个例子,我曾处理过一个项目,编译出来 ZI-data 有 46KB,但芯片只有 48KB RAM。跑起来动不动 HardFault。一查 map 文件,发现是两个 8KB 的串口 FIFO 缓冲、一个 16KB 的 LCD 显存数组、一个 8KB 的协议解析缓冲,全堆在全局区。这种问题不用改算法,把大缓冲改成按需动态申请,或者换成 DMA 半满中断的环形缓冲,RAM 瞬间就降下来了。
4.2 四个实用性极高的省 RAM 手段
我常跟朋友说,省 RAM 不是靠玄学,靠的是取舍。分享几个我实测有效的招数。
第一,能放 Flash 的别放 RAM。查表法需要的正弦表、CRC 表、字库数据,用 const 修饰,直接进 RO-data,跑在 Flash 里一点问题没有。不要图省事在运行时再计算生成。
第二,大数组按需分配,不要一把梭。很多人习惯在文件顶部定义uint8_t buffer[4096];,结果这个 buffer 只在某个初始化阶段用一次,却整整占着 4KB。改成局部数组或者用完后覆盖,影响不大。
第三,RTOS 任务栈按实际深度裁剪。你用 FreeRTOS 默认创建任务时栈给 128 words,如果任务里嵌套调用很多,可能不足;但如果给得太大,每个任务多占 1KB、10 个任务就是 10KB。建议每个任务里面放一个水位检测钩子,跑一轮看最大栈使用深度,再调整。
第四,合理使用位域和变量宽度。能 uint8_t 别用 uint32_t,能位标志的不要整个 int。一个结构体里放了十几个 int 型状态量,改造成位域后少用几十字节,虽然不多,但积少成多,尤其在协议解析和缓存管理场合。
4.3 DMA 缓冲区与内存对齐的坑
DMA 传输对内存地址对齐有要求,很多型号要求缓冲区首地址 4 字节对齐,部分以太网 DMA 甚至要求 32 字节对齐。Cortex-M 内核如果发生非对齐访问,某些场合直接 HardFault。
你定义 DMA 缓冲区时,最稳妥的方式是加对齐修饰:
__attribute__((aligned(4))) uint8_t dma_rx_buf[1024];或者用编译器关键字。还有一个我踩过的坑:如果你把 DMA 缓冲区定义成结构体成员,编译器可能在结构体中插入填充字节,你以为是连续 1KB,实际 DMA 写入时地址错位。解决办法是单独定义缓冲区变量,或者用__attribute__((packed)),但不能滥用,packed 会影响访问性能。
5. 常见问题与排查技巧实录
5.1 程序跑飞、HardFault 到底是不是 RAM 问题
程序跑飞的原因有很多:野指针、数组越界、栈溢出、外设配置错误导致总线错误、时钟配置异常、看门狗复位等。RAM 相关问题在其中的占比非常高,尤其是数组越界,悄悄改掉下一个变量的值,程序行为变得诡异。
排查思路我一般按顺序来:先看 map 文件,确认几个关键全局变量在地址上是否相邻,越界会不会互相覆盖;然后用调试器在 HardFault_Handler 里打断点,查看 LR、PC、堆栈内容,看最后一次调用是在哪个函数;再不行,用内存断点监听特定地址,看谁在非法写。这招特别有效,能找到运行时才暴露的野指针。
5.2 栈溢出、内存越界的定位思路
栈溢出是最难定位的一类,因为错误现场往往在几万次调用之后才出现。我常用的办法是在启动文件里给栈区域填充固定魔数,比如 0xCC,跑一段时间后用调试器检查栈尾部魔数是否被破坏,一破坏就知道栈顶已经踩到下面了。
另外一个定位方式是利用 MPU,Cortex-M 的存储保护单元可以设置 RAM 区域的访问权限,把栈底设为不可写,一旦越界立刻触发 MemManage Fault,直接在调试器里停下,比花几天猜“为什么这边明明没写,变量却变了”强太多。
5.3 外部 SRAM 扩展:什么时候才需要
当你把内部 RAM 优化到极致还是不够用,才考虑外扩。STM32 的 FMC(Flexible Memory Controller)可以挂外部 SRAM,常见型号如 IS62WV51216,容量 512KB。这在跑 GUI、做音频缓冲的时候特别有用。
用 FMC 外扩 SRAM 有几个要点:一是地址线和数据线连接要严格对应,二是时序要按芯片手册配好,FMC 的地址建立时间、数据建立时间设太短,跑起来会随机出错,设太长又浪费性能。另外外部 SRAM 的访问速度远低于内部 SRAM,高频中断里不要直接读写外部 RAM,可以把数据暂存内部,批量搬运再写出去。
我个人的体会是,外扩 RAM 是“最后手段”,不是第一选择。很多项目第一版觉得内存不够,仔细优化以后发现其实够用;优化完还不够,才是真不够。这时候选带 FMC 的型号,或者直接选 RAM 更大的系列,比如换到 STM32H7,往往比外挂芯片省事得多。
最后再分享一个小技巧:如果你在工程里同时使用了 CC RAM 区、DMA 缓冲区、RTOS,一定要在链接脚本里明确每个区域的用途,并且给起始地址留出足够间隔。我见过有人把数组和栈首尾相连,结果栈一涨就把数组踩了,查了整整两天。真正的稳定性,是在硬件结构和内存分区上都留足安全冗余,而不是把每一字节都压到极限。希望这篇能把 STM32 RAM 这块讲透,少走点弯路。