3、Modbus RTU实战:物理层RS-485、报文帧结构、CRC校验计算、Python实现RTU主站

好,咱们进入第三讲。这一章是硬核实战,也是很多初学者容易卡住的地方。Modbus RTU,说白了就是工业现场最常用的“方言”。你想想看,一个车间里几十台设备,怎么让它们跟PLC或者上位机说话?靠的就是RS-485这条物理线,和Modbus RTU这套规矩。

我个人习惯,讲协议一定要从物理层开始。因为很多坑,其实都埋在物理层里。

3.1 物理层RS-485:为什么是它?

RS-485,不是协议,是电气标准。它定义了电压、线缆、拓扑这些东西。为什么工业现场偏爱它?

  • 差分信号,抗干扰强:两根线(A和B)传信号,靠电压差判断0和1。干扰来了,两根线一起变,差值不变。我在一个变频器堆成山的车间里测过,RS-232早就乱码了,RS-485纹丝不动。
  • 支持多节点:一条总线上可以挂32个节点(标准值,用高阻抗芯片能挂更多)。
  • 传输距离远:1200米没问题,加中继还能更远。

关键参数速查表

参数RS-485RS-232
传输方式差分(平衡)单端(非平衡)
最大节点数32(标准)1对1
最大距离1200m15m
抗干扰
典型电压±5V(A-B)±12V

我曾经踩过的坑: 总线两端一定要加120欧姆终端电阻。不加的话,信号反射会导致数据错位。有一次调试一个水处理项目,数据总是间歇性错误,查了两天,最后发现是终端电阻松了。嗯,从那以后我焊电阻都用热缩管固定。

3.2 报文帧结构:RTU的“黑话”格式

Modbus RTU的报文,没有起始位和停止位(那是串口帧的事)。它靠“静默时间”来分割报文。说白了,就是总线空闲3.5个字符时间,表示上一帧结束,下一帧开始。

一个完整的RTU帧长这样:

| 地址码 (1字节) | 功能码 (1字节) | 数据区 (N字节) | CRC校验 (2字节) |

咱们拆开看:

  • 地址码:0x01到0xF7。0x00是广播地址,所有从机都要响应,但从机不回复。
  • 功能码:比如0x03读保持寄存器,0x06写单个寄存器。我常用的就这几个。
  • 数据区:长度可变。比如读寄存器,这里放起始地址和数量。
  • CRC校验:两个字节,低字节在前,高字节在后。这是RTU和ASCII最大的区别之一。

小技巧: 调试时用串口助手抓原始数据,看十六进制。如果看到报文之间没有3.5字符的静默时间,那多半是主站程序写得太急,或者从站响应太慢。

3.3 CRC校验计算:别怕,有现成算法

CRC-16(Modbus)是RTU的“防伪标签”。它用多项式0x8005计算,生成一个16位的校验值。你不需要自己推导数学公式,直接用查表法或者移位法就行。

我贴一段Python代码,这是我自己项目里一直在用的:

def crc16_modbus(data: bytes) -> int:
    """
    计算Modbus RTU CRC-16校验值
    :param data: 待校验的字节数据(地址+功能码+数据区)
    :return: 16位CRC值
    """
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    # 交换高低字节,符合Modbus RTU规范
    return ((crc & 0xFF) << 8) | ((crc >> 8) & 0xFF)

为什么最后要交换高低字节?因为Modbus RTU规定,CRC低字节在前,高字节在后。你算出来的原始值是0x1234,发送时先发0x34,再发0x12。这个顺序搞反了,从机就会认为校验失败。

验证一下: 发送数据 01 03 00 00 00 01,CRC应该是多少?用上面的代码算一下,结果是 84 0A(低字节0x84,高字节0x0A)。你可以用网上在线CRC计算器验证。

3.4 Python实现RTU主站:从零开始写一个

咱们不用现成的库(比如pymodbus),自己手写一个,这样你才能真正理解底层逻辑。

核心步骤就三步:

  1. 打开串口,配置参数(波特率、数据位、停止位、校验位)。
  2. 组装报文(地址+功能码+数据+CRC)。
  3. 发送并等待响应,解析响应数据。

下面是一个完整的读保持寄存器示例:

import serial
import time

def read_holding_registers(port, slave_id, start_addr, quantity):
    """
    读取从机的保持寄存器
    :param port: 串口对象 (serial.Serial)
    :param slave_id: 从机地址 (1-247)
    :param start_addr: 起始寄存器地址 (0-based)
    :param quantity: 读取数量 (1-125)
    :return: 寄存器值列表,或None
    """
    # 1. 组装请求报文
    request = bytes([slave_id, 0x03, 
                     (start_addr >> 8) & 0xFF, start_addr & 0xFF,
                     (quantity >> 8) & 0xFF, quantity & 0xFF])
    crc = crc16_modbus(request)
    request += bytes([crc & 0xFF, (crc >> 8) & 0xFF])

    # 2. 发送请求
    port.write(request)
    time.sleep(0.05)  # 给从机一点处理时间

    # 3. 读取响应(预期长度 = 地址1 + 功能码1 + 字节数1 + 数据N + CRC2)
    expected_len = 3 + quantity * 2 + 2
    response = port.read(expected_len)

    if len(response) < expected_len:
        print(f"响应不完整: 收到{len(response)}字节, 期望{expected_len}字节")
        return None

    # 4. 验证CRC
    recv_crc = (response[-1] << 8) | response[-2]
    calc_crc = crc16_modbus(response[:-2])
    if recv_crc != calc_crc:
        print("CRC校验失败")
        return None

    # 5. 解析数据
    byte_count = response[2]
    values = []
    for i in range(byte_count // 2):
        high = response[3 + i*2]
        low = response[4 + i*2]
        values.append((high << 8) | low)

    return values

# 使用示例
if __name__ == "__main__":
    ser = serial.Serial(
        port='COM3',      # Windows下是COM口,Linux下是/dev/ttyUSB0
        baudrate=9600,
        bytesize=8,
        parity='N',
        stopbits=1,
        timeout=1
    )

    result = read_holding_registers(ser, slave_id=1, start_addr=0, quantity=2)
    if result:
        print(f"寄存器值: {result}")
    ser.close()

注意: 串口参数必须和从机一致。波特率、校验位、数据位、停止位,任何一个不对,收到的都是乱码。我曾经在调试一个温控器时,从机手册写的是“8数据位,无校验,1停止位”,结果实际设备是“8数据位,偶校验,1停止位”。折腾了一下午才发现。

3.5 避坑指南与实战心得

  • 超时设置:从机响应时间一般在10-100ms。超时设太短,容易误判;设太长,主站效率低。我一般设200ms起步。
  • 重试机制:工业现场干扰多,偶尔丢一帧很正常。主站应该自动重试2-3次,而不是直接报错。
  • 不要连续狂发:发完一帧,等从机回复,再发下一帧。有些从机处理能力弱,连续发会死机。
  • 地址冲突:总线上不能有两个相同地址的从机。我见过有人把两个温控器都设成地址1,结果数据全乱套了。

我的调试三板斧:

  1. 先用串口助手手动发报文,看从机回什么。这一步能排除硬件问题。
  2. 再用逻辑分析仪抓RS-485的波形,看电平是否正常,有没有毛刺。
  3. 最后才是写代码。代码里一定要把收发原始数据打印出来,方便定位。

好了,这一章的内容就到这里。RS-485的物理层、RTU的帧结构、CRC的计算、还有Python主站的实现,你都应该能上手了。下一章咱们会讲Modbus TCP,那是以太网环境下的玩法,思路类似,但细节完全不同。

记住,纸上得来终觉浅。拿一个USB转485的模块,接上一个支持Modbus的温控器或者PLC,亲手跑一遍上面的代码。遇到问题别慌,先看波形,再看报文,最后查代码。这是最笨的方法,也是最有效的方法。