F103串口和DMA配合使用总结

常规的串口使用是这样的:先配置基本的GPIO和串口,然后调用发送和接收函数,如果需要中断,可以根据情况配置发送中断和接收中断。

比如:

//PB10:UT3_TX
//PB11:UT3_RX
void lcd_usart_init(uint32_t bound)
{
    //GPIO端口设置
	GPIO_InitTypeDef GPIO_InitStructure;
	USART_InitTypeDef USART_InitStructure;
	NVIC_InitTypeDef NVIC_InitStructure;

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE);	//使能USART3时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);	//使能GPIOB时钟

	//USART3_TX   GPIOB.10
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; //PB.10
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;	//复用推挽输出
	GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化GPIOB.10

	//USART3_RX	  GPIOB.11
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11;//PB11
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;//浮空输入
	GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化GPIOB.11  

	//Usart3 NVIC 配置
	NVIC_InitStructure.NVIC_IRQChannel = USART3_IRQn;
	NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority=3 ;//抢占优先级3
	NVIC_InitStructure.NVIC_IRQChannelSubPriority = 3;		//子优先级3
	NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;			//IRQ通道使能
	NVIC_Init(&NVIC_InitStructure);	//根据指定的参数初始化NVIC寄存器

	//USART 初始化设置
	USART_InitStructure.USART_BaudRate = bound;//串口波特率
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长为8位数据格式
	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(USART3, &USART_InitStructure); //初始化串口3
    
    USART_ITConfig(USART3, USART_IT_RXNE, ENABLE);//开启串口接收中断
//    USART_ITConfig(USART3, USART_IT_TC, ENABLE);//开启串口接收中断
    USART_Cmd(USART3, ENABLE); //使能串口3
}

void USART3_IRQHandler(void)
{
    
	if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET)
    {
        printf("get a data\r\n");
    }

    if(USART_GetITStatus(USART3, USART_IT_TXE) != RESET)
    {
        printf("send a data\r\n");

    }
}

关于串口中断,有几个问题不太明白。

第一,发送函数USART_SendData(USART_TypeDef* USARTx, uint16_t Data)和接收函数USART_ReceiveData(USART_TypeDef* USARTx),都是一次发送1个字节和接收1个字节吗?

第二,中断,是每发送完和接收完1个字节后产生吗?

第三,发送中断标志位有两个USART_IT_TXE和USART_IT_TC,二者有何异同?

先解决这三个问题,然后再继续串口+DMA。

 关于串口发送和接收的中断,手册里有三种类型:

─ 发送数据寄存器空

─ 发送完成

─ 接收数据寄存器满

先看下数据寄存器

 

可以看到,数据寄存器只有8:0位是有用的,其他都保留。

我一开始想的是只有7:0,刚好一个字节,但是这里多了一位,为什么?

数据寄存器 (USART_DR) 只有低 9 位有效,并且第 9 位数据是否有效要取决于 USART控制寄存器1(USART_CR1) 的 M 位设置,当 M 位为 0 时表示 8 位数据字长,当 M 位为 1 表示 9位数据字长(最后一位为奇偶校验位),我们一般使用 8 位数据字长,即无奇偶校验。

根据以上内容,结合USART_SendData(USART_TypeDef* USARTx, uint16_t Data)和接收函数USART_ReceiveData(USART_TypeDef* USARTx)的源码,可以知道,这两个函数都是一次发送或者接收1个字节。

另外,每发送完1个字节,就会产生“发送数据寄存器空”和“发送完成”中断,每接收完1个字节,就会产生“接收数据寄存器满”中断。

这里有个问题,就是“发送数据寄存器空”和“发送完成”中断二者有何区别?

首先要知道,都是针对1个字节来说的。

其中,USART_IT_TXE是在TDR寄存器为空时产生的中断标志位;USART_IT_TC是在DR寄存器发送完最后一个位时产生的中断标志位。

手册里的说法是,TXE置位,意味着TDR的数据移位到DR寄存器,并已启动发送,此时TDR寄存器为空,可以发送下一字节数据到TDR寄存器,并且不会覆盖之前DR寄存器的内容。

这里有个重点问题,容易错。

我在进行串口发送数据时,没有判断这个标志位,直接连续发送,结果数据全乱了,这就是因为没有判断TDR寄存器为空,数据发送过快,导致TDR数据寄存器中的数据位混在一起了,产生了溢出。

所以,需要判断再发送下一个字节

USART_SendData(USART3, 0x5A);
while(USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET){};
USART_SendData(USART3, 0xA5);
while(USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET){};
USART_SendData(USART3, 0x07);
while(USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET){};
USART_SendData(USART3, 0x10);
while(USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET){};
USART_SendData(USART3, 0x70);
while(USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET){};
……

上面只是说明,常规写法如下

for(uint8_t i = 0; i < allProtocolLength; i++)
{
    USART_SendData(USART3, sendData[i]);
    while(USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET){};
}

千万不要在不做任何判断的情况下连续发数据。

现象和解决方式参考这篇文章,讲的很详细

STM32库函数USART_SendData的缺陷和解决方法-文章-单片机-STM32 - 畅学电子网

回到USART_IT_TC标志位,表示发送完成,当包含有数据的一帧发送完成后,并且TXE=1时,由硬件将该位置“1” ,也就是说,当TDR寄存器的数据被赋值到移位寄存器的时候,TXE会置位,当移位寄存器中的数据发完之后,并且,TDR寄存器依然为空,则TC会置位。

一般,只要TXE置位,就可以继续发送下一个字节数据了。

另外,还有个问题,是否要手动清除该标志位?答案是不用。

当TDR寄存器中的数据被硬件转移到移位寄存器的时候,TXE位被硬件置1,对USART_DR的写操作,该位自动清零。所以,不必手动清除。

以上就是串口的基本用法,串口收发数据时,很少会用到发送中断和接收中断。

发送一般都是直接发,接收时也是一个字节一个字节去接收,速度不快。

所以,很多情况下,串口都会结合DMA一起使用,提高效率。

关于DMA的基本内容可直接查阅相关手册,此处仅记录重点内容及DMA+串口的常见用法。

DMA在初始化时,会设置一个缓冲区buffer_size,当缓冲区满的时候,就会产生中断,最常见的就是传输完成TC中断(可以不关心中断)。

到底要不要用DMA的中断呢?

一般来说,发送可用可不用(可直接用串口发送),发送时,可以通过设置缓冲区大小,让DMA在传输完设定长度的字节数据后停止。

但是接收时,也要设定这个缓冲区的大小,只有填满这个缓冲区的时候,才会产生中断(这一点待确认),问题是,我不知道接收的字节数到底是多少,缓冲设小了数据收不完整,缓冲设大了,又没那么多数据可接收,这样,就不会触发接收中断(待确认),就算能触发,也会多出一些没用的数据。

……

有以下几种思路:

DMA直接发送(单次)+DMA直接接收(单次);

DMA发送中断+DMA接收中断;

串口直接发送+循环DMA接收; 

结合空闲中断;

……

那么,到底要如何合理地使用串口+DMA呢?

了解什么是空闲中断

在串口的状态寄存器里面,有个IDLE标志

位说明如下:

这里的总线空闲,指的是串口线处于空闲状态。

可以参考这个问答:

串口的空闲中断和普通中断相比有什么优势

关于空闲中断,记录如下重点内容:

串口中断标志有很多,接收完成、发送完成、CTS、过载错误、噪声错误和空闲等,每个中断标志代表的功能不一样。

普通的有接收中断和发送中断,即每接收或者发送一个字节,就产生一个中断,在接收比较长的数据时会频繁地进中断,可能数据会来不及处理。而空闲中断是一帧数据接收结束后收到一个字节的空闲帧才中断,空闲中断配合DMA可以很好的实现不定长数据接收,出现空闲标志时,认为一帧报文发送完毕,然后进行报文分析。

空闲中断是接收到一个数据以后,接收停顿超过一字节时间认为桢收完,总线空闲中断是在检测到在接收数据后,数据总线上一个字节的时间内,没有再接到数据后发生。也就是RXNE位被置位之后,才开始检测,只被置位一次,除非再次检测到RXNE位被置位,然后才开始检测下一次的总线空闲。一次RXNE位被置位只进行一次。

具体用法参考下面三篇文章: 

STM32学习之串口采用DMA收发数据:需要利用状态机加DMA加串口_暮尘依旧的博客-CSDN博客

STM32F103 串口DMA + 空闲中断 实现不定长数据收发_stm32f103空闲中断_夏夜晚风_的博客-CSDN博客

STM32F103 串口 +DMA中断实现数据收发_stm32f103 dma中断_夏夜晚风_的博客-CSDN博客

几点验证

要发的字节数10个,存在一个50字节大小的数组里。

经验证,缓存要设置比实际传输字节数少1,才会置位发送标志位,比如这里要设置9,但是设置为9,数据就发不完整了。奇怪。不知道哪里出了问题。

首先能确认,发送时,缓存要大于等于实际发送的数据字节数,要不数据发不完整,缓存一满就会停止DMA传输。

如果不是已经发生事件跳到中断里,就不要直接用if来判断标志位有没有置位。

在中断里直接if是因为标志位已经发生了,现在要判断是哪种中断标志。

如果就是要判断标志位有没有发生,要放到while里,而不是用if,或者在while(1)里if判断,因为如果你直接if判断,说不定还没来得及置位,但是因为if不满足条件直接就跳过了,后面就算再置位也不会进行判断了。

比如,这样就不对

这样比较合理

这样调整之后,发送缓存只要大于等于要发送的数据,就能保证数据发送没问题,并且也会置位发送标志位,所以,应该是发送完了或者缓冲区满了都会置位标志位吧,具体以后再慢慢研究吧。

另外,仔细想了想,发送完成之后再开启接收,和发送方的数据不太匹配,可能会漏接收,或者出现其他问题,这一点不太合理。

直接这样吧。

DMA_ClearFlag和DMA_ClearITPendingBit效果是一样的。

前者常在非中断中使用,后者常在中断中使用。 

我的实践方案总结

串口+DMA涉及的东西比较多,两个都能普通收发,都能中断。

比如,串口普通收,串口普通发,串口中断收,串口中断发,DMA普通收,DMA普通发,DMA中断收,DMA中断发,再加上个空闲中断。

其中,我们要明白一点,使用DMA只是不用CPU来运输数据,而是使用DMA控制器来运输数据,但是,串口那边的功能并不影响,寄存器还是一样的。所以,DMA可以不用中断,而是通过串口的中断来判断数据有没有收发完,串口才是最知道它自己的数据有没有发完和收完的。

发送的时候,因为DMA可以指定传输的长度,所以和串口中断的作用是差不多的。但是,在接收时,就推荐使用串口来判断,因为接受时,DMA并不知道串口数据有没有接收完。

发送和接收数据都是由DMA完成,但是判断有没有完成,就需要选择了。

我在实践中是这样选择的,发送完成由DMA发送完成中断来判断,接收完成由串口空闲中断来判断,串口接收完成中断是每个字节产生一个中断,需要一个字节一个字节地去判断,但是空闲中断是接收完一帧数据后再去统一处理,效率高很多。

一个字节一个字节接收时,需要在中断里判断有没有接收到帧头,之后的数据才会保存到数组里,而空闲中断是先接收完一帧数据,然后再去处理。

我们初始化时就开启DMA接收,然后设置空闲中断,发生空闲中断时,就表示一帧数据接收完成了。DMA开启后,有数据就搬,没数据就等待。选择DMA的正常模式,则来一次数据,搬一次,就停了,即DMA只传输一次。如果当传输完一次后,还想再传输下一次,就需要重启DMA接收,依然从头开始存起。如果是循环模式,就会一直继续接收,此时地址是不断增长并循环的。

以下给出方案中这部分代码。

串口初始化

//LCD串口
//PB10:UT3_TX
//PB11:UT3_RX
//单片机串口3接到LCD芯片的串口1(仅串口1支持协议解析)
void lcd_usart_init(uint32_t bound)
{
    //GPIO端口设置
	GPIO_InitTypeDef GPIO_InitStructure;
	USART_InitTypeDef USART_InitStructure;
	NVIC_InitTypeDef NVIC_InitStructure;

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE);	//使能USART3时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);	//使能GPIOB时钟

	//USART3_TX   GPIOB.10
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; //PB.10
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;	//复用推挽输出
	GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化GPIOB.10

	//USART3_RX	  GPIOB.11
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11;//PB11
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;//浮空输入
	GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化GPIOB.11  

	//USART 初始化设置
	USART_InitStructure.USART_BaudRate = bound;//串口波特率
	USART_InitStructure.USART_WordLength = USART_WordLength_8b;//字长为8位数据格式
	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(USART3, &USART_InitStructure); //初始化串口3
    
    USART_DMACmd(USART3,USART_DMAReq_Tx, ENABLE); //使能串口3的DMA发送
    USART_DMACmd(USART3, USART_DMAReq_Rx, ENABLE); //使能串口3的DMA接收
    USART_Cmd(USART3, ENABLE); //使能串口3
    
    /* 串口中断配置 */
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;             // 使能
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;   // 抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;          // 子优先级
    NVIC_InitStructure.NVIC_IRQChannel = USART3_IRQn;           // 串口3中断
    NVIC_Init(&NVIC_InitStructure);     // 嵌套向量中断控制器初始化

    //使能串口空闲中断
    //接收一帧数据产生 USART_IT_IDLE 空闲中断
    USART_ITConfig(USART3, USART_IT_IDLE, ENABLE);
}

DMA发送初始化

//初始化串口3的DMA发送功能
void usart3_dma_send_init()
{
    DMA_InitTypeDef DMA_InitStructure;
    NVIC_InitTypeDef NVIC_InitStructure;
    
    //开启DMA1时钟
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
	
    //串口3发送DMA初始化,DMA1通道2
    DMA_DeInit(DMA1_Channel2);   //将DMA1的通道2寄存器重设为缺省值
    
	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR;  //DMA外设基地址
	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)sendData;  //DMA内存基地址
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST;  //数据传输方向,从内存读取发送到外设
	DMA_InitStructure.DMA_BufferSize = 0;  //DMA通道的DMA缓存的大小,初始化为0不发送
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;  //外设地址寄存器不变
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;  //内存地址寄存器递增
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;  //数据宽度为8位
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; //数据宽度为8位
	DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;  //工作在正常模式,一次传输后自动结束
	DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; //DMA通道 x拥有中优先级 
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;  //DMA通道x没有设置为内存到内存传输
	DMA_Init(DMA1_Channel2, &DMA_InitStructure);
    
    DMA_Cmd(DMA1_Channel2, DISABLE);//初始化时禁止DMA发送
    
    //配置DMA发送中断,发送完成后,清除标志位即可
    NVIC_InitStructure.NVIC_IRQChannel = DMA1_Channel2_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;  // 抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;         // 子优先级
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;            // 使能
    NVIC_Init(&NVIC_InitStructure);     // 嵌套向量中断控制器初始化
    
    //开启DMA1通道2的传输中断,用来判断发送完成
    DMA_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE);
}

DMA接收初始化

//初始化串口3的DMA接收功能
void usart3_dma_receive_init()
{
    DMA_InitTypeDef DMA_InitStructure;
    
    //开启DMA1时钟
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

    //串口3接收DMA初始化,DMA1通道3
    DMA_DeInit(DMA1_Channel3);   //将DMA1的通道3寄存器重设为缺省值
    
	DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR;  //DMA外设基地址
	DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)receiveData;  //DMA内存基地址
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;  //数据传输方向,从外设读取发送到内存
	DMA_InitStructure.DMA_BufferSize = sizeof(receiveData);  //DMA通道的DMA缓存的大小
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;  //外设地址寄存器不变
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;  //内存地址寄存器递增
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;  //数据宽度为8位
	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; //数据宽度为8位
	DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;  //工作在正常模式,一次传输后自动结束
	DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; //DMA通道 x拥有中优先级 
	DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;  //DMA通道x没有设置为内存到内存传输
	DMA_Init(DMA1_Channel3, &DMA_InitStructure);
    
    //发送是主动的,但是接收是被动的,上电就要打开,等着接收HMI的数据
    DMA_Cmd(DMA1_Channel3, ENABLE);//初始化时即开启接收
}

//开启一次DMA发送
void USART3_DMA_SEND_Enable(uint16_t buffer_size)
{ 
	DMA_Cmd(DMA1_Channel2, DISABLE);
 	DMA_SetCurrDataCounter(DMA1_Channel2, buffer_size);//DMA通道的DMA缓存的大小
 	DMA_Cmd(DMA1_Channel2, ENABLE);
}

开启一次DMA发送

//开启一次DMA发送
void USART3_DMA_SEND_Enable(uint16_t buffer_size)
{ 
	DMA_Cmd(DMA1_Channel2, DISABLE);
 	DMA_SetCurrDataCounter(DMA1_Channel2, buffer_size);//DMA通道的DMA缓存的大小
 	DMA_Cmd(DMA1_Channel2, ENABLE);
}

开启一次DAM接收

//开启一次DMA接收
void USART3_DMA_RECEIVE_Enable()
{  
    //先将接收的内存部分数据清0
    memset(receiveData, 0, sizeof(receiveData));
 	DMA_Cmd(DMA1_Channel3, ENABLE);
}

DMA发送完成的中断函数

//DMA1发送完成的中断函数
void DMA1_Channel2_IRQHandler()
{
    if(DMA_GetITStatus(DMA1_IT_TC2) != RESET)
    {
        sendCFlag = 1;
        DMA_ClearITPendingBit(DMA1_IT_TC2);    // 清除传输完成中断标志位
        DMA_Cmd(DMA1_Channel2, DISABLE);       // 关闭DMA发送
    }
}

串口的空闲中断

//串口3的空闲中断处理函数
//判断DMA数据是不是接收完
void USART3_IRQHandler(void)
{
    uint8_t clear;

    if(USART_GetITStatus(USART3, USART_IT_IDLE) != RESET)   // 空闲中断
    {
            clear = USART3->SR; // 清除空闲中断
            clear = USART3->DR; // 清除空闲中断
        
            receiveCFlag = 1;  // 置接收标志位
            //空闲中断产生,但是DMA后续可能还有数据,不关心
            //所以主动关闭DMA接收,
            DMA_Cmd(DMA1_Channel3, DISABLE);
    }   
}

数据准备好后,就开启发送

……
USART3_DMA_SEND_Enable(allProtocolLength);//开启DMA传输进行数据发送

空闲中断里接收完成标志位置位后就开始处理数据

//主函数
int main(void)
{			
    system_init();
    
    while(1)//整体的逻辑就是,串口只要有数据来,就会DMA搬运并置位接收完成标志位
    {       //主循环判断标志位然后处理数据,处理完成后再次开启DMA接收
        if(receiveCFlag && IS_DATA_OK())
        {
            printf("ok\r\n");
            receiveCFlag = 0;//取消接收完成标志位
            //开启DMA接收,为下一次接收做准备
            USART3_DMA_RECEIVE_Enable();
        }
    }
}

总之,要明确的是,DMA只是起到数据搬运的作用,减轻CPU的负担,提高传输效率,DMA的发送和接收中断,通常都是提示发送完成或者接收完成。不要和外设本身的功能给搞混。

它的事件都是给出传输的情况:

其中传输完成用的最多。

DMA和串口遗漏内容补充

串口的发送和接收寄存器地址是一样的&USARTx->DR

又看了一遍串口的一些标志位,摘录如下

我发现,TXE和RXNE是相对的,一个是发送数据寄存器为空,一个是接收数据寄存器不为空,都是针对发送过程中的一个一个字节来的,通常是在发送或者接收一个字节的中间判断是否当前字节发送完或者接收完。

TC和IDLE的作用是相对的,TC表示一帧数据发完了,因为串口有起始位和结束位,只有遇到结束位之后,TC才会置位,也就是一帧数据,什么是一帧数据,就是包含了开始位+数据+停止位的一帧数据;而IDLE就是接收完一帧数据的标志位。

所以,以上的程序中,其实不必非得使用DMA的传输完成中断,也可以使用串口的发送完成中断,这样,能保证串口把要发的数据都发完了。两者任意一个都行。

DMA本身的接收中断,只适合接收定长的数据。

当接收的数据量不够时,无法产生中断,所以通常需要提前知道接收的数据长度,才好提前设置好传输量,所以,只适合接收已知的定长数据。 

所以,以上程序可以将接收设置成循环模式,这样,就不用再手动去关闭和打开DMA接收。当然,手动打开和关闭也可以。重要的是接收地址的数据要重新初始化一下。

DMA不是开了就搬数据,只有在接收到外设的DMA请求时才会搬数据。

比如串口的TXE和RXE置位时,即有数据要发送或者接收时,才会产生DMA请求。

补充

发现一个问题,就是DMA关闭接收,然后再开启,一定要重新设置缓存大小,要不然,就会叠加之前已经接收的缓存,在几次接收之后,到达缓存值,就会自动关闭DMA,在使能开启也不行了,因为此时缓存剩余为0,也就是不接收了,就算DMA是开的,但是允许接收的数据量为0,也就不起作用了。

//开启一次DMA接收
void USART3_DMA_RECEIVE_Enable()
{  
    //先将接收的内存部分数据清0
    memset(receiveData, 0, sizeof(receiveData));
    keyValue = 0;
    /*一定要重新设置缓存,不能直接开启*/
    DMA_Cmd(DMA1_Channel3, DISABLE);
    DMA_SetCurrDataCounter(DMA1_Channel3, sizeof(receiveData));//DMA通道的DMA缓存的大小
 	DMA_Cmd(DMA1_Channel3, ENABLE);
}

补充:DMA发送时的问题

DMA多次发送,只有最后一次生效,这个跟之前“串口发送时太快,没有判断上一次发送完成就发从而导致缓存溢出”的现象很像。
难道每一次发送之间也要进行延时,或者判断?
加了个500ms延时,确实能解决这个问题。不过这种解决方式是下下策。
是不是需要加个判断?

经过多方尝试,不需要在DMA设置上处理,而是每次发送之前,都判断下上一次是否发送完成。这个判断标志是自己在完成一次传输时设置的。

sendCFlag这个标志位就是在发送完成的中断里置位的。

相关思路可以参考:

RT-Thread-串口DMA发送数据时,数据被覆盖RT-Thread问答社区 - RT-Thread

这里网友提供了另一种方法,可参考,本人未试过

stm32f103 DMA控制串口发送数据 数据覆盖问题_stm32串口hdma发送数据被后面覆盖_cxybc的博客-CSDN博客

串口调试工具注意 

今天在用DMA_GetCurrDataCounter(DMA2_Channel3)时,发现总是比发的数据大两个字节,经排查才发现,原来是勾选了串口工具上的“发送新行”,会默认加上\r\n

补充:关于DMA的循环模式

这是一个重点内容。

在上面所讲到的串口+DMA中,都是使用的普通模式,即每次都要手动开启。

发送时,因为是主动的,而且数据不定长,所以通常采用手动模式即可。

但是,当有大量数据需要接收时,手动模式就可能会在开启和关闭的过程中漏数据,比如,串口不断接收某传感器的数据,传感器的数据是不断传输的,需要不停地传送,这种情况下,就推荐使用DMA的循环模式。

使用循环模式的好处是,当指定长度的串口数据通过DMA接收完成后,DMA硬件自动重新设置传输长度,同时开启下一个接收过程,当接收缓冲区满了后,会自动从接收缓冲区数组的开始存储(一帧数据可能数组末尾一段,数组开头一段)。接收过程不会停止,因为DMA通道总是处于使能状态。否则,我们需要在每次传输完成后,通过代码禁止DMA通道,配置下一次的传输长度,最后使能通道,而在这个过程,需要CPU的介入,最严重的问题是,可能错过串口数据的接收导致丢数据。

先参考如下的两篇文章

stm32f103串口接收队列,DMA循环模式+空闲中断_大文梅的博客-CSDN博客

USART+DMA+循环队列接收不定长数据_哈士奇上蔚的博客-CSDN博客

采用循环模式时需要注意几点: 

接收数组尽量大,比如设置500个字节的长度,防止数据覆盖;

结合循环数组的知识来处理;

使用DMA_GetCurrDataCounter(DMA2_Channel3)函数获取缓存的剩余大小;

需要定义两个索引,一个记录写到的位置,一个记录读到的位置,然后在空闲中断里更新写到的位置,在主循环中更新读到的位置。等于是,写在前面跑,读在后面追;

当写到数组末尾时,DMA会自动回到开头,但是读到数组末尾时,就需要自行处理;

示例代码如下

缓存定义

#define RECEIVE_BUFFER          500

//定义接收缓存
typedef struct
{
    uint16_t wIndex;//记录写索引
    uint16_t rIndex;//记录读索引
    uint8_t receiveData[RECEIVE_BUFFER];//接收缓存区
}ST_R_BUFFER;

串口模式更换

DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;  //工作在循环模式

空闲中断

更新写到的位置

//串口4的空闲中断处理函数
//判断DMA是不是接收完一帧数据
void UART4_IRQHandler(void)
{
    uint8_t clear;

    if(USART_GetITStatus(UART4, USART_IT_IDLE) != RESET)   // 空闲中断
    {
        clear = UART4->SR; // 清除空闲中断
        clear = UART4->DR; // 清除空闲中断

        //记录写到的位置索引
        ReceiveBuffer.wIndex = RECEIVE_BUFFER - DMA_GetCurrDataCounter(DMA2_Channel3);
        printf("接收到一帧数据,写索引:%d\r\n", ReceiveBuffer.wIndex);
        printf("接收到一帧数据,读索引:%d\r\n", ReceiveBuffer.rIndex);
    }
}

主函数里,更新读到的位置

void ReceiveFrameHandler(void)
{  
    uint8_t len = 0;
    uint8_t headerExistedFlag = 0;
    uint16_t startIndex = 0;
    uint8_t dataArr[30] = {0};//定义一个临时数组
    uint8_t tailRemainLen = 0;
    uint16_t newRIndex = 0;
    
    if(ReceiveBuffer.rIndex != ReceiveBuffer.wIndex)//只要二者不相等,就有数据要读
    {
        if(ReceiveBuffer.rIndex < ReceiveBuffer.wIndex)
        {
            //寻找帧头
            for(; ReceiveBuffer.rIndex < ReceiveBuffer.wIndex; ReceiveBuffer.rIndex++)
            {
                if(ReceiveBuffer.receiveData[ReceiveBuffer.rIndex] == FRAME_HEADER)//如果找到帧头
                {
                    startIndex = ReceiveBuffer.rIndex;
                    headerExistedFlag = 1;//表示存在帧头
                    break;//但凡找到一个帧头,就跳出
                }
            }
        }
        else
        {
            //如果读索引比写索引要大,则表示转了一圈又回到刚开始了
            //寻找帧头
            for(; ReceiveBuffer.rIndex < RECEIVE_BUFFER; ReceiveBuffer.rIndex++)
            {
                if(ReceiveBuffer.receiveData[ReceiveBuffer.rIndex] == FRAME_HEADER)//如果找到帧头
                {
                    startIndex = ReceiveBuffer.rIndex;
                    headerExistedFlag = 2;//表示存在帧头
                    break;//但凡找到一个帧头,就跳出
                }
            }
            if(headerExistedFlag == 0)//表示在末尾处没找到,再回到开头找
            {
                ReceiveBuffer.rIndex = 0;
                for(; ReceiveBuffer.rIndex < ReceiveBuffer.wIndex; ReceiveBuffer.rIndex++)
                {
                    if(ReceiveBuffer.receiveData[ReceiveBuffer.rIndex] == FRAME_HEADER)//如果找到帧头
                    {
                        startIndex = ReceiveBuffer.rIndex;
                        headerExistedFlag = 1;//表示存在帧头
                        break;//但凡找到一个帧头,就跳出
                    }
                }
            }
        }
        
        if((headerExistedFlag == 1) || (headerExistedFlag == 2))//如果帧头存在
        {
            if(headerExistedFlag == 1)
            {
                len = ReceiveBuffer.receiveData[startIndex + 1];
                memcpy(dataArr, &ReceiveBuffer.receiveData[startIndex], len + 4);
                newRIndex = ReceiveBuffer.rIndex + (len + 4);
            }
            else//如果帧分成数组末和数组头两部分
            {
                tailRemainLen = RECEIVE_BUFFER - ReceiveBuffer.rIndex;
                if(tailRemainLen == 1)
                {
                    len = ReceiveBuffer.receiveData[0];
                    dataArr[0] = ReceiveBuffer.receiveData[ReceiveBuffer.rIndex];
                    memcpy(&dataArr[1], &ReceiveBuffer.receiveData[0], len + 3);
                    newRIndex = len + 3;
                }
                else
                {
                    len = ReceiveBuffer.receiveData[startIndex + 1];
                    memcpy(dataArr, &ReceiveBuffer.receiveData[startIndex], tailRemainLen);
                    memcpy(&dataArr[tailRemainLen], &ReceiveBuffer.receiveData[0], len + 4 - tailRemainLen);
                    newRIndex = len + 4 - tailRemainLen;
                }
            }
            
            if(CheckDataRight(dataArr))//如果接收到的数据是OK的
            {
                //接着判断是哪种功能的数据
                if(里面有需要的数据)
                {
                    ReceiveHandler(dataArr);
                }
            }
            
            //读索引跳到下一帧数据开头,继续处理下一帧
            ReceiveBuffer.rIndex = newRIndex;
        }
    }
}

这里接收的数据帧是带长度字节的,所以根据长度来做一些判断。

这里分成几个步骤:

首先是只要读指针不等于写指针,就要去追数据;

追数据可能会循环到数组开头,所以要做两种情况的判断;

只要找到帧头,就根据长度去校验和分析数据;

在分析数据时,根据情况将数据提取出来,最麻烦的就是可能写数据循环回来了,但是读指针还需要在末尾开始循环追,分两段处理即可。

注意,最后别忘了追上写索引。

更多的自行领会吧。

还是蛮复杂的,改天再看看循环队列,结合着理解。

回来的数据,是先存在数组里,而不是说来了之后不马上处理就没了,除非一直不处理被覆盖,只要在覆盖之前处理完成,就没什么问题了,就算一直来了好几条,都没及时处理也没事,理论上只要缓冲数组够长,就可以一直存储来的数据,后面等有空的时候再去处理即可,就跟处理普通的数组一样的。 

BUG修复 

在运行这个程序的时候,发现有BUG,经过排查,发现这里发生了HardFault

后来仔细想一想,这里的newRIndex可能成负数,这样,当访问数组时,就会产生错误。

从而导致程序卡死!!!!!!

为什么呢?

因为上面的程序我默认就是写数据只领先读数据一帧,让二者产生了位置关系。

但实际上,并不是,如果说DMA写数据很快,读数据没那么快,就会导致写数据领先读数据很多。而且,有时候这种连续写数据的情况下,可能好几帧才产生一次空闲中断,这样的话,二者就更不可能每次都只差一帧数据了。

简单示例如下:

len + 4 - tailRemainLen,如果len等于10,tailRemainLen还有几十个,那么这个结果就会是负数。

显然这个逻辑有问题。

后来优化了BUG

void ReceiveFrameHandler(void)
{  
    uint8_t len = 0;
    uint8_t headerExistedFlag = 0;
    uint16_t startIndex = 0;
    uint8_t dataArr[30] = {0};//定义一个临时数组
    uint8_t tailRemainLen = 0;
    uint16_t newRIndex = 0;
    uint16_t protocolSeparated = 0;//帧数据是否分离成首尾两部分
//    
//    printf("写索引为:%d\r\n", ReceiveBuffer.wIndex);
//        printf("读索引为:%d\r\n", ReceiveBuffer.rIndex);
    
    if(ReceiveBuffer.rIndex != ReceiveBuffer.wIndex)//只要二者不相等,就有数据要读
    { 
//        printf("位置1\r\n");
        //寻找帧头
        for(; ReceiveBuffer.rIndex < RECEIVE_BUFFER; ReceiveBuffer.rIndex++)
        {
            if(ReceiveBuffer.receiveData[ReceiveBuffer.rIndex] == FRAME_HEADER)//如果找到帧头
            {
                startIndex = ReceiveBuffer.rIndex;
                headerExistedFlag = 1;//表示存在帧头
                break;//但凡找到一个帧头,就跳出
            }
        }
        
        if(headerExistedFlag)//如果帧头存在
        {
//            printf("位置2\r\n");
            len = ReceiveBuffer.receiveData[startIndex + 1] + 4;//获取该帧长度
            
            //处理下一次的新索引
            newRIndex = startIndex + len;
            if(newRIndex == RECEIVE_BUFFER)
            {
                ReceiveBuffer.rIndex = 0;
            }
            else if(newRIndex > RECEIVE_BUFFER)//协议分成了两段
            {
                ReceiveBuffer.rIndex = newRIndex - RECEIVE_BUFFER;
                protocolSeparated = 1;
                tailRemainLen = len - (newRIndex - RECEIVE_BUFFER);
            }
            else
            {
                ReceiveBuffer.rIndex = newRIndex;
            }
            
            //有些返回的协议没用就跳过,不必继续分析
            if(ProtocolIgnored(ReceiveBuffer.receiveData[startIndex + 2]))
            {
                return;
            }

            //先将有帧头的一帧数据拿出来
            if(protocolSeparated)//如果协议被拆成了两段存储
            {
                memcpy(dataArr, &ReceiveBuffer.receiveData[startIndex], tailRemainLen);
                memcpy(&dataArr[tailRemainLen], &ReceiveBuffer.receiveData[0], len - tailRemainLen);
            }
            else
            {
                memcpy(dataArr, &ReceiveBuffer.receiveData[startIndex], len);
            }
            
            //判断这一帧的正确性
            if(CheckDataRight(dataArr))//如果接收到的数据是OK的
            {
                //接着判断是哪种功能的数据
                if(dataArr[2] == 0x94)
                {
                    printf("有数据");
                    ReceiveEmgHandler(dataArr);
                }
            }
        }
    }
}

对于这种一边接收一边读取的需求来说,需要将写数据和读数据独立来看,写数据就让他写数据,读数据就读数据,二者不要产生长度上的任何联系,如果说写的太快,读得没那么快,怕数据被覆盖掉,可以通过加大接收缓存来优化这个问题

所以,只要二者不相等,就可以读数据。不必判断谁大谁小。

可以确保的是,肯定是先有数据写进来,然后才会去读,写数据是先于读数据的,因为读数据有二者不相等的进入条件。

还有一点就是,协议就两种情况,要么是一段,要么是被分成首尾两段,这个需要做个判断。

串口开发遇到的问题补充

最近在进行串口开发时,遇到了一些问题,相信也是串口开发时的常见问题,所以在此做个简单的总结。

事情的经过是这样的。

程序写好后,通过串口连接上位机和下位机,程序启动后,没有任何反应,下位机也没有返回数据。

经过排查,发现第一个问题:

波特率设错了,下位机的波特率是460800,但是我用的是115200,不匹配。

一开始,没有返回任何数据,不过我没往波特率上去想,因为我以为波特率错误只会乱码,不会没数据,其实,波特率错了,也可能是没有数据的。

之后,我将上位机程序改到460800,仍然不可以,但是测试256000时,数据传输也正常,115200也正常,于是,我猜想,是不是F103这个串口不支持460800的波特率。

虽然网上也看到一个说法,F103的PCLK1时钟下的低速串口不支持460800这么高的。

但是找到103的串口说明,也没发现这个说法。

注意:后来经验证,103的串口是支持高波特率的,这个表里写的都支持,不管是哪个串口。

考虑到不支持这么高的波特率,我想到这个串口电路中有一个隔离芯片,会不会是这个芯片不支持这么高的波特率,并非是103单片机的问题。

跟硬件证实了一下这个隔离芯片最高只支持150Kbps的波特率,于是硬件帮忙换了个10Mbps的隔离芯片,再次测试460800的波特率,发现数据传输正常。

用串口工具分别测试上位机和下位机没问题后,连接两块板子,然后用printf调试,再次发现,printf收到的数据不对。

经过排查,发现我用的460800的波特率通信,但是我printf用的串口是115200的波特率,显然,因为printf的速度跟不上板子收发的速度,所以数据读取会乱码,或者漏数据显示,这一点倒是没想到。

另外再补充两点:

  1. 板子一般要上电延时一段时间后再通信,因为要等电路稳定下来,尤其是模拟电路,要不然也会通信失败。
  2. 可以使用导线连接来监测某个点所传输的信号,比如上位机的TX接到下位机的RX,如果想要监测发出去的数据,可以将这个点连接到串口工具的接收端显示。

总结下来就是:

  1. 保证串口参数,尤其是波特率要一致;
  2. 要等电路稳定后再通信;
  3. 收发的速率和调试显示的速率也要一致;
  4. 可以用串口工具或者监测手段分别验证上位机和下位机是否可行,之后再联合测试;
  5. 确认硬件支持的最高波特率。

这里再补充个printf的内容:

Fputc里重写的是哪个串口,显示时就用的是哪个串口,这个可以用来快速地验证某个串口是否初始化生效,不要怀疑Fputc必须跟哪个串口绑定。

BUG再修复

上面的第二版程序还是有些问题,总是偶尔性地不读取数据,读指针不往后走。

虽然没想明白这个问题的症结所在,不过过程中倒是想到了另外一个问题。

那就是,我已经是在主循环里来依次读数据了,为什么还有在主循环里再循环一次去读呢?这样会导致一个问题,如果一开始就来了一些杂数据,那么读写索引就不相等,直接进入执行循环,导致读索引无端就一读到底了。

或许是由此引发的问题,后面我就直接把主循环里的循环去掉了。

//校验收到的数据的帧尾和CRC
//如果校验通过返回1;否则返回0
static uint8_t CheckDataRight(uint8_t *arr)
{
    uint8_t calculateCRC = 0;
    uint8_t receivedCRC = 0;
    
    //先验证帧尾
    uint16_t len = arr[1];
    if(arr[len + 3] == FRAME_TAIL)
    {
        //如果帧尾也OK
        //就再验证CRC
        calculateCRC = GetCheckValue(arr, len + 4);
        receivedCRC = arr[len + 2];
        if(calculateCRC == receivedCRC)
        {                         
            return 1;
        }
    }
    
    return 0;
}

//判断数据是否有效
uint8_t IsNeededDataFrame()
{
    uint16_t remainTailLength = 0;
    uint8_t lenByte = 0;
    uint8_t protocolCode = 0;
    uint8_t frameLen = 0;
    uint16_t tempIndex = 0;
    
    //要先判断此刻帧头的位置在哪里
    remainTailLength = RECEIVE_BUFFER - boardReceiveBuffer.rIndex;
    
    //先获取长度字节和功能码
    if(remainTailLength >= 3)
    {
        //如果剩余的超过3,则直接获取字节长度和功能码
        lenByte = boardReceiveBuffer.receiveData[boardReceiveBuffer.rIndex + 1];//长度字节
        protocolCode = boardReceiveBuffer.receiveData[boardReceiveBuffer.rIndex + 2];//功能码
    }
    else
    {
        if(remainTailLength == 2)
        {
            lenByte = boardReceiveBuffer.receiveData[boardReceiveBuffer.rIndex + 1];//长度字节
            protocolCode = boardReceiveBuffer.receiveData[0];//功能码
        }
        else if(remainTailLength == 1)
        {
            lenByte = boardReceiveBuffer.receiveData[0];//长度字节
            protocolCode = boardReceiveBuffer.receiveData[1];//功能码
        }
    }
    
    //如果功能码不符合要求则直接返回
    if(protocolCode != 0x94 && protocolCode != 0xD1 && protocolCode != 0x99)
    {
        boardReceiveBuffer.rIndex++;
        return 0;
    }
    else
    {
        //如果符合要求,则拿出数据进行校验
        //获取帧长度
        frameLen = lenByte + 4;
        tempIndex = boardReceiveBuffer.rIndex + frameLen;
        if(tempIndex <= RECEIVE_BUFFER)//数据帧未断开
        {
            memcpy(receivedFrameArr, &boardReceiveBuffer.receiveData[boardReceiveBuffer.rIndex], frameLen);
            if(CheckDataRight(receivedFrameArr))
            {
                boardReceiveBuffer.rIndex += frameLen;
                return 1;
            }
            else
            {
                boardReceiveBuffer.rIndex++;
                return 0;
            }
        }
        else//数据分为首尾两段
        {
            //先复制尾部
            memcpy(receivedFrameArr, &boardReceiveBuffer.receiveData[boardReceiveBuffer.rIndex], remainTailLength);
            //再复制头部
            memcpy(&receivedFrameArr[remainTailLength], &boardReceiveBuffer.receiveData[0], frameLen - remainTailLength);
        
            if(CheckDataRight(receivedFrameArr))
            {
                boardReceiveBuffer.rIndex = frameLen - remainTailLength;
                return 1;
            }
            else
            {
                boardReceiveBuffer.rIndex++;
                return 0;
            }
        }
    }
}

//处理来自模拟板的数据
void BoardReceiveFrameHandler(void)
{  
    uint8_t len = 0;
    uint16_t receivedData = 0;
    
    if(boardReceiveBuffer.rIndex != boardReceiveBuffer.wIndex)
    {
        //每次进来先获取当前的读索引数据
        receivedData = boardReceiveBuffer.receiveData[boardReceiveBuffer.rIndex];
        if(receivedData == FRAME_HEADER)
        {
            if(IsNeededDataFrame())//如果是需要的数据帧
            {
                //如果是xx数据
                if(receivedFrameArr[2] == 0x94)
                { 
                    BoardReceiveEmgHandler(receivedFrameArr);
                }
              
                //如果是xx数据
                if(receivedFrameArr[2] == 0xD1)
                {
                    BoardReceivePressureHandler(receivedFrameArr);
                }
                
                //如果是xx数据
                if(receivedFrameArr[2] == 0x99)
                {
                    BoardReceivePollingHandler(receivedFrameArr);
                }
            }
        }
        else
        {
            boardReceiveBuffer.rIndex++;
        }
        
        if(boardReceiveBuffer.rIndex >= RECEIVE_BUFFER)
        {
            boardReceiveBuffer.rIndex = 0;
        }  
    }  
}

关于空闲中断的补充

上面讲到的空闲中断,里面提到了“一帧数据”这个概念,一度让我产生了误解。

我以为的一帧数据是“起始位+数据+停止位”这个底层的数据帧,但其实不是,串口底层在接收数据时,检测到起始位,就知道要开始保存数据了,直到检测到停止位,就知道数据到此结束了,最终,就得到了一个字节的数据,这个数据是没有起始位和停止位的,就是中间要传输的数据位。

空闲中断里,讲的一帧数据,其实就是我们一次性发的一包数据,我们习惯称其为一帧数据。

空闲中断(IDLE),俗称帧中断,即第一帧数据接收完毕到第二帧数据开始接收期间存在一个空闲状态(每接收一帧数据后空闲标志位置1),检测到此空闲状态后即执行中断程序,进入中断程序即意味着已经接收到一组完整数据,仅需及时对数据处理或将数据转移出缓冲区即可。串口空闲中断在串口无数据接收的情况下,是不会产生的,产生的条件是当清除空闲标志位后,必须有接收到第一个数据,之后,一旦接收的数据断流,即产生空闲中断

空闲的定义是总线上在一个字节的时间内没有再接收到数据。空闲中断是检测到有数据被接收后,总线上在一个字节的时间内没有再接收到数据的时候发生的。而总线在什么情况时,会有一个字节时间内没有接收到数据呢?一般就只有一个数据帧发送完成的情况,所以串口的空闲中断也叫帧中断。

总之,一帧数据,就是一次性发送的一包数据,不要理解成一个字节的数据帧。底层的时序位,并不算在数据里面。

  • 13
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值