序列通訊應用程式的陷阱 - 從重連到日誌設計
· 更新日期: · Go Komura · 序列通訊, RS-232, C#, .NET, Windows 開發, 設備介接
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(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 本身,而是 邊界、逾時、狀態轉移、重連與可觀測性。
flowchart TB
accTitle: 連線測試會通,正式環境卻會壞
accDescr: 序列通訊只要一條 COM 連接埠與 Read 和 Write 就能開始,連線測試很快就通,但在正式環境會出現回應錯開、卡住、無法恢復等症狀,真正的難處在於邊界、逾時、狀態轉移、重連與可觀測性的圖。
a1["連線測試很快就通"] --> a2["正式環境「偶爾」會壞"]
a2 --> a3["回應錯開、卡住、無法恢復"]
a3 --> a4["難處不在送收 API 本身"]
a4 -.-> a5["邊界、逾時、狀態轉移、重連、可觀測性"]
圖 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 應該就是這次的回應」。這個認定,往往就是序列通訊應用程式踩到的第一顆地雷。
flowchart TB
accTitle: 一次 Write 的到達方式有三種
accDescr: 我方一次 Write 送出的內容,在對方不一定會用一次 Read 收到,可能分成兩次到達,也可能和其他資料連在一起到達的圖。
b0["一次 Write"] --> b1["用一次 Read 就收到"]
b0 --> b2["分成兩次到達"]
b0 --> b3["和其他資料連在一起到達"]
b2 -.-> b4["「這次的 Read 就是這次的回應」不一定成立"]
圖 2: 一次 Write 在對方看起來是什麼樣子,要等收到才知道。
| 常見的認定 | 實際情況 |
|---|---|
Read(16) 就會剛好回 16 byte |
依到達狀況與逾時,可能只拿到一部分 |
DataReceived = 一則訊息到達 |
事件不保證每個 byte 觸發一次,也不是在 UI 執行緒上 |
Write 回來了 = 對方處理完成 |
多數情況比較接近「送出端已經塞進緩衝區」 |
| COM 清單 = 目前連著的真實狀況 | 列舉順序不固定,列舉結果也可能已經是 stale |
因此在序列通訊裡,必須把訊息邊界當成協定自己定義。固定長度訊框、以分隔字元切分、長度 + payload + checksum,形式都可以,但邊界含糊就進入實作,後面幾乎一定會很難收拾。
flowchart TB
accTitle: 邊界要自己定義
accDescr: 序列通訊必須把訊息邊界當成協定自己定義,固定長度訊框、以分隔字元切分、長度加 payload 加 checksum 等形式都可以,但邊界含糊就進入實作會很難收拾的圖。
c0["訊息邊界自己定義"] --> c1["固定長度訊框"]
c0 --> c2["以分隔字元切分"]
c0 --> c3["長度+payload+checksum"]
c0 -.-> c4["含糊帶過會很難收拾"]
圖 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:重連的等待間隔
把逾時當成 推進狀態轉移的規則 來持有,而不是當成「慢的時候的保險」,會比較穩定。
flowchart TB
accTitle: 逾時要依語意分開
accDescr: 逾時要分成打開連接埠之前的 open、訊框中途收不到 byte 的 inter-byte、發出命令到回應完成的 response,以及重連等待間隔的 reconnect backoff,並當成推進狀態轉移的規則來持有的圖。
d0["逾時只有一個不夠用"] --> d1["open: 到打開為止"]
d0 --> d2["inter-byte: 線路靜默"]
d0 --> d3["response: 回應完成"]
d0 -.-> d4["reconnect backoff"]
d1 --> d5["推進狀態轉移的規則"]
d2 --> d5
d3 --> d5
圖 4: 把四種逾時分開,就能當成狀態轉移的規則來處理。
3.4 流量控制與線路狀態
想先寫清楚的設定大概是這些。
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
這裡用「8N1 大致上會合」帶過去,碰上某些裝置就會平白無故地卡死。
3.5 職責分離
把誰負責什麼分開。
- 誰負責讀
- 誰負責寫
- 誰負責剖析
- 誰負責更新業務狀態
序列通訊把 UI 和通訊混得越多,就越容易壞。
flowchart TB
accTitle: 分開職責,不要把 UI 和通訊混在一起
accDescr: 把誰負責讀、誰負責寫、誰負責剖析、誰負責更新業務狀態分開,並說明 UI 和通訊混得越多就越容易壞的圖。
e0["職責分離"] --> e1["負責讀的與負責寫的"]
e0 --> e2["負責剖析的"]
e0 --> e3["負責更新業務狀態的"]
e0 -.-> e4["UI 和通訊混得越多越容易壞"]
圖 5: 把讀、寫、剖析、更新分開,不要把 UI 和通訊混在一起。
3.6 啟動、停止、重連的狀態轉移
最少也要把 Closed、Opening、Ready、WaitingResponse、Fault、Reconnecting 這些狀態放進設計裡。剛拔插完,對方可能還在啟動中;有時候也不能把上一輪的 pending request 拖過來。
stateDiagram-v2
[*] --> Closed
Closed --> Opening: Open 請求
Opening --> Ready: open 成功 + 初始化序列完成
Opening --> Fault: open 失敗 / 權限錯誤 / 初始化逾時
Ready --> WaitingResponse: 送出命令
WaitingResponse --> Ready: 收到對應的回應訊框
WaitingResponse --> Fault: response timeout
Ready --> Fault: I/O 錯誤 / 偵測到斷線
Fault --> Reconnecting: 讓 pending request 失敗並開始 backoff
Reconnecting --> Opening: backoff 結束
Reconnecting --> Closed: 達到上限 / 手動停止
Ready --> Closed: Close 請求
圖 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。
flowchart TB
accTitle: 先累積再切出
accDescr: 把 Read 的傳回單位直接當成一個訊框,收到一半時就會壞掉,因此接收要先累積進緩衝區,再由 parser 從中切出訊框的圖。
f1["以為 Read 的傳回=一個訊框"] --> f2["收到一半就很容易壞掉"]
f2 -.->|"改成"| f3["接收先累積進緩衝區"]
f3 --> f4["由 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 會穩定不少。
flowchart TB
accTitle: 送出收斂到 single writer
accDescr: UI 按鈕、監控計時器、keepalive 與重連處理各自直接 Write 的結構,會出現命令插隊送出以及等待回應時又補送的情況而容易亂掉,收斂到 single writer 會比較穩定的圖。
g1["UI 按鈕"] --> g4["各自直接 Write"]
g2["監控計時器"] --> g4
g3["keepalive、重連"] --> g4
g4 --> g5["出現插隊送出與補送"]
g5 -.->|"改成"| g6["集中到 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、重新執行裝置初始化,整套一起處理才比較安全。
flowchart TB
accTitle: 重連不是重新呼叫 Open
accDescr: USB 轉序列會出現連接埠消失或舊的控制代碼失效的情況,因此重連要把 session 失效、讓 pending request 失敗、停止 reader 與 writer、backoff 之後 reopen、重新執行裝置初始化整套一起處理的圖。
h1["session 失效"] --> h2["讓 pending request 失敗"]
h2 --> h3["停止 reader / writer"]
h3 --> h4["backoff 之後 reopen"]
h4 --> h5["重新執行裝置初始化"]
h1 -.-> h6["只重新呼叫 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 很吃容量,所以現實的做法是兩段式:原始日誌用環形緩衝區只保留一定量,摘要日誌長期保存。
flowchart TB
accTitle: 送收日誌的三個目的
accDescr: 把 RX 的行與 PARSE 的行分開、用 req 把送收對照起來、把狀態轉移留成一行這三個目的,以及原始日誌用環形緩衝區保留一定量、摘要日誌長期保存的兩段式做法的圖。
i1["把 RX 和 PARSE 的行分開"] --> i4["事後能釐清原因的日誌"]
i2["用 req 把送收對照起來"] --> i4
i3["把狀態轉移留成一行"] --> i4
i4 -.-> i5["原始日誌用環形緩衝區,摘要日誌長期保存"]
圖 10: 把收到的 byte 數和切出的訊框分開記錄,事後才追得到錯位。
5. 最佳實踐
最有效的一招,是把職責分開。
reader:只從連接埠讀出 byte 序列writer:只從 outbound queue 依序寫出parser:只從 byte 序列切出 frameprotocol:處理 request 與 response 的對應關係與 checksumapp 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 次數的摘要則對維運很有用。
flowchart TB
accTitle: 依職責劃分的管線
accDescr: reader 只從連接埠讀出 byte 序列,parser 從累積起來的緩衝區切出訊框,protocol 處理對應關係與 checksum,app state 更新業務狀態,送出則由各處排入佇列而只有 writer 負責寫出的職責分離的圖。
p0["連接埠"] --> p1["reader: 只負責讀"]
p1 --> p2["parser: 切出訊框"]
p2 --> p3["protocol: 對應關係與 checksum"]
p3 --> p4["app state: 更新業務狀態"]
q1["各處只負責排入佇列"] --> q2["writer: 只負責依序寫出"]
q2 --> p0
圖 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 的觸發次數輸出到日誌,就能成為懷疑接線或鮑率設定的依據。
flowchart TB
accTitle: 被讀錯的 LEN 會把正確的訊框吞進去
accDescr: 雜訊或假的 STX 讓 LEN 變成很大的值時,parser 會一直當成還不夠而繼續等待,連之後到達的正確訊框都當成壞掉的 payload 吞進去,直到 CRC 判定失敗為止看起來都沒有反應,因此要用逾時丟掉 1 byte 的 STX 重新同步的圖。
j1["雜訊或假的 STX 讓 LEN 被讀錯"] --> j2["parser 一直當成「還不夠」而繼續等"]
j2 --> j3["連正確的訊框也吞進去"]
j3 --> j4["直到 CRC 判定失敗前都沒有反應"]
j4 -.->|"對策"| j5["逾時就丟掉 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 只能機械式地把它接到正在等回應的那一筆上。
如果在這裡逾時之後仍然照常送下一個,就會變成這樣。
- 送出命令 A。在設定的時間內沒有收到回應,判定為逾時
- 送出下一個命令 B
- 遲到的 A 的回應,被當成 B 的回應交給呼叫端
從呼叫端看,明明送的是 B,回來的卻是 A 的值。因為值的格式是對的,檢查也會通過,這是最難找出來的壞法。3.6 的狀態轉移圖之所以沒有畫出從 Fault 直接回到 Ready 的線,就是因為有這條路徑。逾時不是「一筆失敗」,而是「這條連線已經不能相信」的判斷;在不知道接收緩衝區裡還留著什麼的狀態下,只有關掉連接埠再重新打開的 session 重建才能保證恢復。
如果可以動協定那一側,讓訊框帶上請求 ID 再與回應對照才是比較根本的解法。這樣一來,遲到的回應用「ID 不認得就丟掉」處理就好,也不必每逾時一筆就重建一次連線。
sequenceDiagram
accTitle: 遲到回應被接錯
accDescr: 在沒有請求 ID 的協定裡,命令 A 逾時之後送出命令 B,遲到的 A 的回應會被當成 B 的回應交給呼叫端,因此逾時之後要落到 Fault 並重建 session 的圖。
participant C as 呼叫端
participant W as worker
participant D as 裝置
C->>W: 送出命令 A
W->>D: 送出 A
Note over W: 時間內沒有回應而逾時
C->>W: 送出命令 B
W->>D: 送出 B
D-->>W: 遲到的 A 的回應
W-->>C: 被當成 B 的回應交出去
Note over W: 所以逾時之後要落到 Fault
圖 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 的例外)。
flowchart TB
accTitle: 失敗時也一定要停掉 worker
accDescr: SendAsync 失敗在序列通訊裡是家常便飯,就這樣往上跳出去會在沒有停掉 worker 的情況下處置 SerialPort,reader 與 writer 會繼續碰已關閉的串流而例外沒有人觀測得到,因此要在 finally 取消並等待完成的圖。
k1["SendAsync 失敗(家常便飯)"] --> k2["在 finally cancel 並等待完成"]
k2 --> k3["先停掉 worker 再處置"]
k1 -.-> k4["跳過就會一直碰已關閉的串流"]
k4 -.-> k5["常駐應用程式每失敗一次就殘留一個 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 序列、如何控制時間與狀態,遠比 打得開連接埠 重要。光是一開始就把這些分開設計,「偶爾才壞」這一類的通訊缺陷就能減少不少。
flowchart TB
accTitle: 比起打得開,解讀與控制更重要
accDescr: 序列通訊應用程式比起打得開連接埠,更重要的是如何解讀 byte 序列以及如何控制時間與狀態,一開始就分開設計可以減少偶爾才壞的通訊缺陷的圖。
m1["打開連接埠"] -.-> m2["這裡不是難處"]
m3["byte 序列的解讀"] --> m5["一開始就分開設計"]
m4["時間與狀態的控制"] --> m5
m5 --> m6["「偶爾才壞」的缺陷會減少"]
圖 15: 重要的不是打開連接埠,而是解讀與控制的設計。
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
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
在 C# 與 PowerShell 中使用 WMI/CIM ── 硬體資訊取得、處理程序監控、遠端查詢實務指南
取得 PC 序號、監控磁碟可用空間、偵測處理程序啟動,這些需求的標準答案就是 WMI/CIM。本文說明 Get-CimInstance 等 CIM Cmdlet 的用法與從舊版 Get-WmiObject 的遷移、C# 中 System.Management 與 CIM A...
在 Windows 應用程式中處理 USB 裝置的方法 ── 虛擬 COM・HID・WinUSB・專用 SDK 的選擇方式
本文比較從Windows應用程式控制設備與USB裝置的四種方式──虛擬COM埠、HID、WinUSB、廠商製SDK。並以實務角度整理是否需要安裝驅動程式、多台連接時的識別方式、如何追蹤拔插,以及效能上限。
為業務應用程式的 DB 結構做版本管理 ── 防止「各客戶端 DB 不一致」的遷移實踐
為分散在各客戶端的業務應用程式 DB 結構做版本管理的實務指南。整理 PRAGMA user_version 與前進遷移的 C# 實作、EF Core Migrations・DbUp・自行實作的判斷表,一直到兩階段發佈。
WinForms / WPF 應用程式的 CI/CD 實務 ── 用 GitHub Actions 把從建置到簽章・發布全部自動化
本文整理用 GitHub Actions 為 WinForms / WPF 應用程式建置 CI/CD 的實務指南,內容涵蓋在 windows-latest 上進行建置+測試的最小 YAML、以標籤驅動的版本編號、透過 signtool 整合簽章,以及依 MSI/MSIX/C...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
包含序列通訊的 Windows 應用程式,把接收處理、狀態轉移、重連與 UI 分離一併納入設計會比較穩定。
故障調查 & 根本原因分析
偶爾才卡住、只有在 USB 拔插之後無法恢復、光看日誌追不出因果,這類通訊故障的原因釐清與本主題很合。
技術諮詢 & 設計審查
在實作之前先把協定邊界、流量控制、逾時與 single writer 設計梳理清楚,可以減少日後要大幅返工的缺陷。
常見問題
整理諮詢這個主題時常見的問題。
- 序列通訊中呼叫 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。