介绍
本文实现的控制小车程序用于控制自制的遥控小车,控制方式为点击窗口中的按钮实现控制或者通过键盘的方向键来控制。为方便Qt初学者能从本文中学到自己想要的东西,我将一些重要的流程环节都以单独的文章形式发布,初学者可从本文给予的链接跳转至自己想要的部分,同时每一部分我会尽量给出我的学习路程以及参考资料。
注:
- 我也是一个初学者,有一点C语言基础,对C++不是特别懂,所以文章中有误的地方或者不明白的地方请各位大神指出,我会尽量及时回复。
- 关于Qt的教程,我参考的是:QT基础教程 | QT入门 | 信号与槽(Bilibili),只需看前4节就差不多能实现大部分的基础的窗口设计了。
- 本文的程序代码在这里,百度网盘(提取码:adm9 ),共两个文件夹,一个是可直接运行的程序,另一个是程序源码。
- 本文涉及的遥控小车硬件实现部分见此:STM32遥控小车下位机及硬件连接部分(CSDN)
一、界面效果
上图:
说明:
- Qt将窗口程序设计分为UI设计和逻辑设计,窗口的UI设计只需要在Qt Creater中拖动各个控件到你想要的位置,然后使整个界面好看即可。此过程不涉及任何C++编程,但需要对Windows程序设计有一定的了解,明白自己的需求,才能设计出满意的界面。
- 我设计的界面分为几个部分,各部分分别实现什么功能,同时程序低层的核心部分为串口实现,其他的界面交互都是为了调用相关函数实现串口通信的。接下来几节将详细阐述具体实现过程及代码。
二、各部分的实现
1. 核心部分:串口实现
这个部分的实现主要靠调用Qt库里的串口类函数从而实现各种功能。如果想详细了解Qt的串口实现,可以转这里:Qt串口的实现(Bilibili),跟着这个老师做一遍就对串口的实现能大概了解了。
本文主要使用到的串口相关函数如下:
//设置串口号,波特率,校验位等
setPortName()
setBaudRate()
setParity()
setDataBits()
setStopBits()
//打开串口以及读写串口
open()
write()
read()
2. 连接组
如图
说明:
- 该部分有4个控件,两个标签(Label),两个按钮(Button)。两个标签用来提示当前的串口连接状态,两个按钮用来控制连接和测试连接。
- 两个标签:标签1为一个静态的Label,程序运行的整个过程中该控件不发生任何改变。标签2为一个动态的Label,当连接成功时,该Label上的字显示为“已连接”,当未连接时,Label上的字显示为“未连接”。使用函数QLabel.setText()对Label上的文字进行更改。
- 两个按钮:按钮1为串口配置按钮(BtnSerialPortConfig),点击该按钮时,程序会打开一个新的窗口界面从而进行串口配置。按钮2为串口连接按钮(BtnConnectPort),点击该按钮时,程序根据配置文件对串口进行配置和启动,连接成功的话该按钮上的文字会变为断开,以及Label2上的文字会变为“已连接”,再次点击,程序会将串口断开,文字又会变回来。
a. 实现点击配置按钮时打开新的窗口
功能描述:点击配置按钮时程序作出响应,打开一个新的子窗口。具体原理为点击按钮时会发出信号,通过将该信号与一个自定义的函数绑定在一起,就能使信号触发时,函数就开始执行,这个函数就叫这个信号的槽函数。
代码解释:
- 编写槽函数之前需要先在当前工程下添加一个新的窗口类,即设计师界面类,为其命名为newWindow。
- 将newWindow的头文件加到主窗口头文件中,在槽函数中添加如下代码:
void MainWindow::on_BtnSerialPortConfig_clicked() { newWindow *configWindow = new newWindow; //setWindowModality:保证子窗口弹出时禁用主窗口 configWindow->setWindowModality(Qt::ApplicationModal); configWindow->show(); }
详细操作过程可参考这里:QT通过点击按钮弹出新的窗口(CSDN)。
说明:
- QT中的信号与槽原理与计算机的中断原理类似,当某个特定信号发生后,程序就会调用并执行槽函数中的代码。
- 槽函数与信号直接的绑定关系有两种:一是通过connect函数,将信号与自定义的函数关联在一起;二是UI设计界面中,右击某个交互控件,选择转到槽,选择特定的动作,之后程序会将QT库中自带的空槽函数写入主窗口函数中,用户只需在此函数中补充自己想要实现的功能即可。实在是不明白可以参考这个视频:Bilibili:QT基础教程 | QT入门 | 信号与槽,第四节课的内容,这位老师大致总结了信号与槽的几种构建方式。
b. 实现点击连接按钮时打开串口
功能描述:点击连接按钮时,串口能够打开与关闭,并且窗口中的控件文字会发生相应的变化。要实现该功能需要一个标志变量mIsOpen用来表明当前的串口状态,每次点击连接按钮执行槽函数时会判断当前mIsOpen的值,如果当前串口连接,则槽函数执行断开串口的操作,如果当前串口断开,则槽函数执行打开串口的操作。
部分代码:
//===================槽函数===================//
void MainWindow::on_BtnConnectPort_clicked()
{
if(mIsOpen)
{
//如果当前串口状态为打开,则这里执行关闭串口时的动作:
mSerialPort.close(); //关闭串口
ui->BtnConnectPort->setText("连接");//更改按钮2文字为“连接“”
ui->LabPortState->setText("未连接");//更改标签2文字为“未连接”
ui->BtnSerialPortConfig->setEnabled(true);//允许按钮1使能
mIsOpen = false;
}
else
{
//如果当前串口状态为关闭,则这里执行打开串口时的动作:
//1.读取配置文件 2.配置并开启串口 3.判断串口是否打开成功
readConfigFile();
if(startSerialPort())
{
//如果成功打开串口,则执行这里的代码
mIsOpen = true;
ui->BtnConnectPort->setText("断开");
ui->LabPortState->setText("已连接");
ui->BtnSerialPortConfig->setEnabled(false);//禁用按钮1
}
else
{
//如果打开串口失败,则执行这里的代码
QMessageBox::warning(this,"警告","打开串口出错,请检查串口连接或参数设置");
}
}
}
//===============读取配置文件的函数=================//
void MainWindow::readConfigFile()
{
QSettings iniConfigFile(mFileName,QSettings::IniFormat);//新建QSettings类实例,mFileName为配置文件所在地址。
mPortName = iniConfigFile.value("serialport/portname").toString();//读取ini文件中的数据
...
}
//===============配置并开启串口的函数=================//
bool MainWindow::startSerialPort()
{
mSerialPort.setPortName(mPortName);//配置端口
...
return mSerialPort.open(QSerialPort::ReadWrite);//打开串口,打开成功则会返回true,反之则返回false
}
代码说明:
- 在槽函数中首先判断标志变量mIsOpen的状态(true/false),如果为true,则点击按钮2后,槽函数执行关闭串口的动作;如果为false,则槽函数执行打开串口的动作。
- 关闭串口只需要执行QSerialPort类的.close()函数,然后对相关按钮标签文字做更改,以及使能按钮1即可。
- 打开串口需要分三步执行代码:第一步读取配置文件(readConfigFile()),第二步配置串口并打开串口(startSerialPort()),第三步根据startSerialPort()函数的返回值判断串口是否打开成功。若打开成功,则更改按钮标签文字,禁用按钮1;反之若打开失败,则弹出警告窗口(这里使用QT自带的QMessageBox类即可)。
- 对于readConfigFile()函数,我这里使用QSettings类来读取ini文件,关于ini文件的介绍可以见这里:CSDN:ini配置文件格式。QSettings读取ini文件时只需要先创建一个QSettings类实例iniConfigFile,然后调用iniConfigFile.value()函数对想要的数据读取即可,读取到的数据存储到类的成员变量(如mPortName)中以供后面的函数使用。具体可查看QT官方的帮助文档。
- 对于startSerialPort()函数,根据成员变量(如mPortName)的值配置串口并打开,配置时使用对应的.set***()函数即可。打开串口时调用.open()函数即可。注:这里配置串口时除了.setPortName()函数以外,其他的配置函数都不能将QString类型数据直接当做参数传入函数中,故我的源代码中使用了switch-case语句,详情请参考源代码。
- 关于配置文件config.ini,我将其放在了当前程序所在的文件夹下,每次打开程序时都会检测是否存在这个配置文件,如果不存在则会生成一个config.ini文件,并写入默认的配置。主程序的检测文件以及生成文件的代码如下:
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); mIsOpen = false; //第一次打开时将标志变量mIsOpen置为false //第一次打开程序时会创建配置文件 //mFileName mFileName = QCoreApplication::applicationDirPath() + "/config.ini"; QFile file(mFileName); if(!file.exists()) //判断是否有配置文件,如果没有则在这里执行新建配置文件的动作 { file.open(QIODevice::WriteOnly); file.close(); //file.open()打开一个不存在的文件时会新建这个文件 makeConfigFile();//在这个函数中对ini文件写入数据 } } //=================对ini文件写入数据 void MainWindow::makeConfigFile() { QSettings iniConfigFile(mFileName,QSettings::IniFormat); iniConfigFile.setValue("serialport/portname","COM1"); ... }
- 在makeConfigFile()函数中,QT对ini文件的写入与读取类似,也是先要创建一个QSettings类实例iniConfigFile,然后调用内部的iniConfigFile.setValue()函数即可。
3. 显示组
显示组为主窗口的第二个部分,UI界面如图
说明:
- 该组共三个控件,分别为一个显示窗口,两个按钮。两个按钮分别为测试按钮和清空按钮。
- 显示窗口用来显示串口接收到的数据,测试按钮用来测试是否与遥控小车连接成功,清空按钮用来清空显示窗口的内容
a. 实现显示窗口显示接收到的数据
功能描述:开启串口后,当串口中接收到数据时,显示窗口(BrosMessage)会将接收到的数据显示在窗口中。该功能主要用来接受遥控小车发出的验证信息,即监控小车的当前运动状态。
代码片段:
// MainWindow初始化函数中添加的代码
connect(&mSerialPort, SIGNAL(readyRead()), this, SLOT(on_readyRead()));//当串口有数据传来时,读取串口数据,显示在textbrowser上。
//on_readyRead()函数
void MainWindow::on_readyRead()
{
if(mIsOpen)
{
//当前串口已打开,这里执行接收文字并显示的动作
QByteArray recvData = mSerialPort.readLine();//readLine():按行读取串口数据
ui->BrosMessage->append(QString(recvData));//append():将数据显示到显示窗口中
}
}
代码说明:
- MainWindow初始化函数中添加的代码:该代码片段使用connect函数将串口信号与对应的槽函数关联起来。connect函数需要指明四个参数,分别为谁发出信号,发出什么信号,谁接收信号,怎么处理信号。对于该代码,mSerialPort实例发出信号,发出的是该实例具有的readyRead()信号,该信号有效则意味着串口接收到了数据。然后是this,也就是该MainWindow来接收信号,并使用on_readyRead()函数处理信号。
- on_readyRead()函数:这个函数用来执行接收到串口信号后程序的动作,分为两步:第一步是读取串口中的数据,按行读取;第二步是将数据显示到显示窗口BrosMessage中。第一步中使用QSerialPort类自带的readLine()函数,第二步中使用QTextBrowser类自带的append()函数。注意在声明on_readyRead()函数时需要将其声明为slot函数类型。
b. 实现点击测试按钮时发出测试指令
功能描述:点击测试按钮(BtnConnectTest)时,程序立刻通过串口发出测试指令。
代码片段:
void MainWindow::on_BtnConnectTest_clicked()
{
mSerialPort.write(QByteArray::fromHex("1F"));//发送测试代码
}
代码说明:
- on_BtnConnectTest_clicked()为一个槽函数,在该函数中使用QSerialPort类自带的write()即可通过串口发送指定数据。
- 我设计的遥控车指令代码都是两个十六进制数,为正确发送十六进制数,使用了fromHex()函数,该函数可以将字符串形式的十六进制数转换为十六进制字节码流。
c. 实现点击清空按钮时清空显示窗口的内容
功能描述:点击清空按钮(BtnClearMessage)时,程序将显示窗口(BrosMessage)中的所有文字清空。
代码片段:
void MainWindow::on_BtnClearMessage_clicked()
{
ui->BrosMessage->clear();
}
代码说明:
- 这里同上一步类似写一个槽函数命名为on_BtnClearMessage_clicked(),在该函数中,调用QTextBrowser类的clear()函数,实现清空窗口的功能。
4. 控制组
控制组为主窗口的第三个部分,UI界面如图:
说明:
- 该组由8个按钮组成,每个按钮的功能统一,即按钮按下后程序会通过串口发出相应的指令,从而控制遥控小车的状态。
- 指令与功能的对应如下:
前进: 00 右旋转: 04 后退: 01 左旋转: 05 右转: 02 加速: 06 左转: 03 减速: 07 直走: 0a 停止(刹车):ff
a. 实现点击控制按钮时发出相应控制指令
功能描述:某一个控制按钮按下,程序跳转到对应的槽函数,槽函数中实现向串口写数据的功能。
代码片段:
void MainWindow::on_BtnGo_clicked() //按钮 BtnGo 按下时的槽函数
{
mSerialPort.write(QByteArray::fromHex("00"));
}
void MainWindow::on_BtnBack_clicked() //按钮 BtnBack 按下时的槽函数
{
mSerialPort.write(QByteArray::fromHex("01"));
}
void MainWindow::on_BtnRight_clicked() //按钮 BtnRight 按下时的槽函数
{
mSerialPort.write(QByteArray::fromHex("02"));
}
...
代码说明:
- 每一个槽函数都调用相同的函数write()函数,不同的只是传入的参数有区别。其余与测试按钮部分操作原理相同。
5. 菜单栏
菜单栏设计如下:
说明:
- 该菜单栏只有一个选项卡,即“帮助”,在该选项卡下有两个子菜单,分别为“说明”和“关于…”,点击说明栏可弹出新窗口,用于显示本软件的使用说明;点击“关于…”可弹出一个新窗口,用于显示本软件的制作信息等。
a. 设计菜单栏
在QT中设计菜单栏可以直接使用交互界面进行UI设计,当然,前提是该窗口为mainwindow类型窗口,如果是dialog或者widget,就需要使用代码进行编写菜单栏。
b. 设计菜单栏的槽函数
功能描述:当点击菜单栏中的某个选项时,程序转到执行槽函数,从而弹出相应的窗口。
代码片段:
//信号绑定槽函数写法:
connect(ui->ActIllstrate, SIGNAL(triggered()), this, SLOT(on_DescriptionWindow()));
//槽函数写法:
void MainWindow::on_DescriptionWindow()
{
descriptionWidget *descriptionW = new descriptionWidget;
descriptionW->setWindowModality(Qt::ApplicationModal);
descriptionW->show();
}
代码说明:
- 该部分的代码与前文点击串口配置按钮的槽函数写法类似,同样也需要先新建一个qt设计师界面类,并加入到mainWindow.h文件中。
- 两个菜单选项的槽函数写法类似,具体见代码源文件。
6. 子窗口设计
子窗口界面设计如下:
说明:
- 该部分分为两个板块,分别为上板块与下板块:上板块中有5个静态标签Label,分别命名为串口的相关参数名,以及对应5个下拉选择框;下板块有两个按钮控件,分别命名为确定和取消。
- 对于上板块,与用户产生交互的控件只有那5个下拉框,第一个下拉框中的选项为可用的串口选项,该选项内容在启动程序时自动填充。后面4个下拉框选项固定,在UI界面设计时就填充好,不需要使用逻辑代码编写。
- 对于下板块,交互按钮分别为确定按钮和取消按钮。点击确定按钮时,程序将下拉框中的选项存储在config.ini文件中,然后关闭子窗口。点击取消按钮时,程序将只关闭子窗口。
- 除了上述两个板块的固定功能外,设计子窗口时还应实现以下功能:在开启子窗口时,程序能将当前的串口配置信息显示在下拉框中,即下拉框中的默认选项为当前配置的选项。
a. 实现下拉框的自动填充
功能描述:每次弹出子窗口时,程序能自动将当前已连接的所有串口端口名自动填充到串口名下拉框中。
代码片段:
newWindow::newWindow(QWidget *parent) :
QWidget(parent),
ui(new Ui::newWindow)
{
ui->setupUi(this);
//获取当前已连接的串口号,并将其填入到下拉框选项中
QList<QSerialPortInfo> InfoSerialPort = QSerialPortInfo::availablePorts();
int cnt = InfoSerialPort.count();
for(int i=0; i<cnt; i++)
{
ui->CboxPortName->addItem(InfoSerialPort.at(i).portName());
}
}
代码说明:
- 代码中QSerialPortInfo::availablePorts是QSerialPortInfo中的静态变量,可不需实例化对象直接调用。availablePorts中存储的是当前已连接上的串口信息。
b. 实现点击确定按钮时保存配置且关闭窗口
功能描述:点击确定按钮时,程序将当前下拉框的选项内容写到配置文件中,然后在关闭窗口。此过程的槽函数分为三步:1. 读取当前下拉框的内容,2. 写入配置文件,3. 关闭窗口。
代码片段:
void newWindow::on_BtnCommit_clicked()
{
readCbox();
makeConfigFile();
this->close();
}
void newWindow::readCbox()
{
mPortName = ui->CboxPortName->currentText();
...
}
void newWindow::makeConfigFile()
{
QSettings iniConfigFile(fileName,QSettings::IniFormat);
iniConfigFile.setValue("serialport/portname",mPortName);
...
}
代码说明:
- readCbox()为槽函数的第一步,调用QComboBox类的currentText()或currentIndex()以获取当前选择的内容。
- makeConfigFile()为槽函数的第二步,此函数与前文主窗口的makeConfigFile()函数写法相同,此处不多解释(注:这里可能有人会问了,既然两个函数相同,为啥不写成static类型函数然后直接调用呢?原因就是这个函数对类的成员变量有操作,改成static函数类型的话需要将成员变量弄成全局变量,整个工程就要大改,有点麻烦,感兴趣的朋友可以试一试。)
- this->close()为槽函数的第三步,该函数可以直接将当前窗口关闭。
c. 实现点击取消按钮时关闭子窗口
功能描述:点击取消按钮时,直接等效于关闭该窗口。
代码片段:
void newWindow::on_BtnCancel_clicked()
{
this->close();
}
代码说明:
- 该槽函数直接调用this->close()就行,与点击确定按钮槽函数的最后一步相同。
d. 开启子窗口时的初始化动作(实现当前串口配置的显示)
功能描述:每次弹出子窗口时,保证子窗口中的下拉框默认选项为当前配置选项。该过程分为两步:1. 读取配置文件,2. 将参数显示到下拉框中。要使每次初始化时都能执行此过程,将该过程添加到初始化的函数中就行。
代码片段:
newWindow::newWindow(QWidget *parent) :
QWidget(parent),
ui(new Ui::newWindow)
{
ui->setupUi(this);
//获取当前已连接的串口号,并将其填入到下拉框选项中
QList<QSerialPortInfo> InfoSerialPort = QSerialPortInfo::availablePorts();
int cnt = InfoSerialPort.count();
for(int i=0; i<cnt; i++)
{
ui->CboxPortName->addItem(InfoSerialPort.at(i).portName());
}
//设置下拉框中的默认选项为当前配置选项
readConfigFile();
setCbox();
}
void newWindow::readConfigFile()
{
QFile file(fileName);
QSettings iniConfigFile(fileName,QSettings::IniFormat);
mPortName = iniConfigFile.value("serialport/portname").toString();
...
}
void newWindow::setCbox()
{
ui->CboxPortName->setCurrentText(mPortName);
...
}
代码说明:
- 我在初试化函数中添加了两个函数过程,一个是readConfigFile(),另一个是setCbox(),分别完成读取配置文件和设置当前下拉框默认选项的动作。
- readConfigFile()与主窗口的readConfigFile()一致,此处不多叙述。
- setCbox()主要调用setCurrentText()来实现更改下拉框的当前选项。
7. 键盘按键检测的实现
键盘按键检测并没有UI设计,因此放在最后来讲。
功能描述:该部分的功能为当连接好遥控小车时,按下键盘上的上下左右按键可以分别控制遥控小车的前进后退转向。抬起按键时遥控小车又将归位。按键逻辑如下表所示:
up :前进 Ctrl+right :顺时针旋转
down :后退 Ctrl+left :逆时针旋转
right:右转 Z :加速
left :左转 X :减速
代码片段:
// keyPressEvent()函数重写
void MainWindow::keyPressEvent(QKeyEvent *event)
{
if(!event->isAutoRepeat())//解释见代码说明
{
if(mKeyFlag)//判断是否按下Ctrl键
{
switch (event->key()) //判断按下的是哪个按键
{
case Qt::Key_Left:
on_BtnCcw_clicked();
break;
case Qt::Key_Right:
on_BtnCw_clicked();
break;
}
}
else
{
switch (event->key()) //判断按下的是哪个按键
{
case Qt::Key_Up:
on_BtnGo_clicked();
break;
case Qt::Key_Down:
on_BtnBack_clicked();
break;
case Qt::Key_Left:
on_BtnLeft_clicked();
break;
case Qt::Key_Right:
on_BtnRight_clicked();
break;
case Qt::Key_Z:
on_BtnSpeedUp_clicked();
break;
case Qt::Key_X:
on_BtnSpeedDown_clicked();
break;
case Qt::Key_Control: //如果按下Ctrl键则将mKeyFlag设为true
mKeyFlag = true;
break;
}
}
}
}
// keyReleaseEvent()函数重写
void MainWindow::keyReleaseEvent(QKeyEvent *event)
{
if(!event->isAutoRepeat())
{
if(mKeyFlag)//判断是否按下Ctrl键
{
switch (event->key()) //判断松开的是哪个按键
{
case Qt::Key_Left:
getStraight();
break;
case Qt::Key_Right:
getStraight();
break;
case Qt::Key_Control:
mKeyFlag = false; //如果松开Ctrl键则将mKeyFlag设为false
break;
}
}
else
{
switch (event->key()) //判断松开的是哪个按键
{
case Qt::Key_Up:
on_BtnStop_clicked();
break;
case Qt::Key_Down:
on_BtnStop_clicked();
break;
case Qt::Key_Left:
getStraight();
break;
case Qt::Key_Right:
getStraight();
break;
}
}
}
}
代码说明:
- 因为要进行键盘按键检测,故我使用的是QT自带的QKeyEvent类来实现,使用方法为:现在mainWindow头文件中导入QKeyEvent类,再在cpp文件中重写keyPressEvent()和keyReleaseEvent()函数。在QKeyEvent的逻辑中,当键盘事件触发时,程序会回调这两个函数(即keyPressEvent和keyReleaseEvent),而QKeyEvent的定义中本身并没有这两个函数的内容,即回调之后又会立马退出这两个函数,像无事发生一样。故我们只需要在主窗口文件中对这两个函数进行重写,让程序做一些事情,即可实现键盘的按键检测的功能。
- 代码中判断按键键值前,有2个if判断语句,第一个是!event->isAutoRepeat(),判断按键来源是否是键盘自动重复按下信号发出的,第二个是mKeyFlag,该信号为mainWindow类的成员变量,初始化时设置为false,意味着当前ctrl键并未按下。
- event->isAutoRepeat()函数解释:理论上讲,当按下键盘上的按键时,keyPressEvent()就会接收到一个按键信号,如果长按某一按键时,键盘就会持续发送按下的信号给keyPressEvent(),即每隔几十毫秒keyPressEvent()就会接收到一个按键被按下的信号,除了第一个按下信号是人为触发的,后面的按下信号都是键盘自动触发的。而实际上我们只想要在按下按键时程序执行按键检测函数即可,我们并不关心按了多长时间。因此我使用了按键信号event内部的函数isAutoRepeat(),该函数的返回值标志了当前按键事件是否为键盘自动触发,使用该函数就可以滤除掉那些自动触发的按键事件。
- mKeyFlag成员变量的解释:由于控制遥控小车自转时需要用到组合按键Ctrl+left/right,因此我用mKeyFlag来标志Ctrl键是否被按下,在Ctrl键被按下和没被按下后按方向键对应的功能不同,也即对应的程序操作不同。
三、总结
到这里,整个程序就差不多完成了。文章中有部分逻辑不清晰或概念讲述有问题的地方请评论指出,我会不定时查看评论并做出更改。
附:代码源码在此处:百度网盘(提取码:adm9 )
遥控小车硬件部分见此处:STM32遥控小车下位机及硬件连接部分(CSDN)
版权声明:本文为CSDN博主「Neo Qian」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/jkjijijkv/article/details/119965525
暂无评论