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

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

更新紀錄(僅初版,2026年03月19日 發布)
初次發布
引用本文(DOI: 10.5281/zenodo.21616334)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

小村 豪(2026)。〈序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616334 https://staging.comcomponent.com/zh-TW/blog/2026/03/19/001-serial-communication-app-pitfalls/

DOI(最新版本)
10.5281/zenodo.21616334
DOI(此版本)
10.5281/zenodo.21616335

設備整合、儀器、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. 參考資料

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

業務應用程式的日本年號・國定假日・結算日處理 ── 抗改元設計與 JapaneseCalendar・營業日計算實務

報表要顯示「令和8年」、營業日計算要排除國定假日、20日結算次月底付款──日本業務應用程式特有的日期處理,背後藏著「日後會變動的規格」:改元、國定假日的法規修訂、月底的進位。本文整理以 JapaneseCalendar 顯示年號、抗改元的設計方式,國定假日不可寫死在程式碼中...

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

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

常見問題

整理諮詢這個主題時常見的問題。

為什麼序列通訊的 Read 拿不到預期長度的資料?
因為序列通訊是有順序的 byte stream,訊息邊界不會自動出現。我方一次 Write 的內容,對方可能一次全收到、分兩次收到、或和其他資料串在一起收到,呼叫 Read(100) 不代表一定回來 100 bytes。對策是先把接收資料累積進 buffer,再由 parser 從中切出封包,訊息邊界必須由自己以協定定義,例如固定長度框、分隔字元、或長度加 payload 加 checksum。
.NET 的 SerialPort.DataReceived 可以當成「一條訊息到了」的通知嗎?
不行。DataReceived 不保證按 byte 觸發,也不是在 UI 執行緒上執行,把它當作業務事件相當危險。實務上應把 DataReceived 視為「好像有東西來了」的通知,handler 內不要做太重的處理比較安全,UI 更新也務必切回 UI 執行緒。
序列通訊的逾時該怎麼設計?
逾時只有一個是不夠的,要依語意分別思考:open timeout 是打開 port 之前、inter-byte timeout 是封包中途沒再收到 byte 的時間、response timeout 是發出命令到拿到完整回應、reconnect backoff 是重連的等待間隔。把逾時當成驅動狀態轉移的規則來看會比較穩,不假思索用預設值做同步 read 很容易變成無限等待。
USB 轉序列設備的重連該注意什麼?
不要以為重新呼叫 Open() 就叫重連。USB 拔插後 port 可能暫時消失、COM 編號變動、舊 handle 失效、上一輪未完成的 pending request 也失去意義。重連至少要把 session 失效、把 pending request 標為失敗、停止 reader/writer、backoff 後再 reopen、重新執行裝置初始化通通包起來處理,也就是用 session 重建的概念比較穩。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽