写一个 Modbus RTU 现场排查命令行工具:串口探测、寄存器扫描、量程换算与 CRC 校验
现场调试非接触电流电压传感器时(本文以圣斯尔 SSET 的 CE-AJ12N 为例,RS485 Modbus、电流 0-800A、电压 30-500V、辅助电源 15VDC),常见情况不是"不会读数据",而是"读不到数据但不知道卡在哪一步"。串口参数不对、从站地址错、寄存器地址偏移、字节序反了、CRC 校验失败、超时太短——每一项的症状都差不多:没返回或者返回一包看不懂的字节。
本文给一个可运行的命令行工具,把这几步拆开逐项验证。代码只依赖 pyserial,不依赖任何 Modbus 库,方便在现场的任意一台机器上跑。被测器件以圣斯尔 SSET 的 CE-AJ12N 非接触电流电压传感器为例:RS485 接口、Modbus 协议、电流 0-800A(电流精度 ≤0.5%)、电压 30-500V(电压精度 ≤1.5%)、功率精度 ≤1.0%、频率 45-65Hz、辅助电源 15VDC、隔离耐压 ≤2500VDC、工作温度 -40~80℃;命令里的默认参数按它配置。
一、为什么不用现成库先跑通
不是反对用库,而是排查阶段需要看到原始帧。pymodbus 之类的库会把 CRC 校验、异常码、超时异常封装掉,出错时只抛一句 ModbusIOException,看不出是线路问题、地址问题还是波特率问题。
自己组帧有三个直接好处:
- 能看到收发字节,一眼判断是"没回"还是"回了但校验错";
- 能打印异常码(0x01 非法功能、0x02 非法地址、0x03 非法数据、0x04 从站故障);
- 能自己控制超时与重试,方便统计丢包率。
工具跑通之后,正式采集代码再换回成熟的库,是更稳的顺序。
二、Modbus RTU 帧结构回顾
读保持寄存器(功能码 0x03)的请求与应答:
请求: [从站地址][0x03][起始地址Hi][起始地址Lo][数量Hi][数量Lo][CRC16 Lo][CRC16 Hi]
应答: [从站地址][0x03][字节数][数据...][CRC16 Lo][CRC16 Hi]
异常: [从站地址][0x83][异常码][CRC Lo][CRC Hi]
两点容易踩:
- CRC 是低字节在前。上面写成
CRC Lo][CRC Hi,很多"CRC 不对"的问题其实是字节序写反了。 - 帧间间隔是 3.5 个字符时间。连续发帧时要留出这个静默时间,否则从站会把两帧当成一帧。
三、完整代码
保存为 modbus_probe.py,pip install pyserial 后即可运行。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Modbus RTU 现场排查命令行工具(仅依赖 pyserial)。
子命令:
probe 扫描串口参数组合(波特率/校验/停止位),找出能从站应答的组合
scan 扫描从站地址与寄存器区间,列出有响应的地址
read 读指定寄存器并按量程换算为工程量
crc 计算并校验一帧的 CRC16
"""
import argparse
import struct
import sys
import time
try:
import serial
except ImportError:
sys.exit("需要 pyserial:pip install pyserial")
EXC_TEXT = {
0x01: "非法功能码(从站不支持该功能)",
0x02: "非法数据地址(寄存器地址不存在)",
0x03: "非法数据值(数量或取值越界)",
0x04: "从站设备故障",
}
def crc16(data):
"""Modbus CRC16,多项式 0xA001,返回低字节在前的两字节。"""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return struct.pack("<H", crc)
def build_read(slave, addr, count):
"""构造功能码 0x03 读保持寄存器请求帧。"""
body = struct.pack(">B B H H", slave, 0x03, addr, count)
return body + crc16(body)
def char_time(baud, bits=11):
"""单个字符传输时间(秒):默认 8N1 共 11 位。"""
return bits / float(baud)
def transact(ser, frame, expect_len=None, timeout=0.5):
"""发一帧并收应答,返回 (bytes, 状态说明)。"""
ser.reset_input_buffer()
ser.write(frame)
# 帧间静默:3.5 个字符时间
time.sleep(char_time(ser.baudrate) * 3.5)
deadline = time.time() + timeout
buf = b""
while time.time() < deadline:
waiting = ser.in_waiting
if waiting:
buf += ser.read(waiting)
if not expect_len:
# 未指定长度时,用静默时间判断帧尾
time.sleep(char_time(ser.baudrate) * 3.5)
if not ser.in_waiting:
break
elif len(buf) >= expect_len:
break
else:
time.sleep(0.01)
if not buf:
return b"", "超时无应答"
if len(buf) < 4:
return buf, "应答过短,疑似干扰或波特率不匹配"
if crc16(buf[:-2]) != buf[-2:]:
return buf, "CRC 校验失败(线路干扰或字节序错误)"
return buf, "OK"
def cmd_probe(args):
"""扫描常见串口参数组合,找出有应答的组合。"""
combos = []
for baud in args.bauds:
for parity in ("N", "E"):
for stopbits in (1, 2):
combos.append((baud, parity, stopbits))
print("扫描 %d 种串口组合,从站地址 %d,寄存器 %d" % (len(combos), args.slave, args.addr))
for baud, parity, stopbits in combos:
try:
ser = serial.Serial(args.port, baud, bytesize=8,
parity=parity, stopbits=stopbits, timeout=0.3)
except Exception as exc:
print(" %6d %s%s 打开失败:%s" % (baud, parity, stopbits, exc))
continue
try:
ok = 0
for _ in range(args.retry):
frame = build_read(args.slave, args.addr, 1)
buf, status = transact(ser, frame, timeout=0.4)
if status == "OK":
ok += 1
flag = "可用" if ok else "无应答"
print(" %6d %s%s %d/%d 成功 -> %s" % (baud, parity, stopbits, ok, args.retry, flag))
if ok:
ser.close()
return
finally:
if ser.is_open:
ser.close()
print("未找到可用组合,请检查接线(A/B 是否接反)、供电(15VDC)与从站地址")
def cmd_scan(args):
"""扫描从站地址与寄存器区间。"""
ser = serial.Serial(args.port, args.baud, bytesize=8,
parity=args.parity, stopbits=args.stopbits, timeout=0.3)
found = []
for slave in range(args.start, args.end + 1):
frame = build_read(slave, args.addr, args.count)
buf, status = transact(ser, frame, timeout=0.3)
if status == "OK":
found.append(slave)
print(" 从站 %3d 有应答:%s" % (slave, buf.hex(" ")))
elif buf:
print(" 从站 %3d 异常:%s(%s)" % (slave, status, buf.hex(" ")))
ser.close()
print("有应答的从站:" + (", ".join(str(s) for s in found) if found else "无"))
def cmd_read(args):
"""读寄存器并按量程换算。"""
ser = serial.Serial(args.port, args.baud, bytesize=8,
parity=args.parity, stopbits=args.stopbits, timeout=0.5)
frame = build_read(args.slave, args.addr, args.count)
print("TX: " + frame.hex(" "))
buf, status = transact(ser, frame, timeout=0.8)
print("RX: " + (buf.hex(" ") if buf else "(空)") + " 状态:" + status)
ser.close()
if status != "OK":
return
func = buf[1]
if func & 0x80:
print("从站返回异常码 0x%02X:%s" % (buf[2], EXC_TEXT.get(buf[2], "未知")))
return
nbytes = buf[2]
payload = buf[3:3 + nbytes]
print("原始数据(大端 16 位):", end="")
vals = []
for i in range(0, len(payload), 2):
v = struct.unpack(">H", payload[i:i + 2])[0]
vals.append(v)
print(" %d" % v, end="")
print()
if args.scale:
# 工程量换算:value = raw / full_raw * range
print("换算(%d -> %.1f,系数 %.6f):" % (args.full, args.range, args.range / args.full))
print(" " + ", ".join("%.3f" % (v * args.range / args.full) for v in vals))
def cmd_crc(args):
"""计算或校验 CRC16。"""
data = bytes.fromhex(args.hex.replace(" ", ""))
c = crc16(data)
if args.check:
ok = (data[-2:] == crc16(data[:-2]))
print("帧:%s 校验结果:%s(帧内 CRC=%s,计算 CRC=%s)"
% (args.hex, "通过" if ok else "失败", data[-2:].hex(), c.hex()))
else:
print("数据:%s CRC16(低字节在前):%s" % (args.hex, c.hex(" ")))
def main():
p = argparse.ArgumentParser(description="Modbus RTU 现场排查工具")
p.add_argument("--port", default="COM3", help="串口号,Linux 下如 /dev/ttyUSB0")
p.add_argument("--baud", type=int, default=9600)
p.add_argument("--parity", default="N", choices=["N", "E", "O"])
p.add_argument("--stopbits", type=int, default=1, choices=[1, 2])
p.add_argument("--slave", type=int, default=1, help="从站地址")
p.add_argument("--addr", type=int, default=0, help="起始寄存器地址")
p.add_argument("--count", type=int, default=2, help="读取数量(16 位寄存器个数)")
p.add_argument("--retry", type=int, default=3)
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("probe", help="扫描串口参数")
sp.add_argument("--bauds", type=int, nargs="+",
default=[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200])
sp.set_defaults(func=cmd_probe)
sp = sub.add_parser("scan", help="扫描从站地址")
sp.add_argument("--start", type=int, default=1)
sp.add_argument("--end", type=int, default=32)
sp.set_defaults(func=cmd_scan)
sp = sub.add_parser("read", help="读寄存器并换算")
sp.add_argument("--scale", action="store_true", help="按量程换算输出")
sp.add_argument("--full", type=int, default=8000, help="寄存器满量程原始值")
sp.add_argument("--range", type=float, default=800.0, help="工程量满量程,如电流 800A")
sp.set_defaults(func=cmd_read)
sp = sub.add_parser("crc", help="CRC16 计算/校验")
sp.add_argument("hex", help="十六进制帧,如 010300000002")
sp.add_argument("--check", action="store_true", help="校验模式(含 CRC 的完整帧)")
sp.set_defaults(func=cmd_crc)
args = p.parse_args()
args.func(args)
if __name__ == "__main__":
main()
四、四个子命令的用法与典型输出
1)先找串口参数
python modbus_probe.py --port COM3 probe
工具会依次尝试 8 种波特率 × 2 种校验 × 2 种停止位,打印每种组合的成功率,命中即停。现场常见的"完全没反应"问题,八成在这一步就解决了——多半是校验位或停止位不对,而不是波特率。
2)再确认从站地址
python modbus_probe.py --port COM3 --baud 9600 scan --start 1 --end 32
逐个地址发读请求,列出有应答的从站。若某地址返回 83 02 这类异常帧,说明线路通、地址对,但寄存器地址不存在——这一步能区分"地址错"和"寄存器号错"。
3)读数据并换算
python modbus_probe.py --port COM3 --slave 1 --addr 0 --count 2 read --scale --full 8000 --range 800
输出原始寄存器值,再按 value = raw / full × range 换算成工程量。量程换算的通用形式:
工程量 = 原始值 / 寄存器满量程原始值 × 工程量满量程
4)单独验一帧 CRC
python modbus_probe.py crc 010300000002
python modbus_probe.py crc 010300000002C4CB --check
前者算 CRC,后者校验完整帧。手改报文、排查历史遗留的字节序问题时很好用。
五、踩过的坑
- A/B 接反:现象是"完全无应答",不是 CRC 错。RS485 是差分信号,A/B 反了链路就是死的,先量一下 A-B 之间的电压。
- 终端电阻:长距离(超过百米)或高速率时两端要并 120Ω 终端电阻,缺了会表现为时通时不通。
- 超时设太短:从站处理需要时间,9600bps 下单帧传输就要十几毫秒,超时建议 300ms 起步,轮询时按实际丢包率调整。
- 地址偏移:有的厂家文档写的是 40001 这类"协议地址",实际帧里要减去 40001 才是寄存器偏移地址;有的直接给 16 进制偏移。两者混用会差 1。
- 浮点数跨寄存器:32 位浮点占两个寄存器,字节序有
ABCD/CDAB/BADC/DCBA四种组合,读出来是怪数就换一种试试,别先怀疑传感器。 - 供电:CE-AJ12N 辅助电源 15VDC,长线供电会有压降,末端电压不足时表现为"时好时坏",量末端电压而不是只量电源端。
六、关键参数对照
| 项目 | 取值 | 说明 |
|---|---|---|
| 输出接口 | RS485,Modbus 协议 | 二线制差分 |
| 辅助电源 | 15VDC | 长线需核算压降 |
| 电流量程 | 0-800A | 电流精度 ≤0.5% |
| 电压量程 | 30-500V | 电压精度 ≤1.5% |
| 频率范围 | 45-65Hz | 超出范围不适用标称精度 |
| 隔离耐压 | ≤2500VDC | 强弱电隔离 |
| 工作温度 | -40~80℃ | 温漂需按现场极值复核 |
七、技术 FAQ
Q1:probe 扫完全部组合都没有应答怎么办?
按顺序排查三步。先量 A-B 差分电压,确认总线上有信号且极性正确;接着确认从站供电(15VDC 末端电压);再用 USB 转 RS485 适配器短接自发自收,确认适配器本身正常。三步都过还是没应答,再怀疑从站地址或器件本身。
Q2:能读到数据但换算出来明显偏大?
先查"寄存器满量程原始值"这个系数。多数厂家用 8000 对应 800A、也有用 10000 或 65535 的,系数不对会整体缩放。确认方法:加一个已知负载(比如钳形表测出的 100A),反推系数,比翻文档快。
Q3:轮询多个从站时丢包明显,怎么定位?
用 read 子命令对每个从站单独跑 20 次,统计成功率。若只有个别从站丢包,多半是该节点接线或供电问题;若全部丢包且提高波特率后更严重,多半是终端电阻缺失或布线拓扑不合规(总线型而非星型)。定位到节点后再处理,不要一上来就降波特率。
小结
排查 Modbus RTU 链路,核心是把"读不到"拆成可单独验证的几步:串口参数、从站地址、寄存器地址、字节序、CRC、超时。这个工具把每一步都做成一条命令,现场不用翻文档也能逐项排除。跑通后再用成熟库写正式采集程序,链路稳定性才有保障。本文以圣斯尔 SSET 的 CE-AJ12N 非接触电流电压传感器为被测对象,涉及的产品规格与选型参数以圣斯尔官网 sset.cn 公布为准。
浙公网安备 33010602011771号