独自シリアルプロトコルのフレーム設計

基板間や装置間をUARTでつなぎ、自前のコマンドをやり取りする。組み込み開発で何度も登場する場面ですが、「とりあえず文字列で」と始めたプロトコルは、バイナリデータや取りこぼしへの対応で行き詰まりやすくなります。本記事では、独自シリアルプロトコルを設計するときのフレーム構造の定石と、区切り方式の選び方、受信実装の作法を解説します。

なぜ「フレーム」が必要なのか

UARTが運んでくれるのは1バイトずつのデータだけで、「どこからどこまでがひとかたまりの電文か」という情報はありません。受信側は通信の途中から聞き始めるかもしれず、ノイズで1バイト化けたり欠けたりもします。そこで、バイト列に区切りと検証情報を与える取り決め、すなわちフレームを設計します。フレーム設計の質が、そのままプロトコルの堅牢性を決めます。

フレームの基本構成

フィールド 役割 設計のポイント
ヘッダ(フレーム同期) フレームの先頭を示す 受信側の再同期の手がかり。データに出にくい値を選ぶ
長さ 本体のバイト数 受信側が「あと何バイト読むか」を知る
コマンド/シーケンス番号 種別の識別・抜け検出 応答との対応付けにも使う
データ本体 可変長のペイロード 最大長を仕様で決めておく
CRC 誤り検出 対象範囲を仕様で決める(長さ・コマンドを含める設計が一般的)。単純加算より検出力が高い

「ヘッダ+長さ+本体+CRC」が最小限の骨格です(コマンドやシーケンス番号は、用途に応じて本体に含めるか、独立したフィールドとして追加します)。テキスト指向ならSTX/ETXなどの制御文字で挟む流儀、バイナリ指向なら長さフィールドで管理する流儀がよく使われます。

区切りの3方式と使い分け

  • 固定長:実装は最も簡単。コマンドが少なく将来も増えない用途向け。1バイトずれると以後すべてずれるため、再同期の仕組みは別途必要。
  • 長さフィールド方式:ヘッダの直後に長さを置き、その分だけ本体を読む。バイナリプロトコルで広く使われる方式。長さ自体が化けた場合に備え、CRCとタイムアウトで防御する。
  • 区切りバイト+エスケープ方式:特定のバイトをフレームの区切り専用にし、データ中に同じ値が現れたら置換して送る。通信の途中から受信を始めても、区切りを待つだけでフレーム同期できるのが強み。

エスケープの定石:SLIPに学ぶ

区切りバイト方式の代表例が、RFC 1055として標準化されたSLIPです。END(0xC0)をフレームの区切りとし、データ中にENDと同じ値が現れたらESC(0xDB)+ESC_END(0xDC)の2バイトに、ESCと同じ値が現れたらESC(0xDB)+ESC_ESC(0xDD)に置き換えて送ります。受信側はENDが来るまで読み、エスケープを逆変換すれば元のバイト列に戻ります。対応関係をまとめると、送信時は「0xC0 → 0xDB 0xDC」「0xDB → 0xDB 0xDD」、受信時は「0xDB 0xDC → 0xC0」「0xDB 0xDD → 0xDB」と対で変換します。同じ発想のテキスト系の流儀として、DLE+STX/ETXで挟み、データ中のDLEを2つ(DLE DLE)に重ねるDLE透過方式もあります。

注意点1:SLIPは誤り検出を持たない→CRC併用必須。区切りとエスケープは「切れ目」を保証するだけなので、化けの検出には必ずCRCを併用する必要があります。
注意点2:最悪で2倍に膨張→バッファは最悪値で設計。エスケープによりデータ長は最悪で2倍まで膨らむため、送受信バッファはこの最悪値で設計します(膨張を抑えるCOBS(Consistent Overhead Byte Stuffing)という発展形もあり、オーバーヘッドを最悪でも254バイトあたり1バイト以下(nバイトに対して⌈n/254⌉バイト)に抑えられます)。

受信側はステートマシンで書く

受信処理は「フレーム同期待ち→長さ受信→本体受信→CRC検証」と段階的に進む処理なので、ステートマシン(「状態爆発を防ぐステートマシン設計」参照)で書くのがセオリーです。長さフィールド方式での骨格は次のようになります。

typedef enum { ST_SYNC, ST_LEN, ST_BODY, ST_CRC } rx_state_t;
static rx_state_t state = ST_SYNC;   /* 受信状態(呼び出しをまたいで保持する) */
static uint8_t len, cnt, buf[MAX_LEN];   /* 受信中フレームの長さ・位置・本体 */
void rx_byte(uint8_t b)            /* 1バイト受信ごとに呼ぶ */
{
    switch (state) {
    case ST_SYNC:                  /* ヘッダを探す */
        if (b == FRAME_HEADER) { state = ST_LEN; }
        break;
    case ST_LEN:
        if (b > MAX_LEN) { state = ST_SYNC; break; }  /* 異常長は捨てる */
        len = b; cnt = 0;
        state = (len > 0) ? ST_BODY : ST_CRC;
        break;
    case ST_BODY:
        buf[cnt++] = b;
        if (cnt >= len) { state = ST_CRC; }
        break;
    case ST_CRC:
        if (crc_ok(buf, len, b)) { on_frame(buf, len); }  /* b: 受信したCRC値(1バイト) */
        state = ST_SYNC;           /* 成否にかかわらず次のフレームへ */
        break;
    }
}

MAX_LENは受信バッファサイズとして254以下の値で定義してください。なお、この例はCRCを1バイト(CRC-8相当。CRC-8/CCITTやCRC-8/MAXIMなど代表的な種別を例に、多項式や初期値まで仕様で固定してください)とし、CRCの対象を本体(ペイロード)だけに絞った最小の骨格です。実際の設計では長さやコマンドを含めることが多いため、対象範囲(例:LEN+CMD+PAYLOAD)を仕様として明記してください。CRC-16やCRC-32を使う場合は、ST_CRCステートで必要なバイト数を順次受信し、全バイトがそろった時点で検証する処理を追加してください。あわせて必須なのが受信タイムアウトです。フレームの途中で一定時間データが来なければ捨ててST_SYNCへ戻ります。この「あきらめて再同期する」動きがないと、1バイトの欠落から永遠に復帰できないプロトコルになります(「通信におけるI/Oの堅牢化とタイムアウト設計」参照)。

信頼性と将来拡張の上乗せ

  • 誤り検出はCRCで:単純加算のチェックサムより検出力が高く、ルックアップテーブルを使えば実行コストも現実的に抑えられる(「CRC実装時のポイント」参照)。
  • 確実に届けたいならACK/再送:シーケンス番号を入れると重複受信も検出できる。
  • マルチバイト値のエンディアンを仕様書に明記する:CRCを複数バイトで送る場合の送信順序も、あわせて仕様に固定する。
  • 長さフィールドが指す範囲を明記する:ペイロードだけか、コマンドやCRCまで含むかを仕様として固定する。
  • タイムアウト値の決め方を添える:timeout_ms ≧(最大フレームバイト数)×(1バイトの送信時間)× 安全率という式を目安に、ボーレートと最大フレーム長から算出するなど、根拠を仕様に残す(1バイトの送信時間は、UARTのスタートビット・データビット・パリティビット・ストップビットの設定とボーレートで決まる)。
  • 将来の拡張余地を残す:プロトコルバージョンのフィールド、予約領域、未知コマンドを安全に無視する規則を最初から決めておく。

まとめ

独自プロトコルは「ヘッダ+長さ+本体+CRC」の骨格に、区切り方式(長さ方式か区切りバイト+エスケープ方式(SLIP風))とタイムアウト再同期を組み合わせれば、堅牢なものが設計できます。そして同じくらい重要なのが、フレーム仕様を文書化して相手側の実装者と共有することです。プロトコル仕様書は装置の寿命と同じだけ生き続けます。

組み込みソフトの世界 トップへ戻る