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用では一番広く使われています
-
Packetizer :
PacketSerial
に加えてインデックス・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
送信側で 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 & 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用では一番広く使われています
-
Packetizer :
PacketSerial
に加えてインデックス・CRCチェック付きで送受信可能 - MsgPacketizer : いろんな型や独自クラスを簡単に・チェック付きで送受信したい場合
ご指摘・質問・コメント・アドバイス、お待ちしています!