在该设计中主要需要解决的问题就是接收单片机采集到的数据并在上位机将数字实时的通过波形显示出来,然后上位机要有保存下数据文件的功能,便于后续的软件读取数据做进一步的分析处理。有些人吃相难看,无底线,无道德,鉴于串口上位机会被广泛使用,撰写该教程,从头搭建一个好用的串口示波器。
完整的项目链接见文末
QT第一步:安装软件环境
安装qt5.14,可以在这个网站下载安装包。
下载版本: qt-opensource-windows-x86-5.14.2.exe
安装时需要勾选MinGW 相关选项
安装教程不在重复赘述,网上有很多的例子
第二步:初始QT
qt作为一种开源的UI程序设计框架可以便捷的通过qt提供的各种组件以低代码的方式组件自己需要的ui界面,这对于初步入门的设计人员十分的友好,同时qt官方对每个类、方法、变量的文档说明都非常详细并且提供了实例代码入门非常简单。
安装好qt后直接使用qt官方的Qt Creator程序进行开发,当然你可以使用MSVS进行开发,这还需要在MSVC中安装一下qt的官方插件。我使用的是VS2019+qt5.14.2,在UI设计界面上VS和qt还存在一定的兼容性问题,有好多次出现闪退的问题。所以工程不是特别大的时候还是建议老老实实就用Qt Creator进行开发。
安装好后可以看到qt提供很多的模板程序,当然也可以都不使用,直接从空白模板开始我们的工程
第三步:了解信号与槽机制
Qt利用信号与槽(signals/slots)机制取代传统的callback来进行对象之间的沟通。当操作事件发生的时候,对象会发提交一个信号(signal);而槽(slot)则是一个函数接受特定信号并且执行槽本身设置的动作。信号与槽之间,则透过QObject的静态方法connect来链接。
信号在任何执行点上皆可发射,甚至可以在槽里再发射另一个信号,信号与槽的链接不限定为一对一的链接,一个信号可以链接到多个槽或多个信号链接到同一个槽,甚至信号也可连接到信号。
以往的callback缺乏类型安全,在调用处理函数时,无法确定是传递正确类型的参数。但信号和其接受的槽之间传递的资料类型必须要相符合,否则编译器会提出警告。信号和槽可接受任何数量、任何类型的参数,所以信号与槽机制是完全类型安全。
信号与槽机制也确保了低耦合性,发送信号的类别并不知道是哪个槽会接受,也就是说一个信号可以调用所有可用的槽。此机制会确保当在"连接"信号和槽时,槽会接受信号的参数并且正确执行。
上面的解释来自维基百科,说的简单点呢就是说:当你在qt界面中放置了一个按钮,当你运行程序并移动鼠标点击这个按钮的时候。点击按钮这个动作就是一个信号,当然有了信号我们就要执行命令,我们通过软件定义将这个信号连接上一个槽,这个槽函数执行点击动作所需要的对行功能。信号与槽可以是一一对应也可以是一对多、多对一。
在这里我们可以看到:
QObject::connect(ui->button, SIGNAL(clicked()), this, SLOT(senddata()));
上面这个函数就是一个槽函数,他把button按钮的点击信号链接到了发送数据的响应函数上,只要鼠标点击一次就会发送一次。
在qt中每一个对象都有对应的属性,这些属性的值就对应了这个对象的大小,相对位置,名称等等。
在使用qt时也可以选择pyqt,其中也同样拥有界面设计的功能,但以下的程序默认针对c++版本的qt
开始串口助手
在本次的设计当中,我们的几个核心功能是完成串口数据的收发+波形数据绘制+文件操作。整体的设计思路如下所示:
在程序设计上,首先我们需要一个串口类,用来发送和接收数据;
然后我们需要 两个文本框和几个按钮来实现数据的接收和发送,并且设置串口通信的参数;
实现之后我们就需要设置针对串口数据的解析了。
那么我们创建一个qt工程,使用widget作为默认控件。
我们再widget.h中添加相关的函数和变量,指针申明,然后再widget.cpp中实现我们的具体的函数功能。
定时接收串口数据
由于对电脑端接收数据很难做到硬件级的收中断,收到1bit数据就中断处理一次所以我们设置一个定时器,让程序检测当有数据来时就打开定时器开始定时,定时一段时间后关闭中断并接收保存这段时间内的所有数据。
我们如下定义串口对象:
// .h:void find_port(); //查找可用串口QSerialPort *serialport;void on_open_port_clicked();void on_close_port_clicked();//.cpp://打开串口void Widget::on_open_port_clicked(){ update(); sleep(100); //延时100ms find_port(); //重新查找com //初始化串口 serialport->setPortName(ui->com->currentText()); //设置串口名 if(serialport->open(QIODevice::ReadWrite)) { serialport->setBaudRate(ui->baud->currentText().toInt()); //设置波特率 switch(ui->bit->currentIndex()) //设置数据位数 { case 8:serialport->setDataBits(QSerialPort::Data8);break; default: break; } switch(ui->jiaoyan->currentIndex()) //设置奇偶校验 { case 0: serialport->setParity(QSerialPort::NoParity);break; default: break; } switch(ui->stopbit->currentIndex()) //设置停止位 { case 1: serialport->setStopBits(QSerialPort::OneStop);break; case 2: serialport->setStopBits(QSerialPort::TwoStop);break; default: break; } serialport->setFlowControl(QSerialPort::NoFlowControl); // 设置控件可否使用 timerDrawLine->start(100); ui->send_button->setEnabled(true); ui->close_port->setEnabled(true); ui->save_data->setEnabled(true); ui->open_port->setEnabled(false); } else //打开失败提示 { sleep(100); QMessageBox::information(this,tr("Erro"),tr("Open the failure"),QMessageBox::Ok); }}//关闭串口void Widget::on_close_port_clicked(){ serialport->clear(); //清空缓存区 serialport->close(); //关闭串口 timerDrawLine->stop(); //关闭波形刷新 ui->send_button->setEnabled(false); ui->open_port->setEnabled(true); ui->close_port->setEnabled(false);}
这一段时间根据串口发送一帧数据的时间做合理设置,一般来时这个时候收到的数据里包含着好几帧完整的数据,整段数据的头和尾可能并不是我们设置的帧头和帧尾,所以我们需要从中解析出需要的数据。
假设我们这里使用帧头*
,帧尾#
,然后中间使用多个数字连续,每个数字之间用逗号分开。例如有:*210,13,11,12,130#
这样的一组数据。在查找帧数据时,这里我使用了关键字索引,从接收到的字符串中查找到第一个我设置的帧头数据(这里的*
),然后从这个位置开始向后继续检索第一个我设置的帧尾(这里的#
),然后计算帧头到帧尾的数据长度,长度符合要求的时则把这一段字符串从中提取出来做单独处理,源码如下:
//串口接收数据操作//串口接收数据帧格式为:帧头'*' 帧尾'#' 数字间间隔符号',' 符号全为英文格式void Widget::Read_Date(){ int bufferlens = 0; //帧长 QString str = ui->Receive_text_window->toPlainText(); timerserial->stop();//停止定时器, qDebug()<<buffer; QByteArray bufferbegin = "*"; //帧头 int index=0; QByteArray bufferend = "#"; //帧尾 int indexend = 0; QByteArray buffercashe; index = buffer.indexOf(bufferbegin,index); //查找帧头 indexend = buffer.indexOf(bufferend,indexend); //查找帧尾 if((index<buffer.size())&&(indexend<buffer.size())) { bufferlens = indexend - index + 1; //计算帧长度 buffercashe = buffer.mid(index,bufferlens); //截取出数据帧 } char recvdata[buffercashe.size()]; memset(recvdata,0,sizeof(recvdata)); memcpy(recvdata,buffercashe.data(),bufferlens-1); recvdata[buffercashe.size()-1]=35; if(recvdata[0]=='*'&&recvdata[buffercashe.size()-1]=='#') //二次帧检查 { str_to_num(recvdata); //更新数据并缓存到保存区 str+="succeed:"; //在文本窗口给出提示 str+=tr(buffercashe); str += " "; ui->Receive_text_window->clear(); ui->Receive_text_window->append(str); } else { str+="error! "; //错误处理 str+=tr(buffercashe); str += " "; ui->Receive_text_window->clear(); ui->Receive_text_window->append(str); } buffer.clear();}void Widget::serial_timerstart(){ timerserial->start(4); buffer.append(serialport->readAll());}
在上面的程序中,当串口发现有数据进来则引用Widget::serial_timerstart()
开始定时接收。当定时到之后开始处理接收到的数据。如果数据正常后则把数据保存到缓冲区并更新当前的波形数据。有细心的同学可能会问了:我可以使用readline()函数或者其他read类的函数来读取数据吗?这当然是可以,这里之所以用上定时器,主要是考虑如果下位机的数据是连续不间断发送的,那为了提高数据解析的正确效率,避免上位机串口数据接收缓冲区溢出或者错误的\r\n
影响数据接收,所以我们通过定时接收,这样可以从任何一个定时数据段内保证解析出至少一帧有效的数据。
在上面的图片中很清楚的说明了这样操作的优势。假设最低通信波特率是9600,数据帧最长有35个字节,那么定时器最低应该设置到t=35/9600=0.0036
也就是4ms左右时间。在实际的应用时可以根据项目需求自己自己调整定时时间。
值得强调的一点就是,在完成了相关串口的操作之后,我们还需要将当前解析好的数据存储到一个全局的缓存中,这个缓存可以是一个Vector容器,也可以是动态数组。如果考虑到大数据量的稳定性也可以使用SQL数据来管理数据。这部分可以自行发挥。
波形显示
这里显示模型数据使用了很简单的QChart()实例,定义QSplineSeries()对象,然后不断的更新QSplineSeries对象的数据列表,做好坐标轴的处理后就可以实现出动态曲线的效果了。
//曲线设置初始化void Widget::Chart_Init(){ //初始化QChart的实例 chart = new QChart(); //初始化QSplineSeries的实例 lineSeries = new QSplineSeries(); //设置曲线的名称 lineSeries->setName("曲线1"); //把曲线添加到QChart的实例chart中 chart->addSeries(lineSeries); //声明并初始化X轴、两个Y轴 QValueAxis *axisX = new QValueAxis(); QValueAxis *axisY = new QValueAxis(); //设置坐标轴显示的范围 axisX->setMin(0); axisX->setMax(MAX_X); axisY->setMin(0); axisY->setMax(MAX_Y); //设置坐标轴上的格点 axisX->setTickCount(10); axisY->setTickCount(10); //设置坐标轴显示的名称 QFont font("Microsoft YaHei",8,QFont::Normal);//微软雅黑。字体大小8 axisX->setTitleFont(font); axisY->setTitleFont(font); axisX->setTitleText("X-时间"); axisY->setTitleText("Y-角度"); //设置网格不显示 axisY->setGridLineVisible(false); //下方:Qt::AlignBottom,左边:Qt::AlignLeft //右边:Qt::AlignRight,上方:Qt::AlignTop chart->addAxis(axisX, Qt::AlignBottom); chart->addAxis(axisY, Qt::AlignLeft); //把曲线关联到坐标轴 lineSeries->attachAxis(axisX); lineSeries->attachAxis(axisY); //把chart显示到窗口上 ui->graphicsView->setChart(chart); ui->graphicsView->setRenderHint(QPainter::Antialiasing); // 设置渲染:抗锯齿,如果不设置那么曲线就显得不平滑}//更新曲线函数void Widget::DrawLine(){ if(count > MAX_X) { //当曲线上最早的点超出X轴的范围时,剔除最早的点, lineSeries->removePoints(0,lineSeries->count() - MAX_X); // 更新X轴的范围 chart->axisX()->setMin(count - MAX_X); chart->axisX()->setMax(count); } else{ chart->axisX()->setMin(0); chart->axisX()->setMax(MAX_X); } //增加新的点到曲线末端 lineSeries->append(count, (int)Data.Sensor_1); count ++;}
使用上面的程序我们每调用一次DrawLine()就会在画图区域新增一组数据。
这样我们就有了一个可以画出曲线的界面了。值得一提的是,QT chart控件还提供了柱状图,饼图,折线图,等多种绘图样式,可以自行查看qt的相关文档类似上面的内容撰写绘图界面的数据。
文件保存
首先是说明下csv文件的写入格式:以逗号作为分隔符,\n
作为换行符。
上位机的所有文件操作通过对上述的全局缓冲数据进行操作,在源码中定义为m_data
。从串口接收数据时就把数据写入到m_data中,保存时就把数据从m_data中以此写入到txt文件里。
那么就可以先写入一个表头,然后根据格式从缓存好的数据容器中逐条加载后逐行写入并打上时间戳。
下面的代码举例我们要保存的数据是五通道的。
/* 函 数:SaveRecvDataFile 描 述:保存数据按钮点击槽函数 输 入:无 输 出:无*/void Widget::SaveRecvDataFile(){ if(m_data.size()<1) { QMessageBox::information(this, "提示","当前数据为空"); return; } serialport->clear(); //清空缓存区 serialport->close(); //关闭串口 timerDrawLine->stop(); //关闭波形刷新 ui->send_button->setEnabled(false);//禁用部分按键 ui->open_port->setEnabled(true); ui->close_port->setEnabled(false); ui->save_data->setEnabled(false); QString csvFile = QFileDialog::getExistingDirectory(this); //获取文件保存路径 if(csvFile.isEmpty()) return; QDateTime current_date_time =QDateTime::currentDateTime(); //获取系统时间 QString current_date =current_date_time.toString("MM_dd_hh_mm"); //获取时间字符串 csvFile += tr("/%1.csv").arg(current_date); qDebug()<< csvFile; QFile file(csvFile); if ( file.exists()) { //如果文件存在执行的操作,此处为空,因为文件不可能存在 } file.open( QIODevice::ReadWrite | QIODevice::Text ); //以读写模式读取文件 QTextStream out(&file); out<<tr("Time,")<<tr("sensor1,")<<tr("sensor2,")<<tr("sensor3,")<<tr("sensor4,")<<tr("sensor5,\n"); //写入表头 // 创建 CSV 文件 for (const auto &data : m_data) { //测试格式: *111,222,333,444,555# out << data << "\n"; //顺序将缓冲区数据写入文件 } file.close(); QVector<QString>().swap(m_data); //清空缓存区数据 QMessageBox::information(this, "提示","数据保存成功");}
保存数据效果如下图:
好的,那我们基本实现了最初的三个功能,下面附上一张完整效果的演示截图:
下位机单片机逻辑代码示例
最后,我们还需要下位机的单片机能够对应的将数据发送出来,这里按照上文的描述,给出一个单片机的逻辑结构代码示例:
typedef struct sonsor //定义数据结构{ float sensor1; float sensor2; float sensor3; float sensor4; float sensor5;}sonsor;sonsor cap;int main(void){ Sys_Init(); sonsor_Init();//假设初始化传感器 while(1) { delay_ms(100); cap.sensor1 = sonsor_Read(CHANNEL_1);//假设读取5通道的传感器数据 cap.sensor2 = sonsor_Read(CHANNEL_2); cap.sensor3 = sonsor_Read(CHANNEL_3); cap.sensor4 = sonsor_Read(CHANNEL_4); cap.sensor5 = sonsor_Read(CHANNEL_5); //printf() 需要串口重定向到uart输出 printf("*%0.1f,%0.1f,%0.1f,%0.1f,%0.1f#", cap.sensor1, cap.sensor2, cap.sensor3, cap.sensor4, cap.sensor5); }}
核心程序已经给出,有需要的话自行修改就可以。
项目完整链接: github
为众人抱薪者,不可使其毙于风雪。我们生活在一个信息纷繁的世界,请保持独立思考,尊重他人付出,为自己的努力鼓掌。