試:破解特殊字節(jié)與二進(jìn)制安全陷阱)
干了這么多年Linux下的串口調(diào)試我越來(lái)越覺得一個(gè)反直覺的現(xiàn)象很有意思真正把人折磨到崩潰的往往不是波特率沒對(duì)上、數(shù)據(jù)位配錯(cuò)這種明面上的參數(shù)問題而是數(shù)據(jù)流里某個(gè)不起眼的“特殊字節(jié)”。它可能只是一個(gè)普通的0x00、0x11、0x13平時(shí)安安靜靜躺在緩沖區(qū)里但一旦被終端驅(qū)動(dòng)、庫(kù)函數(shù)或者業(yè)務(wù)邏輯以特殊方式解釋輕則數(shù)據(jù)截?cái)嘀貏t整個(gè)協(xié)議狀態(tài)機(jī)錯(cuò)亂表現(xiàn)還極其隱蔽。這篇文章圍繞虛擬串口的搭建、調(diào)試和自動(dòng)化測(cè)試展開重點(diǎn)拆解那些“特殊字節(jié)”到底特殊在哪里以及我在研發(fā)調(diào)試、測(cè)試模擬、硬件聯(lián)調(diào)三個(gè)場(chǎng)景里踩過(guò)的坑和沉淀下來(lái)的排查鏈路。無(wú)論你是單片機(jī)工程師、嵌入式Linux開發(fā)者還是做自動(dòng)化測(cè)試的QA只要手里有串口協(xié)議要調(diào)這篇文章應(yīng)該能幫你少走不少?gòu)澛贰?. 虛擬串口解決的真實(shí)問題從設(shè)備不足到異常場(chǎng)景構(gòu)造1.1 物理串口在開發(fā)調(diào)試中的天然瓶頸做嵌入式或者物聯(lián)網(wǎng)開發(fā)的人對(duì)這套流程應(yīng)該不陌生拿到一塊開發(fā)板先用USB轉(zhuǎn)串口線接到電腦打開終端工具波特率設(shè)好開始看日志。單個(gè)設(shè)備這么調(diào)沒問題但一旦進(jìn)入多設(shè)備并發(fā)調(diào)試物理串口的瓶頸立刻暴露出來(lái)。電腦上的串口資源是有限的臺(tái)式機(jī)可能只有一兩個(gè)板載串口筆記本基本只能靠USB轉(zhuǎn)串口擴(kuò)展插多了USB Hub供電還不穩(wěn)定。更要命的是物理串口調(diào)試沒法方便地構(gòu)造異常場(chǎng)景你要模擬設(shè)備突然斷開、模擬某段數(shù)據(jù)丟失、模擬對(duì)端發(fā)來(lái)一個(gè)帶控制字符的幀靠硬件設(shè)備很難穩(wěn)定復(fù)現(xiàn)。虛擬串口恰恰解決了這兩個(gè)痛點(diǎn)。它用軟件在系統(tǒng)里模擬出一對(duì)相互連接的串口設(shè)備你可以在開發(fā)機(jī)上無(wú)限創(chuàng)建這樣的串口對(duì)一頭接被測(cè)程序一頭接測(cè)試腳本把數(shù)據(jù)鏈路徹底從硬件中解放出來(lái)。1.2 虛擬串口為什么能做到“以假亂真”很多人第一次接觸虛擬串口時(shí)都會(huì)問這玩意兒和真實(shí)的串口芯片有什么區(qū)別軟件模擬的東西時(shí)序上會(huì)不會(huì)差很多要回答這個(gè)問題得先理解Linux里串口通信的抽象模型。對(duì)應(yīng)用程序來(lái)說(shuō)串口就是一個(gè)字符設(shè)備文件你在/dev/ttyS0或者/dev/ttyUSB0上做open/read/write驅(qū)動(dòng)負(fù)責(zé)把數(shù)據(jù)搬運(yùn)到硬件FIFO再由UART芯片完成電平轉(zhuǎn)換和收發(fā)。應(yīng)用層并不關(guān)心電平怎么跳變、起始位停止位怎么對(duì)齊它只關(guān)心read能讀到什么、write寫出去什么。虛擬串口正是利用了這個(gè)抽象層。它最常見的實(shí)現(xiàn)方式是基于內(nèi)核的偽終端pty機(jī)制通過(guò)軟件在內(nèi)存里模擬出一對(duì)互相連接的終端設(shè)備。一端寫入的數(shù)據(jù)立刻可以從另一端讀出來(lái)效果就像一根虛擬的串口線把兩個(gè)程序連在一起。因?yàn)檎麄€(gè)數(shù)據(jù)通路都在內(nèi)存里沒有物理硬件的參與所以時(shí)序的一致性反而比某些劣質(zhì)USB轉(zhuǎn)串口芯片更穩(wěn)定。但這也就引出了一個(gè)關(guān)鍵認(rèn)知虛擬串口模擬的是“串口這個(gè)軟件抽象”不是模擬“UART芯片的電氣行為”。如果你把虛擬串口當(dāng)成物理串口的完整替代品去驗(yàn)證電平信號(hào)、驗(yàn)證硬件流控的物理表現(xiàn)那它做不到。但如果你只是想驗(yàn)證協(xié)議棧、數(shù)據(jù)幀解析、超時(shí)重傳邏輯虛擬串口完全夠用而且比物理串口更好用。我個(gè)人的實(shí)踐體會(huì)是虛擬串口在研發(fā)調(diào)試階段的價(jià)值遠(yuǎn)不止“替代物理串口”這么簡(jiǎn)單。它真正讓人上癮的能力是你可以人為地往數(shù)據(jù)鏈路里注入各種邊界數(shù)據(jù)而這些操作在物理串口上基本不可控。1.3 虛擬串口適合誰(shuí)、不適合誰(shuí)先說(shuō)不適合的場(chǎng)景如果你要驗(yàn)證UART驅(qū)動(dòng)的DMA搬運(yùn)邏輯、要測(cè)量中斷延遲對(duì)收發(fā)時(shí)序的影響、要確認(rèn)RS485方向切換的硬件行為虛擬串口幫不了你這些必須上真實(shí)硬件。再說(shuō)適合的場(chǎng)景協(xié)議棧開發(fā)、上位機(jī)解析邏輯調(diào)試、固件通信模塊聯(lián)調(diào)、自動(dòng)化回歸測(cè)試、多設(shè)備并發(fā)數(shù)據(jù)鏈路驗(yàn)證。這些場(chǎng)景的共同特征是你關(guān)心的是“字節(jié)級(jí)的數(shù)據(jù)內(nèi)容”和“邏輯層的時(shí)序配合”而不是“電氣層的信號(hào)細(xì)節(jié)”。一句話總結(jié)物理串口驗(yàn)證硬件虛擬串口驗(yàn)證邏輯。把這兩個(gè)場(chǎng)景分清楚你在后續(xù)調(diào)試中就不會(huì)被“虛擬串口到底靠不靠譜”這種問題反復(fù)糾纏。2. 那個(gè)“特殊字節(jié)”二進(jìn)制安全與控制字符的真相2.1 不是每個(gè)字節(jié)都生而平等標(biāo)題里那個(gè)“特殊的字節(jié)”是我在這篇文章里最想展開的點(diǎn)。串口協(xié)議本質(zhì)上是個(gè)字節(jié)流協(xié)議它不區(qū)分“數(shù)據(jù)字節(jié)”和“控制字節(jié)”——對(duì)UART芯片來(lái)說(shuō)0x00和0x41沒有任何區(qū)別都是一樣的電平翻轉(zhuǎn)。但對(duì)上層的終端驅(qū)動(dòng)、庫(kù)函數(shù)和業(yè)務(wù)代碼來(lái)說(shuō)某些字節(jié)攜帶了特殊語(yǔ)義處理不當(dāng)就會(huì)出問題。最常見的幾個(gè)特殊字節(jié)有這些字節(jié)特殊含義踩坑場(chǎng)景0x00空字節(jié)C字符串終止符用字符串函數(shù)處理二進(jìn)制幀數(shù)據(jù)被截?cái)?x0A換行符LF終端驅(qū)動(dòng)開啟換行轉(zhuǎn)換數(shù)據(jù)被篡改0x0D回車符CR與0x0A配合時(shí)可能被轉(zhuǎn)換成單個(gè)換行0x11XON字符軟件流控開啟時(shí)被解釋為“恢復(fù)發(fā)送”0x13XOFF字符軟件流控開啟時(shí)被解釋為“暫停發(fā)送”0x1AEOF標(biāo)記某些協(xié)議和庫(kù)把它當(dāng)作文件結(jié)束符這里面的關(guān)鍵在于同樣是0x00在文本協(xié)議里它是正常的數(shù)據(jù)內(nèi)容但在以字符串為核心處理邏輯的代碼里它就成了“到此為止”的哨兵。我見過(guò)太多人在串口調(diào)試上栽跟頭根源不是在協(xié)議設(shè)計(jì)上而是在語(yǔ)言和庫(kù)的字符串處理機(jī)制上。2.2 termios標(biāo)志位虛擬串口調(diào)試的頭號(hào)陷阱很多人以為虛擬串口是純軟件模擬不存在物理硬件的參數(shù)設(shè)置問題所以直接打開設(shè)備就開始讀寫然后發(fā)現(xiàn)收上來(lái)的數(shù)據(jù)和發(fā)出去的對(duì)不上。排查半天問題往往出在termios的默認(rèn)配置上。Linux的串口驅(qū)動(dòng)遵循POSIX終端接口規(guī)范設(shè)備打開后會(huì)套用一套默認(rèn)的終端行為。這套默認(rèn)行為對(duì)“人機(jī)交互終端”是合理的但對(duì)“數(shù)據(jù)通信串口”就是災(zāi)難。比如默認(rèn)模式下收到0x0D會(huì)被轉(zhuǎn)換成0x0AICRNL標(biāo)志輸出時(shí)0x0A會(huì)被轉(zhuǎn)換成0x0D 0x0AOPOST標(biāo)志收到CtrlC、CtrlZ這類信號(hào)字符會(huì)直接觸發(fā)中斷或者掛起ISIG標(biāo)志收到字節(jié)還會(huì)回顯到發(fā)送端ECHO標(biāo)志。這些默認(rèn)行為在交互式終端里是貼心功能在串口數(shù)據(jù)通信里就是數(shù)據(jù)篡改器。你在虛擬串口一端寫入0x0A另一端讀到的可能變成了0x0D 0x0A你在二進(jìn)制幀里帶了個(gè)0x13對(duì)方平臺(tái)如果開了軟件流控直接就把你的數(shù)據(jù)吞了。所以虛擬串口調(diào)試的第一個(gè)規(guī)范動(dòng)作就是把串口設(shè)為raw模式。用stty命令可以快速搞定stty -F /dev/pts/3 raw -echo -ixon -ixoff這里面的關(guān)鍵參數(shù)拆開看raw關(guān)閉所有輸入輸出轉(zhuǎn)換數(shù)據(jù)原樣透?jìng)?echo關(guān)閉回顯防止數(shù)據(jù)被彈回發(fā)送端-ixon關(guān)閉軟件流控的輸入解釋0x11/0x13不再被當(dāng)作XON/XOFF-ixoff關(guān)閉軟件流控的輸出控制避免系統(tǒng)自動(dòng)發(fā)送流控字符如果用Python或者其他語(yǔ)言寫調(diào)試工具也需要在打開串口后顯式設(shè)置termios。Python的serial庫(kù)或者直接操作termios模塊都能做到。我在項(xiàng)目里一般直接用termios.tcsetattr把c_iflag、c_oflag、c_lflag全部清零c_cflag只保留波特率和數(shù)據(jù)位設(shè)置效果等同于raw模式。2.3 特殊字節(jié)的經(jīng)典翻車實(shí)錄這里分享一個(gè)我實(shí)際處理過(guò)的案例。某項(xiàng)目需要用虛擬串口模擬一臺(tái)溫控設(shè)備協(xié)議約定設(shè)備上電后先發(fā)送幀頭0xAA 0x55然后跟著16字節(jié)的狀態(tài)數(shù)據(jù)最后是2字節(jié)CRC校驗(yàn)。上位機(jī)用某圖形化串口庫(kù)按幀讀取邏輯上看起來(lái)沒有毛病——先讀兩個(gè)字節(jié)校驗(yàn)幀頭再按長(zhǎng)度讀數(shù)據(jù)然后校驗(yàn)CRC。但實(shí)際跑起來(lái)偶發(fā)出現(xiàn)幀錯(cuò)位往往讀到幀頭后下一個(gè)字節(jié)根本對(duì)不上數(shù)據(jù)解析始終飄忽不定。整個(gè)團(tuán)隊(duì)排查了一個(gè)下午從波特率配置到數(shù)據(jù)位校驗(yàn)全查了一遍沒問題。最后我用xxd把虛擬串口上實(shí)際的字節(jié)流dump出來(lái)才發(fā)現(xiàn)問題狀態(tài)數(shù)據(jù)里偶爾會(huì)出現(xiàn)0x11和0x13這兩個(gè)字節(jié)而庫(kù)內(nèi)部默認(rèn)開啟了軟件流控直接把這倆字節(jié)當(dāng)成XON/XOFF控制符吞掉了。也就是說(shuō)發(fā)送端確實(shí)發(fā)出了16字節(jié)數(shù)據(jù)但接收端只收到了15字節(jié)幀結(jié)構(gòu)自然全亂了。這個(gè)案例的教訓(xùn)是凡是處理二進(jìn)制串口協(xié)議必須使用二進(jìn)制安全的讀寫方式顯式關(guān)閉一切終端轉(zhuǎn)換和流控解釋。圖形化串口工具自帶的“智能處理”在多數(shù)時(shí)候不是幫你而是在坑你。2.4 怎么做一個(gè)可靠的特殊字節(jié)探測(cè)器在虛擬串口調(diào)試中我強(qiáng)烈建議養(yǎng)成一個(gè)習(xí)慣所有數(shù)據(jù)鏈路先做一次“回環(huán)字節(jié)掃描”。方法很簡(jiǎn)單寫一個(gè)腳本把0x00到0xFF共256個(gè)字節(jié)依次發(fā)出接收端原樣回傳發(fā)送端逐一比對(duì)。只要有一個(gè)字節(jié)對(duì)不上就說(shuō)明中間某個(gè)環(huán)節(jié)在做隱式轉(zhuǎn)換。這個(gè)256字節(jié)掃描我每次搭建新的虛擬串口鏈路都會(huì)跑一遍耗時(shí)不到一秒但能排除掉絕大部分“工具自帶智能處理”導(dǎo)致的問題。跑完掃描再聯(lián)調(diào)協(xié)議出現(xiàn)數(shù)據(jù)異常時(shí)你就可以放心地把矛頭對(duì)準(zhǔn)業(yè)務(wù)邏輯而不是懷疑鏈路本身。3. 從零搭建虛擬串口鏈路工具選型與參數(shù)細(xì)節(jié)3.1 socatLinux下最順手的虛擬串口創(chuàng)建工具Linux下創(chuàng)建虛擬串口對(duì)最常見也是最靠譜的工具是socat。它是一個(gè)萬(wàn)能的數(shù)據(jù)通道工具可以在兩個(gè)端點(diǎn)之間建立雙向數(shù)據(jù)鏈路而端點(diǎn)可以是pty、TCP端口、文件、設(shè)備節(jié)點(diǎn)等任何東西。創(chuàng)建虛擬串口對(duì)的命令非常簡(jiǎn)單socat -d -d pty,raw,echo0 pty,raw,echo0執(zhí)行后socat會(huì)輸出兩個(gè)設(shè)備節(jié)點(diǎn)類似這樣2024/xx/xx 10:00:00 socat[12345] N PTY is /dev/pts/3 2024/xx/xx 10:00:00 socat[12345] N PTY is /dev/pts/4這時(shí)候/dev/pts/3和/dev/pts/4就是一對(duì)虛擬串口任何一端寫入的數(shù)據(jù)都能從另一端讀到。我在實(shí)際使用時(shí)通常會(huì)加上linkname參數(shù)給設(shè)備節(jié)點(diǎn)指定一個(gè)固定的符號(hào)鏈接避免每次啟動(dòng)socat設(shè)備編號(hào)都變化socat -d -d pty,raw,echo0,link/tmp/vcom0 pty,raw,echo0,link/tmp/vcom1這樣不管底層的/pts/N怎么變/tmp/vcom0和/tmp/vcom1始終指向正確的節(jié)點(diǎn)調(diào)試腳本不用跟著改路徑。這里有三個(gè)細(xì)節(jié)值得展開第一raw模式必須顯式指定。不帶raw參數(shù)的socat pty節(jié)點(diǎn)會(huì)套用終端默認(rèn)行為回車換行轉(zhuǎn)換、信號(hào)字符解釋全開著調(diào)試二進(jìn)制協(xié)議必掛。加上raw之后數(shù)據(jù)在pty兩端透?jìng)鞑粨诫s任何轉(zhuǎn)換邏輯。第二echo0必須顯式指定。默認(rèn)情況下pty是帶回顯的你從一端寫入的數(shù)據(jù)除了從另一端讀出來(lái)還會(huì)原樣彈回發(fā)送端。如果你在調(diào)試腳本里先write再read極大概率讀到的是自己剛發(fā)出去的數(shù)據(jù)而不是對(duì)端真正的響應(yīng)。這個(gè)坑非常隱蔽因?yàn)閱慰词瞻l(fā)數(shù)據(jù)好像都正常但邏輯對(duì)不上。第三不要用socat的-d -d就把日志打印全開。-d -d是輸出錯(cuò)誤和警告信息如果要調(diào)試socat本身的數(shù)據(jù)流轉(zhuǎn)需要加-d -d -d甚至更多但日志太詳細(xì)會(huì)影響性能。實(shí)際調(diào)試中很少需要查看socat內(nèi)部日志直接抓業(yè)務(wù)側(cè)數(shù)據(jù)就夠了。3.2 兼容方案沒有socat的環(huán)境怎么辦有些嵌入式開發(fā)環(huán)境或者精簡(jiǎn)版Linux系統(tǒng)里沒有socat也沒有包管理器可以方便地裝。這時(shí)候可以用Python的內(nèi)置庫(kù)寫一個(gè)最小虛擬串口對(duì)核心思路是借助pty模塊打開偽終端再用select做雙向轉(zhuǎn)發(fā)。import os import pty import select def bridge(master_fd, slave_fd): while True: rlist, _, _ select.select([master_fd, slave_fd], [], []) for fd in rlist: data os.read(fd, 1024) target slave_fd if fd master_fd else master_fd os.write(target, data) if __name__ __main__: master1, slave1 pty.openpty() master2, slave2 pty.openpty() print(fvcom0: {os.ttyname(slave1)}) print(fvcom1: {os.ttyname(slave2)}) bridge(master1, master2)這個(gè)腳本只有三十行左右核心邏輯就是select監(jiān)聽兩個(gè)master端一端有數(shù)據(jù)就寫到另一端。因?yàn)閜ty的另一端是slave節(jié)點(diǎn)對(duì)應(yīng)用層來(lái)說(shuō)就是個(gè)串口設(shè)備文件行為和不帶raw配置的socat類似需要在業(yè)務(wù)代碼里顯式設(shè)置termios為raw模式。這個(gè)方案雖然簡(jiǎn)陋但在資源受限的環(huán)境里救過(guò)我好幾次。如果對(duì)延遲有要求可以把select換成epoll但絕大多數(shù)場(chǎng)景select足夠。3.3 數(shù)據(jù)連通性驗(yàn)證別急著上業(yè)務(wù)協(xié)議虛擬串口對(duì)創(chuàng)建好之后我建議先做一個(gè)最小化的數(shù)據(jù)連通性測(cè)試再上業(yè)務(wù)協(xié)議。測(cè)試方法很簡(jiǎn)單一端寫入一段帶有明顯邊界的數(shù)據(jù)另一端讀取并對(duì)比。echo hello-vcom /tmp/vcom0 cat /tmp/vcom1這里有個(gè)細(xì)節(jié)cat會(huì)一直等待輸入直到遇到EOF而在pty上EOF的觸發(fā)條件是兩端都關(guān)閉。所以上面的命令可能會(huì)卡住不要以為是鏈路問題按CtrlC退出即可。正確的做法是讀取固定長(zhǎng)度或者用超時(shí)控制。我自己更習(xí)慣用Python腳本做連通性驗(yàn)證因?yàn)榭梢跃_控制讀寫長(zhǎng)度import os fd0 os.open(/tmp/vcom0, os.O_RDWR) fd1 os.open(/tmp/vcom1, os.O_RDWR) os.write(fd0, bping) data os.read(fd1, 4) print(data) os.write(fd1, bpong) data os.read(fd0, 4) print(data)如果這一步能正確打出ping和pong說(shuō)明鏈路通了。接下來(lái)再做前面提到的256字節(jié)掃描確認(rèn)沒有隱式轉(zhuǎn)換然后才是協(xié)議聯(lián)調(diào)。把鏈路驗(yàn)證和協(xié)議驗(yàn)證分開排查問題時(shí)能省一半時(shí)間。4. 研發(fā)調(diào)試場(chǎng)景用虛擬串口把數(shù)據(jù)鏈路徹底“透明化”4.1 讓字節(jié)流“可視化”的三件套虛擬串口調(diào)試最舒服的一點(diǎn)是你可以同時(shí)在鏈路的任何一個(gè)環(huán)節(jié)插入觀測(cè)點(diǎn)。物理串口調(diào)試時(shí)想抓報(bào)文得外接一個(gè)邏輯分析儀或者用帶嗅探功能的串口工具虛擬串口完全不需要這些用命令行三件套就能完成90%的排障工作socat建鏈路、stty看參數(shù)、xxd看字節(jié)。看參數(shù)用stty -a就能把當(dāng)前串口的所有termios標(biāo)志全部列出來(lái)stty -F /tmp/vcom0 -a我每次遇到“收發(fā)數(shù)據(jù)對(duì)不上”的第一反應(yīng)就是跑這個(gè)命令看c_iflag、c_oflag、c_lflag、c_cflag四組標(biāo)志。只要看到ixon、icrnl、opost、echo這些標(biāo)志處于開啟狀態(tài)而業(yè)務(wù)又是二進(jìn)制協(xié)議那基本已經(jīng)定位到問題了——數(shù)據(jù)在終端層被動(dòng)了手腳。抓字節(jié)流用xxd可以清晰地看到每個(gè)字節(jié)的十六進(jìn)制值和ASCII解釋xxd /tmp/vcom0這條命令會(huì)把從虛擬串口讀到的數(shù)據(jù)逐字節(jié)dump出來(lái)左邊是十六進(jìn)制偏移中間是十六進(jìn)制字節(jié)右邊是ASCII字符。遇到0x11、0x13這種控制字符ASCII欄會(huì)顯示成不可打印字符的占位符一眼就能看出來(lái)。如果只想看純十六進(jìn)制不帶ASCII可以用xxd -p輸出全部是十六進(jìn)制字符串適合直接灌進(jìn)腳本做對(duì)比。也可以配合head -c指定讀取長(zhǎng)度一次只抓固定字節(jié)數(shù)。4.2 一次典型的數(shù)據(jù)幀錯(cuò)位排查鏈路我在虛擬串口調(diào)試中攢了一個(gè)典型的排查鏈路每次遇到“數(shù)據(jù)看起來(lái)對(duì)但解析不對(duì)”的問題都按這個(gè)順序走一遍。第一步確認(rèn)鏈路干凈。跑256字節(jié)掃描確認(rèn)虛擬串口兩端是原始透?jìng)髋懦齮ermios轉(zhuǎn)換的干擾。第二步抓發(fā)送端原始報(bào)文。在被測(cè)程序發(fā)送數(shù)據(jù)前加打印或者直接在虛擬串口一端用xxd抓包記錄下寫入鏈路的確切字節(jié)序列。第三步抓接收端實(shí)際報(bào)文。在接收程序解析前用xxd抓包記錄下從鏈路讀到的確切字節(jié)序列。第四步逐字節(jié)對(duì)比兩個(gè)序列找到第一個(gè)分叉點(diǎn)。分叉點(diǎn)前面的數(shù)據(jù)一般是正常的分叉點(diǎn)之后的差異往往就是問題所在。這個(gè)流程說(shuō)起來(lái)簡(jiǎn)單但執(zhí)行中有個(gè)容易忽略的細(xì)節(jié)發(fā)送端和接收端的抓包必須用同一臺(tái)虛擬串口的兩端而不是在某一端同時(shí)讀寫。很多人喜歡在一端開xxd另一端跑業(yè)務(wù)程序結(jié)果看到的是回顯和自己的數(shù)據(jù)混在一起越看越糊涂。正確的做法是xxd掛在接收端一側(cè)業(yè)務(wù)程序在發(fā)送端一側(cè)或者反過(guò)來(lái)。我之前那個(gè)溫控設(shè)備的案例就是用這個(gè)流程定位到0x11字節(jié)被軟件流控吞掉的。第二條開始發(fā)出去的16字節(jié)數(shù)據(jù)里有一個(gè)0x11接收端只讀到15字節(jié)幀長(zhǎng)校驗(yàn)直接失敗。定位到分叉點(diǎn)后再去查庫(kù)的流控配置問題立刻水落石出。4.3 為什么我堅(jiān)持用os.read/os.write而不是按行讀很多人在Linux下寫串口程序習(xí)慣用C語(yǔ)言的fread/fwrite或者Python的readline這是一個(gè)非常危險(xiǎn)的慣性。串口是字節(jié)流協(xié)議它沒有“行”的概念所謂換行符0x0A只是協(xié)議里可能出現(xiàn)的普通字節(jié)。如果你用readline去讀遇到0x0A就返回一個(gè)完整的二進(jìn)制幀可能被攔腰截?cái)?。我在虛擬串口調(diào)試工具里一律使用os.read和os.write配合顯式長(zhǎng)度控制。讀取時(shí)先明確自己期望多少個(gè)字節(jié)最多加一個(gè)超時(shí)保護(hù)絕不用流式讀行。os.read按字節(jié)數(shù)讀取超時(shí)用select控制寫出去的數(shù)據(jù)再逐字節(jié)比對(duì)返回結(jié)果這套組合在二進(jìn)制協(xié)議調(diào)試中是目前最可靠的做法。舉個(gè)例子溫控設(shè)備狀態(tài)幀是2字節(jié)幀頭16字節(jié)數(shù)據(jù)2字節(jié)CRC共20字節(jié)讀取邏輯就該是import os import select def read_exact(fd, count, timeout1.0): chunks [] remaining count while remaining 0: rlist, _, _ select.select([fd], [], [], timeout) if not rlist: raise TimeoutError(fread timeout, got {len(chunks)} bytes) data os.read(fd, remaining) if not data: raise EOFError(device closed) chunks.append(data) remaining - len(data) return b.join(chunks)這個(gè)函數(shù)每次從緩沖區(qū)讀取指定數(shù)量的字節(jié)讀不滿就一直循環(huán)直到湊夠或者超時(shí)。它能保證上層拿到的永遠(yuǎn)是完整的一幀不會(huì)被readline的“行”邏輯打斷。4.4 用虛擬串口模擬“愛發(fā)不發(fā)”的對(duì)端設(shè)備研發(fā)調(diào)試中有一個(gè)高頻需求模擬一個(gè)行為不可控的對(duì)端設(shè)備比如隨機(jī)延遲響應(yīng)、偶爾丟掉一幀、某段時(shí)間持續(xù)快速發(fā)數(shù)據(jù)。物理串口很難穩(wěn)定模擬這種行為虛擬串口則可以通過(guò)用戶態(tài)腳本輕松實(shí)現(xiàn)。我最常用的是在Python腳本里加一個(gè)while循環(huán)用random模塊控制發(fā)送節(jié)奏和內(nèi)容import os import random import time fd os.open(/tmp/vcom1, os.O_RDWR) while True: frame build_frame(random.randint(0, 100)) os.write(fd, frame) time.sleep(random.uniform(0.01, 0.5)) if random.random() 0.05: time.sleep(random.uniform(0.5, 1.0))這個(gè)腳本模擬了一個(gè)“時(shí)快時(shí)慢、偶爾停擺”的對(duì)端設(shè)備。被測(cè)程序如果在這種鏈路上還能穩(wěn)定解析、超時(shí)重傳邏輯正常那基本可以放心面對(duì)真實(shí)環(huán)境的不確定性。要注意的是虛擬串口模擬的不是真正的物理抖動(dòng)而是協(xié)議層面的時(shí)序不確定性。如果你的目標(biāo)是要測(cè)量物理層的時(shí)鐘精度或者信號(hào)毛刺這個(gè)方案不適用。但對(duì)絕大多業(yè)務(wù)邏輯驗(yàn)證來(lái)說(shuō)用戶態(tài)加延遲插入抖動(dòng)已經(jīng)足夠還原真實(shí)世界的“惡意”。5. 自動(dòng)化測(cè)試設(shè)計(jì)把虛擬串口變成可回歸的數(shù)據(jù)回放機(jī)5.1 虛擬串口和自動(dòng)化測(cè)試的三個(gè)契合點(diǎn)自動(dòng)化測(cè)試最講究的就是三個(gè)詞穩(wěn)定、可重復(fù)、可重置。物理串口在這三個(gè)方面都有天然短板設(shè)備不穩(wěn)定、異常場(chǎng)景不可控、測(cè)試結(jié)束后狀態(tài)難以恢復(fù)。虛擬串口則把這三點(diǎn)都變成了優(yōu)勢(shì)。穩(wěn)定方面虛擬串口的數(shù)據(jù)通路完全在內(nèi)存里沒有物理接觸不良的問題測(cè)試時(shí)不會(huì)出現(xiàn)“昨天能跑今天不能跑”的玄學(xué)??芍貜?fù)方面虛擬串口創(chuàng)建一對(duì)設(shè)備節(jié)點(diǎn)后每次執(zhí)行測(cè)試腳本用的都是同一個(gè)路徑數(shù)據(jù)內(nèi)容完全可控任何一次的收發(fā)序列都可以精確回放??芍刂梅矫鏈y(cè)試結(jié)束后直接刪掉pty設(shè)備對(duì)重新創(chuàng)建狀態(tài)干凈得就像剛開機(jī)。5.2 數(shù)據(jù)回放機(jī)讓協(xié)議驗(yàn)證變成流水線操作自動(dòng)化測(cè)試?yán)镂伊?xí)慣把被測(cè)試程序當(dāng)成一個(gè)“黑盒”虛擬串口一頭插進(jìn)黑盒的串口入口另一頭接一個(gè)數(shù)據(jù)回放機(jī)?;胤艡C(jī)預(yù)先準(zhǔn)備好一組十六進(jìn)制報(bào)文按測(cè)試用例的時(shí)序要求依次發(fā)送同時(shí)記錄被測(cè)試程序的應(yīng)答最后和預(yù)期結(jié)果對(duì)比。寫回放機(jī)時(shí)有一個(gè)關(guān)鍵設(shè)計(jì)報(bào)文不能硬編碼在腳本里應(yīng)該放在可配置的數(shù)據(jù)文件中用一行一個(gè)幀的方式維護(hù)。這樣協(xié)議有更新時(shí)只需要改數(shù)據(jù)文件不用改測(cè)試邏輯。十六進(jìn)制報(bào)文用空格分隔十六進(jìn)制字節(jié)注釋用#開頭加載時(shí)跳過(guò)空行和注釋行解析成bytes類型?;胤艡C(jī)腳本的核心邏輯大致是這樣import os import time def load_frames(path): frames [] with open(path, r) as f: for line in f: line line.strip() if not line or line.startswith(#): continue frames.append(bytes.fromhex(line)) return frames def replay(fd, frames, interval0.05): for frame in frames: os.write(fd, frame) time.sleep(interval)這個(gè)回放機(jī)可以和pytest組織起來(lái)每個(gè)測(cè)試用例加載不同的報(bào)文文件驗(yàn)證不同場(chǎng)景。比如一個(gè)異常場(chǎng)景的用例報(bào)文文件里放幾個(gè)帶錯(cuò)誤CRC的幀驗(yàn)證被測(cè)程序的檢錯(cuò)邏輯一個(gè)邊界場(chǎng)景的用例報(bào)文里塞滿0x00、0xFF、0x11、0x13這些特殊字節(jié)驗(yàn)證協(xié)議的狀態(tài)機(jī)不會(huì)被特殊字節(jié)干擾。這里我強(qiáng)烈建議每個(gè)自動(dòng)化測(cè)試用例在開始前和結(jié)束后都重新創(chuàng)建虛擬串口對(duì)。測(cè)試開始前重新創(chuàng)建保證鏈路狀態(tài)干凈測(cè)試結(jié)束后重新創(chuàng)建清理可能殘留的緩沖數(shù)據(jù)。如果復(fù)用同一個(gè)設(shè)備節(jié)點(diǎn)跑多個(gè)用例很容易出現(xiàn)上一個(gè)用例的殘留數(shù)據(jù)混進(jìn)下一個(gè)用例的輸入流里浪費(fèi)一整天排查時(shí)間。5.3 回環(huán)探針每個(gè)自動(dòng)化用例里的“健康檢查”除了業(yè)務(wù)報(bào)文回放我還會(huì)在每個(gè)自動(dòng)化用例里加一個(gè)“回環(huán)探針”。原理很簡(jiǎn)單發(fā)送端周期性寫入一個(gè)帶隨機(jī)數(shù)和時(shí)間戳的探針報(bào)文接收端必須原樣回傳并在回傳內(nèi)容后附加一個(gè)校驗(yàn)和。發(fā)送端收到回傳后對(duì)比內(nèi)容和校驗(yàn)和如果對(duì)不上立刻判定鏈路異常。這段邏輯用代碼寫出來(lái)大概是import os import random import time import hashlib def send_probe(fd, timeout0.5): nonce random.randbytes(4) timestamp int(time.time()).to_bytes(4, little) payload nonce timestamp checksum hashlib.md5(payload).digest()[:2] probe payload checksum os.write(fd, probe) expect probe rlist, _, _ select.select([fd], [], [], timeout) if not rlist: return False response os.read(fd, len(probe)) return response expect這個(gè)探針特別適合在長(zhǎng)時(shí)間壓測(cè)場(chǎng)景里使用。如果你在跑一個(gè)8小時(shí)的穩(wěn)定性測(cè)試中間鏈路偶爾抖動(dòng)導(dǎo)致一幀數(shù)據(jù)丟失業(yè)務(wù)層可能不會(huì)立即報(bào)錯(cuò)但探針一旦發(fā)現(xiàn)回傳對(duì)不上就能立刻定位到“鏈路在某個(gè)時(shí)刻出了問題”而不是等到測(cè)試結(jié)束才從結(jié)果反推。5.4 模擬時(shí)延與丟包用用戶態(tài)隊(duì)列制造“弱網(wǎng)環(huán)境”自動(dòng)化測(cè)試的進(jìn)階需求是模擬不同的網(wǎng)絡(luò)質(zhì)量。雖然虛擬串口是純內(nèi)存鏈路時(shí)延幾乎可以忽略但可以通過(guò)用戶態(tài)隊(duì)列人為注入延遲、抖動(dòng)和丟包。具體做法是在兩個(gè)pty節(jié)點(diǎn)之間加一個(gè)代理層代理層從一端讀取數(shù)據(jù)后不立即轉(zhuǎn)發(fā)而是先放入一個(gè)隊(duì)列按預(yù)設(shè)的時(shí)延參數(shù)延遲發(fā)送需要模擬丟包時(shí)代理層按概率直接丟棄隊(duì)列中的數(shù)據(jù)。import os import random import threading import time import queue def delayed_forward(src, dst, delay0.1, loss_rate0.0): while True: data os.read(src, 1024) if random.random() loss_rate: continue time.sleep(delay) os.write(dst, data)這個(gè)方案的精度雖然不如內(nèi)核態(tài)的網(wǎng)絡(luò)模擬工具那么高但對(duì)絕大多數(shù)業(yè)務(wù)驗(yàn)證已經(jīng)足夠。比如測(cè)試超時(shí)重傳邏輯時(shí)設(shè)置delay大于被測(cè)程序的超時(shí)閾值就能穩(wěn)定觸發(fā)超時(shí)設(shè)置loss_rate為5%就能驗(yàn)證偶發(fā)丟包下的協(xié)議健壯性。如果你確實(shí)需要精確到毫秒級(jí)別的時(shí)延控制可以用實(shí)時(shí)線程加高精度計(jì)時(shí)器但說(shuō)實(shí)話串口業(yè)務(wù)邏輯里很少有對(duì)時(shí)延如此敏感的場(chǎng)景。用戶態(tài)隊(duì)列配合sleep勝在簡(jiǎn)單、可控、可復(fù)現(xiàn)。5.5 自動(dòng)化用例組織的最小骨架一個(gè)用pytest組織的虛擬串口測(cè)試用例我通常會(huì)分成五步準(zhǔn)備鏈路、啟動(dòng)被測(cè)程序、運(yùn)行回放機(jī)、采集應(yīng)答、斷言結(jié)果。每一步有獨(dú)立的函數(shù)方便復(fù)用和排查。用例開始前的fixture負(fù)責(zé)創(chuàng)建虛擬串口對(duì)并設(shè)置raw模式用例結(jié)束后的fixture負(fù)責(zé)刪除設(shè)備節(jié)點(diǎn)。被測(cè)程序如果有獨(dú)立的啟動(dòng)參數(shù)配置也放在fixture里統(tǒng)一管理。import os import pty import pytest pytest.fixture def vcom_pair(): master1, slave1 pty.openpty() master2, slave2 pty.openpty() path1 os.ttyname(slave1) path2 os.ttyname(slave2) yield path1, path2, master1, master2 os.close(master1) os.close(master2)這個(gè)fixture不依賴socat直接用Python的pty模塊打開測(cè)試環(huán)境更可控。如果你在pytest外面還開著socat占用了同一批ptmx設(shè)備可能會(huì)出現(xiàn)設(shè)備節(jié)點(diǎn)沖突所以自動(dòng)化測(cè)試環(huán)境里我一般不跑手動(dòng)socat調(diào)試全部交給測(cè)試框架統(tǒng)一管理。測(cè)試運(yùn)行后如果斷言失敗xxd抓下來(lái)的現(xiàn)場(chǎng)報(bào)文會(huì)自動(dòng)保留在測(cè)試日志目錄里方便回溯。這一步很關(guān)鍵沒有現(xiàn)場(chǎng)報(bào)文遠(yuǎn)程排障基本是瞎猜。6. 硬件聯(lián)調(diào)場(chǎng)景虛擬串口和真實(shí)設(shè)備之間的橋梁6.1 串口服務(wù)器與TCP映射讓遠(yuǎn)程設(shè)備“變成”本地串口硬件聯(lián)調(diào)階段一種常見需求是把一臺(tái)遠(yuǎn)程設(shè)備通過(guò)TCP網(wǎng)絡(luò)映射成開發(fā)機(jī)上的本地虛擬串口。這樣開發(fā)機(jī)上的串口調(diào)試程序不需要任何網(wǎng)絡(luò)代碼改動(dòng)直接讀寫虛擬串口節(jié)點(diǎn)就能和遠(yuǎn)程設(shè)備通信。socat在這類場(chǎng)景里是最順手的工具。把pty節(jié)點(diǎn)和TCP端口對(duì)接起來(lái)socat pty,raw,echo0,link/tmp/vcom_remote tcp-listen:9000,reuseaddr這條命令創(chuàng)建了一個(gè)虛擬串口/tmp/vcom_remote同時(shí)對(duì)TCP 9000端口進(jìn)行監(jiān)聽。遠(yuǎn)端設(shè)備通過(guò)TCP連接到9000端口后雙向數(shù)據(jù)就在虛擬串口和TCP連接之間流轉(zhuǎn)。上位機(jī)程序完全感知不到底層是TCP網(wǎng)絡(luò)它看到的只是一個(gè)普通的串口設(shè)備節(jié)點(diǎn)。如果是開發(fā)機(jī)主動(dòng)連接遠(yuǎn)程的串口服務(wù)器用connect方向socat pty,raw,echo0,link/tmp/vcom_remote tcp:192.168.x.x:9000這種方式最大的價(jià)值在于你可以把遠(yuǎn)程設(shè)備的調(diào)試體驗(yàn)做得和本地設(shè)備一模一樣。協(xié)議分析工具、自動(dòng)化測(cè)試框架、日志采集腳本全都復(fù)用同一套針對(duì)串口的工具鏈不需要為網(wǎng)絡(luò)通信單獨(dú)開發(fā)一套。6.2 固件側(cè)聯(lián)調(diào)時(shí)的“半虛擬串口”技巧硬件聯(lián)調(diào)還有一個(gè)高頻場(chǎng)景固件已經(jīng)跑在真實(shí)板子上但上位機(jī)軟件還沒寫完。這時(shí)候虛擬串口可以作為“半路中轉(zhuǎn)站”記錄下固件發(fā)出的每一幀數(shù)據(jù)供上位機(jī)開發(fā)人員離線解析。操作方法是把真實(shí)串口設(shè)備的節(jié)點(diǎn)映射到一個(gè)虛擬串口對(duì)的一端另一端跑一個(gè)抓包腳本socat /dev/ttyUSB0,raw,echo0 pty,raw,echo0,link/tmp/vcom_sniffer然后把xxd掛在/tmp/vcom_sniffer上所有經(jīng)過(guò)真實(shí)串口的數(shù)據(jù)都會(huì)同時(shí)被虛擬串口的另一端收到。這樣一來(lái)固件的真實(shí)行為被完整記錄下來(lái)不需要修改任何固件代碼也不需要額外的硬件抓包工具。這個(gè)方法在排查“固件和上位機(jī)對(duì)協(xié)議字段理解不一致”時(shí)特別好用。上位機(jī)開發(fā)人員可以直接拿著抓包文件做離線分析把每個(gè)字節(jié)的偏移、取值、含義一一定位再和自己實(shí)現(xiàn)的解析邏輯對(duì)比效率比兩邊同時(shí)改代碼挨個(gè)對(duì)拍快得多。6.3 聯(lián)調(diào)中的狀態(tài)重置一個(gè)容易被忽視的細(xì)節(jié)硬件聯(lián)調(diào)時(shí)真實(shí)設(shè)備和虛擬串口之間的連接經(jīng)常需要反復(fù)斷開重連。socat進(jìn)程如果異常退出虛擬串口節(jié)點(diǎn)會(huì)留在系統(tǒng)里但對(duì)應(yīng)的pty狀態(tài)可能已經(jīng)殘缺。這時(shí)候再次啟動(dòng)新的socat可能會(huì)出現(xiàn)“設(shè)備節(jié)點(diǎn)存在但讀寫無(wú)響應(yīng)”的情況。我養(yǎng)成的習(xí)慣是每次重連前先清理遺留的符號(hào)鏈接和設(shè)備節(jié)點(diǎn)rm -f /tmp/vcom_remote另外用socat對(duì)接真實(shí)硬件時(shí)盡量不要在真實(shí)串口和pty之間再加額外的代理層。每一層代理都會(huì)引入一次額外的緩沖和調(diào)度延遲對(duì)時(shí)序敏感的業(yè)務(wù)影響不小。如果必須加代理至少要在代理層把數(shù)據(jù)改成逐字節(jié)透?jìng)鞑灰鋈魏未虬徒獍?. 一些值得長(zhǎng)期堅(jiān)持的調(diào)試習(xí)慣寫到這里想再分享幾個(gè)我踩過(guò)不少坑之后形成的習(xí)慣每一個(gè)都對(duì)應(yīng)過(guò)至少一次深夜加班。第一凡是串口協(xié)議一律按二進(jìn)制處理。哪怕你的協(xié)議文檔里全是ASCII字符串也要用二進(jìn)制模式打開設(shè)備、用顯式長(zhǎng)度讀寫、用十六進(jìn)制對(duì)比報(bào)文。因?yàn)槟阌肋h(yuǎn)不知道哪個(gè)版本的對(duì)端設(shè)備會(huì)突然在幀里塞一個(gè)0x00把一切“優(yōu)雅”的字符串解析瞬間擊穿。第二串口調(diào)試工具選命令行三件套。我見過(guò)不少圖形化串口工具界面花里胡哨功能一堆但內(nèi)置的自動(dòng)換行、奇偶校驗(yàn)、時(shí)間戳疊加很容易破壞二進(jìn)制數(shù)據(jù)的完整性。socat、stty、xxd這三個(gè)工具足夠解決98%的串口調(diào)試需求剩下2%用Python腳本補(bǔ)。第三每次鏈路變化后都要重跑256字節(jié)掃描。不管是因?yàn)閾Q了一臺(tái)機(jī)器、換了一個(gè)socat版本還是新增了一個(gè)代理層只要鏈路組件有變動(dòng)就重新掃一遍0x00到0xFF的回環(huán)。這一步十幾秒就能做完但能幫你區(qū)分“鏈路壞了”和“業(yè)務(wù)壞了”這兩個(gè)永遠(yuǎn)糾纏不清的問題。第四把“特殊字節(jié)”當(dāng)成協(xié)議設(shè)計(jì)的第一公民。設(shè)計(jì)協(xié)議幀時(shí)如果你的幀頭、長(zhǎng)度字段或者轉(zhuǎn)義字符恰好是0x11、0x13、0x00這類攜帶控制語(yǔ)義的字節(jié)你必須顯式定義轉(zhuǎn)義規(guī)則或者幀邊界機(jī)制而不是碰運(yùn)氣指望鏈路不做解釋。真實(shí)環(huán)境里遇到一個(gè)開了軟件流控的終端設(shè)備你就知道這些字節(jié)有多危險(xiǎn)。