news 2026/2/3 17:11:19

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/2/2 19:23:38

Input Leap完整指南:5分钟掌握跨设备键盘鼠标共享技术

Input Leap完整指南&#xff1a;5分钟掌握跨设备键盘鼠标共享技术 【免费下载链接】input-leap Open-source KVM software 项目地址: https://gitcode.com/gh_mirrors/in/input-leap Input Leap是一款功能强大的开源KVM软件&#xff0c;通过精密的键盘状态管理和按键映射…

作者头像 李华
网站建设 2026/2/3 1:10:22

PDF目录自动生成终极指南:告别手动编排的烦恼

还在为PDF文档缺少目录而烦恼吗&#xff1f;每次阅读长篇技术文档或学术论文时&#xff0c;是否都希望有个清晰的导航目录&#xff1f;&#x1f914; 今天我要向你介绍一个革命性的开源工具——pdf.tocgen&#xff0c;它将彻底改变你处理PDF文档的方式。 【免费下载链接】pdf.t…

作者头像 李华
网站建设 2026/2/3 16:51:08

告别昂贵CAD软件,这款开源神器让你零成本玩转专业绘图

告别昂贵CAD软件&#xff0c;这款开源神器让你零成本玩转专业绘图 【免费下载链接】LibreCAD LibreCAD is a cross-platform 2D CAD program written in C14 using the Qt framework. It can read DXF and DWG files and can write DXF, PDF and SVG files. The user interface…

作者头像 李华
网站建设 2026/2/3 16:55:58

HoRain云--Nginx单端口多项目配置指南

&#x1f3ac; HoRain云小助手&#xff1a;个人主页 &#x1f525; 个人专栏: 《Linux 系列教程》《c语言教程》 ⛺️生活的理想&#xff0c;就是为了理想的生活! ⛳️ 推荐 前些天发现了一个超棒的服务器购买网站&#xff0c;性价比超高&#xff0c;大内存超划算&#xff01;…

作者头像 李华
网站建设 2026/2/3 11:17:37

Input Leap完整教程:5步实现跨设备键盘鼠标共享

Input Leap完整教程&#xff1a;5步实现跨设备键盘鼠标共享 【免费下载链接】input-leap Open-source KVM software 项目地址: https://gitcode.com/gh_mirrors/in/input-leap Input Leap作为开源KVM软件的杰出代表&#xff0c;能够帮助用户在不同设备间实现键盘鼠标的完…

作者头像 李华
网站建设 2026/2/2 14:30:22

FileConverter终极指南:Windows右键菜单文件转换的免费神器

FileConverter终极指南&#xff1a;Windows右键菜单文件转换的免费神器 【免费下载链接】FileConverter File Converter is a very simple tool which allows you to convert and compress one or several file(s) using the context menu in windows explorer. 项目地址: ht…

作者头像 李华