はじめに (対象読者・この記事でわかること)

この記事は、Arduinoで基本的なLチカや簡単なセンサー読み取りを試したが、複数の動作を同時にさせようとしたときに「なぜかうまく動かない」「プログラムが固まってしまう」といった壁に直面しているArduino初学者や、より複雑な組み込みシステムを構築したいと考えている方を対象としています。

Arduinoの入門書やチュートリアルで頻繁に登場するdelay関数は、手軽に時間制御を行える便利な関数です。しかし、この関数がプログラムの実行を完全に停止させてしまう「ブロッキング処理」であることが、様々な問題を引き起こします。この記事を読むことで、delay関数が「うまく機能してくれない」と感じる根本的な理由を理解し、その問題に対する効果的な解決策であるmillis()関数を使った「ノンブロッキング処理」の実装方法を習得できます。結果として、複数のタスクを同時にこなせる、より応答性の高いArduinoプログラムを書けるようになるでしょう。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - Arduino IDEの基本的な操作方法(スケッチの作成、書き込み) - C++の基本的な文法(変数、関数、条件分岐、ループ、型) - Arduinoのデジタルピンを使った基本的な入出力操作(LED点滅、ボタン読み取りなど)

Arduinoのdelay関数:手軽さの裏に潜む落とし穴

Arduinoで時間制御を行う際、最も手軽で直感的に使えるのがdelay()関数です。この関数は引数に指定したミリ秒数だけ、プログラムの実行を一時停止させます。例えば、LEDを1秒間隔で点滅させる「Lチカ」の基本的なスケッチでは、以下のように頻繁に利用されます。

Cpp
void setup() { pinMode(LED_BUILTIN, OUTPUT); // LEDピンを出力に設定 } void loop() { digitalWrite(LED_BUILTIN, HIGH); // LEDを点灯 delay(1000); // 1000ミリ秒(1秒)待機 digitalWrite(LED_BUILTIN, LOW); // LEDを消灯 delay(1000); // 1000ミリ秒(1秒)待機 }

このコードは非常に分かりやすく、Arduinoプログラミングの導入としては最適です。しかし、このdelay()関数には、組み込みシステムを開発する上で致命的ともいえる大きな問題が潜んでいます。それは、delay()ブロッキング処理であるという点です。

ブロッキング処理とは、ある処理が完了するまで他の全ての処理を停止させてしまうことです。上記のLチカの例では、delay(1000)が実行されている間、Arduinoは文字通り何もしていません。センサーの値を読み取ることも、ボタンの入力を受け付けることも、他のLEDを制御することもできません。

この特性により、以下のような問題が発生します。 - 応答性の低下: センサーから重要なデータが送られてきても、delay実行中はそれを即座に受け取れません。 - マルチタスクの困難さ: 複数のLEDを異なる間隔で点滅させたり、同時にモーターを動かしたり、無線通信を行ったりといった複数のタスクを並行して実行しようとすると、delayが全てを妨げてしまいます。 - ユーザー体験の悪化: ボタンを押しても反応が遅れる、といったユーザーインターフェースの問題を引き起こします。

このような理由から、複雑なArduinoプロジェクトではdelay()関数はほとんど使われることがありません。初心者の方々が「delay関数がうまく機能してくれません」と感じるのは、まさにこのブロッキング処理の性質が原因で、意図した並行処理が実現できないためなのです。

delay地獄からの脱却!millis()を使ったノンブロッキング処理

delay関数が抱えるブロッキングの問題を解決し、複数のタスクを並行して(擬似的に)実行できるようにするための鍵となるのが、Arduino標準ライブラリに含まれるmillis()関数です。この関数を利用することで、プログラムの実行を停止させることなく、特定の処理を一定時間ごとに実行する「ノンブロッキング処理」を実現できます。

ステップ1: millis()の基本的な使い方と概念理解

millis()関数は、Arduinoボードが電源投入またはリセットされてから現在までの経過時間をミリ秒単位で返します。この値は、およそ49日後にオーバーフロー(0に戻る)しますが、ほとんどのアプリケーションでは問題になりません。

millis()を使ってノンブロッキング処理を実装する基本的な考え方は、以下のようになります。

  1. 最後に処理を実行した時刻を記録する変数を用意します。
  2. 処理を実行したい間隔を設定します。
  3. loop()関数内で現在のmillis()の値を取得します。
  4. 「現在の時刻 - 最後に処理を実行した時刻」「処理を実行したい間隔」以上であれば、その処理を実行し、「最後に処理を実行した時刻」を現在の時刻で更新します。

このロジックは、あたかも時計を見て「もう1秒経ったかな?」と判断して行動するのと同じです。時計を見ている間も他の作業は中断されないため、これがノンブロッキングな処理を可能にします。

millis()が返す値はunsigned long型です。これは符号なし長整数型で、最大で約42億までカウントできます。時間を扱う変数では、オーバーフロー時に予期せぬ負の値になるのを避けるため、必ずunsigned long型を使用することが推奨されます。

ステップ2: LED点滅をmillis()で実装する

では、具体的なコードを見ていきましょう。先ほどのブロッキングdelayを使ったLチカを、millis()を使ったノンブロッキングなLチカに書き換えてみます。

Cpp
// 最後にLEDの状態を変更した時刻を記録する変数 unsigned long previousMillis = 0; // LEDの点滅間隔(ミリ秒) const long interval = 1000; // 1秒 // LEDの現在の状態 (HIGHまたはLOW) int ledState = LOW; // LEDが接続されているピン番号 const int ledPin = 13; // 多くのArduinoボードの内蔵LEDは13番ピン void setup() { pinMode(ledPin, OUTPUT); // LEDピンを出力モードに設定 } void loop() { // 現在のmillis()値を取得 unsigned long currentMillis = millis(); // 最後にLEDの状態を変更してから、指定した間隔が経過したかチェック if (currentMillis - previousMillis >= interval) { // はい、間隔が経過しました。 // 最後に処理を行った時刻を更新 previousMillis = currentMillis; // LEDの状態を反転 if (ledState == LOW) { ledState = HIGH; } else { ledState = LOW; } // 新しい状態でLEDを点灯/消灯 digitalWrite(ledPin, ledState); } // ここに他の処理(センサー読み取り、ボタン入力チェックなど)を記述できます。 // このループはdelay()で停止しないため、他の処理も同時に実行され続けます。 }

このコードでは、loop()関数が非常に高速に繰り返し実行されますが、if (currentMillis - previousMillis >= interval)の条件が真になるのは、前回のLED状態変更からintervalミリ秒が経過したときだけです。それ以外の時間では、ifブロックの中のコードは実行されず、loop()関数の残りの部分(ifブロックの下に記述する他の処理)が実行され続けます。これにより、プログラム全体が停止することなく、指定した間隔でLEDを点滅させることができます。

ステップ3: 複数のタスクを同時に実行する例

millis()を使ったノンブロッキング処理の真価は、複数の独立したタスクを同時に管理できる点にあります。それぞれのタスクに対して、独立したpreviousMillisinterval変数を設定することで、まるで複数のプログラムが並行して動いているかのように振る舞わせることが可能です。

ここでは、2つのLEDを異なる間隔で点滅させつつ、同時にボタン入力も監視する例を見てみましょう。

Cpp
// ==== タスク1: LED1 (ピン13) を1秒間隔で点滅 ==== unsigned long previousMillis1 = 0; const long interval1 = 1000; int ledState1 = LOW; const int ledPin1 = 13; // ==== タスク2: LED2 (ピン12) を250ミリ秒間隔で点滅 ==== unsigned long previousMillis2 = 0; const long interval2 = 250; int ledState2 = LOW; const int ledPin2 = 12; // ==== タスク3: ボタン入力 (ピン2) を監視し、シリアルモニタに出力 (チャタリング対策込み) ==== const int buttonPin = 2; int buttonState = 0; // 現在のボタンの状態 int lastButtonState = HIGH; // 前回のボタンの状態 (INPUT_PULLUPなので初期値はHIGH) unsigned long lastDebounceTime = 0; // 最後にボタン状態が変化した時刻 unsigned long debounceDelay = 50; // チャタリング防止の待機時間 (ミリ秒) void setup() { pinMode(ledPin1, OUTPUT); pinMode(ledPin2, OUTPUT); pinMode(buttonPin, INPUT_PULLUP); // 内部プルアップ抵抗を使用 Serial.begin(9600); // シリアル通信を開始 } void loop() { unsigned long currentMillis = millis(); // 現在の時刻を一度だけ取得 // --- タスク1: LED1の点滅処理 --- if (currentMillis - previousMillis1 >= interval1) { previousMillis1 = currentMillis; // 時刻を更新 ledState1 = (ledState1 == LOW) ? HIGH : LOW; // 状態を反転 digitalWrite(ledPin1, ledState1); } // --- タスク2: LED2の点滅処理 --- if (currentMillis - previousMillis2 >= interval2) { previousMillis2 = currentMillis; // 時刻を更新 ledState2 = (ledState2 == LOW) ? HIGH : LOW; // 状態を反転 digitalWrite(ledPin2, ledState2); } // --- タスク3: ボタン入力の読み取りとチャタリング対策 --- int reading = digitalRead(buttonPin); // ボタンの現在の生の状態を読み取る // ボタンの状態が最後に変わってから、チャタリング遅延時間を超えたかチェック if (reading != lastButtonState) { // 状態が変わった場合、チャタリング防止のためのタイマーをリセット lastDebounceTime = currentMillis; } if ((currentMillis - lastDebounceTime) > debounceDelay) { // チャタリング遅延時間が経過したら、安定した状態か確認 if (reading != buttonState) { buttonState = reading; // 安定したボタンの状態を更新 // ボタンが押されたとき(INPUT_PULLUPなのでLOW) if (buttonState == LOW) { Serial.println("Button Pressed!"); } } } lastButtonState = reading; // 次のループのために現在の生の状態を保存 // このloop()関数内に、さらに他のセンサー読み取り、モーター制御、通信処理などを // 同様のmillis()ベースのノンブロッキングロジックで追加していくことができます。 }

この例では、loop()関数が1つのまとまりとして高速に実行されながらも、内部でそれぞれのif文の条件によって、各タスクが適切なタイミングで実行されています。これにより、LED1は1秒間隔、LED2は250ミリ秒間隔で点滅し、同時にボタンが押されたことを即座に検出してシリアルモニタに出力するという、複数のタスクをスムーズに並行して処理するプログラムが実現できています。

ハマった点やエラー解決

millis()を使ったノンブロッキング処理は非常に強力ですが、初めて実装する際にはいくつかの落とし穴があります。

  • unsigned long型の使い忘れ:

    • 問題: previousMilliscurrentMillisintlong型で宣言してしまうと、millis()の値がオーバーフロー(約49日後に0に戻る)した際に、currentMillis - previousMillisの計算結果が負の値になるなど、予期せぬ挙動を引き起こします。例えば、int型では最大約32767ミリ秒(約32秒)しか扱えません。
    • 解決策: 時間を扱う変数は、常にunsigned long型で宣言してください。この型であれば、約42億ミリ秒(約49日)まで正確に時間を計測できます。
  • previousMillisの更新忘れ:

    • 問題: if (currentMillis - previousMillis >= interval)の条件が成立し、処理を実行した後にpreviousMillis = currentMillis;を書き忘れると、条件が常に真になり、その処理がloop()が回るたびに繰り返し実行されてしまいます。
    • 解決策: 条件が成立して特定の処理を実行した直後に、必ずpreviousMillis = currentMillis;を記述し、最後に処理を実行した時刻を正しく更新しましょう。
  • 比較演算子の間違い:

    • 問題: currentMillis - previousMillis >= intervalとすべきところを、誤って<などを使ってしまうと、意図しないタイミングで処理が実行されたり、全く実行されなかったりします。
    • 解決策: 「経過時間が間隔以上になったら」という論理を明確にし、正しい比較演算子(>=)を使用してください。

これらの点に注意しながら実装を進めることで、より堅牢で信頼性の高いノンブロッキング処理を構築できます。

まとめ

本記事では、Arduinoプログラミングにおいて初心者の方が「delay関数がうまく機能してくれません」と感じる根本的な理由が、そのブロッキング特性にあることを解説し、その問題を解決する強力な手法としてmillis()関数を使ったノンブロッキング処理の実装方法を解説しました。

  • 要点1: delay関数はプログラムの実行を完全に停止させる「ブロッキング処理」であり、複数のタスクを同時にこなすことができません。
  • 要点2: millis()関数はArduino起動からの経過時間をミリ秒で返し、これを利用して「最後に処理を行ってから、指定した時間が経過したか」を判断するロジックを構築します。
  • 要点3: 各タスクごとに独立したpreviousMillisinterval変数を持ち、loop()関数内でそれぞれのタスクを個別に時間管理することで、プログラム全体を停止させることなく複数の処理を擬似的に並行実行できます。

この記事を通して、あなたはdelayの制約から解放され、より応答性が高く、複雑な動作を同時にこなせるArduinoプログラムを作成するための強力なツールを手に入れたはずです。これにより、センサーからのリアルタイムなデータ取得、複数のアクチュエーターの協調制御、ユーザー入力への即時反応など、あなたのArduinoプロジェクトの可能性は大きく広がることでしょう。

今後は、このノンブロッキング処理の概念をさらに発展させた「有限状態機械(Finite State Machine: FSM)」や、より高度なタスク管理を可能にする「RTOS(リアルタイムオペレーティングシステム)」といったテーマについても、ぜひ学習を進めてみてください。

参考資料