目次
この記事でできること
- STM32H723ZGでSDカードを読み書きできる
- FatFsをCubeMXで統合する方法が分かる
- FatFsを直接使わない「drv抽象化設計」が理解できる
- CSV形式でログ保存するロガーを実装できる
- そのまま実務で流用できるストレージ基盤が手に入る
はじめに

全く関係ないですが、Analog Discovery 3 を購入しました。今後の測定やデバッグがかなり捗りそうでワクワクしています。jin@GoldearNetworks です。
これまでの入門シリーズでは、printfリダイレクト、ログ基盤、OLED表示と、「情報を出力する仕組み」を中心にファームウェアの土台を作ってきました。しかし、表示だけではその場限りの測定になってしまいます。
計測値を保存したり、後からPCで解析したり、長時間ログを取ったりするためには、データを「残す仕組み」が必要になります。そこで今回は、STM32H723ZGでSDカードの読み書きを行う仕組みを実装します。
単にFatFsを動かすだけではなく、以下の点を重視し、実務でそのまま流用できる構成を目標に設計していきます。
- FatFsを直接アプリから触らない
- drv層で抽象化して再利用可能にする
- CSVロガーとしてそのまま使える形まで仕上げる
この「保存基盤」が完成すると、以下のような応用が一気に現実的になります。
- センサーログの長時間保存
- FFT解析用のデータ取得
- CSV出力 → PC解析
- 将来的なMQTT/クラウド連携
つまり今回の内容は、単なるSDカード入門ではなく、「計測機器ファームウェアのストレージ基盤構築」と言ってもいい重要パートです。少し実務寄りの設計になりますが、将来そのまま使い回せる“自分専用テンプレート”を一緒に作っていきましょう。
開発環境
- 開発基板:STM32H723ZG
- 開発環境:STM32CubeIDE
- MCU設定:STM32CubeMX
- インターフェース:SPI接続(microSDカード)
- ストレージ:SDHC 32GB microSDカード
- ファイルシステム:FatFs(CubeMX統合版)
全体の流れ
今回は、SDカードを「動かす」だけではなく、実務でそのまま流用できるストレージ基盤として整理しながら実装していきます。
大まかな流れは以下の通りです。
- CubeMXでSPI + FatFsを有効化し、SDカードの動作環境を構築する
- FatFsを直接触らないための drv_sd 抽象化レイヤを実装する
- CSV形式で保存する簡易ロガーを作成する
- 実際にファイルを書き込み、PCで中身を確認する
- 内部構造と設計意図を整理し、再利用できる形に仕上げる
Step1. CubeMXでSPI + FatFsを設定する
SDカードを使用するためのハードウェア設定とFatFsの統合をCubeMXで行います。
今回は配線が簡単で扱いやすいSPI接続を使用します。まずは「SDカードがマウントできる状態」をゴールに、最小構成で設定していきます。
SPIのピン設定と有効化
まず、SPIが使用できるピンを確認します。Nucleo STM32H723ZG のユーザーマニュアル(UM2407)を見ると、SPI1のピン配置が記載されています。
microSDはSPIモードで動作させるため、CS / MOSI / MISO / SCK の4本を接続するだけで使用できます。まずは確実に動かすことを優先し、シンプルな構成にしています。
SPI1は以下のピンに割り当てられています。
- SPI1_SCK → PA5
- SPI1_MISO → PA6
- SPI1_MOSI → PB5
- SPI1_CS → PD14 (GPIO_Output)
※ 注意として PD14 は「GPIO_Output」として設定します。SPI1_CSという設定項目がないので注意です。
CubeMXの Pinout 画面を開き、PA5/PA6/PB5 を SPI1_SCK/SPI1_MISO/SPI1_MOSI に設定し、PD14 は CS 用の GPIO_Output に設定します。
続いて Parameter Settings を開き、以下のように設定します。
- Data Size → 8Bits
- First Bit → MSB First
- Prescaler → 256
- Clock Polarity → Low
- Clock Phase → 1 Edge
- NSS Signal Type → Software
ここでは、SPIの基本動作モードを設定します。
microSDのSPI通信は一般的なMode0(CPOL=Low / CPHA=1Edge)で動作するため、Clock Polarity と Clock Phase はデフォルト設定のままで問題ありません。
また、CSはソフトウェア制御とし、GPIOとして手動でON/OFFを切り替えます。これにより、複数デバイスとの共有やデバッグ時の制御が容易になります。
Prescalerは初期動作確認のため低速側(256)に設定しています。SDカード初期化時は低速の方が安定するため、まずは安全側で動かすのがおすすめです。
最後に、各ピンへ User Label を付けておくと、後から回路図やコードを読む際に分かりやすくなります。基本的には Symbol on Pin と同じ名前を付けておけば十分です。
補足として、今回はSDカードの基本動作確認が目的のため、DMAや高速化設定などの最適化は行っていません。まずは「確実に動く最小構成」を優先しています。
FatFsの有効化
続いて、ファイルシステムとして FatFs を有効化します。
Middleware and Software Packs から FATFS にチェックを入れることで、SDカードへファイルの読み書きを行うためのライブラリ一式が自動生成されます。
Interface は「User-defined」を選択します。今回はSPI接続のため、FatFsの下層(diskio)でSPIアクセスを行う構成になります。アプリケーション側からは直接触らないよう、後ほど drv 層でラップして使用します。
補足として、FatFsはファイルの更新日時(タイムスタンプ)を付けるためにRTCと関連します。
今回はRTCは使用せず、Timestamp feature を「Fixed timestamp」に設定しています。この場合、読み書き自体は問題なく動作しますが、ファイルの更新日時は常に固定値になります。
まずはSDカードの基本動作確認を優先するため、RTC連携は行わず最小構成としています。ログに正しい時刻を記録したい場合は、後ほどRTC設定を追加してください。
コード生成(GENERATE CODE)
CubeMXの設定ができたら、右上の GENERATE CODE からコードを生成します。この時点ではまだアプリ側の実装は行わず、まずはビルドできる状態まで作ることを優先します。
生成後、SPI接続でFatFsを動かす場合に重要になるのが、以下のファイルです。
- <project>\FATFS\Target\user_diskio.c
このファイルが、FatFs(diskio層)と実際のSPIアクセス処理を接続するための入り口になります。中身を見ると、セクタ単位の read/write を受け取って下層のドライバ処理に流す構造になっています。本記事では、ここにSPIでの送受信処理(読み書き)を実装していきます。
Step2. drv_sd抽象化レイヤの実装(FatFsラップ)
Step1でSPIとFatFsを有効化し、SDカードへアクセスするための土台は完成しました。この状態でも f_open() や f_write() を直接呼べば、ファイルの読み書き自体は可能です。
ただし、ここでアプリケーションからFatFsを直接触ってしまうと、設計として以下の問題が残ります。
- アプリがFatFs(ミドルウェア)に依存してしまう
- 他のプロジェクトへ流用しにくい
- 将来差し替え(例:LittleFS)で修正範囲が広がる
そこで今回は、FatFsを直接触らずに済むよう、drv層に抽象化レイヤを1枚挟みます。この層を drv_sd(SDファイルシステムドライバ) とします。
レイヤ構造の整理
構成は以下のようになります。
├─ boot / selftest 呼び出し
│ (例:app/boot.c など)
↓
utils(共通/自己診断)
└─ SD自己診断
(utils/sd_selftest.c)
↓
drv(抽象化:ファイル操作)
└─ FatFsラップ(ここだけが ff.h を見る)
(drv/drv_sd.c / drv/drv_sd.h)
↓
mw(ミドルウェア:FatFs本体)
└─ FatFs
(Middlewares/Third_Party/FatFs/src/ff.c など)
↓
FATFS/Target(接続:diskio)
└─ ブロックI/Oの入り口(FatFs ↔ 物理ドライバ)
(FATFS/Target/user_diskio.c)
↓
drv(物理ドライバ相当:SDをSPIで叩く)
└─ SDカードSPIドライバ
(drv/sd_spi.c / drv/sd_spi.h)
↓
plat(ハード依存:SPIバス)
└─ SPI送受信・CS制御など
(plat/spi_bus.c / plat/spi_bus.h)
ポイントは、依存関係を一方向(上→下)にして、FatFs依存を drv_sd に閉じ込めることです。詳細な設計意図は Step5 でまとめて解説します。
drv_sdのAPI設計
まずはアプリ側から使用する公開APIを定義します。FatFsの型(FIL / FRESULT など)は外に出さず、boolのみで扱えるようにしています。
bool drv_sd_init(void); bool drv_sd_mount(void); bool drv_sd_unmount(void); bool drv_sd_write_file(const char* path, const uint8_t* buf, uint32_t size); bool drv_sd_append_file(const char* path, const uint8_t* buf, uint32_t size); bool drv_sd_read_file(const char* path, uint8_t* buf, uint32_t max_size, uint32_t* out_read_size);
実装(FatFsのラップ)
実装側ではFatFsを直接呼び出しますが、このファイルだけが ff.h を参照します。これにより、FatFs依存がdrv層に完全に閉じ込められます。
// drv/drv_sd.c
// --- include ---
#include "drv/drv_sd.h"
#include <stdio.h>
#include "ff.h"
// --- define ---
#define DRV_SD_MOUNT_PATH ""
// --- variable declaration ---
static FATFS s_fs;
static bool s_mounted = false;
// --- functions ---
bool drv_sd_init(void)
{
// 現状の構成(user_diskio + sd_spi)ではここは最小でOK
return true;
}
bool drv_sd_mount(void)
{
FRESULT fr = f_mount(&s_fs, DRV_SD_MOUNT_PATH, 1);
if (fr != FR_OK) {
printf("[SD] f_mount failed: %d\r\n", (int)fr);
return false;
}
s_mounted = true;
return true;
}
bool drv_sd_unmount(void)
{
FRESULT fr = f_mount(NULL, DRV_SD_MOUNT_PATH, 1);
if (fr != FR_OK) {
printf("[SD] unmount failed: %d\r\n", (int)fr);
return false;
}
s_mounted = false;
return true;
}
bool drv_sd_write_file(const char* path, const uint8_t* buf, uint32_t size)
{
FRESULT fr;
FIL fil;
UINT bw = 0U;
if ((s_mounted == false) || (path == NULL) || (buf == NULL)) {
return false;
}
fr = f_open(&fil, path, FA_CREATE_ALWAYS | FA_WRITE);
if (fr != FR_OK) {
printf("[SD] f_open failed: %s (%d)\r\n", path, (int)fr);
return false;
}
fr = f_write(&fil, buf, (UINT)size, &bw);
if ((fr != FR_OK) || (bw != (UINT)size)) {
printf("[SD] f_write failed: %s fr=%d bw=%lu/%lu\r\n",
path, (int)fr, (unsigned long)bw, (unsigned long)size);
(void)f_close(&fil);
return false;
}
fr = f_close(&fil);
if (fr != FR_OK) {
printf("[SD] f_close failed: %s (%d)\r\n", path, (int)fr);
return false;
}
return true;
}
bool drv_sd_append_file(const char* path, const uint8_t* buf, uint32_t size)
{
FRESULT fr;
FIL fil;
UINT bw = 0U;
if ((s_mounted == false) || (path == NULL) || (buf == NULL)) {
return false;
}
fr = f_open(&fil, path, FA_OPEN_ALWAYS | FA_WRITE);
if (fr != FR_OK) {
printf("[SD] f_open(append) failed: %s (%d)\r\n", path, (int)fr);
return false;
}
fr = f_lseek(&fil, f_size(&fil));
if (fr != FR_OK) {
printf("[SD] f_lseek failed: %s (%d)\r\n", path, (int)fr);
(void)f_close(&fil);
return false;
}
fr = f_write(&fil, buf, (UINT)size, &bw);
if ((fr != FR_OK) || (bw != (UINT)size)) {
printf("[SD] append failed: %s fr=%d bw=%lu/%lu\r\n",
path, (int)fr, (unsigned long)bw, (unsigned long)size);
(void)f_close(&fil);
return false;
}
fr = f_close(&fil);
if (fr != FR_OK) {
printf("[SD] f_close failed: %s (%d)\r\n", path, (int)fr);
return false;
}
return true;
}
bool drv_sd_read_file(const char* path, uint8_t* buf, uint32_t max_size, uint32_t* out_read_size)
{
FRESULT fr;
FIL fil;
UINT br = 0U;
if ((s_mounted == false) || (path == NULL) || (buf == NULL) || (out_read_size == NULL)) {
return false;
}
*out_read_size = 0U;
fr = f_open(&fil, path, FA_READ);
if (fr != FR_OK) {
printf("[SD] f_open(read) failed: %s (%d)\r\n", path, (int)fr);
return false;
}
fr = f_read(&fil, buf, (UINT)max_size, &br);
if (fr != FR_OK) {
printf("[SD] f_read failed: %s (%d)\r\n", path, (int)fr);
(void)f_close(&fil);
return false;
}
fr = f_close(&fil);
if (fr != FR_OK) {
printf("[SD] f_close failed: %s (%d)\r\n", path, (int)fr);
return false;
}
*out_read_size = (uint32_t)br;
return true;
}
自己診断テスト(sd_selftest)
起動時には自己診断として、drv_sd経由で実際にファイルの読み書きを行います。ここでもFatFsは一切直接呼び出していません。アプリ層は「保存できるか?」だけを確認しています。
動作確認ログ
実際の起動ログです。
[000005][INF][I2C][../Core/Src/utils/i2c_scan.c:17] I2C scan start (7bit)
[000019][INF][I2C][../Core/Src/utils/i2c_scan.c:24] found device: 0x3C
[000033][INF][I2C][../Core/Src/utils/i2c_scan.c:29] I2C scan end
[000039][INF][BOOT][../Core/Src/app/boot.c:23] boot begin
[000044][INF][SDTST][../Core/Src/utils/sd_selftest.c:46] selftest begin
[000054][INF][SD][../Core/Src/drv/sd_spi.c:218] init OK
[000059][INF][SDTST][../Core/Src/utils/sd_selftest.c:56] sd init OK
[000065][INF][SDTST][../Core/Src/utils/sd_selftest.c:116] fatfs mount…
[000075][INF][SD][../Core/Src/drv/sd_spi.c:218] init OK
[000141][INF][SDTST][../Core/Src/utils/sd_selftest.c:122] fatfs mount OK
[000148][INF][SDTST][../Core/Src/utils/sd_selftest.c:125] fatfs write…
[000463][INF][SDTST][../Core/Src/utils/sd_selftest.c:141] fatfs write OK
[000469][INF][SDTST][../Core/Src/utils/sd_selftest.c:144] fatfs read/verify…
[000518][INF][SDTST][../Core/Src/utils/sd_selftest.c:167] fatfs read/verify OK
[000525][INF][SDTST][../Core/Src/utils/sd_selftest.c:69] raw LBA test disabled
[000532][INF][SDTST][../Core/Src/utils/sd_selftest.c:72] selftest end (sd=1 fatfs=1 raw=0)
[000541][INF][OLED][../Core/Src/drv/oled_i2c.c:54] init begin (addr=0x3C)
[000759][INF][OLED][../Core/Src/drv/oled_i2c.c:72] init OK.
[000869][INF][BOOT][../Core/Src/app/boot.c:48] POST: SD=OK FAT=OK OLED=OK
[000875][INF][BOOT][../Core/Src/app/boot.c:53] boot end
[000985][INF][MAIN][../Core/Src/main.c:135] while loop start
マウント → 書き込み → 読み出し → 検証まで正常に完了していることが確認できます。
Step3. CSVロガーの実装(ログ保存アプリ作成)
Step2では、FatFsを直接触らないための抽象化層(drv_sd)を実装しました。これにより、アプリケーション側はファイルシステムの詳細を意識せず、「ファイルに保存する」という機能だけを扱えるようになりました。
このStep3では、その保存基盤を利用して実際にCSV形式でログを書き出すロガーを実装します。
設計方針(責務の分離)
今回もっとも重要なのは「どこが何を担当するか」を明確に分離することです。
- logger:CSV行を生成する(アプリの責務)
- drv_sd:ファイル読み書き(ストレージの責務)
- FatFs:ファイルシステム実装(ミドルウェア)
logger から ff.h や f_open() などのFatFs APIを直接呼ぶことは禁止し、必ず drv_sd を経由します。この構造にしておくと、将来 LittleFS や SPI Flash に差し替えても、logger 側は一切修正せずに再利用できます。
配置ディレクトリ
ロガーはアプリケーション機能のため app 層に配置します。
- app/logger/logger.c
- app/logger/logger.h
保存処理は drv 層に閉じ込めているため、アプリは drv_sd のAPIだけを呼び出します。
ロガーAPI
今回は最小構成として以下のAPIを用意しました。
- logger_init():初期化(ヘッダ確認)
- logger_append():CSV 1行追記
戻り値は enum で定義し、成功/失敗を明確に扱えるようにしています。bool よりも情報量が多く、実務ではこの形式がおすすめです。
#ifndef APP_LOGGER_H_
#define APP_LOGGER_H_
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
LOGGER_OK = 0,
LOGGER_ERR_MOUNT,
LOGGER_ERR_OPEN,
LOGGER_ERR_WRITE,
LOGGER_ERR_SYNC,
} logger_err_t;
logger_err_t logger_init(void);
logger_err_t logger_append(uint32_t ms, float ch1, float ch2, const char* note);
#ifdef __cplusplus
}
#endif
#endif /* APP_LOGGER_H_ */
実装(CSV生成 → drv_sd経由で保存)
処理内容は非常にシンプルです。以下の流れで1行生成して追記します。
- CSV 1行を snprintf で生成
- drv_sd_append_file() で末尾追記
ポイントは「書き込むサイズは snprintf の戻り値を使う」ことです。sizeof(buffer) を渡すとゴミデータが混ざる原因になります。
#include "app/logger/logger.h"
// --- include ---
#include <string.h>
#include <stdio.h>
#include "drv/drv_sd.h"
// --- define ---
#define LOGGER_PATH "log.csv"
// --- variable declaration ---
static bool s_header_checked = false;
// --- function prototype ---
static logger_err_t ensure_header(void);
// --- functions ---
static logger_err_t ensure_header(void)
{
if (s_header_checked) {
return LOGGER_OK;
}
// 先頭1バイトだけ読んで「空ファイルかどうか」を判定する
// - 読めて rsz > 0 なら既に何か入っているのでヘッダ不要
// - 読めて rsz == 0 なら空なのでヘッダ作成
// - 読めない(ファイルなし等)ならヘッダ作成
uint8_t b = 0U;
uint32_t rsz = 0U;
bool ok = drv_sd_read_file(LOGGER_PATH, &b, 1U, &rsz);
if ((ok == true) && (rsz > 0U)) {
s_header_checked = true;
return LOGGER_OK;
}
// ヘッダを書き込む(ファイルが無ければ作成される)
{
const char* header = "ms,ch1,ch2,note\r\n";
if (drv_sd_write_file(LOGGER_PATH, (const uint8_t*)header, (uint32_t)strlen(header)) == false) {
return LOGGER_ERR_WRITE;
}
}
s_header_checked = true;
return LOGGER_OK;
}
// ----------------------------------------------------------- //
logger_err_t logger_init(void)
{
// mount は外で済ませる想定(boot / selftest など)
return ensure_header();
}
// ----------------------------------------------------------- //
logger_err_t logger_append(uint32_t ms, float ch1, float ch2, const char* note)
{
int32_t ch1_mV = (int32_t)(ch1 * 1000.0f);
int32_t ch2_mV = (int32_t)(ch2 * 1000.0f);
logger_err_t e = ensure_header();
if (e != LOGGER_OK) {
return e;
}
if (note == NULL) {
note = "";
}
// CSV 1行生成
char line[128];
int n = snprintf(line, sizeof(line), "%lu,%ld,%ld,%s\r\n",
(unsigned long)ms, (long)ch1_mV, (long)ch2_mV, note);
if ((n <= 0) || (n >= (int)sizeof(line))) {
return LOGGER_ERR_WRITE;
}
// 追記保存(末尾追記は drv_sd 側で実現済み)
if (drv_sd_append_file(LOGGER_PATH, (const uint8_t*)line, (uint32_t)n) == false) {
return LOGGER_ERR_WRITE;
}
return LOGGER_OK;
}
初期化タイミング
logger_init() はストレージ(SDカード)に依存するため、SDの mount 完了後に初期化する必要があります。
本プロジェクトでは boot シーケンス内で selftest → mount → logger_init() の順に呼び出しています。初期化処理は boot、運用処理はアプリ側、という役割分担を守ると構造が綺麗になります。
呼び出しタイミングについて
補足として、boot(初期化段階)でログを追記する必要はありません。本来 logger_append() は、センサ取得処理や周期タスクなどの「アプリケーション処理」から呼び出します。
本記事ではロガーの実装までを扱い、実際のログ取得処理(周期保存など)は後続の記事で扱います。
Step4. 動作確認(書き込み・追記・PCで確認)
ここでは「確認」を一度だけまとめて行います。やることはシンプルで、log.csv が生成されていて、ヘッダが書かれていることを確認できればOKです。
起動ログの確認
※補足1: 起動ログはプロジェクト構成で表示が変わります
本記事では「マウントできた」「logger初期化できた」ことをログで確認しますが、表示されるタグ名(例:BOOT/SDTST)や文言(例:LOGGER=OK)は、プロジェクトの実装により変わります。以下はあくまで一例で、同等の成功ログが出ていれば問題ありません。
※補足2: 起動が2回走るケースに注意
デバッグ設定や書き込み条件によっては、リセットが2回発生し、起動処理が2回実行されることがあります。起動時にファイル追記(logger_append)を入れている場合、意図せず2行追記されたり、条件によってはファイル内容が崩れることがあります。動作確認用の追記は「ファイルが存在しない場合のみ実行する」など、一度だけ動くガードを入れておくのがおすすめです。
[BOOT] fatfs mount OK
[BOOT] logger init OK
[BOOT] POST: SD=OK FAT=OK LOGGER=OK OLED=OK
SDカードの中身を確認
PCへSDカードを挿入し、ルートディレクトリに log.csv が作成されていることを確認します。中身を開き、以下のヘッダが書き込まれていれば成功です。
Step5. 内部構造と設計意図(なぜこの構成にしているのか)
ここまでで、SDカードへの保存とCSVロガーの動作確認が完了しました。
本記事で本当に伝えたいのは、「SDカードに書けた」という事実そのものではありません。重要なのは、「なぜこの構造にしているのか」という設計思想です。
レイヤ構造の全体像
本プロジェクトでは、責務ごとにレイヤを分離しています。
└ logger / boot / センサ処理
drv(抽象化ドライバ)
└ drv_sd(FatFsラップ)
mw(ミドルウェア)
└ FatFs
plat(ハード依存)
└ sd_spi / spi_bus
上位層ほど「何をしたいか(機能)」を扱い、下位層ほど「どうやって実現するか(ハード制御)」を担当します。
なぜ FatFs を直接呼ばないのか?
アプリがFatFsに依存すると、差し替えや再利用が難しくなります。drv_sd を挟むことで、アプリは「保存したい」という目的だけを扱えるようになります。
依存関係は一方向にする
本プロジェクトでは「依存は上から下へのみ」というルールを守っています。
app → drv → mw → plat (逆依存は禁止)
下位層が上位層を参照しない形にしておくと、循環依存が起きにくく、差し替えや拡張もスムーズになります。
今回得られた成果
この構成により、以下のストレージ基盤が完成しました。
- 再利用可能なストレージ基盤(drv_sd)
- FatFs非依存のアプリ設計(loggerはff.hを見ない)
- CSVロガーとして使える最小実装
まとめ
今回は STM32H723ZG で SDカードを使ったログ保存基盤を実装しました。
これで以下のストレージ基盤が整いました。
- SPI + FatFs の設定
- drv_sd によるファイル操作の抽象化
- FatFs を直接触らない設計
- CSVロガーの実装
- 実機での保存確認
次回は、このプロジェクト全体のディレクトリ構成を整理・再設計し、より再利用しやすいファームウェアテンプレートへ仕上げていきます。
ソースコードについて
本記事は、プロジェクトのディレクトリ構成整理(後続記事)に合わせて内容を更新しています。最新のソースコードは GitHub に公開し、公開後に本記事へリンクを追記します。
※ 本文中のログ出力やファイル配置は、記事執筆時点の構成をベースに説明しています。
前回

次回:(準備中)









コメント