Pico&SdFatでSDカードを読み書き

Category: 電子工作
thumbnail

マイコンを使っていると頻繁に問題になるのがデータの受け渡し方法です。マイコンとPCの両方で使えるインターフェースというのは意外と少なく、測定値をPCに送るときにprintlnでシリアル通信してコピペ、というスマートさに欠ける方法を使いがちです。
逆に、パソコンから画像などの大きなファイルを送るときに、ヘッダファイルに置いてバイナリに含める方法では、ファイルサイズの制約が厳しい上に、ファイルを変更するたびにコンパイルして書き込む必要があります。

そこで便利なのがSDカード。
基本的にSDカードとPCは専用の高速なMMCインターフェースを使って通信していますが、SPIモードという通信方法もあり、その名の通りSPIを使って読み書きします。
MMCモードと比べると速度は段違いに遅いですが、SPIであれば対応していないマイコンなどありません。
今回はSDカードのSPIモードでファイルの読み書きをしてみます。

Tip

RP2040はSDIOコントローラーを持ちませんが、SdFatはRP2040のPIOを利用したMMCモードを実装しています。
ですが、今回はどのマイコンでも利用可能なSPIモードを使います。

ライブラリ

SD.h

Arduinoには標準でSDカード用のライブラリ、SD.hがあります。
FAT16,FAT32のみ対応、ファイル名は8.3形式(名前8文字まで.拡張子3文字まで/日本語不可)の制限があります。

SdFat

今回はサードパーティ製ライブラリのSdFatを使います。
FAT16,FAT32に加えてexFATに対応し、ファイル名の制限はなく、SD.hより少し速いそうです。
SdFatはearlephilhower/arduino-picoに含まれるのでarduino-picoの環境構築がされていれば、追加のインストールは不要です。

APIの仕様はSdFatのリポジトリにあるhtmlのzipを確認してください。
https://github.com/greiman/SdFat/blob/master/doc/html.zip

SDカード

MBRでフォーマットしてください
相性問題が起きやすいので、複数製品を用意しておくといいかも

変換アダプタ

秋月などでSDカードのピッチ変換基板が販売されていますが、余っているmicroSDカードアダプタがあれば、ピンヘッダを直接半田付けすることもできます。
両端のピンはSPIモードでは使わないので不要です。

接続

SDカードの電源、信号電圧は3.3VなのでPicoでは電圧レベルシフトは不要です。

Note

SDカードのピンは10kΩでプルアップする必要があるらしいけど、自分の使ったSDカードはそのままでも問題ありませんでした。

ls

ファイルの一覧を取得して出力する最小限のプログラム

#include "SdFat.h"

SdFs sd;

void setup() {
    sd.begin();
}

void loop() {
    delay(3000);
    sd.ls(LS_DATE | LS_SIZE);  // 変更日時とファイルサイズを表示
}

結果

2026-07-09 06:46           14 Test.txt
2026-07-09 06:47           28 テスト.txt

オプション

オプションはビットフラグで設定されます。オプションはライブラリ内で以下のように定義されており、OR演算することで複数指定できます。

// flags for ls()
/** ls() flag for list all files including hidden. */
const uint8_t LS_A = 1;
/** ls() flag to print modify. date */
const uint8_t LS_DATE = 2;
/** ls() flag to print file size. */
const uint8_t LS_SIZE = 4;
/** ls() flag for recursive list of subdirectories */
const uint8_t LS_R = 8;

// https://github.com/greiman/SdFat/blob/2.3.0/src/common/FsApiConstants.h#L68-L76
  • LS_A: 隠しファイルを表示
  • LS_DATE: 変更日時を表示
  • LS_SIZE: ファイルサイズを表示
  • LS_R: サブディレクトリ内を再帰表示

Note

SdFatには対応するファイルシステムの異なる3つのクラスがある。
下ほど、フラッシュ/メモリの消費が多い。
SdFat32/File32: FAT16, FAT32
SdExFat/ExFile: exFAT
SdFs/FsFile: FAT16, FAT32, exFAT

ファイルリスト取得

ls()はファイルのリストを表示する関数のため、取得することはできません。
lsの実装を追うと、openNextでファイルを順番に開いて、getNameでファイル名を取得しているようです。
https://github.com/greiman/SdFat/blob/master/src/FatLib/FatFilePrint.cpp
https://github.com/greiman/SdFat/blob/master/src/FatLib/FatName.cpp

ls()を使わずに同じようなことをするサンプルプログラム

#include "SdFat.h"

SdFs sd;
FsFile dir;
FsFile file;

void setup() {
  Serial.begin();
  sd.begin();
  dir.open("/");

  delay(1000);

  while (file.openNext(&dir, O_RDONLY)) {
    char name[32];
    file.getName(name, sizeof(name));
    Serial.println(name);
    file.close();
  }
}

void loop() {}

テキストファイル読み取り

ファイルの読み取りにはread関数を使います。
read関数は指定したバイト数のデータを読み取ってバッファに格納します。返り値は実際に読み込んだデータのバイト数で、エラー時は-1です。
available関数は現在位置からEOF(End Of File)までのバイト数を返します。

#include "SdFat.h"

SdFs sd;
FsFile file;

void setup() {
  sd.begin();
}

void loop() {
  delay(3000);
  file.open("/Test.txt", O_READ);  // read-onlyでファイルを開く

  char buf[8];
  int n;

  while (file.available()) {
    n = file.read(buf, sizeof(buf));
    Serial.write(buf, n);
  }

  file.close();
}

テキストファイル書き込み

#include "SdFat.h"

SdFs sd;
FsFile file;

void setup() {
  sd.begin();
}

void loop() {
  delay(5000);

  float temp = analogReadTemp();  // Pico内蔵の温度センサー

  file.open("/new_file.txt", O_WRITE | O_CREAT | O_APPEND);  // 書き込み用に開く、ファイルが無ければ作成、あれば末尾に追記
  file.println(temp);
  file.close();
}

画像表示

SDカードから画像を読み取って、以前使ったST7789カラーディスプレイに表示するプログラムを作成しました。

jpegやpngなどの圧縮画像はデコードする必要があります。Picoの性能なら十分可能ですが、面倒なので今回は非圧縮のbmp形式の画像に限定します。
bmpは始めに54byteのサイズやビット数を含むヘッダー部があり、(カラープロファイルがなければ)その後BGRの値が順番に入ります。

接続


もじゃもじゃ〜

同じSPIチャンネルを共有していますが、CSピンの操作はライブラリが適切に処理してくれるようで、自分で操作しなくても動作しました。

コード

SDカードのroot上のbmpファイルを10秒ごとに切り替えます

簡易的な実装なので、画像はディスプレイに合わせてリサイズして、アルファチャンネル無しの24bit、カラープロファイル無しで書き出してください。
BMPでは効率的なメモリアクセスのために、widthが4の倍数でないときはpaddingが挿入されますが、対応してないので4の倍数限定です。

#include "SdFat.h"
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <SPI.h>

#define SCK  18
#define MOSI 19
#define RST  20
#define DC   21

#define DISPLAY_CS 22
#define SD_CARD_CS 17

#define SD_CONFIG SdSpiConfig(SD_CARD_CS, 0, SD_SCK_MHZ(25), &SPI)

SdFs sd;
FsFile dir;
FsFile file;

Adafruit_ST7789 tft = Adafruit_ST7789(&SPI, DISPLAY_CS, DC, RST);


const uint16_t width = 280;
const uint16_t height = 240;

uint8_t buffer[width*3] = {0};
uint16_t *buffer_u16 = (uint16_t*)buffer;  // bufferをuint16として読むポインタ


void bgr888_to_rgb565() {
  uint8_t *bgr = buffer;  // bufferのピクセル先頭を追従するポインタ
  for (uint16_t n=0; n<width; n++) {
    uint16_t b = (bgr[0]>>3);
    uint16_t g = (bgr[1]>>2);
    uint16_t r = (bgr[2]>>3);
    buffer_u16[n] = r<<11 | g<<5 | b;
    bgr += 3;
  }
}

void tft_init() {
  tft.init(240, 280);
  tft.setRotation(3);
}

void setup() {
  sd.begin(SD_CONFIG);
  dir.open("/");
  tft_init();
  delay(1000);
}

void loop() {
  while (file.openNext(&dir, O_RDONLY)) {
    char name[64];
    file.getName(name, sizeof(name));

    if (name[0]!='.' && strncmp(name+strlen(name)-4, ".bmp", 4)==0) {
      file.seekCur(54);

      for (uint16_t i=0; i<height; i++) {
        file.read(buffer, sizeof(buffer));
        bgr888_to_rgb565();
        tft.drawRGBBitmap(0, height-i-1, buffer_u16, width, 1);
      }

      file.close();
      delay(10000);

    } else {
      file.close();
    }
  }
  dir.open("/");
}

解説

BMPではピクセルデータが下の行から格納されます。

以下の関数でBMPファイルのBGR888形式の画像をST7789用のRBG565形式に変換しています。
右シフト演算子で不要な下位ビットを押し出してから、反対向きに再度シフトして所定の位置に配置。最後にビットORして完成です。

uint8_t buffer[width*3] = {0};
uint16_t *buffer_u16 = (uint16_t*)buffer;  // bufferをuint16として読むポインタ

void bgr888_to_rgb565() {
  uint8_t *bgr = buffer;  // bufferのピクセル先頭を追従するポインタ
  for (uint16_t n=0; n<width; n++) {
    uint16_t b = (bgr[0]>>3);
    uint16_t g = (bgr[1]>>2);
    uint16_t r = (bgr[2]>>3);
    buffer_u16[n] = r<<11 | g<<5 | b;
    bgr += 3;
  }
}

RGBの値をそれぞれ変数に代入していますが、コンパイラが最適化してくれるので、メモリは使われません。
Claude君にさらに最適化できないか訊いたところ、buffer領域を共有できるんじゃないかと。これで、width*2=560byteのメモリを削減することができました。RP2040の広大なメモリ領域であれば気にすることでもありませんが、最適化できて満足です。


メモ