STM32使用HAL库DMA空闲中断实现环形缓冲区串口数据收发详解
文章目录
前言
过往一直在用标准库进行开发,最近手头上有一批不用的板子,一看MCU是STM32F4系列的(以前玩的都还是STM32F1),莫名产生了“踏上新时代的船”的想法,直接下载STM32CubeIDE,使用HAL库开发来调通这些板子。我第一步调试的就是几乎不管什么应用场景都会涉及到的串口,又因为看到好几篇使用串口DMA的文章,决定使用以前没用过的DMA+空闲中断来进行串口接收数据。
第一次写博客,没想到来写前言时已经过万字了。之所以花那么多时间来表述这些学习成果,是因为学习过程中浏览到很多其他网友(其中不少是嵌入式初学者)调试DMA和空闲中断时遇到各种各样的问题,而他们在借鉴的文章中留言这些问题往往很难得到博主及时回应。希望不管是初学者还是有一定经验的开发者,都能从这篇文章中获得自己想要的东西。抛砖引玉,欢迎大家在评论区留言交流!
一、DMA是什么?
在传统的计算机系统中,外设设备需要通过CPU来控制数据的传输。当外设设备需要读取或写入数据时,需要向CPU发出请求,CPU则负责处理这些请求和数据传输的操作。这种方式会占用CPU的时间和资源,降低计算机系统的整体性能。
DMA控制器是一个特殊的硬件设备,它可以直接和系统内存进行数据传输,而不需要通过CPU来控制。在数据传输过程中,外设设备会向DMA控制器发送请求,告诉它需要读取或写入的数据的地址和大小。然后DMA控制器会直接从内存中读取或写入数据,完成数据传输的过程。这样就可以减少CPU的负担,提高数据传输的速度和效率。
二、相关配置
在HAL库的初始化中,DMA的全满中断默认使能,实际上半满中断也是默认使能的,感兴趣的可以看看函数
HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
的代码。
外设->内存 模式下,如果是直传模式,每当有数据被外设送入到FIFO,这些数据会直接从FIFO送到目标地址存储。如果是FIFO模式,FIFO中的数据会等待中断再被传输:
三、DMA工作模式
【1】直传模式(Direct Mode)
直传模式下的普通模式和循环模式,半满和全满中断都是按照用户配置的"接收数据长度"(也就是NDTR)正常产生。
【2】FIFO模式(FIFO Mode)
友情提醒
这部分的内容有点复杂,本人实际应用也没有使用上,只是学习时顺带研究调试了一下,本意只是大概搞明白和Direct Mode的基本区别,没想到疑惑一个接一个,很多地方用户手册也没说明白,最后居然记录了这么多!我将这部分的学习成果分享出来,希望其他使用FIFO Mode的朋友遇到问题了有个参考。不感兴趣的可以直接忽略这部分。
介绍
在两边数据流数据宽度不一致的情况下,需要设置FIFO Mode(或叫"突发模式")。
当数据宽度不一致,一方的单份数据可能是另一方的多份数据,若使用直传模式每次都从FIFO中搬运同样长度的数据,一定会出现数据过载或欠载的情况。因此突发模式通过在FIFO中控制输出单份数据的长度,避免数据流出错。
每个数据流配置有一个4字即16字节的FIFO,在突发模式下,通过配置溢出阈值、数据宽度、节拍数来设置每次在FIFO中搬运的数据长度。
显而易见的是需要保证搬运数据时FIFO中的数据长度满足该次搬运的数据长度要求,否则数据流就可能出错,这意味着中断时FIFO中的数据长度需要是(Burst Size × Data Width)的倍数,不满足要求的配置是被禁止的:

ps:上图表格没有写出MBURST=single tranfer的情况(即每份数据都会被立即传输),因为这与直传模式一样,事实上直传模式下MBURST会由硬件强制置成single tranfer。
举个例子,若数据宽度设置为Byte,溢出阈值设置为3/4,那每当FIFO缓冲12字节数据就会产生中断请求搬运数据到目标地址:
下图非常好地描述了突发模式中FIFO对数据的存储和输送:
普通模式下
当NDTR变为0时或达到FIFO阈值时都会触发中断,半满中断和全满中断的标志位都有可能为1,下文会详细分析。无论哪种中断标志位组合都会导致禁止中断使能(非循环模式下,HAL库的中断处理函数中会把中断标志位为1的中断类型禁掉):
/* Half Transfer Complete Interrupt management ******************************/
if ((tmpisr & (DMA_FLAG_HTIF0_4 << hdma->StreamIndex)) != RESET)
{...}
/* Disable the half transfer interrupt if the DMA mode is not CIRCULAR */
else
{...}
/* Transfer Complete Interrupt management ***********************************/
if ((tmpisr & (DMA_FLAG_TCIF0_4 << hdma->StreamIndex)) != RESET)
{...}
/* Disable the transfer complete interrupt if the DMA mode is not CIRCULAR */
else
{...}
此时需要重置NDTR,否则不会再产生任何中断。由于HAL库的中断处理函数会自动清除标志位,重新使能中断;我们只需在回调函数中再次调用HAL_UART_Receive_DMA()
即可重置NDTR。
循环模式下
当NDTR变为0时不会触发中断,且NDTR会直接重置;由于处于循环模式,HAL库的中断处理函数不会禁止中断使能位。只有FIFO数据达到阈值时才可能会产生中断。
FIFO Mode具体分析
看到这里大家可能会有种违和感,FIFO Mode的中断理应是由每个Stream的FIFO配置的"FIFO阈值"决定的,而FIFO大小是固定16字节的,也就是说触发中断的阈值也是几个固定的数,这个过程中和用户自己设置的NDTR又有什么关联?
下面列几个FIFO Mode中循环模式下的实验现象,数据宽度均为Byte(黄色标注为发生中断):
【1】FIFO Threshold:3/4 ,NDTR:14,此时FIFO阈值 = 12 > 7 = 1/2 NDTR:
接收数据长度 | TCIF(全满中断标志) | HTIF(半满中断标志) | NDTR |
---|---|---|---|
0 | 0 | 0 | 14 |
4 | 0 | 0 | 10 |
8 | 0 | 0 | 6 |
12 | 0 | 1 | 2 |
16 | 0 | 0 | 12 |
20 | 0 | 0 | 8 |
24 | 1 | 1 | 4 |
… | |||
36 | 1 | 1 | 6 |
… | |||
48 | 1 | 0 | 8 |
【2】FIFO Threshold:1/4 ,NDTR:6,此时FIFO阈值 = 4 > 3 = 1/2 NDTR:
接收数据长度 | TCIF(全满中断标志) | HTIF(半满中断标志) | NDTR |
---|---|---|---|
0 | 0 | 0 | 6 |
4 | 0 | 1 | 2 |
8 | 1 | 0 | 4 |
12 | 1 | 1 | 6 |
16 | 0 | 1 | 2 |
20 | 1 | 0 | 4 |
24 | 1 | 1 | 6 |
【3】FIFO Threshold:1/4 ,NDTR:10,此时FIFO阈值 = 4 < 5 = 1/2 NDTR:
接收数据长度 | TCIF(全满中断标志) | HTIF(半满中断标志) | NDTR |
---|---|---|---|
0 | 0 | 0 | 10 |
4 | 0 | 0 | 6 |
8 | 0 | 1 | 2 |
12 | 1 | 0 | 8 |
16 | 0 | 1 | 4 |
20 | 1 | 0 | 10 |
24 | 0 | 0 | 6 |
28 | 0 | 1 | 2 |
只看前两个实验现象的话,虽然确实触发中断了,但可能对两个标志位的值一头雾水;但对照第三个实验数据就一目了然了:实验三中接收数据长度为4和24时,即使长度达到FIFO阈值了却仍因为两个中断标志位都为0而无法触发中断。说明即使在FIFO Mode,两个中断标志位仍是由NDTR决定的!
FIFO Mode下,半满和全满标志位仍会因为NDTR变为初始值一半和0时“变化”,之所以带“引号”是因为标志位的值不会实时变化反映出来,即使到了半满和全满,Debug里看标志位也还是0。但当FIFO中数据达到FIFO阈值时,MCU会判断之前记录的半满和全满标志位是不是为1,如果有任意一个为1则触发中断。可以说,FIFO阈值是触发中断的第一条件,半满全满中断标志位是触发中断的第二条件。
四、DMA接收示例
下面以Direct Mode、循环模式下一个8字节大小的接收缓冲区示例,单片机串口接收完后打印缓冲区内的数据:
-
初始化后,NDTR=8:
-
接收到1字节数据后,NDTR=7:
-
再接收到2字节数据后,NDTR=5:
-
当缓冲区被写满后,即NDTR为0时会触发全满中断。普通模式下需手动清除标志位;循环模式下NDTR会自动重置(到0时自动重置回缓冲区长度),写指针回到缓冲区起始地址,且此时会触发中断回调函数:
-
再接收到数据时,将从缓冲区起始位置重新开始写入:
五. 使用HAL库自带的HAL_UARTEx_ReceiveToIdle_DMA()函数实现DMA+空闲中断(相当多坑)
DMA接收的好处不再赘述,缺点是DMA需要FIFO阈值达到设定值才能触发中断,如果接收的数据少于这个阈值,MCU就无法通知我们缓冲区的数据更新了。因此空闲中断就派上用场了:每当串口接收完一个字节数据后检测到下一个bit不为起始位,说明接收完连续的一帧数据了,就会产生空闲中断。
1.具体实现上与只使用DMA接收的区别
使用HAL库自带的HAL_UARTEx_ReceiveToIdle_DMA()
函数,当DMA触发了半满中断或全满中断时,同样是会进入函数到HAL_DMA_IRQHandler()
的 ,区别在于调用的回调函数不同了。以半满中断为例:
static void UART_DMARxHalfCplt(DMA_HandleTypeDef *hdma)
{
/* --- 无关的已省略 --- */
/* Check current reception Mode :
If Reception till IDLE event has been selected : use Rx Event callback */
if (huart->ReceptionType == HAL_UART_RECEPTION_TOIDLE)
{
/*Call legacy weak Rx Event callback*/
HAL_UARTEx_RxEventCallback(huart, huart->RxXferSize / 2U);
}
else
{
/*Call legacy weak Rx Half complete callback*/
HAL_UART_RxHalfCpltCallback(huart);
}
可以看到,若串口接收模式被设置成空闲中断接收,回调函数会调用HAL_UARTEx_RxEventCallback()
而不是 HAL_UART_RxHalfCpltCallback()
,这也是为什么接收到数据后不是进入之前DMA接收设置的回调函数的原因。
结论:使用HAL_UARTEx_ReceiveToIdle_DMA()
,不管是半满中断、全满中断还是空闲中断,都是调用同一个回调函数HAL_UARTEx_RxEventCallback()
。用户需要重写该函数,三种中断的处理都在里面进行。
2.普通模式(不感兴趣的可以忽略)
接下来看一下为什么普通模式下,接收完一次数据DMA就不工作了:
在普通模式下触发空闲中断时,HAL库串口中断函数中会自动进行配置(已把不相关的代码省去):
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart)
{
/* --- 无关的已省略 --- */
/* Check current reception Mode :
If Reception till IDLE event has been selected : */
if ((huart->ReceptionType == HAL_UART_RECEPTION_TOIDLE)
&& ((isrflags & USART_SR_IDLE) != 0U)
&& ((cr1its & USART_SR_IDLE) != 0U))
{
__HAL_UART_CLEAR_IDLEFLAG(huart);
/* Check if DMA mode is enabled in UART */
if (HAL_IS_BIT_SET(huart->Instance->CR3, USART_CR3_DMAR))
{
/* DMA mode enabled */
uint16_t nb_remaining_rx_data = (uint16_t) __HAL_DMA_GET_COUNTER(huart->hdmarx);
if ((nb_remaining_rx_data > 0U) && (nb_remaining_rx_data < huart->RxXferSize))
{
/* In Normal mode, end DMA xfer and HAL UART Rx process*/
if (huart->hdmarx->Init.Mode != DMA_CIRCULAR)
{
/* Disable the DMA transfer for the receiver request by resetting the DMAR bit in the UART CR3 register */
ATOMIC_CLEAR_BIT(huart->Instance->CR3, USART_CR3_DMAR);
/* At end of Rx process, restore huart->RxState to Ready */
huart->ReceptionType = HAL_UART_RECEPTION_STANDARD;
ATOMIC_CLEAR_BIT(huart->Instance->CR1, USART_CR1_IDLEIE);
/* Last bytes received, so no need as the abort is immediate */
(void)HAL_DMA_Abort(huart->hdmarx);
}
/*Call legacy weak Rx Event callback*/
HAL_UARTEx_RxEventCallback(huart, (huart->RxXferSize - huart->RxXferCount));
}
}
}
} //end of function
需要关注的几个点:
- 首先判断RxEventType是否为
HAL_UART_RECEPTION_TOIDLE
,且处理函数会自动清空IDLE标志; - DMAR置0导致了禁止DMA Mode的接收;
- ReceptionType从
HAL_UART_RECEPTION_TOIDLE
变为了HAL_UART_RECEPTION_STANDARD
,这会导致下次接收无法进入该段处理程序; - IDLEIE置0导致了空闲中断的失效;
HAL_DMA_Abort()
禁止了DMA流。
这就是接收一次数据后不论DMA设置的Buffer有没有满(也就是NDTR不为0),DMA传输都会停止的原因:触发了串口空闲中断。前4点是从串口配置禁止,第5点是从DMA配置禁止。所以普通模式下使用空闲中断要留个心眼:空闲中断是会影响到DMA的配置的,此时DMA作用只是作为单次数据接收的缓冲区,每次接收完都会重置。
结论:普通模式下,使用HAL_UARTEx_ReceiveToIdle_DMA()
,一旦进入过空闲中断就只能再一次调用HAL_UARTEx_ReceiveToIdle_DMA()
来开启DMA和串口的对应配置。
3.循环模式
反过来说,循环模式下,空闲中断就和DMA的运作独立开来了。DMA配置的Memory可以看作一个环形缓冲区,里面的数据并不会因为空闲中断清空。当我们配置好后,就得到了一个会自动写入数据的环形缓冲区,接下来就是如何从里面读取数据了。
关于环形缓冲区的介绍可以看这篇文章:ring buffer,一篇文章讲透它,讲得非常通俗易懂。
实现思路
读数据的前提肯定是有新的数据写入。空闲中断会通知我们数据更新了,因此首当其冲的问题是如何获取新写入到缓冲区数据的长度。 循环模式跟普通模式不同,NDTR不会每次接收完都被重置,但传入HAL_UARTEx_RxEventCallback()
的参数Size
却是 (预设的缓冲区大小 – NDTR),也就是剩余缓冲区大小,具体参考HAL_UART_IRQHandler()
下面的几行代码:
/* Check if DMA mode is enabled in UART */
if (HAL_IS_BIT_SET(huart->Instance->CR3, USART_CR3_DMAR))
{ /* DMA mode enabled */
uint16_t nb_remaining_rx_data = (uint16_t) __HAL_DMA_GET_COUNTER(huart->hdmarx); //返回的值就是NDTR
......
huart->RxXferCount = nb_remaining_rx_data;
......
HAL_UARTEx_RxEventCallback(huart, (huart->RxXferSize - huart->RxXferCount));
}
因此普通模式下把Size
当作写入长度是没问题的,但循环模式下就需要我们进一步计算了。
既然把循环模式的数据存放看作环形缓冲区,那Size
不就基本等同于一个自动更新的写索引了吗?我们维护一个自定义写索引rear
,每次进入空闲中断都更新一次:
rear = (Size != sizeof(RxBuffer))?Size:0; //若Size等于缓冲区长度证明写满了,写指针要回到起点
很容易就能得出写入数据的长度:
Size
> rear
,写入数据长度 = Size
– rear
;Size
<= rear
,写入数据长度 = Size
+ (sizeof(RxBuffer)
– rear
)。紧接而来就有一个坑了:看到这里大家可能会想到,把全满中断关掉,反正也用不上,开着还会进入中断回调,还可能影响到上面的运算逻辑。这个想法本身是没问题的,但实际上回看上面普通模式贴的代码就会发现一个问题:
/* DMA mode enabled */
uint16_t nb_remaining_rx_data = (uint16_t) __HAL_DMA_GET_COUNTER(huart->hdmarx);
if ((nb_remaining_rx_data > 0U) && (nb_remaining_rx_data < huart->RxXferSize))
{
if (huart->hdmarx->Init.Mode != DMA_CIRCULAR)
{......}
/*Call legacy weak Rx Event callback*/
HAL_UARTEx_RxEventCallback(huart, (huart->RxXferSize - huart->RxXferCount));
}
调用回调函数的条件,普通模式和循环模式是一样的:NDTR>0,且NDTR < 预设的BufferSize 。循环模式下一旦NDTR变为0会立刻重置为初始预设值,也就是说如果写入的数据刚好写满缓冲区,进入HAL_UART_IRQHandler()
时是 NDTR == 预设的BufferSize,自然就不会调用回调函数了!
解决方法
首先我们迫不得已还是要开全满中断,解决思路如下:
Size
== sizeof(RxBuffer)
(或 huart->RxEventType
== HAL_UART_RXEVENT_TC
),证明触发的是全满中断,此时需要加入一定延时(不能直接用HAL_Delay()
,SysTick中断抢占优先级最低,在这里调用只会导致程序卡死),等待DMA传输一个单位数据的周期,然后进行如下判断:
NDTR
== sizeof(RxBuffer)
,证明触发中断后没有再往缓冲区里写数据了(中断不会打断DMA传输),本次写入就是刚好写满缓冲区。运算和之前所述一样;NDTR
!= sizeof(RxBuffer)
,证明数据还没写完,我们只要等待空闲中断到来再处理就行了,因此直接return即可;Size
!= sizeof(RxBuffer)
,证明触发的是空闲中断,和之前所述同样处理。ps:
代码仅供参考:
#define UART_RXBUF_MAXSIZE (8)
//串口环形缓冲区结构
typedef struct
{
uint16_t front; //写索引
uint16_t rear; //读索引
uint8_t buf[UART_RXBUF_MAXSIZE]; //缓冲区
}uart_buf_t, * p_uart_buf_t;
uart_buf_t rxCirBuf = {};
//读取串口缓冲区
int get_data_fromRxBuf(uint8_t* data,uint16_t size)
{
p_uart_buf_t p = &rxCirBuf;
if(size == 0 || size > sizeof(p->buf))
return -1;
if(p->front < p->rear)
{
memcpy(data,p->buf + p->front,size);
p->front += size;
}
else
{
uint16_t rest = sizeof(p->buf) - p->front;
memcpy(data,p->buf + p->front,rest);
memcpy(data+rest,p->buf,size - rest);
p->front = size - rest;
}
return 1;
}
//接收中断回调函数
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef * huart, uint16_t Size)
{
if(huart->Instance == USART1)
{
uint16_t BUFF_SIZE = sizeof(rxCirBuf.buf);
//判断是否为全满中断
if(Size == BUFF_SIZE)
{
delay_us(100); //这里记得要用非中断延时
uint16_t NDTR = (uint16_t) __HAL_DMA_GET_COUNTER(huart->hdmarx);
if(NDTR != BUFF_SIZE)
return;
}
//计算数据长度
uint16_t size = (Size > rxCirBuf.rear)?\
(Size - rxCirBuf.rear):(Size + (BUFF_SIZE - rxCirBuf.rear));
//写索引更新
rxCirBuf.rear = (Size != BUFF_SIZE)?Size:0;
/* TODO: */
//测试用:发送接收到的数据
uint8_t dSend[size];
if(1 == get_data_fromRxBuf(dSend, size))
HAL_UART_Transmit(&huart1, dSend, size,20);
}
}
上面代码的测试部分还包含了读取缓冲区的操作,本文不再展开。
总结说明
上面所述的是如何使用循环模式下的环形缓冲区,包括应该在哪里、在什么时候处理接收的数据,并且如何获取到接收到数据的长度。每个人的应用场景不同,有可能处理的数据每帧都有包头包尾,或每帧数据是定长,或数据中就包含长度信息,因此处理时并不一定要计算size
。本文只是介绍了一个实现思路,环形缓冲区也只是进行了最基本的实现,实质上还有很多可挖掘可完善的地方,比如这篇博客里提到的一种情况:处理一帧数据过久会出现的情况(@大文梅)。
六、使用DMA进行串口发送
来都来了,顺便简单讲一下HAL_UART_Transmit_DMA()
发送数据的流程。
首先接收数据和发送数据不是同一个DMA流,数据流的方向都不一样,如果发现无法发送数据不妨先检查下是否忘记配置串口Tx的DMA流。
大概的工作流程
- 在
HAL_UART_Transmit_DMA()
里,发送数据前会自动打开中断使能和DMA流使能并清空中断标志位,配置好后令串口控制寄存器的DMAT位置1(USART_CR3_DMAT
)使能DMA Mode; - 使能后DMA开始从Memory将数据写入到TDR,而当TDR的数据全部送到移位寄存器后,TXE Flag会置1,说明TDR空了,DMA就又送入下一个数据了。 特别说明一下,整个过程TXEIE位是disabled的,因此每次TXE Flag置1都不会触发中断。另外
HAL_UART_Transmit()
也是一样,只有HAL_UART_Transmit_IT()
会使能TXEIE来利用TXE中断发送数据。 这样大家应该可以理解这三者的区别了,包括阻塞发送和非阻塞发送也清晰了。别再写完HAL_UART_Transmit_IT(&huart1, rxBuf, Size)
或HAL_UART_Transmit_DMA(&huart1, rxBuf, Size)
后接一句memset(rxBuf, 0, Size)
然后问为什么发不出数据或者少数据了; - 当全部数据数传输完成后,NDTR计数也到0了,触发了全满中断从而进入了
HAL_DMA_IRQHandler()
。HAL库会自动清空中断标志位,若非循环模式还会禁止DMA中断。最后调用库内置的回调函数UART_DMATransmitCplt()
; UART_DMATransmitCplt()
中会将DMAT置0,禁止DMA Mode;然后USART_CR1_TCIE
置1使能传输完成中断。最后调用回调函数HAL_UART_TxCpltCallback()
,用户可以重写该函数来实现自己的需求。
后话
可能很多人会想:你这文章也太啰嗦了,我都用HAL库了还要去刨它库函数怎么运行吗?直接写解决方案就行了,顺便把源码附上()。
我的想法是:如果你能一次把功能调通且往后也不会出问题,那确实是可以。但是随着应用场景的变化,外设的变化甚至是MCU的变化,你能保证你的代码同样适用吗?嵌入式开发本身就是不断修正的过程。出问题不是问题,出了问题不会找原因才是问题。 而我文章内容就是一个不断出问题、找原因、解决问题的过程。
举个例子,网上其它使用DMA+空闲中断的教程,都是简单地教你使用HAL_UARTEx_ReceiveToIdle_DMA()
,普通模式下要在接收完一次数据后再调用一次。但我在学习过DMA后,第一想法是只调用这个函数一次,因为DMA开启之后没必要每次都去重置,尤其是缓冲区还有很多剩余空间的情况下。我的想法是串口中断应该只会禁止串口的DMA相关使能位,我只要在中断中重新使能就好了。但写了代码之后发现不行,debug时发现整个DMA流都被禁掉了。一看原来HAL库的串口中断函数里调用了HAL_DMA_Abort(huart->hdmarx)
,空闲中断发生就自动给你把DMA流禁了。虽然我在小标题标明了“不感兴趣的可以忽略”,但其实我相信总有人像我一样感兴趣的~
HAL库开发确实大大简化了开发过程,也降低了开发门槛,但这不代表我们对自己的要求降低了。同样的坑,你过了门槛踩到它时都是一样深的,爬出来也是一样费劲。你是想等有人路过把你拉出来,还是自己慢慢努力爬出来?
作者:花舞君