写一个 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]

两点容易踩:

  1. CRC 是低字节在前。上面写成 CRC Lo][CRC Hi,很多"CRC 不对"的问题其实是字节序写反了。
  2. 帧间间隔是 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 公布为准。

posted @ 2026-09-29 10:10  化凡蜕变  阅读(10)  评论(0)    收藏  举报