用 QSerialPort 手搓 Modbus RTU 通信:从串口配置到帧解析的实战指南
你有没有遇到过这种情况?手头有一堆支持 RS-485 的温湿度传感器、电表或 PLC,想把它们的数据读出来做监控系统,结果发现——协议文档翻烂了,代码还是收不到完整响应。要么是“粘包”,要么是 CRC 校验失败,更离谱的是发完命令后总线直接“死机”。
别急,这其实是 Modbus RTU 开发中最常见的坑。而今天我们要讲的,就是如何用 Qt 官方提供的QSerialPort类,从零开始构建一个稳定可靠的 Modbus RTU 主站程序。
我们不依赖第三方库,也不调用 Win32 API 或 Linux termios,而是完全基于 Qt 原生机制,实现跨平台、可调试、易扩展的工业通信模块。整个过程将涵盖:
- 串口参数到底该怎么设?
- 如何正确拼接和解析 Modbus 帧?
- 怎么处理最头疼的“数据粘连”问题?
- 为什么有时候发不出去?RS-485 收发切换怎么搞?
准备好了吗?让我们一步步来拆解这个在工业自动化领域天天都在用的技术组合:QSerialPort + Modbus RTU。
为什么选 QSerialPort 做工业通信?
先说结论:如果你正在开发上位机软件、HMI 界面或者边缘网关,并且需要用串口对接现场设备,那QSerialPort是目前性价比最高、维护成本最低的选择。
它属于 Qt Serial Port 模块(需在.pro文件中添加QT += serialport),封装了 Windows、Linux 和 macOS 下的底层串口操作差异。这意味着你写一套代码,可以在工控机、嵌入式 Linux 设备甚至树莓派上直接运行,无需重写任何驱动层逻辑。
更重要的是,它天然集成 Qt 的信号槽机制。比如当有新数据到达时,会自动触发readyRead()信号;如果串口断开,也能通过errorOccurred()实时感知。这对 GUI 应用来说简直是“天作之合”——界面更新、错误提示、日志记录都可以无缝联动。
举个例子:
connect(serial, &QSerialPort::readyRead, this, &ModbusMaster::readData);这一行就解决了异步非阻塞读取的问题,再也不用担心主线程卡住导致界面无响应。
第一步:把串口“打开”并配对
Modbus RTU 能不能通,第一步就是看物理层能不能握手成功。而关键就在于——串口参数必须和从站设备严格一致。
常见配置如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 波特率 | 9600 / 19200 | 多数仪表默认为 9600 |
| 数据位 | 8 | 几乎所有设备都用 8 |
| 停止位 | 1 | 极少用 1.5 或 2 |
| 校验位 | Even / None | Even 最常见 |
| 流控 | 无 | RS-485 一般不用硬件流控 |
这些参数不是随便选的,哪怕差一位,收到的数据都会变成乱码。
下面是一个典型的初始化流程:
QSerialPort *serial = new QSerialPort(this); // 尝试连接第一个可用的串口(实际项目应让用户选择) foreach (const QSerialPortInfo &info, QSerialPortInfo::availablePorts()) { if (info.portName().contains("USB")) { // 示例:优先选 USB 转串口 serial->setPort(info); break; } } // 设置标准 Modbus RTU 参数 serial->setBaudRate(QSerialPort::Baud9600); serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::EvenParity); serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl);然后尝试打开端口:
if (serial->open(QIODevice::ReadWrite)) { qDebug() << "串口已打开:" << serial->portName(); } else { qWarning() << "无法打开串口:" << serial->errorString(); }⚠️ 注意:在 Linux 上,普通用户可能没有访问
/dev/ttyUSB0的权限。解决方法有两个:
- 加入
dialout用户组:sudo usermod -aG dialout $USER- 或者配置 udev 规则自动赋权
一旦打开成功,就可以开始监听数据了。
第二步:理解 Modbus RTU 的帧结构
很多人第一次写 Modbus 通信,最容易犯的错误就是——以为它是“按包发送”的。但其实,Modbus RTU 没有起始符和结束符,它是靠“时间间隔”来判断一帧是否结束的。
这就是所谓的3.5 字符时间规则。
什么意思?
假设波特率为 9600,每个字符传输时间为 10 位(起始+8数据+停止)÷ 9600 ≈ 1.04ms,那么 3.5 个字符时间约为 3.64ms。也就是说,只要两个字节之间的空闲时间超过 3.64ms,就认为当前帧已经结束。
所以你在接收数据时,不能指望每次readyRead()都能拿到完整的一帧。很可能一次只收到前几个字节,下一次才补上后面的。
这就引出了我们接下来要重点解决的问题:如何重组完整的 Modbus 帧?
第三步:构建接收缓冲区,应对“粘包”与“半包”
由于操作系统调度和串口中断机制的影响,readyRead()可能在任意时刻被触发,甚至单个字节到达也会触发。因此我们必须自己管理一个接收缓冲区。
做法很简单:把每次收到的数据追加到全局缓存中,然后从中查找是否有完整的帧。
QByteArray rxBuffer; void ModbusMaster::onDataReceived() { rxBuffer += serial->readAll(); while (hasCompleteFrame(rxBuffer)) { QByteArray frame = extractFrame(rxBuffer); if (validateCRC16(frame)) { processValidFrame(frame); } else { qDebug() << "CRC校验失败,丢弃帧"; } rxBuffer.remove(0, frame.size()); } // 防止缓冲区无限增长(异常情况下清空) if (rxBuffer.size() > 256) { qWarning() << "接收缓冲区溢出,强制清空"; rxBuffer.clear(); } }其中hasCompleteFrame()判断是否有足够长度的数据可以构成一帧最小结构(至少 6 字节:地址+功能码+数据+CRC);extractFrame()提取完整帧;processValidFrame()解析内容并更新业务逻辑。
这样就能有效避免因分次接收导致的数据错乱。
第四步:动手实现 CRC16 校验
Modbus RTU 使用的是CRC-16-IBM算法,多项式为x^16 + x^15 + x^2 + 1,其逆序形式为0xA001。
下面是标准实现方式:
quint16 ModbusMaster::calculateCRC16(const QByteArray &data) { quint16 crc = 0xFFFF; for (char byte : data) { crc ^= static_cast<uint8_t>(byte); for (int i = 0; i < 8; ++i) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }验证函数也很简单:
bool ModbusMaster::validateCRC16(const QByteArray &frame) { if (frame.size() < 3) return false; // 低字节在前,高字节在后 quint16 received = (static_cast<uint8_t>(frame[frame.size()-1]) << 8) | static_cast<uint8_t>(frame[frame.size()-2]); QByteArray dataPart = frame.mid(0, frame.size() - 2); quint16 calculated = calculateCRC16(dataPart); return received == calculated; }✅ 提示:很多初学者在这里栽跟头——忘了 Modbus 的 CRC 是“低字节在前”。如果你按正常顺序计算却反着比对,永远得不到正确结果。
第五步:构造请求帧并发送
作为主站,我们需要主动发起请求。以“读保持寄存器”为例(功能码 0x03):
QByteArray ModbusMaster::createReadHoldingRegisters(int slaveAddr, int regStart, int regCount) { QByteArray frame; frame.resize(8); frame[0] = static_cast<char>(slaveAddr); // 从站地址 frame[1] = 0x03; // 功能码 frame[2] = static_cast<char>(regStart >> 8); // 起始地址高 frame[3] = static_cast<char>(regStart & 0xFF); // 起始地址低 frame[4] = static_cast<char>(regCount >> 8); // 寄存器数量高 frame[5] = static_cast<char>(regCount & 0xFF); // 寄存器数量低 quint16 crc = calculateCRC16(frame.left(6)); frame[6] = static_cast<char>(crc & 0xFF); // CRC 低字节 frame[7] = static_cast<char>(crc >> 8); // CRC 高字节 return frame; }然后通过串口发送出去:
serial->write(createReadHoldingRegisters(1, 0x0001, 2));但注意!RS-485 是半双工总线,有些廉价转换器需要手动控制收发方向。这时候就得借助 RTS 引脚:
serial->setRequestToSend(true); // 切换为发送模式 QThread::msleep(1); // 给硬件一点反应时间 serial->write(frame); serial->flush(); // 确保所有数据发出 QThread::msleep(1); serial->setRequestToSend(false); // 切回接收模式否则可能出现“发了但对方没收到”的诡异现象。
第六步:加入超时与重试机制,提升鲁棒性
工业现场环境复杂,偶尔丢帧很正常。我们不能因为一次失败就判定设备离线,应该加上合理的容错策略。
建议做法:
- 每个请求设置1 秒左右的超时
- 失败后最多重试2~3 次
- 记录通信日志用于排查问题
可以用QTimer来实现超时检测:
QTimer *timeoutTimer = new QTimer(this); timeoutTimer->setSingleShot(true); connect(timeoutTimer, &QTimer::timeout, [=]() { qDebug() << "请求超时,尝试重发"; resendCurrentRequest(); });每次发送请求前启动定时器,收到响应后立即停止。如果超时,则触发重发逻辑。
实际应用场景举例
想象这样一个系统:你在做一个智能配电柜监控平台,里面有 5 块支持 Modbus RTU 的智能电表,挂在同一根 RS-485 总线上。
你的上位机程序使用QSerialPort连接 USB-RS485 转换器,每隔 500ms 轮询一次各电表的电压、电流、功率等数据。
界面采用 Qt Widgets 实现图表显示,后台通过多线程或事件循环处理通信任务,确保即使某个设备掉线也不会卡住整个系统。
整个架构清晰简洁:
[Qt GUI] ←→ [Modbus Master] ←→ [QSerialPort] ←→ [USB转485] ←→ [RS485总线] ←→ [电表1~5]不需要额外依赖库,也不用担心平台迁移问题。将来移植到嵌入式 Linux 上,只需重新编译即可运行。
常见问题与避坑指南
❌ 问题1:一直收不到数据
排查点:
- 串口参数是否匹配?特别是校验位。
- 是否接反了 A/B 线?RS-485 是差分信号,接反会导致通信失败。
- 是否共地?长距离传输时未共地会引起电平漂移。
❌ 问题2:偶尔出现 CRC 错误
原因:干扰大或波特率过高。尝试降低波特率至 9600,或增加屏蔽线。
❌ 问题3:多个设备冲突
注意:每个从站必须有唯一地址!不要让两个设备地址相同。
❌ 问题4:发完命令后总线“锁死”
可能是:RS-485 收发切换不当,导致主机持续占用总线。务必在发送完成后及时关闭 RTS。
写在最后:掌握这套技能意味着什么?
当你能熟练使用QSerialPort实现 Modbus RTU 通信时,你就已经具备了进入工业控制领域的敲门砖。
无论是做 HMI 上位机、边缘采集网关,还是参与国产化替代项目,这种“底层通、上层活”的能力都非常宝贵。而且你会发现,一旦打通了 Modbus RTU,再去学 Modbus TCP、CANopen 甚至是自定义私有协议,思路都是一脉相承的。
更重要的是,这套方案完全基于 Qt 官方组件,没有第三方依赖、没有授权风险、易于维护和审计,非常适合用于构建自主可控的工业软件系统。
如果你正打算做一个数据采集项目,不妨试试从QSerialPort开始。也许下一次,你就能自信地说:“这个需求,我能搞定。”
你用过 QSerialPort 对接 Modbus 设备吗?遇到了哪些坑?欢迎在评论区分享你的经验!