

前面几篇把采集引擎、协议转换网关、Docker 化部署、双供电切换测试讲透了。这一篇下沉到链路层——现场最磨人的一类故障:通信时好时坏,日志里偶发超时,重启后恢复,过几天又犯。靠 ping 和换网线解决不了时,Wireshark 抓包是唯一能定位根因的手段。
排查时别急着下结论,先分清是谁发的 RST、在哪个阶段发的。
# 宿主侧,按传感器 IP 过滤存盘,循环分片
sudo tcpdump -i eth0 -nn -s0 -G 3600 -W 4 \
-w /data/pcap/modbus_%Y%m%d_%H%M%S.pcap \
"host 192.168.10.21 and tcp port 502"Wireshark GUI 直接抓也行,捕获过滤器:
host 192.168.10.21 and tcp port 502用 Python/pymodbus 跑稳定轮询,故意制造边界条件:拔 PoE、关交换机端口、改防火墙规则、拉长空闲期。保留日志与抓包时间轴对齐。
# 基本聚焦
ip.addr == 192.168.10.21 and tcp.port == 502
# 重传 / 乱序 / 零窗口
tcp.analysis.retransmission
tcp.analysis.duplicate_ack
tcp.analysis.zero_window_probe || tcp.zero_window || tcp.window_size == 0
# 连接复位与异常关闭
tcp.flags.reset == 1
tcp.flags.fin == 1
tcp.analysis.connection_reset # 专家信息辅助
# Modbus 层
modbus
mbtcp.trans_id == 5
modbus.func_code == 3
modbus.exception_code != 0
# 按流跟踪
tcp.stream eq 12注意:显示过滤器 tcp.port == 502 会匹配任一端端口为 502 的段;客户端临时端口在对端,别误判。建议跟进 tcp.stream 或 Follow → TCP Stream。
C->S [PSH,ACK] MBAP/TCP Read Holding Registers 40001 len=6
S->C [ACK]
... 无响应数据
C->S [Retransmission] 同上段
C->S [Retransmission]
C->S [Retransmission]
C RTO 退避后放弃,应用层超时,关闭 socket
C->S [FIN,ACK] 或 close() 后无 FIN 直接新连接判定:
C->S [ACK] 空闲 300s
中间设备(防火墙/NAT/交换机状态检测)老化会话
C 下次轮询发 [PSH,ACK]
S 或中间设备回 [RST,ACK]
C 应用层报 ConnectionResetError / WinError 10054判定要点:
C 持长连接轮询
S 固件会话超时(如 60–180s 无活动)或最大连接数限制
S->C [RST,ACK] 或 [FIN,ACK]
C pymodbus 复用旧 socket 继续写 -> EPIPE/Broken pipe/10054判定:
S->C [FIN,ACK] 固件重启或任务复位
C TCP 栈回 [ACK],进入 CLOSE_WAIT,应用未调用 close()
后续轮询继续在旧 fd 上 send(),返回错误或未报错但丢弃
新轮询逻辑新建连接,出现 TIME_WAIT 堆积判定:ss -tno | grep 502、宿主机 netstat 看状态;Python 侧捕获异常后未销毁重建。
tcp.stream,看每条流建立、保持、终止。 mbtcp.trans_id、mbtcp.unit_id、modbus.func_code。 frame.time_delta_displayed 或 I/O Graph,画请求→响应间隔。 pymodbus 3.x 异步客户端,关键在连接状态管理、失败后退避重建、不在旧 socket 上继续发。
import asyncio, logging, time
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ModbusIOException, ConnectionException
log = logging.getLogger("modbus_poller")
class Poller:
def __init__(self, host, port=502, slave=1, poll=5.0, timeout=2.0):
self.host, self.port, self.slave = host, port, slave
self.poll, self.timeout = poll, timeout
self.client = None
self._conn_lock = asyncio.Lock()
async def _ensure_conn(self):
# 惰性建连 + 健康探测
if self.client is None or not self.client.connected:
async with self._conn_lock:
if self.client is not None:
try: self.client.close()
except Exception: pass
self.client = AsyncModbusTcpClient(
self.host, port=self.port, timeout=self.timeout,
retry_on_empty=True, retries=2,
)
# pymodbus 3.x 走 asyncio 事件循环
ok = await self.client.connect()
if not ok:
raise ConnectionException("connect failed")
# 可选:设置 sockopt keepalive(见下)
async def read(self):
for attempt in range(3):
try:
await self._ensure_conn()
resp = await self.client.read_holding_registers(0, count=2, slave=self.slave)
# pymodbus: resp None / isError / 异常响应都要处理
if resp is None or resp.isError():
raise ModbusIOException(f"bad resp: {resp}")
temp = resp.registers[0] * 0.1
humid = resp.registers[1] * 0.1
return {"temp": temp, "humid": humid, "quality": 0}
except (ConnectionException, ModbusIOException, OSError) as e:
log.warning("poll fail attempt=%d: %s", attempt, e)
# 强制销毁,下一轮重建
try: self.client.close()
except Exception: pass
self.client = None
await asyncio.sleep(min(2 ** attempt, 8)) # 退避,别自旋
return {"quality": 2}
async def run(self):
while True:
t0 = time.monotonic()
res = await self.read()
# 上报/缓存/补传逻辑略
dt = time.monotonic() - t0
await asyncio.sleep(max(0.0, self.poll - dt))注意点:
AsyncModbusTcpClient.connect() 在 pymodbus 3.x 返回 bool/协程行为随版本变化,务必按所用版本验证;包装 try/except,失败后置 None。 asyncio.gather 但各自持有连接。 设置 TCP Keepalive(在自建 socket 时更可控,下面给参考):
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# Linux
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)pymodbus 未暴露该钩子时,用应用层心跳更可移植。
抓包证据 | 根因 | 处置 |
|---|---|---|
重传簇 + CRC/PHY 错误 | 网线/水晶头/屏蔽/EMI/超距 | 换六类屏蔽线、独立桥架、PoE 分离供电,固定双工 100M/1G 全双工 |
RST 来自防火墙 MAC | 状态表老化、NAT 超时 | 长连接保活、静态会话、白名单,或改为受控重连 |
RST 来自传感器 IP,多客户端 | 固件连接数限制/会话超时 | 单主采集、收敛客户端数、固件升级、延长固件超时 |
FIN 后 CLOSE_WAIT 在采集端 | 应用未关 socket | 异常路径显式 close + 重建,别缓存旧对象 |
Dup ACK + 分片迟到 | 固件 TCP 发送缓冲/延迟分片 | 提高应用超时、批量读寄存器、升级固件 |
TID 错配 | 并发请求/旧响应到达 | 串行化或每连接隔离,校验 TID 匹配 |
UDP 旁路通道未处理 | 部分设备报警用 UDP 推,采集端只轮询 TCP | 另起 UDP 接收线程,合并事件总线,别混用连接模型 |
PoE 端口节能关闭 | 交换机节能策略 | 静态 PoE、禁止端口休眠、上行链路监测 |
补充:标题里带 UDP,现场别忽略——部分网口变送器除 Modbus TCP 轮询外,还走 UDP 主动上报报警/心跳。抓包时一并过滤 udp port <n>,确认上报目标 IP/端口、是否需要响应,避免边缘网关只听 TCP 而漏事件。
排障后做回归:
抓包的价值不在"看到重传",而在还原连接生命周期:请求是否发出、是否到达、响应是否返回、谁先关闭、RST 来自哪一层。定位后,修复在采集端连接管理 + 网络路径加固 + 固件/部署约束三方协同,而不是换网线碰运气。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。