ステートマシン(状態遷移)とは?組み込み開発での設計と実装方法を初心者向けに解説

組み込み基礎

組み込み機器のプログラムが大きくなってくると、「今、機器がどの状態なのか」を管理する必要が出てきます。

たとえばモーターを制御する機器なら、

  • 待機中
  • 動作中
  • 停止中
  • エラー中

といった状態が考えられます。

さらに、「スタートボタンが押された」「センサーが異常を検出した」「タイマーが終了した」といった出来事によって、機器の状態は変化します。

このように、機器の動作をいくつかの状態(State)に分け、イベントに応じて別の状態へ切り替える設計方法がステートマシン(State Machine)です。

日本語では「状態機械」「状態遷移」などと呼ばれますが、組み込み開発では「ステートマシン」「状態遷移」という言葉をよく使います。

この記事では、状態・イベント・アクションという基本的な考え方から、状態遷移図、C/C++のenumとswitchを使った実装、KUMITATE-C3での実例まで順番に解説します。

ステートマシンの仕組み。状態・イベント・アクション、状態遷移図、switch-caseと関数ポインタによる実装例を示した図
ステートマシンの状態・イベント・アクションと、組み込み開発での実装方法

ステートマシンとは?

ステートマシンとは、システムの動作を「状態」と「状態の切り替え」によって表現する設計方法です。

たとえば、ボタンを押すとモーターが動く機器を考えてみましょう。

待機
 ↓
スタートボタン
 ↓
動作中
 ↓
停止ボタン
 ↓
待機

さらに異常を検出した場合には、

動作中
 ↓
異常検出
 ↓
エラー

と状態を変更できます。

このように現在の状態と発生した出来事によって、次の状態や実行する処理を決めるのがステートマシンの基本です。

なぜ組み込み開発でステートマシンを使うの?

簡単なプログラムなら、いくつかのif文だけでも動作を作ることができます。

if (buttonPressed) {
    motorStart();
}

if (stopButtonPressed) {
    motorStop();
}

if (errorDetected) {
    motorStop();
    showError();
}

しかし機能が増えてくると、条件が複雑になります。

たとえば「待機中だけスタートを受け付ける」「動作中だけ停止を受け付ける」「エラー中はスタートできない」といった条件が追加されると、単純なフラグとif文だけでは全体の動作を把握しにくくなります。

そこで、

今どの状態か?
      ↓
その状態で何が起きたか?
      ↓
次はどの状態になるか?

という形で整理します。

これにより、機器の動作を設計しやすくなります。

ステートマシンを構成する3つの要素

ステートマシンを理解するときは、まずState・Event・Actionの3つを押さえておくと分かりやすくなります。

State(状態)

Stateは、現在システムがどのような状態にあるかを表します。

  • IDLE:待機中
  • RUN:動作中
  • ERROR:エラー中

などです。

Event(イベント)

Eventは、状態を変化させるきっかけです。

  • ボタンが押された
  • タイマーが終了した
  • UARTでコマンドを受信した
  • センサーが異常を検出した
  • Wi-Fiへ接続した

Action(アクション)

Actionは、イベントや状態遷移に伴って実行する処理です。

  • モーターを起動する
  • LEDを点灯する
  • OLEDへメッセージを表示する
  • エラー情報を保存する
  • 通信データを送信する

状態遷移とは?

ある状態から別の状態へ切り替わることを状態遷移(State Transition)と呼びます。

たとえば、

IDLE
 ↓
スタートボタン
 ↓
RUN

なら、スタートボタンというイベントによって、IDLEからRUNへ状態遷移しています。

さらに、

RUN
 ↓
異常検出
 ↓
ERROR

という遷移も考えられます。

状態遷移図を作ってみよう

ステートマシンを設計するときは、いきなりプログラムを書くよりも、最初に状態遷移図を作ると全体を整理しやすくなります。

            スタート
       ┌─────────────→
       │
   ┌───────┐       ┌───────┐
   │ IDLE  │       │  RUN  │
   └───────┘       └───────┘
       ↑               │
       └───────────────┘
             停止


RUN ──異常検出──→ ERROR

ERROR ──リセット──→ IDLE

図にすると、「どの状態から、どのイベントで、どこへ移動するのか」が分かりやすくなります。

C/C++ではenumで状態を定義できる

C/C++でステートマシンを実装するときは、enumを使って状態を定義する方法が分かりやすいです。

enum State {
    IDLE,
    RUN,
    ERROR
};

現在の状態を保存する変数を用意します。

State state = IDLE;

これでプログラムは、現在の状態がIDLE・RUN・ERRORのどれなのかを管理できます。

switch-caseで状態ごとの処理を書く

現在の状態によって処理を分けるには、switch文がよく使われます。

switch (state) {

    case IDLE:

        // 待機中の処理

        break;


    case RUN:

        // 動作中の処理

        break;


    case ERROR:

        // エラー中の処理

        break;
}

これだけでも、

IDLEの処理

RUNの処理

ERRORの処理

が明確に分離されます。

イベントによって状態を変更する

IDLE状態でスタートボタンが押されたらRUNへ移行する場合は、次のように書けます。

case IDLE:

    if (startButtonPressed()) {

        motorStart();

        state = RUN;
    }

    break;

RUN状態で停止ボタンが押された場合は、

case RUN:

    if (stopButtonPressed()) {

        motorStop();

        state = IDLE;
    }

    break;

となります。

エラー状態を追加する

RUN中に異常を検出したらERRORへ移行させてみます。

case RUN:

    if (errorDetected()) {

        motorStop();

        state = ERROR;
    }

    break;

ERROR状態では、たとえばエラーLEDを点灯させます。

case ERROR:

    errorLedOn();

    if (resetButtonPressed()) {

        errorLedOff();

        state = IDLE;
    }

    break;

こうすると、エラー状態では通常のスタート処理を実行しない、といった制御も自然に表現できます。

ステートマシン全体のコード

ここまでの内容をまとめると、次のようになります。

enum State {
    IDLE,
    RUN,
    ERROR
};

State state = IDLE;


void loop()
{
    switch (state) {

        case IDLE:

            if (startButtonPressed()) {

                motorStart();

                state = RUN;
            }

            break;


        case RUN:

            if (errorDetected()) {

                motorStop();

                state = ERROR;

            } else if (stopButtonPressed()) {

                motorStop();

                state = IDLE;
            }

            break;


        case ERROR:

            if (resetButtonPressed()) {

                clearError();

                state = IDLE;
            }

            break;
    }
}

プログラムを読むと、各状態で何をして、どの条件で次の状態へ移るのかが分かりやすくなります。

フラグを大量に使う設計との違い

ステートマシンを使わずに状態を管理すると、複数のbool変数を使いたくなることがあります。

bool isRunning = false;
bool isError = false;
bool isWaiting = true;

しかし、この方法では矛盾した状態を作れてしまいます。

isRunning = true;
isError = true;
isWaiting = true;

「動作中・エラー中・待機中がすべてtrue」という状態になってしまいました。

一方、

State state = RUN;

のように1つの状態変数で管理すれば、少なくともこのステートマシンでは現在の状態を1つに限定できます。

時間待ちにもステートマシンが使える

Arduino初心者のプログラムでは、時間待ちにdelay()を使うことがあります。

digitalWrite(LED_PIN, HIGH);

delay(1000);

digitalWrite(LED_PIN, LOW);

しかしdelay()で待っている間は、その処理の流れでは別の仕事を進められません。

そこで、状態とタイマーを組み合わせます。

IDLE
 ↓
ボタン
 ↓
LED_ON
 ↓
1秒経過
 ↓
LED_OFF

millis()などで経過時間を確認すれば、待っている間にも他の処理を実行できます。

KUMITATE-C3で試してみよう

KUMITATE-C3にはLEDとSW1が搭載されているため、簡単なステートマシンを試すことができます。

今回は次の3状態を作ります。

IDLE
 ↓ SW1
ON
 ↓ 1秒経過
OFF
 ↓ SW1
ON

KUMITATE-C3ではLEDをGPIO0、SW1をGPIO21として扱います。

const int LED_PIN = 0;
const int SW_PIN  = 21;

enum State {
    IDLE,
    LED_ON,
    LED_OFF
};

State state = IDLE;

unsigned long stateStartTime = 0;


void setup()
{
    pinMode(LED_PIN, OUTPUT);
    pinMode(SW_PIN, INPUT_PULLUP);

    digitalWrite(LED_PIN, LOW);

    Serial.begin(115200);
}


void loop()
{
    switch (state) {

        case IDLE:

            if (digitalRead(SW_PIN) == LOW) {

                digitalWrite(LED_PIN, HIGH);

                stateStartTime = millis();

                state = LED_ON;
            }

            break;


        case LED_ON:

            if (millis() - stateStartTime >= 1000) {

                digitalWrite(LED_PIN, LOW);

                state = LED_OFF;
            }

            break;


        case LED_OFF:

            if (digitalRead(SW_PIN) == LOW) {

                digitalWrite(LED_PIN, HIGH);

                stateStartTime = millis();

                state = LED_ON;
            }

            break;
    }
}

このプログラムでは、delay(1000)を使わずにLEDの点灯時間を管理しています。

そのため、LEDが点灯している1秒間にも、別のセンサー処理や通信処理などを追加できます。

なお、この簡単な例ではボタンを押し続けた場合やチャタリングについて十分な対策をしていません。実際のボタン入力では、押下エッジの検出やデバウンスを組み合わせるとより適切です。

チャタリング対策とも組み合わせられる

ボタンを使ったステートマシンでは、チャタリングにも注意が必要です。

物理スイッチは1回押しただけでも、短時間にON/OFFを繰り返して見えることがあります。

そのまま状態遷移の条件にすると、意図しない複数回の遷移が起きる可能性があります。

詳しくは「チャタリングとは?スイッチ入力が何度も反応する原因と対策を解説」も参照してください。

タイマーとステートマシンは相性がよい

ステートマシンでは、「一定時間経過した」という出来事もイベントとして扱えます。

MOTOR_START
      ↓
5秒経過
      ↓
MOTOR_STOP

あるいは、

CONNECTING
     ↓
10秒経過
     ↓
TIMEOUT

のような設計もできます。

タイマーについては「タイマーとは?マイコンで時間を測る仕組みを初心者向けに解説」も合わせて読むと理解しやすくなります。

イベントと状態を分離するとさらに整理しやすい

規模が大きくなると、状態だけでなくイベントもenumとして定義する方法があります。

enum State {
    IDLE,
    RUN,
    ERROR
};

enum Event {
    EVENT_NONE,
    EVENT_START,
    EVENT_STOP,
    EVENT_ERROR,
    EVENT_RESET
};

すると、

現在の状態
+
発生したイベント
       ↓
次の状態を決める

というステートマシンらしい構造がより明確になります。

状態遷移表で整理する方法もある

状態やイベントが増えてきたら、状態遷移図だけでなく状態遷移表を作る方法も便利です。

現在の状態イベント次の状態処理
IDLESTARTRUNモーター開始
RUNSTOPIDLEモーター停止
RUNERRORERROR緊急停止
ERRORRESETIDLEエラー解除

仕様書や設計書としても、この形は非常に分かりやすくなります。

関数ポインタで状態ごとの処理を切り替える方法

以前の記事で学習した関数ポインタを使ってステートマシンを実装する方法もあります。

void handleIdle();
void handleRun();
void handleError();

typedef void (*StateFunction)();

StateFunction stateTable[] = {
    handleIdle,
    handleRun,
    handleError
};

状態を数値として対応させれば、

stateTable[state]();

のように現在の状態に対応した関数を実行できます。

状態が増えたときに各状態の処理を関数へ分離しやすくなる方法の1つです。

関数ポインタについては「関数ポインタとコールバックとは?仕組みと使い方を初心者向けに解説」で詳しく解説しています。

リングバッファとステートマシンを組み合わせる

前回学習したリングバッファとも組み合わせられます。

たとえばUARTからコマンドを受信する機器なら、

UART
 ↓
受信データ
 ↓
リングバッファ
 ↓
コマンド解析
 ↓
イベント生成
 ↓
ステートマシン
 ↓
状態遷移

という構造にできます。

通信処理と機器の動作ロジックを分離できるため、プログラム全体を整理しやすくなります。

リングバッファについては「リングバッファとは?仕組みとUART受信での使い方を初心者向けに解説」も参照してください。

割り込みから直接状態を変えるべき?

割り込みを使っている場合、割り込みハンドラから複雑な状態遷移処理を直接行うよりも、イベントやフラグを通知して通常処理側で状態遷移させる設計が扱いやすい場合があります。

GPIO割り込み
     ↓
イベント / フラグを通知
     ↓
メイン処理
     ↓
ステートマシン
     ↓
状態遷移

こうすると割り込み処理を短く保ち、機器の状態管理を1か所へ集めやすくなります。

状態へ入った瞬間だけ処理したい場合

実際のステートマシンでは、状態へ入った瞬間だけ実行したい処理があります。

たとえばRUNへ遷移した瞬間だけモーターを起動し、その後は毎回motorStart()を実行したくない場合です。

このような処理は一般にEntry Action(状態開始時処理)として整理できます。

IDLE
 ↓ START
 ↓
[RUNへ遷移]
 ↓
Entry
・モーター開始
・タイマー開始
 ↓
RUN状態を継続

同様に状態を抜けるときだけ行う処理をExit Actionとして整理する方法もあります。

ステートマシンを設計するときのポイント

ステートマシンを作るときは、次の順番で考えると整理しやすくなります。

  1. 状態を洗い出す
    機器がどのような動作状態を持つか考える
  2. イベントを洗い出す
    ボタン、センサー、通信、タイマーなど状態を変化させる出来事を整理する
  3. 状態遷移図を書く
    どの状態からどの状態へ移動するか整理する
  4. アクションを決める
    状態遷移時や各状態で何を実行するか決める
  5. 異常時の動作を考える
    エラーやタイムアウト時にどの状態へ移るか決める

状態を細かくしすぎない

ステートマシンは便利ですが、何でも状態として分割すればよいわけではありません。

状態を細かくしすぎると、逆に状態遷移が複雑になります。

状態が少なすぎる
 ↓
1状態の処理が複雑


状態が多すぎる
 ↓
状態遷移が複雑

「機器の動作として明確に区別したい状態か」を基準に考えるとよいでしょう。

ステートマシンが使われる例

ステートマシンは、さまざまな組み込み機器で利用できます。

  • モーター制御
  • 自動ドア
  • 信号機
  • ロボット
  • 通信プロトコル
  • Wi-Fi接続処理
  • Bluetooth接続処理
  • センサー測定シーケンス
  • 充電制御
  • 機器の起動シーケンス
  • エラー復旧処理
  • ユーザーインターフェース

ステートマシンのメリット

  • 機器の動作状態を明確にできる
  • 状態ごとに処理を分離できる
  • 状態遷移図で仕様を共有しやすい
  • 複雑なif文を整理しやすい
  • エラー状態を明示的に扱える
  • 機能追加時の影響範囲を把握しやすい
  • テストすべき状態と遷移を整理しやすい

ステートマシンを使うときの注意点

  • 状態を増やしすぎない
  • 状態遷移条件を明確にする
  • 同じイベントでも状態によって意味が違うことを意識する
  • 状態遷移時の処理を整理する
  • 想定外のイベントをどう扱うか決める
  • エラーからの復帰方法を決める
  • 割り込み内で複雑な状態処理をしすぎない

これまで学んだ内容がステートマシンにつながる

ステートマシンは、これまで学んできた組み込み開発の基礎知識をまとめて利用できるテーマです。

GPIO
 ↓
ボタン・センサー

割り込み
 ↓
イベント発生

タイマー
 ↓
時間イベント

UART
 ↓
通信イベント

リングバッファ
 ↓
受信データを保持

コールバック
 ↓
イベント通知

        ↓

ステートマシン
        ↓
現在の状態を判断
        ↓
状態遷移
        ↓
モーター / LED / 通信などを制御

つまりステートマシンは、個別に学習してきたGPIO・割り込み・タイマー・通信などを、1つの機器として動かすための設計方法とも言えます。

まとめ

ステートマシンは、機器の動作をいくつかの状態に分け、イベントによって状態を切り替える設計方法です。

  • Stateは現在の状態を表す
  • Eventは状態を変化させるきっかけを表す
  • Actionは状態や遷移に伴って実行する処理を表す
  • 状態から別の状態へ移ることを状態遷移と呼ぶ
  • 状態遷移図を作ると機器全体の動作を整理しやすい
  • C/C++ではenumとswitch-caseで比較的簡単に実装できる
  • フラグを大量に使うより状態を明確にできる場合がある
  • タイマーを組み合わせればdelay()に頼らない処理を作れる
  • 関数ポインタを使って状態ごとの処理を分離する方法もある
  • UART・リングバッファ・割り込みなどとも組み合わせられる

組み込みプログラムでは、単に「センサーを読む」「モーターを回す」といった個々の処理だけでなく、機器全体が今どの状態にあり、次に何が起きたらどう動くのかを設計する必要があります。

ステートマシンを理解すると、複雑な機器の動作を整理してプログラムへ落とし込みやすくなります。まずはIDLE・RUN・ERRORのような3状態程度の小さなステートマシンを自分で作ってみると、考え方をつかみやすいでしょう。

KUMITATE

読むだけでなく、
実際に動かして学びませんか?

クミタテは、組み込み開発や電子工作を 実践しながら学べる学習プラットフォームです。 ESP32を使ったプログラミングから、電子回路、センサー、 通信、基板設計まで、自分のペースで学習できます。

  • 無料で学べる実践的な教材を掲載
  • すぐに試せるサンプルプログラム付き
  • Googleアカウントですぐに登録可能
クミタテに無料登録する
組み込み基礎
スポンサーリンク