ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESP32外部存储全解析:Flash与PSRAM的架构、配置与实战

ESP32外部存储全解析:Flash与PSRAM的架构、配置与实战 我第一次接触ESP32时以为它和STM32一样Flash和RAM都封装在芯片内部。直到看了WROOM-32模组的原理图才发现芯片旁边那颗不起眼的方形芯片才是决定项目能跑多大程序、开多少内存的关键。ESP32的Flash和PSRAM全部放在芯片外部通过SPI总线访问这套设计与传统单片机的存储架构完全不同也成了很多新手最迷茫的地方。本文就把这套外挂存储体系从物理层到软件层拆开讲清楚为什么非要外挂、PSRAM怎么用才能不掉坑、Flash分区与OTA升级的内幕、烧录失败怎么定位以及NOR和NAND到底怎么选。不管你是刚入门Arduino的玩家还是在ESP-IDF里做产品的工程师这套存储知识迟早都要补上。1. 为什么ESP32把RAM和Flash都放在芯片外面1.1 从“芯片内置SRAM 520KB”到“可用的只有300多KB”先从芯片本身说起。ESP32经典款如ESP32-D0WD内部SRAM有520KB听起来不算少但实际跑起来你会发现真正能给你随便用的内存往往只有300多KB。原因很简单Bootloader要占一部分Wi-Fi协议栈要占一部分蓝牙协议栈要占一部分RTOS内核和任务栈还要占一部分。等你把Wi-Fi连上、TCP/IP协议栈跑起来再去创建几个大Buffer内存基本就见底了。这时候你要是想跑LVGL图形界面、做人脸识别、跑语音算法甚至只是开一个复杂的Web页面300多KB内存根本不够用。PSRAM就是为解决这个问题而生的。ESP32原生支持通过Cache和MMU把外部PSRAM映射进CPU地址空间也就是说外挂的PSRAM可以被代码当作普通内存直接读写不需要额外驱动。那为什么不在芯片内部直接做1MB甚至几MB的SRAM呢原因很现实SRAM面积大、成本高而且MCU市场对容量的需求跨度非常大有人要2MB Flash就够有人要16MB Flash加8MB PSRAM。如果全都做进芯片供应链和成本都非常糟糕。外挂方案让模组厂可以自由组合Flash和PSRAM容量同一颗芯片做出十几个SKU性价比远比“一颗芯片打天下”要高。1.2 Flash和PSRAM共用SPI总线的底层原因ESP32芯片内部有好几个SPI控制器其中SPI0和SPI1是专门给外部存储用的。在ESP32-WROVER这类模组上Flash和PSRAM都挂在SPI总线上由芯片内部的Cache控制器统一调度。你可能会问为什么不用更快的并行总线答案很直接引脚不够成本太高。SPI只需要4根线或QIO模式的6根线就能驱动一颗Flash或者PSRAMPCB布线轻松模组也能做得很小。OPI PSRAM在ESP32-S3上能跑出较高的带宽靠的是把数据线从4根增加到8根但底层仍然是SPI协议族。这种设计的代价和收益要分开看。收益是引脚少、方案灵活、成本低代价是带宽有限CPU从Flash取指令、从PSRAM读写数据时都挤在同一条总线上。为了缓解这个问题ESP32引入了Cache机制CPU访问外部存储时优先命中Cache只有发生Cache Miss才会真正走SPI总线到Flash或PSRAM。也正因为如此才引出了一个关键概念——XIPExecute in Place原地执行。ESP32的代码不需要全部拷贝到SRAM里跑而是直接从Flash中取指令只要Cache命中率够高性能表现就很不错。这也是为什么ESP32对Flash的随机读延迟非常敏感也是为什么NOR Flash在系统启动场景里不可替代。1.3 一张表看清常见ESP32模组的存储配置拿到一块ESP32开发板第一件事就是看模组型号而不是看丝印上写的“ESP32 DevKit V4”这种商品名。因为模组型号直接决定了你有多少Flash和PSRAM可以用。模组型号内置SRAM常见Flash容量常见PSRAM容量适用场景ESP32-WROOM-32520KB4/8/16MB无通用物联网、传感器采集ESP32-WROVER-32520KB4/8MB8MBLVGL、图片处理、带摄像头ESP32-S3-WROOM-1512KB8/16MB无基础AI、HMIESP32-S3-WROOM-1-N8R8512KB8MB8MBLVGL、离线语音、复杂AIESP32-C3400KB4MB无不支持低成本IoT、超低功耗很多开发板命名里带“N8R8”这种后缀N代表Flash容量8MBR代表PSRAM容量8MB。这个规则在乐鑫产品线里基本通用看懂了就不会买错板子。ESP32-C3因为定位性价比和低功耗芯片内部没有PSRAM控制器所以它外挂不了PSRAM选型时要注意这点。2. PSRAM的“伪静态”真相与使能姿势2.1 PSRAM“伪静态”的由来里面的DRAM刷新都去哪了PSRAM的全称是Pseudo Static Random Access Memory中文叫伪静态随机存储器。“伪”这个字很有讲究它对外接口像SRAM一样简单不需要额外刷新指令你把它当成普通RAM读写就行但内部存储单元实际是DRAM需要周期性充电刷新。这个刷新逻辑去哪了答案在PSRAM芯片内部。PSRAM芯片自带一个Refresh Controller自动完成周期性刷新不需要MCU操心。MCU看到的就是一颗“掉电丢数据、上电能随机读写”的RAM芯片所以在软件层面它就是个普通内存。ESP32经典版和ESP32-S2/S3使用的PSRAM接口不一样。经典版用的是QSPI PSRAM4根数据线S2/S3上常见的是OPI PSRAM8根数据线Octal PSRAM。OPI的带宽几乎是QSPI的一倍所以S3配PSRAM跑LVGL或AI推理体验明显比经典版好。访问方式上ESP32通过MMU把外部PSRAM映射到CPU地址空间经典版映射在0x3F000000附近用户代码可以直接用指针访问这段地址配置文件里打开支持后一切透明。2.2 使能PSRAMESP-IDF menuconfig与Arduino工具菜单如果你用的是ESP-IDF在工程目录下执行idf.py menuconfig然后按下面路径开启Component config - ESP32-specific - Support for external, SPI-connected RAM选上以后建议同时确认下面两个配置RAM type选择QSPI或OPI必须和模组实际焊接的PSRAM型号一致。Make RAM allocatable选择让malloc()可以使用外部RAM。保存退出重新编译烧录后在串口启动日志里看到类似PSRAM initialized in QSPI mode或者Found 8MB PSRAM的字眼就说明PSRAM已经被正确识别了。如果你用的是Arduino IDE更简单Tools菜单里有“PSRAM”选项把它设为Enabled即可。老版本内核可能没有这个选项建议升级到espressif官方Arduino核心。代码里可以这样验证if (psramInit()) { Serial.printf(PSRAM size: %d bytes\n, ESP.getPsramSize()); Serial.printf(Free PSRAM: %d bytes\n, ESP.getFreePsram()); }烧录后串口监视器能打印出PSRAM容量就说明硬件连接和软件配置都没有问题。常见的一个坑是板子上明明没有焊接PSRAM却把PSRAM选项打开结果是启动时不断刷错误日志甚至直接崩溃。所以先看一眼模组型号再决定开不开这个功能。2.3 malloc并不总是真·mallocPSRAM分配策略与三个大坑PSRAM虽然能被当作普通内存读写但用起来有讲究。默认情况下不管是ESP-IDF还是Arduino核心malloc()优先从内部SRAM分配。只有当内部SRAM不够了才会考虑PSRAM。这样设计的初衷是内部SRAM速度更快但代价是容易出现“内存明明还剩很多malloc却失败”的怪现象。第一个坑就是速度。PSRAM走SPI总线带宽远低于片内SRAM哪怕OPI模式也一样。在紧凑循环里频繁读写PSRAM性能损耗肉眼可见。经验做法是把热数据、高频修改变量放在内部SRAM把大块帧缓冲、图像缓存、字符串池放在PSRAM。LVGL项目中我会把显示Buffer放到PSRAM但DMA描述符和事件处理相关的小结构体留在内部SRAM。第二个坑是DMA。很多外设的DMA传输不认PSRAM地址。典型的是SPI、I2S、LCD接口和RMT比如控制WS2812灯带时DMA描述符和缓冲区必须位于内部SRAM否则驱动程序直接报错或传输乱码。ESP-IDF有些驱动会在初始化时校验缓冲区属性如果校验不通过就返回错误码。这个问题非常隐蔽不少人是写完代码烧录后一运行发现外设不工作排查半天才发现是内存放错了位置。第三个坑是Cache一致性。外部PSRAM的数据经过Cache如果某个外设通过DMA往PSRAM地址写了数据CPU在Cache未失效时读到的是旧数据。表现就是DMA明明完成了读出来的数据却是乱的。解决方式是使用ESP-IDF提供的esp_cache_msync()或者干脆避免用PSRAM做DMA缓冲。3. Flash上到底放了什么启动流程、分区表与OTA升级的完整链路3.1 上电之后从ROM Bootloader到App运行ESP32芯片内部固化了一段ROM代码芯片一上电CPU就从ROM开始执行。ROM里的代码会根据eFuse和GPIO状态决定下一步去哪如果检测到下载模式请求经典ESP32是GPIO0拉低就进入串口下载模式等esptool连接否则就从Flash读取二级Bootloader。二级Bootloader存放在Flash偏移0x1000的位置格式是固定的。它负责读取Flash偏移0x8000处的分区表根据分区表找到要启动的应用分区然后把应用加载起来。为什么要设计成两级引导因为Flash上的应用涉及安全启动签名校验、压缩加载、OTA回退策略、多分区选择等复杂逻辑。如果全部塞进ROMROM没法升级一旦启动逻辑有bug或新增需求就麻烦大了。二级Bootloader是可以更新的这是它存在的最大价值。3.2 分区表Flash的房产证比代码更值得先读分区表是Flash存储布局的总纲它就像一张房产证告诉Bootloader和App哪块地址属于谁、大小多少、类型是什么。分区表本身存放在Flash偏移0x8000处由CSV文本文件编译生成的二进制格式。一个典型的4MB Flash分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 2M,这个表格的含义是nvs分区从0x9000开始占16KB存Wi-Fi配置、校准参数等key-value数据。phy_init分区从0xf000开始占4KB存RF初始化参数。factory分区从0x10000开始占2MB是出厂App所在位置。如果你要开OTA功能就不能只有factory区还需要两个App分区互相备份。8MB Flash的常见OTA分区规划如下# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 2M, ota_0, app, ota_0, 0x210000, 2M, ota_1, app, ota_1, 0x410000, 2M,修改分区表是最容易踩坑的地方。很多人改完CSV后只重新编译烧录App发现设备启动不正常其实是被覆盖区域还残留着旧数据。正确做法是改分区表后执行一次esptool.py erase_flash全片擦除或者至少把分区表和otadata全部擦干净再进行全新烧录。3.3 OTA升级不是“覆盖固件”那么简单双分区、状态标记与回滚OTA升级是ESP32项目里非常常见的需求但它绝不是什么“把新固件写到同一个App分区就完事”。官方推荐的OTA方案采用双分区互为备份当前运行的是A分区新固件下载完成后写入B分区写入过程做校验SHA256写完通过otadata分区里的标记信息告诉Bootloader“下次从B启动”。设备重启后Bootloader检查otadata决定从A还是B启动。这里有一个非常容易忽视的细节新固件从B启动后并不会立刻“转正”。App必须自己调用esp_ota_mark_app_valid_cancel_rollback()来确认“我运行正常”。如果App在标记之前崩溃或者主动重启Bootloader会检测到启动次数异常自动回滚到旧分区。这个机制能避免最坏的情况——升级到坏固件导致设备变砖。OTA升级中另一个经典问题是Flash容量不足。你设计分区表时如果只给ota_0和ota_1各留了2MB而新固件因为加了资源文件涨到2.2MBOTA写入必然失败。这属于分区规划失误不是代码bug。经验是固件体积预估要留出至少30%余量尤其在做资源分离时别把ESP-IDF生成的App bin和你的数据分区混为一谈。3.4 NVS与磨损均衡Flash寿命没那么吓人也没那么耐用Flash的擦写寿命是有限的典型NOR Flash的P/E Cycle擦写周期大约10万次。如果你在产品里写了一个循环每秒往NVS写一次Wi-Fi信号强度这个Flash很快就报废了。NVS组件本身就带有磨损均衡和掉电保护功能所以小数据量的频繁配置读写交给NVS是安全的但绝不能把NVS当日志存储用。如果项目需要记录日志或者存大量运行数据应该用LittleFS或SPIFFS文件系统。它们同样做了磨损均衡但文件系统适合写大块数据、按文件管理。还有一点值得注意直接通过esp_partition_write()往Flash分区写数据时必须自己处理扇区擦除和对齐。Flash的写操作是按Page通常256字节来的擦除是按Sector通常4KB来的不能像RAM一样随意覆盖。很多人第一次直接操作Flash分区时发现数据写不进去或者写进去全是0xFF多半就是没先擦扇区。4. 烧录问题排查从“flash download failed”到稳定的下载姿势4.1 esptool下载流程一键烧录背后到底做了什么不管用Arduino IDE、PlatformIO还是ESP-IDF底层都离不开esptool.py。esptool与ROM中的下载模式通过UART通信流程大致是打开串口、自动复位进入下载模式、读取芯片信息、擦除目标区域、按地址写固件、校验、复位。手动用命令行的典型烧录命令长这样esptool.py --port COM3 write_flash -z \ 0x1000 bootloader.bin \ 0x8000 partitions.bin \ 0xe000 boot_app0.bin \ 0x10000 firmware.bin理解这个过程很重要因为很多IDE里的报错其实发生在“自动进入下载模式”这一步。自动下载依赖DTR/RTS引脚的电平组合来控制EN和GPIO0如果开发板这个电路设计得不标准或者你用的USB转串口芯片信号延迟不对就会出现“连不上设备”的问题。这时候最直接的办法就是手动进入下载模式按住BOOT按键GPIO0拉低短按EN/RST复位保持BOOT不放等串口输出“Connecting…”再松手。这个操作能解决八成以上的自动下载失败问题。4.2 “flash download failed - target dll has been cancelled”的完整排查链路这个报错在Windows下尤其常见光看字面以为是什么DLL文件损坏其实绝大多数情况跟DLL本身没关系而是下载链路被某种原因打断了。我按排查顺序列一下这个顺序也是我自己实际调试时验证过的关闭所有占用串口的程序。串口监视器、其他IDE实例、串口调试助手只要占用同一个COM口烧录必然失败。这是最常见的原因尤其当你开着Arduino IDE的串口监视器又点烧录时。验证串口驱动正常。设备管理器里看COM口有没有消失如果连设备都识别不到先重装CH340或CP210x驱动。手动进入下载模式。按前面说的BOOTEN组合操作排除自动下载电路问题。降低波特率。Arduino IDE默认921600很多开发板在廉价USB线的加持下根本跑不稳这个速度。改成115200后成功率立刻上来。PlatformIO在platformio.ini里加upload_speed 115200。换USB线、换USB口。别小看这一步很多“烧录到一半失败”甚至“target dll has been cancelled”的罪魁祸首就是信号质量差。优先选短一点、带磁环的线插电脑后面的USB口别用前置面板。用命令行esptool验证。如果IDE还是报错打开命令行手动执行esptool.py --port COM3 chip_id如果命令行能正常读出芯片信息说明硬件链路没问题问题在IDE配置如果命令行也失败那基本可以断定是硬件或驱动层面。核对开发板型号。选了ESP32S3的板型却接了经典ESP32的开发板或者芯片是ESP32-C3但选成了ESP32这种低级错误真的会发生而且报错信息非常有迷惑性。提示如果上面所有方法都试过了还是失败检查一下是不是这个板子的Flash型号太老或者太冷门。ESP-IDF和esptool对Flash型号的识别有兼容列表部分无品牌Flash会出现写入校验失败的怪问题。这类情况只能换板子或者换一颗知名品牌的Flash解决。4.3 Arduino、VSCode与命令行不同工具的Flash配置对照Arduino IDE里和Flash相关的配置集中在Tools菜单Board型号、Flash Size、Partition Scheme、Upload Speed。很多时候你觉得代码没问题烧进去却不工作其实是Partition Scheme选得不对比如默认只给了1.2MB的App空间你的固件已经1.5MB了烧录工具也不提醒你启动后就是无休止的重启。PlatformIO在VSCode里的配置集中在platformio.ini一个可用配置长这样[env:esp32dev] platform espressif32 board esp32dev framework arduino board_build.flash_mode qio board_build.partitions huge_app.csv upload_speed 115200 monitor_speed 115200这里board_build.flash_mode对应的是Flash的工作模式qio表示四线I/O模式比qout和dio快但对硬件要求也高。如果你换了Flash型号后启动不稳定优先把qio改成dio试一下。相同容量下dio模式的兼容性最好代价是读取带宽打折但很多产品为了稳定性宁可用dio。ESP-IDF用户的烧录命令更简单idf.py -p COM3 flashidf.py会自动完成编译、下载、复位的流程。如果遇到烧录问题可以加-b 115200指定波特率或者先执行idf.py erase-flash把整片Flash擦干净再烧很多诡异问题在擦除后自动消失。5. NOR还是NANDFlash选型与外扩存储方案5.1 NOR和NAND的关键差异读、写、擦、寿命、坏块做了这么多ESP32项目之后我对NOR和NAND的区别可以用一个类比说清NOR Flash像一本书随便翻到哪一页都能直接读而且支持按字节寻址NAND Flash像一个集装箱仓库比起“读任意位置”更擅长整箱整箱地搬货读写按Page为单位擦除按Block为单位效率高但管理复杂。两者的核心差异用一张表看得更清楚对比项NOR FlashNAND Flash随机读取快支持XIP较慢按页读取写入方式按位写1→0按扇区擦除按页写按块擦除擦除单位扇区通常4KB/64KB块通常128KB坏块管理基本不需要出厂就有坏块必须软件管理ECC校验可以不纠错必须ECC否则数据可靠性差单位容量价格贵便宜典型应用固件、启动代码大容量文件存储、U盘、SSD5.2 ESP32能用NAND启动吗现实约束是XIPESP32官方模组出厂配置的都是NOR Flash这不是随意选的而是技术上必须有。原因就是前面提到的XIP机制CPU需要随机访问Flash中的任何一条指令NOR Flash的随机读速度和按字节寻址能力是刚需。NAND Flash按页读取的特性决定了它无法高效支持XIP方式执行代码因此不能作为ESP32的启动介质。ESP32-S3倒是可以外扩NAND Flash但那只能当数据盘用不能替代启动Flash。而且NAND的坏块管理和ECC校验都要自己在软件里实现用起来成本不低。我的建议是除非你对存储容量有极端需求否则别在ESP32上直接挂NANDSD卡是更省心的选择。5.3 外扩Flash方案文件系统扩展存储与SD卡对比那如果项目确实需要额外的大容量存储怎么办目前最常用的两条路第一条路外挂一颗SPI NOR Flash比如W25Q12816MB接到任意可用的SPI或自定义GPIO口挂载LittleFS文件系统。这种方案适合存网页资源、图片素材、音频片段成本低体积小读取速度也够用。LittleFS在ESP-IDF里有现成组件Arduino核心也支持代码层面基本是即插即用。第二条路SD卡。需要更大容量几百MB到几十GB、更高写入速度、或者需要用户频繁插拔交换数据时SD卡几乎是唯一选择。ESP32-S3自带SDMMC外设经典ESP32也有SDMMC接口驱动基础好文件系统用FAT或LittleFS都行。两条路怎么选我给一个直观的判断标准总存储容量小于64MB、固定不可插拔、以读取为主选外挂NOR容量大、要频繁写、要可插拔选SD卡。有人会在外挂NOR上强行跑FATFS我觉得除非要做兼容Windows的文件交换否则LittleFS无论是磨损均衡、掉电保护还是对小文件的支持都比FATFS更适合Flash。你如果外挂了Flash我这里有一段快速检查Flash是否焊接正常的代码#include spi_flash_mmap.h #include esp_partition.h void check_ext_flash() { const esp_partition_t* part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, storage); if (part NULL) { Serial.println(partition not found); return; } uint8_t buf[256]; esp_err_t err esp_partition_read(part, 0, buf, sizeof(buf)); Serial.printf(read err: %s\n, esp_err_to_name(err)); }是我自己沉淀下来的习惯。每次拿到一块新开发板先确认Flash容量、PSRAM型号、分区表规划再写业务代码。这套流程至少帮我避开了七成以上的烧录失败和内存崩溃问题。希望这篇笔记也能让你在ESP32的存储体系上少走弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进