手写Python Modbus TCP客户端:从协议解析到工业级稳定运行
2026/8/26 9:04:28 网站建设 项目流程

1. 项目概述:为什么一个Modbus TCP客户端值得从零手写一遍?

Modbus协议在工业自动化现场就像空气一样无处不在——PLC、变频器、温控表、电表、DCS系统,只要设备带RS485口或以太网口,十有八九支持Modbus。而Modbus TCP,就是把原本跑在串口上的Modbus RTU/ASCII协议,原封不动地“装进”TCP/IP数据包里,通过网线直连工控机、SCADA服务器甚至树莓派。它不依赖操作系统内核驱动,不强制要求实时性,结构极简、报文透明、调试直观,是工程师第一次接触工业通信时最友好的“入门级协议”。

但问题来了:市面上的Modbus工具(比如Modbus Poll、QModMaster)确实开箱即用,点几下就能读寄存器、写线圈;可一旦你要把它嵌进自己的产线监控系统、边缘计算网关、或者和MQTT/OPC UA做桥接,这些GUI工具就彻底失效了。这时候,一个轻量、可控、可调试、可集成的Python Modbus TCP客户端,就不是“锦上添花”,而是“刚需”。它不靠图形界面糊弄人,每一字节报文都暴露在你眼皮底下;它不打包成黑盒DLL让你猜参数,所有超时、重试、异常码都可捕获、可记录、可告警;它甚至能和你的Django后台、FastAPI接口、或是PyQt主界面无缝咬合。

我做过三个真实场景:一个是给某光伏逆变器厂商写远程诊断脚本,需要每3秒轮询20台设备的电压/电流/故障码,并把异常值推到企业微信;另一个是帮注塑厂改造老旧温控柜,用树莓派+Python客户端采集8路PT100温度,再喂给本地训练的LSTM模型预测模具寿命;第三个是为高校实验室开发教学演示平台,让学生拖拽寄存器地址就能看到原始字节流如何被解析成浮点数。这三个项目,没有一个能靠Modbus Poll搞定——它们要的是逻辑、是状态管理、是错误恢复、是日志溯源。而这些,全得靠你自己写的客户端代码来承载。

所以这篇内容不是教你怎么点开Modbus Poll输个IP就完事,而是带你从协议规范出发,一行行敲出真正能进产线、扛住7×24小时运行、出了问题能快速定位的Modbus TCP客户端。它不追求炫技,只讲清楚:报文怎么构造、连接怎么维持、异常怎么分类、浮点数怎么拆、字节序怎么选、重试策略怎么设、日志怎么打才对得起运维同事半夜打来的电话。如果你正卡在“读出来一堆0”“超时没反应”“浮点数全是NaN”“寄存器地址对不上”这些坑里,那接下来的内容,就是你该抄的作业。

2. 协议底层拆解:Modbus TCP报文结构与通信逻辑

2.1 Modbus TCP不是新协议,而是“旧协议穿新衣”

很多人误以为Modbus TCP和Modbus RTU是两种不同协议,其实不然。Modbus TCP本质上就是把Modbus RTU帧的“功能码+数据区”部分,原样塞进TCP数据包里,前面加了一个7字节的MBAP头(Modbus Application Protocol Header)。它完全抛弃了RTU里的CRC校验和起始/结束字符,把可靠性交给TCP层本身——这正是它比RTU更简单、更易调试的根本原因。

我们来看一个真实抓包示例(Wireshark截取):
192.168.1.100:502 → 192.168.1.200:49153
TCP Payload(十六进制):
00 01 00 00 00 06 01 03 00 00 00 02

这12个字节,就是一次标准的“读保持寄存器(功能码03)”请求。我们逐段拆解:

字节位置长度含义具体值说明
0–12字节事务标识符(Transaction ID)00 01客户端自定义,服务端原样返回,用于匹配请求/响应。不是序列号,可重复,但同一时间不能有相同ID的未完成请求
2–32字节协议标识符(Protocol ID)00 00固定为0,表示Modbus协议。未来扩展预留。
4–52字节长度字段(Length)00 06表示后续字节数,此处为6字节(单元标识符1字节 + 功能码1字节 + 数据区4字节)。注意:这是整个PDU长度,不含MBAP头
61字节单元标识符(Unit ID)01原本RTU里的“从站地址”,TCP中常设为0x01(尤其当服务端只挂一台设备时)。若服务端支持多设备路由,此字段才起作用。
71字节功能码(Function Code)0303=读保持寄存器,06=写单个寄存器,16=写多个寄存器等。必须和服务端支持的功能严格匹配,否则返回异常响应(功能码+0x80)
8–114字节数据区(Data)00 00 00 02前2字节=起始地址(0x0000),后2字节=寄存器数量(0x0002)。地址从0开始计数,但多数设备文档标的是“40001”这种十进制偏移,需换算:40001 → 地址0,40002 → 地址1,以此类推

提示:很多初学者卡在“读不到数据”,第一反应是IP或端口错,其实80%的问题出在地址换算上。比如汇川H3U PLC手册写“400001地址对应M0”,这里的400001是Modbus传统地址编号(4xxxx表示保持寄存器),实际发送时应填0x0000(即十进制0),而非0x400001。这个坑我踩过三次,每次都要翻手册确认前缀。

2.2 响应报文结构:成功与异常的二元世界

服务端响应同样遵循MBAP头+PDU结构。成功响应时,功能码不变,数据区变为实际读取的寄存器值;异常响应时,功能码高位置1(即+0x80),数据区变为1字节异常码。

继续上面的例子,假设服务端返回:
00 01 00 00 00 07 01 03 04 00 01 00 02

  • 前6字节MBAP头(00 01 00 00 00 07)中,Length=0x07=7,表示PDU长7字节;
  • PDU:01 03 04 00 01 00 02
    • Unit ID=01,Function Code=03(未置位),说明是正常响应;
    • 04= 字节数(Byte Count),表示后续有4字节数据;
    • 00 01 00 02= 两个16位寄存器值:0x0001 和 0x0002。

而如果服务端返回:
00 01 00 00 00 03 01 83 02

  • Length=0x03=3,PDU=01 83 02
  • Function Code=0x83=0x03 + 0x80,说明是功能码03的异常响应;
  • 02= 异常码,查Modbus规范可知:02=非法地址(Illegal Data Address),即你请求的寄存器地址超出设备范围。

注意:异常响应不包含数据区长度字段!Length字段值=3(Unit ID 1字节 + Function Code 1字节 + Exception Code 1字节),这点和成功响应完全不同。很多手写解析器因忽略此差异,导致解析失败或内存越界。

2.3 连接模型:无状态 vs 有状态,TCP连接到底要不要复用?

Modbus TCP规范本身是无状态的——每次请求都是独立的,服务端不保存客户端上下文。但现实中,频繁创建/关闭TCP连接会带来巨大开销:三次握手、四次挥手、端口耗尽、TIME_WAIT堆积。尤其在高频采集场景(如每100ms读一次),连接复用是必须的。

然而,复用连接也引入新问题:

  • 粘包风险:TCP是流式协议,两次send()可能被合并成一个TCP包,或一个send()被拆成多个包。若客户端未做应用层分包,可能把两个响应报文当做一个解析,导致Length字段错乱;
  • 请求队列阻塞:若第一个请求超时未返回,后续请求是否排队?还是直接丢弃?这决定了客户端的并发能力;
  • 连接保活:空闲连接可能被中间防火墙/NAT设备断开,需心跳机制维持。

我的实践方案是:默认启用连接池(Connection Pooling),单个Socket复用,但每个请求严格按“发→收→解析”原子执行,禁止并发写入同一Socket。这样既避免粘包(因为每次recv()前已知Length字段,可精确读取指定字节数),又规避了锁竞争。对于需要高吞吐的场景(如同时监控50台设备),则采用“每设备独占连接+异步IO”模式,用asyncio或threading隔离。

3. Python实现核心:从socket裸写到pymodbus深度定制

3.1 方案选型:为什么不用pymodbus,而选择“半手写”?

社区主流方案是pymodbus库,它封装完善、支持RTU/TCP/ASCII、内置异步、有CLI工具。但它也有硬伤:

  • 过度抽象client.read_holding_registers()背后隐藏了MBAP头构造、超时重试、异常码映射等细节,出问题时你只能看源码;
  • 日志粒度粗:默认只打印“Read failed”,不告诉你到底是连接拒绝、读超时、还是收到异常码02;
  • 定制成本高:想加一个自定义重试退避算法(如指数退避+抖动),或把原始报文存入InfluxDB,得绕过层层装饰器;
  • 依赖臃肿:v3.x版本依赖twisted、pyserial等,而纯TCP客户端根本不需要串口支持。

所以我推荐“半手写”路径:底层用原生socket保证可控性,上层用pymodbus的编解码模块(pymodbus.transactionpymodbus.utilities)处理字节转换。这样既避开socket编程的繁琐(如字节序转换、大端小端判断),又保留对连接、超时、重试的完全掌控。

具体分工如下:

  • socket连接管理、send/recv、超时设置、异常捕获 → 自己写;
  • MBAP头构造/解析、功能码校验、寄存器地址转字节、浮点数编码/解码 → 复用pymodbus的BinaryPayloadBuilder/BinaryPayloadDecoder
  • 日志格式、重试策略、连接池管理 → 自己定义。

实测对比:纯socket手写版(含完整MBAP解析)约320行,pymodbus最小化调用约180行,但后者在异常定位上节省至少50%调试时间。这不是偷懒,而是把精力聚焦在业务逻辑而非协议细节上。

3.2 核心类设计:ModbusTCPClient——一个能进产线的客户端骨架

下面是一个精简但生产可用的ModbusTCPClient类骨架(关键方法已展开,完整代码见文末附录):

import socket import struct import time import logging from typing import List, Tuple, Optional, Union from pymodbus.payload import BinaryPayloadDecoder, BinaryPayloadBuilder from pymodbus.constants import Endian from pymodbus.exceptions import ModbusIOException class ModbusTCPClient: def __init__(self, host: str, port: int = 502, timeout: float = 3.0, retries: int = 3, retry_delay: float = 0.1, unit_id: int = 1): self.host = host self.port = port self.timeout = timeout self.retries = retries self.retry_delay = retry_delay self.unit_id = unit_id self._sock = None self._transaction_id = 0 self._logger = logging.getLogger(f"ModbusTCP-{host}") def _connect(self) -> None: """建立TCP连接,带重试""" for attempt in range(self.retries + 1): try: self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock.settimeout(self.timeout) self._sock.connect((self.host, self.port)) self._logger.debug(f"Connected to {self.host}:{self.port}") return except (socket.timeout, socket.error) as e: if attempt == self.retries: raise ModbusIOException(f"Failed to connect after {self.retries} attempts: {e}") self._logger.warning(f"Connect attempt {attempt+1} failed: {e}. Retrying in {self.retry_delay}s...") time.sleep(self.retry_delay) def _send_receive(self, pdu: bytes) -> bytes: """发送PDU并接收完整响应,处理粘包""" # 构造完整MBAP请求 self._transaction_id = (self._transaction_id + 1) & 0xFFFF mbap_header = struct.pack(">HHHB", self._transaction_id, # Transaction ID 0, # Protocol ID len(pdu) + 1, # Length = PDU length + Unit ID self.unit_id) # Unit ID request = mbap_header + pdu try: self._sock.send(request) # 先读MBAP头(7字节) header = self._recv_all(7) _, _, length, _ = struct.unpack(">HHHB", header) # 再读PDU(length字节) pdu_response = self._recv_all(length) return pdu_response except socket.timeout: raise ModbusIOException("Request timed out") except socket.error as e: raise ModbusIOException(f"Socket error: {e}") def _recv_all(self, n: int) -> bytes: """可靠接收n字节,处理TCP粘包/拆包""" data = b'' while len(data) < n: chunk = self._sock.recv(n - len(data)) if not chunk: raise ModbusIOException("Connection closed by server") data += chunk return data def read_holding_registers(self, address: int, count: int, byteorder: Endian = Endian.BIG, wordorder: Endian = Endian.BIG) -> List[int]: """读保持寄存器,返回原始16位整数列表""" # 构造功能码03 PDU pdu = struct.pack(">BHH", 0x03, address, count) response = self._send_receive(pdu) # 解析响应 if len(response) < 2: raise ModbusIOException("Response too short") func_code = response[0] if func_code & 0x80: # 异常响应 exception_code = response[1] raise ModbusIOException(f"Modbus exception {exception_code}") byte_count = response[1] if len(response) != 2 + byte_count: raise ModbusIOException("Response length mismatch") registers = [] for i in range(0, byte_count, 2): reg_bytes = response[2+i:2+i+2] reg_val = struct.unpack(">H", reg_bytes)[0] # 默认大端 registers.append(reg_val) return registers def read_input_registers(self, address: int, count: int) -> List[int]: # 类似read_holding_registers,功能码04 pass def write_single_register(self, address: int, value: int) -> bool: # 功能码06 pass def close(self) -> None: if self._sock: self._sock.close() self._sock = None

这个类的设计哲学是:暴露足够多的控制权,但隐藏协议细节。比如read_holding_registers方法,你只需传入地址、数量、字节序,它自动处理MBAP头、超时、重试、异常码映射;但如果你想查看原始报文,可以重载_send_receive方法添加日志;如果想换用小端字节序,直接传byteorder=Endian.LITTLE即可。

3.3 浮点数与双字处理:为什么0x42C80000不是100.0?

工业设备中,温度、压力、流量等模拟量通常以32位浮点数(IEEE 754)存储在连续两个16位寄存器中。但Modbus协议本身不定义数据类型,只规定“寄存器是16位容器”,因此浮点数如何拆分、如何排序,完全取决于设备厂商。

常见两种布局:

  • ABCD模式(Big-Endian):高位字在前,低位字在后。例如浮点数100.0的IEEE 754编码为0x42C80000,拆成两个16位寄存器:0x42C8(高位)和0x0000(低位)。设备按地址递增顺序存放,即地址40001=0x42C8,40002=0x0000。
  • CDAB模式(Little-Endian):低位字在前,高位字在后。同一数值100.0,寄存器顺序为:40001=0x0000,40002=0x42C8。

更复杂的是字内字节序:有些设备(如部分西门子PLC)在寄存器内部也交换字节,即0x42C8存成0xC842。这就形成了“寄存器序+字节序”的双重组合。

pymodbus的BinaryPayloadDecoder完美解决这个问题:

# 假设从设备读到两个寄存器:[0x42C8, 0x0000] registers = [0x42C8, 0x0000] decoder = BinaryPayloadDecoder.fromRegisters( registers, byteorder=Endian.BIG, # 寄存器间字节序(ABCD or CDAB) wordorder=Endian.BIG # 寄存器内字节序(Big or Little) ) float_value = decoder.decode_32bit_float() # 返回100.0 # 若设备是CDAB+Big模式(先读低位寄存器),则: decoder = BinaryPayloadDecoder.fromRegisters( [0x0000, 0x42C8], # 顺序调换 byteorder=Endian.LITTLE, # 寄存器序为Little(CDAB) wordorder=Endian.BIG # 寄存器内仍为Big )

实操心得:第一次对接新设备,务必用Modbus Poll的“Read Holding Registers”功能,手动输入地址,观察原始16进制值,再用在线IEEE 754转换器验证。我曾为一家水厂调试压力变送器,厂家文档写“ABCD”,实测却是“CDAB”,折腾两天才发现是文档印刷错误。现在我的标准流程是:先抓包看原始字节,再对照设备手册,最后用pymodbus decoder验证,三者一致才写入正式代码。

4. 工程化落地:连接池、日志、重试与生产环境适配

4.1 连接池实现:避免TIME_WAIT风暴与端口耗尽

在Linux系统中,TCP连接关闭后会进入TIME_WAIT状态,持续2MSL(约60秒)。若每秒新建100个连接,60秒内将累积6000个TIME_WAIT连接,很快耗尽本地端口(默认约28000可用)。这对高频采集场景是致命的。

解决方案是连接池(Connection Pool),核心思想:预创建N个空闲连接,请求时从中获取,用完归还,而非每次都新建。这里给出一个线程安全的简易实现:

import threading from queue import Queue from contextlib import contextmanager class ModbusTCPConnectionPool: def __init__(self, host: str, port: int = 502, max_connections: int = 10, **client_kwargs): self.host = host self.port = port self.max_connections = max_connections self.client_kwargs = client_kwargs self._pool = Queue(maxsize=max_connections) self._lock = threading.Lock() # 预热连接池 for _ in range(max_connections): try: client = ModbusTCPClient(host, port, **client_kwargs) client._connect() # 立即建立连接 self._pool.put(client) except Exception as e: logging.warning(f"Failed to pre-warm connection: {e}") @contextmanager def get_client(self): client = None try: client = self._pool.get(timeout=5) # 最多等5秒 yield client finally: if client: # 检查连接是否还活着(简单ping) try: client._sock.send(b'') # 发送空包触发异常 except: # 连接已断,新建一个替换 try: new_client = ModbusTCPClient(self.host, self.port, **self.client_kwargs) new_client._connect() self._pool.put(new_client) except: pass # 新建失败,跳过 else: self._pool.put(client) # 归还连接 # 使用示例 pool = ModbusTCPConnectionPool("192.168.1.100", max_connections=5) with pool.get_client() as client: values = client.read_holding_registers(0, 10) print(values)

这个池子的关键点:

  • 预热机制:启动时就建立好连接,避免首次请求延迟;
  • 健康检查:归还前用send(b'')探测连接活性,断开的连接会被新连接替换;
  • 超时控制get_client()最多等5秒,避免线程永久阻塞;
  • 线程安全Queuethreading.Lock保证多线程并发安全。

注意:连接池不适合长连接+低频场景(如每天只读一次),反而增加内存占用。我的经验是:采集频率≥1Hz时必用池子;≤0.1Hz时,直接单例客户端更轻量。

4.2 日志体系:让运维同事半夜打电话时你能秒定位

工业现场的日志不是为了“好看”,而是为了“救命”。当产线停机,领导问“为什么温度读不出来”,你不能说“我看看代码”,而要立刻给出:“14:23:05.123,与192.168.1.100:502连接超时,重试3次失败,建议检查PLC网络灯”。

因此,日志必须包含五个要素:时间戳(毫秒级)、设备IP、功能码、地址范围、错误详情。我用Python logging模块配置如下:

import logging def setup_modbus_logger(): logger = logging.getLogger("ModbusTCP") logger.setLevel(logging.DEBUG) # 文件处理器:滚动日志,保留30天 file_handler = logging.handlers.RotatingFileHandler( "modbus_client.log", maxBytes=10*1024*1024, # 10MB backupCount=30 ) file_formatter = logging.Formatter( '%(asctime)s.%(msecs)03d | %(levelname)-8s | %(name)s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) file_handler.setFormatter(file_formatter) # 控制台处理器:只输出WARNING以上 console_handler = logging.StreamHandler() console_handler.setLevel(logging.WARNING) console_formatter = logging.Formatter('%(levelname)s - %(message)s') console_handler.setFormatter(console_formatter) logger.addHandler(file_handler) logger.addHandler(console_handler) return logger # 在客户端中注入 self._logger = logging.getLogger(f"ModbusTCP-{host}")

关键日志点:

  • 连接建立/断开时记录INFO
  • 每次请求前记录DEBUG:“Sending 03@40001-40005”;
  • 超时、异常码、socket错误记录ERROR,并包含原始报文hexdump;
  • 重试时记录WARNING:“Retry #2 for 03@40001, delay 0.2s”。

实操心得:有一次客户现场,日志显示“Modbus exception 04”(设备忙),但设备面板一切正常。我翻看完整日志发现,该异常总在某个特定时间点(凌晨2:15)集中爆发。最终定位到是客户定时任务在那时重启PLC固件,导致Modbus服务短暂不可用。没有毫秒级日志,这个规律根本无法发现。

4.3 重试策略:不是越多越好,而是越智能越好

Modbus TCP的典型失败原因:网络抖动、设备瞬时过载、防火墙拦截、PLC扫描周期未到。简单粗暴的“重试3次”往往无效——若网络中断10秒,重试3次(间隔0.1秒)毫无意义;若设备真忙,连续重试只会加剧负载。

我采用**指数退避+抖动(Exponential Backoff with Jitter)**策略:

import random def calculate_retry_delay(attempt: int) -> float: """计算第attempt次重试的等待时间(秒)""" base_delay = 0.1 # 基础延迟0.1秒 max_delay = 5.0 # 最大延迟5秒 # 指数增长:0.1, 0.2, 0.4, 0.8, 1.6... delay = min(base_delay * (2 ** (attempt - 1)), max_delay) # 加入0~100%随机抖动,避免雪崩 jitter = random.uniform(0, delay * 0.5) return delay + jitter # 使用示例 for attempt in range(1, self.retries + 1): try: return self._send_receive(pdu) except ModbusIOException as e: if attempt == self.retries: raise delay = calculate_retry_delay(attempt) self._logger.warning(f"Attempt {attempt} failed: {e}. Retrying in {delay:.3f}s...") time.sleep(delay)

这个策略的优势:

  • 避免同步重试:随机抖动让不同客户端的重试时间错开,防止瞬间大量请求压垮设备;
  • 适应网络状况:早期快速重试(0.1s),后期耐心等待(最大5s),兼顾响应速度与成功率;
  • 可配置base_delaymax_delay可根据设备响应特性调整(如老旧PLC设base_delay=0.5s)。

注意:重试仅针对网络层和协议层错误(超时、连接拒绝、异常码01/02/04),不重试异常码05(拒绝对话)或06(设备忙)——这些是设备主动拒绝,重试只会加重负担。我的规则是:收到05/06,记录日志后立即返回,由上层业务逻辑决定是否降频采集。

5. 常见问题排查:从“读出来全是0”到“浮点数变NaN”的实战手册

5.1 连接类问题速查表

现象可能原因排查步骤解决方案
ConnectionRefusedError设备未开启Modbus TCP服务;防火墙拦截502端口;IP地址错误1.ping 192.168.1.100确认网络通;
2.telnet 192.168.1.100 502测试端口开放;
3. 查设备手册确认Modbus TCP功能已启用
开启设备Modbus服务;关闭防火墙或放行502端口;核对IP/MAC绑定
TimeoutError网络延迟过高;设备响应慢;客户端timeout设置过短1.ping -t 192.168.1.100观察丢包率;
2. 用Modbus Poll测试同一地址,对比响应时间;
3. 抓包看是否发出请求但无响应
增加客户端timeout(如设为10s);优化网络(换网线、改交换机);联系设备厂商确认扫描周期
ConnectionResetError设备主动断开连接;中间设备(如工控防火墙)重置连接Wireshark抓包,看是否有RST包;检查设备日志是否有“连接数超限”提示减少并发连接数;启用连接池;联系网络管理员检查防火墙策略

提示:telnet IP 502是最快速的端口检测法。如果telnet能连上但Modbus客户端连不上,问题一定出在协议层(如MBAP头错误、Unit ID不匹配),而非网络层。

5.2 数据类问题深度解析

问题1:“读出来全是0,但Modbus Poll能读到正确值”

这几乎100%是地址换算错误。Modbus Poll默认使用“40001”这种十进制地址,而Python代码需传入十六进制偏移。例如:

  • Modbus Poll中输入地址40001→ 实际请求地址0x0000(十进制0);
  • 输入400020x0001
  • 输入410000x03E7(十进制999)。

验证方法:用Wireshark抓Modbus Poll的请求包,看MBAP后的PDU数据区,前两字节就是真实地址。

问题2:“浮点数解析结果是NaN或极大值”

根源在于字节序不匹配。IEEE 754的0x42C80000在ABCD模式下是100.0,在CDAB模式下是struct.unpack('<f', b'\x00\x00\xc8B')[0] ≈ 1.19e-38(极小值),而某些错误组合会直接产生NaN。

解决方案:

  1. 用Modbus Poll读取两个寄存器,记下原始值(如0x42C8,0x0000);
  2. 用在线工具(如https://www.h-schmidt.net/FloatConverter/IEEE754.html)输入42C80000,确认期望值;
  3. 尝试四种组合:
    • [0x42C8, 0x0000]+ BIG/BIG → 100.0
    • [0x0000, 0x42C8]+ LITTLE/BIG → 100.0
    • [0xC842, 0x0000]+ BIG/LITTLE → ?
    • [0x0000, 0xC842]+ LITTLE/LITTLE → ?
  4. 找到匹配项,固化到代码中。
问题3:“写寄存器成功,但设备状态没变化”

常见于线圈(Coil)和保持寄存器(Holding Register)混淆。功能码01/05操作线圈(0x地址),03/06/16操作寄存器(4x地址)。例如:

  • 想控制一个继电器(通常映射到线圈00001),却用了write_register(0, 1)(功能码06),设备无视;
  • 正确做法:write_coil(0, True)(功能码05)。

验证:用Modbus Poll的“Write Single Coil”功能测试同一地址,看设备是否响应。

5.3 性能与稳定性避坑指南

  • 坑1:在循环中反复创建/销毁客户端
    错误写法:

    for addr in device_list: client = ModbusTCPClient(addr) client.read_holding_registers(0, 10) client.close() # 每次都新建连接!

    正确写法:用连接池,或单例客户端复用。

  • 坑2:未处理异常码05(拒绝对话)
    某些PLC在固件升级时会返回05,此时重试毫无意义。应在_send_receive中捕获并特殊处理:

    if

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询