
先講結論: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」。這件事決定了後面所有檔案系統的設計,也是為什麼我們用現成函式庫、而不是自己刻。

把流程攤開就是這樣:掛載、開檔、擦除、寫入、關檔。任何一步想偷懶(尤其是少關檔),資料就不會真正落地。
SPIFFS 跟 LittleFS,該選哪一個
結論我先給:新專案直接用 LittleFS,不用猶豫。
理由很明確 —— ESP32 Arduino Core 從 2.0.0 版開始就把 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 上傳

這裡有個我踩過的坑:檔案一定要放在 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() 判斷是目錄還是檔案。除錯時超級好用 —— 「檔案明明上傳了卻打不開」這種問題,通常跑一次這個函式就破案。

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,

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

常見問題與除錯
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);

上面這張表把常見症狀、原因跟解法放一起,下次撞牆可以直接對號入座。
總結
SPIFFS 跟 LittleFS 讓 ESP32 能像一般電腦那樣管理檔案,我認為它是 IoT 開發裡遲早要補的一塊。從設定檔管理、感測器資料記錄,到 Web Server 的靜態資源、OTA 的暫存檔,檔案系統幾乎每個專案都會用到。
所以要記的東西其實只有三件:
- Flash 的脾氣:寫之前要先擦,最小擦除單位 4KB、最小寫入單位 256B,壽命大約 10 萬次
- 選 LittleFS:ESP32 Arduino Core 2.0+ 的預設選項,更快、更穩定、支援目錄結構
- 寫完就 close:Write-back Cache 沒 flush,斷電就是一場空
把這三件事記牢,剩下的就是照著範例打一遍。新專案請直接選 LittleFS —— 原文的結論也是這一句。
文章評論