序列通訊應用的陷阱 - 先釐清 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 .NET的DataReceived不一定每個 byte 都觸發,而且 不是在 UI 執行緒ReadLine()/WriteLine()只有在對方真的是行式文字協定時才好用- 逾時只有一個是不夠的,把
open、inter-byte、response、reconnect的意思拆開會更穩 - 送出端與其讓任何地方都能
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 流控與線路狀態
下列設定建議明確標出。
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
這裡用「8N1 大概合」帶過,遇到某些設備就會平凡地卡死。
3.5 職責分工
把以下職責分開給誰負責。
- 誰在讀
- 誰在寫
- 誰在剖析
- 誰在更新業務狀態
序列通訊把 UI 和通訊混在一起,就越容易壞。
3.6 啟動/停止/重連的狀態轉移
至少把 Closed、Opening、Ready、WaitingResponse、Fault、Reconnecting 這幾種狀態設計好比較保險。剛拔插完對方可能還在啟動中,上一輪未完成的 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 當成業務事件
.NET 的 SerialPort.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 送收日誌太薄
光有 TimeoutException、IOException、Port closed,幾乎甚麼都看不到。把送收時間、port profile、送收 hex dump、parser 錯誤、是哪一條 request 對應的 response、reconnect 的觸發原因都記下來,切分問題就會明顯容易很多。
5. 最佳實務
最有效的是把職責分開。
reader:只從 port 讀 bytewriter:只從 outbound queue 依序寫出parser:只從 byte 序列切出 frameprotocol:處理 request/response 配對與 checksumapp 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. 參考資料
- Microsoft Learn,
SerialPort.DataReceivedEvent - Microsoft Learn,
SerialPort.ReadMethod - Microsoft Learn,
SerialPort.ReadTimeoutProperty - Microsoft Learn,
SerialPort.BaseStreamProperty - Microsoft Learn,
SerialPort.NewLineProperty - Microsoft Learn,
HandshakeEnum - Microsoft Learn,
SerialPort.DtrEnableProperty - Microsoft Learn,
SerialPort.RtsEnableProperty - Microsoft Learn,
SerialPort.GetPortNamesMethod - Microsoft Learn,
SerialPortClass - Microsoft Learn,
COMMTIMEOUTSstructure - Microsoft Learn,
DCBstructure - Microsoft Learn,
CreateFilefunction - pySerial API, Serial API Reference
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
將 .NET Framework 遷移到 .NET 之前該確認的事 - 在著手前就決定勝負的實戰檢核表
整理將 .NET Framework 業務應用程式遷移到現代 .NET 之前必須先盤點的論點。涵蓋著地版本、Windows 專用前提的取捨、不再支援的 API、共用函式庫切法、第三方部件、運營與 CI/CD,幫助在著手前釐清範圍並降低遷移風險。
用 Native AOT 把 C# 做成原生 DLL 的方法 - 用 UnmanagedCallersOnly 從 C/C++ 呼叫
從現有 C/C++ 應用程式以 in-process 方式呼叫 C# 邏輯時,本文示範以 Native AOT 將類別庫發佈為原生 DLL,並用 UnmanagedCallersOnly 公開 cdecl 進入點。透過 handle、錯誤碼與扁平 C ABI 設計交界面,整...
把 Generic Host / BackgroundService 帶進桌面應用程式的理由 - 啟動・壽命・graceful shutdown 的整理會輕鬆很多
整理把 .NET Generic Host 與 BackgroundService 帶進 WPF / WinForms 桌面應用程式的理由,把啟動、lifetime、graceful shutdown 集中於入口管理。透過 StartAsync / ExecuteAsync...
FileSystemWatcher 的使用方法與注意事項 - 漏掉、重複通知、完成判定的陷阱
本文整理 FileSystemWatcher 的正確用法。把事件視為跡象而非完成通知,將通知摺疊為重新掃描請求,由傳送端以 temp 後 rename 明示完成,多 worker 以原子性 claim 取得所有權,最後以 full rescan 與 idempotency ...
ClickOnce 是什麼 - 以實務視角整理機制、更新、適合場面・不適合場面
本文以實務視角整理 ClickOnce 是什麼,從 manifest、快取、更新、簽章的構造,到適合公司內部 .NET 桌面業務應用程式的案件與不適合 machine-wide 或 service、driver 等深度 OS 整合的案件,幫助讀者判斷是否採用並掌握 depl...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。