Modbus是一种常见的工业系统通讯协议。在我们的设计开发工作中经常使用到它。在这一篇中我们将简单实现一个基于QT的Modbus RTU主站上位工具。
1、概述
Modbus RTU主站应用很常见,有一些是通用的,有一些是专用的。而这里我们希望实现一个主要针对我们的产品调试的Modbus RTU主站工具。
在开始软件设计之前,我们先来简略地分析一下,实现这样一个Modbus RTU主站工具包含的主要内容有哪些。我们认为软件需要如下几个方面的内容:
(1)、串口参数的配置
Modbus RTU通过串口来实现通讯,所以我们需要对串口相关的参数进行配置。对串口的配置主要是串口名、波特率、校验位、数据位和停止位等。对于这些参数我们让使用者可以根据需要选择。
而串口号,我们希望软件可以自动搜索当前可用的串口列表。而且我们可以通过操作更新可用的串口列表。对串口的操作主要是串口的打开与关闭。
(2)、从站信息的配置
我们实现Modbus RTU主站应用就是访问从站的数据,所以我们需要在主站应用中配置从站的信息。主要有站地址、数据类型、数据格式等,我们将其设置为可以选择。
读取从站的参数配置,主要是起始地址、读取的数量。写从站参数的配置,主要是起始地址、写入的数量以及写入的数值。
(3)、对从站的操作
Modbus RTU主站对从站的操作无非是读从站数据和写从站数据,我们通过制定读写的寄存器类型、起始地址、数量等通过按钮操作来实现读写命令的发送。
除了手动操作读写外,很多时候我们可能需要Modbus RTU主站自动周期性的读取从站的数据。所以我们让其可以选择以多长的周期自动循环读取。
(4)、对信息的显示
接收信息的显示,作为一款工具软件, 我们当然希望看到我们发给从站的命令究竟有没有成功,最简单的和直观的办法就是将接收到的信息显示出来。对于Modbus RTU主站当然是显示对应的地址的值。
同样的,我们有时候想要看到发送和接收到的原始报文,所以我们对发送和接收到的报文也作相应的显示。
对于个别数据有时候我们还希望看到他的变化趋势,所以我们可以添加一个图形显示,用以显示我们制定的数据的变化趋势。
运行状态的显示, 我们希望对操作的状态进行反馈以指示操作的动作是否执行,所以我们需要状态栏来实现这一需求。
2、界面设计
根据上一节中分析的需求,我们先来设计软件的界面。我们在QT中基于QMainWindow类生成一个操作界面,包括菜单栏、工具栏和状态栏以满足需求中对状态显示及操作命令的要求。
而在中间显示区域,我们将其划分为2列。在左边的一列从上到下设置:串口配置操作区域和读写从站的交互配置区域。在右侧的一列从上到下设置:动态曲线显示区域、收发消息显示区域以及直接输入报文发送命令的输入区域。具体的界面设置如下图所示:
完成如上图的布局后,我们可以选择在属性中配置控件的参数,也可以在代码中添加相关的参数。在这里在代码中通过初始化形式完成参数的设置。完成整个布局后我们先试着运行程序,正常运行则出现如下的界面:
上图就是完成布局后的运行界面,不过我们还没有实现相应的编码,所以目前尚不能实现我们第一节中所预想的功能。
3、编码实现
接下来这一小节,我们将来编码实现相应的功能。我们主要将功能分为串口操作功能、从站操作功能以及信息显示功能三个部分来实现。
3.1、串口操作功能
对串口的操作首先就是对串口参数的设置。我们在代码中对界面上的串口号、波特率、数据位、校验位和停止位的ComboBox控件进行初始化。其中串口号通过自动搜索当前可用的串口来实现。具体的实现方式如下:
//搜索串口
void MainWindow::SearchSerialPorts()
{ui->comboBoxPort->clear();foreach(const QSerialPortInfo &info,QSerialPortInfo::availablePorts()){ui->comboBoxPort->addItem(info.portName());}
}
对串口的操作主要是串口的打开和关闭,在这里因为是Modbus RTU主站应用,我们称之为连接和断开。建立或断开与从站的连接实际就是对串口的配置与操作,只是针对Modbus RTU作了一些封装,具体实现如下:
//串口连接
void MainWindow::on_actionConnect_triggered()
{if (!modbusDevice)return;modbusDevice->setConnectionParameter(QModbusDevice::SerialPortNameParameter,ui->comboBoxPort->currentText());modbusDevice->setConnectionParameter(QModbusDevice::SerialBaudRateParameter,ui->comboBoxBaud->currentText().toInt());switch(ui->comboBoxParity->currentIndex()) //设置奇偶校验{case 0: modbusDevice->setConnectionParameter(QModbusDevice::SerialParityParameter,QSerialPort::NoParity);break;default: break;}switch(ui->comboBoxData->currentIndex()) //设置数据位数{case 1:modbusDevice->setConnectionParameter(QModbusDevice::SerialDataBitsParameter,QSerialPort::Data8);break;default: break;}switch(ui->comboBoxStop->currentIndex()) //设置停止位{case 1: modbusDevice->setConnectionParameter(QModbusDevice::SerialStopBitsParameter,QSerialPort::OneStop);break;case 2: modbusDevice->setConnectionParameter(QModbusDevice::SerialStopBitsParameter,QSerialPort::TwoStop);break;default: break;}modbusDevice->setTimeout(1000);modbusDevice->setNumberOfRetries(3);if (modbusDevice->connectDevice()){//开启自动读取if(ui->checkBoxAuto->isChecked()){connect(pollTimer,&QTimer::timeout, this, &MainWindow::ReadRequest);pollTimer->setInterval(ui->spinBoxInterval->value());pollTimer->start();}//连接槽函数//QObject::connect(serialPort, &QSerialPort::readyRead, this, &MainWindow::ReadSerialData);// 设置控件可否使用ui->actionConnect->setEnabled(false);ui->actionDisconnect->setEnabled(true);ui->actionRefresh->setEnabled(false);}else //打开失败提示{QMessageBox::information(this,tr("错误"),tr("连接从站失败!"),QMessageBox::Ok);}
}
3.2、从站操作功能
在前面一节中我们已经设计过,对从站的操作包括手动按钮读取从站数据、手动按钮写入从站数据以及自动周期读取从站数据。
手动读取从站数据是指点击按钮时触发一次读从站的操作,而从站的地址、读取的寄存器类型、读取的寄存器起始地址和寄存器的数量均根据界面上相应的设置确定。具体的实现如下:
//读数据请求
void MainWindow::ReadRequest()
{if (!modbusDevice){QMessageBox::information(NULL, "Title", "尚未连接从站设备");return;}QModbusDataUnit::RegisterType type;switch(ui->comboBoxDataType->currentIndex()){case 0:type=QModbusDataUnit::Coils;break;case 1:type=QModbusDataUnit::DiscreteInputs;break;case 2:type=QModbusDataUnit::InputRegisters;break;case 3:type=QModbusDataUnit::HoldingRegisters;break;default:type=QModbusDataUnit::Invalid;}int startAddress = ui->spinBoxStartRead->value();Q_ASSERT(startAddress >= 0 && startAddress < 10);// do not go beyond 10 entriesquint16 numberOfEntries = qMin(quint16(ui->spinBoxNumberRead->value()), quint16(10 - startAddress));QModbusDataUnit readUnit=QModbusDataUnit(type, startAddress, numberOfEntries);statusBar()->clearMessage();if (auto *reply = modbusDevice->sendReadRequest(readUnit, ui->spinBoxStation->value())){if (!reply->isFinished())connect(reply, &QModbusReply::finished, this, &MainWindow::ReadSerialData);elsedelete reply; // broadcast replies return immediately}else{statusBar()->showMessage(tr("Read error: ") + modbusDevice->errorString(), 5000);}}
手动写从站操作是指点击按钮触发一次写从站操作,而从站的地址、写入的寄存器类型、写入的寄存器起始地址、写入的寄存器的数量以及写入的值均根据界面上相应的设置确定。而寄存器的值得输入以“,”分割,具体的实现如下:
//写数据请求
void MainWindow::WriteRequest(QList<quint16> values)
{if (!modbusDevice){QMessageBox::information(NULL, "Title", "尚未连接从站设备");return;}QModbusDataUnit::RegisterType type;switch(ui->comboBoxDataType->currentIndex()){case 0:type=QModbusDataUnit::Coils;break;case 1:type=QModbusDataUnit::DiscreteInputs;break;case 2:type=QModbusDataUnit::InputRegisters;break;case 3:type=QModbusDataUnit::HoldingRegisters;break;default:type=QModbusDataUnit::Invalid;}int startAddress = ui->spinBoxStartWrite->value();Q_ASSERT(startAddress >= 0 && startAddress < 10);QModbusDataUnit writeUnit = QModbusDataUnit(type,startAddress, values.size());for(int i=0; i<values.size(); i++){writeUnit.setValue(i, values.at(i));}//serverEdit 发生给slave的IDif (auto *reply = modbusDevice->sendWriteRequest(writeUnit,ui->spinBoxStation->value())){if (!reply->isFinished()){connect(reply, &QModbusReply::finished, this, [this, reply]() {if (reply->error() == QModbusDevice::ProtocolError) {qDebug() << QString("Write response error: %1 (Mobus exception: 0x%2)").arg(reply->errorString()).arg(reply->rawResult().exceptionCode(), -1, 16);} else if (reply->error() != QModbusDevice::NoError) {qDebug() << QString("Write response error: %1 (code: 0x%2)").arg(reply->errorString()).arg(reply->error(), -1, 16);}reply->deleteLater();});}else{reply->deleteLater();}}else{qDebug() << QString(("Write error: ") + modbusDevice->errorString());}
}
对于自动周期性读取从站数据我们通过一个计时器周期性操作,而从站的地址、读取的寄存器类型、读取的寄存器起始地址、寄存器的数量以及间隔时间通过界面设置。而其操作与手动按钮触发一样。
3.3、信息显示功能
对于信息的显示我们主要考虑3个方面的内容。一是读取回来的从站数据结果显示;二是上下行报文的监视;三是操作过程及状态的显示。
首先是对读取回来的从站数据进行显示,在这里我们将读取的寄存器地址及其对应的数据显示在消息框中。同时我们将部分数据在图形显示中以曲线的形式展示出来。
//曲线显示
void MainWindow::ChartDisplay()
{QColor acolor[8]={Qt::red,Qt::blue,Qt::green,Qt::cyan,Qt::yellow,Qt::magenta,Qt::black,Qt::darkRed};QStringList name={"抛物线","正弦值","正弦值","固定值","固定值","固定值","固定值","固定值"};QVector<QPointF> list[8];QVector<QPointF> newlist[8];for(int j=0;j<8;j++){list[j] = lineSeries[j]->pointsVector();//获取现在图中列表if (list[j].size() < 200){//保持原来newlist[j] = list[j];}else{//错位移动for(int i =1 ; i< list[j].size();i++){newlist[j].append(QPointF(i-1,list[j].at(i).y()));}}newlist[j].append(QPointF(newlist[j].size(),values[j]));//最后补上新的数据lineSeries[j]->replace(newlist[j]);//替换更新lineSeries[j]->setName(name[j]);//设置曲线名称lineSeries[j]->setPen(acolor[j]);//设置曲线颜色lineSeries[j]->setUseOpenGL(true);//openGl 加速//mChart->setTitle("Pressure Data");//设置图标标题mChart->removeSeries(lineSeries[j]);mChart->addSeries(lineSeries[j]);mChart->createDefaultAxes();//设置坐标轴}ui->graphicsView->setChart(mChart);
}
其次对于上下行报文我们也将其显示到消息显示框中。在QT对Modbus协议进行封装后,我们没有办法直接获取上下行的报文,我们可以开启日志答应功能,再从其中截取相应的报文。
QLoggingCategory::setFilterRules(QStringLiteral("qt.modbus* = true"));
而操作过程及状态显示则比较简单,我们在状态栏显示相应的操作过程和操作的状态。
4、小结
完成了编码调试后,我们尚需要对这一工具进行一些测试。首先我们安装一个虚拟串口软件用以虚拟我们用于测试的串口,并找到一款Modbus RTU的从站模拟软件。当然有实际的从站和硬件的串行端口更好,在这里我们先用软件模拟。具体的配置如下图所示:
而Modbus RTU从站我们使用MThings来模拟,当然也可以使用其它Modbus RTU从站模拟软件。我们模拟10个保持寄存器和10个线圈,之所以这么设置是因为这两种数据类型支持读写,方便我们测试。具体的配置如下图所示:
现在将我们设计的Modbus RTU主站运行起来,并使用它去访问我们刚才配置的Modbus RTU从站。首先我们实验读从站数据操作。测试的结果如下图所示:
这里我们读取从站从地址0开始的10个保持寄存器,并将值显示在消息框和图形中。我们模拟了2路正弦信号、1路抛物线信号和5路固定值信号。接下来我们测试一下写操作。测试的结果如下图所示:
这里对从站的从地址3开始的3个保持寄存器的值进行修改。设定的值分别是123、456和789,操作完成后我们查看从站的结果如下:
上图中与我们设定值的完全符合,说明我们的写从站操作时正确的。到这里我们基于QT的Modbus RTU主站就基本实现了。当然,我们还可以根据需要修改或添加一些功能以适应不同的应用需求。我们已经将代码发布到Gitee,欢迎下载和交流。
下载地址:https://gitee.com/ErichMoonan/ModbusMaster