提示:本博客作为学习笔记,有错误的地方希望指正,此文可能会比较长,作为学习笔记的积累,希望对来着有帮助。
绪论:笔者这里使用的是QTCreator和C++来实现一个简单的串口上位机的开发的简单过程,使用到C++,主要是为了后面使用Python开发上位机打下基础。这里主要分为初识C++的一些基本知识,其次是QT Creator的使用。特别声明感谢【北京迅为】嵌入式学习之QT学习篇的学习视频,这里的学习笔记就是根据视频中的内容实战记录下来的。B站演示视频
一、C++知识
1、初识C++
什么是C++,C++就是C语言的升级版本,C++面向对象,而C是面向过程的一种语言,是一种高级语言。
2、C++输入输出控制
C++中使用cout 作为输出控制,使用cin作为输入控制,相比C语言更加灵活和方便。
2.1、C++的输出控制cout
在C++中使用的是cout来控制输出的,与c中的printf不同的是cout可以实现数据类型的相应调整行为,使用printf的输出需要时候需要使用输出控制符%d,%c,%u等等的方式,但是C++中使用cout不需要使用输出控制符。需要包含#include <iostream>
的头文件
因为cout是std库中的一个类的对象,所以使用的方法是 std::cout ,如下
例如:cout << "Hello World!" << endl;
2.2、C++的输入控制cin
cin是C++编程语言中的标准输入流对象,即istream类的对象。cin主要用于从标准输入读取数据,这里的标准输入,指的是终端的键盘。此外,cout是流的对象,即ostream类的对象,cerr是标准错误输出流的对象,也是ostream 类的对象。这里的标准输出指的是终端键盘,标准错误输出指的是终端的屏幕。
在理解cin功能时,不得不提标准输入缓冲区。当我们从键盘输入字符串的时候需要敲一下回车键才能够将这个字符串送入到缓冲区中,那么敲入的这个回车键(\r)会被转换为一个换行符\n,这个换行符\n也会被存储在cin的缓冲区中并且被当成一个字符来计算!比如我们在键盘上敲下了123456这个字符串,然后敲一下回车键(\r)将这个字符串送入了缓冲区中,那么此时缓冲区中的字节个数是7 ,而不是6。
cin读取数据也是从缓冲区中获取数据,缓冲区为空时,cin的成员函数会阻塞等待数据的到来,一旦缓冲区中有数据,就触发cin的成员函数去读取数据。
使用cin从标准输入读取数据时,通常用到的方法有cin>>,cin.get,cin.getline。
3、C++对象和类
其中C++的灵魂在于C++的类,类的关键字是使用class处理,在类中包含的是成员变量, C++的类和C语言中的结构体有点相像,类中的成员变量就有点类似C中结构体的成员变量,类中的成员变量不仅是变量还可以是函数,对象就是类的实例化。类似于定义结构体变量。
一个简单的C++类例子:
class student
{
public: //类的公共变量
student(); //构造函数,对象实例化(创建)的时候调用
student(int a); //构造函数的重载函数
~student(); //析构函数,对象删除的时候调用
char name[10];
int age; //类的成员变量
void Test1(void){
//在类中声明和实现函数
cout<<"My name is Test1"<<endl;
};
void Test1(int a); //类函数的重载特性
void Test2(); //在外部实现类的函数
virtual void Test_Virtual(); //虚函数
virtual void Test_VirtualA(){
}; //纯虚函数
private: //私有变量
char name_haha = 'W';
};
3.1、定义一个对象:
- 直接定义,tudent stu;
- 在堆里面定义,stdent *stu = new student;
- 删除对象,delete stu;
student My_student; //实例化类 My_student(对象)
student * My_student1 = new student; //实例化类 指针
delete My_student1; //删除对象
注意:这样只可以删除在堆里面定义的对象,不能删除直接定义的。目的释放堆里面的内存。
3.2、使用一个对象:
- 直接定义的使用 stu.name;
- 在堆里面定义的使用 stu->age;使用指针访问。
C++访问对象方法和C语言访问结构体的方法一样的,直接定义使用“.”来访问,在堆里面定义的指针话使用“->”来访问。
student My_student; //实例化类 My_student(对象)
student * My_student1 = new student; //实例化类 指针
student * My_student2 = new student(168); //实例化类(定义一个对象)调用构造函数的重载函数
My_student.name[0] = 'w'; //使用对象对类中的成员变量设置值
My_student.age = 24; //使用对象对类中的成员变量设置值
My_student.Test2(); //使用类中的函数,使用函数和只用变量一样的
My_student.Test1(); //调用函数
My_student.Test1(168); //调用重载函数
My_student.Test_Virtual(); //调用虚函数
My_student1->age = 24;
My_student1->name[1] = 'w';
3.3、类中的函数
- 再类里面声明
- 实现函数,实现函数可以在类中写,也可以在函数外面写,写在类中会比较长,显得臃肿。在函数外面实现函数的话需要加入函数的类名。需要加入“::”,不加入的话就会默认为普通函数。
class Peple
{
public: //类的公共变量
void Test(void){
//在类中声明和实现函数
cout<<"My name is Test1"<<endl;
};
void Test1();
};
//类外实现函数
void Peple::Test1(void)
{
cout<<"My name is Test1"<<endl;
}
3.4、类的访问修饰符
- public: 表示函数和变量都是公开的,任何人都可以访问。
- private:标志函数的变量只能在自己的类里面自己访问自己,不能通过对象来访问。可以通过类中的函数来实现强行访问私有变量。
- protected:表示函数和变量只能在自己的类里面访问自己,但是可以通过派生类来访问的。
class People
{
public:
char People_name = 168;
private:
char People_name1 = 66;
protected:
char People_name2 = 99;
};
4、C++类属性
4.1、类函数的重载特性
类函数的重载特性就是我们可以在类中定义的函数可以同名,但是参数不同的函数。重载函数的调用的时候会根据参数的个数自动调用哪一个函数。
class People
{
public:
void Test_People_Number(int day);
void Test_People_Number(int day,int time);
};
4.2、构造函数和析构函数
构造函数:假如定义了构造函数,当对象被创建的时候,就会出发这个函数。
析构函数:假如定义了析构函数,当对象被删除或者生命周期结束的时候就会触发析构函数
定义析构函数和构造函数:
- 构造函数和析构函数的名字必须于类名一样的。
- 析构函数要在前面加上一个“~”。
使用new创建的对象是在堆中创建的,函数执行结束后不会自动释放,使用delete就可以实现对象的释放,这个时候就会触发类的析构函数。
构造函数是可以被重载的,析构函数是不会被重载的。当使用构造函数的重载特性的时候,类的实例化(定义一个对象)只能通过堆的方式来实例化一个对象。student * My_student2 = new student(168);
class student
{
public: //类的公共变量
student(); //构造函数,对象实例化(创建)的时候调用
student(int a); //构造函数的重载函数
~student(); //析构函数,对象删除的时候调用
};
student::student() //构造函数
{
cout<<"This is a Create Function"<<endl;
}
student::student(int a) //构造函数的重载函数
{
cout<<"This is a Create Function Data:"<<a<<endl;
}
student::~student() //析构函数
{
cout<<"This is a Delete Function"<<endl;
}
4.3、类的继承
类的继承可以的继承父类中的public和protected的部分,private的部分不能被继承。
当我们觉得这个类不好的时候,可以使用类的继承来添加我们想要的功能。
class 子类: public 父类{
public :
……
protected:
……
}
例如:
class My_new_student:public student{
//类的继承
public:
int grade;
void Test_Virtual(){
//覆盖父类的虚函数
age = 24;
cout<<"This is a Virtual Function Data:"<<age<<endl;
}
};
//类的继承的对象操作
My_new_student my_new_student; //定义一个对象 实例化对象
my_new_student.grade = 99; //设置对象的成员变量的值
my_new_student.age = 20;
在子类去访问父类的成员变量,也是使用“.”和“->”来访问。继承的类同样会调用父类的析构函数和构造函数。
4.4、虚函数和纯虚函数
虚函数:有实际代码定义的,允许派生类对其进行覆盖式的替换,用virtual来修饰。
纯虚函数:没有代码实际定义的虚函数就是纯虚函数。
使用virtua来修饰的,虚函数就是用在类的继承上的,父类的函数可以被子类来修改掉。
class student
{
public: //类的公共变量
virtual void Test_Virtual(); //虚函数
virtual void Test_VirtualA(){
}; //纯虚函数
};
怎样定义一个虚函数?
我们可以在类中写函数,也可以在类外写函数,此时在类外实现函数的话就要继承类。相当于子类可以重新定义这个同名函数,这样就会实现子类的内容覆盖父类中同名的虚函数。
class My_new_student:public student{
//类的继承
public:
int grade;
void Test_Virtual(){
//覆盖父类的虚函数
age = 24;
cout<<"This is a Virtual Function Data:"<<age<<endl;
}
};
此外虚函数的主要作用可以预留接口实现分工合作。
二、QT知识
2.1、初识QT
Qt的跨平台特性非常的强,一套代码不用修改太多,直接通用所有的平台。
可运用于MCU上,QT的三驾马车,QT下的串口串口编程、QT下的网络编程、QT下操作GPIO。
2.2、QT界面基础认知
基本显示界面。
可以在菜单栏中的控件显示左右便编辑框。
2.3、创建QT工程
1、打开软件
2、新建工程
操作步骤New Project–>Application–>Qt Widgets Application–>Choose
实现工程名和工程路径的设置和选择。
选择默认的编译器–>选择基类
2.4、创建QT工程的文件
1、工程文件
2、UI文件
2.5、制作一个简单QQ登录界面
1、设置编辑UI
使用到的组件:图片、文本、gif图的组件式qlabel
放置账号和密码的对话框我们用的组件式qlinedit
按钮我们使用的是qpushbutton
实现步骤,先实现页面布局的布置,需要注意的是密码的输入类型我们设置为密码就行了。设置echoMode(回显模式)
2、修改控件的名称
修改控件的名称方便我们在对控件使用代码操作的时候直观的理解。只需要我们在右侧菜单栏中选择对应的子控件双击即可实现修改控件的名称。
三:QT信号于槽
3.1、信号于槽基本概念
信号:信号是指控件发出特定的信号,例如pushbutton中的信号如下所示。
槽:槽就是槽函数,我们可以把槽绑定在某一个控件的信号上。
信号和槽的概念就类似于单片机中的中断一样的,中断外面有一个事件触发,然后执行对应的中断处理函数。
3.2、信号和槽的关联
自动关联:选中要绑定的控件,右键选择转到槽,选择对应槽事件。对应有触发界面的操作可以用自动关联。
当使用自动关联的时候,这个时候就会在C++的widget类中添加私有槽函数,同时也在widget.cpp中添加对应的槽函数,我们可以通过这个槽函数实现我们想要的交互。
private slots:
void on_pushButton_3_clicked();
槽函数只能声明在private slots或者public slots下面,注意这个只是QT下面才有的slots,标准的C++中没有。
手动关联:适用于无界面操作的环境。使用connect函数连接,
connect(ui->Log_in,SIGNAL(clicked()),this,SLOT(on_Log_In_clicked()));
connect(A, SIGNAL(B),C,SLOT(D));解释就是当事件A发出B信号时,就会触发对象C的槽函数D
手动生成关联函数:
四:QT控件介绍
4.1、添加图图片
1、给工程添加源文件,点击工程右键,添加新文件。
2、选中QT,QT Resource File,选择
添加资源文件以后QT会自动给我们添加好所包含的资源文件以及自动创建资源文件。
3、在工程文件夹中点击Resource 文件夹下的qrc,右键嗲选中Open With 资源编辑器
这里我们需要先添加前缀,目的是资源文件的索引是按照相对路径索引的。只有先添加前缀之后,才可以实现添加文件,不然添加文件是灰色的。
添加文件,这里的前缀就默认/,可以根据文件夹来索引不同文件夹下面的资源文件。例如我不希望图片和我的工程放在同一个目录下,这样显得比较杂乱,因此我新建一个picture的文件夹,将所有用到的文件放置在里面的话,就可以添加前缀为 /picture即可。一定要保存了,才可以显示添加文件。
4、代码中引用源文件
这里我们可以添加不同类型的图片给控件,其中包括背景图片,边框图片以及普通图片,添加背景图片会根据控件的大小自动添加好多阵列的图片,边框图片的话就只有一个,并且随着控件大小变化压缩等,普通图片的话控件比原始图片小的话就等比缩放,控件比原始图片大的时候就保持原始图片大小不变。
5、QT中多种布局文件,存在水平布局、垂直布局、栅格布局,打破布局几种布局方式。
注意:当使用几种布局的时候要可以根据窗格大小开变大小的时候要加入弹簧,在加入弹簧的时候会出现图片不存在的情况的时候,这时候选中要进行布局的几个控件,然后点击右侧的属性栏中的最小尺寸修改大小即可。
4.2、QT输入框
4.2.1、使用控件,使用Line Edit 控件
4.2.2、检测输入信息获取
void Widget::on_LoginButton_clicked()
{
QString Account = ui->AccountEdit->text(); //获取输入框的信息
QString Passworld = ui->PasswordEdit->text(); //获取密码输入
if(Account == "123" && Passworld == "123"){
MainScreen * mainscreen = new MainScreen; //实例化对象
mainscreen->setGeometry(this->geometry()); //获取当前窗口xy的数值,设置窗口
mainscreen->show(); //显示窗口
}
}
4.3、QT按键控件
这里按键使用的是push Button作为按键的显示,按键可以添加信号与槽,此外按键还可以添加图片,和前面的图片添加也是一样的。
五:QT添加新的界面
5.1、添加新文件
1、在工程文件名右键添加新文件,选择QT,QT 设计师界面
5.2、布局界面
和上述步骤一样可以自定义布局文字和图片按键。这里设置的布局如下所示。其中图标可以在这个图标库下载
5.3、实现按钮点击界面跳转
首先在槽函数对应的.cpp 文件中包含对应跳转界面的头文件,#include “mainscreen.h”,添加对应的窗口适应函数。这里在widget.cpp中的槽函数中添加如下的代码,意思就是当输入框内有输入信息的时候,并且输入正确,当点击登录的时候会跳转到对应的界面中去。
void Widget::on_LoginButton_clicked()
{
QString Account = ui->AccountEdit->text(); //获取输入框的信息
QString Passworld = ui->PasswordEdit->text(); //获取密码输入
if(Account == "123" && Passworld == "123"){
MainScreen * mainscreen = new MainScreen; //实例化对象
mainscreen->setGeometry(this->geometry()); //获取当前窗口xy的数值,设置窗口
mainscreen->show(); //显示窗口
}
}
5.4、实现界面的关闭
而咋对应的MainSreen.cpp中文件中我们也先设置好设置的图标的信号与槽,在槽函数中添加界面关闭,当点击图片的时候就会关闭当前的界面。
void MainScreen::on_BUtton_Set_clicked()
{
this->close();
}
以上就是实现对于整个企鹅登陆界面的设置的全部过程。
六:QT上位机
6.1、新建工程及其工程操作
这里新建工程文件和前面的一样这里就不再叙述了。设置窗口的大小。我这里设置窗口的大小为800X480的大小,可以设置其他大小的参数。
6.2、绘制界面
这里使用的接收框是使用plain text edit纯文本编辑,并且属性设置为只读的状态,这样就不能在接收框中写数据。
下拉选择使用的是QComboBox ComboBox 不可编辑组合框 。
其中双击就可以修改添加其属性,设置其不可编辑组合框的一些参数
在上述的默认排序中,默认显示是第一位的,但是这个不是我们想要的默认排序,这个时候我们可以通过属性设置其默认显示的序列。设置当前index即可。
这里使用Line Edit作为数据发送框,其中串口的基本特性使用网格布局设置的。
接下来就是改我们控件的名字了,不然后面编写代码的时候头秃模式,自己给自己绕晕了。
最后的界面效果图如下所示。
6.3、编写代码
首先要在工程文件中添加串口模块,添加 serialport。
6.3.1、串口参数的设置
要实现对于串口参数的设置的话,我们的做法就是在点击打开按钮时候首先要获取界面中配置串口的不可编辑组合框中的数据。
因此在这里我们首先要实现对于打开按键实现信号与槽的设置,设置方法这里使用自动关联的。
串口数据获取部分,我这里是通过获取ui中控件的currentIndex的属性来获取当前配置信息的ID,再将这个ID的值传给数组,其中数组的大小对QSerialPort下面各个串口参数的enum的个数。这样可以通过索引获取值,省的代码判断。
此外这里加入了判断串口打开时候的状态判断,之前打开过了就不用打开,并且通过MessageBox提示显示打开错误。
串口关闭的实现就也是一样的,也是自动关联信号与槽就可以实现了。在槽函数中添加serialPort->close(); 就可以实现关闭串口。
串口发送信息的话也是通过自动关联信号与槽的,数据来源是要获取发送框的数据,然后将发送框中的数据转换成char类型的数据。
串口清空的实现也是自动关联信号与槽,通过对serialPort类实行serialPort->close();操作即可实现对串口的关闭
串口数据接收就比较复杂些,需要手动去关联信号与槽,因为我们的信号是没有在界面上有显示的,只能通过手动关联实现。首先获取串口的buff,然后直接将buff在窗口打印即可。
直接贴代码:
在widget.cpp中的文件代码
#include "widget.h"
#include "ui_widget.h"
#include "QSerialPortInfo" //包含串口头文件
#include "QMessageBox"
Widget::Widget(QWidget *parent) :
QWidget(parent),
ui(new Ui::Widget)
{
ui->setupUi(this);
QStringList serialNamePort; //串口字符列表
serialPort = new QSerialPort(this); //实例化对象
connect(serialPort,SIGNAL(readyRead()),this,SLOT(plainTextEdit_SerialInputData_ReadyData_Slot()));
foreach (const QSerialPortInfo &info,QSerialPortInfo::availablePorts()) {
//获取串口信息
serialNamePort<<info.portName(); //将信息打印到列表中
}
ui->SerialCom->addItems(serialNamePort);//将列表中的数据填充到ui的控件中
}
Widget::~Widget()
{
delete ui;
}
void Widget::on_Button_Serial_Open_clicked()
{
QSerialPort::BaudRate baudrate_arr[] = {
QSerialPort::Baud2400,QSerialPort::Baud2400,QSerialPort::Baud4800,QSerialPort::Baud9600,QSerialPort::Baud19200,QSerialPort::Baud38400,QSerialPort::Baud57600,QSerialPort::Baud115200};
QSerialPort::DataBits databits_arr[] = {
QSerialPort::Data5,QSerialPort::Data6,QSerialPort::Data7,QSerialPort::Data8};
QSerialPort::StopBits stopbits_arr[] = {
QSerialPort::OneStop,QSerialPort::OneAndHalfStop,QSerialPort::TwoStop};
QSerialPort::Parity parity_arr[] = {
QSerialPort::NoParity,QSerialPort::EvenParity,QSerialPort::OddParity};
QSerialPort::BaudRate baudrate;
QSerialPort::DataBits databits;
QSerialPort::StopBits stopbits;
QSerialPort::Parity parity;
baudrate = baudrate_arr[ui->SerialBaud->currentIndex()]; //获取窗口串口波特率
databits = databits_arr[ui->SerialData_Bits->currentIndex()]; //获取数据位
stopbits = stopbits_arr[ui->SerialStop_Bits->currentIndex()]; //获取停止位
parity = parity_arr[ui->Serial_Parity->currentIndex()]; //获取奇偶校验位
serialPort->setPortName(ui->SerialCom->currentText()); //获取串口号
serialPort->setBaudRate(baudrate); //获取波特率
serialPort->setDataBits(databits); //设置数据位
serialPort->setStopBits(stopbits); //设置停止位
serialPort->setParity(parity); //设置奇偶校验位
if(serialPort->open(QIODevice::ReadWrite) == true){
//获取串口打开状态
QMessageBox::information(this,"提示","成功"); //使用MessaBox的形式进行交互显示
}else{
QMessageBox::critical(this,"提示","失败"); //使用MessaBox的形式进行交互显示
}
}
void Widget::on_Button_Serial_Close_clicked()
{
serialPort->close(); //关闭串口
}
void Widget::plainTextEdit_SerialInputData_ReadyData_Slot()
{
QString buff; //定义数据流
buff = QString(serialPort->readAll()); //读取数据
ui->plainTextEdit_SerialInputData->appendPlainText(buff); //显示数据
}
void Widget::on_Button_Serial_SendData_clicked()
{
serialPort->write(ui->LineEdit_Serial_InputData->text().toLocal8Bit().data()); //将数据转换成char类型的
}
void Widget::on_Button_Serial_CleanData_clicked()
{
ui->plainTextEdit_SerialInputData->clear(); //清除串口窗口显示信息
}
在widget.h中实现代码。
#ifndef WIDGET_H
#define WIDGET_H
#include <QWidget>
#include <QSerialPort>
#include "QString"
namespace Ui {
class Widget;
}
class Widget : public QWidget
{
Q_OBJECT
public:
explicit Widget(QWidget *parent = 0);
~Widget();
QSerialPort *serialPort; //定义串口接口变量
private slots:
void on_Button_Serial_Open_clicked();
void on_Button_Serial_Close_clicked();
void plainTextEdit_SerialInputData_ReadyData_Slot();
void on_Button_Serial_SendData_clicked();
void on_Button_Serial_CleanData_clicked();
private:
Ui::Widget *ui;
};
#endif // WIDGET_H
在工程文件夹下实现的代码
#-------------------------------------------------
#
# Project created by QtCreator 2022-05-15T21:08:55
#
#-------------------------------------------------
QT += core gui serialport
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = QTUart
TEMPLATE = app
# The following define makes your compiler emit warnings if you use
# any feature of Qt which has been marked as deprecated (the exact warnings
# depend on your compiler). Please consult the documentation of the
# deprecated API in order to know how to port your code away from it.
DEFINES += QT_DEPRECATED_WARNINGS
# You can also make your code fail to compile if you use deprecated APIs.
# In order to do so, uncomment the following line.
# You can also select to disable deprecated APIs only up to a certain version of Qt.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0
SOURCES += \
main.cpp \
widget.cpp
HEADERS += \
widget.h
FORMS += \
widget.ui
RC_ICONS = uart_icon.ico
6.3.2、打包成window软件
需要将程序给不同的成员使用,源码的保密性和安全性。
打包步骤:
1、将工厂切换到release模式下,然后编译,在这个模式下就没有调试信息了。
2、打开工程文件夹中,在该文件夹中会有添加Release的文件夹,这个时候就会有exe的可执行文件,但是这个时候不能打开,因为还缺少一些动态库。
3、修改图标,但是值得注意的是图标的格式只支持.ico的格式,这里我使用的是在线图片转ico的一个网站,将图标拷贝到我们的工程文件夹中,在我们的工程文件夹目录下添加图标资源 RC_ICONS = uart_icon.ico(图片资源名)。
接着编译一下工程,就可以在release的文件夹中看到.exe可执行文件的图标改变了。
4、封包,添加一些动态库。需要QT的控制台。
当有时候直接点击打不开的时候就可通过win窗口找到QT的安装文件夹,然后找到以后以管理员身份运行即可。
接着创建一个文件夹用来存放QT的动态库和.exe文件的,注意这个文件夹不能含有中文路径,我这里直接在桌面创建名叫做QTUart的文件夹,然后将工程文件中的.exe文件拷贝到新建好的文件夹中。
打开QT控制台,然后在控制台中通过命令进入新建好的文件夹中,具体的做法就是输入cd 空格 /d 文件夹路径,这里有一个快速获取文件夹路径的小技巧,就是直接点击文件路径栏,然后就可以显示当前的文件路径,复制即可。
在QT控制台通过dir指令既可实现当前文件夹文件信息的查看。
继续输入指令windeployqt 打包软件名。然后回车,这样的话就会自动的将.exe文件所需要的库自动的加载到我们创建的文件夹中。
回车之后就会显示加载一些库的文件信息,在我们新建的文件夹中也会自动添加一些执行.exe文件所需要的一些库。
在新建的问价夹中双击即可实现打开我们设计的上位机,这样就可以将文件夹打包发给使用者即可。
OK,我们使用QT开始设计我们自己的串口助手简单版本就好了,这个只是一个基础版本,其实里面还需要有好多优化的东西需要优化的。
七、源码下载
QT-Uart源码
今天的文章上位机读取单片机串口数据_上位机读取单片机串口数据分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/83252.html