Arduinoの状態遷移入門|enum・switch・millis()で停止・再開を実装

プログラミング・電子工作

YOFUKASI LAB / BUILD BEYOND BLINK

Arduinoの状態遷移入門
停止・再開できる工作を
enum・switch・millis()で作る

点灯するだけなら、数行。途中で止める、残り時間から再開する、同時押しでも迷わない。そこまで作ると、工作はぐっと使いやすくなります。

対象:if文とmillis()を学んだ方|基準ボード:Arduino UNO R3|追加ライブラリ不要

Arduino UNOとボタン2個で、待機・動作中・一時停止・完了の状態を切り替える工作の概念イラスト
概念イラストです。配線は第3章の接続表、実際の状態遷移は第2章の表を参照してください。
この記事で作るもの

ボタンAで開始・一時停止・再開、ボタンBでキャンセル。動作中だけ内蔵LEDを点灯し、実際に動作した時間の合計が5秒になると完了する、小さな制御プログラムです。一時停止中の時間は5秒に含めません。

1. 仕様を決める

状態・入力・優先順位を先に表にします。

2. 責務を分ける

入力の安定化、状態の更新、LED出力を分離します。

3. 境界を試す

長押し・同時押し・時間切れを確認します。

01|なぜif文を足すだけでは困るのか

「ボタンを押したらLEDを5秒つける」という課題に、途中停止を追加したとします。さらに再開、完了表示、キャンセルを追加すると、isRunning、isPaused、isDoneなどのフラグが増えていきます。すると「動作中なのに一時停止中でもある」という矛盾した組み合わせを作れるようになります。

有限状態機械(FSM:Finite State Machine)は、現在の状況を限られた状態のうち1つで表し、入力や時間条件に応じて切り替える考え方です。enumで状態に名前を付け、switchでその状態の振る舞いを選びます。C++の特別な自動処理ではなく、動作の整理方法です。

millis()が答えるのは「どれだけ時間が経ったか」。状態が答えるのは「今、その入力をどう扱うか」です。両方を組み合わせると、同じボタンを開始にも再開にも使えます。millis()の基本記事を読んだ後の実践として進めてください。

開始・一時停止・再開・キャンセル・完了という動作の流れを、コードを書く前に言葉で整理するイメージ図
最初に動作を言葉にします。試作の目的は「累計5秒の点灯」だけでなく「途中操作が定義どおりに働く」ことです。

スポンサーリンク



02|まず4つの状態と優先順位を決める

状態は4つです。LEDだけでは待機・一時停止・完了を区別できないため、シリアルモニタに状態名を表示します。出力が同じでも、その次の入力への反応が違うなら、別の状態にする意味があります。

現在の状態 Aを新しく押す 動作時間が累計5秒 Bの押下が確定している間 LED
IDLE/待機 5秒の動作を新規開始 判定しない 待機を維持・時間を0へ 消灯
RUNNING/動作中 時間を保存して一時停止 DONEへ 待機へ・時間を破棄 点灯
PAUSED/一時停止 残り時間から再開 判定しない 待機へ・時間を破棄 消灯
DONE/完了 新しい5秒動作を開始 完了を維持 待機へ・時間を破棄 消灯
同じloop()で条件が重なったら:B → 完了 → A

キャンセルを最優先します。Bが確定した押下状態ならAは無視します。Bが押されていなければ時間切れを判定し、それからAを処理します。完了とAが同じ周期で成立した場合、そのAは一時停止にも新規開始にも使わずに破棄し、もう一度押すまで完了状態を維持します。

ここでいう「同時」は、デバウンス後の入力を同じ更新周期で評価した場合です。物理的な押し始めが同じでも接点の揺れ方は違います。Bが確定するまでの間にAだけが先に確定する可能性はあります。この例のBは学習用キャンセルであり、危険な機械を止める非常停止回路ではありません。

03|部品と配線:内蔵LED+ボタン2個

Arduino UNO R3、タクトスイッチ2個、ブレッドボード、ジャンパーワイヤ、USBケーブルを並べた部品のイメージ
部品のイメージ。実験ではUNO R3の内蔵LEDを使い、外付けLEDは不要です。
用意するもの 数量・条件
Arduino UNO R3 1台。本記事の基準ボード
USBケーブル 1本。UNO R3側はUSB Type-B。PC側の端子に合うデータ通信対応品
モーメンタリスイッチ 2個。押している間だけ導通するもの
ブレッドボード・ジャンパーワイヤ スイッチ2個とGNDを接続できる分
PC・Arduino IDE UNO R3を選択して書き込める環境

USBを抜いてから配線します。各ボタンは入力ピンとGNDの間に接続します。INPUT_PULLUPを使うため、この構成では外付けプルアップ抵抗を追加する必要はありません。離しているとHIGH、押すとLOWです。Arduino公式:入力モードと内部プルアップも参照してください。

接続元 接続先 役割
D2 ボタンAの一方の接点 開始・一時停止・再開
ボタンAの反対の接点 GND 押すとD2がLOW
D3 ボタンBの一方の接点 キャンセル
ボタンBの反対の接点 GND 押すとD3がLOW
内蔵LED 追加配線なし LED_BUILTINで操作

4本足のタクトスイッチは、押さなくても内部でつながっている端子の組があります。同じ組の端子にD2とGNDをつなぐと、常時押下として読まれます。部品のデータシートやテスターで「押したときだけ導通する2点」を確認してください。足の見た目だけで判断しないことが大切です。タクトスイッチの配線と入力の基本も併せて確認できます。

接続の概念図:ボタンAをD2とGNDの間、ボタンBをD3とGNDの間に接続。外付け抵抗なしで内部プルアップを使い、LEDは内蔵LEDを使用
スイッチを離した状態の接続の概念図。内部プルアップはコードで有効にします。端子位置は上の接続表を正としてください。

04|ボタンは「レベル」と「イベント」を分ける

ボタンAは押した瞬間を1回のイベントとして、ボタンBは押している間のレベルとして扱う違いを比べた図
Aは押した瞬間を1回のイベントとして扱い、Bは押下中の状態を使います。

digitalRead(pin) == LOWは「今、押されている」というレベルです。これをそのまま開始・停止の切り替えに使うと、指を離す前にloop()が何度も回り、状態が高速に往復してしまいます。必要なのは、離されていた入力が押下に変わった瞬間のイベントです。

ただし機械式接点は、押した直後に短時間のON/OFFを繰り返すことがあります。これがチャタリングです。Arduino公式のDebounce例でも、時間を使って不要な変化を抑えています。本記事は「生の入力が最後に変化してから25ms以上変わらなければ確定」という方式です。25msはこの教材の初期値であり、すべてのスイッチに対する保証値ではありません。

rawは生の入力、stableは確定した入力、changedAtは生の入力が変わった時刻です。AはstableがLOWに変化した時だけtrueを返します。BはstableがLOWの間ずっとキャンセルを優先します。Bを押したままAを押しても、動作を開始させないためです。

押下だけでなく解放も25msの安定を確認します。短すぎる押下や短すぎる解放をはさんだ連打は、1回ずつ認識されない場合があります。起動時にAを押したままの場合も自動開始せず、一度離してから押し直す仕様です。

スポンサーリンク


05|コピーして動かす完成スケッチ

  1. Arduino IDEで新しいスケッチを開き、下のコード全体に置き換えます。
  2. ボードをArduino UNO、対象ポートを選択して書き込みます。
  3. シリアルモニタを115200bpsで開きます。ボードが再起動した場合はIDLEから始まります。
  4. Aで点灯し、再度Aで消灯して一時停止。さらにAで残り時間から点灯を再開します。
  5. Bでキャンセルし、次のAで新しい5秒を始めます。

検証範囲:掲載スケッチをホストC++環境の模擬入出力で検査。Arduino IDEでのAVR向けビルド、実機配線、接点ノイズ、実測タイミングは未検証です。

#include <Arduino.h>
#include <stdint.h>

const uint8_t ACTION_PIN = 2;
const uint8_t CANCEL_PIN = 3;
const uint32_t DEBOUNCE_MS = 25;
const uint32_t RUN_MS = 5000;

enum State { IDLE, RUNNING, PAUSED, DONE };
State state = IDLE;
uint32_t usedMs = 0;
uint32_t runStarted = 0;

struct Button {
  uint8_t pin;
  bool raw;
  bool stable;
  uint32_t changedAt;

  void begin(uint8_t p) {
    pin = p;
    pinMode(pin, INPUT_PULLUP);
    raw = stable = digitalRead(pin);
    changedAt = (uint32_t)millis();
  }

  bool update(uint32_t now) {
    bool sample = digitalRead(pin);

    if (sample != raw) {
      raw = sample;
      changedAt = now;
    }

    if (raw != stable &&
        (uint32_t)(now - changedAt) >= DEBOUNCE_MS) {
      stable = raw;
      return stable == LOW;
    }

    return false;
  }
};

Button actionButton, cancelButton;

void enterState(State next) {
  if (state == next) return;

  state = next;
  Serial.print(F("STATE="));

  switch (state) {
    case IDLE:
      Serial.println(F("IDLE"));
      break;

    case RUNNING:
      Serial.println(F("RUNNING"));
      break;

    case PAUSED:
      Serial.println(F("PAUSED"));
      break;

    case DONE:
      Serial.println(F("DONE"));
      break;
  }
}

void control(uint32_t now, bool actionPressed, bool cancelHeld) {
  // Priority: cancel > completion > action.
  if (cancelHeld) {
    usedMs = 0;
    enterState(IDLE);
    return;
  }

  if (state == RUNNING) {
    uint32_t segment = (uint32_t)(now - runStarted);

    if (segment >= RUN_MS - usedMs) {
      usedMs = RUN_MS;
      enterState(DONE);
      return;
    }
  }

  if (!actionPressed) return;

  switch (state) {
    case IDLE:
    case DONE:
      usedMs = 0;
      runStarted = now;
      enterState(RUNNING);
      break;

    case RUNNING:
      usedMs += (uint32_t)(now - runStarted);
      enterState(PAUSED);
      break;

    case PAUSED:
      runStarted = now;
      enterState(RUNNING);
      break;
  }
}

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, LOW);

  Serial.begin(115200);

  actionButton.begin(ACTION_PIN);
  cancelButton.begin(CANCEL_PIN);

  Serial.println(F("STATE=IDLE"));
}

void loop() {
  uint32_t now = (uint32_t)millis();

  bool actionPressed = actionButton.update(now);
  cancelButton.update(now);

  control(now, actionPressed, cancelButton.stable == LOW);

  digitalWrite(LED_BUILTIN, state == RUNNING ? HIGH : LOW);
}

06|一時停止しても残り時間を失わない仕組み

動作中の時間だけを累計し、一時停止中の時間は5秒に加えないことを示した概念図
休止時間は動作時間に加えません。図は概念イメージであり、計測値を表すグラフではありません。

覚える値は、すでに動いた合計時間usedMsと、今回動き始めた時刻runStartedの2つです。動作中の今回の経過時間はnow - runStartedで求めます。一時停止した瞬間だけ、この差をusedMsへ加えます。

操作 保存される時間 次に必要な動作時間
Aで開始 usedMs = 0 5,000ms
約2秒後、Aで一時停止 usedMs ≈ 2,000 約3,000ms
約10秒休む usedMsは変化しない 約3,000msのまま
Aで再開 runStartedを現在時刻へ更新 約3,000ms
残り時間を消化 usedMs = 5,000、DONEへ 次のAは新規の5秒

これは説明用の例で、計測結果ではありません。実際の一時停止時刻は入力が確定した周期になります。完了判定を先に行うため、通常の制御経路ではusedMsがRUN_MSを超えません。残り時間はRUN_MS - usedMsで計算でき、加算した合計値のオーバーフローを避けて比較できます。

出力を1か所に集める理由

LEDの点灯条件を「RUNNINGのときだけ」とloop()の最後に書いています。各分岐にHIGH/LOWを分散させると、新しい状態を追加したとき消灯処理を書き忘れやすくなります。状態を決める処理と、状態をピン出力に反映する処理を分ければ、対応表と照合しやすくなります。

ログは状態が変わったときだけ

enterState()は状態が変わったときだけ表示します。毎周期の大量出力は送信バッファが埋まったときに待ち時間を生むことがあります。ログを絞ることも、操作への反応を保つ設計の一部です。シリアルモニタでのデバッグ方法へ進むと観察の手順を広げられます。

07|時間判定とmillis()の周回を理解する

millis()を使うだけで並列処理になるわけではありません。loop()を短い処理で回し、各条件を順番に確認する協調的な構成です。delay()を取り除いても、長いwhile、応答待ちの通信、大量の表示があれば入力の確認は遅れます。

UNO R3ではmillis()の時刻は32ビットの符号なし値として扱われ、2³²ms、計算上約49.71日で周回します。本コードはuint32_tで差を計算します。例えば前回が4,294,967,280、現在が20なら、32ビットの符号なし減算の結果は36msです。

uint32_t elapsed = (uint32_t)(now - started);

if (elapsed >= duration) {
  // 経過時間が到達した
}

now >= started + durationという期限の大小比較は、加算結果が周回したとき意図を外すことがあります。差分方式でも、同じタイマを一周以上更新せず放置した場合の経過時間までは復元できません。本例は短い5秒タイマを通常どおり繰り返し確認する前提です。

delay()は通常のスケッチの進行を待たせますが、すべてのハードウェア機能や割り込みが完全に停止するという意味ではありません。また、millis()は精密な計測器の代替ではありません。ここで得たいのは、ボタンを確認し続けながら時間条件を扱う構造です。

スポンサーリンク


08|正常系より先に、境界の動作を試す

正常な開始・完了に加え、長押し・同時押し・時間切れが重なる境界の場面を確認するイメージ図
正常な開始・完了だけでなく、入力が重なる場面を試します。
テスト 期待する結果 確認ポイント
起動時は何も押さない IDLE・消灯 起動だけでRUNNINGにならない
Aを1回押して離す RUNNING、累計5秒でDONE 自動で再開しない
Aを押し続ける 開始イベントは1回 PAUSEDとの往復が起きない
途中でA、しばらく待ってA 一時停止時間を除いて完了 休止中にDONEにならない
RUNNINGでB IDLE・消灯 次のAは5秒から
PAUSEDでB 残り時間を破棄してIDLE 再開ではなく新規開始
Bを押したままA IDLEを維持 Bを離しても、そのAを後から実行しない
同周期にBとAが確定 Bが優先 制御関数の優先順位を確認
完了時刻とAのイベントが重なる DONEを維持 もう一度Aで新規開始
Aを押したまま電源投入 IDLEを維持 離して押し直すと開始
短いノイズ状のON/OFF 安定時間未満なら確定しない 25msが実部品に適切か
時刻が周回する状況を模擬 差分で残り時間を判定 49日待たずに計算を検査

ホスト環境のテストでは時刻と入力を人工的に与え、開始・完了、一時停止からの再開、キャンセル優先、完了優先、押下の安定化、時刻周回を確認します。これは実機のノイズや配線を再現するものではありません。机上のロジック検査と実機での確認を分けると、「コードは整っているが配線が違う」という問題を見つけやすくなります。

09|動かないときの切り分け

症状 最初に見る場所 対処の方向
ずっと押されている扱い GNDとの導通・スイッチの端子組 USBを抜いて端子組を確認
反応しない ピン番号、GND、HIGH/LOW D2/D3を取り違えていないか確認
1回で何度も切り替わる 生入力を直接使っていないか stableの押下イベントを使う
連打の一部が無視される 解放・押下の安定時間 十分に離してから押す。必要なら測定して調整
再開すると5秒に戻る usedMsの初期化位置 PAUSEDから再開する時はusedMsを消さない
休止中に完了する 時間判定がRUNNING限定か PAUSEDでは経過時間を加算しない
ログが文字化けする モニタの通信速度 115200bpsにそろえる
改造後、反応が遅い 追加処理の実行時間 待ち処理を分割し、ログ量を減らす
内蔵LEDが期待と違う ボード選択とLEDの仕様 本例はUNO R3。別ボードは仕様を確認

スポンサーリンク


10|センサーやモーターへ広げる設計

この設計をセンサーへ広げる場合、センサー値そのものを状態の代わりにしないことがポイントです。「しきい値を超えている」は入力条件、「測定中」「待機中」は装置の状態です。別々に持つと、測定値が変わっても操作の意味を保てます。

発展例 追加する設計 そのまま流用しない点
センサーの異常監視 FAULT状態、異常時の出力、復帰条件 欠測を正常値として処理しない
予告後に動作する装置 READY状態と予告タイマ 予告時間と動作時間のタイマを分ける
複数センサーの監視 取得周期と状態更新を分離 遅いセンサーの待ちが全体を止めないか確認
モーターの一時停止 駆動停止の定義、再始動条件 LEDのLOWをモーター停止の保証と考えない
設定画面のある装置 運転状態とUI状態を分ける すべての組み合わせを1つのenumへ詰め込まない

モーターをGPIOへ直結せず、適切な駆動回路・電源を用います。人や物に危険を与える機構では、ソフトウェアの入力監視とは独立した安全設計が必要です。本記事の停止ボタンはその代用にはなりません。

拡張するときの設計メモ

「この状態でその入力が来たらどうするか」を表の1行として追加します。未定義の入力は無視するのか、異常にするのか。電源を入れ直したら再開するのか、待機するのか。時間切れと操作が同時ならどちらが勝つのか。コードを書く前に、この3点を決めるだけでも矛盾が減ります。

AIへ改造を頼むときの指示例

UNO R3、A=D2、B=D3、INPUT_PULLUP、内蔵LEDを使う。IDLE/RUNNING/PAUSED/DONEの状態表を維持し、B優先・完了優先・Aは押下イベントという仕様を変えずに、残り時間の表示を追加する。待ち処理を増やさず、変更後の境界テストと未検証事項も示すこと。

11|よくある質問

状態遷移には専用ライブラリが必要ですか?

この規模なら不要です。enum、switch、時刻の変数で表現できます。状態が増えたらライブラリを選ぶ前に、独立した機能を分けられないか考えます。

ボタン2個に割り込みは必要ですか?

本例は短いloop()で確認するポーリング方式です。人のボタン操作を扱う教材として割り込みを必要としません。高速パルスの計数など、必要な応答時間が違う課題では別途設計します。

25ms待つならdelay(25)と同じですか?

違います。時刻を比較して条件を満たさなければ処理を戻すため、その間も別の入力を確認できます。ただし入力が確定するまでの遅れ自体は存在します。

一時停止とキャンセルは何が違いますか?

一時停止はusedMsを保持し、キャンセルは0に戻します。同じ消灯でも次のAの意味が違うため、別の状態と処理に分けています。

電源を抜いても途中から再開できますか?

できません。この例の状態と経過時間はRAMにあり、再起動すると待機から始まります。永続化するなら保存頻度、書き込み寿命、復旧時の妥当性を別に設計します。

UNO R4やESP32でも動きますか?

標準APIを使っていますが、本記事の基準はUNO R3です。内蔵LEDの極性、ピン番号、電圧、ボードパッケージの違いを確認し、別ボードでの動作は個別に検証してください。

12|関連する実践記事と一次資料

次に読む内部リンク

仕様確認に使う外部リンク・一次資料

資料確認日:2026年11月8日。25msのデバウンス、5秒の動作時間、4状態、競合時の優先順位は本教材の設計です。公式の推奨設定や実測結果ではありません。イラスト(接続の概念図を含む)はAI生成の概念図です。端子位置や回路の正確さは本文の接続表を優先してください。

次の工作でも使える判断

状態を1つに定め、入力の意味を決め、時間を差分で測り、競合時の優先順位を固定する。この順で考えると、機能を足した後も「なぜその動作になるか」を説明できるコードになります。

関連記事

特集記事

コメント

この記事へのコメントはありません。

TOP
CLOSE