シリアル通信アプリの落とし穴 - 再接続とログ設計まで

· 更新日: · · シリアル通信, RS-232, C#, .NET, Windows開発, 装置連携

更新履歴(11件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
組み立ての例で、送信が失敗したときにワーカーを止めずに抜けていたのを直しました。機器の無応答、ケーブルの抜け、書き込みタイムアウトで`SendAsync`が例外を投げると、`cts.Cancel()`と`Task.WhenAll`を飛ばして`using`の破棄に到達します。`SerialPort`が閉じられたあともreader/writerがそのストリームを触りに行き、その例外は誰も観測しません。常駐アプリでは失敗した操作のワーカーだけが残ります。キャンセルと待ち合わせを`finally`に移し、ワーカー側の例外は本来の失敗理由を覆い隠さないよう記録に留める形にしました。
`SingleWriter`の送信キューが上限なしでした。UI・タイマー・ワーカーが装置の往復より速く積むと、フレームと`TaskCompletionSource`が際限なく溜まり、装置は応答しているのにメモリだけが増え続けます。上限付きのチャネルにし、あふれたら送り手へ返す形にしました(古いものを捨てる方式は、積んだ側がTaskを待っているため使いません)。
`FrameParser`に組み立ての時間切れを入れました。ノイズや偽の`STX`で`LEN`が大きな値に化けると、parserはそれを「まだ足りない」と扱い続け、その後に届く正しいフレームまで化けたpayloadとして吸い込みます。CRCで落ちるまで何も上がってこないため、通信量の少ない機器では数分の無反応に見えます。3.3で挙げているinter-byte timeoutを実装し、超えたら`STX`を1バイトだけ捨てて読み直す形にしました。再同期の発生を通知するイベントと、時間切れの値の決め方も添えています。
`Frame.Build`が256バイト以上のpayloadを弾いていませんでした。LENは1バイトなのでキャストで折り返す一方、payloadはまるごとコピーされるため、受け手は切り詰められた長さでフレームを切り、payloadの途中をCRCと読みます。以降のフレーム境界も総崩れになるので、長さを検査して弾くようにしました。
応答とタイムアウトの取り合いを不可分にしました。`WhenAny`がタイムアウト側を選んだ直後に応答が届くと、`OnFrameReceived`が先に応答待ちの1件を確保して成功で完了させます。そのあとの`TrySetException`は不発に終わるのに`throw`だけが通るため、呼び出し側は結果を受け取っているのにワーカーだけが落ちていました。`CompareExchange`で確保できたときだけタイムアウトとして扱います。
応答待ちの間に停止を要求されたとき、キャンセルされた`Task.Delay`が先に終わるため、正常な終了処理がタイムアウト扱いになっていたのを直しました。タイムアウトとして扱う前にキャンセルを確認するようにしています。
応答がタイムアウトしたあとそのまま次のコマンドを送っていたのを直しました。このフレームにはリクエストIDが無いため、遅れて届いた前のコマンドの応答が、次のコマンドの応答として呼び出し側に渡ります。3.6の状態遷移図どおりFaultへ落としてセッションを作り直す形にし、そもそもリクエストIDを持たせるほうが本質的な解決である旨も書きました。
`SingleWriter`で、`WriteAsync`が例外を投げたときに`TaskCompletionSource`が未完了のまま残り、`SendAsync`を待っている呼び出し側が永久に待つ問題を直しました。書き込み失敗時にその1件を完了させ、ワーカーが止まるときはキューに残った分もまとめて完了させるようにしました。
受信を蓄積してから切り出す実装と、送信をsingle writerに寄せる実装をC#で追加しました(CRC-16/MODBUS、フレーム分割・連結への対応、応答の対応づけとタイムアウトまで)。フレームのずれ方3パターンの図、状態遷移図、hex dumpを含むログ行の書式例、用語表と前提の表を加えました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589700)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「シリアル通信アプリの落とし穴 - 再接続とログ設計まで」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589700 https://staging.comcomponent.com/blog/2026/03/19/001-serial-communication-app-pitfalls/

DOI(最新版)
10.5281/zenodo.21589700
DOI(この版)
10.5281/zenodo.21732724

装置連携、計測器、PLC、バーコードリーダ、USB-シリアル変換。 シリアル通信は古い技術に見えて、Windows アプリの現場ではまだかなり普通に使われています。

少し危ないのは、シリアル通信が 1 本の COM ポート1 本の Read / Write だけで始められてしまうことです。疎通確認はすぐ通るのに、本番へ出すと次のような症状になりがちです。

  • たまにコマンドと応答がずれる
  • 1 日に 1 回だけ固まる
  • USB の抜き差し後だけ復帰しない
  • UI がときどき止まる
  • ログを見ると “Timeout” しか残っていない

シリアル通信アプリで本当に難しいのは送受信 API そのものではなく、境界、タイムアウト、状態遷移、再接続、観測可能性 のほうです。

この記事の対象読者と前提

項目 内容
対象読者 装置や計測器とシリアルでつながる Windows アプリを作る方。疎通確認は通ったのに、本番で「たまに」壊れる状態を減らしたい方を想定しています
前提とする知識 C# でアプリを書けること。シリアル通信そのものの経験は前提にしません
前提とする環境 .NETSystem.IO.Ports.SerialPort を前提に書いていますが、境界、タイムアウト、状態遷移の考え方は言語に依存しません
扱わないこと 電気的な結線の話、特定装置のプロトコル仕様

この記事で使う用語

用語 一行での意味
PLC Programmable Logic Controller。生産設備の制御に使う産業用コントローラ
RS-232 / RS-485 シリアル通信の電気的な規格。RS-232 は 1 対 1、RS-485 は同じ線に複数台をぶら下げられます。RS-485 では誰がいつ送るかを決めないと衝突します
8N1 ポート設定の略記。データビット 8、パリティなし(None)、ストップビット 1 の組み合わせを指します
DTR / RTS 制御線。もともとは通信の準備完了や送信要求を伝える線ですが、実機ではこの変化を起動やモード切替の合図として使っていることがあります
フロー制御 送りすぎを防ぐ仕組み。RTS/CTS は制御線で、XON/XOFF はデータ中の特殊文字で止め・再開を伝えます
keepalive 通信相手が生きているかを確かめるために、定期的に送る軽いコマンド
フレーム 1 メッセージ分の byte 列。どこからどこまでが 1 フレームかは、プロトコル側で決めます
single writer 送信を 1 本のワーカーに集約する設計。どこからでも Write できる状態にしないという意味です

1. まず結論

先に、実務寄りの言い方でまとめておきます。

  • シリアル通信は 順序付き byte stream であって、メッセージ境界は勝手には付きません
  • Read(100) したからといって 100 byte ぴったり返るとは限りません
  • .NETDataReceived は、受信 byte ごとに発火するとは限らず、しかも UI スレッドでもありません
  • ReadLine() / WriteLine() は、相手が本当に行ベースのテキストプロトコルのときだけ素直です
  • タイムアウトは 1 個では足りません。openinter-byteresponsereconnect などの意味を分けたほうが安定します
  • 送信はどこからでも Write できるようにするより、single writer に寄せたほうが崩れにくいです
  • USB-シリアルでは、抜き差し、再列挙、COM 番号変化、再接続失敗を最初から前提にしたほうが平和です

要するに、シリアル通信アプリの難所は「ポートを開けるか」ではなく、byte 列をどう意味あるメッセージへ変換し、その周辺の時間と状態をどう管理するか です。

この記事の知識マップ

この記事は、装置連携などで使うC#のシリアル通信アプリで、たまにだけ壊れる不具合を防ぐための設計ガイドです。シリアル通信は順序付きのbyte streamにすぎずメッセージ境界を持たないため、DataReceivedイベントを1メッセージ到着の通知として扱うことは推奨されず、受信をいったん蓄積してからフレームパーサーで切り出す設計が推奨されます。送信は1本のワーカーに集約するsingle writerに寄せ、timeoutはopen・inter-byte・response・reconnect backoffという意味ごとに分けて設計します。フレームにリクエストIDが無いプロトコルでは応答のタイムアウト後に取り違えが起きうるため、単なる再接続ではなく受信バッファやparser状態まで作り直すセッション再生成が必要になります。

シリアル通信アプリの落とし穴の知識マップシリアル通信がメッセージ境界を持たないbyte streamであることを起点に、フレームパーサーとsingle writer設計がその境界と送信順序をどう扱うか、timeoutの種別分割とリクエストIDの有無が応答の取り違えとセッション再生成にどうつながるかを示す図です。利用する前提とする実装を担う用いるのは非推奨推奨される対応推奨される対応利用する利用する利用する利用する利用する原因になり得る防止する推奨される対応利用する前提とする推奨される対応前提とするシリアル通信フレームパーサー(蓄積してから切り出す設計)single writer(単一書き込み口)byte stream(順序付きbyte列)フレーム境界SerialPort.DataReceivedイベントCRCによるフレーム検証inter-byte timeoutタイムアウトの種別分割response timeoutbackoff付き再接続応答の取り違えリスクリクエストIDsession再生成(再接続設計)フロー制御・DTR/RTShex dumpを含む送受信ログ

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全18件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. シリアル通信は「メッセージ」ではなく「順序付き byte stream」

アプリ側から見ると、シリアル通信は「コマンドを 1 つ送り、応答を 1 つ受ける」ように見えます。ただし下の層では、実際には 順序付きの byte 列 が流れているだけです。

こちらが 1 回 Write した内容も、相手側ではこう見える可能性があります。

  • 1 回の Read で届く
  • 2 回に分かれて届く
  • ほかのデータと連結して届く

この前提を外すと、アプリ側で「今回の Read が今回の応答のはず」と思い込み始めます。この思い込みが、シリアル通信アプリの最初の地雷になりやすいです。

よくある思い込み 実際
Read(16) なら 16 byte ちょうど返る 到着状況やタイムアウト次第で途中までしか取れないことがあります
DataReceived = 1 メッセージ到着 イベントは byte ごと保証されず、UI スレッドでもありません
Write が返った = 相手が処理完了した 多くの場合、送信側がバッファへ積めたことに近いです
COM 一覧 = いま接続されている真実 列挙順は不定で、列挙結果が stale なこともあります

このため、シリアル通信では メッセージ境界をプロトコルとして自分で定義する 必要があります。固定長フレーム、区切り文字ベース、長さ + payload + checksum など、形は何でもよいのですが、曖昧なまま実装に入ると後でほぼ確実に苦しくなります。

3. 最初に決めるべきこと

シリアル通信アプリを作る前に、少なくともここに挙げるものは先に決めておきます。

3.1 フレーム境界

どの byte 列を 1 メッセージと見なすのかを決めます。固定長なのか、改行区切りなのか、長さ付きなのか、checksum / CRC があるのか。ここが曖昧だと、受信側は「まだ足りない」のか「壊れている」のか判断できません。

3.2 テキストか、バイナリか、その混在か

ASCII / UTF-8 の行プロトコルなのか、純バイナリなのか、両方混ざるのかを先に決めます。特に「コマンド部は文字列、payload はバイナリ、末尾だけ改行」のような混在は、どこまでを decode し、どこからを生 byte として扱うかを明示しないとすぐ境界が崩れます。

3.3 タイムアウトの意味

タイムアウトは 1 個ではなく、意味ごとに分けて考えたほうが安全です。

  • open timeout: ポートを開けるまで
  • inter-byte timeout: フレーム途中で byte が来ない時間
  • response timeout: コマンド発行から応答完了まで
  • reconnect backoff: 再接続の待機間隔

タイムアウトは「遅いときの保険」ではなく、状態遷移を進めるためのルール として持つと安定します。

3.4 フロー制御とライン状態

明示しておきたい設定はこのあたりです。

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR / RTS

ここを “8N1 でだいたい合う” で済ませると、相手装置によっては普通に止まります。

3.5 責務分離

誰が何を担当するのかを分けます。

  • 誰が読むのか
  • 誰が書くのか
  • 誰がパースするのか
  • 誰が業務状態へ反映するのか

シリアル通信は、UI と通信を混ぜるほど壊れやすくなります。

3.6 開始・停止・再接続の状態遷移

最低限、ClosedOpeningReadyWaitingResponseFaultReconnecting くらいの状態は設計に入れておくべきです。抜き差し直後、相手がまだ起動中かもしれませんし、前回の pending request を引きずってはいけないこともあります。

Open 要求open 成功 + 初期化シーケンス完了open 失敗 / 権限エラー / 初期化タイムアウトコマンド送信対応する応答フレームを受信response timeoutI/O エラー / 抜線検出pending request を fail させて backoff 開始backoff 経過上限到達 / 手動停止Close 要求ClosedOpeningReadyFaultWaitingResponseReconnecting

この図で大事なのは、Fault から Ready へ直接戻る線がないことです。 異常のあとは必ず ReconnectingOpening を通り、受信バッファ、parser 状態、pending request、初期化シーケンスを作り直してから Ready に戻ります。ここを近道すると、4.7 の「Open() のやり直しだけで再接続した気になる」に落ちます。

3.7 ログと調査性

後から一番困るのは、ほぼここです。最低限、open / close / reopen の時刻、使用したポート設定、送受信フレームの hex dump、checksum / CRC エラー、frame timeout / response timeout、再接続理由は残したいところです。

4. よくある落とし穴

4.1 1 回の Read = 1 メッセージ だと思う

一番多いのはこれです。たとえば相手がヘッダ、長さ、payload、CRC からなるフレームを返すとします。このとき Read(buffer, 0, expectedLength) を 1 回呼んで、その戻り値をそのまま 1 フレームだと思い込むと、途中受信で簡単に壊れます。

よくある壊れ方はこの 3 つです。

  • 長さだけ読めて payload がまだ来ていない
  • 1 フレーム半だけ届いて、後半が次回の Read に回る
  • 2 フレームがまとめて届いて、最初の 1 個だけ処理して残りを捨てる

絵にすると、装置が送った並びと Read が返す並びが一致しない、というだけの話です。

装置が送ったもの
    [--- フレーム1 ---][--- フレーム2 ---]

パターン1: 途中までしか届かない
    1 回目の Read -> [ STX ][ LEN ]                     <- payload がまだ来ていない
    2 回目の Read -> [ payload ][ CRC ][--- フレーム2 ---]

パターン2: 1 フレーム半だけ届く
    1 回目の Read -> [--- フレーム1 ---][ フレーム2 の前半 ]
    2 回目の Read -> [ フレーム2 の後半 ]

パターン3: 2 フレームまとめて届く
    1 回目の Read -> [--- フレーム1 ---][--- フレーム2 ---]   <- 1 個だけ処理して残りを捨てがち

3 パターンとも、「壊れている」のではなく「区切り位置が Read 回数と一致していないだけ」 です。ここを取り違えて、届いた byte 数が想定と違うからエラー扱いにする実装にすると、正常な通信をエラーとして数え始めます。

対策は単純で、受信はまず蓄積し、そこから parser がフレームを切り出す 形に分けることです。骨格コードは 5.1 に置きました。

4.2 DataReceived をそのまま業務イベントにする

.NETSerialPort.DataReceived は便利そうに見えますが、これを「1 メッセージ届いた通知」と思うと危険です。実務上は、DataReceived を「何か来たらしい」の通知と割り切ってハンドラの中では重い処理をせず、UI 更新は必ず UI スレッドへ戻します。

4.3 どこからでも Write してよいと思う

UI のボタン、監視タイマー、再接続処理、keepalive がそれぞれ直接 Write する構成は崩れやすいです。シリアルは byte stream なので、設計次第ではコマンドの割り込みや応答待ち中の追い打ち送信が起きます。特に request-response 型や RS-485 系では、single writer に寄せたほうがかなり安定します。

4.4 ReadLine() / WriteLine() で全部通す

行ベースのテキストプロトコルなら ReadLine() / WriteLine() は便利です。ただし便利なのは、本当に 行プロトコルのときだけ です。NewLine の不一致、payload 中の改行、文字コード差、バイナリ混在などがあると、すぐ境界が壊れます。

4.5 タイムアウトを設計せず、既定のままにする

同期 read を安易に置くと、普通に無限待ちになります。さらに厄介なのは、設定した timeout がすべての読み方に効くとは限らないことです。UI スレッドで同期 read する、1 個の timeout だけで全部を表現しようとする、retry だけ増やす、といった実装は詰まりやすいです。

4.6 RTS/CTS、XON/XOFF、DTR/RTS を軽く見る

ハンドシェイクや制御線は、実機相手ではかなり効きます。設定不一致があると、送信がたまに止まる、一定量を超えると取りこぼす、開いた直後だけ挙動が違う、といった症状になりがちです。実機によっては DTR/RTS の変化を、起動やモード切替の意味で見ていることもあります。

4.7 Open() のやり直しだけで再接続した気になる

特に USB-シリアルでは、一時的にポートが消える、旧ハンドルが無効になる、前回の pending request が意味を失う、といったことが普通に起こります。再接続は少なくとも、session 無効化、pending request の fail、reader / writer 停止、backoff 後の reopen、装置初期化の再実行までまとめて扱ったほうが安全です。

4.8 COM ポート列挙を真実だと思う

GetPortNames() は便利ですが、一覧に出たことと opening できることは同じではありません。前回の COM7 を盲信する、列挙結果の先頭を自動選択する、一覧に出た時点で有効とみなす、といった実装は運用で困りやすいです。

4.9 送受信ログが薄い

TimeoutExceptionIOExceptionPort closed だけでは、ほぼ何も分かりません。送受信時刻、port profile、送受信 hex dump、parser error、どの request に対する response なのか、reconnect の契機が分かるようにしておくと、切り分けはかなり進みます。

書式を先に決めておくと、あとで grep も差分比較もできます。たとえば、こういう 1 行フォーマットにしておきます。

2026-03-19T10:23:41.512+09:00  COM3  TX  req=00A7  len=5   02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00  COM3  RX  req=00A7  len=3   02 01
2026-03-19T10:23:41.531+09:00  COM3  RX  req=00A7  len=6   10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=00A7  frame=02 01 10 00 4B  result=OK
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=-     frame=02 01 11        result=INCOMPLETE  need=2
2026-03-19T10:23:43.540+09:00  COM3  ERR req=00A8  reason=response-timeout  elapsed=2008ms
2026-03-19T10:23:43.541+09:00  COM3  STATE Ready -> Fault  reason=response-timeout

ここでの狙いは 3 つです。

  • RX の行と PARSE の行を分ける。 RX は「何 byte 届いたか」、PARSE は「何フレーム切り出せたか」です。上の例では 2 回目と 3 回目の RX にまたがって 1 フレームが届き、余りが次のフレームの先頭になっています。この 2 種類を混ぜて記録すると、4.1 の分割ずれが起きているのかどうかが後から判定できません
  • req= で送受信を突き合わせられるようにする。 どの応答がどのコマンドに対応するのかは、あとからログだけでは復元できません
  • 状態遷移を 1 行で残す。 Ready -> Fault のような遷移と理由が残っていると、再接続の契機がそのまま追えます

hex dump は容量を食うので、生ログはリングバッファで一定量だけ、要約ログは長期保存 という二段構えにするのが現実的です。

5. ベストプラクティス

一番効くのは、責務を分けることです。

  • reader: port から byte 列を読むだけ
  • writer: outbound queue から順番に書くだけ
  • parser: byte 列から frame を切り出すだけ
  • protocol: request と response の対応や checksum を扱う
  • app state: 業務状態を更新するだけ

受信処理は、Read の戻り単位をそのまま業務単位にせず、いったんバッファへ蓄積してから parser が frame を切り出す構成が安定します。送信は 1 本の worker に集約し、実際の Write を single writer に寄せたほうが順序ズレを減らせます。

タイムアウトも、ひとつの数字で済ませるより、open、inter-byte、response、reconnect の意味ごとに分けたほうが原因の切り分けがしやすくなります。port 設定はその場のコード値より profile として持ち、startup 時にログへ出しておくと現地調査がかなり楽になります。

再接続は、単なる reopen ではなく session 再生成 と考えたほうが安定します。受信バッファ、parser 状態、pending request、初期化シーケンス、readiness 判定まで含めて作り直すと、「たまにだけ壊れる」再接続バグを減らしやすくなります。

最後に、生ログと要約ログを両方持つのがおすすめです。raw hex dump や open / close の履歴は調査に強く、request id や retry 回数の要約は運用に強いです。

ここから、いちばん効く 2 か所だけ骨格コードを置いておきます。.NET 8 / C# 12 で、System.IO.Ports パッケージを参照している前提です。

5.1 受信: 蓄積してから切り出す

例として、STX(0x02)LEN(1 byte)payload(LEN byte)CRC16(2 byte, リトルエンディアン) というフレームを想定します。 形は何でもよいのですが、Read の戻り単位ではなく、この定義でフレームを切る ところが本題です。

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;

public static class Crc16Modbus
{
    // CRC-16/MODBUS: 初期値 0xFFFF、多項式 0xA001 の右送り
    public static ushort Compute(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;
        foreach (var b in data)
        {
            crc ^= b;
            for (var i = 0; i < 8; i++)
            {
                crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
        }

        return crc;
    }
}

public static class Frame
{
    public const byte Stx = 0x02;
    public const int HeaderLength = 2;   // STX + LEN
    public const int CrcLength = 2;

    public static byte[] Build(ReadOnlySpan<byte> payload)
    {
        // LEN は 1 byte なので、256 byte 以上はキャストで折り返す。
        // それでも payload はまるごとコピーされるため、受け手は切り詰められた
        // 長さでフレームを切り、payload の途中を CRC と読む。以降の
        // フレーム境界も総崩れになる。分割するか LEN を 2 byte にするかは
        // プロトコルの決めごとなので、ここでは弾くだけにする
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"1 フレームの payload は {byte.MaxValue} byte までです(LEN が 1 byte のため)。");
        }

        var frame = new byte[HeaderLength + payload.Length + CrcLength];
        frame[0] = Stx;
        frame[1] = (byte)payload.Length;
        payload.CopyTo(frame.AsSpan(HeaderLength));

        var body = frame.AsSpan(0, frame.Length - CrcLength);
        BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
        return frame;
    }
}

public sealed class FrameParser
{
    private readonly List<byte> _buffer = new();

    /// <summary>3.3 の inter-byte timeout。組み立て中のフレームを諦めるまでの時間。</summary>
    private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);

    /// <summary>いま組み立て中の候補が、いつから待ち状態になったか(単調増加の値)。</summary>
    private long _pendingSince;

    /// <summary>CRC が合わずに捨てたフレームを通知する。ログへ落とすために必ず購読する。</summary>
    public event Action<byte[]>? FrameDiscarded;

    /// <summary>組み立てを諦めて再同期したことを通知する。ここが増え続けるなら結線か設定を疑う。</summary>
    public event Action<int>? Resynchronized;

    /// <summary>受信した byte 列を溜め込み、切り出せたフレームだけを返す。</summary>
    public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
    {
        foreach (var b in received)
        {
            _buffer.Add(b);
        }

        var frames = new List<byte[]>();

        while (true)
        {
            // 1. 先頭が STX になるまで捨てる。ノイズや前フレームの残りをここで吸収する
            var stxIndex = _buffer.IndexOf(Frame.Stx);
            if (stxIndex < 0)
            {
                _buffer.Clear();
                _pendingSince = 0;   // 候補が無くなったので待ち時間の計測もやめる
                break;
            }

            if (stxIndex > 0)
            {
                // 候補の先頭が変わった = 別のフレームの組み立てを始める
                _buffer.RemoveRange(0, stxIndex);
                _pendingSince = 0;
            }

            // 2. 長さを読めるところまで届いているか
            if (_buffer.Count < Frame.HeaderLength)
            {
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // 「壊れている」ではなく「まだ足りない」
            }

            int payloadLength = _buffer[1];
            int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;

            // 3. フレーム 1 個分そろっているか
            if (_buffer.Count < frameLength)
            {
                // 「まだ足りない」と「LEN がノイズで壊れている」は、この時点では
                // 区別が付かない。ノイズや偽の STX で LEN が 255 になると、
                // その後に届く正しいフレームまで payload として吸い込み続け、
                // 259 byte そろって CRC で落ちるまで何も上がってこない。
                // 通信量の少ない機器では、これが数分の無反応に見える。
                // 待ちに上限を置き、超えたら候補を捨てて STX を探し直す
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // ここで抜けて、次の受信を待つ
            }

            var frame = _buffer.GetRange(0, frameLength).ToArray();
            _buffer.RemoveRange(0, frameLength);
            _pendingSince = 0;

            // 4. CRC が合わないものは捨てる。捨てたことは必ず外へ出す
            var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
            if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
            {
                frames.Add(frame);
            }
            else
            {
                // ここでフレーム 1 個分まとめて捨てるか、STX 1 byte だけ捨てて読み直すかは設計判断。
                // 前者は単純、後者は LEN 自体がノイズだった場合に強い。どちらにするか決めて明文化する。
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// 組み立て中の候補が AssemblyTimeout を超えていたら、先頭の STX を 1 byte だけ捨てる。
    /// 捨てたら true を返し、呼び出し側は次の STX から読み直す。
    /// フレームごと捨てないのは、本物の STX がこの候補の中にいる可能性があるため。
    /// </summary>
    private bool GiveUpOnStaleCandidate()
    {
        if (_pendingSince == 0)
        {
            // 待ち始めた瞬間。壁時計だと NTP 同期で飛ぶので、単調増加の値で測る
            _pendingSince = Stopwatch.GetTimestamp();
            return false;
        }

        if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
        {
            return false;
        }

        _buffer.RemoveAt(0);
        _pendingSince = 0;
        Resynchronized?.Invoke(_buffer.Count);
        return true;
    }
}

GiveUpOnStaleCandidate が 3.3 で挙げた inter-byte timeout の実体です。これが無いと、ノイズや偽の STXLEN が大きな値(たとえば 255)に化けたとき、parser はそれを「まだ足りない」と扱い続けます。その後に届く正しいフレームまで、化けた payload の一部として吸い込み、259 byte そろって CRC で落ちるまで何も上がってきません。通信量の少ない機器なら、これが数分の無反応として見えます。捨てるのを STX 1 byte だけにしているのは、本物の STX が候補の中に埋まっている可能性があるからです。

2点、前提を書いておきます。この時間切れは Append が呼ばれたときにだけ判定されます。回線が完全に無音になった場合は parser 側では何も起きないので、そこは呼び出し側の response timeout(5.2)で拾います。もう1つ、AssemblyTimeout の値はボーレートとフレーム長から決めてください。1 byte の伝送時間 × 想定最大フレーム長に余裕を足した値が下限で、これより短いと正常なフレームを途中で捨てます。Resynchronized の発火数はログに出しておくと、結線やボーレート設定を疑う材料になります。

読む側は、ポートから読んで parser に渡すだけ にします。ここで業務処理を書き始めると、Read の戻り単位が業務単位に化けます。

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public sealed class SerialReader
{
    private readonly SerialPort _port;
    private readonly FrameParser _parser;
    private readonly byte[] _readBuffer = new byte[4096];

    public SerialReader(SerialPort port, FrameParser parser)
    {
        _port = port;
        _parser = parser;
    }

    public event Action<byte[]>? FrameReceived;

    public async Task RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            int count;
            try
            {
                count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            if (count <= 0)
            {
                continue;
            }

            foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
            {
                FrameReceived?.Invoke(frame);
            }
        }
    }
}

DataReceived を使っていないのは意図的です。4.2 のとおり「何か来たらしい」以上の意味を持たないので、読み取りループを自分で持つほうが、状態と timeout を管理しやすくなります。

5.2 送信: single writer に寄せる

送信側は、どこからでも Write できる状態を作らない のが肝です。 キューに積むところは誰から呼ばれてもよく、実際に Write するのは 1 本のワーカーだけ、という形にします。

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public sealed class SingleWriter
{
    private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);

    /// <summary>送信キューの上限。装置1往復ぶんの時間 × 許容する待ち行列で決める。</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // 上限なしにしない。UI・タイマー・ワーカーが装置の往復より速く積むと、
    // フレームと TaskCompletionSource が際限なく溜まり、装置は応答している
    // のにメモリだけが増え続ける。上限を決めて、あふれたら送り手へ返す
    private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
        new BoundedChannelOptions(QueueCapacity)
        {
            // 満杯なら TryWrite が false を返す。呼び出し側は「いま詰まっている」
            // ことを即座に知れる。DropOldest は使わない ── 積んだ側は
            // Task を待っているので、黙って捨てると永久に返らなくなる
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,
        });

    private Outbound? _inFlight;

    public SingleWriter(SerialPort port, TimeSpan responseTimeout)
    {
        _port = port;
        _responseTimeout = responseTimeout;
    }

    /// <summary>UI からでもタイマーからでも呼んでよい。実際の Write はワーカー 1 本だけが行う。</summary>
    public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
    {
        var item = new Outbound(
            Frame.Build(payload),
            new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));

        if (!_queue.Writer.TryWrite(item))
        {
            // 満杯か、ワーカーが停止済み。どちらの場合も「積めなかった」ことを
            // 呼び出し側へ返す。黙って捨てると、待っている Task が永久に返らない
            item.Completion.TrySetException(new InvalidOperationException(
                $"送信キューへ積めませんでした(上限 {QueueCapacity} 件、またはワーカー停止済み)。"));
        }

        return item.Completion.Task;
    }

    /// <summary>parser がフレームを切り出したら呼ぶ。応答待ちの 1 件へ結び付ける。</summary>
    public void OnFrameReceived(byte[] frame)
    {
        var pending = Interlocked.Exchange(ref _inFlight, null);
        pending?.Completion.TrySetResult(frame);
    }

    public async Task RunAsync(CancellationToken token)
    {
        try
        {
            await foreach (var item in _queue.Reader.ReadAllAsync(token))
            {
                Interlocked.Exchange(ref _inFlight, item);
                try
                {
                    await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
                }
                catch (Exception ex)
                {
                    // 抜線・ポートのクローズ・キャンセルはここで飛んでくる。
                    // この1件を完了させずに抜けると、SendAsync を await している
                    // 呼び出し側が永久に待つ
                    Interlocked.Exchange(ref _inFlight, null);
                    item.Completion.TrySetException(ex);
                    throw;
                }

                // ここで応答まで待つから、次のコマンドが割り込まない
                var timeout = Task.Delay(_responseTimeout, token);
                var finished = await Task.WhenAny(item.Completion.Task, timeout);

                if (finished != item.Completion.Task)
                {
                    // 停止を要求されたときも Task.Delay はキャンセルされて先に終わる。
                    // ここを見ないと、正常な終了処理がタイムアウト扱いになり、
                    // 呼び出し側には TimeoutException が返る
                    token.ThrowIfCancellationRequested();

                    // WhenAny が timeout を選んだあとに応答が届くことがある。
                    // そのとき OnFrameReceived は既に _inFlight を取り、この1件を
                    // 成功で完了させている。取れなかったら応答の勝ちで、ここで
                    // TrySetException を撃っても不発に終わる。それに気付かず
                    // throw まで進むと、呼び出し側は結果を受け取っているのに
                    // ワーカーだけが落ちる。取り合いは CompareExchange で決める
                    if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
                    {
                        // 応答が勝った。完了は OnFrameReceived が入れる(入れた直後)
                        await item.Completion.Task;
                        continue;
                    }

                    item.Completion.TrySetException(new TimeoutException("応答がありませんでした。"));

                    // タイムアウトしたら、この接続はもう信用できない。
                    // 「諦めて次を送る」で済ませてはいけない理由は下に書いたとおりで、
                    // このプロトコルにはリクエストIDが無いため、遅れて届いたAの応答が
                    // 次のBの応答として結び付いてしまう。3.6 の状態遷移図どおり
                    // Fault へ落として、セッションを作り直す
                    throw new TimeoutException("応答がないため、セッションを作り直します。");
                }
            }
        }
        finally
        {
            // ワーカーが止まる理由が何であれ、待たせている件は必ず終わらせる。
            // 応答待ちの1件と、キューに積まれたまま送られなかった分の両方
            var stopped = new OperationCanceledException("送信ワーカーが停止しました。");
            Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
            _queue.Writer.TryComplete();
            while (_queue.Reader.TryRead(out var pending))
            {
                pending.Completion.TrySetException(stopped);
            }
        }
    }
}

タイムアウトでワーカーごと止めているのは、乱暴に見えて必要な処置です。このフレームにはリクエストIDが入っていません。そのため受け手は「いま来たフレームがどのコマンドへの応答か」を判別できず、OnFrameReceived は応答待ちの1件へ機械的に結び付けるしかありません。

ここでタイムアウト後もそのまま次を送ると、こうなります。

  1. コマンドAを送る。既定の時間内に応答が来ないのでタイムアウトにする
  2. 次のコマンドBを送る
  3. 遅れて届いたAの応答が、Bの応答として呼び出し側に渡る

呼び出し側から見ると、Bを送ったのにAの値が返ってきます。値の形式は正しいので検査も通ってしまい、いちばん見つけにくい壊れ方です。3.6 の状態遷移図で Fault から Ready へ直接戻る線を引かなかったのは、この経路があるからです。タイムアウトは「1件の失敗」ではなく「この接続はもう信用できない」という判断で、受信バッファに何が残っているか分からない状態からの復帰は、ポートを閉じて開き直すセッション再生成でしか保証できません。

プロトコル側に手を入れられるなら、フレームにリクエストIDを持たせて応答と突き合わせるほうが本質的な解決です。そうすれば遅れて届いた応答は「知らないIDだから捨てる」で済み、タイムアウト1件のたびに接続を作り直す必要がなくなります。

最後に組み立てます。ポート設定は profile として 1 か所に置き、起動時にログへ出す ところまでが 3.4 の話です。

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

// 3.4 で決めた設定を、その場の値ではなくまとめて置く
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
    Handshake = Handshake.None,
    DtrEnable = true,
    RtsEnable = true,
    ReadTimeout = 500,
    WriteTimeout = 500,
};

Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
                  $"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();

var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);

// 定義したイベントは必ず購読する。ここを忘れると、捨てたフレームも応答も表に出ない
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;

using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);

var request = new byte[] { 0x10, 0x00 };
try
{
    var response = await writer.SendAsync(request);
    Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
    // 送信が失敗しても、ワーカーは必ず止めてから抜ける。ここを飛ばすと、
    // using の破棄で SerialPort が閉じられたあとも reader/writer が
    // そのストリームを触り続け、しかもその例外は誰も観測しない
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // 停止要求による終了。ここは正常系として扱う
    }
    catch (Exception ex)
    {
        // ワーカー側の失敗。ここで投げ直すと、本来の失敗理由
        // (SendAsync の例外)を覆い隠すので、記録に留める
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

cts.Cancel()Task.WhenAllfinally に入っているのは、書き方の好みではありません。シリアル通信で SendAsync が失敗するのは例外ではなく日常です ── 機器が応答しない、ケーブルが抜けた、書き込みがタイムアウトした。そのとき素直に上へ抜けると、ワーカーを止めないまま using の破棄に到達します。SerialPort が閉じられたあとも reader / writer はそのストリームを触りに行き、そこで出た例外は誰も観測しません。常駐アプリなら、失敗した操作のワーカーだけが生き残るという形で少しずつ積み上がります。finally 側の例外を投げ直していないのは、本来の失敗理由(SendAsync の例外)を覆い隠さないためです。

この形にしておくと、後から足したくなるもの、たとえば retry、keepalive、reconnect が、すべて キューに積む側ワーカー側 のどちらかに収まります。Write を直接呼ぶ場所が増えないので、順序ずれの原因が増えません。

6. まず見るチェックリスト

  • メッセージ境界は明文化されているか
  • 受信は byte 蓄積 → frame 切り出しになっているか
  • DataReceived をメッセージ到着扱いしていないか
  • UI スレッドで同期 I/O していないか
  • 送信は single writer になっているか
  • timeout が 1 個ではなく意味ごとに分かれているか
  • Handshake / DTR / RTS が明示されているか
  • reconnect で session を作り直しているか
  • raw hex dump を残しているか
  • 実機抜き差しや途中切断を試験しているか

この中でいくつも怪しい項目があるなら、本番投入前に一度立ち止まる価値があります。

7. まとめ

最後に、要点だけもう一度並べておきます。

  • シリアル通信はメッセージではなく byte stream
  • Read 単位とメッセージ単位は一致しない
  • 境界はプロトコルとして定義する必要がある
  • DataReceived をそのまま業務イベントにすると崩れやすい
  • 送受信は責務を分離し、送信は single writer に寄せる
  • timeout は意味ごとに分割し、再接続は session 単位で設計する
  • raw hex dump を含むログが、後の調査をかなり楽にする

つまり、シリアル通信アプリでは ポートを開けること より、byte 列をどう解釈し、時間と状態をどう制御するか のほうがずっと大事です。ここを最初に分けて設計するだけで、「たまにだけ壊れる」タイプの通信不具合はかなり減ります。

8. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

シリアル通信でRead(16)を呼べば16バイトちょうど受信できますか?
できるとは限りません。シリアル通信は順序付きのbyte streamであって、メッセージ境界は勝手には付かないからです。こちらが1回Writeした内容も、相手側では2回に分かれて届いたり、ほかのデータと連結して届いたりします。長さだけ読めてpayloadがまだ来ていない、1フレーム半だけ届く、2フレームまとめて届く、といった壊れ方が典型です。対策は、受信はまずバッファへ蓄積し、そこからparserがフレームを切り出す形に分けることです。
.NETのSerialPort.DataReceivedイベントを使うときの注意点は何ですか?
DataReceivedは受信byteごとに発火するとは限らず、UIスレッドでもありません。これを「1メッセージ届いた通知」と思うと危険です。実務では「何か来たらしい」程度の通知と割り切り、ハンドラの中では重い処理をせず、UI更新は必ずUIスレッドへ戻します。受信byte列はいったん蓄積してからparserでフレームを切り出す構成が安定します。
シリアル通信のタイムアウトはどう設計すればよいですか?
タイムアウトは1個では足りず、意味ごとに分けたほうが安定します。ポートを開けるまでのopen timeout、フレーム途中でbyteが来ない時間のinter-byte timeout、コマンド発行から応答完了までのresponse timeout、再接続の待機間隔であるreconnect backoffです。タイムアウトは遅いときの保険ではなく、状態遷移を進めるためのルールとして持つと安定します。同期readを既定のまま安易に置くと、普通に無限待ちになる点にも注意が必要です。
USB-シリアル変換でケーブル抜き差し後に復帰しないのはなぜですか?
USB-シリアルでは、一時的にポートが消える、旧ハンドルが無効になる、COM番号が変わる、前回のpending requestが意味を失う、といったことが普通に起こるからです。Open()のやり直しだけでは再接続として足りません。session無効化、pending requestのfail、readerとwriterの停止、backoff後のreopen、装置初期化シーケンスの再実行までまとめて扱う「session再生成」として設計すると、たまにだけ壊れる再接続バグを減らせます。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る