序列通訊應用程式的陷阱 - 從重連到日誌設計

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

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279471)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616335)
初次發布
引用本文(DOI: 10.5281/zenodo.21616334)

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

Go Komura(2026)。〈序列通訊應用程式的陷阱 - 從重連到日誌設計〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616334 https://comcomponent.com/zh-TW/blog/2026/03/19/001-serial-communication-app-pitfalls/

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

設備介接、量測儀器、PLC、條碼掃描器、USB 轉序列轉換器。 序列通訊看起來是舊技術,但在 Windows 應用程式的實務裡仍然相當普遍。

稍微危險的一點是,序列通訊只要有 一條 COM 連接埠 和 一次 Read / Write 就能開始。連線測試很快就會通,可是一放上正式環境,就容易出現下面這些症狀。

  • 偶爾命令和回應會錯開
  • 一天固定卡住一次
  • 只有在 USB 拔插之後無法恢復
  • UI 有時候會停住
  • 翻日誌只剩下 “Timeout”

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

連線測試會通,正式環境卻會壞序列通訊只要一條 COM 連接埠與 Read 和 Write 就能開始,連線測試很快就通,但在正式環境會出現回應錯開、卡住、無法恢復等症狀,真正的難處在於邊界、逾時、狀態轉移、重連與可觀測性的圖。連線測試很快就通正式環境「偶爾」會壞回應錯開、卡住、無法恢復難處不在送收 API 本身邊界、逾時、狀態轉移、重連、可觀測性

圖 1: 連線測試通過之後,序列通訊應用程式真正的難處。

本文的目標讀者與前提

項目 內容
目標讀者 要撰寫透過序列埠與裝置或量測儀器連線的 Windows 應用程式的人。設想的情境是連線測試已經通過,卻想減少正式環境「偶爾」壞掉的狀況
前提知識 能用 C# 寫應用程式。不預設讀者有序列通訊本身的經驗
前提環境 內文以 .NET 的 System.IO.Ports.SerialPort 為前提撰寫,但邊界、逾時與狀態轉移的思路不依賴語言
不涵蓋的內容 電氣接線的話題、特定裝置的協定規格

本文使用的術語

術語 一句話說明
PLC Programmable Logic Controller。用來控制生產設備的工業用控制器
RS-232 / RS-485 序列通訊的電氣規格。RS-232 是一對一,RS-485 可以在同一條線上掛多台裝置。在 RS-485 上如果不先決定誰在什麼時候送,就會發生衝突
8N1 連接埠設定的簡寫。指資料位元 8、無同位(None)、停止位元 1 的組合
DTR / RTS 控制線。本來是用來傳達通訊準備完成或送出請求的線,但實機上有些裝置會把這些線的變化當成啟動或模式切換的訊號
流量控制 防止送太多的機制。RTS/CTS 走控制線,XON/XOFF 則用資料中的特殊字元來傳達停止與恢復
keepalive 為了確認通訊對象還活著而定期送出的輕量命令
訊框 一則訊息份量的 byte 序列。從哪裡到哪裡算一個訊框,由協定那一側決定
single writer 把送出集中到一條 worker 的設計。意思是不要讓任何地方都能 Write

1. 先講結論

先用貼近實務的說法整理一次。

  • 序列通訊是 有順序的 byte stream,訊息邊界不會自動出現
  • 呼叫了 Read(100) 也不代表一定剛好回 100 byte
  • .NET 的 DataReceived 不保證每收到一個 byte 就觸發一次,而且 不是在 UI 執行緒上
  • ReadLine() / WriteLine() 只有在對方真的是行為單位的文字協定時才直接了當
  • 逾時只有一個不夠用。把 open、inter-byte、response、reconnect 等語意分開會比較穩定
  • 送出與其做成任何地方都能 Write,不如收斂到 single writer,比較不容易亂掉
  • 在 USB 轉序列上,一開始就把拔插、重新列舉、COM 編號改變、重連失敗都當成前提會比較平順

換句話說,序列通訊應用程式的難處不在「連接埠打不打得開」,而在 如何把 byte 序列轉換成有意義的訊息,以及如何管理它周圍的時間與狀態。

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 18 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

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

從應用程式這一側看,序列通訊像是「送出一個命令,收到一個回應」。不過在下層,實際上流動的只是 有順序的 byte 序列。

我方一次 Write 送出的內容,在對方看起來可能是這樣。

  • 用一次 Read 就收到
  • 分成兩次到達
  • 和其他資料連在一起到達

一旦拿掉這個前提,應用程式就會開始認定「這次的 Read 應該就是這次的回應」。這個認定,往往就是序列通訊應用程式踩到的第一顆地雷。

一次 Write 的到達方式有三種我方一次 Write 送出的內容,在對方不一定會用一次 Read 收到,可能分成兩次到達,也可能和其他資料連在一起到達的圖。一次 Write用一次 Read 就收到分成兩次到達和其他資料連在一起到達「這次的 Read 就是這次的回應」不一定成立

圖 2: 一次 Write 在對方看起來是什麼樣子,要等收到才知道。

常見的認定 實際情況
Read(16) 就會剛好回 16 byte 依到達狀況與逾時,可能只拿到一部分
DataReceived = 一則訊息到達 事件不保證每個 byte 觸發一次,也不是在 UI 執行緒上
Write 回來了 = 對方處理完成 多數情況比較接近「送出端已經塞進緩衝區」
COM 清單 = 目前連著的真實狀況 列舉順序不固定,列舉結果也可能已經是 stale

因此在序列通訊裡,必須把訊息邊界當成協定自己定義。固定長度訊框、以分隔字元切分、長度 + payload + checksum,形式都可以,但邊界含糊就進入實作,後面幾乎一定會很難收拾。

邊界要自己定義序列通訊必須把訊息邊界當成協定自己定義,固定長度訊框、以分隔字元切分、長度加 payload 加 checksum 等形式都可以,但邊界含糊就進入實作會很難收拾的圖。訊息邊界自己定義固定長度訊框以分隔字元切分長度+payload+checksum含糊帶過會很難收拾

圖 3: 訊息邊界在下層不會自動出現,要先當成協定決定好。

3. 一開始就該決定的事

動手做序列通訊應用程式之前,至少要先把這裡列出來的項目決定好。

3.1 訊框邊界

決定哪一段 byte 序列算作一則訊息。是固定長度、以換行分隔、還是帶長度,有沒有 checksum / CRC。這裡含糊,接收端就無法判斷是「還不夠」還是「壞掉了」。

3.2 文字、二進位,還是兩者混用

先決定是 ASCII / UTF-8 的行協定、純二進位,還是兩者混用。特別是「命令部分是字串,payload 是二進位,只有結尾是換行」這種混用,如果不寫清楚哪一段要解碼、哪一段當成原始 byte 處理,邊界很快就會亂掉。

3.3 逾時的語意

逾時不要只有一個,依語意分開來想比較安全。

  • open timeout:到打開連接埠為止
  • inter-byte timeout:訊框中途收不到 byte 的時間
  • response timeout:從發出命令到回應完成
  • reconnect backoff:重連的等待間隔

把逾時當成 推進狀態轉移的規則 來持有,而不是當成「慢的時候的保險」,會比較穩定。

逾時要依語意分開逾時要分成打開連接埠之前的 open、訊框中途收不到 byte 的 inter-byte、發出命令到回應完成的 response,以及重連等待間隔的 reconnect backoff,並當成推進狀態轉移的規則來持有的圖。逾時只有一個不夠用open: 到打開為止inter-byte: 線路靜默response: 回應完成reconnect backoff推進狀態轉移的規則

圖 4: 把四種逾時分開,就能當成狀態轉移的規則來處理。

3.4 流量控制與線路狀態

想先寫清楚的設定大概是這些。

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

這裡用「8N1 大致上會合」帶過去,碰上某些裝置就會平白無故地卡死。

3.5 職責分離

把誰負責什麼分開。

  • 誰負責讀
  • 誰負責寫
  • 誰負責剖析
  • 誰負責更新業務狀態

序列通訊把 UI 和通訊混得越多,就越容易壞。

分開職責,不要把 UI 和通訊混在一起把誰負責讀、誰負責寫、誰負責剖析、誰負責更新業務狀態分開,並說明 UI 和通訊混得越多就越容易壞的圖。職責分離負責讀的與負責寫的負責剖析的負責更新業務狀態的UI 和通訊混得越多越容易壞

圖 5: 把讀、寫、剖析、更新分開,不要把 UI 和通訊混在一起。

3.6 啟動、停止、重連的狀態轉移

最少也要把 Closed、Opening、Ready、WaitingResponse、Fault、Reconnecting 這些狀態放進設計裡。剛拔插完,對方可能還在啟動中;有時候也不能把上一輪的 pending request 拖過來。

Open 請求open 成功 + 初始化序列完成open 失敗 / 權限錯誤 / 初始化逾時送出命令收到對應的回應訊框response timeoutI/O 錯誤 / 偵測到斷線讓 pending request 失敗並開始 backoffbackoff 結束達到上限 / 手動停止Close 請求ClosedOpeningReadyFaultWaitingResponseReconnecting

圖 6: 連線 session 的狀態轉移。沒有從 Fault 直接回到 Ready 的線。

這張圖裡最重要的,是沒有從 Fault 直接回到 Ready 的線。 出過異常之後一定要經過 Reconnecting 和 Opening,把接收緩衝區、parser 狀態、pending request、初始化序列全部重建,才回到 Ready。在這裡抄近路,就會掉進 4.7 的「只重新呼叫 Open() 就以為完成重連」。

3.7 日誌與可調查性

事後最讓人頭痛的幾乎都是這裡。至少 open / close / reopen 的時刻、使用的連接埠設定、送收訊框的 hex dump、checksum / CRC 錯誤、frame timeout / response timeout、重連理由,都希望能留下來。

4. 常見的陷阱

4.1 以為「一次 Read = 一則訊息」

最常見的就是這個。舉例來說,假設對方會回傳由標頭、長度、payload、CRC 組成的訊框。這時候只呼叫一次 Read(buffer, 0, expectedLength),就把它的傳回值直接當成一個訊框,收到一半時很容易就壞掉。

常見的壞法有這三種。

  • 只讀到長度,payload 還沒到
  • 只到了一個半訊框,後半留給下一次 Read
  • 兩個訊框一起到,只處理第一個就把剩下的丟掉

畫成圖來看,就只是裝置送出的順序和 Read 傳回的順序對不上而已。

裝置送出的內容
    [--- 訊框1 ---][--- 訊框2 ---]

模式1: 只到了一部分
    第 1 次 Read -> [ STX ][ LEN ]                     <- payload 還沒到
    第 2 次 Read -> [ payload ][ CRC ][--- 訊框2 ---]

模式2: 只到了一個半訊框
    第 1 次 Read -> [--- 訊框1 ---][ 訊框2 的前半 ]
    第 2 次 Read -> [ 訊框2 的後半 ]

模式3: 兩個訊框一起到
    第 1 次 Read -> [--- 訊框1 ---][--- 訊框2 ---]   <- 容易只處理一個就把剩下的丟掉

這三種模式都 不是「壞掉了」,而只是「切分位置和 Read 的次數對不上」。把兩者搞混,寫成「收到的 byte 數和預期不同就當成錯誤」的實作,就會開始把正常的通訊算成錯誤。

對策很單純,就是把工作拆開成 接收先累積,再由 parser 從中切出訊框。骨架程式碼放在 5.1。

先累積再切出把 Read 的傳回單位直接當成一個訊框,收到一半時就會壞掉,因此接收要先累積進緩衝區,再由 parser 從中切出訊框的圖。改成以為 Read 的傳回=一個訊框收到一半就很容易壞掉接收先累積進緩衝區由 parser 切出訊框

圖 7: 把 Read 的傳回單位和訊框切開,先累積再切出。

4.2 把 DataReceived 直接當成業務事件

.NET 的 SerialPort.DataReceived 看起來很方便,但把它當成「一則訊息到了」的通知很危險。實務上就把 DataReceived 當成「好像有東西來了」的通知,不要期待更多,處理常式裡不做繁重的工作,UI 更新一律切回 UI 執行緒。

4.3 以為任何地方都可以 Write

讓 UI 按鈕、監控計時器、重連處理、keepalive 各自直接 Write 的結構很容易亂掉。序列是 byte stream,設計不好就會出現命令插隊送出,或是在等回應的時候又補送一筆。特別是 request-response 型或 RS-485 這一類,收斂到 single writer 會穩定不少。

送出收斂到 single writerUI 按鈕、監控計時器、keepalive 與重連處理各自直接 Write 的結構,會出現命令插隊送出以及等待回應時又補送的情況而容易亂掉,收斂到 single writer 會比較穩定的圖。改成UI 按鈕各自直接 Write監控計時器keepalive、重連出現插隊送出與補送集中到 single writer

圖 8: 不要增加直接 Write 的地方,送出集中到一條 worker。

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 轉序列上,連接埠暫時消失、舊的控制代碼失效、上一輪的 pending request 失去意義,這些都是家常便飯。重連至少要把 session 失效、讓 pending request 失敗、停止 reader / writer、backoff 之後 reopen、重新執行裝置初始化,整套一起處理才比較安全。

重連不是重新呼叫 OpenUSB 轉序列會出現連接埠消失或舊的控制代碼失效的情況,因此重連要把 session 失效、讓 pending request 失敗、停止 reader 與 writer、backoff 之後 reopen、重新執行裝置初始化整套一起處理的圖。session 失效讓 pending request 失敗停止 reader / writerbackoff 之後 reopen重新執行裝置初始化只重新呼叫 Open 並不夠

圖 9: 重連要當成 session 的重建,把這一連串動作一起做完。

4.8 把 COM 連接埠列舉當成真實狀況

GetPortNames() 很方便,但出現在清單裡和打得開並不是同一件事。盲目相信上次的 COM7、自動選列舉結果的第一項、出現在清單裡就當成有效,這些實作在維運上很容易碰到麻煩。

4.9 送收日誌太薄

只有 TimeoutException、IOException、Port closed,幾乎什麼都看不出來。把送收時刻、port profile、送收的 hex dump、parser error、這是對哪一筆 request 的 response、reconnect 的觸發時機都留成看得出來的形式,釐清原因就會順利很多。

先把格式決定好,之後才能 grep,也才能做差異比對。舉例來說,可以做成這樣的單行格式。

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

這裡的目的有三個。

  • 把 RX 的行和 PARSE 的行分開。 RX 是「到了幾個 byte」,PARSE 是「切出了幾個訊框」。上面的例子中,一個訊框橫跨第 2 次和第 3 次的 RX 才到齊,多出來的部分成為下一個訊框的開頭。把這兩種混在一起記錄,事後就無法判斷是不是發生了 4.1 的切分錯位
  • 用 req= 讓送收可以互相對照。 哪一個回應對應哪一個命令,事後光靠日誌是還原不出來的
  • 把狀態轉移留成一行。 只要留下 Ready -> Fault 這樣的轉移與理由,就能直接追出重連的觸發時機

hex dump 很吃容量,所以現實的做法是兩段式:原始日誌用環形緩衝區只保留一定量,摘要日誌長期保存。

送收日誌的三個目的把 RX 的行與 PARSE 的行分開、用 req 把送收對照起來、把狀態轉移留成一行這三個目的,以及原始日誌用環形緩衝區保留一定量、摘要日誌長期保存的兩段式做法的圖。把 RX 和 PARSE 的行分開事後能釐清原因的日誌用 req 把送收對照起來把狀態轉移留成一行原始日誌用環形緩衝區,摘要日誌長期保存

圖 10: 把收到的 byte 數和切出的訊框分開記錄,事後才追得到錯位。

5. 最佳實踐

最有效的一招,是把職責分開。

  • reader:只從連接埠讀出 byte 序列
  • writer:只從 outbound queue 依序寫出
  • parser:只從 byte 序列切出 frame
  • protocol:處理 request 與 response 的對應關係與 checksum
  • app state:只更新業務狀態

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

逾時也一樣,與其用一個數字打發,不如依 open、inter-byte、response、reconnect 的語意分開,比較容易釐清原因。連接埠設定不要散在程式碼各處,而是當成 profile 集中持有,並在 startup 時輸出到日誌,實地排查會輕鬆很多。

重連不要當成單純的 reopen,當成 session 重建 會比較穩定。把接收緩衝區、parser 狀態、pending request、初始化序列,一直到就緒判定都重做一遍,就比較容易減少「偶爾才壞」的重連 bug。

最後,建議原始日誌和摘要日誌兩種都留。raw hex dump 和 open / close 的歷程對調查很有用,request id 與 retry 次數的摘要則對維運很有用。

依職責劃分的管線reader 只從連接埠讀出 byte 序列,parser 從累積起來的緩衝區切出訊框,protocol 處理對應關係與 checksum,app state 更新業務狀態,送出則由各處排入佇列而只有 writer 負責寫出的職責分離的圖。連接埠reader: 只負責讀parser: 切出訊框protocol: 對應關係與 checksumapp state: 更新業務狀態各處只負責排入佇列writer: 只負責依序寫出

圖 11: 接收與送出的管線。每個角色都只做一件事。

以下只針對最有效的兩處放上骨架程式碼。前提是 .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),
                $"一個訊框的 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. 是否已經湊滿一個訊框
            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 byte 的 STX 再讀一次,是設計上的取捨。
                // 前者單純,後者在 LEN 本身就是雜訊時比較耐得住。決定用哪一種,並白紙黑字寫下來。
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// 組裝中的候選如果超過 AssemblyTimeout,就只丟掉開頭 1 byte 的 STX。
    /// 丟掉就回傳 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 的實體。沒有它的話,雜訊或假的 STX 讓 LEN 變成很大的值(例如 255)時,parser 會一直把它當成「還不夠」。連之後到達的正確訊框,都會被當成那個壞掉的 payload 的一部分吞進去,直到湊滿 259 byte 被 CRC 判定失敗為止,什麼都不會上來。在通訊量少的裝置上,這看起來就是好幾分鐘沒有反應。只丟掉 1 byte 的 STX,是因為真正的 STX 有可能就埋在候選裡面。

這裡先寫兩個前提。這個逾時只有在 Append 被呼叫的時候才會判定。線路完全靜默的情況下 parser 這一側什麼都不會發生,那部分要靠呼叫端的 response timeout(5.2)接住。另一個是,AssemblyTimeout 的值要從鮑率與訊框長度來決定。下限是 1 byte 的傳輸時間 × 預期的最大訊框長度再加上餘裕,比這個短就會把正常的訊框中途丟掉。把 Resynchronized 的觸發次數輸出到日誌,就能成為懷疑接線或鮑率設定的依據。

被讀錯的 LEN 會把正確的訊框吞進去雜訊或假的 STX 讓 LEN 變成很大的值時,parser 會一直當成還不夠而繼續等待,連之後到達的正確訊框都當成壞掉的 payload 吞進去,直到 CRC 判定失敗為止看起來都沒有反應,因此要用逾時丟掉 1 byte 的 STX 重新同步的圖。對策雜訊或假的 STX 讓 LEN 被讀錯parser 一直當成「還不夠」而繼續等連正確的訊框也吞進去直到 CRC 判定失敗前都沒有反應逾時就丟掉 1 byte 的 STX 重新同步

圖 12: 沒有 inter-byte timeout,被讀錯的 LEN 就會一直吞掉後續訊框。

讀的這一側,就讓它 只負責從連接埠讀出來交給 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 的只有一條 worker,做成這種形式。

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>送出佇列的上限。用裝置一次來回的時間 × 可容忍的等待佇列長度來決定。</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // 不要做成沒有上限。UI、計時器、worker 積得比裝置來回還快時,
    // 訊框和 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 只由一條 worker 執行。</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))
        {
            // 佇列滿了,或是 worker 已經停止。兩種情況都要把「沒排進去」
            // 這件事回報給呼叫端。默默丟掉的話,正在等的 Task 就永遠不會回來
            item.Completion.TrySetException(new InvalidOperationException(
                $"無法排入送出佇列(上限 {QueueCapacity} 筆,或 worker 已停止)。"));
        }

        return item.Completion.Task;
    }

    /// <summary>parser 切出訊框之後呼叫。把它接到正在等回應的那一筆上。</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)
                {
                    // 斷線、連接埠關閉、取消都會從這裡飛出來。
                    // 沒有讓這一筆完成就跳出去的話,正在 await SendAsync 的
                    // 呼叫端會永遠等下去
                    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,並把這一筆
                    // 以成功完成。取不到就是回應贏了,在這裡即使打
                    // TrySetException 也不會生效。沒察覺到這點就
                    // 一路 throw 下去,呼叫端明明已經拿到結果,
                    // 卻只有 worker 掛掉。這場競爭用 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,重建 session
                    throw new TimeoutException("因為沒有收到回應,要重建 session。");
                }
            }
        }
        finally
        {
            // 不管 worker 停止的理由是什麼,讓人等著的那些一定要結束掉。
            // 包含正在等回應的那一筆,以及還排在佇列裡沒送出的部分
            var stopped = new OperationCanceledException("送出 worker 已停止。");
            Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
            _queue.Writer.TryComplete();
            while (_queue.Reader.TryRead(out var pending))
            {
                pending.Completion.TrySetException(stopped);
            }
        }
    }
}

逾時就把整條 worker 停掉,看起來粗暴,卻是必要的處置。這個訊框裡沒有放請求 ID。因此接收端無法判別「剛到的這個訊框是對哪一個命令的回應」,OnFrameReceived 只能機械式地把它接到正在等回應的那一筆上。

如果在這裡逾時之後仍然照常送下一個,就會變成這樣。

  1. 送出命令 A。在設定的時間內沒有收到回應,判定為逾時
  2. 送出下一個命令 B
  3. 遲到的 A 的回應,被當成 B 的回應交給呼叫端

從呼叫端看,明明送的是 B,回來的卻是 A 的值。因為值的格式是對的,檢查也會通過,這是最難找出來的壞法。3.6 的狀態轉移圖之所以沒有畫出從 Fault 直接回到 Ready 的線,就是因為有這條路徑。逾時不是「一筆失敗」,而是「這條連線已經不能相信」的判斷;在不知道接收緩衝區裡還留著什麼的狀態下,只有關掉連接埠再重新打開的 session 重建才能保證恢復。

如果可以動協定那一側,讓訊框帶上請求 ID 再與回應對照才是比較根本的解法。這樣一來,遲到的回應用「ID 不認得就丟掉」處理就好,也不必每逾時一筆就重建一次連線。

遲到回應被接錯在沒有請求 ID 的協定裡,命令 A 逾時之後送出命令 B,遲到的 A 的回應會被當成 B 的回應交給呼叫端,因此逾時之後要落到 Fault 並重建 session 的圖。裝置worker呼叫端裝置worker呼叫端時間內沒有回應而逾時所以逾時之後要落到 Fault送出命令 A送出 A送出命令 B送出 B遲到的 A 的回應被當成 B 的回應交出去

圖 13: 沒有請求 ID,遲到的回應就會被接到下一個命令上。

最後把它們組起來。連接埠設定要當成 profile 集中放在一處,並在啟動時輸出到日誌,這部分就是 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
{
    // 就算送出失敗,也一定要把 worker 停掉再離開。跳過這裡的話,
    // using 處置時 SerialPort 已經關閉,reader/writer 卻還會繼續
    // 碰那條串流,而且那裡拋出的例外沒有人觀測得到
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // 因為停止請求而結束。這裡當成正常情境處理
    }
    catch (Exception ex)
    {
        // worker 那一側的失敗。在這裡重新拋出會掩蓋真正的失敗理由
        //(SendAsync 的例外),所以只記錄下來
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

cts.Cancel() 和 Task.WhenAll 放在 finally 裡,不是寫法上的偏好。在序列通訊中,SendAsync 失敗不是例外,而是家常便飯 ── 裝置沒有回應、纜線被拔掉、寫入逾時。這時候如果就這樣往上跳出去,會在沒有停掉 worker 的情況下走到 using 的處置。SerialPort 關閉之後,reader / writer 仍然會去碰那條串流,而那裡出現的例外沒有人觀測得到。如果是常駐應用程式,就會以只有失敗的那個操作的 worker 活下來的形式一點一點堆積。finally 那一側的例外沒有重新拋出,是為了不掩蓋真正的失敗理由(SendAsync 的例外)。

失敗時也一定要停掉 workerSendAsync 失敗在序列通訊裡是家常便飯,就這樣往上跳出去會在沒有停掉 worker 的情況下處置 SerialPort,reader 與 writer 會繼續碰已關閉的串流而例外沒有人觀測得到,因此要在 finally 取消並等待完成的圖。SendAsync 失敗(家常便飯)在 finally cancel 並等待完成先停掉 worker 再處置跳過就會一直碰已關閉的串流常駐應用程式每失敗一次就殘留一個 worker

圖 14: 正因為送出失敗,才更要在 finally 停掉 worker 再離開。

做成這種形式之後,日後想再加的東西,例如 retry、keepalive、reconnect,都會落在 排進佇列的那一側 或 worker 那一側 其中之一。直接呼叫 Write 的地方不會變多,順序錯亂的原因也就不會增加。

6. 先看的檢查清單

  • 訊息邊界有沒有白紙黑字寫下來
  • 接收是不是做成 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 的日誌,會讓日後的調查輕鬆非常多

也就是說,序列通訊應用程式中,如何解讀 byte 序列、如何控制時間與狀態,遠比 打得開連接埠 重要。光是一開始就把這些分開設計,「偶爾才壞」這一類的通訊缺陷就能減少不少。

比起打得開,解讀與控制更重要序列通訊應用程式比起打得開連接埠,更重要的是如何解讀 byte 序列以及如何控制時間與狀態,一開始就分開設計可以減少偶爾才壞的通訊缺陷的圖。打開連接埠這裡不是難處byte 序列的解讀一開始就分開設計時間與狀態的控制「偶爾才壞」的缺陷會減少

圖 15: 重要的不是打開連接埠,而是解讀與控制的設計。

8. 參考資料

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

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

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

常見問題

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

序列通訊中呼叫 Read(16) 就能剛好收到 16 個位元組嗎?
不一定。序列通訊是有順序的 byte stream,訊息邊界不會自動出現。我方一次 Write 送出的內容,在對方可能分成兩次到達,也可能和其他資料連在一起到達。典型的壞法是:只讀到長度而 payload 還沒到、只到了一個半訊框、兩個訊框一起到。對策是把接收拆成兩段,先把收到的資料累積進緩衝區,再由 parser 從中切出訊框。
使用 .NET 的 SerialPort.DataReceived 事件時要注意什麼?
DataReceived 不保證每收到一個 byte 就觸發一次,也不是在 UI 執行緒上執行。把它當成「一則訊息到了」的通知很危險。實務上就把它當成「好像有東西來了」這種程度的通知,處理常式裡不做繁重的工作,UI 更新一律切回 UI 執行緒。收到的 byte 序列先累積起來,再由 parser 切出訊框,這樣的結構比較穩定。
序列通訊的逾時該怎麼設計?
一個逾時不夠用,依語意分開會比較穩定:開啟連接埠之前的 open timeout、訊框中途收不到 byte 的 inter-byte timeout、從發出命令到回應完成的 response timeout,以及重連等待間隔的 reconnect backoff。把逾時當成推進狀態轉移的規則來持有,而不是當成慢的時候的保險,會比較穩定。還要注意,同步 read 直接沿用預設值放著,就會變成名副其實的無限等待。
USB 轉序列在拔插纜線之後為什麼無法恢復?
因為在 USB 轉序列上,連接埠暫時消失、舊的控制代碼失效、COM 編號改變、上一輪的 pending request 失去意義,這些都是家常便飯。只是重新呼叫 Open() 並不足以稱為重連。把 session 失效、讓 pending request 失敗、停止 reader 與 writer、backoff 之後 reopen、重新執行裝置初始化序列整套包起來,當成「session 重建」來設計,就能減少偶爾才壞的重連 bug。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽