Arduino
プロトコル
serial
マイコン
uart
ArduinoDay 7

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

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

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

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


2018.03.10 追記

いまさらですがライブラリ作ってたの公開忘れたたのであげました -> Packetizer


どんなひとむけ?


  • シリアル通信データを軽くしたい

  • 通信エラーを検出したい

  • 文字列めんどう、文字列きらい、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


送信側で0x00~0xFEに値を制限しているので、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 >> 24) & 0xFF);
uint8_t dataHL = (uint8_t)((data >> 16) & 0xFF);
uint8_t dataLH = (uint8_t)((data >> 8) & 0xFF);
uint8_t dataLL = (uint8_t)((data >> 0) & 0xFF);

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) (
((dataHH << 24) & 0xFF000000)
| ((dataHH << 16) & 0x00FF0000)
| ((dataHH << 8) & 0x0000FF00)
| ((dataHH << 0) & 0x000000FF)
);

// do something with data

}

int32_t, float はともに32bitで構成される数値です。これを8bitずつ (1byteずつ) 、4byteに分割して送っています。あれ、でもこれだとさきほど説明したのと同様、どれがどの部分の値なのかわからなくなってしまいますね。。。値の開始位置がずれて変な値になってしまう可能性があります。

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

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


4. エスケープシーケンスを使う

3でふたたび起きてしまった、どれがどの部分のバイトよ?問題を解決するため、ここではエスケープシーケンスを導入します。ポイントは、


  • 特定の数値をヘッダとする (ここでは0x7E)

  • データ列にヘッダと同じ数値が出た場合、特定の値で置き換える (ここでは0x7D)

  • 置き換えが生じた場合、その次のバイトに 0x7E ^ 0x20 を追加する

  • データ列に0x7D出た場合も同様に、特定の値で置き換えた後、0x7D ^ 0x20 を追加する

  • 受信時に0x7Dが来た場合、そのバイトを読み捨て、次のバイト ^ 0x20 を使う

こちらのセクションで参考にしているのは、xbee-arduino というライブラリです。

// 送信側

0x7E 0x12 0x34 0x56 0x78 // -> そのまま

// 受信側
0x7E 0x12 0x34 0x56 0x78 // -> そのまま

// 送信側

0x7E 0x12 0x7E 0x56 0x78 // -> 変換前 0x7E が送信データに含まれるので、変換する
0x7E 0x12 0x7D 0x5E 0x56 0x78 // -> 変換後 0x5E = 0x7E ^ 0x20

// 受信側
0x7E 0x12 0x7D 0x5E 0x56 0x78 // -> 0x7D が受信データに含まれるので、変換する
0x7E 0x12 0x7E 0x56 0x78 // -> 変換後 0x7E = 0x5E ^ 0x20

const uint8_t HEAD_BYTE = 0x7E;

const uint8_t ESCAPE_BYTE = 0x7D;
const uint8_t ESCAPE_MASK = 0x20;

void send()
{
int32_t data = 305419896;
uint8_t dataBytes[4] =
{
(data >> 24) & 0xFF,
(data >> 16) & 0xFF,
(data >> 8) & 0xFF,
(data >> 0) & 0xFF
}

Serial.write(HEAD_BYTE);

for (uint8_t i = 0; i < 4; ++i)
{
if ((data == ESCAPE_BYTE) || (data == HEAD_BYTE))
{
// 0x7E, 0x7D がデータに含まれていたら、0x7Dをまず送信
Serial.write(ESCAPE_BYTE);
// その後にデータと0x20のXORを追加送信
Serial.write(data ^ ESCAPE_MASK);
}
else
{
Serial.write(data);
}
}
}

void receive()
{
uint8_t data = Serial.read();
uint8_t bytes[4];

if (data == HEAD_BYTE)
{
for (uint8_t i = 0; i < 4; ++i)
{
uint8_t d = Serial.read();
if (d == ESCAPE_BYTE)
{
// 0x7D がデータに含まれていたら0x7Dは読み捨てて、
// 次のバイトと0x20のXORを本当のデータとして取得
bytes[i] = Serial.read() ^ ESCAPE_MASK;
}
else
{
bytes[i] = d;
}
}

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

// do something with data
}
}


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

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

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


パリティビットをつける

単純な誤り検出方法です。詳しくはこちら

これは自分で実装する必要なく、シリアルの仕様としてチェックをしてくれます。

Arduinoでパリティチェックを有効にするには、こちらを参照してください。


チェックサムを追加する

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


  • データを全部足し合わせる

  • パケットの最後に付け足して送る

  • 受信側も受信データを足し合わせる

  • 受信終了後、和があっているか確認する


チェックサムを末尾に追加したパケット構造

0x7E 0x12 0x34 0x56 0x78 0x14 (= checksum)

checksum = (データの全バイトの和) & 0xFF

const uint8_t HEAD_BYTE = 0x7E;

const uint8_t ESCAPE_BYTE = 0x7D;
const uint8_t ESCAPE_MASK = 0x20;

void send()
{
int32_t data = 305419896;
uint8_t dataBytes[4] =
{
(data >> 24) & 0xFF,
(data >> 16) & 0xFF,
(data >> 8) & 0xFF,
(data >> 0) & 0xFF
}

Serial.write(HEAD_BYTE);

uint8_t checksum = 0;
for (uint8_t i = 0; i < 4; ++i)
{
if ((dataBytes[i] == ESCAPE_BYTE) || (dataBytes[i] == HEAD_BYTE))
{
Serial.write(ESCAPE_BYTE);
checksum += ESCAPE_BYTE;
Serial.write(dataBytes[i] ^ ESCAPE_MASK);
checksum += dataBytes[i] ^ ESCAPE_MASK;
}
else
{
Serial.write(dataBytes[i]);
checksum += dataBytes[i];
}
}

// 末尾にチェックサムを追加で送信する
Serial.write(checksum);
}

void receive()
{
uint8_t data = Serial.read();
uint8_t bytes[4];
uint8_t checksum = 0;

if (data == HEAD_BYTE)
{
for (uint8_t i = 0; i < 4; ++i)
{
uint8_t d = Serial.read();

if (d == ESCAPE_BYTE)
{
uint8_t nextByte = Serial.read();
bytes[i] = nextByte ^ ESCAPE_MASK;
checksum += nextByte;
}
else
{
bytes[i] = d;
checksum += d;
}
}

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

// 受信データ末尾のチェックサムと、
// 受信データから計算されたチェックサムを比較して正しいか判定する
uint8_t checksum_recv = Serial.read();

if (checksum == checksum_recv)
{
// correct data !!
// do something with data
}
else
{
// data error !!
}
}
}


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

さらに検出確率があがります。

上記の和の部分をCRCの計算結果に置き換えるだけです。

CRCの詳細はこちらで確認してください。


6. 誤り訂正

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


まとめ

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


  • 0-255の数値を一種類だけ送りたいときは、何も考えず垂れ流そう

  • 0-254で収められるデータなら、255をヘッダにし、データを0-254に制限し、垂れ流そう

  • それ以上の整数や、小数を贈りたいときは、1byteごとに分割しよう

  • 複数byteで構成されるデータを送るときは、ヘッダ・フッタをつけよう

  • ここまでやっても変な値が来ることが多々あるので、誤り検出しよう

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