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
| Modbus | CCITT | |
|---|---|---|
| 多项式 | 0x8005 | 0x1021 |
| 初始值 | 0xFFFF | 0xFFFF |
| 字节序 | 小端(低字节在前) | 小端 |
| 测试向量 | {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 扫描就是为这个而生的。