用 QSerialPort 打通工业通信:手把手教你实现 Modbus RTU 主站
你有没有遇到过这样的场景?项目里要读一台温控仪表的数据,说明书上写着“支持 Modbus RTU”,电脑只有 USB 接口,手头却没人会写串口通信。于是翻遍 CSDN、Stack Overflow,拼凑出一堆片段代码——结果发出去的帧被设备无视,收到的回应全是乱码。
别急,这问题太常见了。今天我们就来彻底解决它:用 Qt 的QSerialPort类,从零构建一个真正可用的 Modbus RTU 主站模块。不讲虚的,只说实战中踩过的坑和绕不开的关键点。
为什么是 QSerialPort + Modbus?
先说清楚我们面对的是什么任务。
在工业现场,PLC、传感器、电表这些设备很多还是通过 RS-485 走 Modbus 协议通信。它们不像 WiFi 模块那样即插即用,而是要求你精确控制每一个字节的发送与接收。
而你在开发上位机软件时,可能要在 Windows 上调试,在 Linux 工控机部署,甚至未来迁移到嵌入式 ARM 平台。如果直接调用 Win32 API 或者 POSIX termios,光是打开串口这一件事就得写三套逻辑。
这时候,Qt 的QSerialPort就显得格外珍贵——它把底层差异全封装好了。只要你会用open()、write()和信号槽,就能在任何平台上跑通串口通信。
再加上 Qt 本身强大的 GUI 能力,做个带界面的数据采集工具也就几天的事。
先搞明白 Modbus RTU 到底怎么“说话”
很多人一开始失败,不是因为代码写错了,而是根本没理解 Modbus 是怎么一帧一帧传数据的。
它不是一个流,而是一条条独立的消息
想象你在对讲机里喊话:“A 设备,请报一下当前温度。”
A 回答:“我是 A,现在 26.5℃。”
然后你再问 B……
Modbus 就是这种主从问答模式。主机(你写的程序)先发请求帧,某个从机收到后返回响应帧。不能两个主机同时说话,也不能连续狂发数据包。
每一帧长得像这样:
| 地址 | 功能码 | 数据 | CRC 校验 |
|---|---|---|---|
| 1字节 | 1字节 | N字节 | 2字节 |
比如你要读地址为 0x01 的设备、起始寄存器 0x006B、共 2 个寄存器,那请求帧就是:
[01][03][00][6B][00][02][CRC低][CRC高]注意最后两个字节是 CRC16/MODBUS 校验值,而且低位在前、高位在后!这是新手最容易栽跟头的地方。
帧之间要有“呼吸间隔”
标准规定:两帧之间必须有至少3.5 个字符时间的空闲。否则接收方会认为这是同一帧的延续。
举个例子,波特率 9600bps,每个字符 11 bit(8N1),那么一个字符时间约 1.15ms。3.5 个字符 ≈ 4ms。也就是说,你每发完一帧,至少等 4ms 才能发下一帧。
但在实际编程中,我们通常不会主动加 delay。因为我们用的是异步接收机制,等收到完整响应后再发下一条更稳妥。
真实可运行的代码长什么样?
下面这个类ModbusRTUMaster,是我从多个工业项目中提炼出来的核心通信模块。你可以直接复制进你的工程使用。
#include <QSerialPort> #include <QSerialPortInfo> #include <QByteArray> #include <QDebug> #include <QTimer> class ModbusRTUMaster : public QObject { Q_OBJECT public: explicit ModbusRTUMaster(QObject *parent = nullptr) : QObject(parent), serial(new QSerialPort(this)) { connect(serial, &QSerialPort::readyRead, this, &ModbusRTUMaster::onDataReceived); connect(serial, &QSerialPort::errorOccurred, this, &ModbusRTUMaster::onError); // 超时定时器,防止卡死 timeoutTimer.setSingleShot(true); connect(&timeoutTimer, &QTimer::timeout, this, &ModbusRTUMaster::onTimeout); } bool connectToDevice(const QString &portName, quint32 baudRate = 9600) { if (serial->isOpen()) serial->close(); serial->setPortName(portName); serial->setBaudRate(baudRate); serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::NoParity); // 多数设备用无校验 serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl); if (serial->open(QIODevice::ReadWrite)) { qDebug() << "✅ 串口已连接:" << portName << "@" << baudRate << "bps"; return true; } else { qWarning() << "❌ 打开串口失败:" << serial->errorString(); return false; } } void readHoldingRegisters(quint8 slaveAddr, quint16 startReg, quint16 regCount) { // 参数合法性检查 if (regCount == 0 || regCount > 125) { // Modbus 协议限制 qWarning() << "⚠️ 寄存器数量非法:" << regCount; return; } // 构造请求帧 QByteArray frame; frame.append(slaveAddr); frame.append(0x03); // 功能码:读保持寄存器 frame.append(static_cast<char>(startReg >> 8)); frame.append(static_cast<char>(startReg & 0xFF)); frame.append(static_cast<char>(regCount >> 8)); frame.append(static_cast<char>(regCount & 0xFF)); quint16 crc = calculateCRC16(frame); frame.append(static_cast<char>(crc & 0xFF)); // 低字节 frame.append(static_cast<char>(crc >> 8)); // 高字节 // 发送并启动超时监控 int sent = serial->write(frame); if (sent == frame.size()) { qDebug() << "📤 发送请求:" << frame.toHex().toUpper(); timeoutTimer.start(1200); // 根据波特率调整,一般 1~2 秒 } else { qWarning() << "⚠️ 发送不完整:" << sent << "/" << frame.size(); } } signals: void dataReady(const QByteArray &data); // 成功读取数据 void errorOccurred(const QString &msg); // 出错通知 private: QSerialPort *serial; QByteArray responseBuffer; QTimer timeoutTimer; quint16 calculateCRC16(const QByteArray &data) { quint16 crc = 0xFFFF; for (char byte : data) { crc ^= static_cast<quint8>(byte); for (int i = 0; i < 8; ++i) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; // 多项式 0x8005,反向 } else { crc >>= 1; } } } return crc; } void onDataReceived() { responseBuffer.append(serial->readAll()); qDebug() << "📥 接收缓存:" << responseBuffer.size() << "字节"; // 至少要有基础头 + CRC if (responseBuffer.size() < 5) return; quint8 funcCode = static_cast<quint8>(responseBuffer[1]); int expectedLen = 0; if (funcCode == 0x83) { // 异常响应 expectedLen = 5; } else if (funcCode == 0x03) { quint8 byteCount = static_cast<quint8>(responseBuffer[2]); expectedLen = 3 + byteCount + 2; // 头+数据+CRC } else { return; // 不是预期响应,暂不处理 } if (responseBuffer.size() >= expectedLen) { QByteArray completeFrame = responseBuffer.left(expectedLen); validateAndProcessResponse(completeFrame); responseBuffer.remove(0, expectedLen); // 清除已处理部分 } } void validateAndProcessResponse(const QByteArray &frame) { timeoutTimer.stop(); // 收到有效响应,取消超时 // 检查 CRC quint16 receivedCRC = (static_cast<quint8>(frame[frame.size()-1]) << 8) | static_cast<quint8>(frame[frame.size()-2]); QByteArray payload = frame.mid(0, frame.size() - 2); quint16 calculatedCRC = calculateCRC16(payload); if (receivedCRC != calculatedCRC) { qWarning() << "❌ CRC 校验失败! 收到=" << hex << receivedCRC << ", 计算=" << calculatedCRC; emit errorOccurred("CRC校验失败"); return; } quint8 func = static_cast<quint8>(frame[1]); if (func == 0x83) { quint8 exceptionCode = static_cast<quint8>(frame[2]); qWarning() << "🚫 设备返回异常码:" << exceptionCode; emit errorOccurred(QString("设备异常 %1").arg(exceptionCode)); return; } // 正常响应:提取数据 QByteArray values = frame.mid(3, static_cast<quint8>(frame[2])); qDebug() << "✅ 数据解析成功:" << values.toHex().toUpper(); emit dataReady(values); } void onTimeout() { if (!responseBuffer.isEmpty()) { qWarning() << "⏰ 响应超时,清除残留数据"; responseBuffer.clear(); } emit errorOccurred("通信超时"); } void onError(QSerialPort::SerialPortError error) { if (error != QSerialPort::NoError) { QString errorMsg = serial->errorString(); qWarning() << "🔧 串口硬件错误:" << errorMsg; emit errorOccurred("串口错误: " + errorMsg); } } };怎么把这个类集成到你的项目里?
假设你有一个简单的 Qt Widgets 界面,上面有个按钮叫btnReadTemp,你想点击后读取设备数据。
第一步:实例化通信对象
// 在 MainWindow 中声明 ModbusRTUMaster *modbus; // 初始化 modbus = new ModbusRTUMaster(this); if (modbus->connectToDevice("/dev/ttyUSB0", 9600)) { connect(modbus, &ModbusRTUMaster::dataReady, this, &MainWindow::onDataReceived); connect(modbus, &ModbusRTUMaster::errorOccurred, this, &MainWindow::showErrorMessage); }第二步:发起读取请求
void MainWindow::on_btnReadTemp_clicked() { modbus->readHoldingRegisters( /*slaveAddr*/ 0x01, /*startReg*/ 0x006B, /*regCount*/ 2 ); }第三步:处理结果
void MainWindow::onDataReceived(const QByteArray &data) { // 假设返回 4 字节浮点数(IEEE 754) float temp; memcpy(&temp, data.constData(), 4); ui->lblTemperature->setText(QString::number(temp, 'f', 1)); }就这么简单。你现在就有了一个跨平台、异步非阻塞、带错误处理的真实 Modbus 主站!
实战中必须注意的几个“坑”
1. 缓冲区粘包问题
串口是按字节流接收的。有可能你第一次readyRead()只收到前 3 个字节,第二次才补全剩下部分。所以一定要用累积缓冲区(responseBuffer),不能收到就立即解析。
我们的代码已经通过responseBuffer.append(...)+ 分段判断解决了这个问题。
2. 波特率不对等于“瞎忙活”
如果你发现一直超时,第一反应应该是:确认波特率、数据位、校验方式是否和设备手册一致!
特别是有些老设备默认是 19200, 8, E, 1,而你用了 9600, 8, N, 1,那是永远对不上号的。
建议做法:先用串口助手(如 XCOM、SSCOM)测试通断,抓一下正确的数据帧格式。
3. CRC 计算顺序别搞反
网上很多 CRC 实现是错的。记住两点:
- 使用多项式0x8005,反向计算;
- 查表法或位运算均可,但输出要符合低字节在前、高字节在后的 Modbus 规范。
你可以拿这段数据测试:{0x01, 0x03, 0x00, 0x6B, 0x00, 0x02},正确 CRC 应该是0x79CB→ 发送时先发0xCB再发0x79。
4. 多设备轮询要排队
如果你想读 5 个不同地址的设备,不要并发发送。必须等前一个响应回来(或超时)之后,再发下一个请求。否则总线会冲突,大家都收不到回复。
可以用队列管理请求:
QQueue<std::function<void()>> requestQueue; void sendNextRequest() { if (!requestQueue.isEmpty()) { auto next = requestQueue.dequeue(); next(); } } // 每次成功/失败后调用 sendNextRequest()进阶技巧:让它更健壮
✅ 自动重试机制
对于偶尔丢包的情况,可以加一次自动重试:
void ModbusRTUMaster::readWithRetry(quint8 addr, quint16 reg, quint16 count, int retry = 1) { currentRetry = retry; doRead(addr, reg, count); } void ModbusRTUMaster::doRead(quint8 addr, quint16 reg, quint16 count) { lastRequest = {addr, reg, count}; readHoldingRegisters(addr, reg, count); } void ModbusRTUMaster::onTimeout() { if (currentRetry > 0) { currentRetry--; QTimer::singleShot(200, this, [this] { doRead(lastRequest.addr, lastRequest.reg, lastRequest.count); }); } else { emit errorOccurred("重试耗尽,通信失败"); } }✅ 日志记录原始报文
调试时最好能把所有收发帧记下来:
qDebug() << "TX:" << txFrame.toHex(); qDebug() << "RX:" << rxFrame.toHex();后期还可以导出成.log文件供客户分析。
✅ 支持功能码扩展
除了 0x03 读寄存器,你还可能需要:
-0x06写单个寄存器
-0x10写多个寄存器
-0x01读线圈状态
都可以基于同一个框架扩展出来。
最后一点思考:这条路还走得通吗?
你说现在都 2025 年了,还在搞串口通信是不是太落后?
其实不然。
OPC UA、MQTT、TSN 这些新技术确实在兴起,但全球仍有数以亿计的 Modbus 设备在运行。工厂里的温控表、电能表、变频器,很多连网口都没有,只能靠 RS-485 接出来。
而且这套方案成本极低:一个 USB 转 485 模块十几块钱,Qt 开发免费开源版本也够用。中小企业做个小系统,一周就能上线。
所以说,掌握QSerialPort + Modbus组合,不是守旧,而是务实。它是连接数字世界与物理世界的最短路径之一。
如果你正在做一个数据采集项目,不妨试试把这个ModbusRTUMaster类放进工程跑一跑。只要接线正确、参数匹配,第一次看到屏幕上显示出真实传感器数据的那一刻,你会感受到一种久违的“掌控感”。
这才是工程师的乐趣所在。
如果你需要完整工程模板(含 UI 示例、配置保存、日志窗口等),欢迎留言,我可以整理一份开源仓库分享出来。