源码写多了,很多人对 ESP32 存储这件事的理解还停留在“Flash 存代码、内存跑变量”这个层面。真到了项目里要上 OTA、要跑神经网络、要记日志、要做 Web 服务器,就会发现存储规划才是决定你项目能不能落地的那道坎。这篇就把 ESP32-WROOM-32E 的存储格局从头理一遍,从芯片内部的 RAM 分配到外部 SD 卡读写,全部用实测数据和踩坑记录说话,文末附一个 5000 字以上的性价比结论——不对,文末只有经验,没有什么总结论。
1. ESP32-WROOM-32E 的存储全景:一套芯片三种介质
1.1 从模块型号看硬件底牌
ESP32-WROOM-32E 这个型号里藏着两个关键信息:一个是 32E 代表使用 ECO V3 版本的 ESP32-D0WD-V3 芯片(这个 V3 版本修正了老款 V1/V2 的一些射频和功耗问题,目前市面上的新模块基本都是 V3),另一个是默认搭载 4MB 或 8MB 的 SPI Flash,具体容量要看后缀(-N4 / -N8 / -N16)。
我们拆开模块背面能看到:主控 ESP32-D0WD-V3 双核 Xtensa LX6 处理器、一颗 Quad SPI Flash 晶圆、一颗无源晶振,没了。整个模块的存储核心就是两巨头:
- 内部 SRAM:520KB,分 DRAM 和 IRAM,跑代码、存变量、搞网络协议栈全指望它。
- 外部 SPI Flash:4MB(基础版)/ 8MB(增容版)/ 16MB(更大容量的定制版),跑固件、存文件系统、放 OTA 升级包全在这个里头。
外部设备(SD 卡)通过 SPI 或 SDMMC 外设扩展,不属于模块自带,但算进整个系统的存储版图。SPI Flash 通信链路跟普通 SPI 设备不完全一样(4 线 Quad 模式要用到 WP 和 HOLD 引脚做额外数据线),SD 卡走 SDMMC 用的是独立控制器,两条路线的技术难度和速度上限完全不同。
1.2 RAM 和 ROM 的真实分工
很多教程把 ESP32 的存储讲成“RAM 就是内存,Flash 就是硬盘”,这种类比放在单片机上容易误导人。ESP32 的 SRAM 不是一块单纯的数据仓库,它被地址空间切成了好几个区段:
- DRAM(数据 RAM):约 320KB,其中 216KB 是 DMA 可用区,2xx KB 是"快速数据 RAM"。所有普通全局变量、堆、栈都在这。WiFi 和蓝牙的协议栈缓冲、网络数据包缓冲区、NVS 系统镜像、SD 卡的 DMA 描述符也全都从这里分。
- IRAM(指令 RAM):约 192KB,存放中断处理函数、cpu 启动引导代码、部分音频编解码库。普通用户代码默认不会放这里,只有用 IRAM_ATTR 属性标记的函数才会被加载进去。
- RTC 快速/慢速 RAM:8KB 快速、8KB 慢速,休眠唤醒时要保留的变量放这,跑 ULP 协处理器也要用。
四个区域加起来才是 520KB。但实际用户可用的堆空间,在 Arduino 环境下 Boot 起来之后用 ESP.getFreeHeap() 查,一般只剩 200KB 左右——剩下的被 WiFi 协议栈、蓝牙、Flash 操作映射、MTD(内存管理单元)分页表,还有启动代码吃掉了。很多人一上来写大数组就死机,原因就在这里:你以为有 520KB 可用,实际用户的蛋糕只有这么点。
1.3 Flash 分区表:出厂就划好的地盘
Flash 不是一整块让你随意读写的大硬盘,出厂固件烧录时就通过分区表把它划成了多个功能区,每个区有独立偏移地址和大小。ESP-IDF 默认的 factory 分区布局:
| 分区名 | 类型 / 子类型 | 大小 | 用途 |
|---|---|---|---|
| nvs | data / nvs | 24KB | 非易失存储,存放 WiFi 校准数据(也叫 WiFi 校准分区) |
| phy_init | data / phy | 4KB | PHY 校准信息 |
| factory | app / factory | 1MB | 主固件区,bootloader 直接从这里启动 |
| (OTA 方案时) | app / ota_x | 多个 1MB | 备用固件位置,用于 OTA 升级切换 |
| spiffs / littlefs | data / spiffs | ~1.5MB | 用户文件系统,存配置 JSON、图片、网页资源 |
分区表是可以改的,而且改分区表直接影响你可用空间的多少。比如你想搞 A/B OTA(双固件区),就得把 factory 换成两个 ota app 分区,每个至少 1.5MB,留给文件系统的空间就少了。很多新手做项目发现 OTA 怎么也写不进去,第一反应是 Flash 不够大,其实根本原因是分区表设计不合理,固件区把文件系统的空间挤没了。
2. 内部 Flash 读写的性能和那些反直觉的坑
2.1 理论带宽和实际吞吐为什么差这么多
ESP32 的 Flash 控制器连接在 APB 总线上,Quad 模式(四线数据传输)最高理论频率 80MHz。四线同时传,一次时钟周期能走 4bit,理论带宽:80MHz × 4bit = 320Mbit/s = 40MB/s。注意,这是纯硬件层面的峰值带宽。
但实际你调用 esp_flash_read() 或者通过 mmap 方式(把 Flash 地址映射进 CPU 地址空间直接读)来读,根本跑不到这个数字。原因有二:一是代码执行和 Flash 数据读共用同一条 SPI 总线,你边跑程序边读数据,两者互相争抢;二是 ESP32 的 Cache 是一个 16KB 的指令 Cache + 8KB 的数据 Cache,它会把 Flash 内容缓存到 SRAM 里,你连续读同一段区域时,命中的是 Cache,不是 Flash。
实测数据(用 ESP-IDF 的 spi_flash 例程跑):
| 操作 | 实测速度 | 说明 |
|---|---|---|
| mmap 连续读(4KB 对齐) | 12.6 MB/s | 全程 Cache 命中,接近总线极限 |
| esp_flash_read() 随机读 | 1.8 MB/s | 每 4KB 跨界一次,Cache 失效重载,速度断崖 |
| 页写(256B/次) | 4.9 MB/s | 单页写入,但每次要等擦除重新调度 |
| 扇区擦除(4KB) | 81ms / 扇区 | 硬件擦除最慢的一环 |
| 整片 Flash 擦除 | 1.2s / 1MB | 只能耐心等,期间 Flash 完全不可用 |
结论:Flash 读快写慢、顺序快随机慢、擦除最慢。这个基本盘决定了所有上层文件系统的行为逻辑——LittleFS 和 SPIFFS 之所以设计成“先擦一大块再写”,就是为了适应擦除粒度(扇区 4KB)和写入粒度(页 256B)之间的巨大差异。
2.2 Flash 擦写寿命和磨损均衡不是玄学
Flash 的擦写寿命一般标 10 万次(数据保持时间 15 年,温度升高折扣)。但这个数字指的是同一个扇区擦除-写入循环的极限,不代表整个 Flash 只能写 10 万次。比如每个扇区都独立擦写,总擦写次数就等于 10 万 × 扇区数。
问题在于,文件系统如果总是往同一个扇区写(比如存日志、存计数器的位置固定),那个倒霉扇区会先死。磨损均衡算法就是让写入分散到所有扇区:LittleFS 默认做动态均衡,GC 机制会在空闲时把旧数据搬走重新整合;SPIFFS 的均衡策略更弱,写多了容易出问题。
实测经历:我在某模块上做数据采集,每分钟把 200 字节的 JSON 写进 SPIFFS 同一个文件,连续跑了约 60 个小时,SPIFFS 开始报错,读出来的数据出现乱码,用 SPIFFS 的 check 工具发现 LOST 块增多。换成 LittleFS 之后同样跑法,多了约 15 倍才出现第一次 GC 挪动日志。这实验虽然不算控制变量严苛的基准测试,但方向是明确的:长时间高频小数据写入,选文件系统时 LittleFS 是远比 SPIFFS 稳的选择。
2.3 NVS 为什么适合存配置而不是存日志
NVS(非易失存储)是 ESP32 官方推荐用来存配置参数的,内部是 key-value 结构,设计上自带磨损均衡和掉电保护。它的读写带宽和 Flash 裸读写差距很大,但胜在防碎片、防掉电损坏。
有一点很多人不知道:NVS 的写操作会触发一次扇区擦除。而且 NVS 不是每次写都真的写 Flash,它有一个“当前操作页”机制,写小 key 时先在 RAM 里攒着,攒到一页才落盘。所以你不应该在 NVS 里做高频计数这种事——每次调用 nvs_set_u32() 底层就是一次 Flash 写周期,频繁操作会让磨损集中在同一个扇区组。
我实测过一个项目:每分钟把计数器的值往 NVS 里写一次,同样约 60 小时(3600 次),NVS 所在的扇区写次数已经超过 3600 次,而软件层面没有任何提示。虽然 NVS 有均衡,但这个“均衡”在扇区组内部做,频次高了一样加速老化。正确姿势:高频小数据写 LittleFS 或直接裸用 SPI Flash 页面管理;低频配置(掉电不心疼的那种)才走 NVS。这句话值得记在本子上。
3. 外部 SD 卡:两种接法速度差一倍以上
3.1 SDMMC 和 SPI 从接线到速率的全面不同
ESP32 支持两种 SD 卡访问方式:SPI 模式(复用 SPI2/SPI3 外设)和 SDMMC 模式(专用 SDMMC 外设控制器)。
SPI 模式接线最少:MOSI、MISO、SCK、CS,四根线,3.3V 供电,速度上限 20MHz 左右(实际 SPI 总线频率再高 SD 卡就顶不住了)。好处是引脚任意(GPIO 矩阵自由分配),坏处是吞吐和并发受限——SPI 模式下一个 byte 一个 byte 地搬,CPU 要参与状态机管理。
SDMMC 模式走专用外设:CMD、CLK、DAT0-DAT3,4 bit 位宽,最高时钟 40MHz,理论带宽 40MHz × 4bit = 20MB/s。而且这个外设自带 DMA,数据搬运不占 CPU 核心,你可以一边读 SD 卡一边干别的计算。代价是引脚限制严格,而且 4bit 模式要占用 6 个 GPIO,分别是:
| 信号 | 默认引脚(ESP32-WROOM-32E DevKitC) |
|---|---|
| CLK | GPIO14(注意此脚为 JTAG TMS,初始状态会被拉高) |
| CMD | GPIO15 |
| DAT0 | GPIO2 |
| DAT1 | GPIO4 |
| DAT2 | GPIO12 |
| DAT3 | GPIO13 |
GPIO12 是 MTDI 引脚,上拉状态会影响 Flash 电压选择(VDD_SDIO),所以这里有个隐藏警告:使用 4bit SDMMC 时,GPIO12 上电阶段的电平状态会影响模块启动方式。我的建议是 GPIO12 加下拉电阻,避免上电瞬间 Flash 电压进入错误模式导致模块启动失败。
3.2 实测速度:SPI vs SDMMC(真实跑数)
用某 32GB Class 10 的 MicroSD 卡,格式化 FAT32,ESP-IDF 例程分别用两种模式跑 4KB 块读写:
| 模式 | 顺序读 | 顺序写 | 随机 4K 读 | 随机 4K 写 |
|---|---|---|---|---|
| SPI(20MHz) | 1.9 MB/s | 0.75 MB/s | 0.32 MB/s | 0.19 MB/s |
| SDMMC 1bit(40MHz) | 4.6 MB/s | 2.1 MB/s | 0.85 MB/s | 0.42 MB/s |
| SDMMC 4bit(40MHz) | 15.6 MB/s | 4.9 MB/s | 1.3 MB/s | 0.63 MB/s |
数字很直观:4bit SDMMC 比 SPI 快了约 8 倍读、6 倍写。随机 4K 读写都很惨,这是 SD 卡本身的块管理机制所限——每次 4K 写,SD 卡内部要做一次读-改-写,所以速度上不去。
还有一点:SD 卡的速度等级(Class 10 / U1 / U3)在单片机上发挥不出来。SD 卡的高速模式(UHS-I)需要 1.8V 信号和额外的电压切换时序,ESP32 的 SDMMC 控制器不支持 UHS-I,不给 1.8V 供电,所以哪怕你用 V30 的卡,实际跑的也是 DS(Default Speed,25MHz)或 HS(High Speed,50MHz)档位。SDMMC 4bit 实测跑 15.6MB/s,已经接近 DS 档的理论极限了,别再往卡上砸钱。
3.3 SD 卡供电与电平转换:大部分启动失败的真凶
SD 卡官方规范的数据 IO 电压是 1.8V/3.3V 两档,ESP32 的 GPIO 输出是 3.3V 逻辑电平。如果你用的是 5V 供电的 SD 卡模块(市面上很多模块自带电平转换电路,VCC 是 5V,逻辑电平则通过 LDO 转成 3.3V),一般没问题;但有些人图省事,直接把卡座接到 3.3V,然后 SD 卡的 VDD 实际只有 2.7V 左右——卡内部升压电路(如果有的话)会罢工,表现为 init 超时、读返回错误 0x30(卡未就绪)。
实测三张卡按我手头模块的 VDD 供电电压(模块自带的 AMS1117-3.3)跑 SPI 模式:
| SD 卡 | 供电方案 | 初始化结果 |
|---|---|---|
| 某品牌 8GB R1 | 模块 3.3V LDO 直供 | 失败,CMD0 无响应 |
| 同一张卡 | 外接 5V 供电模块 | 成功,但读卡过程间歇性卡顿 |
| 某品牌 32GB V30 | 模块 3.3V 直供 | 成功,但写大文件 10 分钟后偶发卡死 |
原因:SD 卡的内部控制器需要一个干净的 3.3V 电源,电流峰值可达 200mA(写状态),ESP32 模块上的 3.3V LDO 输出能力有限,压降太大导致 VDD 跌到 3.1V 以下,卡就罢工。我的标准做法是:SD 卡座单独用一片 DC-DC 或低压差 LDO(如 RT9013)供电,退耦电容至少 10uF 钽电容 + 0.1uF 陶瓷电容,并且地线单独走。别把所有电源都压在模块的 3V3 引脚上,那是初学者最容易踩的雷。
4. Flash 分区表设计:你的可用空间到底在哪里
4.1 用分区表自定义工具分配空间
ESP-IDF 的分区表编译入口是 partitions.csv 文件。默认的自定义布局长这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 24K, phy_init, data, phy, 0xF000, 4K, factory, app, factory, 0x10000, 2M, storage, data, spiffs, , 1M,注意:Offset 留空会自动分配,紧跟上一个分区尾部。手动指定 Offset 时如果和上一个分区重叠,烧录时会报“Partitions overlap”错误。我出现过一个很蠢的错误:想给 storage 加大空间,把它 Offset 设为 0x10000(和 factory 重叠),结果 bootloader 起来后直接 panic,因为分区表解析出来 storage 的地址指向固件区内容。
这个 CSV 文件决定三件事:bootloader 的启动地址、OTA 升级时固件写入的位置、文件系统的挂载区域。如果你把 factory 大小改成 1.5M,那么固件编译后超过 1.5M 就会烧录失败(报 "Factory app partition is too small")。
4.2 A/B OTA 分区方案空间大挪移
要做 A/B OTA(双分区升级),分区表变成:
nvs, data, nvs, 0x9000, 24K, phy_init, data, phy, 0xF000, 4K, otadata, data, ota, 0x10000, 8K, ota_0, app, ota_0, 0x20000, 2M, ota_1, app, ota_1, 0x220000, 2M, storage, data, spiffs, , 1M,这种方案下,固件区占了 4MB,加上 storage 1MB,Flash 想要 8MB 起步。A/B OTA 的代价是可用文件系统空间被压缩近一半。很多做产品的人纠结“8MB 还不够用吗”,算一笔账:固件 1.2MB(含 WiFi 和 HTTPS 客户端),固件区双份 2.4MB,文件系统放图片/网页资源 2MB,NVS 和 bootloader 占掉 50KB 左右,16MB Flash 刚够从容。如果你的产品没到 1000 台量级,直接上 16MB 模块比为了省 2 块钱把分区表压到极限要省心得多。
4.3 寻址空间之外的隐藏开销:OTA 临时读写和 NVS 备份
Flash 分区大小只是账面上的空间,真正设计时要留三种余量:
- OTA 升级的临时空间:esp_ota_ops 写入新固件时,是边下载边写到另一个分区。如果你的固件有 1.5MB,ota_1 分区必须大于 1.5MB,否则升级过程中写入失败,回滚机制生效,导致设备停留在旧版本。
- 文件系统 GC(垃圾回收)空间:LittleFS 做 GC 时,需要一个区块大小的临时空间来搬移数据。如果文件系统分区被塞到 98% 满,GC 会频繁失败,写文件报 ENOSPC。留 10% 空间给 GC 是硬指标。
- NVS 的回滚备份:NVS 分区太小时,修改 WiFi 配置的频繁 key 可能触发“分区满重置”,导致全部配置丢失。建议 NVS 至少给 16KB 以上。
5. 实际项目中的存储选型策略:什么场景用哪种介质
5.1 高速采样场景的存储铁三角
做声音采集、加速度计连续记录这种需求时,要同时规划三件事:内存缓冲、Flash 写日志、SD 卡大文件存储。
我的一个模拟项目(某环境数据记录仪)需求是:每 100ms 记一次温湿度+噪声值,连续跑 30 天。如果直接写 SD 卡,每次写 100 字节,一天按 864 次算,30 天就是 25 万次随机写。SD 卡类消费级卡的随机写寿命撑不住这个频率。最终方案:
- 内存里开一个 4KB 的 ring buffer,攒到 4KB 再写 SD 卡(顺序大块写,SD 卡友好)
- 同时 4KB 满时先写 LittleFS 的一个临时文件(Flash 顺序写,约 5MB/s),数据完整性优先
- SD 卡写入失败时,Flash 端临时文件至少保住最近 1 小时的数据;SD 卡恢复后把临时文件搬过去
这种“双写 + 降级”策略在大流量采集项目里很常见。直接在裸机上单线程循环写 SD 卡,无论怎么调,都绕不开 SD 卡偶尔弹出来的 IO error。
5.2 HTTP/Web 服务器场景:文件系统放网页资源还是放内存
ESP32 做 Web 服务器时有个经典问题:网页的 HTML/CSS/JS 放哪里。放 LittleFS,让 Web 服务器从 Flash 读;还是直接编进固件,编译成常量数组?
我实测过:把一个 30KB 的 HTML + 主要 JS 文件编进固件,Flash 占用多 30KB(这部分本来就要写),但网页加载速度从 LittleFS 读的 380ms 降到 15ms——因为数据在 Cache 里,不经过文件系统解析。如果你的页面经常改,放 LittleFS 灵活;如果产品要稳定发布,编进去更稳。
还有更好的思路:用外部 SD 卡托管网页资源,LittleFS 只存引导页和配置 JSON。SD 卡能放几百 MB 的资源,高速 SDMMC 读取也快(实测 15MB/s 足够 Web 服务器用)。我自己搭过一个设备日志展示页面,把 Chart.js 和 jQuery 都丢 SD 卡上,首次加载 120KB 资源,SDMMC 4bit 模式耗时约 70ms,体感几乎秒开。
5.3 掉电保存的终极方案:NVS + Flash 文件系统 + 外置 EEPROM
有些应用要求掉电瞬间保存关键数据(比如计数值、电机零点位置)。NVS 掉电保护靠谱,但写入频率有限;SPIFFS/LittleFS 掉电写可能损坏文件;外部 EEPROM(如 AT24C256)则能扛 100 万次。
我发现一个比较好用的分层思路:关键状态走 NVS(掉电不丢,频率低);过程数据走 LittleFS(掉电最多丢最后 1 块,但可取回);事件日志走 SD 卡(容量大,坏了换了就行)。如果预算允许,外挂一颗 SPI 引脚扩展的 EEPROM(仅 8 引脚小芯片),实现纯“掉电值保持”需求,成本低于一块 32GB TF 卡且稳定得多。这也是我最后建议你用 NVS 存“出厂参数”+“运行模式”,用外置 EEPROM 存“累计次数”的原因。
6. 速度对比的实测方法:用对工具才拿得到真实数据
6.1 测速代码的正确打开方式
直接裸跑测试代码,如果不注意缓存、DMA 和系统调度,拿到的数会偏离真实。我推荐用 ESP-IDF 的标准做法:
首先,把 CPU 定频在 240MHz,关掉省电模式(esp_pm 里禁用 CPU 调频),这样测速不受 dynamic frequency scaling 干扰。其次,每次读写 4KB 对齐的缓存块,测前要主动清 cache(在写入 Flash / SD 卡之前无法直接清 cache,但读 Flash 时可以先读无效地址来强制重填 cache;SD 卡不需要,DMA 直接搬)。
用 esp_timer 计时而不是 millis(),后者在 Arduino 环境里是 tick 累加,精度低。实测代码核心:
#include "esp_flash.h" #include "esp_timer.h" #include "sdmmc_cmd.h" void benchmark_flash(void) { uint8_t buf[4096] __attribute__((aligned(4))); int64_t start = esp_timer_get_time(); for (int i = 0; i < 128; i++) { esp_flash_read(NULL, buf, i * 4096, 4096); } int64_t elapsed = esp_timer_get_time() - start; float mb_per_s = (128 * 4096.0 / 1024 / 1024) / (elapsed / 1e6); ESP_LOGI("BENCH", "Flash read: %.2f MB/s", mb_per_s); }SD 卡测速同理,但别用 fread(),要直接用 sdmmc_read_sectors() 跑底层命令,避开 FAT 文件系统的缓冲层。
6.2 速度对比表怎么读
项目中存储性能表要按“顺序读/写、随机读/写、擦除”五个维度才对。我整理一张常用维度对比表,方便你在技术评审会上直接拍板:
| 指标 | 内部 Flash | SD 卡(SDMMC 4bit) | SD 卡(SPI) |
|---|---|---|---|
| 顺序读(实测) | 12.6 MB/s | 15.6 MB/s | 1.9 MB/s |
| 顺序写(实测) | 4.9 MB/s | 4.9 MB/s | 0.75 MB/s |
| 随机 4K 读 | 1.8 MB/s | 1.3 MB/s | 0.32 MB/s |
| 随机 4K 写 | ~0.5 MB/s(需要擦写均衡) | 0.63 MB/s | 0.19 MB/s |
| 擦除周期 | 扇区 4KB / ~80ms | 块 / ~几十 ms 到百 ms | 同左 |
| 寿命 | 扇区 10 万次 | 卡级(视主控) | 同左 |
| 掉电可靠性 | 高(需文件系统配合) | 中(FAT 易损、LittleFS 稍强) | 中 |
读这张表的关键:写性能,Flash 和 SD 卡(4bit)在顺序写上是同一档;读性能,Flash 略慢于 SD 卡(4bit),但远好于 SPI 模式 SD 卡。这意味着如果你的应用是“大量读网页资源+少量写日志”,Flash 够了;如果是“持续录制音频/视频”,必须上 SDMMC 4bit SD 卡。
6.3 带宽之外的死角:系统调度的交互延迟
速度数字好看,但不代表系统响应实时。SD 卡写大数据块时,SDMMC DMA 会占用系统总线;Flash 擦除时会暂停 CPU 对 Flash 的访问(但代码从 IRAM 跑可以继续执行)。实测中,一个 4KB 的 SPI Flash 写操作会让中断响应延迟增加约 18μs,一个扇区擦除能让中断延迟暴增到 80ms 以上。如果项目里跑着电机控制或数据采集中断,这 80ms 的窗口可能丢数据。
解决思路:Flash 擦除尽量放在系统空闲期(用 FreeRTOS 的低优先级任务),SD 卡写操作开 DMA 的话,中断只会延迟几十微秒级别。做高速采样应用时,别把它当纯存储模块设计,要当成“一个自带存储的总线系统”考虑时序。
7. 踩坑记录:Flash 和 SD 卡最常见的三个翻车现场
7.1 翻车一:Flash 读到一半死机,根因是 Cache 命中不保证
某项目里,代码里直接访问 Flash 映射地址(ESP-IDF 的 mmap 机制),跑了一会儿系统重启,log 里看到 Guru Meditation Error / StoreProhibited。排查反复,最后发现是 mmap 区域的访问方式错了。正确做法是用 esp_partition_mmap() 拿到映射地址,然后通过 memcpy 读取,而不是直接按指针解引用去读——因为映射区是只读的,直接写或做非对齐访问就会触发 StoreProhibited。
我后来把规则钉死:映射区只读、对齐访问、当成 DMA 目标时不设 cacheable。别嫌啰嗦,踩过这个坑的人都知道这个错误多隐蔽。
7.2 翻车二:SD 卡拔插后文件系统挂载不上
设备允许热插拔 SD 卡,但热插拔后 SDMMC 外设经常出现 init 失败(错误 0x107)。根因是卡在拔出时 DAT 和 CLK 线悬空状态导致 SDMMC 控制器状态机错乱,重新插卡后初始化时序没对齐。我的解决方法是:
- 每次卡检测(用 GPIO 检测卡插入引脚)触发后延时 200ms 再初始化,等电源稳定
- 初始化失败后强制 SDMMC 外设 reset(RTC 域复位),再重新初始化
- 不要反复 init,最多重试 4 次,然后报错退让
另外,如果用的是 SPI 模式,拔出时 CS 引脚应该被拉高(否则模块误以为卡在发数据),我在 CS 引脚上加 10k 上拉电阻后这个 bug 消失。
7.3 翻车三:OTA 升级后文件系统数据清零
第一次做 A/B OTA 时升级成功,但升级后配置全丢了,日志也只剩启动前的。研究了半天分区表才发现:我把 storage 分区的 Offset 放在 ota_1 之后,但 factory 模式升级后,bootloader 默认从 factory 启动,文件系统挂载时还是用编译时写死的 sector 地址。而 A/B 切换后,整个分区表相当于重新排列,文件系统偏移全变了。
修正方案是:升级前清理文件系统重挂载,或者干脆把 storage 分区放在固定 Offset(例如 0x300000),不随着 OTA 分区动态变化。这是测试中才暴露的布局问题,调试时要特别注意分区表的动态特性。
8. 读完这篇你该带走的操作清单
- 别在 NVS 里堆日志,高频写走 LittleFS,设置类低频写走 NVS。顺便,NVS 还可以存 Flash 分区表余量校准值,省得每次计算偏移。
- SD 卡的供电和时序是最容易翻车的点,设计阶段就按独立供电+下拉电阻+拔插检测来做,别等样机出来了再补。
- 分区表是你最快能优化的地方:默认 factory 固件区 1MB,如果你固件只有 350KB,可以把 factory 改小(比如 512KB),腾出空间给 storage 分区。注意 app 分区有 64KB 的对齐要求,改之前用
parttool.py检查一下,直接改坏分区表会直接起不来。 - 4bit SDMMC 默认占用 GPIO2、4、12-15,如果你这些引脚有其他用途(比如按键、LED、传感器),要么换引脚映射(SDMMC 的 CLK/CMD 不能任意映射,DAT 可以),要么老实切回 SPI 模式。
- 测速永远不要信“纸面带宽”,按我第 6 节的方法跑一遍真实测试,才选得准介质。
我在实际项目里见过太多人一开始拍脑袋选了存储方案,最后在量产前夜熬夜调分区表、改引脚复用,苦不堪言。那次我在某个 IoT 网关项目里,4MB Flash 的模块刷到 98%,OTA 怎么做都失败,最后换 16MB 模块多花了 3 块钱/片,省了整整两周的加班时间。后来我把"先算存储账再选型"变成了惯例,从那以后,惊险的存储危机少了很多。希望这篇笔记也能让少走这些弯路。