单片机串口指令识别技巧
目录
零、引言
0、背景介绍
1、代码下载
一、工程应用
0、前言
1、初始化工程
2、添加文件进工程
3、查看ReceiveCMD.c、ReceiveCMD.h文件
0、单字符接收匹配【void executeCommand(char inputChar)】
1、字符串接收匹配【int executeCommandStr(char* inputStr)】
2、老版本的单字符接收匹配【void executeCommandOld(char inputChar)】
4、添加自定义命令
二、源码详解
0、注册表
1、初始化&注册
2、重写的pow和atof库函数
double fast_power(double n, int m)
double Myatof(const char *string)
3、单字符输入匹配
4、字符串输入匹配
5、老版本单字符输入匹配命令
6、三个命令匹配函数的速度对比
三、总结
零、引言
0、背景介绍
这些年本人大大小小的参加过一些电子类竞赛,在频繁的调参过程中发现,最笨的办法就是一次次的修改源码、编译、下载、查看效果这一种,不仅要频繁的修改源码并编译,工程较小的时候还好,一旦工程后期变得庞大,一次编译下载就需要等待一段不算短的时间,而且需要把设备从赛道或者平台捡回到电脑边,这样费时费力的做法在时间资源紧张的竞赛环境中是不可忍受的。
可能有小伙伴要说:市面上已经有无线DAP这一类可提供远程下载调试的设备了啊,也不需要去弯腰拣设备啊。
但我还是想说,能够在几乎不中断测试过程的条件下调试参数和控制设备,总是要比打断测试更新固件要好的。而且据我观察,无论是身边一起参加竞赛奋斗的同学还是网上激烈讨论的群友,拥有无线串口的人比拥有无线DAP的人要多很多。
(反正我就是要写这个,就给无线串口写的,怎么地吧!PS:当然有线串口也是能用的)
总之,为了更方便、更快速、更优雅的进行单片机设备控制与调参,我设计了一套用着还不错的串口字符/字符串匹配的简易命令识别模块,大约300行代码,小巧可爱、随加随用,目前已在HT32F52352@48MHz、STM32F103C8T6@72MHz、STM32F407VET6@168MHz、STM32H7B0VBT6@280MHz等平台测试通过,测试条件为[波特率115200、停止位1、数据位8、校验位None、硬件流控制无]。
今天分享给同学们,希望能让同学们进一步优雅的调试。
1、代码下载
仓库中UsartCMD文件夹便是本文介绍的模块-gitee无需魔法
一、工程应用
0、前言
同学们如果对本人保佑莫名的、极大的信任,在下载到源码后想立刻添加到自己个工程开始应用,那么请跟随我一步步开始吧!
首先,我要提醒同学们,网上下载的代码一定要抱有怀疑,对于陌生的、冷门的、未经大量验证的项目源码,一定要谨慎使用,最好在一个无关紧要的测试项目中验证过安全性和正确性之后,再添加在真正的应用中。
那么让我们开始吧!
(注:本文默认同学们熟练掌握keil的各类操作,具备一定的C语言编程基础,对32位单片机的嵌入式开发过程有一定的熟练度)
1、初始化工程
此步骤同学们根据自己的要求建立工程即可,但请一定要记得初始化串口以及开启串口接收中断。
如果同学们使用CubeMX初始化STM系列单片机,可以参考这位大佬写的教程,写的巨好!
【STM32】HAL库 STM32CubeMX系列学习教程【阅读量10w+,收藏9.6k,点赞1.4k】
2、添加文件进工程
3、查看ReceiveCMD.c、ReceiveCMD.h文件
在使用前请阅读文件头的注释信息,以避免不必要的问题。
源码中已包含两个默认命令以及五个变量命令,添加文件后无需修改可以直接测试。
来到ReceiveCMD.h文件,翻过一系列的结构体和枚举定义,来到函数的定义:
void CMD_Init(void); // 命令初始化
void executeCommand(char inputChar); // 单字符匹配命令
int executeCommandStr(char* inputStr); // 字符串匹配命令
void executeCommandOld(char inputChar); // 老版本的单字符匹配命令
// 具体的命令注册函数,已在CMD_Init()中调用,用户无需关心
int registerCommand(char* command, CmdFunction function);
int registerVariable(char* name, void* value, value_type type);
我们需要使用的第一个就是CMD_Init()函数,顾名思义就是命令的初始化,我们把她放在main函数的初始化区。
第二个便是命令的接收解析了,本模块目前提供三种接收方法(实际只有两种,同学们自行发现喽)
0、单字符接收匹配【void executeCommand(char inputChar)】
使用方法:将此函数放进串口接收中断中,接收上位机发送给单片机的字符。
STM32-HAL库代码示例:
// ***************************************串口中断回调函数***********************************************
int8_t aRxBuffer;
//extern int8_t aRxBuffer; // 写到main.c
//HAL_UART_Receive_IT(&huart1, (uint8_t *)&aRxBuffer, 1); // 写到main.c
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
executeCommand(aRxBuffer); // 单字符输入
HAL_UART_Receive_IT(&huart1, (uint8_t *)&aRxBuffer, 1); // 再开启接收中断
}
编译 下载 测试
针不戳
四个命令一次性发送也没问题哒!(命令之间间隔回车换行)
1、字符串接收匹配【int executeCommandStr(char* inputStr)】
使用方法:这个稍微麻烦一点,需要在串口中断里接收到一整串字符并储存起来之后给到本函数去识别。
STM32-HAL库代码示例:
// ***************************************串口中断回调函数***********************************************
int8_t RxBuffer[32], aRxBuffer;
uint8_t Uart1_Rx_Cnt = 0;
//extern int8_t aRxBuffer; // 写到main.c
//HAL_UART_Receive_IT(&huart1, (uint8_t *)&aRxBuffer, 1); // 写到main.c
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(aRxBuffer == '\r' || aRxBuffer == '\n')
{
RxBuffer[Uart1_Rx_Cnt] = '\0';
Uart1_Rx_Cnt = 0;
executeCommandStr((char *)RxBuffer); // 字符串输入
}
else
{
RxBuffer[Uart1_Rx_Cnt++] = aRxBuffer; // 接收数据转存
}
HAL_UART_Receive_IT(&huart1, (uint8_t *)&aRxBuffer, 1); // 再开启接收中断
}
当然如果不想在串口中断里面执行命令,也可以在中断里设置一个标志位去别的地方通知执行命令匹配执行。
编译 下载 测试
效果与上面相同
挺不错吧
2、老版本的单字符接收匹配【void executeCommandOld(char inputChar)】
这个与第一种用法并无区别,只是内部实现方法有所不同,保留下来抛砖引玉给同学们提供思路。
这里就不演示了。
OK!应用测试部分到这里就结束了,接下来演示自定义的命令添加。
4、添加自定义命令
来到ReceiveCMD.c文件,文件定义了一些测试用变量以及函数用于演示
#define BUF_MAX_LEN 150 // 命令缓存区大小,大于等于最长命令的字符数 && 大于等于最长变量位数(例:Variable4 257.850006 共21位)
char Variable0 = 0; // 示例char变量 请extern外部变量,在变量链接区变量结构体列表内填写相关参数
int Variable1 = 0; // 示例int变量
unsigned char Variable2 = 0; // 示例unsigned char变量
unsigned int Variable3 = 0; // 示例unsigned int变量
float Variable4 = 0; // 示例float变量
uint8_t num_commands = 0; // 成功注册命令计数
uint8_t num_variable = 0; // 成功注册变量计数
ValuePoint VP = {NULL, type_null}; // 待改变量指针及类型
// ***************************************命令响应函数示例********************************************
// ****请在此补充自定义函数或者extern外部函数,在命令定义区命令结构体列表内填写相关参数以链接命令与函数****
void Debug() {
printf("[%s] ", __func__);
printf("char = %d ", Variable0); // 调试语句, 可删除
printf("int = %d ", Variable1); // 调试语句, 可删除
printf("uchar = %d ", Variable2); // 调试语句, 可删除
printf("uint = %d ", Variable3); // 调试语句, 可删除
printf("float = %f\r\n", Variable4); // 调试语句, 可删除
}
来到命令定义区和变量链接区,会发现如下结构体:
// ***************************************函数命令定义区***********************************************
Command commands[] = { // 命令结构体列表
// 触发命令/命令长度(可不填)/触发函数,可引用其他已声明的函数 // 命令末尾请发送新行
{"RST" , 0 , NVIC_SystemReset }, // 串口发送字符串:RST+发送新行=单片机重启
{"Debug" , 0 , Debug },
};
// ***************************************变量命令链接区***********************************************
// 变量代号越短越快匹配上 "Variable0 ":13us -> "V0 ":10us @280Mhz-STM32H7B0VBT6
ValuePair variables[] = { // 变量结构体列表
// 变量代号/代号长度(可不填)/对应变量取址/变量类型 // 变量代号与数字间请加入空格,末尾请发送新行,变量代号+空格+对应类型数字+新行=变量被赋新值
{"V0 " , 0 , &Variable0 , type_char }, // 字符型变量示例
{"V1 " , 10 , &Variable1 , type_int }, // 整型变量示例 串口发送:V1 168+发送新行=Variable1变为168
{"V4 " , 0 , &Variable4 , type_float }, // 单精度浮点型变量示例 串口发送:V4 -168.865550+发送新行=Variable4变为-168.865555 (注意浮点数精度丢失)
{"V2 " , 0 , &Variable2 , type_uchar },
{"V3 " , 0 , &Variable3 , type_uint },
};
这里便是我们添加命令的地方,可以理解为两个注册表。
typedef void (*CmdFunction)(void); // 定义命令函数指针类型
typedef struct { // 定义命令结构体
char* command; // 命令
unsigned char len; // 长度
CmdFunction function; // 函数
} Command;
其中char *command就是去跟串口接收到的字符串匹配的名字,也就是命令的索引。
unsigned char len是command的字符长度,此参数在注册表中无需填写真实值,因为初始化函数中会根据索引字符串二次获取长度。
CmdFunction function是索引字符串对应要执行的函数,填写已有的对应函数名即可。
(注:本文件使用到的函数和变量记得extern到本文件。目前函数只验证了void类型的无传参函数,请同学们注意)
ValuePair结构体定义可在ReceiveCMD.h文件中查看:
typedef enum { // 变量类型枚举,用户可自行添加并在void executeCommand(char inputChar)函数中添加相关语句
type_null, // 空
type_int, // 有符号整型
type_uint, // 无符号整型
type_char, // 有符号字符型
type_uchar, // 无符号字符型
type_float, // 浮点型
} value_type;
typedef struct { // 定义数据结构体
char* name; // 代号
unsigned char len; // 长度
void* value; // 地址
value_type type; // 类型
} ValuePair;
其中大同小异,value_type枚举用于修改变量时确定变量的类型,如有其他类型需求请自行添加枚举以及.c文件的逻辑部分。
同学们按照格式就可以自行发挥啦!
二、源码详解
0、注册表
// ***************************************函数命令定义区***********************************************
Command commands[] = { // 命令结构体列表
// 触发命令/命令长度(可不填)/触发函数,可引用其他已声明的函数 // 命令末尾请发送新行
{"RST" , 0 , NVIC_SystemReset }, // 串口发送字符串:RST+发送新行=单片机重启
{"Debug" , 0 , Debug },
};
// ***************************************变量命令链接区***********************************************
// 变量代号越短越快匹配上 "Variable0 ":13us -> "V0 ":10us @280Mhz-STM32H7B0VBT6
ValuePair variables[] = { // 变量结构体列表
// 变量代号/代号长度(可不填)/对应变量取址/变量类型 // 变量代号与数字间请加入空格,末尾请发送新行,变量代号+空格+对应类型数字+新行=变量被赋新值
{"V0 " , 0 , &Variable0 , type_char }, // 字符型变量示例
{"V1 " , 10 , &Variable1 , type_int }, // 整型变量示例 串口发送:V1 168+发送新行=Variable1变为168
{"V4 " , 0 , &Variable4 , type_float }, // 单精度浮点型变量示例 串口发送:V4 -168.865550+发送新行=Variable4变为-168.865555 (注意浮点数精度丢失)
{"V2 " , 0 , &Variable2 , type_uchar },
{"V3 " , 0 , &Variable3 , type_uint },
};
无需多言,就是咱们用户定义命令的地方。
1、初始化&注册
// **************************************命令初始化函数***********************************************
void CMD_Init(void) {
// ****************************************注册函数命令************************************************
for(int i = 0; i < sizeof(commands)/sizeof(commands[0]); i++) {
if(registerCommand(commands[i].command, commands[i].function)) {
printf("\r\nInit CMD [No.%d]:\"%s\" Error!\r\n", i, commands[i].command); // 打印注册错误命令编号&命令名
}
}
// ****************************************注册变量命令************************************************
for(int i = 0; i < sizeof(variables)/sizeof(variables[0]); i++) {
if(registerVariable(variables[i].name, variables[i].value, variables[i].type)) {
printf("\r\nInit Var [No.%d:]\"%s\" Error!\r\n", i, variables[i].name); // 打印注册错误变量编号&变量名
}
}
}
// **************************************注册函数命令函数***********************************************
int registerCommand(char* command, CmdFunction function) {
uint8_t len = strlen(command);
if(len > BUF_MAX_LEN || len <= 1) // 长度限制(1<len<缓冲区大小)
return 1; // 注册失败,命令长度不符合要求
for (int i = 0; i < num_commands; i++)
if (strcmp(commands[i].command, command) == 0)
return 1; // 注册失败,命令已存在
commands[num_commands].command = command; // 添加新命令名
commands[num_commands].len = len; // 添加新命令长度
commands[num_commands].function = function; // 添加新命令函数
num_commands++;
return 0; // 注册成功
}
// **************************************注册变量命令函数***********************************************
int registerVariable(char* name, void* value, value_type type) {
uint8_t len = strlen(name);
if(len > BUF_MAX_LEN || len <= 1) // 长度限制(1<len<缓冲区大小)
return 1; // 注册失败,长度不符合要求
for (int i = 0; i < num_variable; i++)
if (strcmp(variables[i].name, name) == 0)
return 1; // 注册失败,变量名已存在
variables[num_variable].name = name; // 添加新变量名
variables[num_variable].len = len; // 添加新变量长度
variables[num_variable].value = value; // 添加新变量地址
variables[num_variable].type = type; // 添加新变量类型
num_variable++;
return 0; // 注册成功
}
根据用户自定义的注册表,在初始化阶段检查、注册所有命令,对于不合法的命令 如:无效命令(长度为零或者长度超过缓冲区大小),重复命令(只保留第一个成功注册的命令) 串口输出报错信息。
2、重写的pow和atof库函数
double fast_power(double n, int m)
// ***************************************幂运算函数***********************************************
static double fast_power(double n, int m) {
if (m == 0) return 1; // 当指数为0时,直接返回1
double half_power = fast_power(n, (m >> 1)); // 递归计算 n 的一半幂
if ((m & 0x01))
return n * half_power * half_power; // 如果指数为奇数,先乘以 n
else
return half_power * half_power; // 如果指数为偶数,直接平方结果
}
重写pow算法,舍弃掉小数次幂以及复数次幂的情况,适用于工程应用上整数次幂运算提高运算速度。在大数、高次幂的运算中具有一定速度优势。
static double fast_power(double n, int m) {
if (m == 0) return 1; // 当指数为0时,直接返回1
double half_power = fast_power(n, (m >> 1)); // 递归计算 n 的一半幂
if ((m & 0x01))
return n * half_power * half_power; // 如果指数为奇数,先乘以 n
else
return half_power * half_power; // 如果指数为偶数,直接平方结果
}
static double powr(double n, int m) {
if (m == 0) return 1; // 当指数为0时,直接返回1
double s = 1;
int i = 32; // 默认为最大32位(31)
for( ; (m >> i) == 1; i--) ; // 获得m的有效位数
while (i >= 0) {
if ((m >> i) & 1)
s = n * s * s; // 如果指数为奇数,乘以 n
else
s = s * s; // 如果指数为偶数,直接平方结果
i--;
}
return s;
}
static double Mypow(double n, int m) {
if (m == 0) return 1; // 当指数为0时,直接返回1
double result = 1.0;
while (m--)result *= n; // 循环相乘
return result;
}
@280MHz-STM32H7B0VBT6
double Myatof(const char *string)
// *********************自定义atof函数,较库函数有速度提升,但安全性降低************************************
static double Myatof(const char *string) {
bool IsNegative = false;
bool IsInt = true;
double dblResult = 0;
int i = 1;
while (*string != '\0') {
switch(*string) {
case '-':
IsNegative = true;
break;
case '+':
IsNegative = false;
break;
case '.':
IsInt = false;
break;
default:
if('0' <= *string && *string <= '9') {
if (IsInt)
dblResult = dblResult*10 + (*string - '0');
else
dblResult += (*string - '0') / fast_power(10, i++);
}
break;
}
string++;
}
return IsNegative? -dblResult : dblResult;
}
相较于atof速度有所提高,没有做安全性检查,对未知情况下的输入没有防御,仅适用于规范的输入,对安全性有要求而对速度要求不高的同学请使用官方库的atof函数。
3、单字符输入匹配
// ************************************单字符输入匹配命令**********************************************
void executeCommand(char inputChar) {
// ***************************************变量初始化***********************************************
static char commandBuffer[BUF_MAX_LEN];
static int bufferIndex = 0;
// ****************************************输入缓冲***********************************************
if (inputChar == '\r' || inputChar == '\n') { // 过滤末尾无用数据
commandBuffer[bufferIndex] = '\0';
bufferIndex = 0;
executeCommandStr((char *)commandBuffer);
}
else {
commandBuffer[bufferIndex++] = inputChar;
}
}
本质就是对字符串输入匹配的封装,内置了一个缓冲区,在串口中断调用时比较简洁。
4、字符串输入匹配
// ****************************字符串输入匹配命令,由长度匹配避免冲突************************************
int executeCommandStr(char* inputStr) {
uint8_t len = strlen(inputStr);
if(len > BUF_MAX_LEN || len <= 1 || inputStr == NULL) // 长度检查
return 1; // 返回失败
// ***************************************函数命令识别***********************************************
for (int i = 0; i < num_commands; i++) {
if (len == commands[i].len) { // 匹配长度&命令
if (strcmp(inputStr, commands[i].command) == 0) {
commands[i].function(); // 调用对应的函数
return 0; // 返回成功
}
}
}
// ***************************************变量命令识别***********************************************
char commandBuffer[len];
strncpy(commandBuffer, inputStr, len);
for(int j = 0; j < len; j++) {
if(commandBuffer[j] == ' ') {
commandBuffer[j+1] = '\0';
for (int i = 0; i < num_variable; i++) { // 匹配变量
if (strcmp(commandBuffer, variables[i].name) == 0) {
VP.matchedValue = variables[i].value; // 提取变量地址
VP.type = variables[i].type; // 变量类型识别
switch(VP.type) { // 类型识别,请自行扩展类型枚举及此处分支
case type_int:
*(int*)VP.matchedValue = (int)atoi(inputStr+j+1); // 接收结束修改变量
break;
case type_char:
*(char*)VP.matchedValue = (char)atoi(inputStr+j+1); // 接收结束修改变量
break;
case type_uint:
*(unsigned int*)VP.matchedValue = (unsigned int)atoi(inputStr+j+1); // 接收结束修改变量
break;
case type_uchar:
*(unsigned char*)VP.matchedValue = (unsigned char)atoi(inputStr+j+1); // 接收结束修改变量
break;
case type_float:
*(float*)VP.matchedValue = (float)Myatof(inputStr+j+1); // 接收结束修改变量
break;
default:
break;
}
VP.matchedValue = NULL; // 释放指针
VP.type = type_null; // 重置类型
return 0; // 成功则返回进行下一轮接收
}
}
}
}
return 1; // 返回失败
}
首先对字符串长度进行检查,防止非法输入(为什么我这里又注重安全性检查了?搞不懂之前的我)。
然后进入命令的匹配:长度匹配->索引名匹配->调用函数->返回0.
如果命令匹配失败,则进入变量匹配:获取索引名->索引名匹配->提取变量地址以及变量类型->根据类型修改指针类型去修改对应的变量->释放指针,初始化变量类型->返回0。
两次匹配都失败,则说明不是已注册的命令,返回1。
5、老版本单字符输入匹配命令
// ************************************老版单字符输入匹配命令,较冗杂**********************************************
void executeCommandOld(char inputChar) {
// ***************************************变量初始化***********************************************
static char matchedFlag = 0;
static int Value_PN = 1;
static int tmpValu_int = 0;
static float tmpValu_float = 0.0;
static char commandBuffer[BUF_MAX_LEN];
static int bufferIndex = 0;
// ****************************************输入缓冲***********************************************
if (inputChar != '\r' && inputChar != '\n') { // 过滤末尾无用数据
if(bufferIndex == BUF_MAX_LEN) { // 缓冲区满后抛弃最旧数据
for(int i = 0; i < BUF_MAX_LEN - 1; i++) {
commandBuffer[i] = commandBuffer[i + 1];
bufferIndex--;
}
}
commandBuffer[bufferIndex] = inputChar;
bufferIndex++;
}
// ***************************************数字提取***********************************************
if(matchedFlag == 1) {
if(inputChar == '-') { // 正负号识别
Value_PN = -1;
}
else if(('0' <= inputChar && inputChar <= '9')) {
tmpValu_int = tmpValu_int * 10 + (inputChar - '0');
}
else if(inputChar == '.') { // 浮点数识别
tmpValu_float = (float)(Value_PN * tmpValu_int);
tmpValu_int = 0;
matchedFlag++;
}
else if(inputChar == '\r' || inputChar == '\n') {
if (VP.type == type_int) // 非浮点数类型识别,请自行扩展类型枚举及此处分支
*(int*)VP.matchedValue = Value_PN * tmpValu_int; // 接收结束修改变量
else if (VP.type == type_char)
*(char*)VP.matchedValue = Value_PN * tmpValu_int; // 接收结束修改变量
VP.matchedValue = NULL; // 释放指针
VP.type = type_null; // 重置类型
tmpValu_int = 0;
matchedFlag = 0;
Value_PN = 1;
}
}
else if (matchedFlag > 1) {
if (('0' <= inputChar && inputChar <= '9')) {
tmpValu_float += Value_PN * ((float)(inputChar - '0')/fast_power(10, matchedFlag - 1));
matchedFlag++;
}
else if (inputChar == '\r' || inputChar == '\n') {
if (VP.type == type_float) // 单精度浮点数类型识别
*(float*)VP.matchedValue = tmpValu_float; // 接收结束修改变量
VP.matchedValue = NULL; // 释放指针
VP.type = type_null; // 重置类型
tmpValu_float = 0;
matchedFlag = 0;
Value_PN = 1;
}
}
// ***************************************变量命令识别***********************************************
else if (inputChar == ' ') { // 识别关键->变量名与目标数字之间的空格
for (int i = 0; i < num_variable; i++) { // 匹配变量
if (strcmp(commandBuffer + bufferIndex - variables[i].len, variables[i].name) == 0) {
VP.matchedValue = variables[i].value; // 提取变量地址
VP.type = variables[i].type; // 变量类型识别
matchedFlag = 1; // 阶段标志位置位
return; // 成功则返回进行下一轮接收
}
}
}
// ***************************************函数命令识别***********************************************
if (inputChar == '\r' || inputChar == '\n') { // 命令接受完时识别,减少耗时,改进机制
for (int i = 0; i < num_commands; i++) { // 匹配命令
if (strcmp(commandBuffer + bufferIndex - commands[i].len, commands[i].command) == 0) {
commands[i].function(); // 调用对应的函数
}
}
bufferIndex = 0;
memset(commandBuffer, '\0', BUF_MAX_LEN); // 清空缓冲区
}
}
这个版本最先写出来,使用自己写的字符串转整形或者浮点型数据,比较冗杂,速度也比较慢,上个版本删除了,想想还是舍不得,现在又加回来了,给同学们看看思路,万一也有可取的地方呢,是吧。
6、三个命令匹配函数的速度对比
@280MHz-STM32H7B0VBT6
直接使用字符串输入当然更快,但是工程上就要使用DMA去搬运串口数据实现了,第二种更符合一般应用吧,至少我目前是不怎么用DMA。
在使用重写的atof替换掉官方的之前,浮点型的命令总是耗时很多,在整数型atoi只需要10us左右的时候,浮点型atof能够轻松吃掉40+us,所以参照网络资料尝试重写了atof函数为Myatof,速度提升明显,代价就是安全性。
三、总结
算是一个方便的串口调参工具吧,配合上Vofa+这类工具能够很方便优雅的帮助大家调试设备。
第一次写文章,希望大家多多指正,有什么想法都可以在评论区说出来,期待大家的讨论。
如果能够帮助到同学们并获得大家喜欢的话,那就再好不过了!
Fifteen;
1579985949@qq.com;
2024.05.06;
于 成都;
作者:十五15Fifteen