ESP32-S3 Web 文件上传 SD 卡慢:1KB 小块的命令开销与 64KB PSRAM 缓冲解决
背景
项目是一个心率数据记录器,使用 ESP32-S3 通过 SDMMC 接口将 ADC 采集数据写入 SD 卡。设备需要脱离电脑独立运行,因此在固件里实现了一个 Web 文件管理器,用于在浏览器中临时收发文件:浏览目录、下载记录数据、上传配置、删除旧数据。

文件管理器通过统一 API POST /api/fs 分发所有操作。上传走流式协议:浏览器发 application/octet-stream 请求,第一行是 JSON 元数据(包含 action、fs、path、size),以 \n 分隔,后续是原始文件字节。服务端从 HTTP socket 逐块接收数据,fwrite 到临时文件,完成后 rename 到目标路径。
浏览器端用 XHR 的 upload.onprogress 报告上传进度。
问题
上传 64KB 文件到 SD 卡,耗时约 1 秒。等效速度 ~64 KB/s。
SD 卡标称速率 20 Mbps(2.5 MB/s)。利用率只有 2.5%。上传到内部 LittleFS 存储明显更快。
瓶颈定位
原始代码
#define FILE_MGR_CHUNK_SIZE 1024u
static esp_err_t stream_upload_body(httpd_req_t *req, FILE *file,
size_t bytes_remaining)
{
char buffer[FILE_MGR_CHUNK_SIZE];
while (bytes_remaining > 0u) {
size_t requested = bytes_remaining < sizeof(buffer)
? bytes_remaining : sizeof(buffer);
int received = recv_retry_timeout(req, buffer, requested);
if (received <= 0 ||
fwrite(buffer, 1u, (size_t)received, file) != (size_t)received) {
return ESP_FAIL;
}
bytes_remaining -= (size_t)received;
}
return ESP_OK;
}栈上 1KB 缓冲区,每次循环:recv(1KB) + fwrite(1KB→SD)。
64KB 文件循环 64 次。
单次 SD 写入的开销
SD 卡每次写入都包含固定的命令开销:SDIO 命令握手、FAT 表读取和更新、NAND 块写入。这些开销与写入数据量无关,1 字节和 512 字节的命令往返时间差距不大。1KB 小块写入将这些固定开销放大了数十倍。
排除 WiFi
ESP32-S3 WiFi 的实际吞吐远高于 64 KB/s。瓶颈不在网络层。
为什么内部存储更快
内部 LittleFS 走 SPI Flash,单次写入延迟远低于 SD 卡的 SDIO 命令往返。同样的 1KB 循环,LittleFS 每次写入的固定开销小得多,所以总耗时明显更短。
对比 OTA 上传
同一项目中的 OTA 固件上传使用 4KB 缓冲区:
#define OTA_UPLOAD_BUF_SIZE 4096u写入次数减少到 1/4,速度明显更快。
解决:64KB PSRAM 大块缓冲
ESP32-S3-WROOM-1-N8R8 模块带 8MB 八线 PSRAM。项目中的 SD 数据记录模块已使用 128KB PSRAM 作为数据缓冲队列,剩余可用空间约 7.9MB。
将上传缓冲区从栈上 1KB 改为 PSRAM 上的 64KB 动态分配:
#include "esp_heap_caps.h"
#define FILE_MGR_UPLOAD_BUF_SIZE 65536u
static esp_err_t stream_upload_body(httpd_req_t *req, FILE *file,
size_t bytes_remaining)
{
char *buffer = heap_caps_malloc(FILE_MGR_UPLOAD_BUF_SIZE,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (buffer == NULL) {
buffer = heap_caps_malloc(FILE_MGR_UPLOAD_BUF_SIZE,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
}
if (buffer == NULL) {
return ESP_ERR_NO_MEM;
}
esp_err_t err = ESP_OK;
while (bytes_remaining > 0u) {
size_t requested = bytes_remaining < FILE_MGR_UPLOAD_BUF_SIZE
? bytes_remaining : FILE_MGR_UPLOAD_BUF_SIZE;
int received = recv_retry_timeout(req, buffer, requested);
if (received <= 0 ||
fwrite(buffer, 1u, (size_t)received, file) != (size_t)received) {
err = ESP_FAIL;
break;
}
bytes_remaining -= (size_t)received;
}
free(buffer);
return err;
}MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT 指定从 PSRAM 分配。如果 PSRAM 不可用,回退到内部 RAM。上传完成后立即 free,不长期占用。
改动前后对比
| 指标 | 1KB 栈缓冲 | 64KB PSRAM 缓冲 |
|---|---|---|
| SD 写入次数 / 64KB | 64 次 | 1 次 |
| 内部 RAM 占用 | 1KB(栈) | 0 |
| PSRAM 占用 | 0 | 64KB(临时) |
瓶颈从 SD 卡的小块写入开销转移到 WiFi 吞吐本身。
结论
嵌入式 I/O 性能的关键指标不是设备标称速率,而是每次操作的固定开销。SD 卡的每次写入都有不可省略的命令往返,1KB 小块写入将这些开销放大到不可接受的程度。
PSRAM 是 ESP32-S3 上容量充裕但容易被忽略的资源。8MB 外部 RAM 适合存放上传缓冲、采集数据帧等大块临时数据,避免占用紧张的内部 RAM。
性能分析的核心步骤:量化每个环节的耗时,排除非瓶颈层(WiFi),对比同类操作(OTA 上传)确认差距,最后用最小改动验证假设。
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。