Skip to content

Modbus RTU/TCP 故障处理案例

案例一:RS-485 通信间歇性失败

故障现象

储能系统中,BMS 通过 RS-485 连接到网关,通信成功率约 85%,每隔几分钟出现一次超时错误,重试后恢复正常。

排查过程

bash
# 1. 使用示波器或逻辑分析仪抓取 RS-485 信号
# 发现信号边沿不干净,有振铃现象

# 2. 检查终端电阻
# 用万用表测量总线两端电阻:
# A-B 之间:测量到 ~240Ω(正常应为 60Ω,即两个 120Ω 并联)
# 实际只有一端接了终端电阻!

# 3. 检查总线长度
# 实测总线长度:450m,波特率:19200bps
# 查表:19200bps 最大推荐距离 300m → 超出范围!

# 4. 检查接地
# 发现多个从站的屏蔽层都接了地 → 形成地环路,引入干扰

解决方案

1. 在总线另一端补接 120Ω 终端电阻
2. 将波特率降低到 9600bps(支持 1200m)
3. 屏蔽层改为单端接地(仅主站侧)
4. 在干扰严重的区段更换为铠装屏蔽双绞线

修改后通信成功率提升至 99.9%

案例二:Modbus TCP 连接数耗尽

故障现象

SCADA 系统连接多台 PLC(每台 Modbus TCP),运行 2-3 天后,部分 PLC 无法建立新连接,重启 PLC 后恢复。

排查过程

bash
# 在 PLC 侧(Linux 工控机)查看连接状态
netstat -an | grep :502 | wc -l
# 输出:128  ← 已达到 PLC 最大连接数限制

netstat -an | grep :502 | grep TIME_WAIT | wc -l
# 输出:115  ← 大量 TIME_WAIT 状态连接!

# 查看 SCADA 侧代码
# 发现每次读取都新建连接,读完后调用 close()
# 但 TCP TIME_WAIT 状态持续 2*MSL(约 60-120 秒)
# 高频轮询导致 TIME_WAIT 连接堆积

解决方案

python
# 错误做法:每次读取都新建连接
def bad_read(host, address):
    client = ModbusTcpClient(host)
    client.connect()
    result = client.read_holding_registers(address, 10)
    client.close()  # 产生 TIME_WAIT
    return result

# 正确做法1:长连接复用
class PersistentModbusClient:
    def __init__(self, host, port=502):
        self.client = ModbusTcpClient(host, port=port, timeout=3)
        self._connect()

    def _connect(self):
        retry = 0
        while retry < 3:
            if self.client.connect():
                return
            retry += 1
            time.sleep(1)
        raise ConnectionError(f"Cannot connect to Modbus device")

    def read(self, address, count, unit_id=1):
        if not self.client.is_socket_open():
            self._connect()
        return self.client.read_holding_registers(address, count, slave=unit_id)

# 正确做法2:调整 TCP 参数减少 TIME_WAIT
# Linux 系统级配置
import subprocess
subprocess.run(['sysctl', '-w', 'net.ipv4.tcp_tw_reuse=1'])
subprocess.run(['sysctl', '-w', 'net.ipv4.tcp_fin_timeout=15'])

案例三:浮点数读取值异常

故障现象

读取变频器的输出频率寄存器,得到的值完全错误(如应为 50.0Hz,读到 -1.08e+38)。

排查过程

python
# 读取两个寄存器
result = client.read_holding_registers(100, 2, slave=1)
print(result.registers)  # [0x4248, 0x0000]

# 尝试大端序解析
import struct
raw_be = struct.pack('>HH', 0x4248, 0x0000)
value_be = struct.unpack('>f', raw_be)[0]
print(f"Big-endian: {value_be}")  # 50.0 ✓

# 但实际读到的是 [0x0000, 0x4248](字节序相反)
raw_wrong = struct.pack('>HH', 0x0000, 0x4248)
value_wrong = struct.unpack('>f', raw_wrong)[0]
print(f"Wrong order: {value_wrong}")  # -1.08e+38 ✗

# 结论:该变频器使用小端字序(Low Word First)
raw_le = struct.pack('>HH', 0x0000, 0x4248)  # 低字在前
# 需要交换两个寄存器再解析
raw_fixed = struct.pack('>HH', 0x4248, 0x0000)
value_fixed = struct.unpack('>f', raw_fixed)[0]
print(f"Fixed: {value_fixed}")  # 50.0 ✓

解决方案

python
class FlexibleDecoder:
    """支持多种字节序的 Modbus 数据解码器"""

    @staticmethod
    def decode_float32(registers, word_order='big'):
        """
        word_order: 'big' = 高字在前(ABCD)
                    'little' = 低字在前(CDAB)
                    'big_swap' = 字节交换大端(BADC)
                    'little_swap' = 字节交换小端(DCBA)
        """
        if word_order == 'big':
            raw = struct.pack('>HH', registers[0], registers[1])
        elif word_order == 'little':
            raw = struct.pack('>HH', registers[1], registers[0])
        elif word_order == 'big_swap':
            raw = struct.pack('<HH', registers[0], registers[1])
        elif word_order == 'little_swap':
            raw = struct.pack('<HH', registers[1], registers[0])
        else:
            raise ValueError(f"Unknown word_order: {word_order}")

        return struct.unpack('>f', raw)[0]

# 不同厂商的字节序习惯:
# Schneider/Modicon:大端(ABCD)
# Siemens S7:大端(ABCD)
# ABB 变频器:小端(CDAB)
# 部分中国品牌:需查手册确认

案例四:多主站冲突

故障现象

同一 RS-485 总线上,SCADA 和本地 HMI 同时轮询从站,导致大量 CRC 错误和超时。

根本原因

RS-485 是半双工总线,同一时刻只能有一个主站发送。两个主站同时发送导致总线冲突。

解决方案

方案1:硬件隔离(推荐)
将 HMI 连接到独立的 RS-485 端口(从站支持多端口时)

方案2:Modbus TCP 网关
RS-485 总线 → Modbus RTU/TCP 网关 → 以太网
SCADA 和 HMI 都通过以太网访问网关,网关串行化请求

方案3:时分复用(软件协调)
SCADA 和 HMI 通过共享锁协调访问时间窗口
python
# 方案2:使用 Modbus RTU/TCP 网关(如 Moxa NPort)
# 网关将 RS-485 总线暴露为 Modbus TCP 服务
# 内部串行化所有 TCP 请求,避免总线冲突

# 方案3:分布式锁(Redis)
import redis
import time

class ModbusBusLock:
    def __init__(self, redis_client, bus_id: str, timeout: float = 5.0):
        self.redis = redis_client
        self.lock_key = f"modbus_bus_lock:{bus_id}"
        self.timeout = timeout

    def __enter__(self):
        deadline = time.time() + self.timeout
        while time.time() < deadline:
            # SET NX PX:原子加锁
            if self.redis.set(self.lock_key, "1",
                              nx=True, px=int(self.timeout * 1000)):
                return self
            time.sleep(0.01)
        raise TimeoutError(f"Cannot acquire Modbus bus lock for {self.lock_key}")

    def __exit__(self, *args):
        self.redis.delete(self.lock_key)

# 使用
redis_client = redis.Redis()
with ModbusBusLock(redis_client, "rs485-line1"):
    result = client.read_holding_registers(0, 10, slave=1)

常见错误码

异常码名称含义处理方法
0x01Illegal Function从站不支持该功能码检查功能码是否正确
0x02Illegal Data Address寄存器地址不存在检查地址映射表
0x03Illegal Data Value数据值超出范围检查写入值范围
0x04Slave Device Failure从站内部错误检查从站设备状态
0x05Acknowledge从站正在处理,稍后重试等待后重试
0x06Slave Device Busy从站忙降低轮询频率
0x0AGateway Path Unavailable网关路径不可用检查网关配置
0x0BGateway Target Device Failed目标设备无响应检查设备连接

诊断工具使用

bash
# 使用 modpoll 命令行工具测试
# 读取从站 1 的保持寄存器 40001-40010
modpoll -m tcp -a 1 -r 1 -c 10 192.168.1.100

# RTU 模式
modpoll -m rtu -b 9600 -d 8 -s 1 -p none -a 1 -r 1 -c 10 /dev/ttyS1

# 写入单个寄存器
modpoll -m tcp -a 1 -r 100 -1 -t 4 192.168.1.100 1234

# Wireshark 过滤 Modbus TCP
# 过滤器:modbus
# 查看 Function Code、Exception Code、数据内容

褚成志的IoT笔记