0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

SPIFFS / LittleFS 檔案系統完整教學:ESP32 資料儲存與 IoT 實作

2026 年 6 月 6 日 614點熱度 0人點贊 0條評論

SPIFFS / LittleFS 檔案系統完整教學:ESP32 資料儲存與 IoT 實作封面

先講結論:ESP32 要存東西,就別再硬撐 EEPROM 了

我猜很多人跟我一樣,是從 EEPROM 開始玩 ESP32 的。一開始只存個 WiFi 密碼,感覺還挺夠用;等到要放 HTML 網頁、要記一整年的感測器歷史資料,就開始卡了。

EEPROM 的毛病有兩個:容量小,而且壽命有限(大約 10 萬次擦寫)。真要長期記錄、要放網頁資源,你需要的是檔案系統 —— 也就是 SPIFFS 跟 LittleFS。

它們做的事情說穿了不複雜:讓你把 ESP32 上的 SPI Flash 當成一張迷你 SD 卡來用。一樣是開檔、讀、寫、關檔,只是底層從磁碟換成了 Flash。

為什麼需要檔案系統?

在嵌入式 IoT 專案裡,會被存起來的東西大概就這四類:

  • 設定檔(Configuration):WiFi SSID/密碼、感測器閾值、校準參數
  • 網頁資源:HTML、CSS、JS 檔案(給 Web Server 用)
  • 資料記錄:感測器歷史數據、事件日誌
  • 韌體更新:OTA 更新的暫存檔

其中我最常看到新手卡在第二類。想做個「手機連上就能開網頁」的小專案,網頁檔案一多,全部塞進程式碼字串裡會非常痛苦;這時候把 HTML、CSS、JS 丟進檔案系統,程式碼瞬間乾淨。

SPI Flash 硬體原理:讀很簡單,寫很麻煩

先講硬體。ESP32 用的 SPI Flash 晶片(常見是 Winbond W25Q32 或同級)透過 SPI/QSPI 介面跟 SoC 通訊。

讀取超單純:CS 拉 Low → 送 Read 指令 0x03 → 送 24-bit 位址 → Flash 把資料吐回來。就是這麼一行的事。

但寫入完全是另一回事,這也是我第一次翻 datasheet 時卡最久的地方:

  • 寫入前必須擦除:不能直接把 1 改成 0,只能寫進已經擦除(全 1)的區域
  • 最小擦除單位 = Sector (4KB):你沒辦法只擦掉 1 byte
  • 最小寫入單位 = Page (256B):一次最多寫 256 bytes
  • 擦除次數壽命:大約 10 萬次(Wear Leveling 可以延長)

用一句話總結這個特性:「改一個 byte」的代價是「先擦掉一整個 4KB」。這件事決定了後面所有檔案系統的設計,也是為什麼我們用現成函式庫、而不是自己刻。

寫入檔案到 SPI Flash 的流程

把流程攤開就是這樣:掛載、開檔、擦除、寫入、關檔。任何一步想偷懶(尤其是少關檔),資料就不會真正落地。

SPIFFS 跟 LittleFS,該選哪一個

結論我先給:新專案直接用 LittleFS,不用猶豫。

理由很明確 —— ESP32 Arduino Core 從 2.0.0 版開始就把 LittleFS 設成預設檔案系統,SPIFFS 只是保留向後相容。而且原文說得更直接:LittleFS 更快、更穩定,還支援目錄結構。

SPIFFS 與 LittleFS 差異

那什麼時候還會遇到 SPIFFS?大概就是看舊教學、或接手別人的舊專案。這種時候如果冒出 error: -10025 這種掛載失敗訊息,八成是分區設定沒對上,後面除錯那一段會再講。

選檔案系統之前,先看你的 Flash 容量能留多少空間給它 —— 這是原廠 partition.csv 的預設配置:

Flash 大小 SPIFFS/LittleFS 可用空間 建議檔案數 適用場景
4 MB ~1.5 MB (partition.csv 設定) 50~100 個 一般 IoT 專案
8 MB ~4 MB 200+ 個 含 Web 資源
16 MB ~11 MB 500+ 個 大量記錄/圖形資源

ESP32 LittleFS 實作

1. 安裝與設定

要在 Arduino IDE 裡把檔案上傳到 Flash,得先準備工具。原文列的是這兩條路:

  • 下載 ESP32FS,或改用 PlatformIO 的 Upload Filesystem Image
  • 在專案中建立 data/ 資料夾,把要上傳的檔案放進去
  • 使用 Tools → ESP32 Sketch Data Upload 上傳

上傳檔案到 ESP32 的工具與前置條件

這裡有個我踩過的坑:檔案一定要放在 data/ 底下。放錯位置它不會報錯,只是上傳完你在裝置裡怎麼找都找不到。

2. 基本檔案讀寫

骨架跟你在電腦上寫檔案沒兩樣:掛載、開檔、寫入、關檔,再開起來讀。

// ESP32 LittleFS - 基本檔案讀寫範例
#include "LittleFS.h"

void setup() {
    Serial.begin(115200);

    // 掛載檔案系統
    if (!LittleFS.begin(true)) {  // true = 格式化如果掛載失敗
        Serial.println("LittleFS 掛載失敗!");
        return;
    }
    Serial.println("LittleFS 掛載成功");

    // 寫入檔案
    File file = LittleFS.open("/config.json", FILE_WRITE);
    if (file) {
        String json = "{";
        json += "\"ssid\":\"MyWiFi\",";
        json += "\"password\":\"secret123\",";
        json += "\"interval\":300";
        json += "}";
        file.print(json);
        file.close();
        Serial.println("config.json 已寫入");
    }

    // 讀取檔案
    file = LittleFS.open("/config.json", FILE_READ);
    if (file) {
        Serial.println("--- config.json 內容 ---");
        while (file.available()) {
            Serial.write(file.read());
        }
        Serial.println();
        file.close();
    }
}

void loop() {}

有兩個細節我想特別圈出來。第一,LittleFS.begin(true) 的那個 true 代表「掛載失敗就自動格式化」,開發階段很方便,要不要開就看你能忍受多少資料風險。第二,FILE_WRITE 是寫、FILE_READ 是讀,而寫完一定要 file.close() —— 這點後面除錯還會再遇到。

3. 列出所有檔案

想知道 Flash 裡到底躺了什麼,就自己走一遍目錄:

// 列出 LittleFS 根目錄所有檔案
void listFiles(const char* dirname = "/") {
    File root = LittleFS.open(dirname);
    if (!root || !root.isDirectory()) {
        Serial.println("無法開啟目錄");
        return;
    }

    File file = root.openNextFile();
    while (file) {
        if (file.isDirectory()) {
            Serial.printf("  [DIR] %s\n", file.name());
        } else {
            Serial.printf("  [FILE] %s (%d bytes)\n",
                          file.name(), file.size());
        }
        file = root.openNextFile();
    }
}

這段用 root.openNextFile() 逐筆往後拿,再用 isDirectory() 判斷是目錄還是檔案。除錯時超級好用 —— 「檔案明明上傳了卻打不開」這種問題,通常跑一次這個函式就破案。

LittleFS 的路徑與開啟模式

4. JSON 設定檔管理(ArduinoJson)

設定檔我最推薦存成 JSON,配上 ArduinoJson 幾乎是標配了:

// ESP32 LittleFS + ArduinoJson 設定檔管理
#include <ArduinoJson.h>


struct Config {
    char ssid[32];
    char password[64];
    int  interval;     // 感測器讀取間隔(秒)
    float threshold;   // 溫度警報閾值
} config;

bool loadConfig() {
    File file = LittleFS.open("/config.json", FILE_READ);
    if (!file) return false;

    JsonDocument doc;
    DeserializationError err = deserializeJson(doc, file);
    file.close();

    if (err) {
        Serial.printf("JSON 解析失敗: %s\n", err.c_str());
        return false;
    }

    strlcpy(config.ssid,      doc["ssid"]      | "default",  sizeof(config.ssid));
    strlcpy(config.password,  doc["password"]  | "",         sizeof(config.password));
    config.interval  = doc["interval"]  | 300;
    config.threshold = doc["threshold"] | 50.0;

    return true;
}

bool saveConfig() {
    File file = LittleFS.open("/config.json", FILE_WRITE);
    if (!file) return false;

    JsonDocument doc;
    doc["ssid"]     = config.ssid;
    doc["password"] = config.password;
    doc["interval"] = config.interval;
    doc["threshold"] = config.threshold;

    serializeJsonPretty(doc, file);
    file.close();
    return true;
}

這個版本的靈魂在 loadConfig() 裡的預設值寫法,例如 doc["interval"] | 300,意思是「欄位不存在就當作 300」。靠這招,就算設定檔缺欄位、甚至有點半毀,程式還是能穩穩跑起來 —— 對要放在現場跑好幾個月的 IoT 裝置來說,這比什麼都重要。

實戰:IoT 感測器資料記錄器

把前面學到的全部接起來,做一個「每 10 分鐘記一筆溫度、還能用手機把 CSV 抓下來」的記錄器。架構是:DS18B20 量溫度 → 寫進 LittleFS 的 CSV 檔 → Web Server 開一個 /download 讓你把檔案帶走。

// ESP32 - LittleFS 資料記錄器(含 Web Server 下載)
#include <WiFi.h>
#include <WebServer.h>
#include <LittleFS.h>
#include <OneWire.h>
#include <DallasTemperature.h>

// WiFi
const char* ssid     = "YourSSID";
const char* password = "YourPassword";

// DS18B20
#define ONE_WIRE_BUS 4
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

// Web Server
WebServer server(80);
const char* LOG_FILE = "/sensor_log.csv";

void setup() {
    Serial.begin(115200);

    // 掛載 LittleFS
    if (!LittleFS.begin(true)) {
        Serial.println("LittleFS 掛載失敗");
        return;
    }

    // 初始化感測器
    sensors.begin();

    // 連接 WiFi
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    Serial.printf("\nWiFi 連線成功,IP: %s\n", WiFi.localIP().toString().c_str());

    // 建立 CSV 標頭(如檔案不存在)
    if (!LittleFS.exists(LOG_FILE)) {
        File f = LittleFS.open(LOG_FILE, FILE_WRITE);
        if (f) {
            f.println("timestamp,temperature_c,temperature_f");
            f.close();
        }
    }

    // Web Server 路由
    server.on("/",         handleRoot);
    server.on("/download", handleDownload);
    server.begin();
}

void loop() {
    static unsigned long lastLog = 0;
    server.handleClient();

    if (millis() - lastLog > 600000) {  // 每 10 分鐘
        lastLog = millis();
        logSensorData();
    }
}

void logSensorData() {
    sensors.requestTemperatures();
    float tempC = sensors.getTempCByIndex(0);
    float tempF = tempC * 9.0 / 5.0 + 32.0;

    File f = LittleFS.open(LOG_FILE, FILE_APPEND);
    if (f) {
        char buf[64];
        snprintf(buf, sizeof(buf), "%lu,%.2f,%.2f\n",
                 millis() / 1000, tempC, tempF);
        f.print(buf);
        f.close();
        Serial.printf("已記錄: %.2f°C\n", tempC);
    }
}

void handleRoot() {
    String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>";
    html += "<title>感測器資料記錄器</title></head><body>";
    html += "<h1>ESP32 感測器資料記錄器</h1>";
    html += "<p><a href='/download'>下載 sensor_log.csv</a></p>";
    html += "<p>目前檔案大小: " + String(getFileSize()) + " bytes</p>";
    html += "</body></html>";
    server.send(200, "text/html", html);
}

void handleDownload() {
    File f = LittleFS.open(LOG_FILE, FILE_READ);
    if (!f) {
        server.send(404, "text/plain", "檔案不存在");
        return;
    }
    server.streamFile(f, "text/csv");
    f.close();
}

size_t getFileSize() {
    File f = LittleFS.open(LOG_FILE, FILE_READ);
    if (!f) return 0;
    size_t s = f.size();
    f.close();
    return s;
}
# ESP32 partition.csv 範例
nvs,      data, nvs,     0x9000,  0x6000,
otadata,  data, ota,     0xf000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x1E0000,
app1,     app,  ota_1,   0x1F0000,0x1E0000,
spiffs,   data, spiffs,  0x3D0000,0x200000,  ← 2MB 給檔案系統
coredump, data, coredump,0x5D0000,0x10000,

ESP32 Flash 分區佈局

把每個分區拿到的容量畫成圖會更直觀:spiffs 這塊直接吃掉 2MB,跟應用程式差不多大,這就是它敢讓你放網頁資源跟資料記錄的本錢。

partition.csv 各分區容量比較

常見問題與除錯

Q: E (28) SPIFFS: mount failed, error: -10025

通常是 Flash 分區大小跟程式碼對不上。去檢查 partition.csv 裡的 spiffs 分區夠不夠大,通常調大一點就活了。

Q: 檔案寫入後,重開機就不見了

這幾乎是經典題:寫入後沒有呼叫 file.flush() 或 file.close()。LittleFS 用的是 Write-back Cache,還沒 flush 的資料只要斷電就直接消失。我自己現在寫完檔一律立刻 close,不省這一行。

Q: 要怎麼確認用了多少空間?

不用猜,兩個函式就能問出來:

// 檢查 LittleFS 使用狀況
size_t totalBytes = LittleFS.totalBytes();
size_t usedBytes  = LittleFS.usedBytes();
Serial.printf("總空間: %d bytes, 已用: %d bytes (%.1f%%)\n",
              totalBytes, usedBytes,
              100.0 * usedBytes / totalBytes);

LittleFS 除錯排查表

上面這張表把常見症狀、原因跟解法放一起,下次撞牆可以直接對號入座。

總結

SPIFFS 跟 LittleFS 讓 ESP32 能像一般電腦那樣管理檔案,我認為它是 IoT 開發裡遲早要補的一塊。從設定檔管理、感測器資料記錄,到 Web Server 的靜態資源、OTA 的暫存檔,檔案系統幾乎每個專案都會用到。

所以要記的東西其實只有三件:

  • Flash 的脾氣:寫之前要先擦,最小擦除單位 4KB、最小寫入單位 256B,壽命大約 10 萬次
  • 選 LittleFS:ESP32 Arduino Core 2.0+ 的預設選項,更快、更穩定、支援目錄結構
  • 寫完就 close:Write-back Cache 沒 flush,斷電就是一場空

把這三件事記牢,剩下的就是照著範例打一遍。新專案請直接選 LittleFS —— 原文的結論也是這一句。

標籤: 教學
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang