Qt实现Modbus RTU串口从机:从原理到联调实战指南
2026/9/8 6:42:43 网站建设 项目流程

简介:面向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周期性把最新数据写进QModbusServer

void 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构造函数的第一个参数换成CoilsInputRegisters

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当主机去连这个从机程序,如果一直超时,我的排查顺序固定是这样的:

  1. 串口号对不对:设备管理器里看是COM3还是COM9,程序里下拉框要选对。USB转串口设备拔插一次端口号可能就变了。
  2. 物理接线:如果是TTL电平直接连,注意TXD/RXD要交叉;如果是RS232/RS485,检查A/B线是不是接反了。RS485是差分信号,A接A、B接B,反了要么收不到要么乱码。
  3. 串口参数一致:主机和从机的波特率、数据位、校验位、停止位必须完全一致。最容易出错的是校验位,主机那边默认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原始字节

怀疑协议层面有问题时,不要猜,直接把串口数据抓出来看。这里有个非常好用的调试方法:

  1. 在PC上用虚拟串口软件(比如VSPD)创建一对虚拟串口,COM3和COM4是互通的。
  2. Qt从机程序打开COM3,modbus poll选COM4,两个连起来测试。
  3. 再开一个串口调试助手,想办法把真实串口或者虚拟串口的字节流导出来看。

不过说实话,最方便的还是在调试助手里把收到的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了一个QSerialPortopen同一个端口。前面已经强调过,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上对多线程访问的处理都不算特别友好,多进程隔离反而干净。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询