組み込みまするβ

組み込みソフトウェア初学者向けブログ

シングルタスクの考え方

組み込みソフト技術者の新人研修 - 組み込みまするβの内容の一つに、交通信号機の開発というものがあります。 この開発でシングルタスクのプログラムを作成する上での重要なことを学習してもらうのですが、 その考え方を記事にしてみました。 なお、シングルタスクの考え方を学ぶことが目的であるため、この記事で開発する交通信号機の時間的精度は便宜上ゆるい仕様としています。

以降の内容は組み込みソフトウェア関連の座学を終えていることが望ましいですが、 Chronoについては別記事に詳細がありますし、 本記事で使うLEDドライバは非常に単純なものなので読み進めていくのは難しくないでしょう。

交通信号機の開発

開発ボードには3個のLEDがあり、以下のレジスタ仕様とします。 通常はGPIO接続が多いのかもしれませんが、ここでは単純に適当なアドレスを与えています。

名称 address offset bit RW 内容
LED 0x1000 bit31-3 RW reserved
bit2 RW red(点灯は1、消灯は0を書き込む仕様とする)
bit1 RW yellow(点灯は1、消灯は0を書き込む仕様とする)
bit0 RW blue(点灯は1、消灯は0を書き込む仕様とする)

LEDを利用できない場合はUARTへ出力するprintf等で代替しましょう(点灯:〇/消灯:●)。

青:uart_printf("○●●\r");

黄:uart_printf("●○●\r");

赤:uart_printf("●●○\r");

※当然ながら他の用途でUARTを利用していないことが前提となります。

※CR(\r)のみでLF(\n)させず、信号の点灯・消灯状態を毎回上書きます。

最初のプログラム

交通信号機のことだけを考えて作成してもらうと、次のようなプログラムを作る人が多いと思います。 実際に動作させて目視確認をしてみましょう。

int main(void)
{
    ...

    /* initial setting */
    const Chrono* const chrono = Chrono_getInstance();

    /* main loop */
    for (;;) {
        Led_write(0x1);
        chrono->waitMsec(5000);

        Led_write(0x2);
        chrono->waitMsec(1000);

        Led_write(0x4);
        chrono->waitMsec(5000);
    }

    ...
}

コードを読みやすく改善してみよう

交通信号機の仕様に対して正しく動作していることがわかったら、次に仕様内容を忘れて単純にコードを読んでみましょう。 LEDレジスタに書き込みをして5秒待つ、LEDレジスタに書き込みをして1秒待つ、LEDレジスタに書き込みをして5秒待つというのはわかりますが、 短いコードであっても内容を知らない人には何をしているコードなのかわからないものです。 LEDのビットパターンや待ち時間には名前がついていないので、これらの定数に名前を付けてみるとどうなるでしょうか。

通常は最初から定数にも名前をつけてコーディングするものですが、初学者の方は少しずつ慣れていけばよいと思います。

int main(void)
{
    ...

    typedef enum SignalColor {
        kBLUE_SIGNAL = (1 << 0),
        kYELLOW_SIGNAL = (1 << 1),
        kRED_SIGNAL = (1 << 2)
    } SignalColor_t;

    const uint32_t kBLUE_CYCLE_MSEC = 5000;
    const uint32_t kYELLOW_CYCLE_MSEC = 1000;
    const uint32_t kRED_CYCLE_MSEC = 5000;

    /* initial setting */
    const Chrono* const chrono = Chrono_getInstance();

    /* main loop */
    for (;;) {
        Led_write(kBLUE_SIGNAL);
        chrono->waitMsec(kBLUE_CYCLE_MSEC);

        Led_write(kYELLOW_SIGNAL);
        chrono->waitMsec(kYELLOW_CYCLE_MSEC);

        Led_write(kRED_SIGNAL);
        chrono->waitMsec(kRED_CYCLE_MSEC);
    }

    ...
}

実行結果はもちろん同じなのですが、以前のコードと比べて読みやすくなったと思いませんか? 定数に名前をつけることで可読性があがり、blue, yellow, redという名称から交通信号機を連想することもできそうですよね。

さて、よくある組み込みソフトのサンプルコードだとこれで終了になる場合も多いのですが、 このようなコードを作れるようになっても業務レベルの開発ではまったく役に立ちません。 信号の切り替わるタイミングまでwait関数で待っていては交通信号機以外の処理を実行することができませんよね。 この記事で実装した交通信号機のような単純で低負荷な処理に、専用のCPUを割り当てるようなことは現実的ではないのです。

厳しい言及になりますが、シングルタスクの解説がここまでの内容で終わり、 すぐに組み込みOSを利用したマルチタスクの解説に移行するような書籍やウェブサイトは、 業務レベルのシングルタスク設計について何も解説していないという認識を持っています。

例えば、点灯時間の異なる交通信号機を2つ制御したい場合を考えてみてください。 本記事の交通信号機のような低負荷の処理を実現するのにマルチタスク(マルチスレッド)が必要ですか?

実際に処理が必要なタイミング

実際の交通信号機を思い浮かべてください。 処理が必要なのは信号の色が切り替わるタイミングだけなので、信号が切り替わるタイミングが来るまでは他の処理を実行できますよね。

つまり、その場で時間経過を待つのではなく、時間経過をポーリングすることで待っていた時間を有効に活用することができます。 但し、交通信号機の処理を抜けるわけですから再開するために以下の情報を憶えておかなければなりません。

  • 現信号の色:trafficSignal
  • 現信号の残り時間:baseCount、timeoutCount

次のように実装してみましたがどうでしょうか。

int main(void)
{
    ...

    typedef enum SignalColor {
        kBLUE_SIGNAL = (1 << 0),
        kYELLOW_SIGNAL = (1 << 1),
        kRED_SIGNAL = (1 << 2)
    } SignalColor_t;

    const uint32_t kBLUE_CYCLE_MSEC = 5000;
    const uint32_t kYELLOW_CYCLE_MSEC = 1000;
    const uint32_t kRED_CYCLE_MSEC = 5000;

    /* initial setting */
    const Chrono* const chrono = Chrono_getInstance();
    Led_write(kBLUE_SIGNAL);
    SignalColor_t trafficSignal = kBLUE_SIGNAL;
    uint32_t baseCount = chrono->now();
    uint32_t timeoutCount = chrono->convertMsecToCount(kBLUE_CYCLE_MSEC);

    /* main loop */
    for (;;) {
        switch (trafficSignal) {
        case kBLUE_SIGNAL:
            if (chrono->timeout(baseCount, timeoutCount)) {
                Led_write(kYELLOW_SIGNAL);
                trafficSignal = kYELLOW_SIGNAL;
                baseCount = chrono->now();
                timeoutCount = chrono->convertMsecToCount(kYELLOW_CYCLE_MSEC);
            }
            break;
        case kYELLOW_SIGNAL:
            if (chrono->timeout(baseCount, timeoutCount)) {
                Led_write(kRED_SIGNAL);
                trafficSignal = kRED_SIGNAL;
                baseCount = chrono->now();
                timeoutCount = chrono->convertMsecToCount(kRED_CYCLE_MSEC);
            }
            break;
        case kRED_SIGNAL:
            if (chrono->timeout(baseCount, timeoutCount)) {
                Led_write(kBLUE_SIGNAL);
                trafficSignal = kBLUE_SIGNAL;
                baseCount = chrono->now();
                timeoutCount = chrono->convertMsecToCount(kBLUE_CYCLE_MSEC);
            }
            break;
        }
        /* 他の処理 */
        ...
    }

    ...
}

このような実装であればメインループ内の交通信号機処理の後に他の処理を実行できそうですね。 改善前の実装例では交通信号機の切り替わりタイミングをビジーポーリングで待ち、CPU資源を連続して使っていたということになります。 シングルタスクで大事なのは「メインループ内において、1タスクがCPU資源を連続して使ってはいけない」ということです。

補足説明

その場で待つwait関数が問題なのではありません。 交通信号機の場合は信号切り替え間隔が秒単位なので待ちの時間が長く、他の処理を実行できないので問題となるのです(CPU資源の専有)。 これが例えば数マイクロ秒程度のwaitであれば、他の処理に与える影響がほとんどないことは理解できるでしょう。 但し、この時間には特に明確なスレッショルドがあるわけではなく、 開発対象のシステムに応じて、他の処理を待たせても問題ない十分短い時間や、 メインループが1周する設計上の時間等で総合的に判断しなければなりません。

交通信号機のコンポーネント

これで終わりではありません。 他の処理の追加もできるようにはなりましたが可読性に関してはごちゃごちゃして見難いですし、 移植するにしてもコード片のコピペを強要することになってしまうので、 移植しやすいように交通信号機の処理をコンポーネントにまとめてみましょう。

本来なら最初からコンポーネント(モジュール)として設計するものですが、 コンポーネント(モジュール)化されていない実装から、 独立した処理(交通信号機)を抽出してコンポーネント化までの過程を学習するために少々遠回りの説明としました。

コンポーネントのファイル構成

  • traffic_signal.c (ソースファイル)
  • traffic_signal.h (インクルードファイル)

コンポーネントの実装

/* traffic_signal.c */

#include "emb_chrono.h"
#include "traffic_signal.h"
...

typedef enum SignalColor {
    kBLUE_SIGNAL = (1 << 0),
    kYELLOW_SIGNAL = (1 << 1),
    kRED_SIGNAL = (1 << 2)
} SignalColor_t;

static const uint32_t kBLUE_CYCLE_MSEC = 5000;
static const uint32_t kYELLOW_CYCLE_MSEC = 1000;
static const uint32_t kRED_CYCLE_MSEC = 5000;

static SignalColor_t trafficSignal_;
static uint32_t baseCount_;
static uint32_t timeoutCount_;

static const Chrono* chrono_;

void TrafficSignal_init(void)
{
    chrono_ = Chrono_getInstance();

    Led_write(kBLUE_SIGNAL);
    trafficSignal_ = kBLUE_SIGNAL;
    baseCount_ = chrono_->now();
    timeoutCount_ = chrono_->convertMsecToCount(kBLUE_CYCLE_MSEC);
}

void TrafficSignal_task(void)
{
    switch (trafficSignal_) {
    case kBLUE_SIGNAL:
        if (chrono_->timeout(baseCount, timeoutCount)) {
            Led_write(kYELLOW_SIGNAL);
            trafficSignal_ = kYELLOW_SIGNAL;
            baseCount_ = chrono_->now();
            timeoutCount_ = chrono_->convertMsecToCount(kYELLOW_CYCLE_MSEC);
        }
        break;
    case kYELLOW_SIGNAL:
        if (chrono_->timeout(baseCount, timeoutCount)) {
            Led_write(kRED_SIGNAL);
            trafficSignal_ = kRED_SIGNAL;
            baseCount_ = chrono_->now();
            timeoutCount_ = chrono_->convertMsecToCount(kRED_CYCLE_MSEC);
        }
        break;
    case kRED_SIGNAL:
        if (chrono_->timeout(baseCount, timeoutCount)) {
            Led_write(kBLUE_SIGNAL);
            trafficSignal_ = kBLUE_SIGNAL;
            baseCount_ = chrono_->now();
            timeoutCount_ = chrono_->convertMsecToCount(kBLUE_CYCLE_MSEC);
        }
        break;
    }
}

...
/* traffic_signal.h */
...

void TrafficSignal_init(void);
void TrafficSignal_task(void);

...

使い方

#include "traffic_signal.h"

...

int main(void)
{
    ...

    /* initial setting */
    TrafficSignal_init();

    /* main loop */
    for (;;) {
        TrafficSignal_task();
        /* その他のタスク関数 */
        ...
    }

    ...
}

メイン関数やメインループ内は改善前と比べてすっきりしましたね。

追加課題

時間パラメータが異なる複数の信号機を制御することを考えてみましょう。 初期化時に時間パラメータを与え、かつ複数の信号機を制御できるようにするにはどうすればよいでしょうか。 せっかくコンポーネント(モジュール)化したのですから、当然コピペのような実装はNGです。 特に解答は示しませんが、本記事の内容を理解できていれば難しくはないので考えてみましょう。

まとめ

交通信号機を例にシングルタスクの考え方や実装の1つを理解できたと思います。 この実装により交通信号機タスクの前や後に他のタスクを追加することができるようになりましたが、 すべてのタスク関数は他のタスク関数の実時間制約を満たせる程度の実行時間としなければなりません。 この設計方針を守ることで優先順位のない固定スケジューリングの擬似並列を実現可能となります。

時間精度

最初にも記載していますが本記事の実装は時間精度のゆるいものです。 次の信号への切り替えタイミングが経過してから切り替え処理を行うため、この僅かな遅れが蓄積していくのがわかるでしょうか。 別の実装として青信号を起点に黄信号、赤信号のタイミングを判断する方法もあります(少しの改善ですが遅れの蓄積を減らすことができます)。

時間精度を求めるような場合はTimerの割り込みを利用することで対応できますが、 時間精度を求める要求がTimerの数より多い場合はどうすればよいでしょうか? 組み込みOSを採用していれば周期ハンドラ等を使えますが、 OSレスではChronoで利用しているTimerとは別のTimerを利用して周期ハンドラの仕組みを実現する必要があります。

繰り返しになりますが本記事の内容はシングルタスクの最も単純な実装の1つです。 他にもいろいろなシングルタスク実装がありますので調べたり考えたりしてみてください。