简介基于QT框架与QSerialPort模块实现的一款Modbus串口从站slave程序专为工业自动化、嵌入式及物联网开发者设计用于模拟Modbus RTU/ASCII通信中的从站设备可接收并响应主站请求完成寄存器读写与数据交换。压缩包共3个文件包含1个cpp主程序源码、1个pro工程配置文件和1个user用户配置整体仅12KB轻量适合快速查阅。目前已有1472人学习下载。源码完整展示了串口初始化、信号槽监听、Modbus报文解析、功能码处理、响应报文构建及差错校验等核心流程便于开发者理解从站实现原理并可直接迁移到实际项目中。通过阅读该程序能够系统掌握QT串口编程与Modbus协议结合的综合技能是学习和开发串口通信应用的实用参考。 最近做工业设备联调我被一件事反复折磨上位机写完了现场设备却不在手上。PLC 被产线占着仪表要等货程序逻辑只能靠干想。后来我干脆花了两天用 Qt 写了一个 Modbus RTU 串口从机slave程序专门拿来模拟现场从站设备。这篇文章就把整个项目的思路、核心代码和踩过的坑整理出来给正在做上位机开发、嵌入式联调或者想弄懂 Modbus 协议的朋友一个可以直接落地的参考。这个程序能做什么简单说就是把电脑变成一个虚拟 Modbus 从站从机地址、波特率、寄存器初始值都可以配置上位机通过串口来读它、写它它都能正确响应。没有真实设备的时候拿它来验证上位机通信逻辑、测试组态软件、调触摸屏都非常好用。文章里我会尽量用大白话讲清楚协议细节代码不复杂跟着敲一遍你基本就能摸透 Modbus RTU。1. 需求与整体方案为什么我要手写一个从站程序1.1 从站程序解决的是联调时的现实痛点写从站程序最直接的场景就是联调。我第 n 次坐在电脑前面对的都是这种尴尬局面上位机的按钮和表格都做好了但没设备可连。找 PLC产线不能停。借仪表快递要三天。这时候如果有台电脑能当从站用的还是同一个串口、同一套协议联调工作就能马上推进不用干等设备到位。模拟从站在教学和测试里也很有用。新手学 Modbus 协议与其抱着文档看半天不如打开一个从站程序用串口调试助手或者 Modbus 主机工具发几帧报文看着寄存器被读写协议立刻就能理解。做自动化测试的朋友也可以写脚本控制从站程序把异常返回、超时、CRC 错误这些边界情况都测一遍。所以这个项目的核心不是做一个很酷的软件而是解决没设备时怎么联调这个现实问题。目标明确能用、稳定、可配置三样就够。1.2 自己解析协议还是直接用 libmodbus动手前我纠结了一件事是直接用现成的 libmodbus 库还是自己解析 RTU 帧。两种方案我都简单试过。libmodbus 是 C 语言写的 Modbus 协议栈功能很全支持 RTU 和 TCP。在 Qt 里调用并不复杂创建 context、设置从机地址、调用 modbus_receive 和 modbus_reply 就能撑起一个从站。问题是它走的是同步阻塞模型和 Qt 的信号槽事件循环放在一起有点别扭而且库内部自己管理串口缓冲调试时想打印原始报文不太顺手。如果只是想快速实现用它能省不少事。我最后选了手写解析。原因有两个一是这个项目的帧格式很简单RTU 帧就那几部分组成地址、功能码、数据、CRC解析逻辑两百行内能写完二是手写解析能让我随时打印每一帧的十六进制内容排查问题时非常有底气。而且 Modbus RTU 的细节并不多自己写一遍之后不管遇到什么设备看报文就能定位问题这个收益是库给不了的。如果项目周期紧、要支持的设备类型多、还有多主机并发等复杂场景直接上 libmodbus 更稳妥。两种方案没有绝对的对错看你的目标是什么。2. 核心细节解析Modbus RTU 协议与串口通信要点2.1 RTU 帧怎么组成地址从哪里来Modbus RTU 是主从式协议主机master先发请求从机slave收到后再回响应。RTU 模式下一帧数据由这几个部分组成组成部分长度说明从机地址1字节范围 1~2470 是广播地址功能码1字节如 03 读保持寄存器、06 写单个寄存器、16 写多个寄存器数据区N字节内容由功能码决定CRC16 校验2字节低字节在前、高字节在后这个顺序非常容易记反从机地址要注意广播帧地址 0只接收不响应常规联调基本用不到就设定一个 1 到 247 之间的地址比如 1。上位机发包时地址不对从机是不理你的这也是最常见的问题之一。帧里最容易搞错的就是 CRC 字节序我刚开始按大端方式拼帧用串口助手对比标准报文怎么都对不上最后发现 Modbus RTU 规定 CRC 先发低 8 位再发高 8 位这个细节我后来每次写代码都会专门留意。2.2 CRC16 算法新手踩坑重灾区CRC16 是 Modbus RTU 的校验方式标准多项式是 0xA001反转后的 0x8005。对新手来说不用纠结这个数是怎么推出来的直接用公式或者查表法就行。查表法速度快适合高流量场景按位计算代码简洁从站程序每秒处理几百帧完全够用我用的就是按位版本。quint16 ModbusSlave::crc16(const quint8 *data, int len) { quint16 crc 0xFFFF; for (int i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }我在联调时遇到过一个典型坑从站程序收到读寄存器请求解析、拼响应都对但上位机就是提示超时。后来抓串口数据一对比发现返回的 CRC 算错了——响应帧的 CRC 也按大端拼了。所以 CRC 建议封装成通用函数请求帧和响应帧都用同一个函数生成减少手写出错的机会。2.3 串口参数与 RS232/RS485 的差异Modbus RTU 走串口串口参数必须和上位机保持一致波特率、数据位、停止位、校验位。最常见的配置是 9600 8N18 个数据位、无校验、1 个停止位。但我实际测试时也遇到过 115200 8E1 的设备所以从站程序最好把这些参数做成下拉框可选不要写死。串口物理层常见两种RS232 和 RS485。RS232 是全双工收发分开接 3 根线TX、RX、GND就能通RS485 是半双工两根线A、B差分传输同一时刻只能收或只能发。如果用的是带自动收发切换的 USB 转 485 模块驱动会帮你处理方向写程序不用关心如果是老式纯 485 芯片需要在发完数据后手动切换方向。我的经验是调试期尽量用自动切换模块少一个变量多一分省心。3. 实操过程与核心代码实现3.1 工程搭建与串口模块引入我用的是 Qt 5.15 qmake这个版本资料多Qt 6 同样适用区别不大。新建工程后在 .pro 文件里加一行QT core gui serialport如果用 Qt Creator记得把串口模块加上不然编译直接报 QSerialPort: No such file or directory。接下来在类里引入头文件#include QSerialPort #include QSerialPortInfoQSerialPortInfo用来枚举系统里的可用串口UI 里动态填充串口号就靠它。3.2 串口打开与参数初始化串口打开的过程不复杂但每个参数都影响通信结果。我一般把配置和打开分开写UI 上改完参数再点击打开才生效。核心代码如下bool ModbusSlave::openSerialPort(const QString portName, int baud) { if (m_serial-isOpen()) m_serial-close(); m_serial-setPortName(portName); m_serial-setBaudRate(baud); m_serial-setDataBits(QSerialPort::Data8); m_serial-setStopBits(QSerialPort::OneStop); m_serial-setParity(QSerialPort::NoParity); m_serial-setFlowControl(QSerialPort::NoFlowControl); if (!m_serial-open(QIODevice::ReadWrite)) { // 提示用户比如无法打开串口请检查占用或权限 return false; } connect(m_serial, QSerialPort::readyRead, this, ModbusSlave::onReadyRead); return true; }提示Linux 下打开 /dev/ttyUSB0 如果报 Permission denied先检查当前用户是否在 dialout 组里或者用 sudo 临时跑一下。Windows 下如果串口被串口助手占用程序会打开失败这也是最常见的打开失败原因。3.3 接收数据与帧拼接解析串口数据是一点一点到达的不一定一次完整发来一帧。我刚开始写的时候直接在 readyRead 里拿 readAll 的数据去解析结果经常解析失败——数据被切成了两半。后来我改成缓冲区累积 循环解析把收到的数据先塞进 QByteArray再按帧格式从里面提取完整一帧void ModbusSlave::onReadyRead() { m_buffer.append(m_serial-readAll()); while (m_buffer.size() 8) { // 最小帧长 8 字节 quint8 addr static_castquint8(m_buffer.at(0)); if (addr ! m_slaveAddr) { // 总线上可能有其他地址的报文丢弃并继续查找 m_buffer.remove(0, 1); continue; } quint8 func static_castquint8(m_buffer.at(1)); int frameLen frameLength(func, m_buffer); if (frameLen 0) { m_buffer.remove(0, 1); continue; } if (m_buffer.size() frameLen) return; // 还没收完整等下次 readyRead quint16 crcCalc crc16( reinterpret_castconst quint8 *(m_buffer.constData()), frameLen - 2); quint16 crcRecv static_castquint8(m_buffer.at(frameLen - 1)) 8 | static_castquint8(m_buffer.at(frameLen - 2)); if (crcCalc ! crcRecv) { // 串口噪声或数据错位扔掉一字节继续找帧头 m_buffer.remove(0, 1); continue; } QByteArray frame m_buffer.left(frameLen); m_buffer.remove(0, frameLen); handleFrame(frame); } }frameLength的作用是按功能码返回完整帧长度这样缓冲区里即使同时来了多帧也能正确逐帧消费不会丢包。实现大致是这样int ModbusSlave::frameLength(quint8 func, const QByteArray buf) { switch (func) { case 0x03: // 读保持寄存器请求8 字节 case 0x06: // 写单个寄存器请求8 字节 return 8; case 0x10: { // 写多个寄存器9 字节数 if (buf.size() 7) return -1; int byteCount static_castquint8(buf.at(6)); return 9 byteCount; } default: return -1; } }这种做法本质上是在找帧头 校验 消费三个步骤里循环。就算总线上一堆噪声数据只要 CRC 不对就扔掉一个字节继续找最终能自己恢复同步。实测下来这个逻辑比固定长度截包要健壮得多。3.4 功能码处理03、06、16从站程序至少要支持几个常用功能码我实现的是 03 读保持寄存器、06 写单个寄存器、16 写多个寄存器对应关系如下功能码名称用途0x03读保持寄存器读取参数、状态值0x06写单个寄存器写入单个参数0x10写多个寄存器批量写入参数寄存器内存就是一个数组m_registers上位机读写全在这个数组上操作。下面是一段 handleFrame 的骨架void ModbusSlave::handleFrame(const QByteArray frame) { quint8 func static_castquint8(frame.at(1)); switch (func) { case 0x03: handleReadHoldingRegisters(frame); break; case 0x06: handleWriteSingleRegister(frame); break; case 0x10: handleWriteMultipleRegisters(frame); break; default: sendException(frame, 0x01); // 不支持的功能码返回异常 01 break; } }读保持寄存器的处理逻辑比较有代表性请求是地址 03 起始地址(2字节) 寄存器数量(2字节) CRC2响应是地址 03 字节数 寄存器数据(每寄存器2字节) CRC2void ModbusSlave::handleReadHoldingRegisters(const QByteArray req) { int start (quint8)req.at(2) 8 | (quint8)req.at(3); int count (quint8)req.at(4) 8 | (quint8)req.at(5); if (start count REG_COUNT) { sendException(req, 0x02); // 非法数据地址 return; } QByteArray resp; resp.append(static_castchar(m_slaveAddr)); resp.append(static_castchar(0x03)); resp.append(static_castchar(count * 2)); for (int i 0; i count; i) { quint16 val m_registers[start i]; resp.append(static_castchar(val 8)); resp.append(static_castchar(val 0xFF)); } appendCrc(resp); m_serial-write(resp); }特别注意寄存器数量要和实际缓冲区大小核对。假设从站只有 100 个寄存器上位机却从地址 0 读 200 个就要返回异常码 02非法数据地址不能硬着头皮越界发数据。写单个寄存器的请求帧和响应帧几乎一样数据区是严格的 2 字节不能多也不能少。我之前在数据区校验上偷懒导致上位机写一个寄存器发来 3 字节数据时程序还是原样返回结果主机端判定帧长度错误。后来改成严格校验长度该返回异常就返回异常协议兼容性才好起来。3.5 UI 设计能看见状态才叫调试工具UI 我做得不复杂但有一个原则所有通信细节必须可见。界面左侧是串口配置区串口号用QSerialPortInfo::availablePorts()动态枚举波特率用下拉框右侧是一个寄存器表格用QTableWidget显示地址、寄存器值、读写类型双击单元格就能改值。收发日志区用QPlainTextEdit显示十六进制报文每一帧都标方向TX/RX和时间戳。日志区是我最依赖的调试工具。程序跑起来上位机发的每个字节都能看到和标准 Modbus 报文一对比问题在哪瞬间就清楚了。很多从站程序只做到能通就收工但我觉得对开发工具来说能看见里面在发生什么比功能本身更重要。4. 常见问题与排查技巧实录4.1 主机和从机单独测都正常连一起就不行这个现象我在调试 485 的时候遇到过主机和从机分别用串口助手测试都正常一旦硬件连接起来就完全不通。逐个排查后问题出在物理链路——A、B 线接反了。RS485 的 A 对应 DB 对应 D-很多低成本线缆颜色不标准接反后信号电平完全反相表现为收发都没反应。用万用表量一下主机侧和从机侧标号改成一致就好。我把常见问题整理成了排查顺序检查项可能原因处理方法线序A/B 接反按端子标号对接别只看颜色共地232 通信没接 GNDRS232 必须共地485 远距离也建议共地终端电阻线路长、节点多总线两端各并一个 120 欧电阻串口参数波特率/校验不一致主机从机必须完全一致方向切换非自动收发模块发完数据留足方向切换时间4.2 USB 转串口设备没反应多半是驱动现在很多电脑没有原生串口全靠 USB 转串口模块。CH340、CH341、FTDI 这几个芯片最常见对应驱动也不一样。设备插上没反应先看设备管理器里有没有出现新的 COM 口如果带黄色感叹号就是驱动没装对。CH340/CH341 去芯片官网下载驱动FTDI 用系统自动更新一般能装上。装完驱动再插拔一次COM 口出现后再去 Qt 程序里刷新串口列表。有一点容易被坑同一个 USB 转串口模块插在不同 USB 口上COM 口号可能变化拔插之后 Qt 程序里如果还在用旧的 COM 口号打开就会失败。所以 UI 里的串口号一定要用 availablePorts() 动态刷新别缓存死值。4.3 Linux 下接收丢数据或完全不收Linux 下用 QSerialPort最常遇到的问题不是代码而是系统串口模式没设置。默认情况下串口驱动会把收到的数据按终端规则处理比如转换换行符、拦截特殊字符反映到程序里就是丢数据或者收到莫名其妙少了字节。解决办法是用stty把串口设置成 raw 模式stty -F /dev/ttyUSB0 raw -echo然后再打开程序。如果是长期使用可以用 udev 规则配合脚本在插拔时自动配置。这个坑很隐蔽我第一次在 Linux 上跑从站程序时收上来的帧老是缺字节查了老半天才发现是终端模式在作怪不是代码问题。4.4 打包后运行报错no qt platform plugin could be initialized程序开发调试都没问题换台电脑运行就弹这个报错基本是 Qt 部署时 platforms 插件没带上。用官方自带部署工具重新处理一遍就行windeployqt yourApp.exe运行前确保你的 exe 路径在同一个 Qt 环境的 bin 目录下部署完后 exe 旁边会生成一堆 dll 和 plugins 目录把整个目录拷贝走就能在别的 Windows 机器上运行。如果代码里动态加载了其他模块比如 SQL 驱动可能还需要手动额外拷贝对应插件目录。5. 后续扩展与我的实操体会5.1 这个从站还能怎么变成生产力工具写到这一步程序已经能解决联调问题了。往后扩展方向其实很多一是增加 Modbus TCP 从站把串口从站和网络从站做成同一套寄存器数据源就能当串口转以太网网关用二是把寄存器数据做成配置映射不同项目的地址表用外部配置文件加载不用每次改代码三是结合图表控件 QCustomPlot把寄存器值变动的曲线实时画出来调试 PID、流量控制这类参数会非常直观。这三个方向我都做过不同程度的小尝试收益都很明显。5.2 踩过几次坑之后的真心话最后说点个人体会。写这种工具类程序我的最大感受是先把通信打通再去做界面和功能。很多新手一上来就在 UI 上花大力气做了一堆好看的按钮和表格结果串口数据在底层乱成一团最后又回头改协议栈白费不少功夫。我自己的顺序是先用串口调试助手验证帧格式再拿 Modbus 主机工具确认从站能正常响应最后才是美化界面。每一步都用工具验证过再往下一层走整个开发过程会顺很多。另外强烈建议在电脑上装一对虚拟串口模拟器把两个虚拟串口连成一对跑一个从站实例再开一个串口助手往对端发 Modbus 帧整个调试链路就完全脱离硬件了。我第一次完整跑通协议栈就是在虚拟串口下做到的上一秒还在为 CRC 发愁下一秒就看到上位机表格里的寄存器值跳动了。如果你在从站开发中遇到了其他奇怪问题不妨把现象和报文贴出来一起分析工业通信这东西经验就是靠一个坑一个坑踩出来的。本文还有配套的精品资源点击获取