序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結

· · 序列通訊, RS-232, C#, .NET, Windows 開發, 設備整合

設備整合、儀器、PLC、條碼掃描器、USB-序列轉換器。 序列通訊看起來是舊技術,但在 Windows 應用的現場仍然相當普遍。

稍微危險的一點是,序列通訊只要有 一條 COM port一次 Read / Write 就能開始。連線測試通常一下就能通,可是進入正式環境後,常會出現以下症狀。

  • 偶爾命令和回應錯開
  • 一天卡一次
  • USB 拔插之後才無法恢復
  • UI 偶爾卡住
  • 日誌裡只剩 “Timeout”

序列通訊應用真正困難的並不是送收 API 本身,而是 邊界、逾時、狀態轉移、重連、可觀測性

1. 先下結論

用貼近實務的說法總結,應該掌握的重點如下。

  • 序列通訊是 有順序的 byte stream,訊息邊界不會自動出現
  • 呼叫 Read(100) 不代表一定回來 100 bytes
  • .NETDataReceived 不一定每個 byte 都觸發,而且 不是在 UI 執行緒
  • ReadLine() / WriteLine() 只有在對方真的是行式文字協定時才好用
  • 逾時只有一個是不夠的,把 openinter-byteresponsereconnect 的意思拆開會更穩
  • 送出端與其讓任何地方都能 Write,不如收斂到 single writer,比較不容易崩
  • 在 USB-序列上,一開始就要把拔插、重新列舉、COM 編號變動、重連失敗都當前提

總之,序列通訊應用真正困難的地方不是「能不能打開 port」,而是 如何把 byte 序列轉換成有意義的訊息,以及如何管理周邊的時間與狀態

2. 序列通訊不是「訊息」,是「有順序的 byte stream」

從應用端看,序列通訊像是「發一條命令、收一條回應」。但在底層,流動的其實是 有順序的 byte 序列

也就是說,我方一次 Write 的內容,在對方看來可能會變成這樣。

  • 一次 Read 全收到
  • 分兩次收到
  • 和其他資料串在一起收到

離開這個前提的話,應用端就會開始覺得「這次的 Read 應該就是這次的回應」。這個先入為主的認知,往往是序列通訊應用的第一顆地雷。

常見誤解 實際情況
Read(16) 就會剛好回 16 bytes 視到達狀況與逾時,可能只拿到中間為止
DataReceived = 一條訊息到達 事件不保證按 byte 觸發,也不在 UI 執行緒
Write 回傳 = 對方處理完成 多半比較接近「送出端成功塞進 buffer」
COM 列表 = 目前連線的事實 列舉順序不保證,結果也可能是 stale

因此在序列通訊裡,訊息邊界必須由自己以協定定義。固定長度框、分隔字元、長度 + payload + checksum,形式都可以,但若模糊地進入實作,後期幾乎一定會很痛苦。

3. 一開始就要決定的事情

做序列通訊應用前,至少先決定以下這些事項會比較保險。

3.1 封包邊界

決定哪一段 byte 序列算作一條訊息。固定長度?以換行分隔?帶長度?有 checksum / CRC 嗎?這裡模糊的話,接收端無法判斷是「還不夠」還是「壞了」。

3.2 文字、二進位、還是混合

要先決定是 ASCII / UTF-8 的行協定、純二進位、還是混合。特別是「命令部是字串,payload 是二進位,末端才換行」這種混合,若不明確標出哪一段要解碼、哪一段當成原始 byte,邊界很快就會崩。

3.3 逾時的含意

逾時不是一個就夠,要依語意分別思考。

  • open timeout:打開 port 之前
  • 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 也不能帶到這一輪。

3.7 日誌與可調查性

之後最會讓人頭痛的幾乎都是這裡。至少把下列這些留下:open / close / reopen 的時間、使用的 port 設定、送收封包的 hex dump、checksum / CRC 錯誤、frame timeout / response timeout、重連理由。

4. 常見陷阱

4.1 誤以為「一次 Read = 一條訊息」

最常見的就是這個。例如對方會回一個由 header、length、payload、CRC 組成的封包。如果只呼叫一次 Read(buffer, 0, expectedLength),就把回傳值當作整個封包,很容易在半途收到時就壞掉。

常見的壞法有:

  • 只讀到 length,payload 還沒來
  • 只收到 1.5 個封包,剩下的留給下一次 Read
  • 2 個封包一起到,只處理第一個就丟掉後面

對策很單純,先把接收資料累積起來,再由 parser 從中切出封包,兩段分開。

4.2 把 DataReceived 當成業務事件

.NETSerialPort.DataReceived 看起來很方便,但把它當作「一條訊息到了」的通知相當危險。實務上應該把 DataReceived 視為「好像有東西來了」的通知,handler 內不要做太重的處理比較安全。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、用一個 timeout 覆蓋所有情境、只靠增加 retry,這些實作都容易卡死。

4.6 輕看 RTS/CTS、XON/XOFF、DTR/RTS

握手與控制線在面對實體設備時相當關鍵。設定不一致可能導致送出偶爾卡住、超過某量就漏收、剛打開時行為不一樣等症狀。也有些設備把 DTR/RTS 的切換當成啟動或模式切換的訊號。

4.7 以為重開 Open() 就叫重連

特別是 USB-序列上,port 可能暫時消失、舊 handle 失效、上次的 pending request 也失去意義,都很常見。重連至少要把 session 失效、把 pending request 標為失敗、停止 reader / writer、backoff 後再 reopen、重新執行裝置初始化通通包起來處理比較保險。

4.8 把 COM port 列舉當作事實

GetPortNames() 很方便,但列表上有和能 open 不是同件事。盲信上次的 COM7、自動選列表第一項、列表裡出現就當成有效,這些實作在運維上很容易踩雷。

4.9 送收日誌太薄

光有 TimeoutExceptionIOExceptionPort closed,幾乎甚麼都看不到。把送收時間、port profile、送收 hex dump、parser 錯誤、是哪一條 request 對應的 response、reconnect 的觸發原因都記下來,切分問題就會明顯容易很多。

5. 最佳實務

最有效的是把職責分開。

  • reader:只從 port 讀 byte
  • writer:只從 outbound queue 依序寫出
  • parser:只從 byte 序列切出 frame
  • protocol:處理 request/response 配對與 checksum
  • app state:只更新業務狀態

接收處理不要把 Read 的回傳單位直接當成業務單位,先把資料累積進 buffer,再由 parser 切出 frame 比較穩。送出集中在一條 worker,實際的 Write 也收斂到 single writer,可以減少順序錯亂。

逾時也別用一個數字打天下,拆成 open、inter-byte、response、reconnect 幾種語意,問題排查會容易很多。port 設定建議以 profile 的形式保存,而不是散落在程式碼各處,並在 startup 時輸出到日誌,現場調查會輕鬆很多。

重連不要當成單純的 reopen,用 session 重建的概念比較穩。把接收 buffer、parser 狀態、pending request、初始化流程、就緒判定一併重做,能明顯減少「偶爾才壞」的重連 bug。

最後建議同時保留原始日誌與摘要日誌。原始 hex dump 與 open / close 歷史對調查很有用,request id 與 retry 次數的摘要對運維有用。

6. 先看的 checklist

  • 訊息邊界是否已經書面化
  • 接收是否為 byte 累積 → 切 frame 的結構
  • 有沒有把 DataReceived 當成訊息到達
  • UI 執行緒是否在做同步 I/O
  • 送出是否已收斂到 single writer
  • timeout 是否依語意分開而不是只用一個
  • Handshake / DTR / RTS 是否有明確設定
  • reconnect 是否是以重建 session 的概念做
  • 是否保留 raw hex dump
  • 是否測試過實體設備拔插與中途斷線

如果好幾項都可疑,進正式環境前先整理一次比較保險。

7. 總結

要記住的重點整理如下。

  • 序列通訊不是訊息,而是 byte stream
  • Read 的單位與訊息單位不會一致
  • 邊界需要用協定定義
  • DataReceived 直接當成業務事件很容易崩
  • 送收職責分離,送出收斂到 single writer
  • timeout 依語意分割,重連以 session 為單位設計
  • 含 raw hex dump 的日誌會讓後續調查大幅輕鬆

也就是說,序列通訊應用真正重要的並不是 打開 port,而是 如何解讀 byte 序列,以及如何控制時間與狀態。只要一開始就把這些拆開設計,「偶爾才壞」類型的通訊問題就能減少很多。

8. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽