ESP32-S3 Web 文件上传 SD 卡慢:1KB 小块的命令开销与 64KB PSRAM 缓冲解决

背景

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

ESP32-S3 Web 文件浏览器界面

文件管理器通过统一 API POST /api/fs 分发所有操作。上传走流式协议:浏览器发 application/octet-stream 请求,第一行是 JSON 元数据(包含 actionfspathsize),以 \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 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。