news 2026/1/13 17:59:32

qserialport与Modbus协议集成:完整示例解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
qserialport与Modbus协议集成:完整示例解析

用 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 示例、配置保存、日志窗口等),欢迎留言,我可以整理一份开源仓库分享出来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/1/5 8:50:32

如何快速配置WSA-Script:Windows 11安卓子系统终极指南

如何快速配置WSA-Script&#xff1a;Windows 11安卓子系统终极指南 【免费下载链接】WSA-Script Integrate Magisk root and Google Apps into WSA (Windows Subsystem for Android) with GitHub Actions 项目地址: https://gitcode.com/gh_mirrors/ws/WSA-Script 还在为…

作者头像 李华
网站建设 2026/1/12 4:20:00

Windows系统OneDrive终极清理指南:一键彻底卸载释放资源

想要完全移除Windows系统中的OneDrive组件&#xff0c;释放宝贵的系统资源吗&#xff1f;OneDrive-Uninstaller是一个专为Windows 10设计的批处理脚本工具&#xff0c;能够深度清理OneDrive的所有相关文件、服务配置和注册表项&#xff0c;确保卸载的彻底性。本文将详细介绍如何…

作者头像 李华
网站建设 2026/1/10 15:10:15

中小企业如何借助Dify实现AI能力快速内化?

中小企业如何借助Dify实现AI能力快速内化&#xff1f; 在今天&#xff0c;几乎每一家中小企业都在思考同一个问题&#xff1a;我们该如何真正用上大模型&#xff1f; 不是停留在“试一下ChatGPT写文案”的层面&#xff0c;而是把AI深度融入业务流程——比如让客服自动解答90%的…

作者头像 李华
网站建设 2026/1/8 23:30:53

彻底删除Microsoft Teams:3步解决顽固残留问题,让电脑性能飙升

你是否曾为Microsoft Teams的顽固残留而烦恼&#xff1f;即使卸载了程序&#xff0c;它依然在后台占用资源、拖慢系统启动速度。本指南将为你提供一套专业的三步解决方案&#xff0c;彻底清除Teams残留&#xff0c;显著提升电脑性能。 【免费下载链接】OneDrive-Uninstaller Ba…

作者头像 李华
网站建设 2026/1/4 19:51:15

FanControl终极指南:彻底解决Windows风扇噪音的革新方案

FanControl终极指南&#xff1a;彻底解决Windows风扇噪音的革新方案 【免费下载链接】FanControl.Releases This is the release repository for Fan Control, a highly customizable fan controlling software for Windows. 项目地址: https://gitcode.com/GitHub_Trending/…

作者头像 李华
网站建设 2026/1/13 2:15:21

静态代码扫描终极指南:TscanCode让你的代码质量飞跃提升

静态代码扫描终极指南&#xff1a;TscanCode让你的代码质量飞跃提升 【免费下载链接】TscanCode 项目地址: https://gitcode.com/gh_mirrors/tsc/TscanCode 作为开发人员&#xff0c;你是否曾因代码中的隐藏漏洞而彻夜难眠&#xff1f;是否在项目上线后才发现那些本可避…

作者头像 李华