【网络通讯】Qt中使用Modbus Tcp协议(附Demo)
Modbus Tcp通讯
- 1. 什么是Modbus Tcp协议?
- 1.1 Modbus Tcp
- 1.2 其他Modbus协议变体
- 1.3 Modbus Tcp应用场景
- 2. Modbus Tcp协议原理解析
- 2.1 通讯模型
- 2.2 报文结构
- 2.3 常用功能码
- 2.4 注意事项
- 3. Qt实现Modbus Tcp客户端
- 4. 扩展:Qt跨线程问题
1. 什么是Modbus Tcp协议?
在工业自动化领域,设备之间的通信与数据交换至关重要。Modbus协议作为一种经典的通信协议,因其简单、开放和易于实现的特点,被广泛应用于各种工业设备之间的数据传输。而Modbus TCP则是Modbus协议的一个重要变体,基于以太网实现了更高效的通信。
Modbus协议因此也常常被工业领域用来作为设备与设备之间的主流通讯协议。
1.1 Modbus Tcp
Modbus TCP(也称为Modbus TCP/IP)是Modbus协议的一个变种,基于TCP/IP协议栈在以太网上进行通信。它继承了Modbus协议的所有优点,同时利用以太网的高带宽和稳定性,提供更快速和可靠的数据传输。
1.2 其他Modbus协议变体
Modbus协议有多种变体,用于不同的场景,包括:
- Modbus RTU: 基于串行通信(如RS-485),数据传输效率较低,适用于点对点或点对多点的简单网络;
- Modbus ASCII: 同样基于串行通信,但使用ASCII码表示数据,便于调试,但效率更低;
- Modbus TCP: 基于以太网,支持更高的数据传输速率和更复杂的网络拓扑;
与传统的Modbus RTU相比,Modbus TCP消除了串行通信的限制,支持更大的网络规模和更高的数据传输速度,适用于现代工业自动化系统。
1.3 Modbus Tcp应用场景
- 工业自动化控制系统
- 能源管理系统
- 智能制造与工业物联网(IIoT)
- 建筑自动化系统
2. Modbus Tcp协议原理解析
2.1 通讯模型
Modbus TCP采用客户端-服务器(Master-Slave)的通信模型,也就是主从模型。客户端发起请求,服务器响应请求。一个网络中可以有多个客户端和服务器,服务器通常是PLC、传感器、仪表等工业设备,客户端多是PC或者具体的一个工控机程序,少量分布于PLC与PLC的通讯。
Modbus TCP基于TCP协议,采用面向连接的通信方式。通信过程包括建立连接、数据传输和断开连接(即也要同TCP/IP,执行三次握手,4次挥手)。数据传输过程中,客户端和服务器通过TCP端口(默认502)进行数据交换。
2.2 报文结构
Modbus TCP的报文由两部分组成:
- MBAP头(Modbus Application Protocol Header)
- PDU(Protocol Data Unit)
MBAP头包含7个字节,用于管理传输层的数据。具体结构如下:
- 事务标识符(Transaction Identifier): 2字节,用于匹配请求和响应。
- 协议标识符(Protocol Identifier): 2字节,固定为0,表示Modbus协议。
- 长度字段(Length Field): 2字节,表示后续PDU的长度。
- 单元标识符(Unit Identifier): 1字节,用于识别设备,特别在串行通信中用于区分不同的从设备,即目标从站的地址(Slave ID)。
PDU包含功能码和数据部分:
- 功能码(Function Code): 1占1个字节,用于指示请求的具体操作类型,如读线圈、写寄存器等。
- 数据(Data): 长度不定,根据功能码的不同而有所区别,包含要操作的数据地址和数据值等。
2.3 常用功能码
MODBUS TCP协议定义了多种功能码,用于实现不同的操作。以下是一些常用的功能码及其说明:
- 0x01: 读线圈状态(Read Coils),用于从从站中读取一系列线圈的当前状态。
- 0x02: 读离散输入状态(Read Discrete Inputs),用于从从站中读取一系列离散输入的当前状态。
- 0x03: 读保持寄存器(Read Holding Registers),用于从从站中读取一系列保持寄存器的值。
- 0x04: 读输入寄存器(Read Input Registers),用于从从站中读取一系列输入寄存器的值。
- 0x05: 写单个线圈(Write Single Coil),用于将从站中的一个线圈设置为ON或OFF状态。
- 0x06: 写单个保持寄存器(Write Single Register),用于将单个保持寄存器的值写入从站。
- 0x10: 写多个保持寄存器(Write Multiple Registers),用于将一系列保持寄存器的值写入从站。
解释一下离散和保持这两个名称在Modbus中的定义:
首先,Modbus协议为了清晰地管理不同类型的数据,定义了一个包含4种数据模型的数据模型。这四种类型是根据数据的物理特性(是开关量还是模拟量)和访问权限(只读还是可读写)来划分的,如下表格:
数据类型 | 物理意义 | 访问权限 | 功能码(举例) |
---|---|---|---|
线圈 (Coils) | 开关量输出,如:继电器状态、LED灯开关 | 读/写 | 读:0x01 |
离散输入 (Discrete Inputs) | 开关量输入,如:按钮状态、开关状态、报警触点 | 只读 | 读:0x02 |
保持寄存器 (Holding Registers) | 模拟量输出/设置值,如:目标速度、温度设定值、PID参数 | 读/写 | 读:0x03 |
输入寄存器 (Input Registers) | 模拟量输入/测量值,如:当前温度、实际压力、流量读数 | 只读 | 读:0x04 |
“离散”指的是这种数据只有两种明确的状态,非此即彼,没有中间状态。最常见的例子就是:开/关 (On/Off)、是/否 (Yes/No)、真/假 (True/False)、1/0。在电气上,它通常对应一个数字量信号,比如一个开关的通断、一个按钮是否被按下、一个报警触点的状态。
即:离散输入是一类只读的、代表外部开关量信号的数据点。它们反映了现场设备的状态。
“保持”意味着这些数据在设备断电或重启后通常会被保留(非易失性存储)。更重要的是,它代表了这是一种可以被保持或维持的设置值或控制目标。最关键的特性是,这些数据是可读且可写的。
即:保持寄存器是一类可读写的、用于存储系统设置点、控制参数或过程值的16位数据。
为了更直观地理解,我们可以做一个比喻:
离散输入就像你家的门铃按钮。你(主站)只能读取(听) 它是否响了(1或0),但你不能通过家里的电话(Modbus网络)去远程“按响”它。它的状态完全由门口的客人(外部传感器)决定。
保持寄存器就像你家的空调遥控器。你既可以读取当前设定的温度,也可以远程写入一个新的温度设定值。
简单说一下主从站连接过程:
- 客户端(Master)使用TCP协议与服务器(Slave)建立连接,通常使用IANA分配的Modbus TCP端口号502。
- 客户端构造包含MBAP头和PDU的MODBUS TCP报文,并通过TCP连接发送给服务器。
- 服务器接收到请求后,根据请求的功能码和数据执行相应的操作,并构造响应报文发送给客户端。
- 响应报文同样包含MBAP头和PDU,其中PDU部分包含操作结果或数据。
- 通信任务完成后,客户端可以关闭TCP连接。在某些情况下,连接可能会保持打开状态以进行后续的通信。
3和4其实是一前一后的关系,由于显示美观,我将之分开换行输出。
2.4 注意事项
- 超时管理: 在通信过程中,需要实现超时管理机制,以避免无期限地等待可能不出现的应答。
- 字节序: 在发送和接收数据时,需要注意字节序的问题。不同系统可能采用不同的字节序(大端或小端),因此在跨系统通信时需要进行字节序的转换。
- 错误处理: 服务器在无法执行请求的操作时,会返回异常响应。客户端需要能够解析异常响应,并根据异常码进行相应的错误处理。
- MODBUS TCP 是一个为封闭环境设计的“明文传输指令集”,而非一个安全的通信协议。(错误处理:服务器在无法执行请求的操作时,会返回异常响应。客户端需要能够解析异常响应,并根据异常码进行相应的错误处理。)
3. Qt实现Modbus Tcp客户端
// .pro文件 新增两个模块
QT += serialport serialbus
Qt版本:Qt 5.15.2 Based on Qt 6.4.3 (MSVC 2019, x86_64)
系统版本:windows 11 家庭版 / windows 11 专业版 / windows 11 均可
系统类型:64位操作系统,基于x64的处理器
软件架构:QQuick + C++ 混合开发
下面的类,仅供参考:(由于项目原因,我会忽略部分功能实现,仅基础Modbus Tcp功能展示,完整的请移至末尾demo下载查看)
// KModbusTcpClient.h#include <QModbusTcpClient>
#include <QModbusDataUnit>
#include <QModbusReply>
#include <QEventLoop>
#include <QTimer>
#include <QVariant>
#include <QMap>#include <KDefinition.h> // 注意,此为元对象的封装class KModbusTcpClient : public QObject
{Q_OBJECT
public:static KModbusTcpClient& ins() { static KModbusTcpClient modbus; return modbus; }
public slot:bool connectDevice(const QString& host, int port = 502);void disconnectDevice();// 获取连接状态QModbusDevice::State connectionState() const;bool isconnected() const;// 读取单个值QVariant read(int address);// 写入单个值bool write(int address, const QVariant& value);// 批量读取QMap<int, QVariant> readMultiple(const QVector<int>& addresses);// 批量写入bool writeMultiple(const QMap<int, QVariant>& addressValueMap);/* 线程安全函数(用于跨线程使用) */void threadRead(int address);void threadWrite(int address, QVariant value);signals:void errorOccurred(const QString& error);void valueChanged(int address, const QVariant& value);void connectionStateChanged(QModbusDevice::State state);void connected();void disconnected();private:// 地址解析AddressInfo parseAddress(int address) const;// 等待操作完成bool waitForReply(QModbusReply* reply, int timeout = 1000);// 监控读取void readForMonitoring(int address);// 处理连接状态变化void handleStateChanged(QModbusDevice::State state);explicit KModbusTcpClient(QObject* parent = nullptr);~KmodbusTcpClient();
private:enum AddressType { Coil, HoldingRegister };struct AddressInfo {AddressType type;int modbusAddress; };struct MonitorInfo {int intervalMs;QTimer* timer;QVariant lastValue;};QModbusTcpClient* m_client;QMap<int, MonitorInfo> m_monitoredAddresses;mutable QMutex m_mutex;
}
// KModbusTcpClent.cpp/* 说明: cpp中出现的****Set()等在头文件没出现的接口都是元对象封装中的声明的,具体可查看demo */
#include <KModbusTcpClient.h>
#include "KUserSetting.h"
#include "ThreadUtils.h"
#include <QtConcurrent>KModbusTcpClient::KModbusTcpClient(QObject *parent)
: QObject(parent), m_client(new QModbusTcpClient(this))
{m_client->setTimeout(1000);m_client->setNumberOfRetries(3);connect(this, &KModbusTcpClient::connected, []() {KModbusTcpClient::ins().isConnectedSet(true);KModbusTcpClient::ins().showResponseSet("Connected to Modbus Tcp");});connect(this, &KModbusTcpClient::disconnected, []() {KModbusTcpClient::ins().isConnectedSet(false);KModbusTcpClient::ins().showResponseSet("modbusTcp break connect!");});
}KModbusTcpClient::~KModbusTcpClient()
{stopAllMonitoring();disconnectDevice();
}KModbusTcpClient::AddressInfo KModbusTcpClient::parseAddress(int address) const
{AddressInfo info;// 根据您的具体需求(项目需求):// 400、401、450、451 等地址是线圈// 1000、1002、1004 等地址是保持寄存器// 线圈地址范围:400-499if (address >= 400 && address <= 499) {info.type = Coil;info.modbusAddress = address /*- 400*/; // 400 → 0, 401 → 1, etc.}// 保持寄存器地址范围:1000-1999else if (address >= 1000 && address <= 1999) {info.type = HoldingRegister;info.modbusAddress = address/* - 1000*/; // 1000 → 0, 1002 → 2, etc.}else {//throw std::invalid_argument("Invalid Modbus address");}return info;
}bool KModbusTcpClient::waitForReply(QModbusReply *reply, int timeout)
{QEventLoop loop;QTimer timer;timer.setSingleShot(true);timer.start(timeout);connect(reply, &QModbusReply::finished, &loop, &QEventLoop::quit);connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);loop.exec();return timer.isActive(); // 如果超时则返回false
}void KModbusTcpClient::readForMonitoring(int address)
{// 执行读取操作QVariant value = read(address);QMutexLocker locker(&m_mutex);if (m_monitoredAddresses.contains(address)) {MonitorInfo &info = m_monitoredAddresses[address];// 检查值是否变化if (info.lastValue != value) {info.lastValue = value;locker.unlock(); // 解锁后再发射信号,避免死锁emit valueChanged(address, value);} else {info.lastValue = value;}}
}void KModbusTcpClient::handleStateChanged(QModbusDevice::State state)
{// 转发状态变化信号emit connectionStateChanged(state);// 发出更具体的连接/断开信号if (state == QModbusDevice::ConnectedState) {emit connected();} else if (state == QModbusDevice::UnconnectedState) {emit disconnected();}
}void KModbusTcpClient::threadRead(int address)
{if (address == 450) {plcGetMaterialFinishSet(read(address).toBool());//qInfo() << "450:" << plcGetMaterialFinish();}else if (address == 451) {plcDownMaterialSaftPosSet(read(address).toBool());//qInfo() << "451:" << plcDownMaterialSaftPos();}
}void KModbusTcpClient::threadWrite(int address, QVariant value)
{write(address, value);
}QMap<int, QVariant> KModbusTcpClient::readMultiple(const QVector<int> &addresses)
{QMap<int, QVariant> results;for (int addr : addresses) {results[addr] = read(addr);}return results;
}bool KModbusTcpClient::writeMultiple(const QMap<int, QVariant> &addressValueMap)
{bool success = true;for (auto it = addressValueMap.begin(); it != addressValueMap.end(); ++it) {if (!write(it.key(), it.value())) {success = false;}}return success;
}
bool KModbusTcpClient::write(int address, const QVariant &value)
{AddressInfo info = parseAddress(address);QModbusDataUnit unit;if (info.type == Coil) {unit = QModbusDataUnit(QModbusDataUnit::Coils, info.modbusAddress, 1);unit.setValue(0, value.toBool() ? 0xFF00 : 0x0000);} else {unit = QModbusDataUnit(QModbusDataUnit::HoldingRegisters, info.modbusAddress, 1);unit.setValue(0, value.toUInt());}if (QModbusReply *reply = m_client->sendWriteRequest(unit, 1)) {if (waitForReply(reply)) {if (reply->error() == QModbusDevice::NoError) {return true;} else {emit errorOccurred(reply->errorString());}}reply->deleteLater();}return false;
}QVariant KModbusTcpClient::read(int address)
{AddressInfo info = parseAddress(address);QModbusDataUnit request;if (info.type == Coil) {request = QModbusDataUnit(QModbusDataUnit::Coils, info.modbusAddress, 1);} else {request = QModbusDataUnit(QModbusDataUnit::HoldingRegisters, info.modbusAddress, 1);}if (QModbusReply *reply = m_client->sendReadRequest(request, 1)) {if (waitForReply(reply)) {if (reply->error() == QModbusDevice::NoError) {const QModbusDataUnit unit = reply->result();if (unit.valueCount() > 0) {return info.type == Coil ? QVariant(unit.value(0) != 0): QVariant(unit.value(0));}} else {emit errorOccurred(reply->errorString());KModbusTcpClient::ins().showResponseSet(reply->errorString());}}reply->deleteLater();}return QVariant();
}
QModbusDevice::State KModbusTcpClient::connectionState() const
{return m_client->state();
}bool KModbusTcpClient::isconnected() const
{return m_client->state() == QModbusDevice::ConnectedState;
}
bool KModbusTcpClient::connectDevice(const QString &host, int port)
{if (m_client->state() == QModbusDevice::ConnectedState)return true;m_client->setTimeout(1000);m_client->setNumberOfRetries(3);m_client->setConnectionParameter(QModbusDevice::NetworkAddressParameter, host);m_client->setConnectionParameter(QModbusDevice::NetworkPortParameter, port);return m_client->connectDevice();
}void KModbusTcpClient::disconnectDevice()
{if (m_client->state() == QModbusDevice::ConnectedState)m_client->disconnectDevice();if (m_client->state() == QModbusDevice::UnconnectedState) {KModbusTcpClient::ins().isConnectedSet(false);KModbusTcpClient::ins().showResponseSet("modbusTcp break connect!");}
}
关于线程安全函数的意义:
QTcpSocket无法在其他线程直接调用的根本原因在于 Qt 的对象线程亲和性规则和事件驱动模型。直接跨线程调用违反了线程安全假设,破坏了事件处理的机制。 信号和槽(特别是 Qt::QueuedConnection)以及 QMetaObject::invokeMethod是 Qt 提供的线程间通信的安全桥梁。它们通过将操作请求或事件通知封装成消息并投递到目标对象所属线程的事件队列中,由该线程的事件循环在正确的上下文中执行实际操作,从而保证了线程安全和事件处理的正确性。理解并遵循这个模型是编写健壮、高效的 Qt 多线程网络应用的关键。
Demo下载: https://download.csdn.net/download/m0_43458204/91988995
由于平台限制,无法上传免费资源,如有需要者,可联系:xuyi970821(微信)
4. 扩展:Qt跨线程问题
关于 Qt 框架中对象线程亲和性和线程安全的核心问题。
QTcpSocket(以及所有 QObject的子类)的行为严格遵循 Qt 的事件驱动模型和线程规则。你观察到的现象——不能在其他线程直接操作在 A 线程(无论是主线程还是其他子线程)创建的 QTcpSocket对象,而必须通过信号和槽机制——正是 Qt 为了保证线程安全和事件循环正常工作而设计的机制。其原理可以详细解释如下:
核心概念:线程亲和性
- 每个 QObject实例(包括 QTcpSocket)在创建时都会被关联到一个特定的线程,这个线程称为该对象的所属线程或宿主线程。
- 这个关联关系在对象创建时确定(由其父对象或创建它的线程决定),之后通常不能随意更改(除非显式调用
moveToThread()
)。 - 对象的线程亲和性决定了它的事件处理和信号槽连接的执行上下文
因为所有Qt的类都是继承自QObject,包括QTcpSocket/QModbusTcpClient,所以都遵循Qt的基本规则,那为什么要有线程亲和性呢?
- 事件循环: Qt 的核心是事件循环。每个拥有事件循环的线程(通常通过 QThread::exec()启动)都有一个独立的事件队列。
- 事件处理: 对象的事件(如定时器事件、网络事件 QSocketNotifier、自定义事件等)只会在其所属线程的事件循环中被处理。对于 QTcpSocket,当底层网络有数据到达或状态改变时,操作系统会通知 Qt,Qt 会生成一个网络事件并放入该 socket 所属线程的事件队列。只有在该线程的事件循环处理到这个事件时,才会触发相应的 readyRead(), connected(), disconnected(), errorOccurred()等信号。
- 线程安全: 强制要求对象在其所属线程中被访问,是 Qt 避免多线程环境下对对象内部状态进行不加锁的并发访问的主要机制。直接跨线程访问对象成员函数是危险的。
了解了Qt通讯的底层逻辑之后,我们回到刚才的问题,为什么不能直接跨线程调用,而是要通过信号和槽:
1、内部状态的非线程安全:
- QTcpSocket内部维护着复杂的状态(连接状态、读/写缓冲区、错误信息、底层 socket 描述符等)。它的成员函数(如 connectToHost(), write(), read(), close())在访问和修改这些状态时,默认假设它们只在所属线程中被调用。
- 如果从另一个线程直接调用这些函数,就会导致两个线程同时访问和修改同一个对象的状态,而没有任何同步机制(如互斥锁)。这会导致竞态条件,结果是未定义的,最常见的就是程序崩溃(访问无效内存、破坏数据结构等)。
2、事件循环依赖:
- 如前所述,网络事件(数据到达、连接建立/断开)的处理依赖于对象所属线程的事件循环。如果在非所属线程调用 read(),即使有数据到达,该线程的事件循环也不会处理这个 socket 的事件,因此 read()可能读不到数据或行为异常。
- 像 connectToHost()这样的异步操作,会启动一个连接过程,其结果(成功或失败)是通过事件通知的。如果调用线程不是所属线程,这个通知事件会被放入所属线程的队列,调用线程无法直接感知或处理这个结果。
而信号和槽机制能解决这个问题的根本是:
1、线程安全的通信机制:
- Qt 的信号和槽机制是设计用来安全地进行跨线程通信的核心工具。
- 当你将一个信号连接到一个槽函数,并且这两个对象位于不同线程时(或者使用 Qt::QueuedConnection连接方式),Qt 会自动处理跨线程调用。
2、Qt::QueuedConnection(队列连接):
- 这是跨线程信号槽连接的默认行为(当信号发射线程和槽对象所属线程不同时)。
- 原理: 当信号在 A 线程(例如工作线程)发射时:Qt 不会直接在 A 线程调用槽函数,而是将一个包含了信号参数副本的“调用事件” 放入槽对象所属线程(B 线程)的事件队列。B 线程的事件循环在运行到该事件时,会在 B 线程的上下文中调用槽函数。
3、效果:
槽函数最终是在槽对象所属的线程(B 线程)中被执行的。对于 QTcpSocket对象来说,这意味着对它的操作(槽函数调用)最终会发生在它自己的所属线程中。
4、QMetaObject::invokeMethod:这是另一种实现跨线程调用的方式,本质与队列连接的信号槽相同。但是我没用过这种方式,我感觉不太好用,不如信号和槽的简单方便。
当我们知道了问题、原理,所以解决的思路就很清楚了:
- 设计: 让 QTcpSocket对象在其所属线程中完成所有操作。其他线程不直接持有或操作该 socket 对象;
- 绑定: 在调用线程中创建信号,然后通过
QObject::connect()
函数链接到创建的线程中去,通过信号和槽的通讯去调用。 - 其他线程 -> Socket 线程: 当其他线程需要让 socket 执行操作(如连接、发送数据、断开)
- Socket 线程 -> 其他线程: 当 socket 有数据到达或状态变化需要通知其他线程
本文所有仅代表博主个人理解,如有问题,欢迎沟通。