FreeRTOS实战(四)·USART串口实现DMA数据转运(江协/江科大代码移植)

#新星杯·14天创作挑战营·第11期#

目录

1.  DMA

1.1  简介

1.2  存储器映像

1.3  DMA框图

1.4  基本结构

1.5  触发源选择

1.6  数据宽度与对齐

2.  NVIC

2.1  简介

2.2  NVIC基本结构

2.3  NVIC优先级分组

2.4  NVIC初始化

3.  程序设计

3.1  中断配置

3.2  串口配置

3.3  DMA数据转运初始化配置

3.4  创建二值信号量

3.5  DMA数据转运配置

3.6  中断服务函数

3.7  接收任务创建

3.8  完整main函数

3.9  完整Usart函数

3.10  运行结果


1.  DMA

1.1  简介

        DMA,全称Direct Memory Access,即直接存储器访问。

        DMA传输将数据从一个地址空间复制到另一个地址空间,提供在外设和存储器之间或者存储器和存储器之间的高速数据传输,无须CPU干预,节省了CPU的资源。

        如果没有不通过DMA,CPU传输数据还要以内核作为中转站,例如将ADC采集的数据转移到SRAM中。

        而如果通过DMA的话,DMA控制器将获取到的外设数据存储到DMA通道中,然后通过DMA总线与DMA总线矩阵协调,将数据传输到SRAM中,期间不需内核参与。

主要特征:

  • 同一个DMA模块上,多个请求间的优先权可以通过软件编程设置(共有四级:很高、高、中等和低),优先权设置相等时由硬件决定(请求0优先于请求1,依此类推);
  • 独立数据源和目标数据区的传输宽度(字节、半字、全字);
  • 可编程的数据传输数目:最大为65535;
  • 对于大容量的STM32芯片有2个DMA控制器 两个DMA控制器,DMA1有7个通道,DMA2有5个通道。

1.2  存储器映像

        计算机系统的五大组成部分:运算器、控制器、存储器、输入设备和输出设备。

        其中运算器和控制器合在一起叫CPU。

STM32所有类型的存储器:

类型起始地址存储器用途
ROM0x0800 0000程序存储器Flash存储C语言编译后的程序代码
0x1FFF F000系统存储器存储BootLoader,用于串口下载
0x1FFF F800选项字节存储一些独立于程序代码的配置参数
RAM0x2000 0000运行内存SRAM存储运行过程中的临时变量
0x4000 0000外设寄存器存储各个外设的配置参数
0xE000 0000内核外设寄存器存储内核各个外设的配置参数

1.3  DMA框图

        在看之前我们先需要搞懂一个概念什么是寄存器,寄存器是一种特殊的存储器,寄存器的每一位后面连接着一根导线,可以操作外设电平的状态,完成如操作引脚电平,开关的打开或者关闭,切换数据选择器,当做计数器等的操作:

所以寄存器可以说是连接软件和硬件的桥梁,软件读写寄存器就相当于在控制硬件的执行。

        下面我们来看看DMA的框图:

①:DMA总线访问各个存储器;

②:DMA内部的多个通道进行独立的数据转运;

③:仲裁器用于管理多个通道,防止冲突;

④:DMA从设备用于配置DMA参数;

⑤:DMA请求用于硬件触发DMA的数据转运;

1.4  基本结构

        上面的图看不懂,没关系,我们总结一下:

        我们拆分一下,先看这部分:

        可以看出DMA的转运是后方向的,可以外设到内存,也可以内存到外设,我们可以通过函数进行控制:

#define DMA_DIR_PeripheralDST              ((uint32_t)0x00000010)
#define DMA_DIR_PeripheralSRC              ((uint32_t)0x00000000)
#define IS_DMA_DIR(DIR) (((DIR) == DMA_DIR_PeripheralDST) || \
                         ((DIR) == DMA_DIR_PeripheralSRC))

        然后再来看看二者所需的数据:

        首先是基地址,也就是两者的起始地址,这两个参数决定数据从哪里来到哪里去,所需函数:

//外设
  uint32_t DMA_PeripheralBaseAddr; /*!< Specifies the peripheral base address for DMAy Channelx. */

//存储器
  uint32_t DMA_MemoryBaseAddr;     /*!< Specifies the memory base address for DMAy Channelx. */

        然后是数据宽度,其作用计时指定一次转运要按多大的数据宽度来进行,其可以选择字节(uint8_t),半字(uint16_t),字(uint32_t):

#define DMA_PeripheralDataSize_Byte        ((uint32_t)0x00000000)
#define DMA_PeripheralDataSize_HalfWord    ((uint32_t)0x00000100)
#define DMA_PeripheralDataSize_Word        ((uint32_t)0x00000200)
#define IS_DMA_PERIPHERAL_DATA_SIZE(SIZE) (((SIZE) == DMA_PeripheralDataSize_Byte) || \
                                           ((SIZE) == DMA_PeripheralDataSize_HalfWord) || \
                                           ((SIZE) == DMA_PeripheralDataSize_Word))

        其函数是:

//外设
  uint32_t DMA_PeripheralDataSize; /*!< Specifies the Peripheral data width.
                                        This parameter can be a value of @ref DMA_peripheral_data_size */

//存储器
  uint32_t DMA_MemoryDataSize;     /*!< Specifies the Memory data width.
                                        This parameter can be a value of @ref DMA_memory_data_size */

        地址是否自增,作用是决定下次转运是不是要把地址移到下一个位置去,其参数可以选择使能或者失能:

#define DMA_PeripheralInc_Enable           ((uint32_t)0x00000040)
#define DMA_PeripheralInc_Disable          ((uint32_t)0x00000000)
#define IS_DMA_PERIPHERAL_INC_STATE(STATE) (((STATE) == DMA_PeripheralInc_Enable) || \
                                            ((STATE) == DMA_PeripheralInc_Disable))

        其函数是:

//外设
  uint32_t DMA_PeripheralInc;      /*!< Specifies whether the Peripheral address register is incremented or not.
                                        This parameter can be a value of @ref DMA_peripheral_incremented_mode */

//存储器
  uint32_t DMA_MemoryInc;          /*!< Specifies whether the memory address register is incremented or not.
                                        This parameter can be a value of @ref DMA_memory_incremented_mode */

        然后我们看看另一个参数:传输计数器,这个值表示DMA需要转运几次,你可以将其理解为他是一个自减计数器,假如你初始化的值为5,那么每次转运一次计数减1,当减到0的时候,DMA就不会在进行转运了,并且当其减到0,之前自增的地址又会回到起始地址,方便新一轮的转换:

  uint32_t DMA_BufferSize;         /*!< Specifies the buffer size, in data unit, of the specified Channel. 
                                        The data unit is equal to the configuration set in DMA_PeripheralDataSize
                                        or DMA_MemoryDataSize members depending in the transfer direction. */

        那么他是怎么进行新一轮的转换呢?这就要靠自动重装器,其作用就是当转运次数归零后,询问是否将转运次数回到最初值,这样如果我们配置为循环模式,DMA计数归零回到起始地址,而自动重装器又将DMA的数据恢复,这样就可以循环:

#define DMA_Mode_Circular                  ((uint32_t)0x00000020)
#define DMA_Mode_Normal                    ((uint32_t)0x00000000)
#define IS_DMA_MODE(MODE) (((MODE) == DMA_Mode_Circular) || ((MODE) == DMA_Mode_Normal))

        函数:

  uint32_t DMA_Mode;               /*!< Specifies the operation mode of the DMAy Channelx.
                                        This parameter can be a value of @ref DMA_circular_normal_mode.
                                        @note: The circular buffer mode cannot be used if the memory-to-memory
                                              data transfer is configured on the selected Channel */

        然后就是触发机制,主要配置其使能或者失能,使能软件触发,失能硬件触发:

#define DMA_M2M_Enable                     ((uint32_t)0x00004000)
#define DMA_M2M_Disable                    ((uint32_t)0x00000000)
#define IS_DMA_M2M_STATE(STATE) (((STATE) == DMA_M2M_Enable) || ((STATE) == DMA_M2M_Disable))

这里需要注意一点软件触发不能和循环一起使用。

因为软件触发就是想将传输计数器清零,但是循环模式我们上面也说了清零后会进行自动重装,因此不能一起使用。

        最后就是,使能DMA,也就是开启DMA:

1.5  触发源选择

        对于硬件触发我们需要根据不同的触发源,选择不同的通道,软件触发就随便了:

1.6  数据宽度与对齐

        根据下表,简单来说就是右对齐,要是目标宽度不够,取最低位(可以参考第四行),要是目标宽度比源端宽度大则高位补零(可以参考第二或者三行):

2.  NVIC

2.1  简介

        首先,我们需要知道什么是中断?

        中断是在主程序运行过程中,出现了特定的中断触发条件(中断源),使得CPU暂停当前正在运行的程序,转而去处理中断程序,处理完成后又返回原来被暂停的位置继续运行。

        什么是中断优先级?

        中断优先级是:当有多个中断源同时申请中断时,CPU会根据中断源的轻重缓急进行裁决,优先响应更加紧急的中断源。

        什么是中断嵌套?

        中断嵌套是:当一个中断程序正在运行时,又有新的更高优先级的中断源申请中断,CPU再次暂停当前中断程序,转而去处理新的中断程序,处理完成后依次进行返回。

        STM32F1系列包含最多:68个可屏蔽中断通道,包含EXTI、TIM、ADC、USART、SPI、I2C、RTC等多个外设。

        使用NVIC统一管理中断,每个中断通道都拥有16个可编程的优先等级,可对优先级进行分组,进一步设置抢占优先级和响应优先级。

2.2  NVIC基本结构

        NVIC:嵌套中断向量控制器

        统一分配中断优先级,和管理中断

        NVIC内核外设,CPU的小助手

        米色圈住部分,意思是:一个外设可能会占用多个中断通道,所以这里有“n”条线,NVIC只有一个输出口,NVIC根据每个中断的优先级进行分配中断的先后顺序。

例如:医院叫号,CPU为医生,NVIC进行排号,中断是病人,NVIC根据病人的紧急程度进行排号,找医生。

2.3  NVIC优先级分组

        NVIC的中断优先级由优先级寄存器的4位(0~15)决定,这4位可以进行切分,分为高n位的抢占优先级和低4-n位的响应优先级。

        抢占优先级高的可以中断嵌套,响应优先级高的可以优先排队,抢占优先级和响应优先级均相同的按中断号排队。

        对于响应优先级,相当于“插队”,此时“1”在看病(程序在进行中),“4”要是比较严重,“4”可以进行插队到“2”前面,但是要等“1”看完(进程走完):

        对于抢占优先级(中断嵌套),相当于“1”在看病(程序在进行中),“4”不等“1”看完直接冲到屋内,把“1”推到一边,“4”先看病(进行),“4”先看完,在进行“1”,再依次进行后续的操作:

在FreeRTOS中我们主要使用的是优先级分组4,即所有优先级都是抢占优先级,这样FreeRTOS能完全控制哪些任务或中断可以抢占当前上下文。

2.4  NVIC初始化

        NVIC的配置非常简单:中断分组、定义结构体、配置中断线路、配置优先级、使能:

	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);	
 
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;						//定义结构体变量
 
	NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;		//选择配置NVIC的EXTI15_10_IRQn线
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 6;	//指定NVIC线路的抢占优先级为6
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;			//指定NVIC线路的响应优先级为0
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;				//指定NVIC线路使能
	NVIC_Init(&NVIC_InitStructure);								//将结构体变量交给NVIC_Init,配置NVIC外设	
 

3.  程序设计

        在 FreeRTOS 中创建一个任务,该任务的作用是用于接收串口发送的数据,我们采用二值信号量进行处理,当串口接收到数据后,DMA将串口接收到的数据直接存到数组中,无需CPU的参与,当串口检测到总线空闲后触发中断,重新计数DMA的值,并发送信号量通知任务处理。

        对于二值信号量,入门篇已经详细解释了,这里不在做过多的描述,我们可以将二值信号量理解为一个非0即1的标志位,详细可以看看,上面一篇是概念介绍,下面是API的调用:

FreeRTOS菜鸟入门(十一)·信号量·二值、计数、递归以及互斥信号量的区别·优先级翻转以及继承机制详解_互斥锁和二值信号量-CSDN博客

FreeRTOS菜鸟入门(十二)·信号量·二值信号量与计数信号量_#include "semphr.h-CSDN博客

        对于下面我们想要使用的工程,是我们之前移植好的空白工程,介绍中附带有详细移植链接:

基于STM32F1系列FreeRTOS移植模版资源-CSDN文库

        准备工作完成了,开始移植。

3.1  中断配置

        我们将我们上面工程的Usart.c的代码更改一下,加一些中断处理,增加系统的稳定性,对于中断的配置我们前面也说了:

//配置嵌套向量中断控制器NVIC
static void NVIC_Configuration(void)
{
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);	
 
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;						//定义结构体变量
 
	NVIC_InitStructure.NVIC_IRQChannel = DEBUG_USART_IRQ;		//选择配置NVIC的USART1_IRQn线
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 6;	//指定NVIC线路的抢占优先级为6
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;			//指定NVIC线路的响应优先级为0
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;				//指定NVIC线路使能
	NVIC_Init(&NVIC_InitStructure);								//将结构体变量交给NVIC_Init,配置NVIC外设	
 
}

        对于这里为什么这样配置,首先对于为什么需要将优先级分组设为4,我们前面也描述了:我们知道响应优先级虽然也能插队,但是其还是需要等1,做完事情才能轮到它:

        但是抢占优先级就不同了,抢占优先级表示我就是老大,无论前面谁做什么事情,都往旁边让让,我先来:

        而我们FreeRTOS讲究的就是实时响应,这样配置为分组4,用于16个抢占优先级多么完美。不过抢占优先级也不能随便给,我们在FreeRTOSConfig.h文件做了一些配置:

/******************************************************************
            FreeRTOS与中断有关的配置选项                                                 
******************************************************************/
#ifdef __NVIC_PRIO_BITS
	#define configPRIO_BITS       		__NVIC_PRIO_BITS
#else
	#define configPRIO_BITS       		4                  
#endif
//中断最低优先级
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY			15     

//系统可管理的最高中断优先级
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY	5 

#define configKERNEL_INTERRUPT_PRIORITY 		( configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )	/* 240 */

#define configMAX_SYSCALL_INTERRUPT_PRIORITY 	( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )

        首先我们需要了解,我们FreeRTOS需要靠中断进行计数节拍来进行任务的调度,我们硬件外设调用中断不能优先级比系统节拍还高,这样就会造成系统阻塞,这里我们还要知道两个概念:

中断类型优先级作用
SysTick通常为 0(最高)系统节拍中断,用于任务调度和时间管理。
PendSVconfigKERNEL_INTERRUPT_PRIORITY(最低)上下文切换中断,确保在无其他中断时切换任务。

        既然SysTick已经设成0了(最高优先级),但是为什么我们还要将可配置的优先级设为最高到5呢?这是因为剩下的几个保留给,不依赖 FreeRTOS 的高实时性中断(如硬件看门狗),这样我们在设置后续的中断优先级,既不会影响系统实施性,又能做到实时中断。

3.2  串口配置

        还是在Usart.c文件,找到串口初始化函数USART_Config()做一些修改,其他配置不用变,增加刚刚配置好的中断函数:

	// 串口中断优先级配置
	NVIC_Configuration();

        同时使能空闲中断,用于检测一帧数据接收完成:

	// 开启 串口空闲IDEL 中断
	USART_ITConfig(DEBUG_USARTx, USART_IT_IDLE, ENABLE);//使能 串口空闲中断(IDLE),用于检测一帧数据接收完成。  

        然后开启串口接收DMA转运数据:

  // 开启串口DMA接收
	USART_DMACmd(DEBUG_USARTx, USART_DMAReq_Rx, ENABLE); 

        完整修改:

//USART GPIO 配置,工作参数配置
void USART_Config(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;
	USART_InitTypeDef USART_InitStructure;

	// 打开串口GPIO的时钟
	DEBUG_USART_GPIO_APBxClkCmd(DEBUG_USART_GPIO_CLK, ENABLE);
	
	// 打开串口外设的时钟
	DEBUG_USART_APBxClkCmd(DEBUG_USART_CLK, ENABLE);

	// 将USART Tx的GPIO配置为推挽复用模式
	GPIO_InitStructure.GPIO_Pin = DEBUG_USART_TX_GPIO_PIN;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(DEBUG_USART_TX_GPIO_PORT, &GPIO_InitStructure);

  // 将USART Rx的GPIO配置为浮空输入模式
	GPIO_InitStructure.GPIO_Pin = DEBUG_USART_RX_GPIO_PIN;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
	GPIO_Init(DEBUG_USART_RX_GPIO_PORT, &GPIO_InitStructure);
	
	// 配置串口的工作参数
	// 配置波特率
	USART_InitStructure.USART_BaudRate = DEBUG_USART_BAUDRATE;
	// 配置 针数据字长
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;
	// 配置停止位
	USART_InitStructure.USART_StopBits = USART_StopBits_1;
	// 配置校验位
	USART_InitStructure.USART_Parity = USART_Parity_No ;
	// 配置硬件流控制
	USART_InitStructure.USART_HardwareFlowControl = 
	USART_HardwareFlowControl_None;
	// 配置工作模式,收发一起
	USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
	// 完成串口的初始化配置
	USART_Init(DEBUG_USARTx, &USART_InitStructure);	

	// 串口中断优先级配置
	NVIC_Configuration();
	// 开启 串口空闲IDEL 中断
	USART_ITConfig(DEBUG_USARTx, USART_IT_IDLE, ENABLE);//使能 串口空闲中断(IDLE),用于检测一帧数据接收完成。  
  // 开启串口DMA接收
	USART_DMACmd(DEBUG_USARTx, USART_DMAReq_Rx, ENABLE); 
	
	// 使能串口
	USART_Cmd(DEBUG_USARTx, ENABLE);	    
}

3.3  DMA数据转运初始化配置

        这个我们可以根据刚才介绍的这张图进行去配置:

        先确认三个宏定义:

// 串口对应的DMA请求通道
#define  USART_RX_DMA_CHANNEL     DMA1_Channel5
// 外设寄存器地址
#define  USART_DR_ADDRESS        (&DEBUG_USARTx->DR)
// 一次发送的数据量
#define  USART_RBUFF_SIZE            1000 

        开始初始化配置,首先开启时钟,定义结构体,确认传输方向,我们这是想传输串口的数据,因此外设到存储器:

	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);// 开启DMA时钟

	/*DMA初始化*/
	DMA_InitTypeDef DMA_InitStructure;											//定义结构体变量

	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;							//数据传输方向,选择由外设到存储器

        然后是对二者格式的配置:

外设基地址:

        外设基地址:因为我们是想读取串口数据,因此基地址指向串口的数据寄存器地址(如&USART1->DR);

        存储器基地址:我们创建一个缓冲区数组,接收数据存放在其中,基地址指向数组的首地址。

char Usart_Rx_Buf[USART_RBUFF_SIZE];

数据宽度:都是字节。

地址是否自增:

        外设:因为我们需要从串口的数据寄存器地址取出数据,因此不能自增。

        存储器:为了防止数据覆盖,将数据依次存入缓冲区,地址需要自增。

	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)USART_DR_ADDRESS;			//外设基地址,给定形参AddrA
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;	    //外设数据宽度
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;			      //外设地址自增,选择失能

	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)Usart_Rx_Buf;				//存储器基地址,内存地址(要传输的变量的指针)
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;			  //存储器数据宽度
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;						    //存储器地址自增,选择使能,每次转运后,数组移到下一个位置

        然后转运次数,模式为循环模式,因为我们的触发方式是外设到存储器,因此将存储器到存储器失能,优先级因为就一个DMA,因此无所谓,这里就给了中等,然后初始化:

	DMA_InitStructure.DMA_BufferSize = USART_RBUFF_SIZE;						//转运的数据大小(转运次数)
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;								  //模式,选择循环模式
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;							    	//存储器到存储器,选择失能
	DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh;					//优先级,选择中等
	DMA_Init(USART_RX_DMA_CHANNEL, &DMA_InitStructure);							//将结构体变量交给DMA_Init,配置DMA1的通道1

        开始时清除标志位,对于标志位的选择我们上面也说了,硬件DMA转运有其特定的通道,这里我们主要使用的是USART1的接收,因此选择通道5(图可以参考上面DMA触发源选择的图),使能DMA:

	// 清除DMA所有标志
	DMA_ClearFlag(DMA1_FLAG_TC5);
	DMA_ITConfig(USART_RX_DMA_CHANNEL, DMA_IT_TE, ENABLE);
	// 使能DMA
	DMA_Cmd (USART_RX_DMA_CHANNEL,ENABLE);

        完整配置:

char Usart_Rx_Buf[USART_RBUFF_SIZE];

void USARTx_DMA_Config(void)
{
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);// 开启DMA时钟

	/*DMA初始化*/
	DMA_InitTypeDef DMA_InitStructure;											//定义结构体变量

	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;							//数据传输方向,选择由外设到存储器

	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)USART_DR_ADDRESS;			//外设基地址,给定形参AddrA
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;	    //外设数据宽度
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;			      //外设地址自增,选择失能

	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)Usart_Rx_Buf;				//存储器基地址,内存地址(要传输的变量的指针)
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;			  //存储器数据宽度
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;						    //存储器地址自增,选择使能,每次转运后,数组移到下一个位置

	DMA_InitStructure.DMA_BufferSize = USART_RBUFF_SIZE;						//转运的数据大小(转运次数)
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;								  //模式,选择循环模式
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;							    	//存储器到存储器,选择失能
	DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh;					//优先级,选择中等
	DMA_Init(USART_RX_DMA_CHANNEL, &DMA_InitStructure);							//将结构体变量交给DMA_Init,配置DMA1的通道1

	// 清除DMA所有标志
	DMA_ClearFlag(DMA1_FLAG_TC5);
	DMA_ITConfig(USART_RX_DMA_CHANNEL, DMA_IT_TE, ENABLE);
	// 使能DMA
	DMA_Cmd (USART_RX_DMA_CHANNEL,ENABLE);
}

3.4  创建二值信号量

        因为我们后续使用DMA数据转运需要二值信号量,我们先来到主函数进行创建一下,首先创建一个二值信号量句柄,方便调用:

SemaphoreHandle_t BinarySem_Handle =NULL;

        然后找到AppTaskCreate()函数,调用xSemaphoreCreateBinary()函数创建二值信号量:

   /* 创建 BinarySem */
  BinarySem_Handle = xSemaphoreCreateBinary();	 
  
	if(NULL != BinarySem_Handle)
    printf("BinarySem_Handle二值信号量创建成功!\r\n");

        调用完成的函数:

//任务创建函数
static void AppTaskCreate(void)
{
  BaseType_t xReturn = pdPASS;/* 定义一个创建信息返回值,默认为pdPASS */
  
  taskENTER_CRITICAL();           //进入临界区

   /* 创建 BinarySem */
  BinarySem_Handle = xSemaphoreCreateBinary();	 
  
  if(NULL != BinarySem_Handle)
    printf("BinarySem_Handle二值信号量创建成功!\r\n");
	
  
  vTaskDelete(AppTaskCreate_Handle); //删除AppTaskCreate任务
  
  taskEXIT_CRITICAL();            //退出临界区
}

        注意调用头文件:

#include "stm32f10x.h"                  // Device header
#include "Delay.h"

#include "LED.h"
#include "Usart.h"
#include "Key.h"  

/* FreeRTOS头文件 */
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"

/* 标准库头文件 */
#include <string.h>

3.5  DMA数据转运配置

        为了防止在操作DMA寄存器时,新数据写入导致配置冲突或数据损坏,我们先暂停DMA转运:

DMA_Cmd(USART_RX_DMA_CHANNEL, DISABLE);       // 暂停DMA
DMA_ClearFlag(DMA1_FLAG_TC5);                 // 清除传输完成标志
USART_RX_DMA_CHANNEL->CNDTR = USART_RBUFF_SIZE; // 重置计数器
DMA_Cmd(USART_RX_DMA_CHANNEL, ENABLE);        // 重启DMA

        然后由于这是在中断里,我们调用中断释放二值信号量函数:

	//给出二值信号量 ,发送接收到新数据标志,供前台程序查询
	xSemaphoreGiveFromISR(BinarySem_Handle,&pxHigherPriorityTaskWoken);	//释放二值信号量

        该宏用于在中断服务函数(ISR) 中检查是否需要立即进行任务切换。如果 pxHigherPriorityTaskWoken 为 pdTRUE,表示有更高优先级的任务因信号量释放而就绪,此时会触发 PendSV 中断,强制退出当前中断后立即执行任务切换。

  //如果需要的话进行一次任务切换,系统会判断是否需要进行切换
	portYIELD_FROM_ISR(pxHigherPriorityTaskWoken);

        完整配置:

extern SemaphoreHandle_t BinarySem_Handle;//二值信号量句柄

void Uart_DMA_Rx_Data(void)
{
	BaseType_t pxHigherPriorityTaskWoken;
	// 关闭DMA ,防止干扰
	DMA_Cmd(USART_RX_DMA_CHANNEL, DISABLE);      
	// 清DMA标志位
	DMA_ClearFlag( DMA1_FLAG_TC5 );          
	//  重新赋值计数值,必须大于等于最大可能接收到的数据帧数目
	USART_RX_DMA_CHANNEL->CNDTR = USART_RBUFF_SIZE;    
	DMA_Cmd(USART_RX_DMA_CHANNEL, ENABLE);       
	/* 
	xSemaphoreGiveFromISR(SemaphoreHandle_t xSemaphore,
												BaseType_t *pxHigherPriorityTaskWoken);
	*/
	
	
	//给出二值信号量 ,发送接收到新数据标志,供前台程序查询
	xSemaphoreGiveFromISR(BinarySem_Handle,&pxHigherPriorityTaskWoken);	//释放二值信号量
  //如果需要的话进行一次任务切换,系统会判断是否需要进行切换
	portYIELD_FROM_ISR(pxHigherPriorityTaskWoken);

	/* 
	DMA 开启,等待数据。注意,如果中断发送数据帧的速率很快,MCU来不及处理此次接收到的数据,
	中断又发来数据的话,这里不能开启,否则数据会被覆盖。有2种方式解决:

	1. 在重新开启接收DMA通道之前,将LumMod_Rx_Buf缓冲区里面的数据复制到另外一个数组中,
	然后再开启DMA,然后马上处理复制出来的数据。

	2. 建立双缓冲,在LumMod_Uart_DMA_Rx_Data函数中,重新配置DMA_MemoryBaseAddr 的缓冲区地址,
	那么下次接收到的数据就会保存到新的缓冲区中,不至于被覆盖。
	*/
}

3.6  中断服务函数

        这个非常简单,为了防止在数据传输的时候被打扰,进入临界段,然后判断是否接收接收到数据,进行DMA转运,然后退出临界区:

/* 声明引用外部队列 & 二值信号量 */
extern SemaphoreHandle_t BinarySem_Handle;

void DEBUG_USART_IRQHandler(void)
{
  uint32_t ulReturn;
  /* 进入临界段,临界段可以嵌套 */
  ulReturn = taskENTER_CRITICAL_FROM_ISR();

	if(USART_GetITStatus(DEBUG_USARTx,USART_IT_IDLE)!=RESET)
	{		
		Uart_DMA_Rx_Data();       /* 释放一个信号量,表示数据已接收 */
		USART_ReceiveData(DEBUG_USARTx); /* 清除标志位 */
	}	 
  
  /* 退出临界段 */
  taskEXIT_CRITICAL_FROM_ISR( ulReturn );
}

3.7  接收任务创建

        既然数据转运完数据了,我们肯定需要处理,所以我们创建一个接收任务,当二值信号量释放后(DMA数据转运成功),接收二值信号量,打印接收到的数据。

        首先回到主函数我们先创建一个任务句柄,方便对任务的操作:

static TaskHandle_t KEY_Task_Handle = NULL;/* KEY任务句柄 */

        创建任务主体,一直等待二值信号量,收到后LED电平翻转,打印数据,清空数据缓冲区:

static void KEY_Task(void* parameter)
{	
	BaseType_t xReturn = pdPASS;/* 定义一个创建信息返回值,默认为pdPASS */
  while (1)
  {
    //获取二值信号量 xSemaphore,没获取到则一直等待
		xReturn = xSemaphoreTake(BinarySem_Handle,/* 二值信号量句柄 */
                              portMAX_DELAY); /* 等待时间 */
    if(pdPASS == xReturn)
    {
      Toggle_LED_R();
      printf("收到数据:%s!\r\n",Usart_Rx_Buf);
      memset(Usart_Rx_Buf,0,USART_RBUFF_SIZE);/* 清零 */
    }
  }
}

        找到AppTaskCreate()把任务创建了:

  /* 创建KEY_Task任务 */
  xReturn = xTaskCreate((TaskFunction_t )KEY_Task,  /* 任务入口函数 */
                        (const char*    )"KEY_Task",/* 任务名字 */
                        (uint16_t       )512,  /* 任务栈大小 */
                        (void*          )NULL,/* 任务入口函数参数 */
                        (UBaseType_t    )3, /* 任务的优先级 */
                        (TaskHandle_t*  )&KEY_Task_Handle);/* 任务控制块指针 */ 
  if(pdPASS == xReturn)
    printf("创建KEY_Task任务成功!\r\n"); 

3.8  完整main函数

#include "stm32f10x.h"                  // Device header
#include "Delay.h"

#include "LED.h"
#include "Usart.h"
#include "Key.h"  

/* FreeRTOS头文件 */
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"

/* 标准库头文件 */
#include <string.h>

/* 
 * 任务句柄是一个指针,用于指向一个任务,当任务创建好之后,它就具有了一个任务句柄
 * 以后我们要想操作这个任务都需要通过这个任务句柄,如果是自身的任务操作自己,那么
 * 这个句柄可以为NULL。
 */
static TaskHandle_t AppTaskCreate_Handle = NULL; /* 创建任务句柄 */
static TaskHandle_t KEY_Task_Handle = NULL;/* KEY任务句柄 */

/********************************** 内核对象句柄 *********************************/
/*
 * 信号量,消息队列,事件标志组,软件定时器这些都属于内核的对象,要想使用这些内核
 * 对象,必须先创建,创建成功之后会返回一个相应的句柄。实际上就是一个指针,后续我
 * 们就可以通过这个句柄操作这些内核对象。
 *
 * 内核对象说白了就是一种全局的数据结构,通过这些数据结构我们可以实现任务间的通信,
 * 任务间的事件同步等各种功能。至于这些功能的实现我们是通过调用这些内核对象的函数
 * 来完成的
 * 
 */
SemaphoreHandle_t BinarySem_Handle =NULL;

/******************************* 宏定义 ************************************/
/*
 * 当我们在写应用程序的时候,可能需要用到一些宏定义。
 */
extern char Usart_Rx_Buf[USART_RBUFF_SIZE];

//一些函数声明
static void AppTaskCreate(void);/* 用于创建任务 */

static void KEY_Task(void* pvParameters);/* KEY_Task任务实现 */

static void All_Function_Init(void);/* 用于初始化板载相关资源 */

int main(void)
{
  BaseType_t xReturn = pdPASS;/* 定义一个创建信息返回值,默认为pdPASS */

	All_Function_Init();//硬件初始化
	
	while (1)
	{
		 /* 创建AppTaskCreate任务 */
		xReturn = xTaskCreate((TaskFunction_t )AppTaskCreate,  /* 任务入口函数 */
													(const char*    )"AppTaskCreate",/* 任务名字 */
													(uint16_t       )512,  /* 任务栈大小 */
													(void*          )NULL,/* 任务入口函数参数 */
													(UBaseType_t    )1, /* 任务的优先级 */
													(TaskHandle_t*  )&AppTaskCreate_Handle);/* 任务控制块指针 */ 
		/* 启动任务调度 */           
		if(pdPASS == xReturn)
			vTaskStartScheduler();   /* 启动任务,开启调度 */
		else
			return -1;  
	}
}

//任务创建函数
static void AppTaskCreate(void)
{
  BaseType_t xReturn = pdPASS;/* 定义一个创建信息返回值,默认为pdPASS */
  
  taskENTER_CRITICAL();           //进入临界区

   /* 创建 BinarySem */
  BinarySem_Handle = xSemaphoreCreateBinary();	 
  
	if(NULL != BinarySem_Handle)
    printf("BinarySem_Handle二值信号量创建成功!\r\n");
	
  /* 创建KEY_Task任务 */
  xReturn = xTaskCreate((TaskFunction_t )KEY_Task,  /* 任务入口函数 */
                        (const char*    )"KEY_Task",/* 任务名字 */
                        (uint16_t       )512,  /* 任务栈大小 */
                        (void*          )NULL,/* 任务入口函数参数 */
                        (UBaseType_t    )3, /* 任务的优先级 */
                        (TaskHandle_t*  )&KEY_Task_Handle);/* 任务控制块指针 */ 
  if(pdPASS == xReturn)
    printf("创建KEY_Task任务成功!\r\n"); 
  
  vTaskDelete(AppTaskCreate_Handle); //删除AppTaskCreate任务
  
  taskEXIT_CRITICAL();            //退出临界区
}

static void KEY_Task(void* parameter)
{	
	BaseType_t xReturn = pdPASS;/* 定义一个创建信息返回值,默认为pdPASS */
  while (1)
  {
    //获取二值信号量 xSemaphore,没获取到则一直等待
		xReturn = xSemaphoreTake(BinarySem_Handle,/* 二值信号量句柄 */
                              portMAX_DELAY); /* 等待时间 */
    if(pdPASS == xReturn)
    {
      Toggle_LED_R();
      printf("收到数据:%s!\r\n",Usart_Rx_Buf);
      memset(Usart_Rx_Buf,0,USART_RBUFF_SIZE);/* 清零 */
    }
  }
}


//初始化声明
static void All_Function_Init(void)
{
	/*
	 * STM32中断优先级分组为4,即4bit都用来表示抢占优先级,范围为:0~15
	 * 优先级分组只需要分组一次即可,以后如果有其他的任务需要用到中断,
	 * 都统一用这个优先级分组,千万不要再分组,切忌。
	 */
	NVIC_PriorityGroupConfig( NVIC_PriorityGroup_4 );
	
	/* LED 初始化 */
	LED_GPIO_Config();

	/* 串口初始化	*/
	USART_Config();

	//按键初始化
	Key_GPIO_Config();

 	/* DMA初始化	*/
	USARTx_DMA_Config(); 
}



3.9  完整Usart函数

#include "Usart.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"

char Usart_Rx_Buf[USART_RBUFF_SIZE];//DMA数据存储缓冲区

//配置嵌套向量中断控制器NVIC
static void NVIC_Configuration(void)
{
	/*NVIC中断分组*/
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);	
 
	/*NVIC配置*/
	NVIC_InitTypeDef NVIC_InitStructure;						//定义结构体变量
 
	NVIC_InitStructure.NVIC_IRQChannel = DEBUG_USART_IRQ;		//选择配置NVIC的USART1_IRQn线
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 6;	//指定NVIC线路的抢占优先级为6
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;			//指定NVIC线路的响应优先级为0
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;				//指定NVIC线路使能
	NVIC_Init(&NVIC_InitStructure);								//将结构体变量交给NVIC_Init,配置NVIC外设	
 
}

//USART GPIO 配置,工作参数配置
void USART_Config(void)
{
	GPIO_InitTypeDef GPIO_InitStructure;
	USART_InitTypeDef USART_InitStructure;

	// 打开串口GPIO的时钟
	DEBUG_USART_GPIO_APBxClkCmd(DEBUG_USART_GPIO_CLK, ENABLE);
	
	// 打开串口外设的时钟
	DEBUG_USART_APBxClkCmd(DEBUG_USART_CLK, ENABLE);

	// 将USART Tx的GPIO配置为推挽复用模式
	GPIO_InitStructure.GPIO_Pin = DEBUG_USART_TX_GPIO_PIN;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(DEBUG_USART_TX_GPIO_PORT, &GPIO_InitStructure);

  // 将USART Rx的GPIO配置为浮空输入模式
	GPIO_InitStructure.GPIO_Pin = DEBUG_USART_RX_GPIO_PIN;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
	GPIO_Init(DEBUG_USART_RX_GPIO_PORT, &GPIO_InitStructure);
	
	// 配置串口的工作参数
	// 配置波特率
	USART_InitStructure.USART_BaudRate = DEBUG_USART_BAUDRATE;
	// 配置 针数据字长
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;
	// 配置停止位
	USART_InitStructure.USART_StopBits = USART_StopBits_1;
	// 配置校验位
	USART_InitStructure.USART_Parity = USART_Parity_No ;
	// 配置硬件流控制
	USART_InitStructure.USART_HardwareFlowControl = 
	USART_HardwareFlowControl_None;
	// 配置工作模式,收发一起
	USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
	// 完成串口的初始化配置
	USART_Init(DEBUG_USARTx, &USART_InitStructure);	

	// 串口中断优先级配置
	NVIC_Configuration();
	// 开启 串口空闲IDEL 中断
	USART_ITConfig(DEBUG_USARTx, USART_IT_IDLE, ENABLE);//使能 串口空闲中断(IDLE),用于检测一帧数据接收完成。  
  // 开启串口DMA接收
	USART_DMACmd(DEBUG_USARTx, USART_DMAReq_Rx, ENABLE); 
	
	// 使能串口
	USART_Cmd(DEBUG_USARTx, ENABLE);	    
}

//DMA初始化
void USARTx_DMA_Config(void)
{
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);// 开启DMA时钟

	/*DMA初始化*/
	DMA_InitTypeDef DMA_InitStructure;											//定义结构体变量

	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;							//数据传输方向,选择由外设到存储器

	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)USART_DR_ADDRESS;			//外设基地址,给定形参AddrA
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;	    //外设数据宽度
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;			      //外设地址自增,选择失能

	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)Usart_Rx_Buf;				//存储器基地址,内存地址(要传输的变量的指针)
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;			  //存储器数据宽度
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;						    //存储器地址自增,选择使能,每次转运后,数组移到下一个位置

	DMA_InitStructure.DMA_BufferSize = USART_RBUFF_SIZE;						//转运的数据大小(转运次数)
	DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;								  //模式,选择循环模式
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;							    	//存储器到存储器,选择失能
	DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh;					//优先级,选择中等
	DMA_Init(USART_RX_DMA_CHANNEL, &DMA_InitStructure);							//将结构体变量交给DMA_Init,配置DMA1的通道1

	// 清除DMA所有标志
	DMA_ClearFlag(DMA1_FLAG_TC5);
	DMA_ITConfig(USART_RX_DMA_CHANNEL, DMA_IT_TE, ENABLE);
	// 使能DMA
	DMA_Cmd (USART_RX_DMA_CHANNEL,ENABLE);
}

extern SemaphoreHandle_t BinarySem_Handle;//二值信号量句柄

void Uart_DMA_Rx_Data(void)
{
	BaseType_t pxHigherPriorityTaskWoken;
	// 关闭DMA ,防止干扰
	DMA_Cmd(USART_RX_DMA_CHANNEL, DISABLE);      
	// 清DMA标志位
	DMA_ClearFlag( DMA1_FLAG_TC5 );          
	//  重新赋值计数值,必须大于等于最大可能接收到的数据帧数目
	USART_RX_DMA_CHANNEL->CNDTR = USART_RBUFF_SIZE;    
	DMA_Cmd(USART_RX_DMA_CHANNEL, ENABLE);       
	/* 
	xSemaphoreGiveFromISR(SemaphoreHandle_t xSemaphore,
												BaseType_t *pxHigherPriorityTaskWoken);
	*/
	
	
	//给出二值信号量 ,发送接收到新数据标志,供前台程序查询
	xSemaphoreGiveFromISR(BinarySem_Handle,&pxHigherPriorityTaskWoken);	//释放二值信号量
  //如果需要的话进行一次任务切换,系统会判断是否需要进行切换
	portYIELD_FROM_ISR(pxHigherPriorityTaskWoken);

	/* 
	DMA 开启,等待数据。注意,如果中断发送数据帧的速率很快,MCU来不及处理此次接收到的数据,
	中断又发来数据的话,这里不能开启,否则数据会被覆盖。有2种方式解决:

	1. 在重新开启接收DMA通道之前,将LumMod_Rx_Buf缓冲区里面的数据复制到另外一个数组中,
	然后再开启DMA,然后马上处理复制出来的数据。

	2. 建立双缓冲,在LumMod_Uart_DMA_Rx_Data函数中,重新配置DMA_MemoryBaseAddr 的缓冲区地址,
	那么下次接收到的数据就会保存到新的缓冲区中,不至于被覆盖。
	*/
}

/*****************  发送一个字节 **********************/
void Usart_SendByte( USART_TypeDef * pUSARTx, uint8_t ch)
{
	/* 发送一个字节数据到USART */
	USART_SendData(pUSARTx,ch);
		
	/* 等待发送数据寄存器为空 */
	while (USART_GetFlagStatus(pUSARTx, USART_FLAG_TXE) == RESET);	
}

/****************** 发送8位的数组 ************************/
void Usart_SendArray( USART_TypeDef * pUSARTx, uint8_t *array, uint16_t num)
{
  uint8_t i;
	
	for(i=0; i<num; i++)
  {
	    /* 发送一个字节数据到USART */
	    Usart_SendByte(pUSARTx,array[i]);	
  
  }
	/* 等待发送完成 */
	while(USART_GetFlagStatus(pUSARTx,USART_FLAG_TC)==RESET);
}

/*****************  发送字符串 **********************/
void Usart_SendString( USART_TypeDef * pUSARTx, char *str)
{
	unsigned int k=0;
  do 
  {
      Usart_SendByte( pUSARTx, *(str + k) );
      k++;
  } while(*(str + k)!='\0');
  
  /* 等待发送完成 */
  while(USART_GetFlagStatus(pUSARTx,USART_FLAG_TC)==RESET)
  {}
}

/*****************  发送一个16位数 **********************/
void Usart_SendHalfWord( USART_TypeDef * pUSARTx, uint16_t ch)
{
	uint8_t temp_h, temp_l;
	
	/* 取出高八位 */
	temp_h = (ch&0XFF00)>>8;
	/* 取出低八位 */
	temp_l = ch&0XFF;
	
	/* 发送高八位 */
	USART_SendData(pUSARTx,temp_h);	
	while (USART_GetFlagStatus(pUSARTx, USART_FLAG_TXE) == RESET);
	
	/* 发送低八位 */
	USART_SendData(pUSARTx,temp_l);	
	while (USART_GetFlagStatus(pUSARTx, USART_FLAG_TXE) == RESET);	
}

///重定向c库函数printf到串口,重定向后可使用printf函数
int fputc(int ch, FILE *f)
{
		/* 发送一个字节数据到串口 */
		USART_SendData(DEBUG_USARTx, (uint8_t) ch);
		
		/* 等待发送完毕 */
		while (USART_GetFlagStatus(DEBUG_USARTx, USART_FLAG_TXE) == RESET);		
	
		return (ch);
}

///重定向c库函数scanf到串口,重写向后可使用scanf、getchar等函数
int fgetc(FILE *f)
{
		/* 等待串口输入数据 */
		while (USART_GetFlagStatus(DEBUG_USARTx, USART_FLAG_RXNE) == RESET);

		return (int)USART_ReceiveData(DEBUG_USARTx);
}

3.10  运行结果

完整工程:

基于STM32F1系列移植FreeRTOS实现串口进行DMA数据接收.zip资源-CSDN文库

FreeRTOS菜鸟入门系列_时光の尘的博客-CSDN博客

FreeRTOS实战系列_时光の尘的博客-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

时光の尘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值