本文へスキップ

loop()に全部書くのをやめる組み込み設計パターンを自作した

22 分で読めます

組み込みのプログラムは、放っておくとloop()が太る。LEDを点滅させ、センサーを読み、モーターを回し、画面を更新して……と機能を足すたびにloop()の中のif文とグローバル変数が増えていき、数百行になった頃には「このLEDはなぜ光っているのか」を追えなくなる。

これを止めるための設計パターンを自作して、リファレンス実装と教材ごと公開している。名前はEmbedded Module Architecture。ハードウェア1個をクラス1個に閉じ込め、全モジュールに同じ4つのメソッドを持たせ、データは共有構造体を一方向に流す。この3つを守ると、機能追加が「loop()にifを足す」から「モジュールのフォルダを1つ作る」に変わる。

https://github.com/takushio2525/Embedded-Module-Architecture

この記事は、オブジェクト指向を知らない人が読んでも最後まで通るように書く。クラス・インターフェース・仮想関数といった用語は、出てきた時点でその都度説明する。C言語の関数と構造体が読めれば前提は足りる。開発環境はPlatformIOで、Arduino IDEではない。この違いも本文で説明する。

loop()にベタ書きした場合とモジュールに分けた場合の比較図

なぜ作ったのか

理由1: 増える場所がloop()の1か所に集中する

まず、よくある書き方を見てほしい。LED1個と温度センサー1個を制御するだけのコードだ。

#include <Arduino.h>

// ピン番号がマジックナンバーとして散らばる
#define LED1_PIN 2
#define TEMP_PIN 34

// グローバル変数がバラバラに並ぶ
bool led1State = false;
float temperature = 0.0;
unsigned long lastBlink = 0;
unsigned long lastRead  = 0;

void loop() {
    unsigned long now = millis();

    if (now - lastBlink >= 500) {          // LED: 500msごとに点滅
        lastBlink = now;
        led1State = !led1State;
        digitalWrite(LED1_PIN, led1State ? HIGH : LOW);
    }

    if (now - lastRead >= 1000) {          // 温度: 1秒ごとに読む
        lastRead = now;
        temperature = analogRead(TEMP_PIN) * 3.3 / 4095.0 * 100.0;
    }

    // ここにモーター制御を足したら? ディスプレイ表示を足したら?
}

動くには動く。問題は増え方のほうで、機能を1つ足すたびに「グローバル変数が1〜2個」と「loop()の中のifが1個」が同時に増える。モーターとディスプレイと通信を足せば、loop()はすぐ数百行になる。

そして3.3 / 4095.0 * 100.0のような数値がコードの中に直接埋まっているので、後から見て何の数字か分からない。別のプロジェクトでLED点滅だけ使い回そうとしても、LED関連の行だけ抜き出すのが難しい。変数名が他とかぶり、依存が絡んでいるからだ。

理由2: delay()を1か所でも置くと、全部止まる

delay(500)は、その500msの間、CPUを完全に止める。LEDを点滅させるつもりでdelay()を書くと、その間ボタンは読まれないし、センサーの値は更新されないし、通信は取りこぼす。センサーとアクチュエータを同時に扱う組み込みでは、delay()は致命的なボトルネックになる。

理由3: クラスに分けただけでは、まだ足りない

「じゃあクラスにすればいい」で分割すると、次はこうなりやすい。

void setup() {
    led.init();
    tempSensor.begin();       // beginだったり
    motor.start();            // startだったり
    display.initialize();     // initializeだったり
}

void loop() {
    led.toggle();
    tempSensor.readValue();   // 更新メソッド名もバラバラ
    motor.drive();
    display.refresh();
}

クラスにはなった。それでもmain.cppは読みやすくなっていない。モジュールを足すたびに「これは何を呼ぶんだっけ」と自分の書いたコードを調べ直すことになるし、「全モジュールを順番に初期化する」という共通処理をひとつも書けない。名前が揃っていないからだ。

理由4: 複数人で書くと、統合の日にまとめて効いてくる

チームや授業で分担すると、Aさんは関数だけで書き、Bさんはクラスで書き、Cさんは生成AIに任せた独自構造で書く、ということが普通に起きる。個々は動くのに、結合の段階でインターフェースの不一致・命名規則の違い・データの受け渡し方の齟齬が一気に噴き出す。

生成AIに書かせる場合も同じで、各自が自由に指示を出すと出力の構造がバラバラになる。逆に言えば、守るべき形が1つに決まっていれば、人間が書いてもAIが書いても同じ形のコードが出てくる

必要だったのは「クラスを使うこと」ではなく、「全モジュールが同じ形になる規約」のほうだった。それを設計パターンとして固定したのが、このアーキテクチャになる。

開発環境はPlatformIO(Arduino IDEではない)

コードの話に入る前に、環境を明確にしておく。このリファレンス実装はPlatformIOのプロジェクトで、Arduino IDEでは開かない。

PlatformIOは、マイコンのプログラムを書いてボードに書き込むための開発環境で、VSCodeの拡張機能として動く。Arduino IDEが単体アプリなのに対して、PlatformIOはVSCodeの上に乗る、という違いがまずある。

ややこしいのは、PlatformIOとArduinoが排他ではないことだ。PlatformIOは「どのマイコン向けに、どのフレームワークでビルドするか」を設定ファイルで選ぶ仕組みになっていて、そこでArduinoフレームワークを選べる。pinMode()digitalWrite()millis()は、このArduinoフレームワークが提供している関数だ。つまり本リポジトリの構成は「開発環境はPlatformIO、その上で使うフレームワークはArduino」であって、コード自体はArduinoのAPIで書かれている。

その設定を持っているのがplatformio.iniで、リポジトリのsample/にあるものは次のように始まる。

; sample/platformio.ini(冒頭)
[env:esp32-s3-cam-n16r8]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200

framework = arduinoの行が「Arduinoフレームワークを使う」の宣言にあたる。Arduino IDEでいう「ボードマネージャのURLを追加してボードを選び、ライブラリマネージャでインストールする」という一連の作業が、この1ファイルに置き換わっている。初回のpio runで必要なものが自動的にダウンロードされるので、git cloneした人は全員同じバージョンでビルドできる。Arduino IDE時代によくあった「自分のPCでは動くんだけどな」が起きにくいのは、この点が大きい。

なおアーキテクチャのコア部分はESP32固有のAPIを使っておらず、millis()などArduino標準のAPIだけで書いてある。サンプルの題材がESP32-S3なだけで、設計そのものはArduinoフレームワークが動くマイコンなら移植できる。

全体像: 3つの層に分ける

プロジェクトは3層に分かれている。どのファイルがどこに属するかを先に把握しておくと、以降のコードが読みやすい。

PlatformIOプロジェクトの層構成と依存の向き

  • コア層lib/ModuleCore/)— IModule.hModuleTimer.hの2ファイルだけ。プロジェクト固有の型に一切依存しないので、フォルダごとコピーすれば別プロジェクトでもそのまま動く
  • モジュール層lib/{Name}Module/)— ハードウェア1個につき1フォルダ。.h.cppのペアで構成する
  • プロジェクト層include/src/main.cpp)— そのプロジェクト固有の設定とデータ、そしてloop()

依存の向きは常に下から上、つまりモジュール層がコア層を参照し、プロジェクト層がモジュール層を参照する。逆向きの参照はしない。

以降のコードはすべてsample/から引用している。sample/は設計パターンを示すためのコードで実機検証はしていない。実機で動かしたものはverified/のほうに置いてある。

部品1: ハードウェア1個をクラス1個に閉じ込める

そもそもクラスとは

クラスとは、変数と関数を1つにまとめた型のこと。C言語の構造体に関数も入れられるようになったもの、と考えるとだいたい合っている。クラスから作った実体をインスタンスと呼ぶ。設計図がクラスで、それを元に作った物がインスタンスだ。

クラスの中の変数や関数にはprivatepublicという印を付けられる。privateを付けたものはクラスの外から触れない。publicを付けたものだけが外から使える。これが「クラスの中に閉じ込める」という言い方の実体になる。

このアーキテクチャでは、ハードウェア部品1個をクラス1個にすると決めている。LEDならLedModule、ボタンならButtonModule、IMUセンサーならMpu6500Module。区切り方に迷う余地がないので、規約として強い。

外に見せるのはConfigDataだけ

クラスを作るとき、次に迷うのが「何をprivateにするか」だ。ここも決めてある。外に見せるのはConfigDataという2つの構造体だけで、それ以外は全部privateに落とす。

  • Config — 起動時に決まって、その後変わらないもの(ピン番号、周期、閾値)
  • Data — 実行中に変わるもの(現在値、押されているかどうか)

出力モジュールの最小構成であるLedModuleは、これだけで済む。

// sample/lib/LedModule/LedModule.h
#pragma once
#include <Arduino.h>
#include "IModule.h"

// --- Config構造体 ---
struct LedConfig {
    uint8_t ledPin;  // LED出力ピン
};

// --- Data構造体 ---
struct LedData {
    bool state = false;
    uint8_t brightness = 0;
};

// SystemDataの前方宣言はIModule.h内で行われている

// --- モジュール実装 ---
class LedModule : public IModule {
private:
    LedConfig _config;

public:
    LedModule(const LedConfig& config);
    bool init() override;
    void updateOutput(SystemData& data) override;
};

LedModule(const LedConfig& config);の行はコンストラクタといって、インスタンスが作られるときに1回だけ走る初期化用の関数だ。ここで受け取ったConfigをクラスの中に保存しておく。: public IModuleoverrideの意味は次の章で説明するので、今は飛ばしてよい。

中身はこうなっている。

// sample/lib/LedModule/LedModule.cpp
#include "LedModule.h"
#include "SystemData.h"
#include <Arduino.h>

LedModule::LedModule(const LedConfig& config) : _config(config) {}

bool LedModule::init() {
    pinMode(_config.ledPin, OUTPUT);
    Serial.println("[Led] init OK");
    return true;
}

void LedModule::updateOutput(SystemData& data) {
    digitalWrite(_config.ledPin, data.led.state ? HIGH : LOW);
}

ピン番号は_config.ledPinから読む。_configprivateなので、main.cpp側からLEDのピン番号を直接いじることはできない。#define LED1_PIN 2がグローバルに置かれていた状態と比べると、触れる範囲が明確に狭まっている。

Dataのメンバに= false= 0とデフォルト値を書いているのも規約のうちだ。init()の前やセンサー故障時にも参照されうるので、初期値がないと不定値がそのまま使われてしまう。

ピンアサインは1ファイルに集約する

Configの実体はinclude/ProjectConfig.hにまとめて置く。ピン番号がこの1ファイルに集まるので、基板の配線を変えたときに直す場所がここだけになる。

// sample/include/ProjectConfig.h(抜粋)
// ===== LED =====
const LedConfig LED_CONFIG = {
    .ledPin = 2, // GPIO2: オンボードLED
};

// ===== ボタン(プルアップ接続) =====
const ButtonConfig BUTTON_CONFIG = {
    .pin = 0,          // GPIO0: BOOTボタン(多くのESP32ボードに搭載)
    .activeLow = true, // LOW=押下(内蔵プルアップ使用)
    .debounceMs = 30,  // デバウンス30ms
};

部品2: 全モジュールに同じ4つのメソッドを持たせる

init()begin()start()が混在する問題は、インターフェースで解決する。

インターフェース・継承・仮想関数

インターフェースとは、メソッドの名前と形だけを決めた約束のことだ。中身は書かない。「このクラスはinit()という名前で、引数なしで、boolを返す関数を持つこと」とだけ決める。

C++では、この約束を親クラスとして書き、子クラスがそれを継承する。継承とは、親クラスの持ち物を引き継いで新しいクラスを作ること。class LedModule : public IModuleが「LedModuleIModuleを継承する」の書き方だ。

親が決めた形のメソッドを、子が自分用に書き直すことをオーバーライドという。C++ではオーバーライドされる側のメソッドにvirtual(仮想関数)を付け、する側にoverrideを付ける。virtualが付いていると、親の型を通して呼んでも子の実装が呼ばれる。この性質が後で効いてくる。

virtualの後ろに= 0を付けたものは純粋仮想関数といって、親には中身がなく、子が必ず実装しなければならないメソッドになる。

実物がこれだ。

// sample/lib/ModuleCore/IModule.h
#pragma once

// SystemDataの前方宣言
// 各プロジェクトでSystemData構造体を定義すること
struct SystemData;

class IModule {
public:
    // 仮想デストラクタ
    virtual ~IModule() {}

    // モジュールの初期化
    // 戻り値: true=成功, false=失敗
    virtual bool init() = 0;

    // 入力フェーズ更新(センサー読み取り等)
    // 入力を持つモジュールのみオーバーライドする
    virtual void updateInput(SystemData& data) {}

    // 出力フェーズ更新(アクチュエータ制御・画面描画等)
    // 出力を持つモジュールのみオーバーライドする
    virtual void updateOutput(SystemData& data) {}

    // モジュールの終了処理(リソース解放)
    // デフォルトは空実装。解放が必要なモジュールのみオーバーライド
    virtual void deinit() {}

    // 動的有効/無効化フラグ
    // false の場合、ループ内で updateInput()/updateOutput() がスキップされる
    bool enabled = true;
};

純粋仮想にしたのはinit()だけにしてある。初期化しないモジュールは存在しないので、全モジュールに実装を強制していい。一方でupdateInput()updateOutput(){}の空実装を持たせた。LEDに入力フェーズはないし、ボタンに出力フェーズはない。ここを純粋仮想にすると全モジュールが空のupdateInput()を書かされることになり、規約が守られない方向に働く。

コードの先頭にあるvirtual ~IModule() {}仮想デストラクタで、親の型のポインタ経由でインスタンスを破棄したときに、子クラス側の後片付けまで正しく走らせるための決まり文句だと思っておけばいい。

struct SystemData;のほうは前方宣言といって、「この名前の型がどこかにある」とだけ伝える書き方だ。中身を知らなくてもポインタや参照は書ける。これのおかげでIModule.hはどのプロジェクトの型にも依存せず、コア層としてコピーできる状態を保っている。

揃えた結果、1本の配列でまとめて回せる

形が揃うと、種類の違うモジュールを1つの配列に入れられるようになる。IModule*はポインタなので、LedModuleだろうがButtonModuleだろうが同じ配列に入る。そしてvirtualが付いているおかげで、IModule*越しにinit()を呼ぶと、実際にはそれぞれの子クラスのinit()が走る。

// 異なるクラスでも、IModule* の配列に入れられる
IModule* allModules[] = { &ledModule, &buttonModule, &mpu6500Module };
const int ALL_COUNT = sizeof(allModules) / sizeof(allModules[0]);

for (int i = 0; i < ALL_COUNT; i++) {
    if (!allModules[i]->init()) {
        allModules[i]->enabled = false;   // 失敗したモジュールだけ畳む
    }
}

モジュールを1個足しても、このfor文は1行も変わらない。配列に1行足すだけで済む。

IModuleを中心としたクラス構成図

部品3: データはSystemDataに集める

残る問題は、クラス同士がどうやってデータを渡すかだ。ここを決めておかないと、ButtonModuleLedModuleのポインタを持って直接叩く、という書き方に流れる。動きはするが、依存が網の目になって「なぜこのLEDが光っているのか」を追えなくなる。

規約はこうした。全モジュールのDataを1つの構造体にまとめ、モジュール同士は直接呼び合わない。

// sample/include/SystemData.h(抜粋)
#pragma once
#include "LedModule.h"
#include "ButtonModule.h"
#include "Mpu6500Module.h"

// ===== システムデータ =====
struct SystemData {
    LedData            led;
    ButtonData         button;
    Mpu6500Data        mpu;
};

入力モジュールは自分のData書くだけ、出力モジュールは自分のData読むだけ。ButtonModuledata.ledを触ることも、LedModuledata.buttonを見ることも禁止する。モジュールを追加するたび、ここに1行ずつ増やしていく。

部品4: loop()を3フェーズに固定する

SystemDataを用意したうえで、loop()の構造を「入力 → ロジック → 出力」の3段に固定する。

3フェーズ実行モデルのデータフロー

実際のloop()はこれだけになる。

// sample/src/main.cpp(抜粋)
void loop()
{
    // 1. 入力フェーズ
    for (int i = 0; i < INPUT_COUNT; i++)
    {
        if (inputModules[i]->enabled)
        {
            inputModules[i]->updateInput(systemData);
        }
    }

    // 2. ロジックフェーズ
    applyPattern(systemData);

    // 3. 出力フェーズ
    for (int i = 0; i < OUTPUT_COUNT; i++)
    {
        if (outputModules[i]->enabled)
        {
            outputModules[i]->updateOutput(systemData);
        }
    }
}

「ボタンが押されたらLEDを点ける」という判断は、ButtonModuleの仕事でもLedModuleの仕事でもなく、真ん中のロジックフェーズの仕事になる。

void applyPattern(SystemData &data) {
    if (data.button.justPressed) {
        data.led.state = !data.led.state;
    }
}

窮屈に見えるが、これを守ると「なぜこのLEDが光っているのか」の答えが必ずapplyPattern()の中にある状態になる。副次的な利点として、この関数はdigitalWrite()を一切呼ばず構造体を読んで構造体を書くだけなので、SystemDataを手で組んで呼べば実機なしで挙動を確かめられる。

配列の並び順がそのままフェーズ内の実行順序になるので、「通信の受信を先に反映してから他の入力を読む」といった順序制御は、配列の並べ替えだけで済む。

部品5: delay()の代わりにModuleTimerを使う

3フェーズを回すには、各update()が呼ばれた瞬間に「今やることがあるか」だけ判断して即座に返る必要がある。ブロックしていいモジュールは1つもない。つまりloop()から呼ばれる範囲ではdelay()が規約違反になる。

代わりに使うのは、millis()の差分を取るだけの小さなクラスだ。

// sample/lib/ModuleCore/ModuleTimer.h
#pragma once
#include <Arduino.h>

class ModuleTimer {
private:
    unsigned long _startTime;

public:
    // コンストラクタ
    ModuleTimer() : _startTime(0) {}

    // 基準時刻をセット
    void setTime(unsigned long offsetMs = 0) {
        _startTime = millis() - offsetMs;
    }

    // 基準時刻からの経過時間(ms)を取得
    unsigned long getNowTime() const {
        return millis() - _startTime;
    }
};

millis()は起動からの経過ミリ秒を返す関数で、約49.7日で0に戻る。ただしunsigned long同士の引き算なので、0に戻った直後でも経過時間は正しく求まる。この性質を各モジュールで書き直さずに済むのが、小さくてもクラスに切り出す実利になる。

毎周回動く必要のないモジュールは、自分でタイマーを見て、自分の番でなければ即座にreturnする。

// sample/lib/BatteryModule/BatteryModule.cpp(抜粋)
void BatteryModule::updateInput(SystemData& data) {
    if (_sampleTimer.getNowTime() < _config.sampleIntervalMs) {
        return;
    }
    _sampleTimer.setTime();

    // ADC読み取り → 電圧変換
    uint16_t adcRaw = analogRead(_config.adcPin);
    float voltage = _adcToVoltage(adcRaw);
    // ...リングバッファに積んで移動平均を取る
}

「自分の周期は自分で持ち、それ以外の時間は他に譲る」を全モジュールが守ると、OSもタスクも使わずに協調的なスケジューリングになる。

なおdelay()が禁止なのはloop()から呼ばれる範囲の話で、setup()の中は別だ。起動時に1回止まるだけなら、他の処理を邪魔しない。実際、後述する初期化リトライではsetup()内でdelay(100)を使っている。

実装例: ButtonModuleを頭から読む

ここまでの部品が全部入っている例として、ボタン入力のモジュールを見る。まずヘッダー。

// sample/lib/ButtonModule/ButtonModule.h
#pragma once
#include <Arduino.h>
#include "IModule.h"
#include "ModuleTimer.h"

// --- Config構造体 ---
struct ButtonConfig {
    uint8_t       pin;              // ボタンピン
    bool          activeLow;        // true: LOW=押下(プルアップ)、false: HIGH=押下(プルダウン)
    unsigned long debounceMs;       // デバウンス安定時間 [ms](例: 30)
};

// --- Data構造体 ---
struct ButtonData {
    bool pressed      = false;  // デバウンス後の押下状態
    bool justPressed  = false;  // 今ループで押下された(立ち上がりエッジ)
    bool justReleased = false;  // 今ループで離された(立ち下がりエッジ)
};

// --- モジュール実装 ---
struct SystemData;

class ButtonModule : public IModule {
private:
    ButtonConfig _config;
    ModuleTimer  _debounceTimer;
    bool         _lastRaw       = false;  // 前回の生入力値
    bool         _stableState   = false;  // デバウンス確定後の状態
    bool         _prevStable    = false;  // 前ループの確定状態(エッジ検出用)

public:
    ButtonModule(const ButtonConfig& config);
    bool init() override;
    void updateInput(SystemData& data) override;
};

private側に状態が3つ並んでいる。機械式のスイッチは押した瞬間にON/OFFが数ミリ秒細かく暴れるので、それを均す(デバウンスする)ために必要な変数だ。ベタ書きならloop()の外にグローバル変数が3個並んでいたはずのものが、クラスの中に収まっている。

// sample/lib/ButtonModule/ButtonModule.cpp(抜粋)
void ButtonModule::updateInput(SystemData& data) {
    // 生の入力値を読み取り、activeLowの場合は反転
    bool rawPressed = digitalRead(_config.pin);
    if (_config.activeLow) {
        rawPressed = !rawPressed;
    }

    // 入力値が変化したらデバウンスタイマーをリセット
    if (rawPressed != _lastRaw) {
        _debounceTimer.setTime();
        _lastRaw = rawPressed;
    }

    // デバウンス安定時間を超えたら状態を確定
    if (_debounceTimer.getNowTime() >= _config.debounceMs) {
        _stableState = _lastRaw;
    }

    // エッジ検出
    data.button.pressed      = _stableState;
    data.button.justPressed  = (_stableState && !_prevStable);
    data.button.justReleased = (!_stableState && _prevStable);
    _prevStable = _stableState;
}

外に出るのはpressedjustPressedjustReleasedの3つだけで、デバウンスの都合は一切漏れない。ロジックフェーズ側はdata.button.justPressedを見るだけでよく、スイッチが暴れることを気にしなくて済む。

1つ注意がある。justPressedは1周だけ立つフラグなので、ロジックフェーズで必ず拾う必要がある。取りこぼしたくない用途では、ロジック側に値を保持しておく変数(ラッチ)を持つことになる。

導入方法

環境を用意する

  1. VSCodeをインストールする
  2. 拡張機能タブで「PlatformIO IDE」を検索してインストールする
  3. リポジトリをgit cloneし、sample/またはverified/フォルダをPlatformIOで開く
  4. ビルドは画面下のチェックマーク、またはターミナルでpio run
  5. 書き込みは画面下の矢印、またはターミナルでpio run -t upload

ライブラリのインストールはplatformio.iniが自動でやるので、手で入れるものはない。

自分のプロジェクトに持っていく

lib/ModuleCore/の2ファイル(IModule.hModuleTimer.h)だけコピーすれば、コアは移植できる。この2つはプロジェクト固有の型を知らないので、そのまま動く。

そのうえで、新しいモジュールを追加する手順は毎回この5ステップになる。

  1. lib/{Name}Module/{Name}Module.hConfig/Data構造体とクラス宣言を書く
  2. lib/{Name}Module/{Name}Module.cppinit()updateInput()またはupdateOutput()を実装する
  3. include/SystemData.h{Name}Dataのフィールドを1行足す
  4. include/ProjectConfig.h{NAME}_CONFIGのインスタンスを足す
  5. src/main.cpp — インスタンスを作り、inputModules[]outputModules[]に登録する

loop()には手を入れない。ここが変わらないことが、この設計パターンの主目的にあたる。

platformio.iniに1行だけ必要な設定がある

lib/の中のモジュールからinclude/のヘッダーを参照するので、インクルードパスを通しておく必要がある。

build_flags =
    -I include   ; これがないと lib/ から include/ が見えない

最初に踏むつまずき

.hからSystemData.hをインクルードしない

SystemData.hは各モジュールの.hをインクルードしている。だからモジュールの.hからSystemData.hを読むと、お互いがお互いを読む循環依存になる。.hでは前方宣言だけを使い、#include "SystemData.h".cppに置く。ここを外すと出るエラーは決まっている。

error: 'SystemData' does not name a type
error: 'struct SystemData' has no member named 'button'

前者は循環依存か前方宣言漏れ、後者はSystemData.hへのメンバ追加忘れだ。

init()の失敗はenabledで畳む

センサーが刺さっていない、I2Cが応答しない、といった事態で全体を止めない。init()falseを返したらそのモジュールだけenabled = falseにして、ループから外す。

// sample/src/main.cpp(抜粋)
static void initModuleArray(IModule **modules, int count, const char *label)
{
    const int MAX_RETRY = 3;
    for (int i = 0; i < count; i++)
    {
        bool success = false;
        for (int r = 0; r < MAX_RETRY; r++)
        {
            if (modules[i]->init())
            {
                success = true;
                break;
            }
            delay(100);
        }
        if (!success)
        {
            Serial.printf("[System] %s Module %d: init failed, disabled\n", label, i);
            modules[i]->enabled = false;
        }
    }
}

enabledは実行中にも戻せる。ロジックフェーズで数秒おきにinit()を再試行すれば、接触不良で落ちたセンサーが挿し直しで自動復帰する。

I2CやSPIのバスはmain.cppで一元管理する

「1ハードウェア = 1クラス」を素直に適用して、モジュールのinit()の中でbus.begin()を呼ぶ設計にすると事故る。2つ目のモジュールの初期化でバスがリセットされ、先に初期化した側が黙って壊れることがあるからだ。バスは部品ではなく共有資源なので、ここは例外にしてある。

// sample/src/main.cpp(抜粋)
static TwoWire mpuWire = TwoWire(0); // MPU6500用I2C

void setup()
{
    Serial.begin(115200);
    Serial.println("[System] 起動");

    // バス初期化(全モジュールのinit()より前に実行)
    mpuWire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
    // ...
}

バスのポインタはConfig構造体には入れず、コンストラクタの引数で渡す(Mpu6500Module mpu6500Module(MPU6500_CONFIG, &mpuWire);)。

割り込みや別タスクからSystemDataを触らない

BLEのコールバックや外部割り込みは、loop()とは別の文脈で走る。ここからSystemDataを直接書くと、一方向のデータフローがフェーズの途中で破られ、「同じ周回なのに前半と後半で違う値を見た」という再現しづらいバグになる。いったんvolatileを付けたフラグとバッファに置いて、updateInput()で吸い上げる。volatileは「この変数はいつ書き換わるか分からないので、コンパイラは値をレジスタに溜め込まず毎回読み直せ」という指示だ。

// 割り込み / 別タスク側
volatile bool _hasNewData = false;
SensorRaw _buffer;

void ISR_or_Task() {
    _buffer = readRaw();
    _hasNewData = true;
}

// updateInput() 内
void FooModule::updateInput(SystemData& data) {
    if (_hasNewData) {
        _hasNewData = false;
        data.foo.value = convert(_buffer);
    }
}

「組み込みでvirtualは重いのでは」を測る

このアーキテクチャはvirtualに乗っているので、「仮想関数はメモリを食うのでは」という疑問が付いて回る。virtualを持つクラスは、どの実装を呼ぶかを実行時に引くための表(vtable)を持ち、インスタンス側にはその表を指すポインタが1本増える。本当に重いなら設計ごと考え直すことになるので、実際に測った。

同じLedModuleButtonModule・同じロジックで、A: IModule*の配列を仮想関数経由で回す版と、B: 継承もvirtualも使わず具象クラスを直接呼ぶ版の2本をビルドして比較する。計測条件は次のとおり。

  • ボード: esp32-s3-devkitc-1(ESP32-S3)
  • PlatformIO Core 6.1.19 / platform espressif32 53.3.13 / framework-arduinoespressif32 3.1.3
  • ホストはmacOS 26.4(arm64)
  • 最適化オプションはフレームワークの既定のまま。build_flagsはインクルードパスの-Iのみ
  • ビルドは決定的なので各1回。数字はpio runが出すChecking sizeの値
RAM(.data + .bss) Flash
A: IModule経由 20,084 B 333,564 B
B: 直接呼び出し 20,068 B 333,380 B
+16 B +184 B

オブジェクト単体のサイズもコンパイラに出力させた。

A: IModule継承 B: 継承なし
sizeof(LedModule) 8 B 2 B
sizeof(ButtonModule) 24 B 16 B

増分の正体は、各インスタンスに付くvtableへのポインタ(32bit機なので4バイト)と、それに伴うアライメント調整だ。sizeofの差の合計は14Bで、リンク後のRAM差16Bとの2Bのずれは.bss上の配置による。

Flashの+184Bはファーム全体の0.06%で、ESP32クラスなら誤差と言っていい。ただしこれはESP32-S3での数字であって、Flashが32KBしかないAVRのような環境では同じ184Bでも比率がまるで変わる。そこは自分のターゲットで測ってほしい。

なおこのA/B比較はこの記事のために作った最小プロジェクトで、リポジトリには含まれていない。手元で確かめるなら、LedModuleButtonModuleだけのmain.cppを継承あり・なしで用意してpio runを2回叩けば同じ形の数字が出る。

教材とAI向けの規約ファイル

規約は、コードだけ置いても伝わらない。「なぜinit()だけ純粋仮想なのか」「なぜモジュール同士のクロス参照を禁止するのか」を説明できる形にしておかないと、使う側が自分で判断できない。そこでリポジトリにはPDFを3本入れてある。

Step ドキュメント 内容
1 docs/01_教科書.pdf C++クラスの基礎から3フェーズモデルまでをQ&A形式で
2 docs/02_実装ガイド.pdf sample/の実コードを題材にした解説
3 docs/03_設計仕様書.pdf ルール集・API仕様。必要なときに引く

1本目は「構造体とクラスの違い」から始めて、privateにする理由、virtual= 0の意味、3フェーズモデル、PlatformIOの使い方までを順に積み上げる構成にしている。この記事の内容をもっと細かく分解したもの、と考えてもらえばいい。

もう1つ、ARCHITECTURE.mdCLAUDE.mdをリポジトリ直下に置いてある。これはコーディング支援AIに新規モジュールを書かせるときの規約ファイルで、「.hSystemData.hをインクルードしない」「Dataにはデフォルト値を書く」といった、この記事で挙げた規約をそのまま書き下したものだ。フォーク先でもこの2ファイルを維持しておけば、AIが生成するコードもパターンから外れにくくなる。人間向けに言語化する作業と、AI向けに規約を書く作業が、ほとんど同じ内容になるのは面白いところだった。

https://github.com/takushio2525/Embedded-Module-Architecture

まとめ

やっていることは3つだけになる。

  • ハードウェア1個をクラス1個に閉じ込め、外に見せるのはConfigDataだけにする
  • 全モジュールがIModuleを継承して、init()/updateInput()/updateOutput()/deinit()の形を揃える
  • データはSystemDataに集め、loop()を入力・ロジック・出力の3段に固定する

これを決めると、機能追加が「モジュールを1個作って配列に登録する」という定型作業に変わり、loop()が読める状態のまま保たれる。抽象化のコストはESP32-S3でFlash +184B・RAM +16B。この程度で済むなら払う価値はあると思っている。

規約の中身は、自分のプロジェクトに合わせて変えていい。大事なのは「モジュールごとに書き方が違う」状態をなくすことのほうで、決まってさえいれば、人間が書いてもAIが書いても同じ形のコードが出てくる。