STM32综合-基于HAL库(第十二届蓝桥杯嵌入式省赛)


前言

相关说明:

开发板:CT117E-M4(STM32G431RB 蓝桥杯嵌入式比赛板)
开发环境: CubeMX+Keil5
涉及题目:第十二届蓝桥杯嵌入式省赛
题目难点:停车管理系统逻辑编写;数据接收,解析,判定,更新。
代码思路:(使用usart1时需要修改引脚为PA8 PA9 PA10)串口接收到数据后,先判定数据接收长度是否正确,即每接收到一个字节都重新开启定时器,最后一字节数据接收完且进入定时器中断后判断接收数据长度,准确无误则进行数据解析;解析时将数据分段保存:车类型,车牌号,时间;保存好后再对数据的合法性进行判定,车类型是否为规定类型之一,类型、车牌号数据长度是否为四位。时间是否合法(月份对应天数,时分秒对应进制);最后是存储数据的更新,车牌号是否已经存在?不存在的话判断是否还有空余车位?有则将类型、车牌号、时间等数据存储在数组中;存在的话考虑现在接收时间是否大于到达时间?时间合法则对存储在数组组中的数据进行计算和输出。


CubeMX配置、主要函数代码及说明:

一、CubeMX配置(第十二届省赛完整版)

1.使能外部高速时钟:在这里插入图片描述

2.配置时钟树:在这里插入图片描述

3.GPIO:

在这里插入图片描述

4.TIM3(PWM):在这里插入图片描述在这里插入图片描述

5.TIM6(串口在接收到最后一字节数据5us后进入定时器中断函数):在这里插入图片描述

6.USART1:在这里插入图片描述

7.NVIC(中断配置):在这里插入图片描述

二、代码相关定义、声明

1.函数声明

main.c
void Car_Change(char *type,char *carNum,time_t *time,char *str);		//Car数组改变
uint8_t Dat_Check(char *type,char *carNum);								//判断接收数据正误(格式 时间)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);			//定时器6中断 数据接收超时判定(避免一个合法数据分多次发送)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);				//串口接收中断函数(每接收一字节中断一次)
void Settle_Accounts(struct car outCar);								//结账
void Switch_RecBuff(char *type,char *carNum,time_t *time,char *timStr);	//CNBR:A392:200202120000 数据解析(类型 车牌 时间)
void LCD_Init_Show();				//LCD初始化显示
void LCD_Refresh(uint8_t page);		//LCD更新显示
void LED_Change();					//LED状态改变

gpio.h
void KEY_Scan(void);					//按键扫描
void LED_AllClose(uint8_t *LCD_Close);	//LED显示更新

2.宏定义

#define LED_GPIO_PORT	GPIOC
#define LED1_GPIO_PIN	GPIO_PIN_8
#define LED2_GPIO_PIN	GPIO_PIN_9
#define LED3_GPIO_PIN	GPIO_PIN_10
#define LED4_GPIO_PIN	GPIO_PIN_11
#define LED5_GPIO_PIN	GPIO_PIN_12
#define LED6_GPIO_PIN	GPIO_PIN_13
#define LED7_GPIO_PIN	GPIO_PIN_14
#define LED8_GPIO_PIN	GPIO_PIN_15

#define ON 	GPIO_PIN_RESET
#define OFF	GPIO_PIN_SET

#define LED1(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED1_GPIO_PIN,a)
#define LED2(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED2_GPIO_PIN,a)
#define LED3(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED3_GPIO_PIN,a)
#define LED4(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED4_GPIO_PIN,a)
#define LED5(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED5_GPIO_PIN,a)
#define LED6(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED6_GPIO_PIN,a)
#define LED7(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED7_GPIO_PIN,a)
#define LED8(a) HAL_GPIO_WritePin(LED_GPIO_PORT,LED8_GPIO_PIN,a)

#define KEY1_GPIO_PORT 	GPIOB
#define KEY1_GPIO_PIN	GPIO_PIN_0
#define KEY2_GPIO_PORT 	GPIOB
#define KEY2_GPIO_PIN	GPIO_PIN_1
#define KEY3_GPIO_PORT 	GPIOB
#define KEY3_GPIO_PIN	GPIO_PIN_2
#define KEY4_GPIO_PORT 	GPIOA
#define KEY4_GPIO_PIN	GPIO_PIN_0

3.变量定义

main.c
uint8_t CNBR=0;							//CNBR类型车辆数
uint8_t VNBR=0;							//VNBR类型车辆数
uint8_t IDLE=8;							//空闲位置
double CNBR_Price=3.5;					//CNBR类型停车费用
double VNBR_Price=2.0;					//VNBR类型停车费用
char str[30];							//用于组合字符串
uint8_t LED_Close[3]={
    
    1,0,1};			//LED关闭数组

uint8_t recDatBuff[3][20]={
    
    0,0,0,0};	//数据接收数组(recDatBuff[0]存储停车类型,recDatBuff[1]存储车牌号,recDatBuff[2]存储时间,冒号存储在数组0,1行的最后一个位置)
uint8_t recDex=0; 						//接收数组下标
uint8_t recNum=0;						//接收数组行号
uint8_t recDat;							//本次接收数据(一次一字节)
uint32_t recLong=0;						//数接收长度
uint8_t firstByte=1;					//接收到本次传输数据的第一个字节(用于判定传输超时)
uint8_t switch_flag=0;					//接收数据转换标志,完整接收一次数据后置1,随后进行转换、判断、存储

int Error;		//接收数据错误标志
int recYear;	//接收年
int recMon;		//接收月
int recDay;		//接收日
int recHour;	//接收时
int recMin;		//接收分
int recSec;		//接收秒

struct car		//车辆结构体
{
    
    
	char num[10];		//车牌号
	char type[10];		//车辆类型
	int dftime;			//时间差
	double EndPrice;	//最终价格
	double type_price;	//停车单价(元/小时)
	char reach_time[60];//到达时间字符串
	char leave_time[60];//离开时间字符串
	int reach;			//到达时间时间戳
	int leave;			//离开时间时间戳
};
struct car car[9];		//车辆存储结构体数组
uint8_t car_dex=0;		//存储数组下标

三、主要函数

首先是按键按下对数据以及输出PWM的更改;更改PWM输出时,按键按下后,先判断LED_Close[2]存储的状态,为灭则使LED2亮,开启PWM,输出1KHz信号。为亮则使LED2灭,暂停PWM,输出持续的低电平。(PWM在MX的配置为1KHz的输出。)

1.按键扫描

gpio.c
void Data_Change(uint8_t mode)//数据改变
{
    
    
	switch(mode)
	{
    
    
		case ADD:
			CNBR_Price+=Price_step;
			VNBR_Price+=Price_step;
			break;
		
		case SUB:
			CNBR_Price-=Price_step;
			VNBR_Price-=Price_step;
			break;
	}
}

void Setting_Mode()//设置模式
{
    
    
	uint8_t delay=0;
	while(1)
	{
    
    
		if(HAL_GPIO_ReadPin(KEY1_GPIO_PORT,KEY1_GPIO_PIN)==GPIO_PIN_RESET)//change mode
		{
    
    
			HAL_Delay(10);
			if(HAL_GPIO_ReadPin(KEY1_GPIO_PORT,KEY1_GPIO_PIN)==GPIO_PIN_RESET)
			{
    
    
				while(HAL_GPIO_ReadPin(KEY1_GPIO_PORT,KEY1_GPIO_PIN)==GPIO_PIN_RESET);
				LCD_Refresh(1);
				break;
			}
		}
		
		else if(HAL_GPIO_ReadPin(KEY2_GPIO_PORT,KEY2_GPIO_PIN)==GPIO_PIN_RESET)//++
		{
    
    
			HAL_Delay(10);
			if(HAL_GPIO_ReadPin(KEY2_GPIO_PORT,KEY2_GPIO_PIN)==GPIO_PIN_RESET)
			{
    
    
				while(HAL_GPIO_ReadPin(KEY2_GPIO_PORT,KEY2_GPIO_PIN)==GPIO_PIN_RESET);
				Data_Change(ADD);
				LCD_Refresh(2);
			}
		}
		
		else if(HAL_GPIO_ReadPin(KEY3_GPIO_PORT,KEY3_GPIO_PIN)==GPIO_PIN_RESET)//--
		{
    
    
			HAL_Delay(10);
			if(HAL_GPIO_ReadPin(KEY3_GPIO_PORT,KEY3_GPIO_PIN)==GPIO_PIN_RESET)
			{
    
    
				while(HAL_GPIO_ReadPin(KEY3_GPIO_PORT,KEY3_GPIO_PIN)==GPIO_PIN_RESET);
				Data_Change(SUB);
				LCD_Refresh(2);
			}
		}
	}
}

void KEY_Scan()//按键扫描
{
    
    
	if(HAL_GPIO_ReadPin(KEY1_GPIO_PORT,KEY1_GPIO_PIN)==GPIO_PIN_RESET)//change mdoe
	{
    
    
		HAL_Delay(10);
		if(HAL_GPIO_ReadPin(KEY1_GPIO_PORT,KEY1_GPIO_PIN)==GPIO_PIN_RESET)
		{
    
    
			while(HAL_GPIO_ReadPin(KEY1_GPIO_PORT,KEY1_GPIO_PIN)==GPIO_PIN_RESET);
			LCD_Refresh(2);
			Setting_Mode();
		}
	}
	
	else if(HAL_GPIO_ReadPin(KEY4_GPIO_PORT,KEY4_GPIO_PIN)==GPIO_PIN_RESET)//PWM
	{
    
    
		HAL_Delay(10);
		if(HAL_GPIO_ReadPin(KEY4_GPIO_PORT,KEY4_GPIO_PIN)==GPIO_PIN_RESET)
		{
    
    
			while(HAL_GPIO_ReadPin(KEY4_GPIO_PORT,KEY4_GPIO_PIN)==GPIO_PIN_RESET);
			if(LED_Close[2]==1)//如果灭 即输出低电平
			{
    
    
				LED_Close[2]=0;
				HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_2);
			}
			else
			{
    
    
				LED_Close[2]=1;
				HAL_TIM_PWM_Stop(&htim3,TIM_CHANNEL_2);
				HAL_GPIO_WritePin(GPIOA,GPIO_PIN_7,GPIO_PIN_RESET);
			}
		}
	}
}

接下来是数据的处理,分别为接收,解析,判定和更新。

2.串口接收中断、定时器中断(接收)

串口在接收到一个字节数据时进入串口中断函数,每次进入串口中断函数都需要重新开启判定,在最后一字节数据接收完5us后进入定时器中断函数,在定时器中断函数中判定接收数据长度是否符合要求,不符合则返回Error,符合要求则按照设定好的规则进行保存。我使用的是二维数组,遇到冒号就换行,最后根据数据长度来设定接收结束的标志(接收结束后将开始解析)即可。

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)//定时器6中断 数据接收长度判定
{
    
    
	HAL_TIM_Base_Stop_IT(&htim6);//关闭判定
	HAL_UART_Receive_IT(&huart1,&recDat,sizeof(recDat));//重新开启串口接收中断
	if(recLong!=22)
	{
    
    
		Error=1;//错误标志位置1
	}
	else
	{
    
    
		switch_flag=1;//字符串转换标志位置1
	}
	recLong=0;//接收长度重置
	recNum=0;//recNum重置
	recDex=0;//recDex重置
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)//串口接收中断函数(每接收一字节中断一次) 在最后一字节接收完5us后判断数据长度
{
    
    
	HAL_TIM_Base_Stop_IT(&htim6);//关闭判定
	
	recLong++;
	
	recDatBuff[recNum][recDex++]=recDat;//将接收到的数据存储进数组
	
	if(recDat==':')//如果本次接收数据为冒号则数组换行,下标置为0
	{
    
    
		recNum++;
		recDex=0;
	}
 
	TIM6->CNT=0;
	HAL_TIM_Base_Start_IT(&htim6);//重新开启判定
	
	HAL_UART_Receive_IT(&huart1,&recDat,sizeof(recDat));//重新开启串口接收中断
}

3.数据解析

将保存在二维数组中的数据进行字符串组合,数组中第0行数据为车的类型+冒号,第1行数据为车牌号+冒号,第2行数据为时间
这里用到time.h中的函数对时间进行转换,先将时间进行类型转换(字符型转整型),转换后根据mktime函数规则进行调整(年份减1900,月份减1),再将调整后的结果赋值给时间结构体,再调用mktime函数进行时间转换,转换后的时间为自1970年1月1日以来持续时间的秒数 (为什么用一个错误的时间测试,mktime不会返回-1,欢迎懂的大佬留言)

void Switch_RecBuff(char *type,char *carNum,time_t *time,char *timStr)//CNBR:A392:200202120000 数据解析 类型 车牌 时间
{
    
    
	struct tm timeTem;//定义时间结构体
	sprintf(type,"%c%c%c%c",recDatBuff[0][0],recDatBuff[0][1],recDatBuff[0][2],recDatBuff[0][3]);//解析类型
	
	sprintf(carNum,"%c%c%c%c",recDatBuff[1][0],recDatBuff[1][1],recDatBuff[1][2],recDatBuff[1][3]);//解析车牌号
	
	sprintf(timStr,"%c%c%c%c%c%c%c%c%c%c%c%c",recDatBuff[2][0],recDatBuff[2][1],recDatBuff[2][2],recDatBuff[2][3],recDatBuff[2][4],recDatBuff[2][5],recDatBuff[2][6],recDatBuff[2][7],recDatBuff[2][8],recDatBuff[2][9],recDatBuff[2][10],recDatBuff[2][11]);//解析时间
	
	recYear=2000+(recDatBuff[2][0]-48)*10+(recDatBuff[2][1]-48)-1900;//将时间转换为int类型数据 如'0'要转换成0,字符0对应的ASCII码为48,则0为'0'-48 1~9以此类推
	recMon=(recDatBuff[2][2]-48)*10+(recDatBuff[2][3]-48)-1;//时间结构体存储规则,月份减1,年份减1900
	recDay=(recDatBuff[2][4]-48)*10+(recDatBuff[2][5]-48);
	recHour=(recDatBuff[2][6]-48)*10+(recDatBuff[2][7]-48);
	recMin=(recDatBuff[2][8]-48)*10+(recDatBuff[2][9]-48);
	recSec=(recDatBuff[2][10]-48)*10+(recDatBuff[2][11]-48);
	
	timeTem.tm_year=recYear;//一一对应赋值
	timeTem.tm_mon=recMon;
	timeTem.tm_mday=recDay;//刚开始这里成员选成了tm_yday,需要注意,排查了好久,yday是代表一年中的第几天,mday代表一月中的第几天
	timeTem.tm_hour=recHour;
	timeTem.tm_min=recMin;
	timeTem.tm_sec=recSec;
	*time=mktime(&timeTem);//将时间结构体用mktime函数转化为自1970年1月1日以来持续时间的秒数 (为什么用一个错误的时间测试,mktime不会返回-1,欢迎懂的大佬留言)
}

4.判定数据正误

车类型是否为规定类型之一,类型、车牌号数据长度是否为四位(这里用冒号的位置进行判断)。时间是否合法(月份对应天数是否准确,,2月份还需考虑闰年;时分秒对应进制有误);

uint8_t Dat_Check(char *type,char *carNum)//判定接收数据正误 格式 时间
{
    
    
	recMon+=1;//月份在上面为了转换减了1 这里需要加回来 recYear同理
	recYear-=100;
	if(strcmp(type,"CNBR")!=0 && strcmp(type,"VNBR")!=0)//如果类型不是其中之一(判断类型)
	{
    
    
		return 1;
	}
	
	if(recDatBuff[0][4]!=':' || recDatBuff[1][4]!=':')//数组最后一个是否为:(判断格式)
	{
    
    
		return 1;
	}
	
	if(recMon>12 || recMon<0)//判断时间合法性 下同 很好理解
	{
    
    
		return 1;
	}
	else if(recMon==2)//2月
	{
    
    
		if(recYear%4==0)//闰年
		{
    
    
			if(recDay>28 ||recDay<0)
			{
    
    
				return 1;
			}
		}
		else//非闰年
		{
    
    
			if(recDay>29 ||recDay<0)
			{
    
    
				return 1;
			}
		}
	}
	else if(recMon==1 || recMon==3 || recMon==5 || recMon==7 || recMon==8 || recMon==10 || recMon==12)//大月
	{
    
    
		if(recDay>31 ||recDay<0)
		{
    
    
			return 1;
		}
	}
	else if(recMon==4 || recMon==6 || recMon==9 || recMon==11)//小月
	{
    
    
		if(recDay>30 ||recDay<0)
		{
    
    
			return 1;
		}
	}
	
	if(recHour>23 || recHour<0)//时
	{
    
    
		return 1;
	}
	
	if(recMin>59 || recMin<0)//分
	{
    
    
		return 1;
	}
	
	if(recSec>59 || recSec<0)//秒
	{
    
    
		return 1;
	}
	
	return 0;//无误返回0
}

5.数据更新

接收数据判定无误后进入数据更新步骤。首先判断车牌号是否已经存在,如不存在则为进入,进入时需要判断是否有空余车位,有则将接收数据保存在数组中,并更新车位信息;如果车牌号存在则为离开,离开需判断时间是否大于车辆到达时间,若合法则将离开车辆信息传递给结算函数进行结算并更新车位信息,不合法返回Error。

void Car_Change(char *type,char *carNum,time_t *time,char *str)//Car数组改变
{
    
    
	uint8_t i;
	uint8_t dir=1;//方向标志位 1为进 0为出
	uint8_t outcar_dex;//离开车辆下标
	for(i=0;i<9;i++)//判断停车场是否存在该车
	{
    
    
		if(strcmp(carNum,car[i].num)==0)//如存在
		{
    
    
			dir=0;//方向为出
			outcar_dex=i;//记录下标
			if(*time<car[outcar_dex].reach)//与到达时间对比 判断时间是否合法
			{
    
    
				printf("Error\n");
				return;
			}
			break;
		}
	}
	
	if(dir==1)//in
	{
    
    
		if(IDLE==0)//无空闲车位
		{
    
    
			return;
		}
		if(strcmp(type,"CNBR")==0)//如果车辆类型为CNBR
		{
    
    
			CNBR++;
			IDLE--;
			car[car_dex].type_price=CNBR_Price;//存储价格
		}
		else if(strcmp(type,"VNBR")==0)//如果车辆类型为VNBR
		{
    
    
			VNBR++;
			IDLE--;
			car[car_dex].type_price=VNBR_Price;//存储价格
		}
		strcpy(car[car_dex].type,type);//存储车类型
		strcpy(car[car_dex].num,carNum);//存储车牌号
		strcpy(car[car_dex].reach_time,str);//存储车到达时间字符串
		car[car_dex].reach=*time;//存储车到达时间
		car_dex++;//下标++ 为储存下一辆车做准备
	}
	else//out
	{
    
    
		if(strcmp(type,"CNBR")==0)
		{
    
    
			CNBR--;
			IDLE++;
		}
		else if(strcmp(type,"VNBR")==0)
		{
    
    
			VNBR--;
			IDLE++;
		}
		strcpy(car[outcar_dex].leave_time,str);//存储车离开时间字符串
		car[outcar_dex].leave=*time;//存储车离开时间
		Settle_Accounts(car[outcar_dex]);//结算
		for(i=outcar_dex;i<car_dex;i++)//存储数组前移
		{
    
    
			car[i]=car[i+1];
		}
		car_dex--;//下标-- 为储存下一辆车做准备
	}
}

6.结算

打印到达和离开信息,并用difftime函数计算时间差,单位为秒,再将时间单位化为小时,最后计算费用并将时间和费用信息进行打印。

void Settle_Accounts(struct car outCar)//结账
{
    
    
	printf("%s:%s:%s\n",outCar.type,outCar.num,outCar.reach_time);//串口打印到达信息
	printf("%s:%s:%s\n",outCar.type,outCar.num,outCar.leave_time);//串口打印离开信息
	outCar.dftime=difftime(outCar.leave,outCar.reach)/60/60;//difftime函数返回两时间的差值 单位为秒
	if(outCar.dftime==0)outCar.dftime=1;//不足一小时记为一小时 
	outCar.EndPrice=outCar.dftime*outCar.type_price;  //计算费用
	printf("%s:%s:%d:%.2f\n\n",outCar.type,outCar.num,outCar.dftime,outCar.EndPrice);//串口打印时长及费用信息
}

7.Main函数

在主循环之前需要做好初始化工作。
1.要先重置定时器更新标志位(TIMX->SR=0),否则程序运行后将立刻进入定时器中断函数。
2.开启串口接收中断
3.LCD初始化显示
4.PWM初始化
主循环逻辑大体就是实时更新LCD和LED,并检测是否需要对数据进行转换,转换完后判断数据是否合法,合法的话是车辆进入还是车辆离开。

int main(void)
{
    
    
  /* USER CODE BEGIN 1 */
	time_t time;//保存传输的时间
	char type[10];//保存传输的类型
	char carNum[20];//车牌号
	char timStr[60];//时间字符串
//	memset(&time, 0, sizeof(time));
  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_TIM3_Init();
  MX_USART1_UART_Init();
  MX_TIM6_Init();
  /* USER CODE BEGIN 2 */
	LCD_Init();//LCD初始化
	LCD_Init_Show();//LCD初始化显示
	HAL_UART_Receive_IT(&huart1,&recDat,sizeof(recDat));//开启串口接收中断
	TIM6->SR=0;//中断标志位清零
	
	LED_Close[2]=1;//LED2默认为灭
	HAL_TIM_PWM_Stop(&htim3,TIM_CHANNEL_2);//PWM关闭
	HAL_GPIO_WritePin(GPIOA,GPIO_PIN_7,GPIO_PIN_RESET);//输出持续低电平
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    
    
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
		KEY_Scan();//按键扫描
		LCD_Refresh(1);//LCD更新显示
		if(switch_flag==1 && !Error)//接收数组转换标志为1并且无错误就继续执行
		{
    
    
			switch_flag=0;//转换标志位重置
			Switch_RecBuff(type,carNum,&time,timStr);//进行数据转换
			Error=Dat_Check(type,carNum);//判定接收数据是否合法
			if(!Error)//如果无错误
			{
    
    
				Car_Change(type,carNum,&time,timStr);//对存储车辆信息进行更新
			}
			else//接收数据不合法
			{
    
    
				Error=0;//重置错误标志位
				printf("Error\n");//串口输出提示信息
			}
			LED_Change();
		}
		else if(Error)//接收数据长度不符
		{
    
    
			Error=0;//重置错误标志位
			printf("Error\n");//串口输出提示信息
		}
		LED_AllClose(LED_Close);//LED显示更新
  }
  /* USER CODE END 3 */
}

四、实验结果

1.数据长度有误

a.数据过长
在这里插入图片描述
b.数据过短
在这里插入图片描述
c.返回
在这里插入图片描述

2.数据不合法

a.类型错误
在这里插入图片描述
b.时间不合法
在这里插入图片描述
c.离开时间小于到达时间
在这里插入图片描述

在这里插入图片描述
d.返回
在这里插入图片描述

3.数据正常

a.输入车辆到达信息
在这里插入图片描述
b.输入车辆离开信息
在这里插入图片描述
c.返回
在这里插入图片描述

五、源码(转载请注明出处)

在这里插入图片描述


总结

以上就是全部内容,如有错误请批评指正。

猜你喜欢

转载自blog.csdn.net/Octopus1633/article/details/123744868