【追試】シリアル通信で受信完了をできるだけ早く検知する方法 [Raspberry pi]

はじめに

数年前に ↓ こんな記事を書きました。

bamch0h.hatenablog.com

私のブログで一番見られている記事らしく、みんな意外とシリアル通信するんだなと驚いています。そんな私も折を見て見返すのですが、ブログを読むたびに「本当に早いの?この対応しなかったらどれだけの速度で通信して、この対応をしたらどれくらいの速度になって、差がどれくらいなの?」という疑問があり、ちゃんと測定してみることにしました。

測定環境

測定環境は ESP32-WROOM-32D の UART2 と Raspberry Pi 4B の UART2 を接続しました。

ESP32 Raspberry Pi 4
TXD(IO17) GPIO1(RXD)
RXD(IO16) GPIO0(TXD)
CTS(IO2) GPIO3(RTS)
RTS(IO15) GPIO2(CTS)

測定時間

測定時間は、ESP32から 100 バイト送信してラズパイ側で受信しきったら 1 バイト返してESP32がそれを受信するまでの時間を測定時間とします。

ESP32側のプログラムは Arduino IDE を使用して Serial.write() してから Serial.flush() した後で micro() でティックカウントを取得し、これを送信完了時の時間として記録します。次に Serial.avairable() が 1 になったタイミングで再度ティックカウントを取得し、この時間を受信完了の時間として記録し、これらの差分を計算することで測定時間とします。

これを 1000 回繰り返して、平均、最小、最大を計算します。

測定パターン

今回は以前のブログの検証となるので、検知手法をカーネル修正前と後で比べる必要があります。

カーネルを修正せずに受信完了を検知する方法としては「1. 十分な時間待つ」と「2. 受信バイト数を固定化する」の二つが考えられます。

1. 十分な時間待つ

受信時間までの待ち時間ですが、受信割り込みがかかるまでの時間を十分な時間としました。

ラズパイ4Bの受信割り込みバイト数はデフォルト16バイトです。通信設定は以下の設定としますので 16 (バイト) * 10 (ビット) * 1,000,000 (μs) / 500,000 (bps) = 320 (μs) がウェイト時間となります

通信設定
ボーレート 500,000
データ長 8
パリティ なし
ストップビット 1

2. 受信バイト数を固定化する

こちらは単純に受信したトータルバイト数が既定のバイト数に達した時点で受信完了とする方法です。

今回は 100バイトを送信したときの応答までの時間を計測することとしているので 100バイトが既定バイト数となります。

カーネルを修正版での計測

さて、次に以前のブログで紹介した方法での計測を・・・と思っていたのですが、実測途中で、この方法では受信完了を誤判定することがわかりました。

どのように誤判定するかというと、受信タイムアウトの発生数をカウントしておいて受信開始前と開始後の値を比較することで受信完了を検知していましたが、100バイト受信しきる前に受信タイムアウト割り込みが発生するケースがあり誤判定となる場合がありました。なぜ受信タイムアウトが受信途中で起こるのか、明確なことはわかりませんが、色々調査してみてある程度推測はできました。その推測を以下で説明します。

まず前提として、今回の測定環境では RTS/CTS ハードウェアフロー制御を行っています。というのも通信と同時に動画再生等の重い処理が動作していると UART の FIFO から内部バッファに吸い出す処理が追い付かなくなりオーバーランエラーが発生してしまうケースがあったためです。そしてフロー制御で送信が一旦止まるとその間に受信タイムアウトが発生してしまい予期せぬ受信タイムアウト割り込みが発生してしまっているのではないかと推測しています。

この理由により以前のブログに記載していた方法は使えないことが分かったので別の解法が必要になりました。詳細は 後述 するのですが、ザックリいうと、UART の FIFO が空かどうかをチェックして空であれば受信タイムアウト割り込み時間 (32 * 1000 * 1000 / 500000 = 64 (μs)) だけ待ってから再度空チェックをし、それでも空だった場合は受信が完了したものとみなすようにしました。

よって、今回は以下の3パターンを計測して比較することとしました。

  1. 受信割り込み時間待って判定するパターン
  2. 固定長で判定するパターン
  3. FIFOの空チェック後受信タイムアウト割り込み時間待って判定するパターン

計測結果

計測結果は以下の通りです。

① 受信割り込み時間待って判定
② 固定長で判定
③ 受信タイムアウト割り込み時間待って判定

ave 739.01 μs 165.87 μs 253.86 μs
min 525.00 μs 152.00 μs 236.00 μs
max 1037.00 μs 241.00 μs 531.00 μs

上記の通り、一番早く受信完了できるのは②の「固定長での判定」でした。次点で③、続いて①という結果になりました。

結果として私が考えていた方法は最速の方法ではなかったということになりますが、②の優位性をあえて述べるとするならば、受信するデータが固定長ではなく、受信データからデータサイズを取得できないような場合においては有効なアプローチなのではないかと思います。

グラフを見ると ① に不穏なパターンが見られますね・・・これについてはよくわからないです・・・

まとめ

以前にポストした「シリアル通信で受信完了をできるだけ早く検知する方法」を実際に検証しました。

結果として最速で検知する方法ではないことがわかりましたが、今までもやもやしていた部分が明確になったことはよかったと思いました。

また、もう一つ良かった点としては、最速の方法が固定長での判定だとわかったことです。固定長の判定はカーネルをいじることがないため実装のハードルがぐんと下がりました。今後のシリアル通信処理を書く時の指針にできると思いました。

あと、想像ではカーネルをいじらないともっと遅くなると思っていたので、それについてもいい方向に裏切られたという印象です。受信割り込み時間待つやり方でも最大で約1msで応答が返ってくるのは結構早いという印象です。 LinuxWindows 等の汎用OSはタスクスケジューリングの関係で数msの遅延が当たり前のように発生することが多いので、もしかするとラズパイのOS (Raspbian) が頑張ってチューニングされているのかもしれません。

ということで、追試の結果として、最速の方法は「固定長で判定する」ということでこのブログのまとめとさせていただきます。チャンチャン♪

おまけ

ここからは計測途中で色々わかったことや雑多なことをおまけとして記載したものです。興味のある方はご覧ください :)

動画を再生しながらの計測結果

ave 800.91 μs 340.23 μs 364.62 μs
min 490.00 μs 124.00 μs 194.00 μs
max 4258.00 μs 5806.00 μs 4433.00 μs

動画無しの時と比べると最大値がかなり上がっていますが、最小値が逆に早くなっています。これはなんでかはよくわかってません。

ラズパイのUART PL011のFIFOのサイズ

以前のブログでは PL011 の FIFO のサイズは 16バイトと記載していましたが、今回計測してみると受信割り込みが16バイト単位で掛かっていて、計算と合わないなーとずっと疑問だったんですが、ラズパイのCPUマニュアルを見ると FIFOサイズは 32バイトと記載がありました。なので、その半分の16バイトで受信割り込みがかかるのは正しい動作でした。

参考にしたマニュアルはこちら → https://datasheets.raspberrypi.com/bcm2711/bcm2711-peripherals.pdf

計測に使用したソースコード

計測に使用したソースコード一式は以下のリポジトリにアップしています。ご興味ある方はご覧ください。

GitHub - bamchoh/rpi-serialport-test at blog_20240613

FIFOバッファの空チェックを用いた受信完了待ちについて

さて、今回③として計測したFIFOバッファの空チェックを用いた受信完了待ちの詳細です。

PL011 には FIFO バッファが空かどうかの状態を格納したレジスタがあります。これを ioctl 経由で取得できるようにすればユーザーランドで FIFOバッファの空チェックができるようになります。

ただ、空チェックだけだとタイミングによっては中間バッファにデータが取り残されている可能性もあるため、そちらも ioctl 経由で取得し前回値の差と送受信数が一致しているかもチェックすることで確実な受信完了チェックを行いました。以下がそのコードです。

 // ... 一部省略 ...

    struct serial_icounter_struct icount;
    __u32 rxtotal = 0;
    ioctl(fd, TIOCGICOUNT, &icount);
    rxtotal = icount.rx; // 中間バッファの受信バイト数を取得

    // waiting for receive any data
    int duration = 32 * 1000 * 1000 / atoi(b_optarg);
    printf("duration: %d\n", duration);
    int total = 0;
    int fifo_is_empty;
    while(1) {
        int len = read(fd, buf, sizeof(buf));
        if (len <= 0) {
            fprintf(stderr, "read error!!\n");
            break;
        }
        ioctl(fd, UIOGRXFE, &fifo_is_empty); // FIFOバッファが空かどうかのフラグ取得
        ioctl(fd, TIOCGICOUNT, &icount); // 中間バッファの情報も取得

        for(int i = 0; i < len;i++) {
            rxbuf[total+i] = buf[i];
        }
        total += len;

        if(fifo_is_empty) { 
            // FIFOバッファが空なら duration (64μs) だけスリープ
            usleep(duration);

            // 再度フラグと中間バッファの情報取得
            ioctl(fd, UIOGRXFE, &fifo_is_empty);
            ioctl(fd, TIOCGICOUNT, &icount);
        }

        // スリープから戻っても FIFOバッファが空で中間バッファも取りこぼしがないなら受信完了
        if(fifo_is_empty && (icount.rx - rxtotal) == total) {
            txbuf[0] = (unsigned char)total;
            write(fd, txbuf, 1);

            total = 0;
            rxtotal = icount.rx;
        }

    }