Qt的Modbus模块自带了标准的Modbus服务器、客户端例程。本文以该例程源码为例,对Qt的用法和技巧进行说明。
1. 例程项目结构、功能与UI界面
1.1 项目结构
Qt的Modbus服务器例程可以从Qt Creator的示例界面打开,本文以Modbus Server example为例。打开示例后在Debug界面选择运行平台后,即可编译与运行例程。项目结构如下所示,其中,CMakeLists.txt
是CMake编译文件,main.cpp,mainwindow.cpp,settingsdialog.cpp
分别对应主程序、主界面与配置界面程序。两个ui文件是对应的ui界面。
1.2 功能界面
本例程用于实现Modbus RTU和Modbus TCP服务器,默认可设置线圈、离散输入、输入寄存器和保持寄存器各10个,可以实现Modbus服务器数据读、写操作,可以模拟服务器繁忙状态。例程的UI界面分为Mainwindow主窗口和settingsdialog配置对话框两个窗口,界面示意如下。
1.3 控件命名规律
这里需要注意的是,coils和Descrete Inputs使用了QCheckBox组件,控件名称依次为coils_0
~coils_9
和disc_0
~disc_9
,如何寄存器和保持寄存器使用QLineEdit
,控件名称依次为inReg_0
~inReg_9
和holdReg_0
~holdReg_9
,这样有规律的命名在后续使用正则表达式查询与更新控件操作变得更简洁。
1.4 UI界面的Action Editor
在UI界面编辑器的下方,有Action Editor和SIgnals and Slots Editor编辑器,分别用来编辑UI菜单与按钮动作,以及与界面有关的信号和槽。为了实现UI文件和编程语言解耦(UI界面可以导出为独立文件,通过pyQt也可以供python语言使用),没有在UI文件中编辑信号和槽,而只编辑了相关动作。
2. 程序概述
例程主要程序部分由main.cpp
,mainwindow.cpp
和settingsdialog.cpp
组成。
2.1 主程序
main.cpp
程序是标准的Qt包含UI界面的主程序,只是声明了MainWindow对象并开始了程序循环。
//main.cpp
// Copyright (C) 2017 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#include "mainwindow.h"
#include <QApplication>
#include <QLoggingCategory>
int main(int argc, char *argv[])
{
// Uncomment the following line to enable logging
// QLoggingCategory::setFilterRules(QStringLiteral("qt.modbus* = true"));
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
2.2 主窗口头文件
主窗口头文件mainwindow.h
中,包含对本项目主窗口类内容的声明。
//mainwindow.h
// Copyright (C) 2017 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QButtonGroup>
#include <QMainWindow>
#include <QModbusServer>
//这里用到了 QT_BEGIN_NAMESPACE 宏,表示下面要使用的QLineEdit类和Ui类
//属于Qt的命名空间
QT_BEGIN_NAMESPACE
class QLineEdit;
namespace Ui {
class MainWindow;
class SettingsDialog;
}
QT_END_NAMESPACE
class SettingsDialog;
class MainWindow : public QMainWindow
{
//Q_OBJECT宏是Qt里面最重要的宏,该宏包含了Qt里信号和槽的机制
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
//Q_SLOTS宏用来声明Qt槽
private Q_SLOTS:
void onConnectButtonClicked();
void onStateChanged(int state);
void coilChanged(int id);
void discreteInputChanged(int id);
void bitChanged(int id, QModbusDataUnit::RegisterType table, bool value);
void setRegister(const QString &value);
void updateWidgets(QModbusDataUnit::RegisterType table, int address, int size);
void onCurrentConnectTypeChanged(int);
void handleDeviceError(QModbusDevice::Error newError);
//定义了初始化,配置容器以及容器中各个主要部件的私有成员
private:
void initActions();
void setupDeviceData();
void setupWidgetContainers();
Ui::MainWindow *ui = nullptr;
QModbusServer *modbusDevice = nullptr;
QButtonGroup coilButtons;
QButtonGroup discreteButtons;
//这里用到了Qt的容器类之一QHash,后面还会用到另一个QMap
//这两个容器都是用来生成键值对,QHash的效率高于QMap
QHash<QString, QLineEdit *> registers;
SettingsDialog *m_settingsDialog = nullptr;
};
#endif // MAINWINDOW_H
2.3 设置窗口头文件
//settingsdialog.h
// Copyright (C) 2017 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#ifndef SETTINGSDIALOG_H
#define SETTINGSDIALOG_H
#include <QtSerialBus/qtserialbusglobal.h>
#include <QDialog>
//QT_CONFIG宏用来确定Qt是否已经包含(安装)了某些库或组件
//这里如果Qt没有安装modbus_serialport则不显示下面内容
#if QT_CONFIG(modbus_serialport)
#include <QSerialPort>
#endif
QT_BEGIN_NAMESPACE
namespace Ui {
class SettingsDialog;
}
QT_END_NAMESPACE
class SettingsDialog : public QDialog
{
Q_OBJECT
public:
// 定义了一个Settings结构体用于保存串口通讯参数
struct Settings {
#if QT_CONFIG(modbus_serialport)
int parity = QSerialPort::EvenParity;
int baud = QSerialPort::Baud19200;
int dataBits = QSerialPort::Data8;
int stopBits = QSerialPort::OneStop;
#endif
};
//这里使用了显式构造函数关键字 explicit,这个关键字保证在新建SettingsDialog
//对话框的时候必须包含parent组件。
explicit SettingsDialog(QWidget *parent = nullptr);
~SettingsDialog();
Settings settings() const;
private:
Settings m_settings;
Ui::SettingsDialog *ui;
};
#endif // SETTINGSDIALOG_H
2.4 头文件里的宏和构造函数
头文件是对类或组件包含与引用内容的综合,在Qt中,使用了部分预定义宏来实现一定目标。在前面的例程中可以看常用的宏包括:
- QT_BEGIN_NAMESPACE 、QT_END_NAMESPACE
- QT_CONFIG(modbus_serialport)
- Q_OBJECT
- Q_SLOTS
- Q_SIGNALS(本例未用到)
此外,由于SettingsDialog需要指定parent组件,因此声明了一个显性的构造函数,当一个类包含显性构造函数的关键词时,就不能隐性声明。
3.程序主体
例程的主体包含在 mainwindow.cpp和settingsdialog.cpp中,其中settingsdialog.cpp通过一个简单的对话框实现参数配置。结构较为简单,而mainwindow.cpp则实现了本项目程序的其他绝大部分功能。
3.1 对话框程序
3.1.1 源码注释
//settingsdialog.cpp
// Copyright (C) 2017 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#include "settingsdialog.h"
#include "ui_settingsdialog.h"
//对话框类
//类的构造函数(初始化对象):继承类,内部成员初始化
//后面的部分也可以放在类构造函数内部再初始化,效果和这里是相同的
SettingsDialog::SettingsDialog(QWidget *parent) :
QDialog(parent),
ui(new Ui::SettingsDialog)
{
ui->setupUi(this);
ui->parityCombo->setCurrentIndex(1);
#if QT_CONFIG(modbus_serialport)
//初始化时,将m_settings中的值写入ui界面对应部分
ui->baudCombo->setCurrentText(QString::number(m_settings.baud));
ui->dataBitsCombo->setCurrentText(QString::number(m_settings.dataBits));
ui->stopBitsCombo->setCurrentText(QString::number(m_settings.stopBits));
#endif
//这里使用了lambda函数连接信号槽。这是典型的lambda函数的用法
//点击apply按钮后,从UI界面组件里取值并存放到m_settings中
//m_settings是Setting类型的结构体
connect(ui->applyButton, &QPushButton::clicked, [this]() {
#if QT_CONFIG(modbus_serialport)
m_settings.parity = ui->parityCombo->currentIndex();
if (m_settings.parity > 0)
m_settings.parity++;
m_settings.baud = ui->baudCombo->currentText().toInt();
m_settings.dataBits = ui->dataBitsCombo->currentText().toInt();
m_settings.stopBits = ui->stopBitsCombo->currentText().toInt();
#endif
hide();
});
}
SettingsDialog::~SettingsDialog()
{
delete ui;
}
//该类提供了一个settings()方法用于返回
SettingsDialog::Settings SettingsDialog::settings() const
{
return m_settings;
}
3.1.2 lambda函数
在 C++ 11 和更高版本中,Lambda 表达式(通常称为 Lambda,或者匿名函数)是一种在被调用的位置或作为参数传递给函数的位置定义匿名函数对象(闭包)的简便方法。 Lambda 通常用于封装传递给算法或异步函数的少量代码行。在Visual Studio官方文档中有一个将lambda函数作为sort函数第三个参数的例子。
#include <algorithm>
#include <cmath>
void abssort(float* x, unsigned n) {
std::sort(x, x + n,
// Lambda expression begins
[](float a, float b) {
return (std::abs(a) < std::abs(b));
} // end of lambda expression
);
}
3.1.3 组件内容的读取和写入
代码 ui->baudCombo->currentText().toInt();
从一个ComboBox中读取内容并转换为int类型。
代码ui->baudCombo->setCurrentText(QString::number(m_settings.baud))
向ComboBox中写入数据,QString::number
函数将一个字符串型的数字转换为数字。
3.2 主窗口程序
3.2.1 构造与析构函数
// Copyright (C) 2017 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#include "mainwindow.h"
#include "settingsdialog.h"
#include "ui_mainwindow.h"
//调用的Qt函数
#include <QModbusRtuSerialServer>
#include <QModbusTcpServer>
#include <QRegularExpression>
#include <QRegularExpressionValidator>
#include <QStatusBar>
#include <QUrl>
//定义枚举类型
enum ModbusConnection {
Serial,
Tcp
};
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
setupWidgetContainers();
#if QT_CONFIG(modbus_serialport)
ui->connectType->setCurrentIndex(0);
onCurrentConnectTypeChanged(0);
#else
// lock out the serial port option
ui->connectType->setCurrentIndex(1);
onCurrentConnectTypeChanged(1);
ui->connectType->setEnabled(false);
#endif
//新建一个设置对话框
m_settingsDialog = new SettingsDialog(this);
//初始化
initActions();
}
//析构函数,断开连接并删除设备
MainWindow::~MainWindow()
{
if (modbusDevice)
modbusDevice->disconnectDevice();
delete modbusDevice;
delete ui;
}
3.2.2 初始化函数
初始化函数主要设置UI界面组件的状态,并建立必要的信号槽之间的连接,主要包括按钮与菜单项的信号与槽连接。
void MainWindow::initActions()
{
ui->actionConnect->setEnabled(true);
ui->actionDisconnect->setEnabled(false);
ui->actionExit->setEnabled(true);
ui->actionOptions->setEnabled(true);
connect(ui->connectButton, &QPushButton::clicked,
this, &MainWindow::onConnectButtonClicked);
connect(ui->actionConnect, &QAction::triggered,
this, &MainWindow::onConnectButtonClicked);
connect(ui->actionDisconnect, &QAction::triggered,
this, &MainWindow::onConnectButtonClicked);
connect(ui->connectType, &QComboBox::currentIndexChanged,
this, &MainWindow::onCurrentConnectTypeChanged);
connect(ui->actionExit, &QAction::triggered, this, &QMainWindow::close);
connect(ui->actionOptions, &QAction::triggered, m_settingsDialog, &QDialog::show);
}
3.2.3 连接类型变更
void MainWindow::onCurrentConnectTypeChanged(int index)
{
if (modbusDevice) {
modbusDevice->disconnect();
delete modbusDevice;
modbusDevice = nullptr;
}
//auto是现代c++语言的一个代表性的改进,可以自动识别数据类型
//static_cast是一个强制类型转换操作符。强制类型转换,也称为显式转换。
//将index转换为ModbusConnection类型
auto type = static_cast<ModbusConnection>(index);
if (type == Serial) {
#if QT_CONFIG(modbus_serialport)
modbusDevice = new QModbusRtuSerialServer(this);
#endif
} else if (type == Tcp) {
modbusDevice = new QModbusTcpServer(this);
if (ui->portEdit->text().isEmpty())
//QLatin1String是对US-ASCII/Lating-1编码字符串的封装
//可以理解为仅接收ASCII字符串
ui->portEdit->setText(QLatin1String("127.0.0.1:502"));
}
ui->listenOnlyBox->setEnabled(type == Serial);
if (!modbusDevice) {
ui->connectButton->setDisabled(true);
statusBar()->showMessage(tr("Could not create Modbus server."), 5000);
} else {
//QModbusDataUnit是Qt的QMudbus类的核心子类,包括写入,读取,设置寄存器地址等内容
//QModbusDataUnitMap是一个简化的QMap写法,等同于
//QMap<QModbusDataUnit::RegisterType, QModbusDataUnit>
QModbusDataUnitMap reg;
reg.insert(QModbusDataUnit::Coils, { QModbusDataUnit::Coils, 0, 10 });
reg.insert(QModbusDataUnit::DiscreteInputs, { QModbusDataUnit::DiscreteInputs, 0, 10 });
reg.insert(QModbusDataUnit::InputRegisters, { QModbusDataUnit::InputRegisters, 0, 10 });
reg.insert(QModbusDataUnit::HoldingRegisters, { QModbusDataUnit::HoldingRegisters, 0, 10 });
//setMap是QModbusServer的方法,用来将QMap的寄存器地址分配给服务器
modbusDevice->setMap(reg);
//建立QModbusServer和UI界面之间的信号槽连接,其中
//dataWritten是QModbusServer的信号而
//stateChanged和errorOccurred是QModbusServer的父类
//QModbusDevice的信号
connect(modbusDevice, &QModbusServer::dataWritten,
this, &MainWindow::updateWidgets);
connect(modbusDevice, &QModbusServer::stateChanged,
this, &MainWindow::onStateChanged);
connect(modbusDevice, &QModbusServer::errorOccurred,
this, &MainWindow::handleDeviceError);
//又是一个使用lambda函数的示例,同时emit出一个信号
connect(ui->listenOnlyBox, &QCheckBox::toggled, this, [this](bool toggled) {
if (modbusDevice)
modbusDevice->setValue(QModbusServer::ListenOnlyMode, toggled);
});
emit ui->listenOnlyBox->toggled(ui->listenOnlyBox->isChecked());
connect(ui->setBusyBox, &QCheckBox::toggled, this, [this](bool toggled) {
if (modbusDevice)
modbusDevice->setValue(QModbusServer::DeviceBusy, toggled ? 0xffff : 0x0000);
});
emit ui->setBusyBox->toggled(ui->setBusyBox->isChecked());
setupDeviceData();
}
}
这一段程序包括了auto自动类型,QLatin1String类以及QModbus相关类的操作。
也包括了典型的信号槽连接以及Lambda函数和emit信号的方法。
3.2.4 处理设备错误
void MainWindow::handleDeviceError(QModbusDevice::Error newError)
{
if (newError == QModbusDevice::NoError || !modbusDevice)
return;
statusBar()->showMessage(modbusDevice->errorString(), 5000);
}
3.2.5 连接按钮处理函数
void MainWindow::onConnectButtonClicked()
{
//并没有直接连接,而是通过一个intendToConnect变量来尝试连接
bool intendToConnect = (modbusDevice->state() == QModbusDevice::UnconnectedState);
statusBar()->clearMessage();
if (intendToConnect) {
if (static_cast<ModbusConnection>(ui->connectType->currentIndex()) == Serial) {
modbusDevice->setConnectionParameter(QModbusDevice::SerialPortNameParameter,
ui->portEdit->text());
#if QT_CONFIG(modbus_serialport)
modbusDevice->setConnectionParameter(QModbusDevice::SerialParityParameter,
m_settingsDialog->settings().parity);
modbusDevice->setConnectionParameter(QModbusDevice::SerialBaudRateParameter,
m_settingsDialog->settings().baud);
modbusDevice->setConnectionParameter(QModbusDevice::SerialDataBitsParameter,
m_settingsDialog->settings().dataBits);
modbusDevice->setConnectionParameter(QModbusDevice::SerialStopBitsParameter,
m_settingsDialog->settings().stopBits);
#endif
} else {
//使用了QUrl类,提供关于网络URL操作的内容。从fromUserInput返回的
//URL可以自动区分port和host。
const QUrl url = QUrl::fromUserInput(ui->portEdit->text());
modbusDevice->setConnectionParameter(QModbusDevice::NetworkPortParameter, url.port());
modbusDevice->setConnectionParameter(QModbusDevice::NetworkAddressParameter, url.host());
}
modbusDevice->setServerAddress(ui->serverEdit->text().toInt());
if (!modbusDevice->connectDevice()) {
statusBar()->showMessage(tr("Connect failed: ") + modbusDevice->errorString(), 5000);
} else {
ui->actionConnect->setEnabled(false);
ui->actionDisconnect->setEnabled(true);
}
} else {
modbusDevice->disconnectDevice();
ui->actionConnect->setEnabled(true);
ui->actionDisconnect->setEnabled(false);
}
}
3.2.6 状态改变函数
连接状态,线圈状态改变的部分函数。根据选择按钮的状态修改Modbus服务器对应的寄存器值。
void MainWindow::onStateChanged(int state)
{
bool connected = (state != QModbusDevice::UnconnectedState);
ui->actionConnect->setEnabled(!connected);
ui->actionDisconnect->setEnabled(connected);
if (state == QModbusDevice::UnconnectedState)
ui->connectButton->setText(tr("Connect"));
else if (state == QModbusDevice::ConnectedState)
ui->connectButton->setText(tr("Disconnect"));
}
void MainWindow::coilChanged(int id)
{
QAbstractButton *button = coilButtons.button(id);
bitChanged(id, QModbusDataUnit::Coils, button->isChecked());
}
void MainWindow::discreteInputChanged(int id)
{
QAbstractButton *button = discreteButtons.button(id);
bitChanged(id, QModbusDataUnit::DiscreteInputs, button->isChecked());
}
void MainWindow::bitChanged(int id, QModbusDataUnit::RegisterType table, bool value)
{
if (!modbusDevice)
return;
if (!modbusDevice->setData(table, quint16(id), value))
statusBar()->showMessage(tr("Could not set data: ") + modbusDevice->errorString(), 5000);
}
3.2.7 寄存器写入函数
寄存器写入函数使用了一部分技巧,线圈和寄存器的选择都是通过正则表达式查找并根据控件名称,给控件增加了一个叫id的属性,并把控件名称的数字部分赋给id属性。
void MainWindow::setRegister(const QString &value)
{
if (!modbusDevice)
return;
//从寄存器触发控件获取名称赋予objectName
const QString objectName = QObject::sender()->objectName();
//register是定义的QHash表,存储了<QString,QLineEdit*》键值对
//register在setupWidget方法中遍历控件并生成
if (registers.contains(objectName)) {
bool ok = true;
//从触发控件的ID属性中取出id值,并且按照输入或者保持寄存器类型进行赋值
const quint16 id = quint16(QObject::sender()->property("ID").toUInt());
if (objectName.startsWith(QStringLiteral("inReg")))
ok = modbusDevice->setData(QModbusDataUnit::InputRegisters, id, value.toUShort(&ok, 16));
else if (objectName.startsWith(QStringLiteral("holdReg")))
ok = modbusDevice->setData(QModbusDataUnit::HoldingRegisters, id, value.toUShort(&ok, 16));
if (!ok)
statusBar()->showMessage(tr("Could not set register: ") + modbusDevice->errorString(),
5000);
}
}
3.2.8 更新组件函数
线圈coil和保持寄存器holdRegister对客户端来说是可读可写的,在客户端修改寄存器内容后需要更新组件内容以与寄存器内容一致。
void MainWindow::updateWidgets(QModbusDataUnit::RegisterType table, int address, int size)
{
for (int i = 0; i < size; ++i) {
quint16 value;
QString text;
switch (table) {
case QModbusDataUnit::Coils:
modbusDevice->data(QModbusDataUnit::Coils, quint16(address + i), &value);
coilButtons.button(address + i)->setChecked(value);
break;
case QModbusDataUnit::HoldingRegisters:
modbusDevice->data(QModbusDataUnit::HoldingRegisters, quint16(address + i), &value);
registers.value(QStringLiteral("holdReg_%1").arg(address + i))->setText(text
.setNum(value, 16));
break;
default:
break;
}
}
}
3.2.9 配置设备数据
这是一个私有函数。
void MainWindow::setupDeviceData()
{
if (!modbusDevice)
return;
for (quint16 i = 0; i < coilButtons.buttons().count(); ++i)
modbusDevice->setData(QModbusDataUnit::Coils, i, coilButtons.button(i)->isChecked());
for (quint16 i = 0; i < discreteButtons.buttons().count(); ++i) {
modbusDevice->setData(QModbusDataUnit::DiscreteInputs, i,
discreteButtons.button(i)->isChecked());
}
bool ok;
//在使用如下格式的c++的for循环格式遍历Qt容器时,会出现detach的情况
//使用qAsConst方法用来避免这一问题
for (QLineEdit *widget : qAsConst(registers)) {
if (widget->objectName().startsWith(QStringLiteral("inReg"))) {
modbusDevice->setData(QModbusDataUnit::InputRegisters, quint16(widget->property("ID").toUInt()),
widget->text().toUShort(&ok, 16));
} else if (widget->objectName().startsWith(QStringLiteral("holdReg"))) {
modbusDevice->setData(QModbusDataUnit::HoldingRegisters, quint16(widget->property("ID").toUInt()),
widget->text().toUShort(&ok, 16));
}
}
}
3.2.10 配置组件容器
void MainWindow::setupWidgetContainers()
{
coilButtons.setExclusive(false);
discreteButtons.setExclusive(false);
//这里使用了Qt正则表达式来查找诸如 coils_xx类型的内容,并且将数字xx赋值到一个id字段中
QRegularExpression regexp(QStringLiteral("coils_(?<ID>\\d+)"));
//使用findChildren方法查找满足上述正则表达式名称的QCheckBox组件
const QList<QCheckBox *> coils = findChildren<QCheckBox *>(regexp);
for (QCheckBox *cbx : coils)
//在QButtonGroup coilButtons中添加按钮,设置其id及连接按钮动作
coilButtons.addButton(cbx, regexp.match(cbx->objectName()).captured("ID").toInt());
connect(&coilButtons, &QButtonGroup::idClicked, this, &MainWindow::coilChanged);
//其他线圈与寄存器类似
regexp.setPattern(QStringLiteral("disc_(?<ID>\\d+)"));
const QList<QCheckBox *> discs = findChildren<QCheckBox *>(regexp);
for (QCheckBox *cbx : discs)
discreteButtons.addButton(cbx, regexp.match(cbx->objectName()).captured("ID").toInt());
connect(&discreteButtons, &QButtonGroup::idClicked, this, &MainWindow::discreteInputChanged);
regexp.setPattern(QLatin1String("(in|hold)Reg_(?<ID>\\d+)"));
const QList<QLineEdit *> qle = findChildren<QLineEdit *>(regexp);
for (QLineEdit *lineEdit : qle) {
registers.insert(lineEdit->objectName(), lineEdit);
lineEdit->setProperty("ID", regexp.match(lineEdit->objectName()).captured("ID").toInt());
lineEdit->setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9a-f]{0,4}"),
QRegularExpression::CaseInsensitiveOption), this));
connect(lineEdit, &QLineEdit::textChanged, this, &MainWindow::setRegister);
}
}