当前位置:   article > 正文

利用STM32CubeMX和Keil模拟器,3天入门FreeRTOS(5.4) —— 事件组

利用STM32CubeMX和Keil模拟器,3天入门FreeRTOS(5.4) —— 事件组

前言

(1)FreeRTOS是我一天过完的,由此回忆并且记录一下。个人认为,如果只是入门,利用STM32CubeMX是一个非常好的选择。学习完本系列课程之后,再去学习网上的一些其他课程也许会简单很多。
(2)本系列课程是使用的keil软件仿真平台,所以对于没有开发板的同学也可也进行学习。
(3)叠甲,再次强调,本系列课程仅仅用于入门。学习完之后建议还要再去寻找其他课程加深理解。
(4)本系列博客对应代码仓库:gitee仓库

前期准备

(1)将上一篇博客的工程复制一份

在这里插入图片描述

(2)现在才发现,STM32CubeMX默认创建的那个任务不是空闲任务。(苦笑)这个任务里面只有一个非阻塞延时函数,因此我们感受不到他的存在。而他又占用了空间,但是我们无法删除这个任务,只能修改。官方解释是说这个任务包含了一些中间件的初始化。官方解释的中间件有:

  • LwIP:这是一个用于嵌入式设备的轻量级TCP/IP协议栈,用于实现网络通信功能
  • FatFs: 这是一个用于小型嵌入式系统的通用FAT文件系统模块,用于实现文件系统功能
  • FreeRTOS:这是一个轻量级的实时操作系统,用于实现多任务处理和时间管理。
  • USB Device (Middleware): 这个中间件提供了USB设备类的实现,如USB通信设备类(CDC)、USB大容量存储设备类(MSC)等。

(3)这个事情也解释了上一篇博客为啥FreeRTOS默认支持3个128字任务,而我们实验出现了FreeRTOS堆空间不足的问题,因为上篇博客我们创建了4个128字任务,而非3个。

/* USER CODE END Header_StartDefaultTask */
void StartDefaultTask(void *argument)
{
  /* USER CODE BEGIN StartDefaultTask */
  /* Infinite loop */
  for(;;)
  {
    osDelay(1);
  }
  /* USER CODE END StartDefaultTask */
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在这里插入图片描述

(4)STM32CubeMX的这个默认创建的任务无法被删除,这个问题在ST的官方论坛2018年就被提及,历经4年,ST依旧没有做出改变。有网友提出建议:请给这些人加薪,他们因出色的工作而应得的。哈哈哈哈。

在这里插入图片描述

实战

使用STM32CubeMX创建事件组

(1)如果使用的是CMSIS_V1,是无法在STM32CubeMX中创建事件组的。如果和我一样是使用的CMSIS_V2就可以。

在这里插入图片描述

使用keil端创建事件组

(1)包含event_groups.h头文件(按Ctrl+F搜索Private includes

/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
#include "usart.h"
#include "semphr.h"
#include "event_groups.h"
/* USER CODE END Includes */
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

(2)创建事件组(按Ctrl+F搜索RTOS_EVENTS

  /* USER CODE BEGIN RTOS_EVENTS */
  /* add events, ... */
	//动态创建事件组
	KeilEventGroupHandle = xEventGroupCreate();
  /* USER CODE END RTOS_EVENTS */
  • 1
  • 2
  • 3
  • 4
  • 5

应用代码

(1)补充添加三个宏定义(按Ctrl+F搜索Private macro

/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */
#define EventGroup_L    (1<<0)
#define EventGroup_M    (1<<1)
#define EventGroup_ALL  (EventGroup_L | EventGroup_M )
/* USER CODE END PM */
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

(2)补充如下代码(按Ctrl+F搜索Header_StartCubemxTask

/* USER CODE END Header_StartCubemxTask */
void StartCubemxTask(void *argument)
{
  /* USER CODE BEGIN StartCubemxTask */
  /* Infinite loop */
  for(;;)
  {
		Task_H = 0;
		Task_M = 0;
		Task_L = 1;
		// 这里是刻意采用阻塞延时,目的是增加任务执行时间
		HAL_Delay(5);
		xEventGroupSetBits(KeilEventGroupHandle, EventGroup_L);
  }
  /* USER CODE END StartCubemxTask */
}

/* Private application code --------------------------------------------------*/
/* USER CODE BEGIN Application */
int fputc(int ch, FILE *f)
{
	unsigned char temp[1]={ch};
	HAL_UART_Transmit(&huart1,temp,1,0xffff);
	return ch;
}
void StartKeilTask_M(void *argument)
{
	while(1)
	{		
		//需要低优先级任务先完成才能执行这里
		/* xEventGroup     : 等待的事件组
		 * uxBitsToWaitFor : 等待的位
		 * xClearOnExit    : 如果是pdTRUE表示清除对应事件位,pdFALSE不清除对应事件位
		 * xWaitForAllBits : 如果是pdTRUE表示所有等待事件位全为1(逻辑与),pdFALSE等待的事件位有一个为1(逻辑或)
		 * xTicksToWait    : 无限等待
		*/
		xEventGroupWaitBits( KeilEventGroupHandle, EventGroup_L, pdFALSE, pdTRUE, portMAX_DELAY );
		Task_H = 0;
		Task_M = 1;
		Task_L = 0;
		// 这里是刻意采用阻塞延时,目的是增加任务执行时间
		HAL_Delay(5);
		xEventGroupSetBits(KeilEventGroupHandle, EventGroup_M);
		
	}
}
void StartKeilTask_H(void *argument)
{	
	while(1)
	{
		//需要中优先级和低优先级任务先完成才能执行这里
		xEventGroupWaitBits( KeilEventGroupHandle, EventGroup_ALL, pdTRUE, pdTRUE, portMAX_DELAY );
		Task_H = 1;
		Task_M = 0;
		Task_L = 0;
		// 这里是刻意采用阻塞延时,目的是增加任务执行时间
		HAL_Delay(5);
		
		//需要中优先级或低优先级任务先完成才能执行这里
		xEventGroupWaitBits( KeilEventGroupHandle, EventGroup_ALL, pdTRUE, pdFALSE, portMAX_DELAY );
		Task_H = 1;
		Task_M = 0;
		Task_L = 0;
		// 这里是刻意采用阻塞延时,目的是增加任务执行时间
		HAL_Delay(5);
	}

}
/* USER CODE END Application */
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69

仿真结果

(1)打开仿真器,结果如下图所示进行轮回。

在这里插入图片描述

理论

为什么需要事件组

(1)前面我们学习了队列,信号量,互斥量。我们这里做一下回顾:
<1>队列:当我们需要在各个任务之间传递数据的时候,就应该利用队列。(可以理解为传送带)
<2>信号量:如果是某个资源同一时间只能被限定的任务获取,那么就用信号量。(用门票进行理解)
<3>互斥量:如果是某一个资源只能被一个任务获取,并且只能由上锁的任务解锁,那么就用互斥量。(用上厕所举例)
(2)那么现在存在一个问题,如果一个任务需要等待多个条件同时满足,或者是多个条件中某一个满足,这个任务才可以继续执行下去。我们应该怎么做呢?
<1>互斥量明显不可以,因为他这个是只能保护一个资源。无论是多个条件满足执行任务,还是多个条件只满足一个才执行任务。互斥量都是无法做到的。
<2>队列可以做到多个条件都满足,任务才继续进行下去。例如创建多个队列,满足一个条件,就往对应的队列中写入数据,最终等待所有队列都有数据才执行任务。但如果是要求多个任务中满足其中一个,似乎就无法实现。而且队列还需要存储数据,对空间也是浪费。
<3>信号量,他可以做到多个条件中满足一个,任务就继续执行下去,例如满足一个条件就释放一次信号量。要求多个任务都满足,任务继续执行下去,信号量似乎也可以,但是这样就需要创建多个信号量。这样会导致有一些重复的数据浪费。
(3)为了解决这个问题,FreeRTOS提出了事件组的解决办法。因此总结一下事件组的作用:

  • 事件组允许任务在阻塞状态下等待多个事件的组合发生。
  • 事件组在事件发生时解除等待同一事件或事件组合的所有任务的阻塞。
  • 事件组提供了减少应用程序使用的RAM的机会,因为通常可以用单个事件组替换许多二进制信号量。

事件组是什么

(1)事件组其实就是一个uint32_t或者uint16_t的数据。他的高八位FreeRTOS 的源代码中被用于存储一些额外的信息,但这些信息对于一般的应用程序开发者来说是不可见和不可访问的。
(2)当configUSE_16_BIT_TICKS这个宏设置1时候,事件组能够包含8个事件,此时的事件组是一个uint16_t的数据。而如果configUSE_16_BIT_TICKS这个宏设置0的时候,事件组就能够包含24个事件,此时的事件组是一个uint32_t的数据。
(3)我们可以根据这个数据的每一个bit来对应一个事件,然后做相应的判断。

#if( configUSE_16_BIT_TICKS == 1 )
	typedef uint16_t TickType_t;
	#define portMAX_DELAY ( TickType_t ) 0xffff
#else
	typedef uint32_t TickType_t;
	#define portMAX_DELAY ( TickType_t ) 0xffffffffUL

	/* 32-bit tick type on a 32-bit architecture, so reads of the tick count do
	not need to be guarded with a critical section. */
	#define portTICK_TYPE_IS_ATOMIC 1
#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在这里插入图片描述

事件组的两个重要参数和返回值

(1)事件组最重要的是xEventGroupWaitBits()函数,这个函数需要关注两个参数,一个是xClearOnExit,另外一个是xWaitForAllBits
(2)xWaitForAllBits更容易理解,而且讲解xClearOnExit例子的时候需要配合这个参数,所以先解释xWaitForAllBits。这个参数就是用来指定事件的逻辑与或者是逻辑或的。
<1>事件组的bit0bit1bit4需要同时满足,也就是全部为1,才能够触发条件。那么xWaitForAllBits将会被设置为pdTRUE
<2>事件组的bit0bit1bit4只需要满足其中一个条件,也就是有一个参数为1,就能够触发条件。那么xWaitForAllBits将会被设置为pdFALSE
(3)xClearOnExit用于指定xEventGroupWaitBits()函数返回时候,对应的事件组事件是否会被清除。
<1>将xClearOnExit设置为pdTRUE,那么函数返回之后,对应的事件组事件位会被清除。

  • xWaitForAllBitspdTRUE,如果事件组的bit0bit1bit4全部为1,没有发送超时。此时bit0bit1bit4会被全部清零。

  • xWaitForAllBitspdTRUE,如果事件组的bit0 = 1bit1 = 1bit4 = 0,此时超时了,函数返回。事件组的对应标志不会被清除,依旧是bit0 = 1bit1 = 1bit4 = 0

  • xWaitForAllBitspdFALSE,此时事件组bit0 = 1bit1 = 0bit4 = 0,并且没有超时,函数返回之后,事件组的对应事件都会被清除。也就是bit0 = 0bit1 = 0bit4 = 0

  • xWaitForAllBitspdFALSE,此时事件组的bit0 = 0bit1 = 0bit4 = 0,发送超时,函数返回,事件组的事件依旧为0,bit0 = 0bit1 = 0bit4 = 0

<2>将xClearOnExit设置为pdFALSE,那么函数返回之后,对应的事件组事件位不会被清除。

(4)返回值:如果条件满足了返回事件值,一般用于xWaitForAllBits设置为pdFALSE时候或者超时使用。因为xWaitForAllBits设置为pdFALSE的时候,我们可能需要知道是那个具体的事件被置为1。如果是超时那么我们就需要知道是那个条件不满足,导致的超时事件发送。

/**
 * @brief  等待事件组
 *
 * @param  xEventGroup     等待的事件组
 *        -uxBitsToWaitFor 等待的事件
 *        -xClearOnExit    如果是pdTRUE表示清除对应事件位,如果是超时退出,那么对应的事件位不会被清除;pdFALSE不清除对应事件位
 *        -xWaitForAllBits 如果是pdTRUE表示逻辑与,所有条件需要均满足;pdFALSE表示逻辑或,只需要满足一个条件即可
 *        -xTicksToWait    等待的时间,0表示判断后即刻返回,portMAX_DELAY表示一定等到成功才返回,或者是其他有效值,单位Tick
 *
 * @return 非超时退出,返回成功等待的事件值。超时退出,返回的是超时时刻的事件值。
 */
EventBits_t xEventGroupWaitBits(
                       const EventGroupHandle_t xEventGroup,
                       const EventBits_t uxBitsToWaitFor,
                       const BaseType_t xClearOnExit,
                       const BaseType_t xWaitForAllBits,
                       TickType_t xTicksToWait );
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

解释上面仿真结果产生原因

(1)首先我们知道StartKeilTask_H任务优先级最大,StartKeilTask_M其次,StartCubemxTask任务优先级最小。
<1>因此,当任务开始调度的时候,先执行StartKeilTask_H。但是此任务第一句就是事件等待,因此这个任务进入阻塞态。
<2>最高优先级任务进入阻塞态,因此是中优先级任务开始执行。但是中优先级任务也是需要进行事件等待。
<3>因此最低优先级任务执行。

在这里插入图片描述

(2)因为低优先级任务释放了事件,因此成功唤醒了中优先级任务。但是需要注意的一点是,这里的xEventGroupWaitBits()第三个参数设置的是pdFALSE,因此不会清除事件。

在这里插入图片描述

(3)中优先级任务也设置了事件,两个事件都被设置,因此高优先级任务被唤醒。不过这里的第一个xEventGroupWaitBits()第三个参数设置的是pdTRUE,所以说两个事件标志都被会清除。从而第二个xEventGroupWaitBits()会导致高优先级任务阻塞。

在这里插入图片描述

(4)
<1>因为高优先级的第一个xEventGroupWaitBits()会清除两个事件标志位。所以说第二个xEventGroupWaitBits()会导致高优先级任务进入阻塞态。
<2>此时调度执行中优先级任务,但是因为中优先级任务进行xEventGroupWaitBits()判断的时候,事件不满足,因而中优先级任务也进入阻塞态。
<3>最终就只有低优先级任务会开始执行了。

在这里插入图片描述

(5)
<1>低优先级任务执行完成之后,设置事件标志位。
<2>此时因为高优先级任务的第二个xEventGroupWaitBits()中的第四个参数设置的是pdFALSE。因此是逻辑或,满足条件,高优先级任务再次被唤醒,并且清除对应的事件标志位。
<3>此时被唤醒的任务不仅仅只有高优先级任务,还有中优先级任务。因为中优先级任务也是依赖于EventGroup_L事件标志位。但是因为优先级的原因,因此此时只有高优先级任务执行。

在这里插入图片描述

(6)在第五步的时候,低优先级任务不但唤醒了高优先级任务,也唤醒了中优先级任务。因为优先级的原因,中优先级任务无法得到执行。但是此时高优先级任务因为第一个xEventGroupWaitBits()受到了阻塞,因此中优先级任务得到响应。

在这里插入图片描述

(7)
<1>中优先级任务执行完成之后,再此被xEventGroupWaitBits()阻塞,因为EventGroup_L事件标志位在第5步骤的时候,被高优先级的第二个xEventGroupWaitBits()清除。
<2>此时高优先级和中优先级任务都被阻塞。因此只有低优先级任务能够被执行。

在这里插入图片描述

(8)低优先级任务执行完成之后,会设置EventGroup_L事件标志位,此时高优先级任务和中优先级任务都会被唤醒。只不过因为优先级的缘故,所以是高优先级任务在执行。

在这里插入图片描述

(9)
<1>因为高优先级任务的第一个xEventGroupWaitBits()会清除两个事件标志位,因此第二个xEventGroupWaitBits()将会导致高优先级任务阻塞。
<2>此时被第8步唤醒的中优先级有机会得到执行。

在这里插入图片描述

(10)
<1>中优先级执行一次之后,会设置EventGroup_M事件标志位。因为高优先级的第二个xEventGroupWaitBits()的第4个参数设置的是pdFALSE,因此满足条件,高优先级任务被唤醒。
<2>此时回到初始状态,即执行到高优先级的第一个xEventGroupWaitBits(),而且两个事件标志位都被清零,所有任务都处于就绪态。因此回到第一步开始执行。

在这里插入图片描述

测试超时对xClearOnExit设置为pdTRUE的影响

添加代码

(1)关于超时对清除事件的影响这一部分,我所查到的资料中提及的比较少。因此我添加一个测试实验来进行讲解。
(2)我们首先找到上面的StartKeilTask_H()函数添加如下代码。

#define Test_TimeoutClear 1  //测试如果超时,xWaitForAllBits设置为pdTRUE是否会清除对应的事件
void StartKeilTask_H(void *argument)
{	
	EventBits_t uxBits;
#if Test_TimeoutClear
	//这里目的是为了让低优先级任务先释放事件
	vTaskDelay(6);
#endif /* Test_TimeoutClear */
	while(1)
	{
#if Test_TimeoutClear
		//清除对应的事件位; 需要中优先级或低优先级任务先完成才能执行这里
		uxBits = xEventGroupWaitBits( KeilEventGroupHandle, EventGroup_ALL, pdTRUE, pdTRUE, 0 );
		if((uxBits & ( EventGroup_M | EventGroup_L )) == ( EventGroup_M | EventGroup_L ))
		{
			printf("EventGroup_M | EventGroup_L\r\n");
		}
		else if((uxBits & EventGroup_L ) != 0)
		{
			printf("EventGroup_L\r\n");			
		}
		else if((uxBits & EventGroup_M ) != 0)
		{
			printf("EventGroup_M\r\n");			
		}
		else
		{
			printf("Timeout\r\n");
		}
#endif /* Test_TimeoutClear */
		
		//清除对应的事件位; 需要中优先级和低优先级任务先完成才能执行这里;
		xEventGroupWaitBits( KeilEventGroupHandle, EventGroup_ALL, pdTRUE, pdTRUE, portMAX_DELAY );
		Task_H = 1;
		Task_M = 0;
		Task_L = 0;
		// 这里是刻意采用阻塞延时,目的是增加任务执行时间
		HAL_Delay(5);
		
		//清除对应的事件位; 需要中优先级或低优先级任务先完成才能执行这里
		uxBits = xEventGroupWaitBits( KeilEventGroupHandle, EventGroup_ALL, pdTRUE, pdFALSE, portMAX_DELAY );
		Task_H = 1;
		Task_M = 0;
		Task_L = 0;
		// 这里是刻意采用阻塞延时,目的是增加任务执行时间
		HAL_Delay(5);
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

解析结果

(1)首先,最高优先级任务我增加一个6ms的非阻塞延时函数,目的是为了能够让低优先级任务执行完成,然后释放事件。

在这里插入图片描述

(2)此时我们有一个事件被释放,但是因为xEventGroupWaitBits()要求满足两个事件,因此这里会被立刻返回。并且成功打印出超时退出时候的事件组值。

在这里插入图片描述

(3)但是因为此时是超时,所以低优先级释放的事件组并不会被清除。所以我们看到,逻辑分析仪上的数据两次流程是一模一样的。

在这里插入图片描述

API函数介绍

创建

(1)动态创建事件组

/**
 * @brief  动态创建事件组
 *
 * @param  无
 *
 * @return 如果创建了事件组, 则返回事件组的句柄。 如果没有足够的 FreeRTOS 堆可用于创建事件组, 则返回 NULL。
 */
EventGroupHandle_t xEventGroupCreate( void );
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

(2)静态创建事件组

/**
 * @brief  静态创建事件组
 *
 * @param  pxEventGroupBuffer 等待的事件组
 *
 * @return 如果成功创建了事件组, 则返回事件组的句柄。 如果 pxEventGroupBuffer 为 NULL,则返回 NULL。
 */
EventGroupHandle_t xEventGroupCreateStatic( StaticEventGroup_t *pxEventGroupBuffer );

/* --- 使用方法 --- */

/* 声明一个变量来保存创建的事件组的句柄。*/
EventGroupHandle_t xEventGroupHandle;
/* 声明一个变量来保存与创建的事件组相关联的数据。 */
StaticEventGroup_t xCreatedEventGroup;
/* 尝试创建事件组。 */
xEventGroupHandle = xEventGroupCreateStatic( &xCreatedEventGroup );
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

删除事件组

(1)删除事件组

void vEventGroupDelete( EventGroupHandle_t xEventGroup );
  • 1

设置事件标志位

(1)这里需要注意一点,我看有一些C站博主真的乱写博客,所以这里额外说明一下。如果我想让bit0bit3被置为,应该写成((1<<0) | (1<<3))或者直接写0x09,而不是(0x01 | 0x03)
(2)(0x01 | 0x03)最终置位的是bit0bit1

/**
 * @brief  设置事件标志位
 *
 * @param  xEventGroup   要设置位的事件组
 *        -uxBitsToSet   指定要在事件组中设置的一个或多个位的按位值。 例如,将 uxBitsToSet 设置为 0x08,可仅设置位 3。 将 uxBitsToSet 设置 为 0x09,可设置位 3 和位 0。
 *
 * @return 如果成功创建了事件组, 则返回事件组的句柄。 如果 pxEventGroupBuffer 为 NULL,则返回 NULL。
 */
EventBits_t xEventGroupSetBits( EventGroupHandle_t xEventGroup,const EventBits_t uxBitsToSet );
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

清除事件标志位

(1)用法同xEventGroupSetBits()

EventBits_t xEventGroupClearBits(EventGroupHandle_t xEventGroup,const EventBits_t uxBitsToClear );
  • 1

等待事件标志位

(1)参照上面事件组的两个重要参数和返回值

EventBits_t xEventGroupWaitBits(
                       const EventGroupHandle_t xEventGroup,
                       const EventBits_t uxBitsToWaitFor,
                       const BaseType_t xClearOnExit,
                       const BaseType_t xWaitForAllBits,
                       TickType_t xTicksToWait );
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

参考

(1)ST官方英文论坛:Is there a way to disable the automatic default task creation in CubeMx ?
(2)掌握FreeRTOS实时内核:事件组
(3)FreeRTOS官方手册:事件组

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小蓝xlanll/article/detail/353634
推荐阅读
  

闽ICP备14008679号