输入关键词开始搜索

Modbus RTU 实战:从协议解码到主站轮询

Modbus 是工业自动化领域的”普通话”——1979 年诞生,至今几乎所有 PLC、传感器、变频器都支持。本文记录在 Qt/C++ 中实现 Modbus RTU 从站解码和主站轮询的完整过程。


为什么已经有一套自定义协议了,还要加 Modbus?

因为真实世界里不是只有你自己的设备。

  • 自定义协议 → 自己设计的下位机
  • Modbus RTU → 别人的设备:西门子 PLC、台达变频器、欧姆龙温控器

一个工业上位机必须具备连接第三方设备的能力,而 Modbus 就是最低成本的通路——不需要驱动、不需要授权、不需要联网。


Modbus RTU 帧结构

┌─────────┬──────────┬───────────┬─────────┐
│ Address │ Function │ Data      │ CRC16   │
│ 1B      │ 1B       │ 0~252B    │ 2B LE   │
└─────────┴──────────┴───────────┴─────────┘

与自定义协议的关键差异

自定义协议Modbus RTU
帧同步帧头 0xA55A无帧头
帧长度显式 Length 字段隐式(靠 CRC 推断)
CRC 多项式0x1021 (CCITT)0x8005 (Modbus)
传输模式TCP 流RS-485 总线
主从关系下位机主动推送主站轮询

最大的差异是”无帧头”。这在 RS-485 上不是问题——总线空闲的 3.5 字符间隔就是帧边界。但在 TCP 隧道中,这个间隔会因网络延迟而失效。


滑动窗口 CRC 扫描

不依赖帧头,只靠 CRC 自己找到帧边界:

void ModbusDecoder::feed(const QByteArray &data) {
    m_buffer.append(data);

    for (int offset = 0; offset < bufLen - 3; ++offset) {
        for (int frameLen = 4; frameLen <= maxFrame; ++frameLen) {
            // 对 [offset, offset+frameLen) 计算 CRC
            uint16_t crcCalc = crc16_modbus(raw + offset, frameLen - 2);
            uint16_t crcRecv = (raw[offset+frameLen-1] << 8)
                             |  raw[offset+frameLen-2];

            if (crcCalc == crcRecv) {
                // CRC 匹配!提取帧
                uint8_t addr = raw[offset];
                uint8_t func = raw[offset + 1];
                // 验证地址范围
                if (addr < 1 || addr > 247) continue;
                // 验证功能码范围
                if (func > 0x10 && func < 0x80) continue;
                // 合法帧 → 发射信号
                emit frameDecoded(frame);
                m_buffer.remove(0, offset + frameLen);
                found = true;
                break;
            }
        }
    }
}

为什么遍历所有可能的 frameLen?

因为 Modbus 帧没有 Length 字段。帧长度 = 地址(1) + 功能码(1) + 数据(N) + CRC(2)。N 是多少?不知道——只能从最小 (N=0 → frameLen=4) 到最大 (N=252 → frameLen=256) 逐一尝试,找到 CRC 匹配的那个。

复杂度

最坏情况 O(N²):256 字节缓冲区 × 256 种帧长度 = 65536 次 CRC 计算。但查表 CRC 一次只要 1 次查表 + 1 次 XOR,现代 CPU 上 65536 次 ≈ 0.5ms。100Hz 数据流下,实际缓冲区通常只有 8~32 字节,运算量更低。

为什么加地址和功能码校验?

CRC16 有 1/65536 的误匹配率。200 帧/秒 × 3600 秒/小时 = 72 万帧/小时,理论上有 11 次假阳性。加上地址 (1~247) 和功能码 (≥0x01, ≤0x10 或 ≥0x80, ≤0x8F) 的过滤,假阳性降到几乎为零。

缓冲区保护

if (m_buffer.size() > MAX_BUFFER) {  // 2048 字节
    qWarning() << "buffer exceeded, trimming";
    m_buffer.remove(0, m_buffer.size() - TRIM_TARGET);  // 保留最近 1024 字节
}

如果上位机不停发数据但不给完整帧(恶意或 bug),缓冲区无限增长。2048 字节的上限 = 约 8 帧完整 Modbus 查询,足够覆盖正常的网络拥塞。


CRC16-Modbus vs CRC16-CCITT

ModbusCCITT
多项式0x80050x1021
初始值0xFFFF0xFFFF
字节序小端(低字节在前)小端
测试向量{0x01,0x03,0x00,0x00,0x00,0x01}0x840A"123456789"0x29B1

为什么 Modbus 用 0x8005 而不是 0x1021?

历史原因。Modbus 1979 年诞生时,0x8005 是当时主流芯片(Intel 8051)硬件支持的 CRC 多项式。而 0x1021 是 IBM SDLC 链路协议推广开的,用于同步通信。两者没有优劣之分,只是生态不同。

唯一要注意的是:Modbus CRC 的计算不包括 CRC 自身,但包括地址和功能码。这是新手的最常见错误——把数据部分的 CRC 也算进去了。


双协议运行时切换

PulseQt 的设计要求同一套 IChannel(TCP/串口)可以在自定义协议和 Modbus 之间无缝切换:

// ParseWorker
void setProtocol(const QString &proto) {
    if (proto == "modbus") {
        m_usingModbus = true;
        m_modbusMaster->start();           // 开始轮询
    } else {
        m_usingModbus = false;
        m_modbusMaster->stop();            // 停止轮询
        m_heartbeatTimer->start();         // 恢复心跳保活
    }
}

数据流分叉:

Channel::onData

    ├── raw 模式 → ProtocolDecoder::feed()  (7 状态机)

    └── modbus 模式 → ModbusDecoder::feed() (滑动窗口 CRC)

两者的 frameDecoded 信号统一汇入 ParseWorker::onFrameDecoded,之后的数据管道、缓冲、数据库、UI 渲染完全复用。

为什么心跳只在 raw 模式生效?

Modbus 是主从架构,主站每 10ms 发一次查询 = 自带心跳。再加自定义心跳帧反而干扰总线。raw 模式是下位机主动推送,需要心跳确认链路存活。


主站轮询:为什么不用独立线程?

void ModbusMaster::onPoll() {
    emit writeData(buildReadHoldingRegisters(m_slave,
                                              m_startAddr,
                                              m_regCount));
}

ModbusMaster 只是一个 QTimer + 帧构造器。定时器触发 → 构建查询帧 → 通过 writeData 信号交给 Channel 发送。

之所以不用独立线程:轮询间隔 10ms,QTimer 的精度 (±1ms) 足够。用线程反而要处理线程间的 QSerialPort 竞争——串口只能在创建它的线程中使用。


总结

做法理由
滑动窗口 CRC 扫描替代帧头Modbus 没有帧头,TCP 隧道中 3.5 字符间隔不可靠
CRC16 + 地址/功能码双重校验杜绝 1/65536 的假阳性 → 近乎零误帧
双协议共用一个 Channel复用 TCP/串口连接,降低资源占用
主站用 QTimer 不用线程避免串口跨线程竞争,够用就好

Modbus 协议本身并不复杂——复杂的是在失去”帧头”和”字符间隔”的 TCP 隧道中保持解码的可靠性。滑动窗口 CRC 扫描就是为这个而生的。