Qt 实现串口调试实用程序(上)

本文介绍使用 Qt Creator 4.11 实现串口调试实用程序,可定制实现个人所需的功能,并在实现的过程中学习 Qt。本文着重于实战编写程序过程中遇到的特殊情况,阅读本文需要有一定的 C++ 计算机语言基础和 Qt 基础。

本文在基于 Linux 4.14 内核的 Ubuntu Desktop 操作系统(地平线旭日 X3 派操作系统映像)上使用 Qt Creator 4.11 完成,借助于 CH340G 方案的 USB 转串口模块(TXD 与 RXD 已短接)完成开发与测试,在不同的环境中的开发、测试过程或与本文略有出入。

创建界面

Qt 的图形界面使用的计算机语言是 QML,其本质为 XML,采用键、值的方式描述视觉元素的类型、样式和基本属性。对于接触过 Android 应用程序开发以及 Web 服务前端开发的人而言,QML 不难理解;对于没有此类开发经验的人而言,只要能理解应该使用哪种控件、设定何种属性以及以何种形式在界面上组织控件、明确如何设计出便于使用的用户界面,就可以使用 Qt Creator 中的设计工具以可视化的方式轻松设计图形界面。

Qt 设计出的界面中,不同控件之间的位置、尺寸关系存在一定的约束,这保证了可以改变大小的窗口中各控件能够维持相对的位置关系以及相对或绝对的大小,比如一些按键、复选框之类的控件并不希望被改变尺寸,而对于文本框、图片甚至嵌入式浏览器控件而言,只要其尺寸不小于某个下限即可,且大部分控件之间的位置关系和间隔距离都不应当改变。Qt 中提供了 4 种布局:垂直布局 (Vertical Layout)、垂直布局 (Horizontal Layout)、栅格布局 (Grid Layout) 以及表单布局 (Form Layout)。其中垂直和水平布局都以一维方式组织控件或子布局,对于一些输入框和按钮而言能够有效组织,比如录入个人信息或者登录认证的界面,甚至表单布局更适合这种应用。对于本文要设计的串口调试实用程序,栅格布局最合适。

此外,Qt 中提供了弹簧 (Spacer),可以在两个控件之间创建水平或垂直的空间,并将相邻控件在水平或垂直方向推倒上级布局控件的最末端。弹簧常用于水平和垂直布局中。

要实现的界面如下图。由图中的布局形式可知,栅格布局可以轻松以这种形式组织各控件。

要搭建该界面,应首先添加栅格布局,向栅格布局中拖入所需控件。在上图所示的界面中,左侧有接收显示区和发送区,其中接收显示区在上,发送区在下,两者皆使用 Text Browser 控件实现,但接收显示区不允许用户修改,因此其 readOnly 属性应是 true,如下图。为在编写程序时易于区分和调用,创建控件过后应先修改控件的 objectName

为能够使得发送区位于接收显示区之下,拖动发送区至接收显示区的下边缘,界面上应出现线段指示,表明被拖动的控件将贴附于该线之下,如图所示。对于栅格布局而言,这会在接收显示区下端新增一行,如果出现了竖线,则新增一列以放置控件。控件可占据一个或多个连续的单元格(矩阵)中,如果有两个列的行数不相等,行数较少的就会存在空单元格或者占据多个单元格的控件。

对于这些用于显示或键入文本的控件而言,其尺寸可以灵活变化;但为了便于使用或布局美观,其宽度和高度应有最小值。由于其尺寸需要灵活变化,控件的 sizePolicy 属性应设置为 Expanding,为具有该属性的控件设定最小宽高,即 minimumSize 后即可使得控件不会被调整到尺寸过小。

串口调试需要的设置功能按照一般的串口调试软件设计方式布局在窗口靠右的区域,因此应在右侧新开一列。对于串口设置而言,相关的控件可以统一放置于一个框内,该框内所有控件的尺寸和相对位置都是固定的,因此该框尺寸也没有改变的必要。Qt 中的 Group Box 可以用于组织这些控件,因此先拖动该控件至窗口右边缘,当窗口右边缘出现蓝色竖线时松手。此时框内已经可以放置控件。

串口所需的设置项目有:端口号(或设备路径)、波特率、数据位、停止位、校验、流控制,且均可使用下拉列表供用户选择。考虑到用户需要手动重载串口的端口号(设备路径)列表,因此还需要增加用于扫描串口的按钮,并且要有用于开启和关闭串口的按钮切换串口状态。为满足个人需要,本文中的串口调试实用程序还增加了切换十六进制显示、增加接收时间戳以及 RTS 和 DTR 引脚的控制选项,这两个选项特别适合用于调试 ESP 系列微处理器。按常规形式在框内安放这些元件,用文本框 (Label) 增加文字说明,即形成所需的效果。如果框太小,宜通过改变属性的方式输入数值改变其尺寸,因为拖动边缘定位点不一定总是奏效。调整尺寸所需改变的属性仍然是 minimumSize,但 sizePolicy 属性宜设定为:Horizontal PolicyFixedVertical PolicyExpanding,当窗口高度变大时,该控件下端会出现空白,这更符合一般的界面设计逻辑。此外,还需要实现清空接收区的功能,该按钮可以贴附于放置串口设置的框下端,但必须与接收显示区在同一行。

按照个人的需要,发送区右侧应设置发送按钮和在数据末尾追加换行符的功能,此处的控件布局不再赘述。

编写程序

该程序采用 C++ 语言编写。C++ 语言在较新的版本中提供了诸多功能,例如自动类型、匿名函数,但本文中的程序并未采用较高版本的 C++ 编译器,因此并未使用 C++ 的新特性。

main.cpp 编写

该程序为一 Widgets Application,main.cpp 中只需编写如下内容:

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();
    return a.exec();
}

该程序段用于激活界面显示。

mainwindow.* ### 编写

本程序要实现的逻辑功能均在 mainwindow.cpp 中实现。由于程序中设计了一个 MainWindow 界面,因此需要在 mainwindow.cpp 中实现 MainWindow 类。

需要保证 mainwindow.cpp 已经包含 mainwindow.h

首先实现构造函数。该函数中需包含对所需模块的初始化,以及信号和槽的连接等。由于使用了串口资源,该程序中需要引入 QSerialPort 库。为了便于程序中调用和头文件中定义,在 mainwindow.h 中添加:

#include "QtSerialPort/QSerialPort"
#include "QtSerialPort/QSerialPortInfo"

为实现 MainWindow 类,mainwindow.h 还应当包含如下内容以声明类:

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

private slots:

private:
    Ui::MainWindow *ui;
};

此后可根据程序编写需要向该程序段中添加内容。转到 mainwindow.cpp,此时可以开始实现 MainWindow 类的构造函数:

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);
    myserial = new QSerialPort();
}

以及回收函数:

MainWindow::~MainWindow()
{
    delete ui;
    if(myserial->isOpen()) myserial->close();
    delete myserial;
}

上两个程序段一并实现了串口的初始化和反初始化。在初始化串口之后,还应添加串口对象的信号与本程序中某个槽的对应关系,在编写好槽函数后可以添加该程序段;在反初始化串口之前,保险起见关闭了已经打开的串口。

接下来需要编写用于界面控件实现功能的程序。界面上需要在操作时传递信号并由槽函数处理的控件有:

  • 波特率选择:串口启动后,确保用户可以随时更改波特率;

  • 扫描串口:按下该按钮后,程序立即扫描可用的端口号(设备路径);

  • 开启(关闭)串口:按下该按钮后,程序立即改变串口的当前状态以及控件的文本;

  • RTS 和 DTR:串口启动后,确保用户可以随时拉低或拉高对应引脚;

  • 清空接收区和发送数据:按下这些按钮后,相应动作立即执行。

添加这些槽函数宜在设计界面右击对应控件,在右键菜单中选择“转到槽” (“Go to slot”),Qt Creator 会弹出对话框用于选择信号类型,如下图。确认后,Qt Creator 会编写好 connect() 程序段,并跳转至槽函数的实现,用户可以在其中编写程序。

假设波特率选择下拉菜单名称为 baudRateBox,在它的 currentTextChanged() 槽函数中,添加:

if(myserial->isOpen()) {
    myserial->setBaudRate(ui->baudRateBox->currentText().toInt());
}

假设端口选择下拉菜单名称为 portNameBox,在扫描串口按钮的 clicked() 槽函数中,添加:

ui->portNameBox->clear();
auto Infos=QSerialPortInfo::availablePorts();
QList<QSerialPortInfo>::iterator info=Infos.begin();
for(;info!=Infos.end();info++)
{
    ui->portNameBox->addItem(info->portName());
}

另假设数据位下拉菜单名称为 portDataBitBox,停止位下拉菜单名称为 stopbBox,校验位下拉菜单名称为 parityBox,流控制下拉菜单名称为 flowctlBox,开启(关闭)串口按钮名称为 togglePortBtn,在开启(关闭)串口按钮的 clicked() 槽函数中,添加:

if(!myserial->isOpen())
{
    myserial->setPortName(ui->portNameBox->currentText());
    myserial->setBaudRate(ui->baudRateBox->currentText().toInt());

    QSerialPort::DataBits DataBitMap[4]={QSerialPort::Data5,QSerialPort::Data6,QSerialPort::Data7,QSerialPort::Data8};
    QSerialPort::StopBits stopbMap[3]={QSerialPort::OneStop, QSerialPort::OneAndHalfStop, QSerialPort::TwoStop};
    QSerialPort::Parity parityMap[5]={QSerialPort::NoParity, QSerialPort::EvenParity, QSerialPort::OddParity, QSerialPort::SpaceParity, QSerialPort::MarkParity};
    QSerialPort::FlowControl flowctlMap[3]={QSerialPort::NoFlowControl, QSerialPort::SoftwareControl, QSerialPort::HardwareControl};

    int curBitMapSel=ui->portDataBitBox->currentIndex();
    if(curBitMapSel>=4)curBitMapSel=3;
    myserial->setDataBits(DataBitMap[curBitMapSel]);

    myserial->setParity(parityMap[ui->parityBox->currentIndex()]);
    myserial->setStopBits(stopbMap[ui->stopbBox->currentIndex()]);
    myserial->setFlowControl(flowctlMap[ui->flowctlBox->currentIndex()]);
    if(myserial->open(QIODevice::ReadWrite))
    {
        ui->portNameBox->setDisabled(true);
        ui->portDataBitBox->setDisabled(true);
        ui->stopbBox->setDisabled(true);
        ui->parityBox->setDisabled(true);
        ui->flowctlBox->setDisabled(true);
        ui->togglePortBtn->setText("关闭串口");
        onRTSchanged(); onDTRchanged();
    }
    else
    {
        QMessageBox::warning(this,tr("不能打开串口"),tr("不能打开串口,可能是因为设备存在故障,或其他程序正在使用此串口。"),QMessageBox::Close);
    }
}
else
{
    ui->portNameBox->setEnabled(true);
    ui->portDataBitBox->setEnabled(true);
    ui->stopbBox->setEnabled(true);
    ui->parityBox->setEnabled(true);
    ui->flowctlBox->setEnabled(true);
    myserial->close();
    ui->togglePortBtn->setText("打开串口");
}

RTS 和 DTR 可分别通过 setRequestToSend() 以及 setDataTerminalReady() 实现控制,此处以 RTS 举例。假设 RTS 复选框名称为 checkBoxRTS,在其 stateChanged() 槽函数中,添加:

    if(myserial->isOpen()) {
        if(ui->checkBoxRTS->checkState() == Qt::Checked) myserial->setRequestToSend(true);
        else myserial->setRequestToSend(false);
    }

清空接收显示区只需调用其 clear() 函数即可,槽函数的编写方法不再赘述。对于发送按钮而言,假设发送区名称为 sendText,其 click() 槽函数中应添加:

if(myserial->isOpen())
{
    auto TXdata=ui->sendText->toPlainText();
    if(ui->checkBoxCRLF->isChecked())TXdata.append("\r\n");
    QByteArray QBAsend = TXdata.toLocal8Bit();
    myserial->write(QBAsend);
}

为能够在串口接收到数据后立刻呈现,串口对象收到数据并可以读取的信号也需要连接到槽函数:

QObject::connect(myserial, &QSerialPort::readyRead, this, &MainWindow::doRefresh);

假设接收显示区名称为 recvBox,其槽函数

if(myserial->isOpen())
{
    QByteArray QBArecv =myserial->readAll();
    QString Recv="";
    if(ui->checkBoxHex->isChecked())
    {
        QString Temp;
        foreach(QChar dat,QBArecv)
            Recv+=Temp.sprintf("%02X ",(unsigned int)dat.unicode());
    }
    else Recv+=QString::fromLocal8Bit(QBArecv);
    ui->recvBox->insertPlainText(Recv);
}

经历以上过程后,一个实现了基本功能的串口调试实用程序即编写完毕。其它功能的实现、修补以及遇到的问题不在本文讲解。