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

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

adventar.org

ファームウェアエンジニアの中林です。 複数のタスクで動く機能をホストPC上でテストする方法をご紹介しちゃう本シリーズ、part3 は FreeRTOS POSIX porting + Catch2 でもう少し実用なテストを書いてみましょう。 正直、サンプルを作り込むのが大変だったので、少し手抜き気味ですが、普段やっているテストの雰囲気は出せたかな、と思います。

サンプルコードは下記リポジトリnetwork-testディレクトリにあります。

github.com

network-test

まず、どういうテストを書くかイメージを共有します。 ネットワーク通信やシリアル通信の処理を書く時、送信タスクと受信タスクをそれぞれ用意することがあると思います。 これを簡易的に模擬して、HTTPリクエストをメインタスクから投げて、受信タスクでレスポンスを受け取るというシナリオをテストしてみます。 テストする対象は C でも C++ でも良いのですが、普段プロダクトコードは C で、テストコードは C++ で書いているので、同じ想定でサンプルを構成しました。

今回、送信はメインタスクから固定のHTTPリクエストを投げるだけ、受信はHTTPレスポンスを受けて文字列としてメインタスクに返すだけ、ということにします。 それに何の意味が…?と言われるとサンプルがやっていることに意味はないのですが、この基本形ができていれば、どこかでコールバックを渡すインタフェースにしておけば大抵テストを書くことができます。

受信タスク

ではまず、受信タスクから見ていきましょう。 メインタスクとの情報共有用に context_t を定義しています。 これは、HTTP レスポンスを受信するソケットのディスクリプターと、受信結果をメインタスクに返すためのキューを持っています。

struct context_t {
    int fd;
    QueueHandle_t q;
};

static void receiver_task(void *arg)
{
    context_t *ctx = static_cast<context_t*>(arg);

    while ( true ) {
        // メインタスクから通知が来たらループを抜けてタスクを終了する
        if ( xTaskNotifyWait(0, 0, NULL, 0) == pdTRUE) {
            break;
        }

        // ディスクリプタからデータが受信可能かどうか調べる
        fd_set rfds;
        FD_ZERO(&rfds);
        FD_SET(ctx->fd, &rfds);
        struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
        int r = select(ctx->fd + 1, &rfds, NULL, NULL, &tv);

        if (r < 0) {
            if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
                break;
            }
            continue;
        } else if (r == 0) {
            // timeout
            continue;
        }

        // 受信可能だった場合はデータを受信する
        char buf[512] = {0};
        r = read(ctx->fd, buf, 512);
        // TODO: please check error in your production code
        if ( r > 0 ) {
            // バッファを確保して、メインタスクに送る
            char *b = static_cast<char*>(malloc(r));
            assert(b != NULL);
            memcpy(b, buf, r);
            if ( xQueueSendToBack(ctx->q, &b, 0) != pdTRUE ) {
                break;
            }
        }

        vTaskDelay(pdMS_TO_TICKS(10));
    }

    vTaskDelete(NULL);
}

メインタスク (テストケース)

続いてメインタスクの方です。 HTTP サーバーに接続して、受信タスクを作成し、HTTP リクエストを送信しています。 その他はインラインのコメントを参照しながら眺めてみて下さい!

static int connect_to_server(void)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);

    // `http://0.0.0.0:8000/` を想定して TCP 接続する
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8000);
    REQUIRE(inet_pton(AF_INET, "0.0.0.0", &addr.sin_addr) > 0);
    REQUIRE(connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0);

    return fd;
}

TEST_CASE("simple network test using receiver task")
{
    // HTTP サーバーに接続
    int fd = connect_to_server();

    // 受信タスクを作成
    QueueHandle_t q = xQueueCreate(10, sizeof(char*));
    TaskHandle_t receiver;
    context_t ctx {
        .fd = fd,
        .q = q,
    };
    xTaskCreate(receiver_task, "receiver", 4096, &ctx, 5, &receiver);

    // HTTP リクエストを送信
    const char *req = "GET / HTTP/1.1\r\nHOST:0.0.0.0:8000\r\n\r\n";
    write(fd, req, strlen(req));

    // 受信タスクから HTTP レスポンスを受け取る
    char *buf = NULL;
    REQUIRE(xQueueReceive(q, &buf, pdMS_TO_TICKS(1000)) == pdTRUE);
    // 簡略化のため、最初に `HTTP/1.1` があるはずなので、それだけテストしている
    REQUIRE(strncmp(buf, "HTTP/1.1", 8) == 0);

    // 受信タスクを終了する
    xTaskNotify(receiver, 0, eSetBits);
    // 後片付け
    // please wait for finishing the receiver task in your production code
    free(buf);
    close(fd);
    vQueueDelete(q);
}

実行方法

ホストPC上で HTTP サーバーを起動します。 なんでも良いのですが、Rust が好きなので、Rust の simple-http-server を使うことにします。

みなさまの手元には当然 Rust の環境があると思うので、Rust の環境構築は飛ばして、次のコマンドで simple-http-server のインストールと起動ができます。

cargo install simple-http-server
simple-http-server

simple-http-server はデフォルトでは、0.0.0.0:8000 を使用します。

$ simple-http-server 
     Index: disabled, Cache: enabled, Cors: disabled, Coop: disabled, Coep: disabled, Range: enabled, Sort: enabled, Threads: 3
          Upload: disabled, CSRF Token: 
          Auth: disabled, Compression: disabled
         https: disabled, Cert: , Cert-Password: 
          Root: /home/tomoyuki,
    TryFile404: 
       Address: http://0.0.0.0:8000
    ======== [2022-12-03 19:30:13] ========

続いて、テストを実行します。

# at freertos-catch/network-test
$ make prepare
$ make run
./build/network-test
===============================================================================
All tests passed (4 assertions in 1 test case)

無事テストがパスしましたね!

ちゃんと動いていると、simple-http-server の方にもアクセスログが残ります。

[2022-12-03 19:33:35] - 127.0.0.1 - 200 - GET /

さらに先へ

今回のサンプルコードでは、ローカルに立てているとは言え、ユニットテストの外にある HTTP サーバーにアクセスしてテストをしています。 テストの手段として、こういう作りも道具箱に入れておくと便利です。

ただ、この方法の不便なところは、通常起こりえないエラーケースのテストが書きにくいことが多い、という点です。 そのような場合、私がよくやる手段としては、read() / write() のような関数にラッパーをかませて、間に好き勝手できるバッファを差し込む方法があります。 よくあるモックとかスタブとかですね。

ラッパーを用意する方法は、関数ポインタにしておく、静的にコンパイル対象を切り替える、リンカでこっそり差し替える、weak シンボル使う、とか色々ありますので、その時々でコスパの良い方法を選択すると幸せになれます。

ハマりどころ

サンプルコードだけ見るとサクッと作れそうなものですが、その道中にはやはりハマりどころがちょいちょいありました。 せっかく苦労したので紹介して供養します。

1. EINTR

https://www.freertos.org/FreeRTOS-simulator-for-Linux.htmlPort-Layer Design Description に記述がありますが、OS Tick を ITIMER シグナルで実現しているようです。 そのせいか、select() や blocking な read() / write() など、時間がかかるシステムコールを呼ぶと、EINTR が発生しまくります (ちょっとこのあたり推測なので真の原因は別かもしれません) 。

Nature Remo 開発でのメインのユースケースは、socket 使ったネットワーク機能や vfs 使ったシリアルデバイス通信なので、この影響をもろに受けてハマりました。 select とか書く時は errno を見て、EINTR ならやり直すようにしておきましょう。 大体こんな感じで使ってます。

    struct timeval tv = {
        .tv_sec = 0,
        .tv_usec = 100000,
    };
    fd_set rfds;
    FD_ZERO(&rfds);
    FD_SET(fd, &rfds);

    int r;
    while (true) {
        r = select(fd + 1, &rfds, NULL, NULL, &tv);
        if (r == -1) {
            if (errno == EINTR) {
                continue;
            }
            break;
        }
        break;
    }

    return r;

select 呼び出し時に struct timeval が更新されるかどうかは実装依存なので、お使いの環境に合わせてご利用ください。

2. FreeRTOS POSIX porting のバグ…

当初、FreeRTOS kernel の v10.4.1 を使っていました。 確か、初めて POSIX porting を触ったときの最新バージョンだったと思います。

今回改めてユニットテストでサニタイザーをモリモリ使い始めましたが、なんと kernel の porting 部分にメモリリークがあり、サニタイザーが反応する、という事件がありました。 まず自力で操作すると、タスクスケジューラが終了するときに、メモリの解放漏れがある、ということがわかりました。

最新の kernel ソースを見ると修正されていたので、history を追ってみると、v10.4.2 では下記 PR が merge されて修正されていました。 本当にタッチの差で、謎のメモリリークと戦うハメになってしまいました…。

github.com

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

Natureではこんなテスト環境ステキやん、と思ってくれる仲間を募集しています。

herp.careers

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

herp.careers

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

speakerdeck.com