打造Modbus TCP從站仿真器:從需求到實測)
做工業(yè)自動化開發(fā)Modbus這個協(xié)議你一定不陌生。PLC、儀表、傳感器、上位機組態(tài)軟件十幾年來基本靠它溝通。但協(xié)議熟悉歸熟悉真到聯(lián)調(diào)階段設(shè)備沒進場、點位沒接好、想模擬一個設(shè)備掉線或者數(shù)據(jù)跳變的工況往往比寫業(yè)務(wù)代碼還折騰。這篇博文我就用AI原生開發(fā)的思路從零搭一個Modbus仿真器。簡單說就是我把“要一個什么樣的仿真器”描述清楚讓AI承擔代碼生成的主體工作自己負責協(xié)議校驗、邊界打磨和實測驗證。整個過程里提示詞怎么設(shè)計、生成后的代碼怎么審查、哪些坑必須手動填我都會拆開講。適合正在做上位機開發(fā)、協(xié)議測試或者想試著用AI驅(qū)動完整小項目的工程師參考。1. 為什么選擇AI原生開發(fā)方式做這個仿真器1.1 工業(yè)調(diào)試中的真實痛點做過程控或物聯(lián)網(wǎng)項目的朋友大概率遇到過這幾類場景。第一類設(shè)備還沒有到貨但上位機界面、數(shù)據(jù)采集邏輯、告警規(guī)則都得提前開發(fā)和自測??偛荒茏屨麄€項目組干等硬件到位。第二類設(shè)備在現(xiàn)場但你不敢隨便拿真實設(shè)備做破壞性測試比如強制讓傳感器上報異常值、讓從站延遲響應(yīng)、或者測試主站在設(shè)備斷線時能不能正確告警。第三類網(wǎng)絡(luò)里同時掛著十幾個從站每個站有三四十個寄存器點位上位機要按站號輪詢而你手里根本沒有十幾臺真實設(shè)備去組網(wǎng)。這些場景其實都在指向同一個剛需你需要的不是一臺真實設(shè)備而是一個“可編程的、行為可控的、能模擬各種異常情況的假設(shè)備”。這就是Modbus仿真器最核心的用處。市面上有不少現(xiàn)成的仿真工具有的能模擬簡單的寄存器讀寫有的為了兼容通用性把界面做得非常重。但真正到了項目里你要的可能只是“按我定義的寄存器表啟動一個TCP服務(wù)并且能隨時把某個站切到離線狀態(tài)”。這種輕量、定制、隨拆隨用的工具反而很少有現(xiàn)成產(chǎn)品能完全滿足。與其浪費時間在通用工具里配置不如自己動手做一個符合自己業(yè)務(wù)習慣的仿真器。1.2 AI原生開發(fā)與傳統(tǒng)編碼的差異我理解的傳統(tǒng)開發(fā)模式鏈路大概是這樣的先查協(xié)議文檔確認報文格式再設(shè)計一個框架然后逐段寫代碼實現(xiàn)功能最后寫測試用例不斷修正。整個過程里電腦只是一個編輯器核心的腦力勞動全部由開發(fā)人員承擔。而AI原生開發(fā)最大的變化在于你可以用自然語言把需求講清楚讓AI先生成一個大半成品然后你來做架構(gòu)審查、協(xié)議核對、邊界補全和驗證。這有點像你身邊有一位非常熟悉代碼但不太了解業(yè)務(wù)上下文的老工程師你把需求描述得越準確他給你的初稿質(zhì)量就越高而你只需要負責把最關(guān)鍵的協(xié)議細節(jié)和項目約束注入進去。這種開發(fā)方式的收益不是“少打了幾行字”這么簡單而是把開發(fā)者的注意力從重復的輪子搭建轉(zhuǎn)移到更具價值的“需求定義”和“質(zhì)量驗證”上來。仿真器這種項目特別適合AI原生開發(fā)因為它協(xié)議標準明確、邊界場景豐富、測試手段直觀AI生成的代碼能覆蓋大量路徑而人工需要盯住的反而是協(xié)議細節(jié)、資源管理和異常注入這些“臟活”。1.3 功能取舍與選型思路動手前我先給自己定了個邊界避免做成一個不倫不類的大雜燴。第一版本只做Modbus TCP從站仿真不做RTU串口模式因為TCP調(diào)試起來最方便無論是本機回環(huán)還是局域網(wǎng)訪問都不用關(guān)心串口占用和波特率問題。第二版本只實現(xiàn)從站側(cè)行為也就是作為被讀取的一方響應(yīng)主站的報文請求。這樣能覆蓋絕大多數(shù)“驗證上位機邏輯”的需求。第三版本要留出口子方便以后擴展RTU模式和主站模式。功能碼方面我要求支持讀線圈、讀保持寄存器、寫單個線圈、寫單個寄存器、寫多個線圈、寫多個寄存器這幾個最常用的。寄存器地址范圍不做死通過配置動態(tài)分配。異常響應(yīng)必須支持因為測試上位機對異常碼的處理往往是仿真器價值最大的部分。選型上我決定用Python理由后面詳細說。整個項目的核心原則就一句話先做一個能跑的、行為可控的TCP從站再逐步把邊界和異常場景補上。2. Modbus協(xié)議核心要點與仿真器設(shè)計拆解2.1 協(xié)議里最容易混淆的幾個點做仿真器之前協(xié)議細節(jié)必須摳干凈否則生成出來的代碼表面上能監(jiān)聽端口、返回數(shù)據(jù)實際上全是錯的。Modbus里最容易踩坑的有四件事。第一是字節(jié)序問題。Modbus寄存器是16位寬傳輸時默認使用大端序也就是高字節(jié)在前低字節(jié)在后。假設(shè)你有一個寄存器值為0x1234上位機讀到的字節(jié)流必須是0x12 0x34反過來就是錯的。這個細節(jié)不仔細核對很容易出現(xiàn)寄存器值“怎么讀都差好遠”的問題。第二是CRC校驗。串口RTU模式用CRC16校驗雖然TCP模式本身有TCP校驗和但很多Modbus TCP設(shè)備在報文層也保留了CRC字段部分工具和代碼庫也會做校驗。正確計算CRC的底層邏輯是初始值為0xFFFF按位異或后右移遇到低比特位為1時與多項式0xA001異或這個算法沒有捷徑只能把函數(shù)寫對。第三是數(shù)量限制。讀取線圈或寄存器的單次數(shù)量不能超過0x7D125這是一個協(xié)議層面的硬性約束很多實現(xiàn)偷懶不做校驗就直接返回數(shù)據(jù)結(jié)果把主站搞蒙。第四是地址對齊。協(xié)議報文里的寄存器地址是從0開始計數(shù)的。比如你配置了一個保持寄存器地址范圍從0到99那么主站請求報文中要讀的地址就填0到99但很多PLC里的點位表習慣從1開始命名配置工具里常做加一減一的轉(zhuǎn)換。仿真器內(nèi)部必須明確以協(xié)議報文里的地址為真實下標不要讓外部顯示習慣污染協(xié)議層邏輯。2.2 仿真器的三層內(nèi)部結(jié)構(gòu)我在設(shè)計這個仿真器時刻意把代碼分成三層讓AI生成的代碼也按照這個結(jié)構(gòu)去組織。最底層是數(shù)據(jù)層負責保存所有寄存器、線圈和離散輸入的狀態(tài)??梢院唵斡米止?jié)數(shù)組或者Python的list來存儲每個地址映射一個值。為了讓仿真器靈活這一層最好做成可配置的比如通過JSON或YAML文件描述寄存器總數(shù)、初始值、每個寄存器周期變化規(guī)律。第二層是協(xié)議層職責是解析從socket接收到的原始字節(jié)流提取事務(wù)標識符、協(xié)議標識符、長度、單元標識符、功能碼和數(shù)據(jù)然后根據(jù)功能碼讀寫數(shù)據(jù)層最后組裝成Modbus TCP響應(yīng)報文。這一層是整個仿真器最核心的部分AI生成時最需要人工核對的就是這里。第三層是交互層提供啟動參數(shù)解析、運行時日志輸出、異常注入開關(guān)。它不參與協(xié)議處理只負責跟使用者打交道。三層的價值在于你后續(xù)無論怎么擴展功能改動都會被限制在某一層里。比如要新增一個功能碼你只需要動協(xié)議層要讓某個寄存器值按照正弦波自動變化你只需要動數(shù)據(jù)層。AI生成代碼時這種清晰的邊界也能讓生成結(jié)果不跑偏。2.3 為什么異常注入功能很重要普通仿真工具最讓人撓頭的地方就是它只會正常應(yīng)答不會給你使絆子??蓪嶋H測試里最有價值的往往就是那些“異常時刻”。主站軟件能不能在從站掉線時彈告警重試幾次才算合理寄存器地址越界時主站會不會正確處理異常碼這些場景需要在仿真階段就暴露出來而不是等到現(xiàn)場設(shè)備出問題才去排查。所以我的仿真器必須支持三類異常注入。第一類無響應(yīng)。也就是主站發(fā)來了請求但從站故意不回答讓主站產(chǎn)生超時重連的邏輯行為。第二類CRC或格式錯誤。往正常響應(yīng)里插入錯誤的校驗碼或者在報文中加一段垃圾數(shù)據(jù)模擬鏈路被干擾。第三類合法異常碼。比如請求地址超出配置范圍時返回0x02非法數(shù)據(jù)地址請求功能碼不支持時返回0x01非法功能碼從站處理不過來時返回0x06從站忙。有了這幾個開關(guān)你的上位機健壯性測試就基本覆蓋到位了。3. 實操過程從提示詞到可運行仿真器3.1 開發(fā)語言與運行環(huán)境準備語言我直接選Python理由很現(xiàn)實。第一第三方庫和腳本資源豐富AI對Python代碼生成的準確率明顯高于其他語言。第二標準庫socket和struct就能完成Modbus TCP的收發(fā)和字節(jié)拆包不需要引入龐大的依賴。第三后期如果要加界面比如用tkinter或者Web方式Python也都有比較輕的方案。環(huán)境方面我建議使用Python 3.9以上版本。項目除了標準庫之外如果要做自動化驗證可以臨時安裝一個Modbus客戶端調(diào)試工具比如現(xiàn)成開源的ModbusPoll開源替代品或者直接用Python的pymodbus庫做回測。但項目本身的依賴約等于零這對一個仿真器來說非常理想。Windows和Linux都可以跑監(jiān)聽端口默認用502這是Modbus的標準端口。不過Windows下直接在低端口監(jiān)聽需要管理員權(quán)限簡單處理方式是允許運行參數(shù)里改成用1502或10502這些非特權(quán)端口測試時在客戶端里填對應(yīng)端口就好。3.2 提示詞設(shè)計把需求講成AI聽得懂的結(jié)構(gòu)化描述AI原生開發(fā)里提示詞就是需求文檔。我發(fā)現(xiàn)一個很好用的套路把需求拆成“目標輸入輸出約定行為細節(jié)測試要求”四個部分。我實際用的提示詞大概是這個思路請用Python實現(xiàn)一個Modbus TCP從站仿真器監(jiān)聽指定端口。要求 1. 支持功能碼0x01讀線圈、0x03讀保持寄存器、0x05寫單個線圈、 0x06寫單個寄存器、0x0F寫多個線圈、0x10寫多個寄存器。 2. 使用標準socket庫監(jiān)聽TCP默認端口502。 3. 寄存器空間大小通過啟動參數(shù)或者配置文件設(shè)置 初始值在模塊內(nèi)定義。 4. 請求報文按Modbus TCP格式解析 事務(wù)標識符2字節(jié) 協(xié)議標識符2字節(jié) 長度2字節(jié) 單元標識符1字節(jié) 功能碼1字節(jié) 數(shù)據(jù)區(qū)。 5. 響應(yīng)報文必須保持相同的事務(wù)標識符和協(xié)議標識符。 6. 注冊到日志模塊啟動時輸出監(jiān)聽地址和端口信息。 7. 對不支持的功能碼返回異常響應(yīng)0x01。注意我沒有讓AI直接寫“全部實現(xiàn)”而是先讓它把框架和標準功能碼實現(xiàn)出來。這樣生成的代碼更容易審查也不會因為一次需求太多而出現(xiàn)邏輯混亂。如果項目里還要模擬異常注入我會在拿到第一版可運行代碼后再追加需求而不是在第一輪就把異常響應(yīng)、日志等級、配置文件全塞進去。AI原生開發(fā)里小步迭代比一次性完整描述更不容易失控。3.3 核心代碼生成與人工審查AI生成的代碼框架通常長這樣主線程創(chuàng)建socketbind到端口循環(huán)accept來一個連接就開一個線程處理。從直觀性講這個結(jié)構(gòu)是標準的能跑。但作為開發(fā)人員我不能直接說“可以了”逐行審查是必須的。我建議重點盯三個位置。第一半包與粘包處理。TCP是一個字節(jié)流協(xié)議一次recv可能只收到上半截報文也可能一次性收到了好幾個請求粘在一起。如果代碼只做單次recv然后立刻解析那么高并發(fā)或者慢速網(wǎng)絡(luò)下必然出問題。我的做法是每個連接維護一個緩沖區(qū)不斷接收并嘗試按MBAP頭部里的長度字段確定這一幀是不是已經(jīng)完整不完整就繼續(xù)等。第二監(jiān)聽地址。AI很自然地會用127.0.0.1但要作為仿真器給局域網(wǎng)里的上位機連接必須綁定0.0.0.0。這是一個必須在人工審查階段發(fā)現(xiàn)的問題。第三線程序號增長。如果用threading處理的連接沒有做異常捕獲或者線程回收一個客戶端斷開時可能拋出異常導致整個服務(wù)崩潰。我通常在異常處理里把線程放在try/except里并記錄客戶端斷開日志而不是讓異常冒泡出來。核心代碼實現(xiàn)以讀保持寄存器0x03為例AI生成的解析邏輯大體會是這樣def handle_request(data): transaction_id data[0:2] protocol_id data[2:4] length int.from_bytes(data[4:6], big) unit_id data[6] func_code data[7] if func_code 0x03: start_addr int.from_bytes(data[8:10], big) quantity int.from_bytes(data[10:12], big) if quantity 125: return build_exception(transaction_id, unit_id, func_code, 0x03) if start_addr quantity len(holding_registers): return build_exception(transaction_id, unit_id, func_code, 0x02) values holding_registers[start_addr:start_addr quantity] response struct.pack(H, 1 quantity * 2) bytes([func_code, quantity * 2]) struct.pack( H * quantity, *values) return transaction_id protocol_id struct.pack(H, len(response) 1) bytes([unit_id]) response這里需要注意事務(wù)標識符和協(xié)議標識符必須原樣返回長度字段計算的是“單元標識符功能碼數(shù)據(jù)區(qū)”這一段的總長度一個字節(jié)都不能多算少算。AI生成時經(jīng)常把長度算錯我的經(jīng)驗是拿到代碼后自己手動構(gòu)造幾個已知報文逐字節(jié)驗證再放行。3.4 測出來才算數(shù)本地回環(huán)與客戶端回測代碼能啟動之后我堅決不只用“啟動沒有報錯”來驗收。我會做兩輪測試。第一輪是構(gòu)造已知報文做單測。我直接構(gòu)造一個讀保持寄存器0x03的報文請求地址從0開始讀4個寄存器然后檢查響應(yīng)里的數(shù)據(jù)區(qū)是不是跟配置的初始值一致。比如初始值是[100, 200, 300, 400]那么大端序返回的字節(jié)就該對應(yīng)0x0064,0x00C8,0x012C,0x0190。第二輪是用現(xiàn)成的Modbus客戶端工具連接仿真器讀取真實點位寫入寄存器再讀回來。這一輪能驗證跨語言、跨實現(xiàn)的兼容性很多自研仿真器就是栽在這一步——自己跟自己玩沒問題跟別人的客戶端一握手就露餡。如果條件允許把仿真器部署到Linux服務(wù)器上再用Windows上位機軟件走局域網(wǎng)連接這樣還能順帶驗證網(wǎng)絡(luò)環(huán)境、防火墻、端口映射這些實戰(zhàn)場合的問題。仿真器的好處就在這它不是為了跑通一遍流程而是為了反復模擬各種上線后的惡劣情況讓問題在進機房之前就暴露出來。4. 常見問題與排查技巧實錄4.1 協(xié)議實現(xiàn)上的坑我自己在做的過程中最大的坑是字節(jié)序和長度字段。AI第一次生成的struct格式用的是小端結(jié)果寄存器值全部高低顛倒讀出來的數(shù)值跟配置完全對不上。排查方式很簡單構(gòu)造一個已知的寄存器值用Wireshark抓包或者手工打印響應(yīng)報文看字節(jié)順序。第二個坑是長度字段多算了一個字節(jié)因為有些示例代碼把單元標識符的長度漏了導致客戶端一直報“報文格式錯誤”。這類問題靠肉眼看代碼不容易發(fā)現(xiàn)靠抓包能看到到底多出來的一個字節(jié)在哪。第三個坑是邊界讀寄存器的時候起始地址合法但起始地址加數(shù)量之后的結(jié)束地址越界了。這個校驗條件如果寫成大于等于而不是大于會把最后一個合法地址也攔掉導致主站讀取失敗。第四個坑是半包。之前在局域網(wǎng)上跑沒問題結(jié)果用弱網(wǎng)環(huán)境模擬主站讀取頻繁丟包排查下來是recv只收了一次就著急解析拆包不完整導致的。4.2 AI生成階段的協(xié)作問題用AI生成不等于當甩手掌柜?!癆I生成的代碼看起來很有道理”往往是藏雷最多的地方。我遇到過AI聲稱調(diào)用了某個標準庫函數(shù)但實際上Python這個版本里根本沒有這個API一運行就報AttributeError。也遇到過AI把協(xié)議層的邏輯寫成了從某個文件讀取配置的方式雖然也是按我的需求來但顯然跑偏了。還有一次它生成的異常響應(yīng)里功能碼忘了加0x80等于告訴主站“我正常處理了這條不支持的功能碼請求”這在協(xié)議層是完全錯誤的行為。所以我給自己定了幾條AI協(xié)作的規(guī)矩。第一要求AI給出“關(guān)鍵算法”的測試用例比如CRC函數(shù)直接跑幾個已知結(jié)果校驗。第二一次只改一個需求點不要讓AI同時處理新增功能碼和重構(gòu)線程模型。第三所有生成代碼必須拿著Modbus協(xié)議規(guī)范過一遍任何“看起來差不多”的函數(shù)都要單獨驗證。AI是很好的初稿生成器但不是權(quán)威裁判。4.3 實用排查工具與驗證技巧做這類協(xié)議仿真器光看代碼和日志還不夠我習慣搭配三個工具做多維度驗證。第一是抓包工具直接看以太網(wǎng)幀里的原始字節(jié)流確認事務(wù)標識符、長度、數(shù)據(jù)區(qū)都符合預期。第二是現(xiàn)成Modbus客戶端用一個與項目毫無關(guān)聯(lián)的第三方實現(xiàn)來連我的仿真器這種“獨立視角”的兼容性測試最有說服力。第三是自動化腳本批量構(gòu)造正常請求、異常請求、越界請求和畸形報文驗證仿真器在各種攻擊性輸入下不崩潰。自動化腳本里我會跑一個循環(huán)連續(xù)寫入一個寄存器1000次再讀出來檢查數(shù)據(jù)是否一致排除并發(fā)中的狀態(tài)覆蓋問題。這些工具組合起來基本能覆蓋一個Modbus從站仿真器的全部測試維度。我個人最大的體感是測試比編寫功能本身更費時間。仿真器本身邏輯不復雜難就難在你要讓它在真實網(wǎng)絡(luò)環(huán)境下面對各種不按套路出牌的請求還能穩(wěn)如泰山。5. 一點個人體會把整個仿真器從需求描述到可運行這一輪做下來我的感受是AI原生開發(fā)真正改變的不是“寫代碼”這個動作而是把人的精力逼到了更重要的地方——定義清楚邊界、審查協(xié)議細節(jié)、設(shè)計驗證路徑。仿真器這種協(xié)議型工具恰好是AI生成代碼的高地因為Modbus規(guī)范足夠明確網(wǎng)上示例足夠多AI輸出的框架能節(jié)省至少一半時間。但協(xié)議這東西錯一個字節(jié)就是錯算法再聰明也不如實際報文驗證來得踏實。如果手頭沒有真實設(shè)備又想練一練Modbus調(diào)試和上位機開發(fā)按照這個思路做一個仿真器確實是個非常合適的方向。我現(xiàn)在還保留了那個自動化測試腳本后續(xù)再新增功能碼或者調(diào)整異常注入邏輯都用它回歸一遍。下一步我準備給它加一個簡單的Web控制面板把寄存器讀寫、異常注入開關(guān)都做成網(wǎng)頁上的按鈕這樣現(xiàn)場工程師不用碰命令行也能靈活控制仿真流程。AI原生的開發(fā)節(jié)奏很適合這種持續(xù)增補的小工具每補一個功能請AI接手初稿我再做協(xié)議層的把關(guān)效率和可控性基本達到了平衡。