Arduino 公式フォーラムの “Is there a non blocking delay that’s as easy to use as the built in delay function?” で見つけた「邪道」なコードを掘り下げてみました。

例えば複数のタスクを異なる周期で起動する、次のようなスケッチがあるとします。

#define INTERVAL1 110 // [msec]
#define INTERVAL2 120 // [msec]
#define INTERVAL3 130 // [msec]

uint32_t previousMillis1 = 0;
uint32_t previousMillis2 = 0;
uint32_t previousMillis3 = 0;

void setup() {
  ; // 何らかの初期化
}

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

  // 1つ目のタスク
  if (currentMillis - previousMillis1 >= INTERVAL1) {
    previousMillis1 = currentMillis;
    ; // 何らかの処理
  }

  // 2つ目のタスク
  if (currentMillis - previousMillis2 >= INTERVAL2) {
    previousMillis2 = currentMillis;
    ; // 何らかの処理
  }

  // 3つ目のタスク
  if (currentMillis - previousMillis3 >= INTERVAL3) {
    previousMillis3 = currentMillis;
    ; // 何らかの処理
  }
}

これを次のように書けたら、少し嬉しくありませんか?

#define INTERVAL1 110 // [msec]
#define INTERVAL2 120 // [msec]
#define INTERVAL3 130 // [msec]

void setup() {
  ; // 何らかの初期化
}

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

  // 1つ目のタスク
  DO_EVERY(INTERVAL1, currentMillis) {
    ; // 何らかの処理
  }

  // 2つ目のタスク
  DO_EVERY(INTERVAL2, currentMillis) {
    ; // 何らかの処理
  }

  // 3つ目のタスク
  DO_EVERY(INTERVAL3, currentMillis) {
    ; // 何らかの処理
  }
}

DO_EVERY() の正体は、次のようなマクロです。

// ノンプリエンプティブな周期的タスクを起動するちょっと変わったマクロ
#define DO_EVERY(period, now)  for (static uint32_t prev = 0; now - prev >= period; prev = now)

このマクロを展開すると、次のようなコードになります。

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

  // 1つ目のタスク
  for (static uint32_t prev = 0; currentMillis - prev >= INTERVAL1; prev = currentMillis) {
    ; // 何らかの処理
  }

  // 2つ目のタスク
  for (static uint32_t prev = 0; currentMillis - prev >= INTERVAL2; prev = currentMillis) {
    ; // 何らかの処理
  }

  // 3つ目のタスク
  for (static uint32_t prev = 0; currentMillis - prev >= INTERVAL3; prev = currentMillis) {
    ; // 何らかの処理
  }
}

for() 文の構造 for (初期化式; 条件式; 変化式) { 繰り返し処理; } に当てはめれば、冒頭のコードとの違いは、次の通りとなります。

  • uint32_t previousMillis1 = 0; などが static uint32_t prev = 0; に置き換わる
  • if() 文による条件式が for() 文中の条件式に置き換わる
  • 変化式の prev = currentMillis 実行後に、条件式が繰り返される「ムダ」がある

特に最後の「ムダ」という一点でアレルギー反応を起こす人もいるかと思いますが、「邪道」と言われようが私は全然 OK で、むしろ読み易さの点において積極的に使いたい派です :stuck_out_tongue_winking_eye:

ノンプリエンプティブ・マルチタスクの注意点

ウィキペディアの マルチタスク によれば、「ノンプリエンプティブ・マルチタスク」は次のように説明されています。

各タスク自身が、短い時間間隔でOSに処理を返す方式によって実現されているものを、ノンプリエンプティブなマルチタスク、協調的マルチタスクという。

この「短い時間間隔で」というのがミソです。この手の例題に用いられる digitalWrite()analogWrite() で LED を点滅させたりサーボモータを駆動したりする、ナノ秒オーダーで完了する処理なら問題とはならないでしょう。

しかしサンプリング周期一定が好ましいフィルタリング処理(タイマ割り込みを使わない前提)で、浮動小数点演算でミリ秒オーダーの時間がかかったりすると、その周期性に問題が出始めます。

試しに冒頭のコードにおいて、各タスクの起動周期をそれぞれ 110msec、120msec、130msec に、処理時間が 1msec、2msec、3msec かかると想定した次のようなコードで、実際の周期性を観測してみます。

各タスクの処理時間がミリ秒単位でかかる場合の周期性を確認するコード
#define INTERVAL1 110 // [msec]
#define INTERVAL2 120 // [msec]
#define INTERVAL3 130 // [msec]

uint32_t previousMillis1 = 0;
uint32_t previousMillis2 = 0;
uint32_t previousMillis3 = 0;

void ExecTask(uint32_t ms) {
  if (ms == 1) {
    Serial.println(millis()); // 各タスクの開始時間を出力
  }

  delay(ms); // 各タスクの擬似的な処理
}

void setup() {
  Serial.begin(115200);
  while (!Serial);
  delay(1000); // Arduino UNO R4 WiFi は、Serial の初期化に最低 600msec 必要
}

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

  // 1つ目のタスク
  if (currentMillis - previousMillis1 >= INTERVAL1) {
    previousMillis1 = currentMillis;
    ExecTask(1);
  }

  // 2つ目のタスク
  if (currentMillis - previousMillis2 >= INTERVAL2) {
    previousMillis2 = currentMillis;
    ExecTask(2);
  }

  // 3つ目のタスク
  if (currentMillis - previousMillis3 >= INTERVAL3) {
    previousMillis3 = currentMillis;
    ExecTask(3);
  }
}

観測結果

次のグラフは、ExecTask() の出力を 1 秒から 10 秒まで Arduino UNO R4 Minima で観測した結果です。明らかにタスク2はタスク1の、タスク3はタスク1と2の影響を受け、それぞれ ±1msec、±2msec のブレが観測されます。

各タスクの処理時間がミリ秒単位でかかる場合の周期性
各タスクの処理時間がミリ秒単位でかかる場合の周期性

もちろんこれがアプリケーションの性能に影響を与えなければ問題はありませんが、あちこちで紹介されているサンプルコードでは、こういう問題が生じ得るということは認識しておいた方が良いでしょう。

原因と改善策

タスクの周期性にブレが生じる原因は、各タスクの起動を判定する基準時刻を loop() 先頭の uint32_t currentMillis = millis(); で決めていることにあります。そこで、各タスクごとに基準時刻を更新することにします。

各タスクの処理時間がミリ秒単位でかかる場合の周期性を確認するコード(改善版)
void loop() {
  uint32_t currentMillis;

  // 1つ目のタスク
  if ((currentMillis = millis()) - previousMillis1 >= INTERVAL1) {
    previousMillis1 = currentMillis;
    ExecTask(1);
  }

  // 2つ目のタスク
  if ((currentMillis = millis()) - previousMillis2 >= INTERVAL2) {
    previousMillis2 = currentMillis;
    ExecTask(2);
  }

  // 3つ目のタスク
  if ((currentMillis = millis()) - previousMillis3 >= INTERVAL3) {
    previousMillis3 = currentMillis;
    ExecTask(3);
  }
}

もっと長い時間を観測すればブレが出てくる可能性はありますが、効果は「アリ」です。

各タスクの処理時間がミリ秒単位でかかる場合の周期性(改善版)
各タスクの処理時間がミリ秒単位でかかる場合の周期性(改善版)

改善版のマクロ

もちろん、この改善版にも「邪道」なマクロはあります。

// ノンプリエンプティブな周期的タスクを起動するちょっと変わったマクロ
#define DO_EVERY(period, prev)  static uint32_t prev = 0; for (uint32_t now = millis(); now - prev >= period; prev = now)

#define INTERVAL1 110 // [msec]
#define INTERVAL2 120 // [msec]
#define INTERVAL3 130 // [msec]

void setup() {
  ; // 何らかの初期化
}

void loop() {
  // 1つ目のタスク
  DO_EVERY(INTERVAL1, previousMillis1) {
    ; // 何らかの処理
  }

  // 2つ目のタスク
  DO_EVERY(INTERVAL2, previousMillis2) {
    ; // 何らかの処理
  }

  // 3つ目のタスク
  DO_EVERY(INTERVAL3, previousMillis3) {
    ; // 何らかの処理
  }
}

どこで宣言されているかパッと見分からない変数が出てくる時点で、強いアレルギー反応を起こす人が続出かと思いますが、僕的には「アリ」なネタでした :sunflower:

オススメ文献

2014年11月とかなり古い文献なのでよくご存知の方もいると思いますが、Arduino でのマルチタスクについての優れた文献を紹介しておきます。複数のタスクを統制するための状態遷移やオブジェクト指向なコードなど、Blink Without Delay からさらにステップアップするのに最適と思います。

またこれらを短くまとめた「Arduinoのマルチタスクについて」も合わせて紹介しておきたいと思います。