电能监测上位机的 Qt C++ 实现:UART 和 TCP 通讯、数据帧解析

本文介绍使用 Qt Creator 4.11 实现电能监测上位机的通讯部分,并在基于 Linux 4.14 内核的 Ubuntu Desktop 操作系统(地平线旭日 X3 派操作系统映像)上使用 Qt Creator 4.11 完成,借助于 CH340G 方案的 USB 转串口模块完成开发与测试,在不同的环境中的开发、测试过程或与本文略有出入。

本文基于 [电能监测上位机的 Qt C++ 实现:界面设计]https://guyueju.oss-cn-beijing.aliyuncs.com43196)。

1 方案提出

本人有监测家中路由器等网络设备电源的需求,但使用传统的计量方案只能在家中通过计量表本身查看各项参数,长期不在家时很难了解到网络设备的电源状态。因此,考虑采用电能计量模块与计算机连接的方式通过计算机收集并呈现数据,或者通过嵌入式系统与网络连接从而通过互联网传输电能参数。本程序针对型号为 SUI-101A 的交流变送器模块(自定协议)编写了通讯程序,模块外观如下图。

变送器外观图

该模块使用 UART 通信,因此其既能连接到单片机,也能连接到计算机,但连接到计算机需要电平转换或者 USB 转串口模块。

项目考虑了现场使用和远程使用两种场景,因此提出了使用程序提供服务端供另一个程序连接的设想,该设想来源于电网自动抄表系统中的“中继器”角色。这一设想简化了部署难度,也使得开发工作只需要实现一个程序,该程序可独立完成所有工作。互联网也使得该程序的可扩展性更好,它可以开放服务端供多个程序连接,从不同的计算机上同时监视同一组电能参数,性能远优于传统的通信方案。

2 SUI-101A 数据帧解析

Qt 实现串口调试实用程序(上) 中,本人通过实例介绍了 Qt 中串口的使用,因此在本文中不会再详细说明串口相关的程序。

UART 和 TCP 提供了用于收发数据的通信协议。在此基础上,组织数据、保证数据完整性需要另一套协议。SUI-101A 支持两种协议,一种是 Modbus-RTU,一种是自定义协议。两者都采用变长数据帧的形式组织数据,且实现形式类似。本程序使用后者,其数据帧结构定义如下。

自定数据帧定义

根据 SUI-101A 的数据手册描述,其具备 1 秒一次的自动上报功能,该功能将通过 UART 上报全部测量数据。本程序利用该功能被动获取模块提供的电能参数,因此只需要从 UART 接收数据。

2.1 识别完整数据帧

QSerialPort 类提供了 readyRead 信号,该信号是在 UART 上接收超时时产生的,通过超时为数据分帧。这是不定长数据帧接收的普遍策略。即便能够分帧接收,数据帧之间的时间间隔虽然不会太短,但数据帧中的数据间时间间隔不一定短,这导致分帧有时会出现错误。为保证完整接收数据帧,处理数据帧的函数必须具备识别不完整帧以及拼接分帧的能力。在本程序中,由于数据帧只有一种形式,且长度固定,因此分帧是同时根据 QSerialPort 的超时接收和长度进行的:

static QByteArray Buffed;
if(serial_flag)
{
    QByteArray QBArecv = myserial->readAll();
    if(QBArecv.length() > 0)
    {
        Buffed.append(Recv);
        if(Buffed.length() == 31)
        {
            // 此处解析数据帧
            Buffed.clear();
        }
        else if(Buffed.length() > 31) Buffed.clear();
    }
}

程序从 UART 接收到的数据均存放于缓冲区中,并在每次接收后都计算缓冲区中数据的长度,只要与预期长度相同就开始解析数据帧,在解析结束后清空缓冲区以备下一次接收,如果未达到预期长度则等待追加数据,如果超出长度则清空缓冲区并重新接收。

2.2 解析数据帧及转换数据

SUI-101A 的自定协议数据帧具备帧头,且采用了字节和校验方式,这两个特征可用于检查数据帧是否正确传输,其程序实现为:

bool frame_check(QByteArray &buf)
{
    unsigned char cksum = 0;
    if(buf.length() != 31) return 0;
    if(buf[0] != 0x55 || buf[1] != 0x55) return 0;
    for(int i=0; i<30; ++i)
    {
        cksum += buf[i];
    }
    if(cksum == buf[30]) return 1;
    return 0;
}

校验合格的数据帧将被解析。下图给出了数据帧存放数据的结构。

全部测量数据帧结构

由于数据是高字节优先传输的,计算机中数据通常以小端序的形式存储,为避免 RAM 拷贝的方式出现问题,本程序将从数据帧中取出数据,并对数据逐字节处理:

void frame_parse(int *val_buf, QByteArray &buf) {
    for(int i=0; i<24; ++i)
    {
        if(i%4 == 0) val_buf[i/4] = 0;
        else val_buf[i/4] *= 256;
        val_buf[i/4] += buf[i];
    }
}

处理过的数据还需经过单位转换才能使用。此外,本程序还需要视在功率,可通过公式 S=P*arccos(PF) 根据已知数据求得,其中 P 是有功功率,PF 是功率因数,S 是视在功率。

3 建立 TCP Socket 连接

使用网络可以轻松建立多个连接,并快速传输大量数据,但网络有时并不稳定,连接可能会断开,数据也有可能丢失。TCP Socket 是基于 TCP 协议实现的,相比 UDP 而言,TCP 协议的数据包能有序且安全地到达目标,并且带有一定的校验机制。

TCP Socket 连接中有两个角色:服务端和客户端,多个客户端可以连接到同一个服务端。每个客户端都可以与服务端通讯,但不能直接与其它客户端通讯;服务端向客户端传送数据时需要指定目标是哪个客户端,如果需要广播,只能向所有客户端逐个发送。本程序同时实现了服务端和客户端,服务端用于为其它设置为远程模式的程序提供服务端,客户端用于连接其它程序的服务端。

3.1 准备 TCP Socket 服务端

Qt 中已经集成了对 TCP Socket 的实现,在程序中可通过以下代码引入:

#include <QTcpSocket>
#include <QTcpServer>

其中 QTcpSocket 是 TCP Socket 实现,QTcpServer 是服务端实现。两者的区别在于,QTcpSocket 是将所建立的连接抽象为对象,QTcpServer 接受的连接以 QTcpSocket 的形式存在。为了支持服务端同时与多个客户端建立连接,程序中一般会用数组存放所有 Socket 连接,在 Qt 中可以利用 QList 实现:

QList<QTcpSocket*> tcpSocket;

同时,还要定义 QTcpServer 对象:

QTcpServer *tcpServer=nullptr;

要启动一个服务端,应先实例化 QTcpServer,再为其编写建立连接的槽函数,并连接至该对象的 newConnection 信号;如果之前开启过服务端,需要注意避免在有服务端工作时建立新的服务端,并在建立服务端后清理残留的连接。调用 QTcpServerlisten() 方法后,访问其监听的端口就能够建立连接了。开启服务端的程序如下,其中调用 QTcpServerlisten() 方法时传入的 QHostAddress::Any 参数是使服务端监听来自任意地址的连接请求。

bool MainWindow::openSocketServer()
{
    if(tcpServer==nullptr)
    {
        tcpServer=new QTcpServer(this);
        connect(tcpServer,&QTcpServer::newConnection,this,&MainWindow::on_TCPserver_Connect);
    }
    if(tcpServer==nullptr)return false;
    if(tcpServer->isListening())
    {
        for(auto itor=tcpSocket.begin();itor!=tcpSocket.end();itor++)
            (*itor)->disconnectFromHost();
        tcpServer->close();
        for(auto itor=tcpSocket.begin();itor!=tcpSocket.end();itor=tcpSocket.erase(itor));
    }
    return tcpServer->listen(QHostAddress::Any ,11400);
}

TCP Socket 服务端每次接受连接后,都会回到等待连接的状态以便接受下一个连接。已建立的连接既能够传送数据,也有可能断开,因此对于该连接而言,程序需要为其分配用于接收数据和清理已断开连接的槽函数。因此,openSocketServer() 中的槽函数 on_TCPserver_Connect() 编写如下:

void MainWindow::on_TCPserver_Connect(void)
{
    QTcpSocket *socket=tcpServer->nextPendingConnection();
    tcpSocket.append(socket);
    connect(socket,&QTcpSocket::readyRead,this,&MainWindow::on_TCPserver_Read);
    connect(socket,&QTcpSocket::disconnected,this,&MainWindow::on_TCPserver_Disconnect);
}

当一个 Socket 连接断开后,程序应从连接列表中移除该连接,并回收为建立该连接所使用的 RAM,避免无效连接影响程序运行。处理已断开连接的 on_TCPserver_Disconnect() 槽函数编写如下:

void MainWindow::on_TCPserver_Disconnect(void)
{
    int cnt=0;
    for(auto itor=tcpSocket.begin();itor!=tcpSocket.end();)
    {
        QTcpSocket *socket=(QTcpSocket*)*itor;
        if(socket->state()==QTcpSocket::ConnectedState)
            itor++;
        else
            itor=tcpSocket.erase(itor);
        cnt++;
    }
}

3.2 准备 TCP Socket 客户端

在 Remote 模式启动后,程序会启动 TCP Client 并尝试与服务器连接。与服务端不同的是,客户端是一个 Socket 连接,因此只需实例化一个 QTcpSocket 对象即可。

if(tcpClient==nullptr) {
    tcpClient=new QTcpSocket(this);
    connect(tcpClient,&QTcpSocket::connected,this,&MainWindow::on_TCP_Connect);
    connect(tcpClient,&QTcpSocket::readyRead,this,&MainWindow::on_TCP_Read);
}

启动该客户端时需要提供服务端的 IP 地址以及端口号。以下代码段从界面中的输入框获得字符串形式的 IP 地址和整数形式的端口号并启动客户端。考虑到客户端有意外断开的可能,程序中增加一个标志位 RelinkFlag,用于指示断开连接的动作是人为的还是网络问题。

RelinkFlag=true;
QString ip=ui->textEditIP->toPlainText();
int port=ui->textEditPort->toPlainText().toInt();
tcpClient->connectToHost(ip,quint16(port));

连接成功和连接断开时的槽函数如下。如果断开连接是非人为因素引起的,程序会先关闭当前连接,再重新启动连接。

void MainWindow::on_TCP_Connect(void)
{
    connect(tcpClient,&QTcpSocket::disconnected,this,&MainWindow::on_TCP_Disconnect);
    RelinkFlag=true;
    ui->ButtonOpenClose->setText("断开");
}
void MainWindow::on_TCP_Disconnect(void)
{
    tcpClient->close();
    if(RelinkFlag)
    {
        tcpClient->connectToHost(ip,quint16(port));
    }
    else if(ui->ButtonOpenClose)ui->ButtonOpenClose->setText("连接");
}

3.3 TCP Socket 数据收发

与 UART 类似,TCP Socket 上数据是以数据包的形式分段传输的。在 Qt 中,编程人员不必关心数据分包发送和接收的过程,只需在出现 readyRead 信号后读取已经收到的内容。

for(auto itor=tcpSocket.begin();itor!=tcpSocket.end();itor++)
{
    QTcpSocket *socket=(QTcpSocket*)*itor;
    QByteArray RxBuff=socket->readAll();
    if(RxBuff.length()==0)continue;
    QString RxStr=QString::fromLocal8Bit(RxBuff);
    // 此处解析数据帧
}

此代码段能够遍历 TCP Server 的连接,从接收到数据的连接中读取数据并处理。对于 TCP Client 而言,其只有一个连接,因此不需要遍历。

QByteArray RxBuff=tcpClient->readAll();
QString RxStr=QString::fromLocal8Bit(RxBuff);
// 此处解析数据帧

在本程序中,经 TCP Socket 传输的数据与 SUI-101A 定义的数据帧格式相同,因此解析程序通用。

通过 TCP Socket 连接发送数据只需要调用 QTcpSocket 对象的 write() 方法即可。在本程序中,运行于 Host 模式的程序实例在从 UART 上接收有效数据帧后,可将该数据帧广播至 TCP Server 中的所有客户端。与无线电技术的广播不同,计算机网络中的广播是通过逐个发送实现的,有多少连接就要发送多少次。在本文 2.1 节代码段中 Buffed.clear(); 前增加程序,将 UART 上的有效数据帧实时广播至服务端的所有连接,代码如下。

if(tcpSocket!=nullptr)
{
    for(auto itor=tcpSocket.begin();itor!=tcpSocket.end();itor++)
    {
        QTcpSocket *socket=(QTcpSocket*)*itor;
        if(socket->state()==QTcpSocket::ConnectedState)
        {
            socket->write(Buffed);
        }
    }
}