0x6A Logbook

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

ESP32 SD 卡讀寫完整教學:SPI 模式、SDMMC 4-bit 初始化序列、FAT32 檔案系統與 Data Logging

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

ESP32 SD 卡讀寫完整教學封面

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 卡通訊模式

先看全貌。下面這張表把三種接法攤開比較,你可以先憑「我要用幾條線」跟「我要多快」這兩個問題,決定自己該走哪一條路。

SD 卡三種通訊模式腳位與適用場景對照

這裡有個很容易忽略的重點:SPI 的腳位是可以自己選的,只要避開不能用的腳位就好;但 SDMMC 用的是固定腳位,一個都不能改。所以如果你的專案已經把 GPIO 15、14、2、4、12、13 用掉了,那 SDMMC 這條路基本上就跟你無緣。

再往下看一層。不管你選哪一種模式,最後碰到的檔案操作 API 其實是同一套 —— 兩條路都經過 FATFS 落到 FAT32 檔案系統:

SD 卡存取的軟體堆疊:SD.h、SD_MMC.h、FATFS 與驅動

看懂這張堆疊圖,你之後的除錯會快很多:開檔失敗是檔案系統層的問題,掛載失敗通常是驅動或硬體層的問題,兩者的排查方向完全不同。

SPI 初始化序列

SD 卡在 SPI 模式下的初始化流程是固定的,順序不能跳,也幾乎不能省:

  1. 延遲 ≥ 74 CLK:上電後先給 74 個空時脈讓 SD 卡內部電壓穩定
  2. CMD0 (GO_IDLE):重置信卡,進入 SPI 模式
  3. CMD8 (SEND_IF_COND):檢查 SD 卡版本與電壓範圍(2.7~3.6V)
  4. CMD58 (READ_OCR):讀取 OCR 暫存器確認電壓支援
  5. ACMD41 (SD_SEND_OP_COND):啟動初始化並等待卡就緒(HCS=1 表示支援 SDHC)
  6. CMD2 (ALL_SEND_CID):取得卡片識別碼
  7. CMD3 (SEND_REL_ADDR):取得相對位址(RCA)
  8. CMD7 (SELECT/DESELECT_CARD):選擇卡片進入傳輸狀態
  9. 初始化後可將時脈提高到 20~40 MHz

SD 卡 SPI 模式初始化序列流程

這串流程裡唯一會「卡住」的步驟是第 5 步的 ACMD41 —— 它在等卡內部把電壓穩定下來,所以實作上必須是重送直到就緒,而不是送一次就往下走。很多「初始化偶爾失敗」的案例,追到最後都是在這裡沒有正確地重試。

區塊讀取時序

讀取的最小單位是一個 512 bytes 的區塊,流程是「先下命令、再收資料」:

SD 卡 SPI 單區塊讀取 CMD17 時序

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

區塊寫入時序

寫入的順序正好反過來 —— 這次是「先送資料、再收回應」:

SD 卡 SPI 單區塊寫入 CMD24 時序

最容易被忽略的是最後那段:卡收下資料之後,會在 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);
    }
}

這種寫法的代價是「換版本就讀不回舊檔」。所以記得在結構裡留一個版本欄位,或是把欄位順序當成不可變的規格來管。

效能測試

把三種模式擺在一起跑,數字就有了明顯的階梯:

SD 卡 SPI 與 SDMMC 讀寫速度比較

測試結果總結:

  • 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 的完整光譜 —— 簡單的資料記錄跟高速多媒體串流,都有對應的方案。

依資料率選擇 SD 卡通訊模式的決策流程

把決策流程攤成文字,選型參考就是這幾句:

  • 簡單日誌記錄 (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 不能上拉」這種地方付出代價。

標籤: 教學
最後更新: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