與排錯實戰(zhàn))
簡介這是一份基于C實現的MODBUS TCP服務器源碼主要面向工業(yè)自動化領域的開發(fā)者用于快速搭建支持MODBUS TCP通信的服務端程序。源碼實現了MODBUS協議中常用的功能碼03讀取輸入寄存器/離散輸入與功能碼16寫多個保持寄存器覆蓋請求解析、應答構造和常見錯誤處理流程可作為理解協議棧、學習TCP/IP網絡編程及進行二次開發(fā)的基礎框架同時幫助理解MODBUS協議規(guī)范與報文交互機制。資源壓縮包共22個文件、約152KB以7個h頭文件與5個cpp源文件為主附帶Visual Studio工程配置sln、vcxproj、rc等整體結構清晰便于在Windows環(huán)境下直接編譯調試。目前已有2173人學習/下載。通過研讀源碼開發(fā)者可以掌握從報文接收到響應返回的完整實現思路并據此便捷地擴展線圈讀寫等更多功能碼適配智能能源管理、環(huán)境監(jiān)測、自動化產線等不同工業(yè)控制場景提升設備互聯與遠程控制的開發(fā)效率。1. 為什么 MODBUS TCP SERVER 源碼值得翻現場最常見的尷尬是用現成的 Modbus 從站模擬器主站數據讀得干干凈凈等換成一版自己寫的服務端連接能建立、報文也發(fā)了寄存器卻永遠返回零或者偶爾飄成亂碼。你面前只有一個黑匣子轉向現有工具又復現不了最后只能硬著頭皮把服務端源碼從頭到尾查一遍。這正是 MODBUS TCP SERVER 源碼的價值所在它不是拿來到處復制粘貼的模板而是你處理現場問題的最后一本底賬。這篇文章會從一份最小可運行的服務端源碼講起把 Modbus TCP 的報文結構、寄存器模型、并發(fā)處理和事務標識拆清楚再把我在幾個設備聯網項目里遇到過的坑按“現象—原因—解決”寫成排查清單。適合兩類人一類是要把 Modbus TCP Server 集成到采集網關、邊緣盒子或上位機里的開發(fā)工程師另一類是天天跟 PLC、HMI、組態(tài)軟件對點表被未知從站行為反復折磨的調試工程師。讀完你至少能帶走兩樣東西一套可以直接抄作業(yè)的源碼骨架一份按圖索驥的排錯手冊。至于最后要不要用開源庫、要不要自己維護看完第三四章你自己就能拍板。2. 從協議到源碼MODBUS TCP SERVER 的最小骨架2.1 先看 7 字節(jié)頭事務、單元和長度怎么對齊寫服務端之前腦子里必須有一張固定的幀結構圖否則后面所有字節(jié)偏移都會算錯。Modbus TCP 的請求由 MBAP Header 和 PDU 組成。MBAP 固定 7 字節(jié)事務標識符占 2 字節(jié)協議標識符占 2 字節(jié)長度字段占 2 字節(jié)單元標識符占 1 字節(jié)。事務標識符用在同一個連接里把請求和響應配對協議標識符必須為 0表示這是 Modbus 協議長度字段表示單元標識符加 PDU 一共還有多少字節(jié)單元標識符在串口透傳網關里代表從站地址純 TCP 場景通常填 0 或 1。服務端最容易翻車的不是總字節(jié)數而是長度字段的計算口徑。請求頭里的 length 是“單元標識符 1 字節(jié) PDU 長度”。也就是說服務端讀完 7 字節(jié)頭之后還要繼續(xù)讀 length-1 個字節(jié)才是完整的 PDU。很多新人直接把后續(xù)所有可讀字節(jié)當成一幀收完在連續(xù)請求的場景下會把下一個請求的頭也吞進去表現就是第一幀正常第二幀開始全部錯位。下面這張表列出四個最常用功能碼的請求響應格式實現的邊界判斷基本都圍繞它展開。功能碼含義請求 PDU響應 PDU0x03讀保持寄存器起始地址(2B) 寄存器數量(2B)字節(jié)數(1B) 寄存器值(NB)0x04讀輸入寄存器起始地址(2B) 寄存器數量(2B)字節(jié)數(1B) 寄存器值(NB)0x06寫單個寄存器地址(2B) 值(2B)地址(2B) 值(2B)0x10寫多個寄存器起始地址(2B) 數量(2B) 字節(jié)數(1B) 值(NB)起始地址(2B) 數量(2B)注意 Modbus 協議里的寄存器地址從 0 開始而組態(tài)軟件界面上往往顯示成 40001、40002這個 1 的偏移是上位機顯示層的事源碼里別跟著加。我在項目里見過服務端收到起始地址 0 卻去讀數組下標 1最后每個點位都差一位。2.2 寄存器模型的取舍用哪種內存結構支撐讀寫Modbus 的 PDU 不關心數據來自數組、數據庫還是某個硬件寄存器服務端源碼真正的分水嶺在寄存器模型的組織方式。最直接的做法是把每種寄存器區(qū)域做成一個數組。保持寄存器用 16 位無符號整數地址連續(xù)讀寫都靠下標索引。先初始化一塊長 100 的列表寫單個寄存器就更新第 n 個元素讀就按切片返回。這個模型跑通協議已經足夠但它有個明顯缺點當點位數量和業(yè)務邏輯變多每個功能碼分支都要復制一遍邊界判斷代碼很快會變得沒法維護。我建議的順序是先用簡單數組把服務端跑通再做數據訪問層抽象。不要一開始就上配置文件、多區(qū)域映射那會讓問題疊加。真實項目里常見的做法是維護一個點表配置每個條目包含起始地址、數量、讀寫權限和數據來源服務端在收到請求后根據配置動態(tài)查表。這個會在最后一章展開。選型方面還有另一條路直接用現成開源庫比如某開發(fā)者常用的 Python Modbus 庫它內置了 Datablock 和協議解析上位機工具類項目用它非常省事。我當時堅持自己寫一遍是因為內核要移植到一個很小的實時系統里第三方庫依賴太重不如一套純 socket 代碼容易裁剪。這個選擇沒有標準答案關鍵看你的運行環(huán)境是否允許引入外部依賴。2.3 最小源碼不依賴第三方庫的 Python server下面給出一份我裁剪過的最小實現只覆蓋讀保持寄存器和寫單個寄存器以及異?;匕K茏屇阍诒镜亓⒖炭吹揭粋€可交互的 Modbus TCP Server。import socket import struct import threading # 保持寄存器表地址從 0 開始長度 100 REG_HOLDING [0] * 100 REG_LOCK threading.Lock() def build_response(tid, unit, pdu): # MBAP 中 length 單元標識(1) PDU 長度 length len(pdu) 1 return struct.pack(HHHB, tid, 0, length, unit) pdu def handle_client(conn): try: while True: mbap conn.recv(7) if not mbap: break tid, pid, length, unit struct.unpack(HHHB, mbap) if pid ! 0: break # 還需要讀取 length-1 字節(jié)的 PDU pdu b remain length - 1 while remain 0: chunk conn.recv(remain) if not chunk: return pdu chunk remain - len(chunk) func pdu[0] if func 0x03: # 讀保持寄存器 start, count struct.unpack(HH, pdu[1:5]) # 單次最多讀 125 個寄存器 if count 0 or count 125 or start count len(REG_HOLDING): resp build_response(tid, unit, bytes([0x83, 0x02])) else: with REG_LOCK: values REG_HOLDING[start:start count] payload bytes([count * 2]) for v in values: payload struct.pack(H, v) resp build_response(tid, unit, bytes([0x03]) payload) elif func 0x06: # 寫單個保持寄存器 addr, value struct.unpack(HH, pdu[1:5]) if addr len(REG_HOLDING): resp build_response(tid, unit, bytes([0x86, 0x02])) else: with REG_LOCK: REG_HOLDING[addr] value resp build_response(tid, unit, bytes([0x06]) struct.pack(HH, addr, value)) else: # 非法功能碼 resp build_response(tid, unit, bytes([func | 0x80, 0x01])) conn.sendall(resp) finally: conn.close() def start_server(ip0.0.0.0, port502): srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((ip, port)) srv.listen(5) print(Modbus TCP Server listening on {}:{}.format(ip, port)) while True: conn, addr srv.accept() threading.Thread(targethandle_client, args(conn,), daemonTrue).start() if __name__ __main__: start_server()這段代碼有幾個值得圈出來的點。tid, pid, length, unit用HHHB一次拆出所有多字節(jié)字段都按網絡大端解析。build_response統一封裝 MBAP 頭每個分支不用各自拼長度從根上減少“響應長度算錯”的概率。REG_LOCK是必須加的因為多客戶端線程同時讀寫同一個列表不加鎖會出現數據交錯現場表現是寄存器值莫名其妙歸零或者變成舊值。功能碼 0x03 的響應里有“字節(jié)數”字段這是 01/02/03/04 共有的結構漏掉之后主站會一直等到超時。異?;匕仨毎压δ艽a最高位置 1例如 0x03 變 0x83同時帶異常碼 0x02 表示非法數據地址。主站看到高位為 1 才會按異常處理直接回普通功能碼會讓主站把后面的字節(jié)當成正常數據去解析必然錯亂。啟動后用 Modbus Poll 或其他主站工具連接 502 端口讀起始地址 0、數量 10如果返回的數據比你預期少或者讀到亂碼先看是不是字節(jié)數字段和實際返回長度不一致。這是最小實現最容易踩的第一個坑后面避坑章節(jié)還會繼續(xù)展開。3. 源碼不只是收發(fā)事務標識、并發(fā)和連接管理3.1 事務標識符(TID)并發(fā)請求配對的關鍵MBAP 頭第一個字段是事務標識英文縮寫 TID。在同一個 TCP 連接里客戶端可以連續(xù)發(fā)送多個請求并不嚴格要求一問一答。服務端必須原樣回顯請求中的 TID客戶端才能把響應和請求對應起來。如果服務端把 TID 改成了自增序列客戶端就會出現響應超時、請求錯位。有些簡單的上位機工具并不校驗 TID但不校驗不代表你可以隨便改。我用過的幾款主流組態(tài)軟件都會在協議棧里核對這個字段一旦發(fā)現對不上就把這條響應丟棄。所以源碼里最保守的做法是在handle_client入口保存?zhèn)魅氲?tidbuild_response原樣帶回。有一點容易被忽略Modbus TCP 標準允許在同一連接上多路復用事務但很多設備端從站只支持串行處理。如果你的客戶端是同一連接并發(fā)發(fā)請求而你的服務端是一個線程連續(xù)阻塞讀取響應自然是一個一個返回這時客戶端如果做了多事務 pending就會認為部分請求超時。服務端源碼要區(qū)分好“連接級并發(fā)”和“事務級并發(fā)”。最簡單的辦法是限制同一連接同一時刻只處理一個事務客戶端通常也會退化為串行。真正要支持事務級并發(fā)就得為每個連接維護一個 pending 字典按 TID 找到等待的 writer 再回包復雜度會明顯上升。3.2 多客戶端連接線程池與 asyncio 的取舍最小源碼用threading.Thread對每個客戶端起一個線程。這在客戶端數量少的時候沒有問題五六個主站輪詢完全扛得住。但現場往往是多臺主站同時連接每臺還會建立多條連接線程數一旦跑到幾十上百系統上下文切換開銷會非常明顯服務端響應延遲開始抖動。常見的替代方案有兩個。第一是線程池用ThreadPoolExecutor限制最大并發(fā)線程數。因為每個連接的處理函數是一個長循環(huán)會一直占住一個線程所以線程池大小嚴格意義上是“最大并發(fā)連接數”不是“每秒能處理的請求數”。超出的連接會在 listen 隊列里排隊如果 backlog 太小新連接會直接被內核拒絕。配合線程池一定要做空閑超時比如 30 秒沒收到幀就關閉連接否則一個斷開的客戶端沒被發(fā)現線程池里就少了一個可用的線程。第二是 asyncio 單線程事件循環(huán)適合連接多、每連接流量小、寄存器響應快的場景。但 asyncio 有個致命禁忌業(yè)務代碼里不能出現任何阻塞調用比如time.sleep、同步數據庫查詢、文件鎖。一旦阻塞所有客戶端的請求一起卡住。用 asyncio 實現 Modbus TCP Server 時讀取一幀的標準寫法是先讀 7 字節(jié)頭再根據長度字段用readexactly(length - 1)把剩余部分讀夠不要手動拼包因為 Modbus 幀邊界是自描述的長度字段就是最好的拆包依據。下面是 asyncio 服務端處理連接的核心骨架保留了 MPAB 讀取邏輯import asyncio import struct async def handle_connection(reader, writer): try: while True: mbap await reader.readexactly(7) tid, pid, length, unit struct.unpack(HHHB, mbap) if pid ! 0: break pdu await reader.readexactly(length - 1) # 按功能碼構造響應 pdu邏輯同第二章節(jié) resp build_response(tid, unit, pdu) writer.write(resp) await writer.drain() except (asyncio.IncompleteReadError, ConnectionResetError): pass finally: writer.close() await writer.wait_closed()這里的關鍵參數是readexactly它要么讀滿指定字節(jié)要么拋異常絕不會出現少讀半個數據包的情況。缺點是一旦客戶端發(fā)了一個不完整的頭服務端會一直等下去所以必須在asyncio.wait_for里包一層超時。不要小看這個超時真實網絡環(huán)境里丟包后客戶端重傳服務端如果沒有超時機制連接會一直掛著慢慢耗盡系統文件描述符。3.3 長連接 vs 短連接哪種現場更常見Modbus TCP 設計默認是長連接。真正的 PLC 主站會保持連接到從站周期性地輪詢寄存器斷線后還會自動重連。服務端如果處理完一次請求就關閉連接主站每個周期都要重新建連現場會出現“數據刷新慢、狀態(tài)經常斷”的假象。短連接場景主要出現在 Web 后臺測試、臨時腳本或者需要快速釋放端口的工具中。做服務端源碼時要兩種都支持但不要主動關閉長連接。如果你確實需要回收空閑資源把空閑超時設置得盡量長至少 60 秒以上如果主站輪詢周期本身就長要考慮把這個超時再放大。還有一個常被忽略的點半開連接?,F場網絡抖動之后客戶端已經不在但服務端的recv還一直在阻塞它根本不知道對方已經消失。解決方法是給每個連接設置 socket 超時conn.settimeout(60)超時后關閉連接并記錄日志。這一步是 demo 代碼和生產級代碼真正的分界線。某些系統里還需要配合 TCP keepalive讓內核層探測對端是否存活但 keepalive 默認探測周期很長不能替代應用層超時。4. 參數與配置讓 MODBUS TCP SERVER 源碼跑得穩(wěn)4.1 必調參數超時、緩沖區(qū)、地址范圍與端口把源碼從“能跑”推向“能扛現場”無非是把下面這幾個參數調成符合業(yè)務的數值。參數推薦值作用調大/調小的影響socket 超時30-60s檢測半開連接太小會讓慢周期主站誤認為斷線太大浪費連接資源recv 緩沖區(qū)2-8KB接收多幀時緩存Modbus 單幀小于 260B太大并不會提升性能監(jiān)聽 backlog5-10未 accept 的連接隊列太小高并發(fā)會丟連接太大占用系統內存寄存器區(qū)長度按點表實際越界返回異常碼設成 65536 沒有任何收益反而放寬了越界風險最大連接數按設備性能保護服務不被打爆超過可以拒絕新連接返回日志字節(jié)序big-endian寄存器默認大端改成小端會直接造成數據翻倍端口默認是 502Linux 下低于 1024 的端口需要 root 權限很多開發(fā)環(huán)境會先用 1502 調試上線再映射到 502。如果你用 Docker 部署容器內的 502 可以映射到宿主機的 1502不影響協議本身。SO_REUSEADDR一定要在 bind 之前設置否則服務端程序重啟后會因為之前的 TIME_WAIT 狀態(tài)占用端口而失敗。這個選項不會像某些博客講的那樣“解決并發(fā)連接被 bind 拒絕”的問題它只是允許重用地址避免快速重啟時端口被占用。4.2 功能碼的邊界03/04/06/16 怎么實現與限制Modbus TCP 的 PDU 理論最大長度是 253 字節(jié)這一個數學約束決定了很多功能碼的數量上限。讀保持寄存器 0x03 和讀輸入寄存器 0x04 單次最多讀 125 個寄存器因為每個寄存器 2 字節(jié)125 個就是 250 字節(jié)加上功能碼和字節(jié)數正好 252卡在 253 以內。寫多個寄存器 0x10 單次最多寫 123 個因為請求里還有起始地址和數量留給數據的空間更少。很多從站源碼沒有做這個限制收到 count200 的請求時直接越界訪問列表并拋異常連接直接斷掉。主站看到連接斷開通常會連續(xù)重試然后報告“從站無響應”。正確的做法是像第二章節(jié)代碼里那樣把邊界判斷放在任何數據處理之前如果 count 超過上限就返回異常碼 0x02 非法數據地址而不是讓程序崩潰。06 寫單個寄存器是最簡單的不存在批量上限問題但要處理地址越界。有些主站用 06 寫單個設置項時起始地址正好是點表末尾你如果只判斷addr len(REG_HOLDING)就沒有給“最后一個合法地址”留空間。常規(guī)寫法是addr len(REG_HOLDING)對應數組下標 0 到 len-1 合法。01 和 02 線圈/離散輸入功能碼在現場也很常見。線圈是 bit 存儲讀取 2000 個線圈需要 250 字節(jié)數據正好是 PDU 允許的極限。如果只實現了寄存器客戶端發(fā) 01 功能碼就會收到 0x01 異常。大多數設備端會同時實現 01/02/03/04因為組態(tài)軟件默認要把所有類型都讀一遍。4.3 錯誤處理異常碼返回與日志Modbus 異常碼總共就那么幾個0x01 非法功能碼、0x02 非法數據地址、0x03 非法數據值、0x04 從站設備故障。返回異常時必須把功能碼最高位置 1。比如 0x03 變成 0x830x06 變成 0x86。最高位沒有置 1 的響應會被主站按正常數據處理這是現場排查時最難發(fā)現的錯誤之一因為報文結構看起來完整實際內容全是錯位數據。日志不要貪多。每個連接記錄 TID、功能碼、起始地址和數量就夠了寄存器內容只有在異常時才打印。我見過有人把每個請求的完整十六進制都打進日志結果服務端跑一天日志文件幾個 GB真正想看關鍵信息的時候全被刷沒了。生產環(huán)境里建議加一個開關默認開“請求摘要日志”級別 INFO調試時開“詳細報文日志”級別 DEBUG。抓包工具能覆蓋一部分問題但服務端日志能看到“程序內部認為哪里越界”這是 Wireshark 看不到的。5. 避坑MODBUS TCP SERVER 的常見故障排查5.1 現象請求一多寄存器值就被置零現象是單個連接單次請求都正常主站并發(fā)輪詢或者頻繁寫操作之后某個寄存器開始變成 0再過一會又恢復。原因通常是兩個一是多線程同時寫同一個寄存器數組沒有加鎖Python 里雖然 GIL 保證了單個字節(jié)碼指令的原子性但列表切片和賦值并不是整體原子操作兩個線程交錯時會讀到舊值二是服務端讀取 PDU 時沒有嚴格按 length 字段接收用了一次不完整的 recv導致下一幀的偏移量出錯寫入請求的地址亂套。解決方法是給寄存器數據加鎖并在解析幀時嚴格按“先讀 7 字節(jié)頭再循環(huán)讀夠 length-1 字節(jié)”來收包。第二章節(jié)代碼里的while remain 0就是這個目的。不要相信一次 recv 能拿到完整 PDU尤其在本地回環(huán)之外的以太網環(huán)境。5.2 現象連接不釋放導致端口耗盡現象是服務端運行一段時間后新的客戶端連接全部失敗用netstat能看到大量 TIME_WAIT 或者 ESTABLISHED 狀態(tài)的連接堆積程序日志里全是 bind 或 accept 相關的報錯。原因是對端異常斷開時服務端 recv 沒有返回一直阻塞或者客戶端主動關閉時服務端沒有進入 close 流程。每個堆積連接占一個文件描述符Linux 默認進程最大文件描述符是 1024很快就會被耗盡。解決方法是給每個連接設置超時時間conn.settimeout(60)在handle_client里把 socket 超時異常統一捕獲并關閉連接。同時可以在 accept 后設置 TCP keepalive但 keepalive 默認參數在 Linux 下是 2 小時不適合短周期檢測需要配合套接字選項調整探測時間。最穩(wěn)的還是應用層超時加線程回收。5.3 現象字節(jié)序反了明明是 1 讀出來是 256現象是寫一個寄存器值 1主站讀出來是 256寫 0x0102讀出來 0x0201。原因是 Modbus 寄存器默認大端高字節(jié)在前。如果用本地字節(jié)序的struct.pack(H, value)在 x86/ARM 小端環(huán)境里會把低字節(jié)放到內存前面報文變成低字節(jié)在前主站按大端解析正好反了。解決方法是所有多字節(jié)字段統一用H、I不要用裸的H或I。Python 的struct默認用本地字節(jié)序機器不同行為不同最容易踩。排查時寫一個固定值 0x1234 到寄存器讀回來看字節(jié)排列能立刻確認是不是字節(jié)序問題。5.4 現象模擬器可以讀真實 PLC 請求超時現象是 Modbus Poll、模擬主機都能正常讀寫換成某品牌 PLC 做主站后服務端日志顯示收到了請求但主站一直報超時或者間歇性失敗。原因通常是真實 PLC 會一次性建立多個連接并在一個連接里連續(xù)發(fā)送多個請求還有可能是 PLC 發(fā)送了你沒有實現的功能碼比如 0x10 寫多個寄存器而服務端返回了非法的異常碼主站協議棧直接放棄重試。解決方法是先抓包看 PLC 實際發(fā)出來的請求是什么樣子。重點看三個字段TID 是否在同一個連接上遞增、功能碼是什么、起始地址和數量是多少。抓不到包時在服務端日志里打印收到的原始 PDU 十六進制哪怕不打完整報文也要打功能碼和長度。很多問題時隱性的主站只報超時不會告訴你它發(fā)送了什么。5.5 現象異步框架里用阻塞讀導致整個服務卡死現象是用 asyncio 實現的服務端在低并發(fā)時正??蛻舳诉B接一多整體響應延遲飆升甚至所有請求都沒反應。原因是在異步回調里用了同步的time.sleep、同步 socket 操作或者 CPU 密集型的大循環(huán)。asyncio 是單線程事件循環(huán)一個阻塞調用會停掉整個循環(huán)其他連接即使早就準備好了也無法被處理。解決方法是所有耗時操作放進線程池比如await asyncio.to_thread(do_heavy_work, data)讀取網絡數據必須用reader.readexactly不要用socket.recv。同時給每個請求的處理加上超時保護避免某個客戶端不發(fā)完數據時整個事件循環(huán)被readexactly永久占住。這個坑最容易在“把原來 thread 版改成 asyncio 版”的時候出現因為很多代碼只是換了個殼內部還是一堆阻塞函數。6. 從源碼到交付寄存器映射、抓包驗證和壓測6.1 把寄存器表變成配置而不是把地址寫死在 if 里第二章節(jié)的源碼用的是一個固定長度列表適合跑通協議。真正到一個有幾十個點位、不同寄存器代表溫度、轉速、狀態(tài)的現場再在 if 分支里手寫地址就很痛苦了。我一般會把點表抽成一個配置結構每個數據區(qū)包含起始地址、長度、讀寫權限、當前值來源函數。比如溫度寄存器由傳感器線程更新設定值寄存器由 Modbus 寫請求更新。服務端收到請求后先按功能碼找到對應的數據區(qū)再做邊界判斷。這樣做的好處是現場點位調整時只改配置不重新編譯。點表結構可以用一個簡單的 Python 類來表示核心目的是讓“地址—數據來源”的映射一目了然。不要在這里過度設計第一個版本用 dict 加回調函數就足夠。6.2 抓包驗證是源碼調試的第一現場Wireshark 是排查 Modbus TCP Server 最直接的證據來源。過濾條件寫tcp.port 502逐幀展開 MBAP 和 PDU立刻能看到長度字段是不是正確的、功能碼有沒有回執(zhí)、異常碼高位有沒有置 1。我最常用的驗證套路是先寫一個寄存器值 0x0102再讀回來。如果抓包看到的值是 0x0102說明字節(jié)序和收發(fā)都正常如果看到 0x0201不用懷疑就是字節(jié)序問題。然后再測邊界讀起始地址 0、數量 125成功改成數量 126應返回 0x83 0x02。這個用例能同時驗證正常路徑和異常路徑。Wireshark 里如果看到 Malformed 標記基本是長度字段和實際數據長度不匹配。此時不要急著改程序先看這一幀的 Byte Count 和后續(xù)實際字節(jié)數差了幾個。常見原因是在響應里多寫了地址或者少算了 unit 標識符長度。6.3 給源碼加一個最小壓測腳本交付之前我會用一個幾十行的腳本做一次并發(fā)冒煙。下面是一個用 Python 線程池模擬多主站的簡單框架每一線程建立獨立連接連續(xù)執(zhí)行寫后讀統計成功率import socket import struct import threading def modbus_read(conn, start, count): # 構造 03 請求 tid 1 pdu struct.pack(BHH, 0x03, start, count) mbap struct.pack(HHHB, tid, 0, len(pdu) 1, 1) conn.sendall(mbap pdu) header conn.recv(7) tid2, pid, length, unit struct.unpack(HHHB, header) data b while len(data) length - 1: data conn.recv(length - 1 - len(data)) return data def worker(ip, port, errors): try: conn socket.create_connection((ip, port), timeout3) for i in range(100): resp modbus_read(conn, 0, 10) if len(resp) 22: # 1字節(jié)功能碼 1字節(jié)數量 20字節(jié)數據 errors.append(short response) break conn.close() except Exception as e: errors.append(str(e)) threads [] errors [] for i in range(20): t threading.Thread(targetworker, args(127.0.0.1, 502, errors)) t.start() threads.append(t) for t in threads: t.join() print(errors:, len(errors))這個腳本不對響應內容做過深校驗它主要看兩件事多連接并發(fā)時會不會崩、會不會出現短包。如果 errors 數量逐漸增長下一步就去查服務端 backlog 和線程數如果短包頻繁極大概率是響應長度字段寫錯。6.4 一個長期受用的交付習慣這套源碼骨架我在不同項目里打磨過很多輪真正讓它穩(wěn)定下來的不是代碼技巧而是“每跑一個用例都留證據”的習慣。本地能連通只是第一步邊界返回、異常碼、并發(fā)讀寫、抓包一致性這四個驗證點缺一不可?,F在每接到一個要集成 Modbus 從站的活我都會先花一小時把最小服務和壓測腳本搭起來再開始談業(yè)務點表。源碼的價值不在第一次運行通過而在后續(xù)任何一次故障里你都能翻開它快速定位到那幾行可疑的判斷。希望幫到你。本文還有配套的精品資源點擊獲取