很多刚接触 ESP32-S3 的朋友,拿到开发板第一件事多半是点个灯。编译、下载都顺利,可一旦开始加 WiFi、推屏幕、存配置,各种存储相关的报错就冒出来了。编译窗口里那一排 RAM、ROM、FLASH 占用统计,看着头大。我自己第一次在这颗芯片上做项目时,也闹过“把所有只读内容都塞进Flash”的笑话,系统被我改得乱七八糟,最后花了大半天把官方手册里存储相关的章节和链接脚本翻了一遍,才算找到门路。
这篇文章想把 ESP32-S3 的内存分类彻底捋一遍:RAM、ROM、FLASH 分别是什么,内部 SRAM 和外部 PSRAM 有什么不同,Flash 分区又是怎么回事,以及在实际工程里到底怎么看待和优化这些内存占用。如果你正准备用 ESP32-S3,或者已经在用但老被内存问题绊住脚,这篇文章应该能帮你省掉不少折腾时间。
1. 内存地图:ESP32-S3的RAM、ROM、FLASH各司其职
1.1 三个存储角色,谁负责什么
很多人一看到“内存”两个字,第一反应就是电脑里的内存条,对应的是 DDR 颗粒。但在单片机上,内存这个翻译很容易误导人。ESP32-S3 上的 RAM、ROM、FLASH,其实更像是一个分工明确的小团队:
- RAM:运行时临时数据。变量、堆、栈、队列、互斥量,全部住在 RAM 里。掉电以后数据全部消失。
- ROM:出厂固化的只读存储。芯片一上电还没准备好从外部 Flash 加载程序,就需要一小段固有代码把时钟、Flash、启动流程初始化好。这部分代码焊在芯片内部,改不了。
- FLASH:主要的掉电非易失存储。程序代码、固件配置、文件系统、校准数据,都塞在 Flash 里。掉电以后数据能保住,而且可以擦写。
这么一看,RAM 相当于“桌面上的草稿纸”,ROM 相当于“出厂时印在芯片里的说明书”,FLASH 相当于“可以随时换页的工作笔记本”。很多人觉得 Flash 容量大、能存程序,所以它是“最大的内存”,这就搞混了存储和内存的关系。在工程上,程序代码和静态只读数据主要占 Flash,真正让 CPU 跑起来、让系统流转起来的变量和栈才占 RAM。
1.2 地址空间里的“片上”与“片外”
ESP32-S3 的存储体系里有一个很关键的现象:部分存储放在芯片内部,部分放在芯片外部。内部 SRAM 和内部 ROM 属于片上资源,速度非常快;Flash 和 PSRAM 则挂在 SPI 总线上,属于片外资源,访问时要经过缓存模块和总线的仲裁。
从 CPU 的角度看,这些存储并不是凭空存在的,它们都被映射到一个统一的 4GB 地址空间里。代码可以访问的地址,有的落在 ROM 区域,有的落在 SRAM 区域,有的落在 Flash 映射区域,还有的落在 PSRAM 映射区域。这个机制和我们电脑里的内存映射几乎一样,只不过嵌入式程序员通常需要自己关心哪段地址对应什么东西。
我把常见的关系整理成了下面这张表:
| 存储类型 | 位置 | 主要用途 | 掉电后 | 典型特点 |
|---|---|---|---|---|
| SRAM(内部) | 芯片内部 | 变量、堆、栈、任务控制块 | 丢失 | 速度快,容量有限 |
| ROM(内部) | 芯片内部 | 启动引导代码、底层函数库 | 不丢失 | 出厂固化,只读不可改 |
| FLASH(外部) | SPI 总线上 | 程序代码、分区、文件系统 | 不丢失 | 可擦写,但擦写次数有限 |
| PSRAM(外部) | SPI 总线上 | 大块数据缓冲、内存池 | 丢失 | 容量大,速度比内部 SRAM 慢 |
这张表是理解后续所有问题的基础。先记住一句话:程序跑的时候,CPU 的指令大多数时候是从 Flash 里取出来的,而它读写的数据大多数时候是放在 SRAM 里的,偶尔也可以用 PSRAM 兜底。
2. RAM到底有几种:SRAM、PSRAM、IRAM和DRAM
2.1 片内SRAM,为什么还要分指令和数据
不少第一次用 ESP32-S3 写工程的人会发现,编译器的输出里有 IRAM 和 DRAM 两个分类,而裸想到“RAM 就是 RAM”,为什么还要分开?
这和 CPU 的取指和数据访问方式有关系。CPU 要执行一段代码,必须先把指令取到内部;CPU 要处理一个变量,必须把数据读进来。如果指令和数据都往同一个存储区域挤,取指操作和数据读写操作就可能互相等待。ESP32-S3 的处理器核内部采用指令总线和数据总线分离的设计,这被称为哈佛结构。因此,芯片内那一部分 SRAM 被划分成可以映射为指令 RAM(IRAM)的区域,和数据 RAM(DRAM)的区域。
在默认配置下,ESP32-S3 内部有约 512KB 的 SRAM,其中一部分固定用于数据访问,一部分允许被配置成指令 RAM 或数据 RAM。这个比例可以通过链接脚本和寄存器配置调整。大多数应用场景下,系统默认会用大部分 SRAM 作为 DRAM 使用,IRAM 主要存放中断处理函数、实时性要求极高的代码段,以及部分需要避免从 Flash 取指造成延迟的底层逻辑。
开发中常常见到的IRAM_ATTR和DRAM_ATTR这类宏,就是用来给变量或函数指定存放区域的。给函数加上 IRAM 属性,它就会在链接时被放进 IRAM;给变量加上 DRAM 属性,它就会优先放在 DRAM。一个很实用的判断标准是:如果某个函数在中断上下文里会被调用,并且你对执行时间敏感,就把它放到 IRAM;如果某个数据缓冲区要参与 DMA 传输,也必须放到 DRAM,因为大部分 DMA 控制器访问不了 Flash 映射区域。
2.2 外接PSRAM之后,大内存就够用了吗
片内 512KB 的 SRAM 对于大多数轻量应用是够用的,但如果你要做图形界面、摄像头采集、音频流处理这类场景,内存经常会不够。这时候 ESP32-S3 支持外接 PSRAM 这一点就很香。市场上很多模块会焊上一片 8MB 的 PSRAM,看起来内存一下大了很多。
不过,我在这里要提醒一句:PSRAM 是挂在 SPI 总线上的外部存储器,虽然容量大,但性能和片内 SRAM 差了不止一个量级。每访问一次 PSRAM,都要经过 SPI 总线协议转换、缓存缺失、回填等操作,延迟明显更高。做实时性要求高的逻辑,不能假设大内存就万事大吉。
另外,并不是随手一个malloc分配的内存都会自动落到 PSRAM。官方 SDK 默认的堆分配策略,在很多配置下是先考虑内部 SRAM,只有当你显式开启 PSRAM 并配置好 malloc 策略后,malloc才会倾向于从 PSRAM 分配大块内存。如果你只是外接了 PSRAM,却没有在配置里打开它,那再大的外存也只是一个躺在数据手册里的纸面参数。
还有一个容易被忽略的点:DMA。多数外设的 DMA 传输有内存类型限制,内部 DRAM 可以直接作为 DMA 缓冲区,PSRAM 则不一定支持所有外设的 DMA 访问。在摄像头、麦克风、SD 卡这类高速传输场景里,如果你把缓冲区指到了 PSRAM,可能就会出现数据错乱或卡死的情况。
所以,我的态度是:PSRAM 适合用来做缓存、帧缓冲、大数据样本,不适合用来存放高频访问的小数据结构,更不适合作为所有任务栈的首选区域。它解决的是“有没有”的问题,不解决“快不快”的问题。
2.3 堆和栈到底放在哪,又是怎么算的
在实时操作系统的环境里,每个任务都有独立的栈空间,任务栈的分配来自系统的堆管理器。ESP32-S3 的官方 SDK 基于一个常见的实时操作系统内核,它把内存分成了不同类型的堆区。你可以用带能力参数的分配接口去申请特定类型的内存,比如只能从内部 DRAM 分配、只能从 PSRAM 分配,或者两个区域都可以。
栈这个东西比较特殊。每个任务创建时,系统会从堆里划出一块连续内存作为栈。如果任务栈开得太小,函数嵌套调用一深,栈就把相邻内存踩坏了,轻则变量值被改掉,重则直接死机;如果任务栈开得太大,堆空间被占掉太多,后面的内存分配又会失败。这就导致很多开发者在调试时反复摩擦:刚把任务栈调大,malloc 又失败;刚把 malloc 省出来,任务又栈溢出。
我个人的经验是,排查内存问题时不要只盯着一处。先看链接后的报告,搞清楚 DRAM 还剩多少;再通过实时操作系统的内存统计接口看堆剩余;最后再根据实际任务的最深层级调整任务栈。不要图省事把所有任务栈的都统一设置成一个很大的值,那是对内存资源的最大浪费。系统提供的栈检测机制也建议打开,宁可捕捉到栈溢出告警,也不要等到死机后才怀疑是栈问题。
3. ROM和FLASH:一个出厂定终身,一个擦写随缘
3.1 芯片启动时,ROM做了第一棒接力
很多人对片内 ROM 几乎没有感知,因为它既不能存自己的代码,也没法通过常规烧录方式修改。但它却是整个系统能够跑起来的第一棒。
每次复位或者上电,CPU 会从芯片内部预设的启动地址开始执行,这个地址对应的就是 ROM 里的启动引导代码。这部分代码会先把芯片内部的时钟配置好,初始化 SPI 控制器,探测并初始化外部 Flash,然后从 Flash 中读取二级引导程序和分区表,最后跳转过去运行。后续的二级引导才会把你的应用程序从 Flash 分区里加载到内存映射区域,并且真正开始执行应用代码。
很多朋友看到这里可能会问:既然 Flash 里的二级引导程序已经那么成熟,为什么 ROM 不能省?答案是:ROM 是固定的、可靠的,芯片刚上电时外部 Flash 的配置还没建立,Pin 的复用状态也还不确定。假如启动代码也放在外部 Flash 里,那就是先有鸡还是先有蛋的问题了,系统根本找不到入口。所以原厂会在芯片出厂时焊死一段很小的引导程序,这是所有系统启动的地基。
此外,ROM 里还预置了不少基础函数和底层库。平时编译工程时,有些系统调用看起来好像没占多少 Flash 空间,一部分原因就是编译器把函数实现指向了 ROM 地址。这在嵌入式领域很常见:固件代码空间不够时,利用 ROM 库能省下可观的 Flash 空间,代价是你不能随意修改这些函数的实现细节。
3.2 Flash分区:程序、配置、文件系统各占一块
ESP32-S3 外接的 Flash 芯片,表面上看是一个整体,实际上需要通过分区表划分成多个逻辑区域。你烧录程序时,并不是把所有内容堆在一起,而是按照分区表的布局,分别写入不同的地址区间。
最常见的分区包括:
- 引导程序分区:负责二级引导,跳转到主应用。
- 应用分区:存放你的固件主体,包括代码段和只读数据。
- 非易失存储分区:存放键值对,比如 WiFi 配置、设备参数,掉电不丢。
- 文件系统分区:可以用来挂载文件系统,存日志、证书、图片等资源。
- OTA 相关分区:用于在线升级,往往会有两个交替使用的应用分区。
如果你用的 Flash 是 4MB、8MB 或更大,真正留给应用代码的空间其实取决于分区表如何划分。很多初学者遇到“代码写不进 Flash”或者“突然 OTA 失败”,就是因为分区表里给应用分区定的空间偏小,或者 OTA 分区没有留够。
这里也提醒一句:Flash 虽然是可擦写的,但不是无限寿命的。每次擦写都会消耗寿命,反复往同一个地址写数据,久而久之会导致坏块。所以不要把 Flash 当作普通内存一样高频写入。对于频繁变化的数据,尽量用非易失存储分区或者带损耗均衡的文件系统;对于日志类数据,设计好循环覆盖的粒度,避免死磕同一块区域。
3.3 XIP到底是不是把代码放在Flash里运行
很多代码跑在电脑上时,程序要先从磁盘加载进内存再执行。但 ESP32-S3 这类芯片不一样,它采用的是片外 Flash 执行代码的方式,也就是通常所说 XIP。翻译成大白话:CPU 可以直接通过缓存模块去读取 Flash 地址空间里的指令来执行,并不需要把整段代码事先拷贝到 SRAM。
这就解释了为什么编译出来的固件往往有几兆字节,而运行时分配的变量只占几十或几百 KB。你写的复杂业务逻辑、字符串、静态查找表,大部分时间是躺在 Flash 里的;CPU 用到哪个函数,就通过缓存去哪个地址取指令。这样既省了 SRAM,又让代码可以突破内部存储的大小限制。
但 XIP 不是没有代价。第一,从 Flash 取指令的速度无论如何都比从内部 SRAM 取指令慢,某些频繁调用的热点代码会影响整体执行性能;第二,因为 Flash 挂在共享的 SPI 总线上,如果 NVS、文件系统、OTA 写入等操作正在占用 Flash 总线,CPU 的取指令操作就会受到影响,极端情况下会出现执行停顿。如果你在做中断处理或者在实时性要求极高的循环里,最好把关键代码标记到 IRAM,避免访问 Flash 引入不确定性。
有经验的人会习惯性检查中断回调函数有没有挂IRAM_ATTR,因为中断触发后,系统的中断控制器要去取中断处理函数的指令。如果此时 Flash 总线恰好被长时间写操作占用,中断响应就会被拖住,甚至出现看起来像死机的现象。所以,中断处理函数必须够短、够快,并且尽量放在 IRAM,这是使用 XIP 机制的一个核心避坑原则。
4. 实际操作:怎么看清一个工程的内存占用
4.1 先跑一遍idf.py size,看懂三类尺寸
在你动手调内存之前,第一步永远是先看数据。官方 SDK 的命令行工具里就提供了一个很直观的命令,可以输出编译后的内存占用概览。以典型工程为例,终端里执行:
idf.py size输出结果里会包含 DRAM、IRAM、IROM、DROM 这几个关键词。DRAM 是数据 RAM,IRAM 是指令 RAM,IROM 是占用 Flash 的代码段,DROM 是占用 Flash 的只读数据段。看到这一排数字时,很多人会误以为 IROM 和 DROM 占的是 RAM,其实它们占的是 Flash 地址空间。简单判断方法是:凡是带 IROM、DROM 的项,消耗的是外置 Flash;凡是带 RAM 的项,消耗的是芯片内部的 SRAM。有的人以为优化了代码段就能让运行时内存变大,这个方向经常是南辕北辙。
如果你还想知道具体是哪个源文件或哪个函数占的最大,可以继续执行:
idf.py size-components idf.py size-files前者按库和组件维度统计,后者按文件维度统计。调优时我一般先看整体,再按这个线索往下钻,很快就能定位到几个“内存刺客”。
4.2 用map文件找到内存占用大户
如果命令行统计还不够细,那就得看链接生成的 map 文件了。在构建目录下,通常会有一个.map文件,里面记录了所有符号的最终地址、大小、所属段。这东西看着像天书,但配合文本搜索用起来非常顺手。
比如你怀疑某个全局数组占空间太大,直接搜索数组名,就能看到它被放到了哪个段、占用多少字节、具体地址是多少。再看它所在的段是 DRAM、IRAM 还是 Flash 映射区域,就能判断这个数组是不是被意外放错了位置。
还有一种比较隐蔽的情况:在代码里定义了一个常量字符串,它应该放在 Flash 的 DROM 段,但因为某些编译器转换或者指针操作,它被强制放进了 DRAM,白白浪费了有限的 SRAM。用 map 文件搜索常量名,一旦发现地址落在 RAM 段,及时调整存储属性,效果立竿见影。这个排查方法看起来老土,但非常可靠,比凭感觉猜快得多。
4.3 打开PSRAM,但别让它接管一切
如果你的模块上焊了外接 PSRAM,强烈建议先把配置打开,否则它会一直处于“摆设”状态。在配置菜单里,一般路径是组件配置下的特定芯片配置项,找到“支持外接 SPI 连接 RAM”相关开关,选择正确的 PSRAM 类型和容量,保存配置后重新编译。
打开后还要再看一项:外接内存的分配策略。常见策略有三种含义很大的方向:
- 一种是不启用自动分配,所有运行时堆仍以内部 SRAM 为主,PSRAM 只有显式申请时才被使用;
- 另一种是让 malloc 自动把某些大块分配放到 PSRAM;
- 还有一种是完全把 PSRAM 作为系统堆的来源。
很多朋友想省事,直接把策略调到“完全用 PSRAM”,结果就是系统里的关键栈和高频数据全部落到慢速内存上,整体性能不升反降。我的建议是:保留内部 SRAM 作为默认堆,同时让大块申请、帧缓冲、音频缓冲优先走 PSRAM,这样能兼顾容量和性能。
需要强调,打开 PSRAM 是要付出代价的。系统需要从内部 SRAM 中划出一部分区域作为访问 PSRAM 的缓存地址窗口,这部分是不能被应用逻辑占用的。所以,在某些场景下你会看到,刚打开 PSRAM 后内部 SRAM 剩余反而变少了,这是正常现象,不要恐慌。
5. 常见问题与排查心得
5.1 编译时报内存不足,先分三个方向查
很多人一看到编译输出里有内存不足的错误就慌,急急忙忙去删功能。其实大部分内存不足不是“真的代码太多”,而是三个独立方向的问题混在一起。
第一个方向,Flash 空间不足。这类报错通常和链接器输出中 IROM、DROM 超限有关,解决思路是压缩代码、移除调试日志、擦除没用到的库。第二个方向,RAM 空间不足,这类报错和 DRAM、IRAM 有关,解决思路是减少全局变量、缩小任务栈、把大数据挪到 PSRAM。第三个方向,地址空间不足,这一点比较隐蔽,往往和链接脚本里的内存布局有关,比如 IRAM 用了太多、DMA 缓冲区占了太多内部 DRAM。
排查时建议先把idf.py size-components的输出保存下来,对比是哪个分类接近上限,再决定优化方向。不要看到“内存不足”就盲目砍代码,有时候只是差那么几十字节,调整一个数组的内存位置就解决了。
5.2 malloc了一天,还是NULL
malloc返回空指针是嵌入式里非常经典的崩溃来源。程序跑着跑着突然出问题,一查日志发现是内存分配失败。不少人的第一反应是“总内存不够”,其实很多时候是碎片化或者内存类型限制。
碎片化好理解,频繁申请和释放不同大小的内存块,会导致连续内存空间不够,即使总剩余量充足也无法拿出一个大块。嵌入式系统里能用固定内存池、静态分配的时候尽量别偷懒用动态分配,能显著减少这类问题。另外,很多实时操作系统提供多次尝试分配的函数,如果你申请的是多块小内存,可以考虑用按类型分配的函数,并且把各个内存池的统计信息打印出来,看看剩余最大块到底是多少。
还有一类问题更隐蔽:某些库函数内部会申请特定类型的 RAM,比如内部 DMA 能访问的 DRAM。如果这种类型的内存已经耗尽,即使 PSRAM 剩余几十兆,分配仍然会失败。解决办法是在早期设计时就预留一块足够的内部 DRAM 缓冲区,并把大的、不需要 DMA 的数据主动放到 PSRAM 区域。
5.3 中断函数里访问Flash,导致玄学死机
这恐怕是新手进阶时最容易碰到的“玄学”问题。表现是系统平时跑得好好的,偶发进入一个中断,或者同时在读写 Flash,然后就死机或复现随机故障。
排查方法并不神秘:检查中断回调里的代码是否有函数调用,而该函数没有被放置在 IRAM;检查中断处理过程中是否做了 Flash 读、文件系统写、打印日志这类耗时操作。只要遇到这种“偶发但致命”的故障,优先把中断回调精简为只设置一个标志位,具体的处理放到主循环或者专用任务里去做。
从原理上讲,中断的实时性取决于“立即执行”。如果在 Flash 总线被占时还要从 Flash 取指令,中断响应就被卡住了。这一点在 XIP 架构下尤其关键。所以我的习惯是:只要定位到某个回调函数会被中断触发,就直接给它加上 IRAM 存放属性,同时避免在回调里调用任何可能访问 Flash 的库函数。
5.4 内存使用避坑速查表
| 常见现象 | 可能原因 | 快速处理方式 |
|---|---|---|
| 编译时提示 DRAM 超限 | 全局变量或任务栈过大 | 减少全局变量、缩小栈、把大缓冲移到 PSRAM |
| 编译时提示 Flash 超限 | 代码或只读数据过多 | 开启编译优化、移除日志、检查分区表大小 |
| malloc 返回 NULL | 堆碎片或特定内存耗尽 | 打印堆剩余和最大块,改用静态缓冲区 |
| 开 PSRAM 后性能变差 | 关键数据落到慢速 PSRAM | 调整内存分配策略,保留 IRAM/DRAM 核心堆 |
| 偶发死机与中断相关 | 中断回调访问 Flash | 回调加 IRAM 存放属性,回调内只做标志位处理 |
| OTA 升级失败 | 分区表给 OTA 分区空间不足 | 检查并重新规划分区表 |
这张表基本涵盖了我平时被问到最多的几类问题。真正处理起来,最耗时间的往往不是问题本身,而是定位问题的过程。把内存分类的基础概念吃透了,很多问题其实一眼就能看出方向。
最后再分享一个小技巧:我每做完一个阶段,都会把idf.py size的完整输出另存为一个文本文件,和上次的版本做对比。这样代码一变更,内存变化立刻有数,不会等到最后所有功能都堆上去才发现内存爆炸。这种习惯不复杂,但能帮你在调优内存时保持清晰,少走很多回头路。