
ESP32 SD 卡讀寫:SPI 與 SDMMC 模式完整教學
做 IoT 專案做到某一天,你一定會遇到這個問題:資料太多,RAM 裝不下,得存到卡上。SD 卡就是最常用的那張卡 —— 感測器資料記錄(Data Logging)、放韌體更新檔、存多媒體檔案,幾乎都靠它。
而 ESP32 支援兩種 SD 卡通訊模式:SPI 模式與 SDMMC 模式。這兩條路不是「新的比較好」那麼簡單,它們在腳位數量、可用 GPIO、最高速度上各有取捨。這篇我會把兩條路都走一遍,從最底層的初始化序列,一路講到實際的資料記錄程式。本文依序會走過:
- SD 卡 SPI 初始化序列與通訊協定
- 區塊讀取(CMD17)與區塊寫入(CMD24)時序
- ESP32 Arduino SD.h 與 SD_MMC.h 程式設計
- FAT32 檔案系統操作(建立、讀寫、刪除)
- CSV 資料記錄(Data Logging)實戰
- SPI vs SDMMC 效能比較
SD 卡通訊模式
先看全貌。下面這張表把三種接法攤開比較,你可以先憑「我要用幾條線」跟「我要多快」這兩個問題,決定自己該走哪一條路。

這裡有個很容易忽略的重點:SPI 的腳位是可以自己選的,只要避開不能用的腳位就好;但 SDMMC 用的是固定腳位,一個都不能改。所以如果你的專案已經把 GPIO 15、14、2、4、12、13 用掉了,那 SDMMC 這條路基本上就跟你無緣。
再往下看一層。不管你選哪一種模式,最後碰到的檔案操作 API 其實是同一套 —— 兩條路都經過 FATFS 落到 FAT32 檔案系統:

看懂這張堆疊圖,你之後的除錯會快很多:開檔失敗是檔案系統層的問題,掛載失敗通常是驅動或硬體層的問題,兩者的排查方向完全不同。
SPI 初始化序列
SD 卡在 SPI 模式下的初始化流程是固定的,順序不能跳,也幾乎不能省:
- 延遲 ≥ 74 CLK:上電後先給 74 個空時脈讓 SD 卡內部電壓穩定
- CMD0 (GO_IDLE):重置信卡,進入 SPI 模式
- CMD8 (SEND_IF_COND):檢查 SD 卡版本與電壓範圍(2.7~3.6V)
- CMD58 (READ_OCR):讀取 OCR 暫存器確認電壓支援
- ACMD41 (SD_SEND_OP_COND):啟動初始化並等待卡就緒(HCS=1 表示支援 SDHC)
- CMD2 (ALL_SEND_CID):取得卡片識別碼
- CMD3 (SEND_REL_ADDR):取得相對位址(RCA)
- CMD7 (SELECT/DESELECT_CARD):選擇卡片進入傳輸狀態
- 初始化後可將時脈提高到 20~40 MHz

這串流程裡唯一會「卡住」的步驟是第 5 步的 ACMD41 —— 它在等卡內部把電壓穩定下來,所以實作上必須是重送直到就緒,而不是送一次就往下走。很多「初始化偶爾失敗」的案例,追到最後都是在這裡沒有正確地重試。
區塊讀取時序
讀取的最小單位是一個 512 bytes 的區塊,流程是「先下命令、再收資料」:

要注意的是:命令送出去之後,資料不是立刻回來。你得持續送時脈,等卡先回一個 R1 回應、再送一個 0xFE 的資料令牌,真正的 512 bytes 才會跟著出來。如果中間 CS 被拉高,這筆交易就作廢了。
區塊寫入時序
寫入的順序正好反過來 —— 這次是「先送資料、再收回應」:

最容易被忽略的是最後那段:卡收下資料之後,會在 MISO 上把訊號拉低表示「我正在把資料寫進 Flash」,這段時間你只能等。這也是為什麼寫入永遠比讀取慢 —— 慢的不是傳輸,是等待。
Arduino 程式設計
SPI 模式:基本讀寫
入門先從最標準的寫法開始,一行 SD.begin() 就把上面那串初始化序列全部處理掉:
// ESP32 SD 卡 SPI 模式 — 基本讀寫
#include <SD.h>
#include <SPI.h>
#define SD_CS 5 // Chip Select (GPIO 5)
void setup() {
Serial.begin(115200);
// 初始化 SD 卡 (SPI 模式)
if (!SD.begin(SD_CS)) {
Serial.println("SD 卡初始化失敗!");
Serial.println("請檢查:");
Serial.println(" 1. 接線是否正確");
Serial.println(" 2. SD 卡是否格式化為 FAT32");
Serial.println(" 3. CS 腳位是否正確");
return;
}
Serial.println("SD 卡初始化成功!");
}
void loop() {
// === 寫入檔案 ===
File dataFile = SD.open("/test.txt", FILE_WRITE);
if (dataFile) {
dataFile.println("Hello from ESP32!");
dataFile.printf("Timestamp: %lu
", millis() / 1000);
dataFile.close();
Serial.println("寫入成功!");
} else {
Serial.println("開啟檔案失敗(寫入)");
}
// === 讀取檔案 ===
File readFile = SD.open("/test.txt");
if (readFile) {
Serial.println("=== 檔案內容 ===");
while (readFile.available()) {
Serial.write(readFile.read());
}
readFile.close();
Serial.println("=== 結束 ===");
}
delay(5000);
}
如果 SD.begin() 回傳 false,程式碼裡印的那三行檢查提示就是排查順序:先看接線,再看卡片的格式是不是 FAT32,最後確認 CS 腳位有沒有對上。
CSV 感測器資料記錄(Data Logging)
接下來是實戰情境:把感測器資料寫成 CSV。這裡的關鍵不是寫入本身,而是時間戳從哪裡來 —— 有 RTC 就用真實時間,沒有就退回 millis():
// ESP32 SD 卡 CSV 資料記錄器
#include <SD.h>
#include <SPI.h>
#include "RTClib.h" // 選用:DS3231 RTC 模組
#define SD_CS 5
RTC_DS3231 rtc;
File logFile;
void setup() {
Serial.begin(115200);
// 初始化 RTC(選用)
if (!rtc.begin()) {
Serial.println("RTC 未找到,將使用 millis() 時間戳");
}
if (!SD.begin(SD_CS)) {
Serial.println("SD 卡初始化失敗!");
return;
}
// 檢查檔案是否存在,若不存在則寫入標題行
if (!SD.exists("/sensor.csv")) {
logFile = SD.open("/sensor.csv", FILE_WRITE);
if (logFile) {
logFile.println("timestamp,temp,hum,pressure,light");
logFile.close();
}
}
Serial.println("SD 卡資料記錄器已啟動");
}
void logData(float temp, float hum, float press, int light) {
logFile = SD.open("/sensor.csv", FILE_APPEND); // 附加模式
if (!logFile) {
Serial.println("開啟記錄檔失敗!");
return;
}
// 時間戳
unsigned long ts;
if (rtc.begin()) {
DateTime now = rtc.now();
logFile.printf("%04d-%02d-%02d %02d:%02d:%02d,",
now.year(), now.month(), now.day(),
now.hour(), now.minute(), now.second());
} else {
logFile.printf("%lu,", millis() / 1000);
}
// 感測器資料
logFile.printf("%.1f,%.1f,%.1f,%d
", temp, hum, press, light);
logFile.close();
}
void loop() {
// 模擬感測器資料
float t = 25.0 + random(-20, 20) / 10.0;
float h = 60.0 + random(-50, 50) / 10.0;
float p = 1013.0 + random(-30, 30) / 10.0;
int l = analogRead(34);
logData(t, h, p, l);
Serial.printf("Logged: %.1f %.1f %.1f %d
", t, h, p, l);
delay(60000); // 每分鐘記錄一筆
}
另外注意開檔用的是 FILE_APPEND。如果你每次都開 FILE_WRITE 然後關檔,資料是接在後面沒錯,但養成用 APPEND 的習慣比較安全,不會有一天不小心把先前的紀錄整段蓋掉。真正的關鍵則是 close():沒關檔就斷電,卡上的 FAT 表很容易壞掉。
檔案管理:列出所有檔案
當專案跑了一段時間,你總會想知道卡上到底有什麼、有沒有把空間寫爆。這支遞迴函式可以列出目錄樹與每個檔案的大小:
// 列出 SD 卡根目錄所有檔案與大小
void printDirectory(File dir, int numTabs) {
while (true) {
File entry = dir.openNextFile();
if (!entry) break;
for (uint8_t i = 0; i < numTabs; i++) Serial.print(" ");
Serial.print(entry.name());
if (entry.isDirectory()) {
Serial.println("/");
printDirectory(entry, numTabs + 1);
} else {
Serial.printf(" %lu bytes
", entry.size());
}
entry.close();
}
}
void listRoot() {
File root = SD.open("/");
if (root) {
printDirectory(root, 0);
root.close();
}
}
SDMMC 4-bit 模式(最快)
要速度就換 SDMMC 模式,但代價是腳位固定、而且不能改:
// ESP32 SDMMC 4-bit 模式
// 注意!SDMMC 模式使用固定腳位,不可更改!
#include "SD_MMC.h"
void setup() {
Serial.begin(115200);
// SDMMC 4-bit 模式初始化
if (!SD_MMC.begin()) {
Serial.println("SDMMC 初始化失敗!");
Serial.println("請檢查:");
Serial.println(" CMD → GPIO 15");
Serial.println(" CLK → GPIO 14");
Serial.println(" DATA0 → GPIO 2");
Serial.println(" DATA1 → GPIO 4");
Serial.println(" DATA2 → GPIO 12");
Serial.println(" DATA3 → GPIO 13");
return;
}
Serial.println("SDMMC 模式初始化成功!");
// 讀寫測試
File f = SD_MMC.open("/speed.txt", FILE_WRITE);
if (f) {
// 寫入 1MB 測試資料
uint8_t buf[512];
memset(buf, 0xAA, 512);
unsigned long start = micros();
for (int i = 0; i < 2048; i++) {
f.write(buf, 512);
}
f.close();
unsigned long elapsed = micros() - start;
float speed = (1024.0 * 1024.0) / elapsed;
Serial.printf("1MB 寫入耗時: %.2f s (%.2f MB/s)
",
elapsed / 1e6, speed);
}
}
這段程式順便做了一次 1 MB 的寫入計時,方便你驗證自己的卡與接線到底跑出多少速度。如果數字明顯偏低,先檢查 GPIO 12 有沒有外接上拉電阻 —— 那是 SDMMC 4-bit 最常見的效能兇手。
二進位資料記錄(高效)
CSV 好處是人看得懂,缺點是佔空間又慢。如果資料只是要給程式自己讀,直接寫二進位結構會省非常多:
// ESP32 二進位資料記錄 — 更小更快
struct SensorRecord {
uint32_t timestamp;
float temperature;
float humidity;
float pressure;
uint16_t light;
uint16_t crc; // 簡單校驗
};
void logBinaryRecord(File &file, const SensorRecord &rec) {
// 直接寫入結構體(二進位)
file.write((uint8_t*)&rec, sizeof(SensorRecord));
}
void readBinaryRecords(File &file) {
SensorRecord rec;
while (file.read((uint8_t*)&rec, sizeof(SensorRecord)) == sizeof(SensorRecord)) {
// 處理每筆記錄
Serial.printf("%u: %.1f C, %.1f %%
",
rec.timestamp, rec.temperature, rec.humidity);
}
}
這種寫法的代價是「換版本就讀不回舊檔」。所以記得在結構裡留一個版本欄位,或是把欄位順序當成不可變的規格來管。
效能測試
把三種模式擺在一起跑,數字就有了明顯的階梯:

測試結果總結:
- SPI 40 MHz:讀取 5.2 MB/s,寫入 3.5 MB/s(足夠一般日誌記錄)
- SDMMC 4-bit:讀取 16 MB/s,寫入 10 MB/s(適合高速資料串流)
- SDMMC 4-bit + DMA:讀取 24 MB/s,寫入 18 MB/s(極速!)
- 寫入比讀取慢約 30~40%,因為寫入後需要等待 Flash 燒錄
換句話說,如果你只是每分鐘記一筆感測器資料,SPI 模式已經是「用不完的效能」;但如果你要錄音或錄影,SPI 這條路會先撞到天花板。
常見問題與排查
SD 卡的問題幾乎都集中在「初始化失敗」與「寫入不穩」這兩類。下面這張表是我建議的第一站:
| 問題 | 原因 | 解法 |
|---|---|---|
| SD.begin() 失敗 | CS 腳位錯誤或初始化時脈太快 | 確認 CS 腳位,在 begin() 前降低 SPI 時脈 |
| 卡片無法掛載 | 未格式化為 FAT32 | 使用 SDFormatter 工具格式化 |
| SPI 模式 CS 須專用 | 其他 SPI 裝置共用 CS 會衝突 | 設定 SPI.setCSActiveLevel() 或使用獨立 CS |
| SDMMC 不穩定 | GPIO 12 上拉電阻衝突 | GPIO 12 為 MTDI 腳,需注意外部上拉 |
| 寫入速度慢 | 單筆寫入無緩衝 | 使用 file.write(buf, size) 批次寫入 |
| 檔案損毀 | 寫入中斷電或未 close() | 確保 close(),使用 UPS 或大電容 |
| 2GB 以上無法使用 | 僅支援 FAT32 | SDHC/SDXC 需 FAT32 格式化(不能 exFAT) |
| SPI 模式讀不到 SDHC | ACMD41 未設 HCS=1 | Arduino SD.h 已處理,但自訂實作需注意 |
SDMMC 腳位衝突
選了 SDMMC 就等於把六個固定 GPIO 交出去,所以得先知道它們原本還兼了什麼工作:
| SDMMC 腳位 | GPIO | 衝突周邊 | 解決方案 |
|---|---|---|---|
| CMD | 15 | RTC 時脈輸出 | 上拉電阻 10kΩ |
| CLK | 14 | TMS (JTAG) | SDMMC 模式下不可使用 JTAG |
| DATA0 | 2 | 內建 LED | 可使用,但 LED 會閃爍 |
| DATA1 | 4 | 觸摸感測器 T0 | 不可同時使用 T0 觸摸 |
| DATA2 | 12 | MTDI(決定 Flash 電壓) | 注意:GPIO 12 外部不可接上拉!否則會使 Flash 進入 1.8V 模式 |
| DATA3 | 13 | 一般 GPIO | 無衝突 |
整張表裡最需要記住的是 GPIO 12(DATA2)。它是 MTDI 腳,開機時用來決定 Flash 的工作電壓,所以外部絕對不能接上拉電阻 —— 一個不小心的 10kΩ 上拉,就能讓整塊板子進不了正常的開機流程。
SPI 模式也支援 SDHC
最後補一個實務技巧。SPI 模式雖然腳位自由,但預設時脈不一定跑得順,這時候可以手動指定:
// ESP32 SD 卡 SPI 模式 — 手動設定 SPI 時脈
#include <SD.h>
#include <SPI.h>
#define SD_CS 5
void setup() {
Serial.begin(115200);
// 手動設定 SPI 參數
SPI.begin(18, 19, 23, 5); // SCK, MISO, MOSI, CS
SPIClass spi = SPI;
spi.beginTransaction(SPISettings(20000000, MSBFIRST, SPI_MODE0));
if (!SD.begin(SD_CS, spi, 20000000)) {
Serial.println("初始化失敗,降低時脈重試...");
if (!SD.begin(SD_CS, spi, 1000000)) {
Serial.println("仍然失敗!");
return;
}
}
Serial.println("SD 卡初始化成功!");
Serial.printf("卡片容量: %llu MB
",
SD.cardSize() / (1024 * 1024));
Serial.printf("總空間: %llu MB
",
SD.totalBytes() / (1024 * 1024));
Serial.printf("已用空間: %llu MB
",
SD.usedBytes() / (1024 * 1024));
}
這段程式的精神是「先衝高時脈,失敗就自動降速重試」。手上卡種類雜的時候,這個兩段式寫法比固定一個時脈值務實很多。
ESP-IDF 原生 fatfs
如果你不用 Arduino 框架而是直接寫 ESP-IDF,介面就換成 VFS 掛載那一套:
// ESP-IDF SD 卡範例 (SPI 模式)
#include "esp_vfs_fat.h"
#include "sdmmc_cmd.h"
#include "driver/sdspi_host.h"
void app_main(void) {
esp_vfs_fat_sdmmc_mount_config_t mount_config = {
.format_if_mount_failed = false,
.max_files = 5,
.allocation_unit_size = 16 * 1024
};
sdmmc_card_t *card;
const char mount_point[] = "/sdcard";
esp_err_t ret = esp_vfs_fat_sdmmc_mount(
mount_point, &mount_config, sdmmc_host, &slot_config, &card);
if (ret != ESP_OK) {
ESP_LOGE("SD", "Mount failed: %s", esp_err_to_name(ret));
return;
}
sdmmc_card_print_info(stdout, card);
// 讀寫檔案
FILE *f = fopen("/sdcard/hello.txt", "w");
fprintf(f, "Hello from ESP-IDF!");
fclose(f);
// 卸載
esp_vfs_fat_sdmmc_unmount(mount_point, card);
}
這裡的 mount_point 概念很重要:掛載成功之後,SD 卡上的檔案就變成一個普通的 POSIX 路徑,你可以直接用 fopen()、fprintf() 操作,不需要任何 SD 專屬的 API。
總結
繞完一圈,ESP32 的 SD 卡支援其實涵蓋了從低腳位 SPI 到高效能 SDMMC 4-bit DMA 的完整光譜 —— 簡單的資料記錄跟高速多媒體串流,都有對應的方案。

把決策流程攤成文字,選型參考就是這幾句:
- 簡單日誌記錄 (1 KB/s):SPI 模式,僅需 6 條線,可用任何 GPIO
- 高速感測器資料流 (100 KB/s):SPI 40 MHz + 512 bytes 批次寫入
- 音頻/影像記錄 (1 MB/s):SDMMC 1-bit 模式,最少 5 條線
- 高速串流 (10 MB/s+):SDMMC 4-bit + DMA,最佳效能但需注意腳位衝突
- 跨平台相容:使用 FAT32 格式化,Windows/Mac/Linux 可直接讀取
我自己的習慣是:先問資料速率,再挑模式;選定模式之後才開始規劃腳位。順序反過來做,通常會在「畫完電路圖才發現 GPIO 12 不能上拉」這種地方付出代價。
文章評論