Help us understand the problem. What is going on with this article?

Arduinoでバイナリ送受信のシリアル通信をするときのパケットの構造

2020.05.27 追記

  • union を使った方法を追記
  • SLIP を使ったエスケープシーケンスに変更
  • COBS を使った方法を追加
  • その他もろもろ細かいところを修正

ArduinoでPCや他のデバイスにデータを送りたいとき、シリアル通信使うことが多々あると思います。でも文字列 (Serial.print()など) で送るのはサイズもでかいし、冗長だし、コンマでデータを区切りたくないし、もっと軽くしたい!バイナリで通信だ!ってなりますよね。わかります。

でもバイナリで通信するときには実際にはどんな感じのデータ構造で通信するの?ってところを具体的にまとめられればと思います。Arduinoでの基本的なシリアル通信のやり方はこちらで確認してください。

ここ違うよ!とか、もっとこんな方法があるよ!とか、世間一般ではこんな方法が有名だよ!などあれば、ぜひコメントしていただければ嬉しいです。

結論から

長くなってしまったので、まずはじめに結論です。これを頭に入れて読んでみてください!

  • 0 - 255 の数値を一種類だけ送りたいときは、何も考えず垂れ流そう
  • 0 - 254 で収められるデータなら、255をヘッダにしてパケットを区切り、データを0-254に制限しよう
  • それ以上の整数や、小数を贈りたいときは、1byteごとに分割しよう
  • もっとたくさんデータを送りたいときは、union を活用しよう
  • 複数バイトで構成されるデータを送るときは、パケットに目印を入れて区切りを明確にしよう
    • SLIP を使ってエスケープシーケンスで送る
    • COBS を使えば SLIP よりも少ないデータ量に抑えられる
  • ここまでやってもノイズなどの影響で変な値が来ることが多々あるので、誤り検出しよう
  • もっと簡単にこういうシリアル送受信をしたい場合、こちらのライブラリをどうぞ
    • PacketSerial : 自作のものではないですが、Arduino用では一番広く使われています
    • PacketizerPacketSerialに加えてインデックス・CRCチェック付きで送受信可能
    • MsgPacketizer : いろんな型や独自クラスを簡単に・チェック付きで送受信したい場合

どんなひとむけ?

  • シリアル通信データを軽くしたい
  • 通信エラーを検出したい
  • 文字列めんどう、文字列きらい、ArduinoのStringきらい
  • バイナリで通信するとき、具体的にどうやってやるのさ?
  • こちらの記事が大変参考になりました、というような方

シリアル通信でバイト列を扱うときのフレーム構造

諸注意

  • バイナリでのやりとりに特化、文字列は扱わない
  • 機種依存する整数型は使わず、cstdint の整数型のみ使う
  • 便宜上一区切りのデータ構造をパケットと呼ぶ
  • バイトオーダは無視する
  • フロー制御に関しても無視
  • サンプルコードでは必要な全データが既にシリアルのバッファに積まれていると想定

はじめに

シリアル通信では、1byte 0-255の数値を1単位として送ります。なので、こいつがいくつまとまって、どの場所のどの値がういう意味を持つか、をきちんと決めてあげないといけません。このシリアル通信でのデータ構造をパケットと呼ぶことにします。具体例に合わせて、シリアル通信のパケットをつくっていきます。

1. 0-255の数値を送りたい

例:AnalogRead() した値を送受信する

よくあるやつですね。とりあえずざっくりした1つの値だけを送りたいときには何も気にする必要はありません。垂れ流しましょう。

void send()
{
    uint8_t data = (uint8_t) analogRead(PIN_NO) / 4; // analogRead()で0-1023が来ている想定
    Serial.write(data);
}

void receive()
{
    uint8_t data = Serial.read();

    // do something with data
}

2. 複数の 0 - 255 の値を送りたい

Arduinoに対して0 - 255 のRGBの値を送り、指示された色でLEDを光らせたいことがあります。先程のように、0-255の値を連続して送ってしまうと、どれがRでどれがGでどれがB???ってなっちゃいます。こんなときにお手軽なのが、この方法です。ポイントは、こちらです。

  • 255 (0xFF) をヘッダにし、ヘッダがきたらその後のデータを読む
  • 0-254 (0xFE) までをRGBデータとし、255 は使わない

データのはじまりを示す、ヘッダをくっつけて、それを他で使わないようにするだけです。簡単ですね。やってみましょう。

パケット構造
0xFF 0x12 0x34 0x56

送信側で 0x000xFE に値を制限しているので、0xFF はヘッダでしか登場しないはずです。

void send()
{
    uint8_t r = constrain(r_value, 0, 254);
    uint8_t g = constrain(g_value, 0, 254);
    uint8_t b = constrain(b_value, 0, 254);

    Serial.write(255); // header
    Serial.write(r);
    Serial.write(g);
    Serial.write(b);
}

void receive()
{
    uint8_t data = (uint8_t)Serial.read();
    if (data == 0xFF) // check if incoming byte is header
    {
        uint8_t r = Serial.read();
        uint8_t g = Serial.read();
        uint8_t b = Serial.read();
    }

    // change led color with rgb data
}

0 - 254 までの範囲しかデータとして使えませんが、簡単に通信することができました。この255段階のデータで充分ならば、無理に小難しいことをする必要はありません。こんな感じでサクッとパケットをつくりましょう!

3. 0 - 255 より大きい整数や小数を送りたい

0 - 255 じゃ満足できん!もっと大きな数値を送らせろ!整数ばっか送ってて何が楽しいの!?小数送らせてくれよ!ってことありますよね。そんなときはこんな感じで送ります。

パケット構造
0x12345678 (= 305419896) // -> これを1byteずつに分割する
0x12 0x34 0x56 0x78      // -> 分割されたものを順番に送信
void send()
{
    int32_t data = 305419896;
    uint8_t dataHH = (uint8_t)((data & 0xFF000000) >> 24);
    uint8_t dataHL = (uint8_t)((data & 0x00FF0000) >> 16);
    uint8_t dataLH = (uint8_t)((data & 0x0000FF00) >>  8);
    uint8_t dataLL = (uint8_t)((data & 0x000000FF) >>  0);

    Serial.write(dataHH);
    Serial.write(dataHL);
    Serial.write(dataLH);
    Serial.write(dataLL);
}

void receive()
{
    uint8_t dataHH = (uint8_t)Serial.read();
    uint8_t dataHL = (uint8_t)Serial.read();
    uint8_t dataLH = (uint8_t)Serial.read();
    uint8_t dataLL = (uint8_t)Serial.read();

    int32_t data = (int32_t) (
          (((int32_t)dataHH << 24) & 0xFF000000)
        | (((int32_t)dataHL << 16) & 0x00FF0000)
        | (((int32_t)dataLH <<  8) & 0x0000FF00)
        | (((int32_t)dataLL <<  0) & 0x000000FF)
    );

    // do something with data

}

int32_t, float はともに32bitで構成される数値です。これを8bitずつ (1byteずつ) 、4byteに分割して送っています。
こんな感じで切って貼ってをすれば、大きな値を送ることも可能ですね。

4. union を活用して、もっと大きなデータを送る

大きな値を送ることはできるようになったけど、たった変数一つだけ?送るとき毎回こんな面倒なことやるの??と思ったそこのあなた。union を使ってもっと簡単にできますよ!例として、float x3 の通信をしてみましょう。 こんな位置データを送りたいとします。

float x;
float y;
float z;

前節で説明したようなビットシフトを全部やるのは面倒くさすぎる、、、そこで union を使いましょう!

union Position
{
    struct {
        float x;
        float y;
        float z;
    };
    uint8_t bin[sizeof(float) * 3]; // -> 4 * 3 = 12
};

union の詳細はこちらで確認してください。要は、並列に宣言されている struct{}uint8_t bin[] のデータのアドレスが揃うので、struct{}uint8_t bin[] のバイトサイズを同じにしておけば、struct{} をバイナリにしたら中身がどうなっているか?が、勝手に bin に入ってくれるわけです。つまり、、、

Position pos;

// こんな感じで変更すると、binに勝手にバイナリデータが反映されます
pos.x = 1.1f;
pos.y = 2.2f;
pos.z = 3.3f;

for (uint8_t i = 0; i < sizeof(Position); ++i)
{
    // あとはこれだけで、xyzの値を1バイトずつ送れます
    Serial.write(pos.bin[i]);
}
// こうやって一行で書くこともできます
// Serial.write(pos.bin, sizeof(Position));

なんと、、、これは簡単ですね、、、!もう向かうところ敵なしなので、どんどんパケットを送りまくりましょう!

あれ、でもこれだと前に説明したのと同様、どれがどの部分の値なのかわからなくなってしまいますね。。。
パケットの開始位置がずれて、受け取った値がぐちゃぐちゃになってしまう可能性があります。

じゃー、これに255のヘッダをつけて、開始位置を決めてあげれば、、、
あれ?4分割した値に255の値が出て来ることがあるぞ?
よし、0−254までに制限して、、、あれ?なんかとんでもない値になっちゃったぞ?

ってなっていまいます。どうしましょう。

5. SLIP を使って、パケットの区切りを明確にする

3節・4節でふたたび起きてしまった、どれがどの部分のバイトよ?問題を解決するため、ここではSLIP(Serial Line Internet Protocol)と呼ばれる通信プロトコルを導入します。
SLIPを導入することで、パケットの区切りを明確にすることができます。
ポイントを下記にまとめます。

エンコード

  • END = 0xC0 をパケットの最後のバイトの目印とし、それ以外に使わないようにする = パケットの区切りが明確になる
  • パケット中に END と同じ数値が出た場合、ESC = 0xDB で置き換え、次のバイトに ESC_END = 0xDC を追加する
  • パケット中に ESC と同じ数値が出た場合、ESC = 0xDB で置き換え、次のバイトに ESC_ESC = 0xDD を追加する

デコード

  • END はパケットの最後にしか出てこないので、それが出るまでデータ列を受信する
  • 受信パケット内に ESC があった場合、そのバイトは読み捨てて次のバイトを確認する
    • ESC の次のバイトが ESC_END だった場合、そのバイトを END にする
    • ESC の次のバイトが ESC_ESC だった場合、そのバイトを ESC にする

変換例

上記のポイントを踏まえて変換例を見てみると、こんな感じになります。

// 送信側
0x12 0x34 0x56 0x78      // -> 送信したいデータ列、特に手を加える必要なし
0x12 0x34 0x56 0x78 0xC0 // -> SLIPにエンコード (区切りの END = 0xC0 を追加するだけ)

// 受信側
0x12 0x34 0x56 0x78 0xC0 // -> SLIPのパケットを受信
0x12 0x34 0x56 0x78      // -> END = 0xC0 を取り除けば元のデータになる

これは特に手を加える必要はありませんでしたね。
変換が必要なのはこんな場合です。

// 送信側
0x12 0xC0 0x56 0xDB 0x9A                // -> 元データに END = 0xC0, ESC = 0xDB が含まれるので変換が必要
0x12 0xDB 0xDC 0x56 0xDB 0xDD 0x9A 0xC0 // -> SLIPへ変換後 END ESC を変換し、最後に END を追加

// 受信側
0x12 0xDB 0xDC 0x56 0xDB 0xDD 0x9A 0xC0 // -> END まで受信し、ESC を読み捨て、次のバイトを ESC_END か ESC_ESC に変換
0x12 0xC0 0x56 0xDB 0x9A                // -> これで元のデータに戻りました

実装例

それではこの変換を、union と組み合わせて実装してみましょう!

const uint8_t END = 0xC0;
const uint8_t ESC = 0xDB;
const uint8_t ESC_END = 0xDC;
const uint8_t ESC_ESC = 0xDD;

union Data
{
    float f;
    uint8_t b[4];
};

Data send_data;

void send()
{
    // 送信データを準備
    send_data.f = 123.456f;

    for (size_t i = 0; i < sizeof(Data); ++i)
    {
        uint8_t data = send_data.b[i]; // i バイト目のデータ

        if(data == END) // データの途中に END があったら
        {
            Serial.write(ESC);     // ESC で置き換え
            Serial.write(ESC_END); // ESC_END を追加します
        }
        else if(data == ESC) // データの途中に ESC があったら 
        {
            Serial.write(ESC);     // ESC で置き換え
            Serial.write(ESC_ESC); // ESC_ESC を追加します
        }
        else
        {
            Serial.write(data); // それ以外はそのまま送ります
        }
    }
    Serial.write(END); // 最後だけENDを送信
}

Data recv_data;

// Serialから、ENDが最後に入ったデータが来たとします
void receive(uint8_t* src, size_t size)
{
    for (size_t i = 0; i < size; ++i)
    {
        uint8_t data = src[i]; // i 番目の受信データ

        if (data == END) // 必ずパケットの終端なはず
        {
        }
        else if (data == ESC) // ESCがあったら次のバイトは変換されているはず
        {
            uint8_t next = src[i + 1]; // ESC の次のパケット

            if (next == ESC_END) // ESC の次が ESC_END なら
            {
                recv_data.b[i] = END; // i 番目のバイトは END
            }
            else if (next == ESC_ESC) // ESC の次が ESC_END なら
            {
                recv_data.b[i] = ESC; // i 番目のバイトは ESC
            }
            ++i; // next の次のバイトへ
        }
        else
        {
            recv_data.b[i] = data; // それ以外のときはそのまま
        }
    }

    Serial.print("received data is : ");
    Serial.println(recv_data.f);
}

これでやっと、どれがどのバイトなのかわからない問題を解決することができました。
ただ、エスケープバイトだったり、ヘッダバイトと同じ値のデータがたくさん出てくるデータの場合、最悪で2倍までデータ量が膨らんでしまいます。これは由々しき事態です。

6. COBSを使用して、区切りを明確にしたままデータ量を削減する

そこで、COBS (Consistent Overhead Byte Stuffing) を使ってエンコードしましょう!
これはその名の通り、データ量が一定数 (2バイト) しか増えずに、どれがどのバイトかわからない問題を解決できます。
細かい変換ルールもあるのですが、ざっくりとポイントを説明すると、こんな感じになります。

  • 0 をパケットの最後のバイトの目印とし、パケットの最後にしか使わないようにする
  • パケットの最初に、初めて 0 が出てくるまでのバイト数を追加する
  • パケット中に 0 が出た場合、そのバイトを次に出てくる 0 までのバイト数で置き換える
  • これをパケット長の分だけ繰り返す
  • パケットの最後に 0 を追加する

変換例

変換例を見てみるとわかりやすいです。
Wikipedia にある例を借用します。

Example Unencoded data (hex) Encoded with COBS (hex)
1 00 01 01 00
2 00 00 01 01 01 00
3 11 22 00 33 03 11 22 02 33 00
4 11 22 33 44 05 11 22 33 44 00
5 11 00 00 00 02 11 01 01 01 00

パケットの最後だけにしか 0 が出てこないので区切りが明確です!
しかも2バイトしか増えないなんて天才ですね。。。

補足:この最初と最後の追加2バイトのうち、最後の1バイトを減らした、COBS/R というものもあります。

実装例

省略します!!!!!
Packetizer ではこのあたりで実装されていますので、ご参考にしてくださいね。
これで、パケットが倍まで膨れてしまう(かもしれない)問題を解決できました。
最後に、もう少しだけ通信の成功確率を上げられるようにしましょう!

7. 誤り検出: 通信エラーを検出して除外する

たくさん通信をしていると、たまにうまくデータが取得できないときがあります。
たとえば1秒に30回LEDの色を送っていると、ときおり色がチラついたり、サーボがいきなり変なところへ動いたり、、、。
それ、データおかしくなってません?

原因はノイズであったり、さまざまですが、アプリケーションによっては誤った値を使ってしまうのは致命的です。
せめて間違った値は使わないようにしたい。。。それでは対策を見ていきましょう。

パリティビットをつける

単純な誤り検出方法です。詳しくはこちら
これは自分で実装する必要なく、シリアルの仕様としてチェックをしてくれます。
Arduinoでパリティチェックを有効にするには、こちらを参照してください。

チェックサムを追加する

もうすこしだけ、検出確率があがります。ポイントは、

  • データを全部足し合わせる
  • パケットの最後に付け足して送る
  • 受信側も受信データを足し合わせる
  • 受信終了後、和があっているか確認する
チェックサムを末尾に追加したパケット構造
0x7E 0x12 0x34 0x56 0x78 0x14 (= checksum)
checksum = (データの全バイトの和) & 0xFF

CRCの計算結果をフッタにする

さらに検出確率があがります。
上記の和の部分をCRCの計算結果に置き換えるだけです。
CRCの詳細はこちらで確認してください。

8. 誤り訂正

ちょっと山にこもって勉強してきます

9. ライブラリを活用する

ここまで色々と説明してきましたが、毎回これを実装するのは面倒ですね。。。作りました!
ので、作ったライブラリと、Arduinoで有名なライブラリを紹介して終わりにしたいと思います。

PacketSerial

自作のものではないですが、COBS/SLIP を使ったシリアル通信を実現しているライブラリです。
おそらく一番広く使われていて、安定もしていると思います。
中身を見てみても実装の参考になるので、ぜひ見てみてください。

Packetizer

こちらが自作したもので、ごくごく最近 COBS/SLIP を使うようにアップデートしました。
そこまでは上記の PacketSerial と同じですが、下記のような個人的要望があったので自作しました。

  • インデックスバイトが欲しい
  • CRCで誤り検出をしたい

こんな感じで簡単に使えるので、ぜひ使ってみてください。

#include <Packetizer.h>

void setup()
{
    Serial.begin(115200);

    // register callback called if packet has come
    Packetizer::subscribe(Serial,
        [](const uint8_t* data, const size_t size)
        {
            // one-line send data array
            Packetizer::send(Serial, data, size);
        }
    );
}

void loop()
{
    Packetizer::parse(); // should be called to trigger callback
}

MsgPacketizer

こちらは MessagePack の形式にシリアライズしたデータを、COBSにエンコードして簡単にシリアルで送受信できるようにしたライブラリです。このページで紹介したような、いろいろな問題点を回避できるので、さらに簡単に通信したいと思う方はぜひ使ってみてください。Packetizerではバイナリデータしか扱えませんでしたが、こちらのライブラリは自作のクラスを含む、大体どんな型でも一発で送受信可能です。

#include <MsgPacketizer.h>

// input to msgpack
int i;
float f;
MsgPack::str_t s; // std::string or String
MsgPack::arr_t<int> v; // std::vector or arx::vector
MsgPack::map_t<String, float> m; // std::map or arx::map

uint8_t recv_index = 0x12;
uint8_t send_index = 0x34;

void setup()
{
    Serial.begin(115200);

    // update received data directly
    MsgPacketizer::subscribe(Serial, recv_index, i, f, s, v, m);
}

void loop()
{
    // must be called to receive
    MsgPacketizer::parse();

    // send received data back
    MsgPacketizer::send(Serial, send_index, i, f, s, v, m);
}

MessagePack のエンコード・デコードのみが必要な方は、MsgPack のライブラリを見てみてください。

まとめ

簡単にまとめると、こんな感じでしょうか?

  • 0 - 255 の数値を一種類だけ送りたいときは、何も考えず垂れ流そう
  • 0 - 254 で収められるデータなら、255をヘッダにしてパケットを区切り、データを0-254に制限しよう
  • それ以上の整数や、小数を贈りたいときは、1byteごとに分割しよう
  • もっとたくさんデータを送りたいときは、union を活用しよう
  • 複数バイトで構成されるデータを送るときは、パケットに目印を入れて区切りを明確にしよう
    • SLIP を使ってエスケープシーケンスで送る
    • COBS を使えば SLIP よりも少ないデータ量に抑えられる
  • ここまでやってもノイズなどの影響で変な値が来ることが多々あるので、誤り検出しよう
  • もっと簡単にこういうシリアル送受信をしたい場合、こちらのライブラリをどうぞ
    • PacketSerial : 自作のものではないですが、Arduino用では一番広く使われています
    • PacketizerPacketSerialに加えてインデックス・CRCチェック付きで送受信可能
    • MsgPacketizer : いろんな型や独自クラスを簡単に・チェック付きで送受信したい場合

ご指摘・質問・コメント・アドバイス、お待ちしています!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away