戰(zhàn):從字節(jié)流解析到上位機(jī)穩(wěn)定收發(fā))
上個(gè)月朋友寄過來一臺(tái)用STM32F103C8T6做控制的小設(shè)備讓我?guī)兔τ肅#寫個(gè)上位機(jī)通過串口通信把數(shù)據(jù)采上來。東西不復(fù)雜但前后折騰了兩天——不是單片機(jī)那邊的問題而是C#串口通信里那些文檔不會(huì)明說的細(xì)節(jié)接收事件分包、UI線程卡頓、編碼錯(cuò)亂、拔插USB轉(zhuǎn)串口線后無法恢復(fù)……每一件單獨(dú)看都是小問題串在一起就非常折磨人。這篇文章不是SerialPort類的API翻譯而是把我這些年做C#上位機(jī)、對(duì)接各類串口設(shè)備的經(jīng)驗(yàn)整理出來。從底層傳輸原理講到實(shí)際封裝再到疑難排查。不管你是用C#對(duì)接STM32、51單片機(jī)、掃碼槍、Modbus設(shè)備還是串口屏都能找到可落地的參考。1. 串口通信不是“發(fā)字符串”那么簡(jiǎn)單先搞懂底層在傳什么很多初學(xué)者把串口通信理解為“兩個(gè)程序之間互相發(fā)字符串”這個(gè)認(rèn)知會(huì)在第一次對(duì)接真實(shí)硬件時(shí)碰壁。實(shí)際上串口通信傳輸?shù)氖且粋€(gè)個(gè)字節(jié)也就是byte。至于這些字節(jié)是ASCII字符、GBK中文、二進(jìn)制協(xié)議還是Modbus報(bào)文完全由通信雙方的協(xié)議決定。C#里的SerialPort類只是幫你把硬件收到的字節(jié)流送進(jìn)程序它本身不關(guān)心數(shù)據(jù)含義。1.1 從RS232/RS485到USB轉(zhuǎn)串口物理層決定了你的代碼寫法大多數(shù)現(xiàn)代PC早就沒有原生RS232串口了所以我們?nèi)粘S玫摹按凇被臼荱SB轉(zhuǎn)串口設(shè)備比如CH340、CP2102、FT232。這類設(shè)備在系統(tǒng)里被虛擬成一個(gè)COM口對(duì)C#來說打開COM3和使用古老的物理串口沒有任何區(qū)別。但物理層差異會(huì)直接影響你的工程選型RS232電平常見于老式PLC、單片機(jī)調(diào)試口、掃碼槍。傳輸距離一般在15米以內(nèi)點(diǎn)對(duì)點(diǎn)通信。RS485電平常見于工業(yè)現(xiàn)場(chǎng)、Modbus總線、多機(jī)通信。用A/B差分信號(hào)傳輸距離可達(dá)1200米支持一主多從。TTL電平STM32、51單片機(jī)的最小系統(tǒng)板直接引出的一般是TTL串口。它和RS232電平不兼容所以很多設(shè)備需要MAX232芯片轉(zhuǎn)電平。你在C#里做的事情其實(shí)是透明的不管是哪種電平系統(tǒng)串口驅(qū)動(dòng)最終都會(huì)把收到的數(shù)據(jù)統(tǒng)一變成字節(jié)流交給你。唯一要注意的是接線TTL的TX接對(duì)方的RXRX接對(duì)方的TXGND必須共地。很多“通信沒反應(yīng)”的問題就是TX/RX接反了或者GND沒連跟代碼一點(diǎn)關(guān)系都沒有。1.2 波特率、數(shù)據(jù)位、校驗(yàn)位、停止位為什么9600能通而4800不通串口通信的參數(shù)設(shè)置是C#里SerialPort構(gòu)造函數(shù)最常看到的五個(gè)參數(shù)它們的組合決定了通信雙方能否“聽懂”對(duì)方參數(shù)常見值說明波特率9600、115200每秒傳輸?shù)姆?hào)數(shù)雙方必須一致數(shù)據(jù)位8、7一個(gè)字節(jié)中實(shí)際承載數(shù)據(jù)的位置校驗(yàn)位None、Odd、Even簡(jiǎn)單檢錯(cuò)機(jī)制停止位One、Two標(biāo)志一個(gè)字節(jié)傳輸結(jié)束的電平寬度有朋友問過“為什么串口波特率9600能通信4800反而沒有數(shù)據(jù)”——這個(gè)問題通常和波特率誤差有關(guān)。單片機(jī)常用的晶振頻率從11.0592MHz到72MHz都有串口波特率是由定時(shí)器/計(jì)數(shù)器分頻產(chǎn)生的不是所有波特率都能精確匹配。比如某個(gè)51單片機(jī)用11.0592MHz晶振9600波特率誤差接近0%但4800波特率可能因?yàn)榉诸l方式不同導(dǎo)致誤差變大。如果雙方晶振不同誤差積累到一定程度就收不到數(shù)據(jù)。解決辦法是優(yōu)先使用設(shè)備廠商推薦的波特率不要自己隨便改換用帶自動(dòng)波特率檢測(cè)功能的芯片或者用邏輯分析儀看波形。此外還有“數(shù)據(jù)位7奇偶校驗(yàn)”這種老協(xié)議寫法。現(xiàn)在大多數(shù)設(shè)備使用8N18數(shù)據(jù)位、無校驗(yàn)、1停止位如果你對(duì)接的設(shè)備文檔寫了8E1或7E1一定不要憑感覺改成8N1否則解析出來的數(shù)據(jù)全是亂的。1.3 SerialPort類背后的字節(jié)流與緩沖區(qū)C#的SerialPort類本身維護(hù)了一個(gè)內(nèi)部接收緩沖區(qū)。當(dāng)串口硬件收到數(shù)據(jù)時(shí)驅(qū)動(dòng)會(huì)把字節(jié)寫入緩沖區(qū)同時(shí)觸發(fā)DataReceived事件。這里有個(gè)關(guān)鍵點(diǎn)DataReceived事件是在后臺(tái)線程觸發(fā)的而且它并不是“收到一幀完整數(shù)據(jù)才觸發(fā)”而是緩沖區(qū)里有一定數(shù)據(jù)就觸發(fā)。數(shù)據(jù)可能被拆成好幾段也可能一次來了好幾幀粘在一起。這個(gè)問題在接收處理時(shí)特別重要。很多人第一次寫接收代碼是這樣的private void SerialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data serialPort1.ReadExisting(); textBox1.AppendText(data); }這種寫法在數(shù)據(jù)量小、頻率低的場(chǎng)景下好像也能跑但只要設(shè)備連續(xù)發(fā)送數(shù)據(jù)Text變長(zhǎng)的速度就會(huì)讓人抓狂而且你根本無法判斷一幀數(shù)據(jù)的邊界。正確做法是把字節(jié)先緩存起來按協(xié)議幀的完整長(zhǎng)度解析后面第3章我會(huì)專門講。2. 寫一個(gè)能用的C#串口通信模塊前先做好這幾件事很多人拿到項(xiàng)目就開始寫SerialPort的Open和Read但我建議先花半小時(shí)做兩件事確認(rèn)開發(fā)環(huán)境、分析通信協(xié)議。這兩件事沒做好后面全是返工。2.1 環(huán)境選型和項(xiàng)目工程落地C#串口通信在技術(shù)棧上并不挑版本。.NET Framework 4.x、.NET Core 3.1、.NET 6/7/8都能用System.IO.Ports.SerialPort。但對(duì)于新手我建議直接用Visual Studio 2022創(chuàng)建一個(gè).NET 8的Windows窗體重應(yīng)用或者C# WinForms。這里有個(gè)常見的坑別人用VS2019創(chuàng)建的C#上位機(jī)源碼你用VS2015打開大概率會(huì)失敗。因?yàn)?csproj文件格式和老版本不一致NuGet包和C#語言版本也可能不兼容。反過來如果你用VS2015開發(fā)又希望讓別人用VS2019/2022打開那就盡量別用太新的語言特性。我曾經(jīng)有一個(gè)項(xiàng)目在.NET Framework 4.5.2上用VS2015寫的交給客戶后被他們用VS2022升級(jí)打開結(jié)果代碼里用了async void事件處理器的重載簽名都變了編譯直接報(bào)錯(cuò)。開發(fā)串口程序時(shí)我通常還會(huì)單獨(dú)安裝一個(gè)NuGet包System.IO.Ports。雖然.NET 8桌面應(yīng)用已經(jīng)內(nèi)置了串口支持但如果你寫的是類庫、需要在其他項(xiàng)目復(fù)用顯式引用這個(gè)包會(huì)更省心。2.2 協(xié)議分析和字節(jié)拼裝不要一上來就寫代碼串口通信的核心是協(xié)議。設(shè)備端是STM32、51單片機(jī)、PLC還是掃碼槍決定了你的收發(fā)幀格式。常見協(xié)議結(jié)構(gòu)一般包括幀頭比如0xAA 0x55用來識(shí)別一幀開始。命令字/地址告訴設(shè)備你要讀什么、寫什么。數(shù)據(jù)長(zhǎng)度告訴接收方這幀有多少有效數(shù)據(jù)。數(shù)據(jù)區(qū)真正要傳的內(nèi)容。校驗(yàn)CRC16、累加和、異或校驗(yàn)等。幀尾0x0D 0x0A之類的結(jié)束符。我開始設(shè)計(jì)協(xié)議前會(huì)把通信雙方的字節(jié)流畫出來例如請(qǐng)求幀: AA 55 01 03 00 C8 0D 0A 幀頭 幀頭 命令 長(zhǎng)度 數(shù)據(jù) 校驗(yàn) 幀尾然后用一個(gè)Hex字節(jié)數(shù)組把請(qǐng)求發(fā)出去再在接收端按同樣的規(guī)則去匹配幀頭、解析長(zhǎng)度、提取數(shù)據(jù)、校驗(yàn)。舉個(gè)例子如果協(xié)議是“讀取溫濕度返回4字節(jié)溫度4字節(jié)濕度”那么收到數(shù)據(jù)后需要先找到幀頭再判斷數(shù)據(jù)長(zhǎng)度是否足夠最后按偏移量截取字節(jié)數(shù)組。截取字符串的方法在純文本協(xié)議時(shí)可用但在二進(jìn)制協(xié)議里不要用Substring去截字符串因?yàn)樽止?jié)到字符串之間的編碼轉(zhuǎn)換會(huì)產(chǎn)生很多坑。2.3 SerialPort核心封裝打開、關(guān)閉、收發(fā)、異常處理生產(chǎn)環(huán)境里的串口通信不能把SerialPort的調(diào)用散落在窗體各個(gè)按鈕事件里。我會(huì)封裝一個(gè)SerialPortManager類統(tǒng)一管理端口枚舉、打開、關(guān)閉、發(fā)送、訂閱接收事件和異常恢復(fù)。public class SerialPortManager : IDisposable { private SerialPort _port; public event Actionbyte[] DataReceived; public SerialPortManager(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.DataReceived OnDataReceived; } public void Open() { if (_port.IsOpen) return; _port.Open(); } public void Close() { if (_port.IsOpen) _port.Close(); } public void Send(byte[] data) { if (!_port.IsOpen) throw new InvalidOperationException(串口未打開); _port.Write(data, 0, data.Length); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int count _port.BytesToRead; byte[] buffer new byte[count]; _port.Read(buffer, 0, count); DataReceived?.Invoke(buffer); } public void Dispose() { _port.DataReceived - OnDataReceived; Close(); _port.Dispose(); } }這里有一個(gè)細(xì)節(jié)DataReceived事件里盡量不要用ReadLine()、ReadExisting()這些偏向文本的方法。要么用Read(byte[], int, int)讀原始字節(jié)要么用ReadBufferSize控制緩沖區(qū)大小。因?yàn)楹芏鄥f(xié)議是二進(jìn)制的用文本方法容易把0x0A這樣的換行符誤當(dāng)成結(jié)束標(biāo)志。3. 數(shù)據(jù)上來的那一刻才是問題的開始接收處理與UI刷新串口通信的困難不在打開串口而在怎么把“斷續(xù)的字節(jié)流”還原成“完整的一幀幀數(shù)據(jù)”再流暢地顯示到界面上。這一章的內(nèi)容可以說是串口上位機(jī)開發(fā)里最容易翻車的地方。3.1 串口接收事件“一段一段”到不能直接按幀解析很多設(shè)備的發(fā)送是有間隔的比如每100ms發(fā)一幀每幀16字節(jié)。但SerialPort的DataReceived事件可不會(huì)保證每次觸發(fā)正好拿到16字節(jié)。它可能先觸發(fā)拿到7字節(jié)再觸發(fā)拿到9字節(jié)也可能緩沖區(qū)里積累了3幀共48字節(jié)一次性觸發(fā)。如果直接按“來一次事件就解析一次”處理你的程序大概率會(huì)經(jīng)常出錯(cuò)。解決思路很簡(jiǎn)單先在內(nèi)存里開一個(gè)Listbyte暫存收到的數(shù)據(jù)每次收到數(shù)據(jù)都追加進(jìn)去然后在一個(gè)循環(huán)里嘗試從緩存中解析出完整幀。解析成功后把這幀從緩存中移除再繼續(xù)解析下一幀。核心邏輯可以用一個(gè)BufferManager來做public class ReceiveBuffer { private readonly Listbyte _buffer new Listbyte(); public void Append(byte[] data) _buffer.AddRange(data); public byte[] TryParseFrame() { // 查找?guī)^ int headIndex FindHead(); if (headIndex 0) return null; if (headIndex 0) _buffer.RemoveRange(0, headIndex); // 丟棄幀頭前的垃圾數(shù)據(jù) if (_buffer.Count 2) return null; int length _buffer[1]; // 假設(shè)協(xié)議的第二個(gè)字節(jié)是數(shù)據(jù)長(zhǎng)度 int frameLength length 4; // 幀頭長(zhǎng)度數(shù)據(jù)校驗(yàn) if (_buffer.Count frameLength) return null; byte[] frame _buffer.GetRange(0, frameLength).ToArray(); _buffer.RemoveRange(0, frameLength); return frame; } }這樣無論設(shè)備是連續(xù)發(fā)多幀還是拆成小包發(fā)最終都能按協(xié)議幀的邊界正確解析出來。注意我這里用_buffer[1]做長(zhǎng)度字段只是舉例實(shí)際協(xié)議字段位置要以你手上的協(xié)議文檔為準(zhǔn)。3.2 循環(huán)采集數(shù)據(jù)導(dǎo)致UI卡頓根因和四個(gè)解法“C#循環(huán)數(shù)據(jù)采集和UI刷新卡頓”是一個(gè)幾乎人人都會(huì)被拷打的典型問題。原因很簡(jiǎn)單SerialPort的DataReceived事件在后臺(tái)線程觸發(fā)而WinForms/WPF的UI控件不能在非UI線程直接修改。很多人為了圖省事直接在事件里寫Invoke回UI線程去刷新TextBox、Chart、DataGridView。如果設(shè)備每50ms來一幀數(shù)據(jù)UI每50ms就被強(qiáng)制刷新一次界面就會(huì)卡得沒法看因?yàn)閁I線程長(zhǎng)期被刷新邏輯霸占連鼠標(biāo)拖動(dòng)窗口都要排隊(duì)。我常用的四種解法從易到難排降低刷新頻率把接收到的原始數(shù)據(jù)都存到內(nèi)存隊(duì)列里用System.Windows.Forms.Timer每隔200ms~500ms刷新一次UI。用線程安全隊(duì)列ConcurrentQueuebyte[] incomingQueue接收線程只入隊(duì)UI定時(shí)器出隊(duì)并更新界面。這樣UI線程的壓力可控。數(shù)據(jù)聚合顯示如果只是顯示數(shù)字沒必要每幀都刷新TextBox。可以在UI定時(shí)器里把這段時(shí)間的最大值、最小值、平均值顯示出來。高頻實(shí)時(shí)曲線用雙緩沖繪圖控件用BufferedGraphics或圖表庫自己控制重繪區(qū)域避免每幀F(xiàn)ull Refresh。實(shí)時(shí)性要求和UI流暢度要平衡。很多設(shè)備其實(shí)100ms發(fā)一幀200ms刷新一次UI完全夠用而UI卡頓的概率會(huì)大幅下降。如果你非要做毫秒級(jí)實(shí)時(shí)顯示那就不應(yīng)該用常規(guī)的UI控件而是用專門的高性能曲線控件或者在獨(dú)立面板里繪制。3.3 亂碼、丟數(shù)據(jù)、字符串截取編碼問題一鍋端串口通信里“亂碼”是出現(xiàn)頻率最高的關(guān)鍵詞。絕大多數(shù)亂碼不是硬件問題而是發(fā)送方和接收方的編碼方式不一致。比如設(shè)備返回的是GBK編碼的中文電文你在C#里用Encoding.UTF8.GetString去解碼出來的就是一團(tuán)亂碼反過來也一樣。正確的做法是先把設(shè)備協(xié)議文檔確認(rèn)清楚純ASCII、UTF-8、GBK還是ANSI。然后再對(duì)應(yīng)選擇解碼方式Encoding.UTF8.GetString(buffer, 0, count); Encoding.GetEncoding(GBK).GetString(buffer, 0, count);另外在拆解字符串時(shí)比如要截取從第2個(gè)字節(jié)開始的后4個(gè)字節(jié)這4個(gè)字節(jié)如果是數(shù)值型數(shù)據(jù)根本不應(yīng)該轉(zhuǎn)成字符串而是應(yīng)該轉(zhuǎn)成UInt16、Int32這類數(shù)值ushort temperature BitConverter.ToUInt16(buffer, 2);很多初學(xué)者喜歡把所有接收數(shù)據(jù)都用Encoding.ASCII.GetString變成字符串再用Substring截取再int.Parse轉(zhuǎn)數(shù)字。這套流程在純文本協(xié)議里勉強(qiáng)能用但在二進(jìn)制協(xié)議里會(huì)出大事——二進(jìn)制數(shù)里很多字節(jié)可能不是有效的ASCII字符轉(zhuǎn)換成字符串后長(zhǎng)度、內(nèi)容都會(huì)改變Substring的位置全部對(duì)不上。所以我的原則是二進(jìn)制協(xié)議全程用byte[]處理字符串截取只在真正處理ASCII協(xié)議時(shí)才使用數(shù)值從字節(jié)轉(zhuǎn)換用BitConverter。順帶一提串口發(fā)送中文時(shí)也要注意編碼。如果設(shè)備要顯示中文通常需要發(fā)送GBK字節(jié)而不是UTF-8字節(jié)。3.4 掃碼槍等外部設(shè)備的“自動(dòng)觸發(fā)”事件怎么接入“C#掃碼槍觸發(fā)事件”也是熱搜詞。大多數(shù)USB或串口掃碼槍本質(zhì)上是一個(gè)“鍵盤輸入設(shè)備”或者“串口設(shè)備”。如果它默認(rèn)模擬鍵盤焦點(diǎn)在哪個(gè)文本框它就往哪里輸入字符如果它被配置為串口模式就需要走串口接收。用C#串口接掃碼槍最常見的模式是掃碼槍掃描條碼后自動(dòng)發(fā)送一串ASCII字符并以回車0x0D結(jié)尾。這時(shí)用SerialPort.ReadLine()或者接收緩存里按回車字符截?cái)嗑湍艿玫綏l碼內(nèi)容。注意掃碼槍的波特率默認(rèn)常見是9600但部分新款是115200要對(duì)上設(shè)備配置。接入掃碼槍事件時(shí)我建議不要在DataReceived里直接彈窗或查詢數(shù)據(jù)庫。掃到一個(gè)條碼往往意味著要馬上執(zhí)行一次產(chǎn)品查詢、庫存更新、記錄入庫。這些操作如果放到后臺(tái)線程界面會(huì)阻塞甚至卡死。正確的做法是先把條碼解析出來扔進(jìn)ConcurrentQueue再由后臺(tái)任務(wù)或者異步方法去處理。這樣連續(xù)快速掃碼時(shí)程序也能保持響應(yīng)。4. 串口通信的擴(kuò)展場(chǎng)景Modbus、串口屏、STM32/51實(shí)戰(zhàn)學(xué)會(huì)了基礎(chǔ)的收發(fā)解析你就能應(yīng)對(duì)大多數(shù)串口場(chǎng)景了。但在實(shí)際工控項(xiàng)目中還會(huì)碰到一些現(xiàn)成的通信協(xié)議和設(shè)備比如Modbus RTU、串口屏、單片機(jī)自定義協(xié)議。這一章挑三個(gè)典型場(chǎng)景講。4.1 C#做Modbus RTU主站NModbus4的使用與坑Modbus是工業(yè)領(lǐng)域最常用的串口協(xié)議之一RTU模式通過串口傳輸二進(jìn)制幀CRC校驗(yàn)是必須的。C#里最有名的庫是NModbus4它提供ModbusSerialMaster類可以很輕松地讀寫保持寄存器、線圈、離散輸入。using Modbus.Device; using (var port new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One)) { port.Open(); var master ModbusSerialMaster.CreateRtu(port); ushort[] registers master.ReadHoldingRegisters(1, 0, 10); }這個(gè)庫用起來簡(jiǎn)單但有幾個(gè)坑你必須知道它底層還是依賴SerialPort的DataReceived機(jī)制所以如果你同時(shí)自己處理接收事件會(huì)和庫內(nèi)部讀取沖突造成讀到的數(shù)據(jù)錯(cuò)亂。默認(rèn)超時(shí)時(shí)間可能太短。當(dāng)設(shè)備響應(yīng)慢、或串口通信受干擾時(shí)ReadHoldingRegisters會(huì)拋超時(shí)異常。你需要把串口的ReadTimeout和WriteTimeout調(diào)大并在catch里做重試。串口波特率9600在Modbus上最常見但一定不要自己改設(shè)備的波特率要和從站保持一致。如果你不想用這個(gè)庫也可以用3.1節(jié)的接收緩沖區(qū)自己實(shí)現(xiàn)Modbus RTU幀解析多機(jī)輪詢時(shí)用一個(gè)隊(duì)列管理輪詢命令避免同時(shí)發(fā)多條命令導(dǎo)致從站響應(yīng)沖突。4.2 和STM32/51單片機(jī)通信的幀結(jié)構(gòu)設(shè)計(jì)STM32F103C8T6、51單片機(jī)這類MCU是C#上位機(jī)最常見的對(duì)接對(duì)象。MCU端的資源有限協(xié)議越簡(jiǎn)單越好。我比較推薦的幀結(jié)構(gòu)是字段長(zhǎng)度說明幀頭2字節(jié)0xAA 0x55命令1字節(jié)0x01讀取0x02寫入等數(shù)據(jù)長(zhǎng)度1字節(jié)后面的數(shù)據(jù)區(qū)字節(jié)數(shù)數(shù)據(jù)區(qū)N字節(jié)按照命令定義校驗(yàn)1字節(jié)前面所有字節(jié)的異或和C#端發(fā)送讀取命令時(shí)按字節(jié)拼好數(shù)組計(jì)算異或校驗(yàn)然后Write出去。單片機(jī)收到后做同樣的校驗(yàn)如果校驗(yàn)不對(duì)就丟棄。反過來單片機(jī)返回?cái)?shù)據(jù)時(shí)上位機(jī)用3.1節(jié)的接收緩沖區(qū)解析。這里要特別提醒MCU端的串口中斷處理如果寫得不夠好你上位機(jī)發(fā)送命令的間隔太快單片機(jī)可能來不及處理導(dǎo)致丟幀。所以上位機(jī)發(fā)命令時(shí)一般要設(shè)置一個(gè)應(yīng)答確認(rèn)機(jī)制超時(shí)未收到回復(fù)就重發(fā)。重發(fā)次數(shù)限制3~5次不要無限重發(fā)否則一旦設(shè)備掉線你會(huì)把整個(gè)串口堵死。4.3 陶晶馳串口屏等顯示設(shè)備的通信注意事項(xiàng)串口屏是另一種常見外設(shè)比如陶晶馳、迪文、大彩等品牌。它們通常用串口接收指令來切換畫面、顯示文本、設(shè)置進(jìn)度條。以陶晶馳為例它的串口屏往往內(nèi)置了組態(tài)軟件只需要往串口發(fā)送特定格式的指令比如在變量地址寫入數(shù)值。和普通MCU不同串口屏對(duì)指令時(shí)序要求不太高但要注意幾點(diǎn)有些串口屏支持主動(dòng)上傳觸摸事件。這時(shí)候你也需要接收解析。屏的通斷、復(fù)位和上位機(jī)啟動(dòng)順序很重要。很多串口屏在上電后的前幾百毫秒不響應(yīng)命令如果上位機(jī)一打開串口就瘋狂發(fā)指令指令會(huì)丟失。建議串口打開后延時(shí)200ms~500ms再開始初始化。串口屏的指令集文檔會(huì)寫“發(fā)送HEX”還是“發(fā)送ASCII”。比如有些屏要發(fā)0xAA 0x00 0x01這樣的HEX幀有些屏要發(fā)page0這個(gè)ASCII字符串。兩者代碼寫法完全不同一定要先確認(rèn)。5. 串口調(diào)試路上繞不開的坑排查思路與工具最后聊一聊真到了現(xiàn)場(chǎng)通信不通、數(shù)據(jù)亂、設(shè)備掉線時(shí)該怎么排查。我見過太多人在代碼里一層層加日志搞了一天最后發(fā)現(xiàn)是USB轉(zhuǎn)串口線的問題。所以排查順序比排查深度更重要。5.1 為什么換一根USB轉(zhuǎn)串口線通信就掛了USB轉(zhuǎn)串口線的芯片有CH340、CP2102、FT232、PL2303等。不同芯片在驅(qū)動(dòng)、穩(wěn)定性、兼容性上差別很大。CH340便宜但部分兼容性一般FT232貴但穩(wěn)定工業(yè)客戶設(shè)備常常指定要用它。很多時(shí)候“同一個(gè)程序在開發(fā)機(jī)上跑得好好的換到客戶工控機(jī)上就不通”就是因?yàn)榭蛻舻墓た貦C(jī)上沒有對(duì)應(yīng)驅(qū)動(dòng)或者驅(qū)動(dòng)版本不對(duì)。排查方法很簡(jiǎn)單打開設(shè)備管理器看“端口COM和LPT”下面有沒有帶黃色感嘆號(hào)的設(shè)備。有感嘆號(hào)就是驅(qū)動(dòng)問題而不是代碼問題。另外不同USB口供電質(zhì)量不一樣。有些設(shè)備插在前面板USB口上工作正常插到后面板USB口就亂碼多半是供電或電磁干擾問題。這種情況可以考慮換帶屏蔽層的串口線、加磁環(huán)或者用USB隔離器。5.2 串口被占用、設(shè)備掉線、接收中斷的恢復(fù)策略C#里打開串口時(shí)如果提示“拒絕訪問”或“端口被占用”通常有三種原因上一次程序異常退出沒有正確關(guān)閉串口另一個(gè)程序占用了相同的COM口號(hào)USB轉(zhuǎn)串口設(shè)備被重新插拔后COM口號(hào)變了但你的程序還按舊的COM口號(hào)打開。我的策略是在啟動(dòng)時(shí)自動(dòng)枚舉SerialPort.GetPortNames()把可用端口列到下拉框而不是固定寫死COM3打開失敗時(shí)給出異常提示并在2秒后重試程序退出時(shí)保證調(diào)用Dispose關(guān)閉串口還可用SerialPort.BaseStream的DataReceived事件和ErrorReceived事件捕獲斷線、溢出等情況。設(shè)備掉線也是老問題。USB轉(zhuǎn)串口設(shè)備在通信過程中被人拔掉再插上后COM口號(hào)可能會(huì)變成COM4或者COM7程序如果還持著COM3就直接異常。比較成熟的方案是開啟一個(gè)后臺(tái)線程定期檢測(cè)串口設(shè)備是否存在不存在就自動(dòng)重新枚舉并重連。5.3 調(diào)試工具、日志和復(fù)現(xiàn)手段寫C#串口程序時(shí)我建議手邊常備三樣工具串口調(diào)試助手用于在沒有C#程序的情況下直接和硬件設(shè)備通信確認(rèn)設(shè)備本身正常。邏輯分析儀當(dāng)懷疑硬件時(shí)序、波特率、字節(jié)內(nèi)容出錯(cuò)時(shí)用邏輯分析儀抓UART波形能直接看到TX/RX的電平變化非常直觀。Hex日志自己在程序里打印收發(fā)字節(jié)的十六進(jìn)制。不要只打字符串很多不可見字符在字符串界面是空白只有Hex日志才能還原現(xiàn)場(chǎng)。我的日志打印一般是這樣public static string ToHex(byte[] data) { return string.Join( , data.Select(b b.ToString(X2))); }無論是發(fā)送還是接收都記錄一條帶時(shí)間戳的Hex日志。這樣設(shè)備說“我發(fā)了0x01 0x03 0x00 0x00 0x00 0x0A”你的Hex日志里一看就知道程序到底收到了什么、改了沒有。排查亂碼問題時(shí)Hex日志比任何高級(jí)調(diào)試器都管用。串口上位機(jī)開發(fā)最考驗(yàn)人的不是C#語言本身而是你對(duì)“字節(jié)流邊界”“線程模型”“設(shè)備協(xié)議”這三件事的理解。我踩過不少坑也因?yàn)檫@些坑總結(jié)出一套固定的封裝和排查流程。如果你的項(xiàng)目剛起步不要急著追求炫酷的界面先把一個(gè)穩(wěn)定的串口通信層寫好再往上加業(yè)務(wù)邏輯。后面不管接的是什么設(shè)備都會(huì)省心很多。