FreeRTOS POSIX porting + Catch2 でマルチタスクなユニットテスト環境を整備する part2

この記事は Nature Remo Advent Calendar 2022 の3日目として書きました。

adventar.org

ファームウェアエンジニアの中林です。 複数のタスクで動く機能をホストPC上でテストする方法をご紹介しちゃう本シリーズ、part2 は FreeRTOS POSIX porting + Catch2 で簡単なテストを書くところまでやっていきます。 今回紹介しているソースコードはこちらの、testsディレクトリにあります。

github.com

さて、FreeRTOS POSIX porting + Catch2 でゴリゴリとマルチタスクなテストを書いていきたいところですが、2つ、問題があります。

問題点

1. FreeRTOS POSIX porting は…

前回のエントリでも書いた通り、タスクスケジューラを起動するためには vTaskStartScheduler() を実行する必要があります。 しかし、vTaskStartScheduler() は呼び出したスレッドがスケジューラとなるため、そのスレッドの実行がブロックされます。 そのため、適当に main 関数で vTaskStartScheduler() を呼び出すと、テストがそこで停止してしまいます。

さらに、テストが全て実行されたあとに、レポートを出力するには、プログラムが終了する必要があります。 そのためには、vTaskEndScheduler() を適切なタイミングで呼び出さなければなりません。

2. Catch2 は…

もう1つは、Catch2 のアサーションがスレッドセーフじゃないことです。 このことは Catch2 のドキュメントに制限事項として記載されています。

github.com

書いてあることを要約すると、テスト内でスレッドは使えるが、アサーションは常に1つのスレッドからしか使ってはならない、とのことです。 ドキュメント内にわかりやすい例が乗っているので引用します。

次のテストは OK です。 アサーションREQUIREマクロを1つのスレッドでしか使っていません。

    std::vector<std::thread> threads;
    std::atomic<int> cnt{ 0 };
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&]() {
            ++cnt; ++cnt; ++cnt; ++cnt;
        });
    }
    for (auto& t : threads) { t.join(); }
    REQUIRE(cnt == 16);

逆に、こっちはダメです。 各スレッドからアサーションCHECKマクロを呼んでしまっています。

    std::vector<std::thread> threads;
    std::atomic<int> cnt{ 0 };
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&]() {
            ++cnt; ++cnt; ++cnt; ++cnt;
            CHECK(cnt == 16);
        });
    }
    for (auto& t : threads) { t.join(); }
    REQUIRE(cnt == 16);

実際に複数スレッドからマクロを呼び出すとテスト結果がめちゃくちゃになることがわかります。

解決策

問題として、以下の2点がありました。

  1. FreeRTOS の vTaskStartScheduler() / vTaskEndScheduler() をうまく呼び出さないといけない
  2. Catch2 のアサーションを単一スレッドから呼び出さないといけない

それでは、それぞれの解決方法を見ていきましょう。

1. テストランナーをカスタマイズする

まず1つ目のタスクスケジューラ周りですが、解決方法は単純です。 テストランナーをカスタマイズし、FreeRTOS タスク test_main を1つ立ち上げて、そのタスクにテスト実行をお任せし、テストが完了したら vTaskEndScheduler() を呼び出してもらいます。 main 関数を実行しているスレッドはタスク起動後に vTaskStartScheduler() を呼び出し、test_main タスクがテスト実行が完了して、vTaskEndScheduler() を実行するのを待ちます。

最終的に main.cpp は次のようにします。

#define CATCH_CONFIG_RUNNER
#include <catch.hpp>
#include <FreeRTOS.h>
#include <task.h>

struct Context {
    int result;
    int argc;
    char** argv;
};

static void test_main(void *arg)
{
    Context *ctx = (Context*)arg;
    ctx->result = Catch::Session().run(ctx->argc, ctx->argv);
    vTaskEndScheduler();
}

extern "C" int main( int argc, char* argv[] )
{
    Context ctx = Context {
        0,
        argc,
        argv,
    };
    xTaskCreate(&test_main, "test", 32000, &ctx, 5, NULL);
    vTaskStartScheduler();

    return ctx.result;
}

test_main 関数の ctx->result = Catch::Session().run(ctx->argc, ctx->argv); のところでテストケースを実行しています。 Catch のテストケースフィルターなどはコマンドライン引数が必要なので、Context として、共有しています。

2. アサーションを単一スレッドから呼び出しやすいテンプレートを用意する

立ち上げたタスク内でアサーションマクロを使わないように、というのは、もはや気をつける、しかないです。 が、できるだけ簡単にテストを書けるようにテンプレートを用意します。

まず、次のようなテストケースは何も問題ありません。 test_main タスクの中でアサーションを実行しています。

TEST_CASE("usual test but executed on FreeRTOS task")
{
    REQUIRE(true);    
}

続いてタスクを1つ起動する場合ですが、次のように、test_main タスクと結果を格納する変数を共有して渡してあげる必要があります。 ここで、sub-task の中でアサーションを実行してはいけません。 毎回、結果共有用の変数を意識してプログラミングするのはめんどうですね?

TEST_CASE("test sub task activity")
{
    auto sub = [](void *arg) {
        int32_t *result = static_cast<int32_t*>(arg);
        *result = 1;
        vTaskDelete(NULL);
    };

    int32_t result = 0;
    xTaskCreate(sub, "sub-task", 4096, &result, 2, NULL);

    // wait a minute to sub-task completion
    vTaskDelay(pdMS_TO_TICKS(10));
    REQUIRE(result == 1);
}

そこで、C++ lambda のキャプチャを活用して、main_test タスクとの結果共有用変数を簡単に扱えるようにします。 まず、利用側のコードです。 make_test_case() 関数で引数として渡している lambda は新しく起動する FreeRTOS タスク上で実行されます。 lambda で result をキャプチャすることで、両タスク間で変数を共有できています。 お手軽!

TEST_CASE("with test task runner helper")
{
    int32_t result = 0;
    auto test_case = make_test_case([&]() {
        // this lambda is executed on another FreeRTOS task.
        result = 1;
    });

    // `test_case.execute()` blocks the execution until lambda completion or timeout.
    REQUIRE(test_case.execute(10));
    REQUIRE(result == 1);
}

make_test_case() の裏側はこんな実装になっています。 大元は、どうやってテンプレート化するか悩んでいたら、井田さん (@ciniml) が作ってくれました。感謝!

#include <FreeRTOS.h>
#include <task.h>

struct ITestRunner {
    virtual void run() = 0;
};

static void task_handler(void* arg)
{
    auto runner = reinterpret_cast<ITestRunner*>(arg);
    runner->run();
    vTaskDelete(NULL);
}

template<typename TTestBody, size_t PRIORITY=5, size_t STACK=32000>
struct FreertosTestTaskRunner : public ITestRunner {
    TaskHandle_t parent;
    TaskHandle_t runner;
    TTestBody testbody;

    FreertosTestTaskRunner(TTestBody&& testbody) : testbody(testbody) {
        this->parent = xTaskGetCurrentTaskHandle();
        this->runner = NULL;
    }

    void run() override {
        this->testbody();
        xTaskNotify(this->parent, 1, eSetBits);
    }

    bool execute(int timeout_ms) {
        if ( xTaskCreate(&task_handler, "test", STACK, this, PRIORITY, &this->runner) != pdPASS ) {
            return false;
        }
        if( xTaskNotifyWait(0, 1, NULL, pdMS_TO_TICKS(timeout_ms)) != 1 ) {
            vTaskDelete(this->runner);
            return false;
        }
        return true;
    }
};

template<size_t P=5, size_t S=32000, typename TTestBody>
static FreertosTestTaskRunner<TTestBody, P, S> make_test_case(TTestBody&& body) {
    return FreertosTestTaskRunner<TTestBody, P, S>(std::forward<TTestBody>(body));
}

おまけですが、タスクプライオリティとスタックサイズをテンプレートパラメータとしているため、次のように書くと、プライオリティとスタックサイズを変更しながら実行するテストが書けます。

TEMPLATE_TEST_CASE_SIG("parameterized task configuration test", "", ((size_t P, size_t S), P, S), (2, 1024), (3, 512))
{
    int32_t result = 0;
    auto test_case = make_test_case<P, S>([&]() {
        result = 1;
    });

    REQUIRE(test_case.execute(10));
    REQUIRE(result == 1);
}

エンジニア積極採用中です

Natureでは意地でもテスト環境を整備したい仲間を募集しています。

herp.careers

カジュアル面談も歓迎なので、ぜひお申し込みください。

herp.careers

Natureのミッション、サービス、組織や文化、福利厚生についてご興味のある方は、ぜひCulture Deckをご覧ください。

speakerdeck.com