资讯动态

Qt实现Modbus RTU串口从机:从原理到联调实战指南

发布时间:2026/9/8 6:42:47 来源:尧图企业网站定制
简介面向QT开发者与工业通信入门者的Modbus串口从站程序基于QSerialPort实现完整slave逻辑可响应主站读写请求适合学习Modbus RTU/ASCII协议解析、串口参数配置与信号槽异步收发的综合实践。资源包共3个文件包含cpp源码、pro工程文件及user配置以轻量结构完整呈现从端口初始化、报文解析到响应回传的实现框架整体约12KB。程序覆盖0x03读保持寄存器、0x06写单个线圈等功能码处理流程并包含超时、校验失败等错误处理思路可直接在Qt环境中编译调试。已有1472人浏览学习适合正在做串口通信或工业物联网项目的开发者参考。通过源码可掌握QSerialPort的readyRead事件驱动模型、CRC校验构建方式以及如何按Modbus帧格式填充响应数据工程文件还保留了编译配置便于快速复现运行环境。整体是一份结构紧凑、可直接运行的从站实现示例。1. 项目概述与需求拆解最近帮朋友调试一套设备上位机对方提了个需求用Qt写一个Modbus串口从机程序说白了就是让这台PC模拟一个Modbus RTU Slave设备给外部的主机比如PLC、组态软件、modbus poll读写数据用。这种需求在工控圈其实非常常见——设备还没定型、硬件还没到位的时候先用软件模拟一个从机验证主机侧的采集逻辑、协议解析、界面交互是否正常。先说说这程序的定位。一个标准的Modbus串口从机核心就三件事挂到串口上监听请求、解析主机发来的RTU帧、按功能码读写对应寄存器后返回响应。用Qt来做最大的优势是用QModbusServer这个现成类不用自己逐字节去拼CRC16、处理超时重传这些脏活累活。Qt官方从5.8开始把QModbus模块作为附加模块引入到5.14正式成为核心模块串口从机RTU Slave和TCP从机都能支持。这个项目最适合谁看一类是刚接触Qt串口编程、想找个完整范例入门的开发者另一类是工控现场经常要跟Modbus设备打交道、想搞个趁手模拟工具的调试工程师。读完你不仅能跑起来一个能用的slave程序还能搞明白串口参数怎么配、寄存器映射怎么设计、用modbus poll联调时遇到连不上该往哪个方向排查。2. 核心原理Modbus RTU串口通信到底在干什么2.1 串口参数为什么是“9600 8N1”Modbus RTU是跑在串口上的串口本身的参数必须主机和从机完全一致否则物理层就过不去。最常见的配置是9600波特率、8位数据位、无校验、1位停止位简写为9600 8N1。很多国产仪表出厂默认就是9600 8N1所以你写程序时把这四个参数设对大概率能直接连上。这里有个新手容易忽略的点校验位和停止位的组合在某些串口驱动下比较挑剔。比如你要配“无校验2位停止位”在QSerialPort里要先把setParity设为NoParity再把setStopBits设为TwoStop这个顺序本身没问题但如果你用的是USB转串口线CH340、FTDI、CP2102这些芯片对停止位组合的处理略有差异实测下来“8N1”兼容性最好建议默认就用它。另外波特率不是越高越好9600在30米左右的屏蔽双绞线上非常稳115200虽然快但线一长或者干扰大就容易丢帧。2.2 一条完整请求是怎么走完的主机发一帧RTU请求结构是从机地址1字节、功能码1字节、数据段N字节、CRC16校验2字节。以读取保持寄存器功能码0x03为例主机发送的原始字节长这样01 03 00 00 00 0A C5 CD拆开看01是目标从机地址03是功能码00 00是起始寄存器地址从0号开始00 0A是寄存器数量读10个C5 CD是前面字节算出来的CRC16校验。从机收到后如果地址匹配且CRC校验通过就返回01 03 14 00 64 00 65 00 66 ... (共10个寄存器数据)01是原地址03是原功能码14十进制20是后面数据的总字节数接着就是20个字节的寄存器值。QModbusServer在内部帮你做完了这些脏活你只需要告诉它“把值存到哪个寄存器里”就行。2.3 四种数据模型别搞混Modbus协议把从机内部数据分成四张表对应四个不同的功能码区间数据类型操作类型对应功能码常见用途线圈Coil读写位0x01读、0x05写单、0x0F写多开关量输出如继电器离散输入Discrete Input只读位0x02读开关量输入如按钮状态保持寄存器Holding Register读写字0x03读、0x06写单、0x10写多模拟量输出、参数配置输入寄存器Input Register只读字0x04读模拟量采集如温度值我最早踩过一个坑在Qt的QModbusDataUnit里只给HoldingRegisters赋了初值结果用modbus poll去读输入寄存器功能码0x04返回的一直是0。后来才反应过来四张表是各自独立的内存区域你往保持寄存器里写了值输入寄存器那边什么都不会有。所以初始化的时候要把所有用得到的区域都set一遍。3. 实操从零写一个串口从机程序3.1 工程配置和环境准备我用的是Qt 5.14.2 MinGW 7.3.0 64位工程文件.pro需要额外加一行QT core gui serialbus serialport注意serialbus是Modbus模块所在serialport是串口通信基础模块两个都要加缺一个都编译不过。如果你用的Qt版本低于5.8那没办法只能自己找第三方库但现在的Qt在线安装器默认都带这俩模块一般不会有问题。界面部分我建议用QWidget加一个下拉框选串口号、一个下拉框选波特率、一个SpinBox设从机地址、一个PlainTextEdit显示日志够用就行。别一上来就上Model/View架构调试工具类程序最重要的是直观、能改、能看。主要控件在代码里手动new就行不用搞.ui文件省得维护两套东西。3.2 初始化QModbusServer的关键代码核心逻辑就一段初始化从机、绑定串口、设置参数。看下面这段#include QModbusRtuSerialSlave #include QModbusDataUnit #include QSerialPort // 成员变量QModbusServer *modbusServer; void SlaveWindow::initModbusServer() { if (modbusServer) { modbusServer-disconnect(); delete modbusServer; modbusServer nullptr; } // 创建RTU串口从机对象 modbusServer new QModbusRtuSerialSlave(this); // 关联错误处理和状态变化 connect(modbusServer, QModbusServer::errorOccurred, this, SlaveWindow::onModbusError); connect(modbusServer, QModbusServer::stateChanged, this, SlaveWindow::onModbusStateChanged); // 关键串口参数必须在这里设置 modbusServer-setConnectionParameter( QModbusDevice::SerialPortNameParameter, ui-comboPort-currentText()); modbusServer-setConnectionParameter( QModbusDevice::SerialParityParameter, QSerialPort::NoParity); modbusServer-setConnectionParameter( QModbusDevice::SerialBaudRateParameter, ui-comboBaud-currentText().toInt()); modbusServer-setConnectionParameter( QModbusDevice::SerialDataBitsParameter, QSerialPort::Data8); modbusServer-setConnectionParameter( QModbusDevice::SerialStopBitsParameter, QSerialPort::OneStop); // 设置从机地址1~247都行0是广播地址一般不用 modbusServer-setServerAddress(ui-spinAddr-value()); // 关键注册一段保持寄存器起始地址0长度100 QModbusDataUnit regUnit(QModbusDataUnit::HoldingRegisters, 0, 100); for (int i 0; i 100; i) { regUnit.setValue(i, 1000 i); // 给个默认值方便观察 } modbusServer-setData(regUnit); // 连接串口 if (!modbusServer-connectDevice()) { appendLog(QStringLiteral(从机启动失败%1) .arg(modbusServer-errorString())); } }这里有一个矛盾点需要解释QModbusRtuSerialSlave内部已经帮你管理了QSerialPort所以你不能再自己new一个QSerialPort去打开同一个串口否则会报“串口被占用”。上面的代码里串口参数通过setConnectionParameter传给modbusServer内部由它去打开串口。3.3 寄存器数据怎么读写setData只是初始化时批量写一次。实际运行中数据可能来自界面输入、定时器采集、网络报文等需要主动往寄存器里塞值。推荐的方式是维护一个时序用QTimer周期性把最新数据写进QModbusServervoid SlaveWindow::updateRegisterValue(int address, quint16 value) { if (!modbusServer) return; // 先读出来再改其中某个值最后写回去 QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address, 1); if (modbusServer-data(unit)) { unit.setValue(0, value); modbusServer-setData(unit); } }这里我推荐“读-改-写”三步而不是直接setData一个新建的unit因为setData如果传入的地址范围和你初始化时注册的范围不一致Qt内部可能直接忽略但是表面上看代码没报错。你要是只在启动时注册了地址0~99后来突然往地址200写多半是静默失败。调试时在日志里打一句“updateRegisterValue addr%1 val%2”很快就对上了。关于线圈和输入寄存器逻辑完全一样只是QModbusDataUnit构造函数的第一个参数换成Coils或InputRegisters。3.4 日志输出和错误捕获工控程序最怕“莫名其妙就是不行”所以日志一定要完善。我习惯把串口打开状态、收到的错误、寄存器读写请求都打印出来void SlaveWindow::onModbusError(QModbusDevice::Error error) { appendLog(QStringLiteral(Modbus错误%1) .arg(modbusServer-errorString())); } void SlaveWindow::onModbusStateChanged(QModbusDevice::State state) { if (state QModbusDevice::ConnectedState) appendLog(QStringLiteral(从机已连接等待主机请求...)); else if (state QModbusDevice::UnconnectedState) appendLog(QStringLiteral(从机已断开)); }用QModbusServer还有个看不见的好处主机读写寄存器时从机侧的日志里能看到对应的请求记录。不过这是它内部处理的不经过特殊声明你拿不到逐帧报文。想要抓原始RTU帧看建议用串口调试助手或者虚拟串口抓包工具下文会讲比在代码里加钩子省事得多。3.5 完整程序骨架把上面代码拼起来main窗口的构造函数里长这样SlaveWindow::SlaveWindow(QWidget *parent) : QWidget(parent) { // 先创建UI控件端口下拉框、波特率下拉框、地址SpinBox、启动按钮、日志区 setupUi(); // 枚举系统可用串口 const auto ports QSerialPortInfo::availablePorts(); for (const QSerialPortInfo info : ports) ui-comboPort-addItem(info.portName()); // 默认波特率填充常用档位 ui-comboBaud-addItems({9600, 19200, 38400, 115200}); ui-comboBaud-setCurrentText(9600); } void SlaveWindow::onStartButtonClicked() { if (!modbusServer) { initModbusServer(); } else if (modbusServer-state() QModbusDevice::ConnectedState) { modbusServer-disconnectDevice(); appendLog(QStringLiteral(从机已停止)); } else { initModbusServer(); } }把启动按钮的点击信号连到onStartButtonClicked就行。串口没插、端口被占用、参数不对这些错误都会通过errorOccurred信号通知你。注意USB转串口拔插后端口号会变如果程序长时间挂着建议在定时器里刷新端口列表或者给用户一个“重新扫描串口”按钮。4. 常见问题与排查技巧实录4.1 主机连不上从机先查物理层用modbus poll当主机去连这个从机程序如果一直超时我的排查顺序固定是这样的串口号对不对设备管理器里看是COM3还是COM9程序里下拉框要选对。USB转串口设备拔插一次端口号可能就变了。物理接线如果是TTL电平直接连注意TXD/RXD要交叉如果是RS232/RS485检查A/B线是不是接反了。RS485是差分信号A接A、B接B反了要么收不到要么乱码。串口参数一致主机和从机的波特率、数据位、校验位、停止位必须完全一致。最容易出错的是校验位主机那边默认Even偶校验而从机这边设成了NoParity那就永远连不上。modbus poll连接时有个细节从站地址Slave ID、功能码Function、起始地址Address、寄存器数量Quantity都要填对。比如你Qt程序里setServerAddress(1)poll里面Slave ID就得填1。地址也分“PLC地址”和“协议地址”poll的Address一栏默认从0开始填但很多PLC的保持寄存器地址从40001开始4xxxx是保持寄存器的标志位换算关系是协议地址 PLC地址 - 40001。新手在这一步最容易懵其实记住一点Modbus协议层地址一定是从0开始的界面上的40001只是行业习惯。4.2 用串口助手抓RTU原始字节怀疑协议层面有问题时不要猜直接把串口数据抓出来看。这里有个非常好用的调试方法在PC上用虚拟串口软件比如VSPD创建一对虚拟串口COM3和COM4是互通的。Qt从机程序打开COM3modbus poll选COM4两个连起来测试。再开一个串口调试助手想办法把真实串口或者虚拟串口的字节流导出来看。不过说实话最方便的还是在调试助手里把收到的hex字节和发送的hex字节对照比较。Modbus RTU的CRC16校验是Little-Endian低字节在前。比如计算完CRC是0xC5CD帧里看到的顺序是CD C5。如果看到CRC字节顺序反了说明主机和从机对CRC字节序的理解不一致但这种问题Qt内部已经处理好了不会遇到。真正自己写CRC算法时比如你以后要移植到单片机上这个坑才会显现出来。Qt的QModbusRtuSerialSlave用的是标准Modbus CRC16多项式0x8005初始值0xFFFF不是常见的CRC-16/MODBUS的变体注意别和别人写的CRC校验混了。4.3 RS485方向切换问题用RS485跑Modbus RTU时因为485是半双工发送和接收共用一对线所以需要一个方向控制引脚DE/RE。很多USB转485模块是自动切换方向的但工业现场的老式板卡可能需要程序手动拉高/拉低某个GPIO。Qt里QModbusRtuSerialSlave内部用的是QSerialPort它会自动处理串口的读写但不会帮你控制485方向引脚。如果你用的板卡需要手动切换得在写入数据前拉高DE写完再拉低。最粗暴的方法是继承QModbusRtuSerialSlave重写processPrivateData或直接改底层但这会比较复杂。更省事的方法是买自动方向切换的485模块国内市面上绝大多数USB转485模块包括CH340芯片的都是自动方向我用下来基本没遇到要手动切换的情况。如果你确实是板卡级开发建议在硬件上把DE引脚用逻辑电路做成自动方向代码层面不用管。4.4 数据读出来一直是0如果主机能连上但读出来的寄存器全是0最常见的原因有两个没有给对应数据区域赋初值。我前面强调过保持寄存器、输入寄存器、线圈、离散输入是四块独立内存。你只初始化了保持寄存器主机用功能码0x04读输入寄存器拿到的自然是空值。Qt的QModbusServer对没设置过的数据区域一般返回空数据或0表现就是读出来全是0。地址不匹配。主机读的起始地址是0但你在Qt里注册的起始地址是100那就完全错开了。我习惯统一从0开始并且日志里把地址范围打出来一目了然。4.5 程序启动时报“QModbusDevice::connectDevice: 串口被占用”用第三方串口调试助手同时打开了同一个串口就会这样。另一个常见原因是你程序里自己又new了一个QSerialPort去open同一个端口。前面已经强调过QModbusServer内部已经管理了串口对象你只管通过setConnectionParameter传参数不要再手动open。如果调试时确实要同时用串口助手看数据可以打开虚拟串口那条通道或者在程序里加个“只监听不占用”的调试模式这个方法比较复杂实际意义不大实在不行就买两个USB转串口模块。4.6 大端小端数据错乱Modbus寄存器是16位的一个寄存器能存0~65535。如果你的数值超过这个范围比如32位浮点数、32位整数Modbus RTU协议标准做法是用两个连续的寄存器拼起来。这里有个行业习惯差异组合方式高寄存器地址低寄存器地址常见厂家Big-Endian (AB CD)存高16位存低16位西门子、施耐德Little-Endian (CD AB)存低16位存高16位台达、部分国产仪表用Qt的QModbusServer读写时它只认“一个寄存器一个quint16”不帮你做组合。所以你要写32位浮点数得自己在代码里拆寄存器。比如void writeFloatToRegisters(float value, int addrLow, int addrHigh) { quint32 bits; memcpy(bits, value, 4); quint16 high bits 16; quint16 low bits 0xFFFF; QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, addrLow, 2); unit.setValue(0, low); // 低地址寄存器放低16位Little-Endian unit.setValue(1, high); modbusServer-setData(unit); }这个是Little-Endian的写法如果对方是大端把high和low对调就行。调试时先用32位整数0x12345678写进去主机读出来看是12 34 56 78还是78 56 34 12一下就判断出字节序了。5. 进阶从纯工具到灵活框架写完一个能跑的slave程序还远远不够。真正到项目里从机程序往往要承担更复杂的任务——比如模拟一整台设备的状态机。一个典型的做法是用QTimer周期扫描一个“设备状态映射表”把模拟的温度、压力、转速等值实时更新到对应寄存器地址段同时监听某些特殊寄存器比如“启动命令寄存器”主机往里写1时触发你定义的“设备启动”逻辑。这就是一个简易的设备仿真器比单纯用modbus poll读固定的假数据有意义得多。我建议把代码里写死的寄存器地址全部提取成配置文件用最土的INI或JSON格式存起来{ device_name: 模拟温控器, holding_register_start: 0, holding_register_count: 100, coil_start: 0, coil_count: 16, initial_values: { 1: 2500, 2: 3200 } }启动时用这个文件动态生成所有数据区域。这样在给不同设备写演示程序时改配置文件不用改代码现场调起参数也方便。我从实际项目中体会从机程序的价值绝大部分在寄存器地址规划上协议本身是标准化的怎么把设备的数据模型映射到寄存器上才是真正体现思路的地方。另外一个值得优化的方向是同时支持RTU和TCP。代码结构相似只要把QModbusRtuSerialSlave换成QModbusTcpServer设置serverAddress从1改为任意地址再指定一个监听端口默认502其他逻辑基本复用。测试时常用一个RTU从机跑串口、一个TCP从机跑网络两边数据一起看排查现场问题会轻松很多。唯一的坑是如果你在本机同时跑modbus poll和TCP从机502端口可能被别的服务占用注意换一个空闲端口测试。6. 写在最后的几个实用心得我在实际项目里反复调这段代码积累了几条不一定写进文档、但非常救命的体会分享给大家。第一串口程序不要一上来就追求“完美架构”。很多朋友写Qt程序喜欢上来就拆一堆类、搞一堆信号槽、用MVVM写到后面都忘了要调协议。先从单个cpp文件把功能跑通确认串口通、寄存器读写没问题再考虑重构。调试类的工具能跑比好看重要得多。第二虚拟串口是联调神器。电脑上装一个VSPD之类工具创造一对虚拟串口自己和自己互通。Qt从机程序打开其中一个modbus poll连另一个完全不用接硬件就能验证协议和程序逻辑。等你验证完了再拿USB转串口接真实设备问题基本剩下物理层了。第三modbus poll的“轮询模式”值得细看。它在连接正常时可以设一个几百毫秒的周期循环读保持寄存器你会看到UI上寄存器值一直在刷新。这个功能除了测试还能当简易的数据采集器用——把从机程序的模拟数据存到日志里验证主机逻辑是否正常。第四实在排查不出问题就把Qt程序换成串口助手手动发一帧RTU请求。比如手动发01 03 00 00 00 01 84 0A地址1、功能码3、从0读1个寄存器看从机返回什么。如果返回了正常响应说明从机侧没问题问题在主机那边如果没响应重点查Qt的从机地址和寄存器配置。我自己用这套方案给至少三个项目做过Modbus从机仿真稳定跑过72小时不丢帧。你按上面步骤搭出来的程序配合虚拟串口和modbus poll完全可以直接拿到现场当半成品设备用。如果后面想扩展成多从机模拟最省事的办法是跑多个进程实例每个实例打开一个虚拟串口对比在单进程里搞多线程省心很多——毕竟Qt的串口模块在Windows和Linux上对多线程访问的处理都不算特别友好多进程隔离反而干净。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价