thkanaのアイコン画像
thkana 2022年04月29日作成 (2022年05月03日更新) © CC BY 4+
セットアップや使用方法 セットアップや使用方法 Lチカ Lチカ 閲覧数 7815
thkana 2022年04月29日作成 (2022年05月03日更新) © CC BY 4+ セットアップや使用方法 セットアップや使用方法 Lチカ Lチカ 閲覧数 7815

[Arduino] マルチタスクのまねごと(3:時間の測り方)

前回:LED点滅中にスイッチ操作をしたい

そういえば...

そういえば、マルチタスクって何? という話をここまでしていませんでしたっけ。
いまさらながらですが説明すると、タスク(お仕事)をマルチ(複数)にほぼ同時にこなすことです。ほぼソフトウェア工学の用語ですかね、あまりそれ以外では聞いたことはありません。
WindowsやLinuxなどはマルチタスクなOSなので、特に意識することなく音楽プレイヤーとブラウザを立ち上げるだけでBGMを流しながらWebサイトを眺める、なんていうお仕事を難なくこなしてくれます。
しかし、マイコンではそんな仕組みを持っていないことも多いです。だけど、いくつかのお仕事を同時にしたいことってしばしばある...ということで、これまでLED1を点滅するお仕事とLED2を点滅するお仕事、あるいはLEDの点滅のお仕事とスイッチを調べて適宜処理するお仕事でその辺の説明をしてきました。

マルチタスクで仕事ができるということは、個々のタスクが「キリのいいところで止められる」のが一応の基本となります。コンピュータ/OS側が高機能ならシステム側でひとつのタスクの動作を途中で止めて、他のタスクを走らせてから元のタスクに戻る、なんていう芸もできますが、特に組み込み用のシンプル(原始的?)なマルチタスクシステムでは、意図的にタスクの切り替えを発生させないとタスクが切り替わりません。その時にdelay()を使う場合もある...なんて話は「まねごと」の範疇を超えるのでいつか機会があれば。

時間の測り方

カップ麺を食べたくなったとします。お湯を入れて3分間...さて、どうやって測りましょう。腹時計に自信のある方は別として、測るといったらだいたい次のどちらかのパターンでしょう。

  • キッチンタイマーがあるから、3分に設定してアラームが鳴ったら食べる(最初から決まった時間を測る)
  • 時計を見て、いま11時50分だから11時53分になったら食べる(時間の差分をみて決まった時間が経過したかどうか調べる)

プログラムにおいても、方針としてはだいたいこのどちらかに分類できるかと思います。どちらかが優れているとかそういう話ではなくて、諸々の都合を考えて使いやすい手段を使えばいいです。

タイマー型

(タイマー型とか次に出てくる時計型とかいうのは、説明の都合でそう呼んだだけで本当にそういう名前があるわけじゃないです)
これまでのプログラムではdelay(100)を多用してきました。もう一度(単純なLチカで)プログラムを書くなら

// Program 3.1
void setup() {
    pinMode(13, OUTPUT);
}
int count = 0;
int ledstat;
void loop() {
    if (count == 0) {
        ledstat = HIGH;
    }
    if (count == 10) {
        ledstat = LOW;
    }
    count++;
    if (count >= 20) {
        count = 0;
    }
    digitalWrite(13, ledstat);
    delay(100);
}

これはまさに100ミリ秒間は仕事をしない、タイマー型の時間計測です。
Arduinoではdelay()関数が用意されていますが、場合によってはその手の「待ち」関数が用意されていないシステムもあるかも知れません。このような場合には、例えばArduino UNOであれば

// Program片 3.1.1
    for (int i = 0; i < 10; i++) {
        for (int j = 0; j < 11400; j++) {
            volatile int k = i + j;
        }
    }

これがおおよそ100ミリ秒の「時間つぶし」になります。ただ、この方法はコンパイラのオプションなどの影響をうけやすいので「最後の手段」的に使うものと考えた方がいいでしょう。

時計型

Arduinoであれば、millis()という関数があります。リセット後にミリ秒単位で経過時間を計っています。つまり、リセットを起点とした「時計」です。これを使って2秒周期の点滅をしてみましょう。
時間の測り始めの「時刻」を記録して、その後の「時刻」から引くことで経過時間を求めています。

// Program 3.2
unsigned long t0;
void setup() {
    pinMode(13, OUTPUT);
    t0 = millis(); //始めの時刻を覚えておく
}
int ledstat;
void loop() {
    unsigned long t = millis();
    if (t - t0 < 1000) { //経過時間が1秒未満
        ledstat = HIGH;
    } else if (t - t0 < 2000) { //経過時間が2秒未満
        ledstat = LOW;
    } else { //2秒を過ぎた、なら新しく時刻を覚える
        t0 = t;
    }
    digitalWrite(13, ledstat);
    delay(100);
}

あるいは、点灯時間と消灯時間を別々に考えることも可能です。

//Program 3.3
unsigned long ton0, toff0;
int ledstat;
void setup() {
    pinMode(13, OUTPUT);
    ton0 = millis();  //始めの時刻を覚えておく
    ledstat = HIGH;   //最初は点灯
}

void loop() {
    unsigned long t = millis();
    if (ledstat == HIGH && t - ton0 >= 1000) {  //点灯してが1秒過ぎた
        ledstat = LOW;
        toff0 = t; //消灯時間の始めの時刻を覚えておく
    } else if (ledstat == LOW && t - toff0 >= 1000) {  //消灯して1秒が過ぎた
        ledstat = HIGH;
        ton0 = t;  //点灯時間の始めの時刻を覚えておく
    }
    digitalWrite(13, ledstat);
    delay(100);
}

複数のLEDを点滅するような場合には、各々のLEDの点灯開始/消灯開始を記録すればそれぞれのLEDを自由にコントロールできますね。

細かいことですが、loop()中で時刻を計算したり「始めの時刻を覚える」ときに、millis()の値を保存した変数tを使っています。変数ではなく直接millis()を記述しても、このプログラムでは支障はないのですが、毎回millis()を呼び出していると途中で時刻が一つ(1ミリ秒)進んでしまう(loop()の頭のほうと後の方でmillis()が返す値が違う)可能性が出てきます。時間の設定によっては上手く動かないプログラムが出てくる可能性が出てきます。これに対し、loop()の先頭などで一回取得して、loop()内では再取得しないほうがややこしいことを考えなくてよくなる場合がありますので、ここは習慣的に変数に格納して使うようにするのがおすすめです。
さらに余談になりますが、スイッチ状態を取得して動作するプログラムでも同様で、何回かdigitalRead()をすると途中で状態が変わっている可能性もあるので、1回だけ取得して変数に保存し、あとはその変数を参照する、というようにしたほうが安全なことが多いかと思います。

「本当の」タイマー

マイコンには大抵「タイマー」と呼ばれるペリフェラル(周辺装置)が組み込まれています。もちろんArduino UNOにも載っています。タイマーを使うにはそれなりの手順を踏まなければいけないのですが、その辺の手順をまとめているライブラリがありますのでありがたく使わせていただきましょう。ライブラリはいくつかありますが、今回はMsTimer2を使ってみます。なお、タイマーライブラリはマイコンの機種に依存することが多く、Arduinoの機種が変わると別機種用のライブラリは使えないことがあります。今回はAVRを使ったArduino専用ということになりますが悪しからず。
また、タイマーは他の用途で使ってしまうこともあり、そのようなときは使えない場合も出てきます。

さて。ではタイマーライブラリは何をしてくれるかというと、メイン(setup()やloop())のプログラムの動作に関係なく(割り込み処理)、指定した時間が経過する度に登録しておいた関数を呼び出します。今までの例ではLED点灯時間と消灯時間を別々に設定できるようにプログラムを組んできましたから、それを踏襲してプログラムを組んでみましょう。

// Program 3.4
#include <MsTimer2.h>
const int ONPERIOD = 1000;
const int OFFPERIOD = 1000;

volatile int ledstat = LOW;
void ledToggle() {
    if (ledstat == LOW) {
        //これまでLEDが消灯だった->点灯処理
        ledstat = HIGH; //点灯
        MsTimer2::msecs = ONPERIOD;  //タイマー時間設定
    } else {
        //これまでLEDが点灯だった->消灯処理
        ledstat = LOW; //消灯
        MsTimer2::msecs = OFFPERIOD; //タイマー時間設定
    }
}

void setup() {
    pinMode(13, OUTPUT);
    MsTimer2::set(ONPERIOD,ledToggle);//初回、関数登録も含めて設定
    ledToggle(); //時刻0の処理
    MsTimer2::start(); //タイマー動作開始
}

void loop() {
    //タイマーが呼び出すledToggle()関数の中でledstatが変化する
    digitalWrite(13,ledstat);
}

volatileというキーワードが出てきましたが、これはとりあえず「プログラムの本来の流れ以外でデータが書き換えられる可能性がある」ときのおまじないと考えておいてください。これがついていないと、例えば

a = 0;
b = a;
// このあとaは使われない

という処理があったときに、コンパイラが「このaいらないじゃない」と判断してb = 0という結果出力をしてしまう可能性が出てきます。そういうのを防止するためのキーワードです。今回は、タイマー中で(プログラムの本来の流れでないところで)ledstatを書き換えているのでvolatileを付けています。

タイマーを使って複数のLEDを点滅させたいとすれば、これまでの投稿でdelay()を行った回数を数えたのと同じようにタイマーが呼び出しを掛けた回数を数えれば実現できます。[1:まずはLED2個の点滅から]の回のプログラム2.6をタイマー利用に書き換えてみるなら、こんなことになるでしょうか。

// Program 3.5
#include <MsTimer2.h>

const int LED1 = 13;
const int LED2 = 12;

volatile int led1stat = LOW;
volatile int led2stat = LOW;

int count = 0;
void ledToggle() {
    // LED1の制御
    if (count % 20 == 0) {  // 2秒毎
        led1stat = HIGH;    // LED1を点灯する
    }
    if (count % 20 == 13) {  // 2秒毎+1.3秒
        led1stat = LOW;      // LED1を消灯する
    }
    // LED2の制御
    if (count % 17 == 0) {  // 1.7秒毎
        led2stat = HIGH;    // LED2を点灯する
    }
    if (count % 17 == 5) {  // 1.7秒毎+0.5秒
        led2stat = LOW;     // LED2を消灯する
    }
    // 全体周期の管理
    count++;
    if (count == 340) {  // 全体のワクは34秒
        count = 0;
    }
    //今回は割り込み時間の変更はない
}

void setup() {
    pinMode(LED1, OUTPUT);
    pinMode(LED2, OUTPUT);
    MsTimer2::set(100, ledToggle);
    MsTimer2::start();
}

void loop() {
    digitalWrite(LED1, led1stat);
    digitalWrite(LED2, led2stat);
}

このプログラムで、2回目のようにスイッチを調べたいとすれば、スイッチは時間は関係ないのでloop()関数の中にスイッチを調べて行う処理を書くことができますね。(ただし、「チャタリング対策」などは必要になってくるでしょうが)

「割り込み処理」の注意

なお、タイマー処理のような割り込み処理で呼び出す関数中で行う処理にはいろいろと制限があることがあります。
・割り込み中は他の割り込みが制限されることが多いので、一つの割り込み中にとどまってしまうと他の割り込み処理が阻害されます。割り込みはいろいろなところで使われるので(Arduinoであればdelay()関数の計時とか、シリアルの送受信とか)、色々不具合を引き起こしかねません。なので、割り込み処理からはできるだけ速やかに復帰することが原則です
・同じ話になってしまいますが、割り込み中は他の割り込みが制限されることがあるので、割り込みで呼ばれる処理の中から割り込みを利用する処理を呼ぶと正常に動作しないことがあります。先に例に挙げたdelay()とかSerialの関数とか、ですね。割り込み中では、変数の操作や、せいぜい単純な(割り込みを使わない)IO操作程度の処理をしないほうが吉、です。
もし、「割り込みがあったらシリアルに表示を行う」としたいとしたら、割り込み中ではSerial.print()とかは使わない方がいいので、こんな感じのプログラムにすることになるでしょうか。

// Program 3.6
#include <MsTimer2.h>

volatile bool interrupt;
void intTrig() {
    interrupt=true; //処理を求めるフラグを立てる
}

void setup() {
    Serial.begin(9600);
    MsTimer2::set(1000, intTrig);
    MsTimer2::start();
}

void loop() {
    if(interrupt){
        Serila.print(millis());
        Serial.println(" Get timer interrupt.");
        interrupt=false; //処理が終わったのでフラグをクリアする
    }
}

もっといろいろな処理をするようになると「排他処理」とかいろいろ出てきますが、いまのところはこんなものでしょうか。


次回は...LED点滅よりはちょっと難しい?というか時間ががかる処理をするときに問題になりそうなことについてちょっと書いてみようかしら。

thkanaのアイコン画像
一応組み込みエンジニアのつもり... でも最近製品になるようなコード書いてないなぁ。
  • thkana さんが 2022/04/29 に 編集 をしました。 (メッセージ: 初版)
  • thkana さんが 2022/04/30 に 編集 をしました。 (メッセージ: リンクの一部変更)
  • thkana さんが 2022/05/02 に 編集 をしました。 (メッセージ: 前回へのリンクがおかしかったので修正)
  • thkana さんが 2022/05/03 に 編集 をしました。 (メッセージ: 割り込み処理について/次回へのリンク追加)
  • thkana さんが 2022/05/03 に 編集 をしました。 (メッセージ: 次回リンクの設定ミス修正)
ログインしてコメントを投稿する