STM32 UART在循环DMA模式下接收大量不定字长数据并进行乒乓缓存

串口(uart)是一种低速的串行异步通信,适用于低速通信场景,通常使用的波特率小于或等于115200bps。

对于小于或者等于115200bps波特率的,而且数据量不大的通信场景,一般没必要使用DMA,或者说使用DMA并未能充分发挥出DMA的作用。

对于数量大,或者波特率提高时,必须使用DMA以释放CPU资源,因为高波特率可能带来这样的问题:

  • 对于发送,使用循环发送,可能阻塞线程,需要消耗大量CPU资源“搬运”数据,浪费CPU

  • 对于发送,使用中断发送,不会阻塞线程,但需浪费大量中断资源,CPU频繁响应中断;以115200bps波特率,1s传输11520字节,大约69us需响应一次中断,如波特率再提高,将消耗更多CPU资源

  • 对于接收,如仍采用传统的中断模式接收,同样会因为频繁中断导致消耗大量CPU资源
    因此,高波特率场景下,串口非常有必要使用DMA。

中断轮询模式(IT)

使用HAL_UARTEx_ReceiveToIdle_IT函数,该函数会持续接收数据,直到缓冲区溢出或触发空闲事件。

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
/**
* @brief Receive an amount of data in interrupt mode till either the expected number of data is received or an IDLE event occurs.
* @note Reception is initiated by this function call. Further progress of reception is achieved thanks
* to UART interrupts raised by RXNE and IDLE events. Callback is called at end of reception indicating
* number of received data elements.
* @note When UART parity is not enabled (PCE = 0), and Word Length is configured to 9 bits (M = 01),
* the received data is handled as a set of uint16_t. In this case, Size must indicate the number
* of uint16_t available through pData.
* @param huart UART handle.
* @param pData Pointer to data buffer (uint8_t or uint16_t data elements).
* @param Size Amount of data elements (uint8_t or uint16_t) to be received.
* @retval HAL status
*/
HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
{
HAL_StatusTypeDef status;

/* 检查没有正在进行的接收过程 */
if (huart->RxState == HAL_UART_STATE_READY)
{
if ((pData == NULL) || (Size == 0U))
{
return HAL_ERROR;
}

/* 接收数据,直到空闲事件发生*/
huart->ReceptionType = HAL_UART_RECEPTION_TOIDLE;
huart->RxEventType = HAL_UART_RXEVENT_TC;

status = UART_Start_Receive_IT(huart, pData, Size);

/* 检查接收是否成功开始 */
if (status == HAL_OK)
{
if (huart->ReceptionType == HAL_UART_RECEPTION_TOIDLE)
{
__HAL_UART_CLEAR_IDLEFLAG(huart);
ATOMIC_SET_BIT(huart->Instance->CR1, USART_CR1_IDLEIE);
}
else
{
/* 如果在开始接收时已有错误待处理,则可能已引发中断并导致接收中止 */
status = HAL_ERROR;
}
}

return status;
}
else
{
return HAL_BUSY;
}
}

main.c

1
2
3
4
/* USER CODE BEGIN 2 */
HAL_UARTEx_ReceiveToIdle_IT(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE);

/* USER CODE END 2 */

usart.c

每次接收完数据(指溢出或空闲后)进入该回调,将缓冲区数据传回上位机,然后重新准备下一次接收

1
2
3
4
5
6
7
8
9
/* USER CODE BEGIN 1 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
//uint16_t index = Size;
//HAL_UARTEx_ReceiveToIdle_IT(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE);
HAL_UART_Transmit_IT(&huart1, (uint8_t *)RxBuffer, sizeof(RxBuffer));
HAL_UARTEx_ReceiveToIdle_IT(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE);
}
/* USER CODE END 1 */

DMA模式(少量数据)

DMA绕过CPU进行数据传输(外设-内存),因此可以节省CPU资源。DMA分为正常(Normal)模式和循环(Circular)模式,一般大规模数据传输时使用循环模式。
使用 HAL_UARTEx_ReceiveToIdle_DMA函数,仅需在最开始调用一次。当接收完成(达到指定字节数)或触发Idle事件时接收停止。每次接收事件完成后触发HAL_UARTEx_RxEventCallback中断回调。

应用要求:输入一段文字,若MCU成功接收则回复“Wilco”。

main.c

在loop前调用一次以启动接收:

1
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE);

usart.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* USER CODE BEGIN 1 */
int count = 0; //接收次数
uint16_t index = 0; //接收数据量
uint8_t Wilco[] = "wilco\n"; //发送缓冲区

/*
@name: UART接收中断回调函数
*/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
index = Size;
count++;
HAL_UART_Transmit_IT(&huart1, (uint8_t *)Wilco, sizeof(Wilco)); //中断轮询发送以免DMA冲突
/* for (int i = 0; i < RX_BUFFER_SIZE; i++)
{
RxBuffer[i] = 0; // 清空接收缓冲区(可选)
} */
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE); //重新开启接收
}
/* USER CODE END 1 */

DMA模式(大量数据)与乒乓缓存

参考文献

一个严谨的STM32串口DMA发送&接收(1.5Mbps波特率)机制

STM32 HAL 库实现乒乓缓存加空闲中断的串口 DMA 收发机制,轻松跑上 2M 波特率

MaJerla(Github)

DMA在循环模式下工作时,如果在大规模传输数据时仍旧空闲中断(或传输完成中断)会有风险,因为当DMA传输数据完成,CPU介入开始拷贝DMA通道缓冲区数据时,如果此时UART继续有数据进来,DMA继续搬运数据到缓冲区,就有可能将数据覆盖,因为DMA数据搬运是不受CPU控制的,即使你关闭了CPU中断。

因此严谨的做法需要建立双buffer,CPU和DMA各自使用一块内存交替访问,即乒乓缓存,处理流程为:

  1. DMA先将数据搬运到buf1,搬运完成通知CPU来拷贝buf1数据
  2. DMA将数据搬运到buf2,与CPU拷贝buf1数据不会冲突
  3. buf2数据搬运完成,通知CPU来拷贝buf2数据
  4. DMA继续开始拷贝新数据

STM32大多数型号不提供现成的双缓存机制,但提供“半满中断”,即数据搬运到buf大小的一半时,可以产生一个中断信号。基于这个机制,我们可以实现双缓存功能,只需将buf空间开辟大一点即可。

  1. DMA将数据搬运完成buf的前一半时,触发“半满中断”事件,Callback中通知CPU来拷贝buf前半部分数据
  2. DMA继续将数据搬运到buf的后半部分,与CPU拷贝buf前半部数据不会冲突
  3. buf后半部分数据搬运完成,触发“溢满中断”,Callback通知CPU来拷贝buf后半部分数据
  4. DMA循环拷贝新数据

基于上述描述机制,DMA方式接收串口数据,有三种中断场景需要CPU去将buf数据拷贝到final中,分别是:

  • DMA通道buf溢满(传输完成)场景,触发满溢中断(HAL_UARTEx_RxEventCallback
  • DMA通道buf半满场景,触发半满中断(HAL_UART_RxHalfCpltCallback
  • 串口空闲中断场景,触发空闲中断(UART_FLAG_IDLE

三种中断事件场景

数据处理流程图

也就是说,代码总共需要考虑以下几种情况:

  1. 数据量未达到半满,触发空闲中断
  2. 数据量达到半满,未达到满溢,先触发半满中断,后触发空闲中断
  3. 数据量刚好达到满溢,先触发半满中断,后触发满溢中断
  4. 数据量大于缓冲区长度,DMA循环覆盖溢出的字节

对于情况1:在空闲中断中拷贝全部数据

对于情况2:在半满中断中通知CPU拷贝一半的数据,DMA继续接收剩下的数据,最后在空闲中断中拷贝剩下的数据

对于情况3:在半满中断中通知CPU拷贝一半的数据,DMA继续接收剩下的数据,最后在满溢中断中拷贝剩下的一半数据

对于情况4:综合处理

下面这个代码经过试验不太好使,空闲中断和满溢中断似乎有冲突,导致接收完成后MCU进不去满溢中断。

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
/* USER CODE BEGIN USART1_IRQn 0 */
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
__HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_IDLE);
HAL_UART_DMAStop(&huart1);
uint32_t temp = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); //已接收的数据大小

if (temp != 0)
//如果接收到了数据
{
uint8_t txbuffer[] = "rx ok in idle IT\n";
HAL_UART_Transmit(&huart1, txbuffer, sizeof(txbuffer), 0xFFFF);

if (rx_half_flag == 0)
//如果半满中断尚未触发过(前半段)
{
for (int i = 0; i < temp; i++)
{
RX_final[final_index] = Rx_buffer[i];
final_index++;
HAL_UART_Receive_DMA(&huart1, Rx_buffer, RX_BUFFER_SIZE - temp);
}
}
else if (rx_half_flag)
//如果半满中断已触发过(后半段)
{
uint32_t temp_size = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx) - RX_BUFFER_SIZE / 2;
for (int i = RX_BUFFER_SIZE / 2; i < RX_BUFFER_SIZE / 2 + temp_size; i++)
{
RX_final[final_index] = Rx_buffer[i];
final_index++;
}
HAL_UART_Receive_DMA(&huart1, Rx_buffer, RX_BUFFER_SIZE/2 - temp_size);
}
}
else if (temp == RX_BUFFER_SIZE)
{
HAL_UART_Receive_DMA(&huart1, Rx_buffer, RX_BUFFER_SIZE);
}
}
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
  /* USER CODE BEGIN 1 */
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
rx_half_flag = 1;

uint8_t sendbuffer[] = "rxhalf IT\n";
HAL_UART_Transmit(&huart1, (uint8_t *)sendbuffer, sizeof(sendbuffer), 0xFFFF);

for (int i = 0; i < RX_BUFFER_SIZE / 2; i++)
{
RX_final[final_index] = Rx_buffer[i];
final_index++;
}

HAL_UART_Receive_DMA(&huart1, (uint8_t *) Rx_buffer, RX_BUFFER_SIZE / 2);
}
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
uint8_t sendbuffer[] = "rx ok\n";
HAL_UART_Transmit(&huart1, (uint8_t *)sendbuffer, sizeof(sendbuffer), 0xFFFF);

for (int i = RX_BUFFER_SIZE / 2; i < RX_BUFFER_SIZE; i++)
{
RX_final[final_index] = Rx_buffer[i];
final_index++;
}

rx_half_flag = 0;
}
HAL_UART_Receive_DMA(&huart1, (uint8_t *) Rx_buffer, RX_BUFFER_SIZE);
}
/* USER CODE END 1 */

继续调试,删掉除打印测试信息以外的所有功能性代码:

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
  /* USER CODE BEGIN USART1_IRQn 0 */
uint32_t temp_size = 0;
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
HAL_UART_DMAStop(&huart1);
temp_size = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); //已接收数据长度

if (temp_size != 0)
{
uint8_t txbuf3[] = "rxidle\n";
HAL_UART_Transmit(&huart1, txbuf3, sizeof(txbuf3), 0xFFFF);

}
}
/* USER CODE END USART1_IRQn 0 */

/* USER CODE BEGIN 1 */
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
uint8_t txbuf1[] = "rxhalf\n";
HAL_UART_Transmit(&huart1, txbuf1, sizeof(txbuf1), 0xFFFF);
}
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
uint8_t txbuf2[] = "rxfull\n";
HAL_UART_Transmit(&huart1, txbuf2, sizeof(txbuf2), 0xFFFF);
}
}
/* USER CODE END 1 */

正常情况下,接收缓冲区大小为10,当发送“12”(实际为"12\r\n“四个字节)时,应只触发空闲中断;发送“123”时,触发半满中断和空闲中断;发送“12345678”时,触发半满中断和满溢中断。但测试发现,发送“12345678”共计十个字节数据,也就是预期接收数据大小等于缓冲区大小时,只能触发一次半满中断,满溢中断无法触发。若禁用空闲中断,发送“12345678”时,半满中断和溢满中断都能触发,初步判断是空闲中断的问题。

由于DMA配置在循环模式,当接收数据大小等于缓冲区大小时,RX总线仍会处于空闲状态,因为即将到来的新的数据会覆盖掉环形队列的第一项,因此实际上NVIC会先触发空闲中断,而空闲中断中有这一段代码:

1
2
3
4
5
6
7
temp_size = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);  //已接收数据长度

if (temp_size != 0)
{
uint8_t txbuf3[] = "rxidle\n";
HAL_UART_Transmit(&huart1, txbuf3, sizeof(txbuf3), 0xFFFF);
}

当接收数据大小等于缓冲区大小时,temp_size实际上等于0,导致程序在空闲中断中直接跳过了满溢判断,开始下一次接收。因此需要单独对temp_size == 0这种情况进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* USER CODE BEGIN USART1_IRQn 0 */
uint32_t temp_size = 0;
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
temp_size = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
idle_flag = 1;
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
if (temp_size == 0) //满溢
{
//满溢回调
}
else if (temp_size != 0) //非满溢
{
HAL_UART_DMAStop(&huart1);
uint8_t txbuf3[] = "rxidle\n";
HAL_UART_Transmit(&huart1, txbuf3, sizeof(txbuf3), 0xFFFF);
HAL_UART_Receive_DMA(&huart1, Rx_buffer, RX_BUFFER_SIZE);
}
}
/* USER CODE END USART1_IRQn 0 */

这样,空闲中断在数据填满缓冲区时不会进行任何操作而直接跳出。只有当数据未填满缓冲区时才会进行原先的操作(主要是DMAStop这一步)。到这里,三个中断触发的逻辑总算是理顺了,接下来只需要进行搬运数据的处理就可以了。

usart.c

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
/* USER CODE BEGIN 1 */
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
Rx_half_flag = 1; //半满标记
uint8_t txbuf1[] = "rxhalf\n";
HAL_UART_Transmit(&huart1, txbuf1, sizeof(txbuf1), 0xFFFF);

Rx_buffer_head = Rx_buffer_tail;
Rx_buffer_tail = RX_BUFFER_SIZE / 2 - 1;

for (int i = Rx_buffer_head; i <= Rx_buffer_tail; i++)
{
Rx_final[Rx_final_index] = Rx_buffer[i];
Rx_final_index++;
}

}
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
Rx_half_flag = 0; //清除半满标记

uint8_t txbuf2[] = "rxfull\n";
HAL_UART_Transmit(&huart1, txbuf2, sizeof(txbuf2), 0xFFFF);

Rx_buffer_head = Rx_buffer_tail + 1;
Rx_buffer_tail = RX_BUFFER_SIZE - 1;

for (int i = Rx_buffer_head; i <= Rx_buffer_tail; i++)
{
Rx_final[Rx_final_index] = Rx_buffer[i];
Rx_final_index++;
}
}
}
/* USER CODE END 1 */

中断服务函数:

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
/* USER CODE BEGIN USART1_IRQn 0 */
uint32_t temp_size = 0;
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
temp_size = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); //本次接收到的数据大小
//idle_flag = 1;
__HAL_UART_CLEAR_IDLEFLAG(&huart1); //清除Idle标记
if (temp_size == 0) //满溢
{
//满溢回调
}
else if (temp_size != 0) //非满 ??
{
HAL_UART_DMAStop(&huart1);

uint8_t txbuf3[] = "rxidle\n";
HAL_UART_Transmit(&huart1, txbuf3, sizeof(txbuf3), 0xFFFF);

if (Rx_half_flag == 0) //未半满,前半段触发空闲
{
Rx_buffer_head = Rx_buffer_tail;
Rx_buffer_tail = Rx_buffer_tail + temp_size - 1;

//拷贝数据
for (int i = Rx_buffer_head; i <= Rx_buffer_tail; i++)
{
Rx_final[Rx_final_index] = Rx_buffer[i];
Rx_final_index++;
}
}
else if (Rx_half_flag != 0) //已半满,后半段触发空闲
{
Rx_buffer_head = Rx_buffer_tail + 1;
Rx_buffer_tail = Rx_buffer_tail + temp_size - RX_BUFFER_SIZE / 2;

for (int i = Rx_buffer_head; i <= Rx_buffer_tail; i++)
{
Rx_final[Rx_final_index] = Rx_buffer[i];
Rx_final_index++;
}
}
temp_size = 0;
Rx_buffer_head = 0;
Rx_buffer_tail = 0;
Rx_half_flag = 0;
HAL_UART_Receive_DMA(&huart1,(uint8_t *) Rx_buffer, RX_BUFFER_SIZE);
}
}
/* USER CODE END USART1_IRQn 0 */

写到这里只处理了单次接收数据大小小于缓冲区长度的情况,可以发现代码量还是挺大的。尤其是这么写代码存在一个比较麻烦的逻辑:当DMA接收的数据量大于缓冲区大小RX_BUFFER_SIZE时,由于DMA工作在循环模式,那么溢出的数据会被DMA重新放到缓冲区的开始部分,从而覆盖原有的数据。要处理这部分数据势必要引入比较复杂的判断机制,还要实时更新队首和队尾的指针,导致整个程序变得比较复杂。

好在HAL库除了普通的HAL_UART_Receive_DMA()HAL_UART_RxCpltCallback()外,HAL库还提供了HAL_UARTEx_RxEventCallback回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @brief Reception Event Callback (Rx event notification called after use of advanced reception service).
* @param huart UART handle
* @param Size Number of data available in application reception buffer (indicates a position in
* reception buffer until which, data are available)
* @retval None
*/
__weak void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
/* Prevent unused argument(s) compilation warning */
UNUSED(huart);
UNUSED(Size);

/* NOTE : This function should not be modified, when the callback is needed,
the HAL_UARTEx_RxEventCallback can be implemented in the user file.
*/
}

该回调函数会在“advanced reception service”事件发生后触发,这里的所谓高级接收服务就包括之前需要分开判断的DMA半满中断、DMA满溢中断和空闲中断(实际上还有一个错误中断)。这三个中断触发后都会回调HAL_UARTEx_RxEventCallback()函数。在拷贝数据时,无需再单独进行中断回调类型的判断。由于DMA工作不依赖CPU,因此在该函数内要做的就是将缓冲区内的数据拷贝至目标地址。注意这里的形参Size表示缓冲区可用数据长度(从这个位置开始往后的位置都为空),而不是本次回调所接收的数据长度。

三种中断事件场景

再对照一下这张图,红色的部分就是rx_size,其值等于Size(缓冲区总的有效数据长度)减去rx_buf_head(头指针)。当缓冲区溢出并循环存储新数据至缓冲区开头时,Size会同步更新。

原先的三个中断中的代码可以合到一个中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* USER CODE BEGIN 1 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
uint8_t txbuf[] = "rx done\n";
HAL_UART_Transmit_IT(&huart1, txbuf, sizeof(txbuf));

static uint8_t rx_buf_head = 0;
static uint8_t rx_size; //待处理数据长度

rx_size = Size - rx_buf_head;

for (uint16_t i = 0; i < rx_size; i++)
{
RxFinal[final_index++] = RxBuf[(rx_buf_head + i) % RxBufSize]; // 环形缓冲处理
if (final_index >= RxFinalSize) final_index = 0; // 避免 RxFinal 溢出
}

rx_buf_head = rx_buf_head + rx_size;

if (rx_buf_head >= RxBufSize) rx_buf_head = 0;
}
/* USER CODE END 1 */

这么写就无需判断到底是哪个中断触发的回调,只需要通过Size和头指针位置计算本次接收到的数据长度,然后按照队列逻辑按次序拷贝即可,因DMA循环而引起的溢出可通过取模操作来处理。

验证:

  1. 输入“12345678\r\n”:

  2. 输入“123456789abcdefghijklmn”:

  3. 先输入"123",再输入"1234567“,再输入"12",再输入"123456789”:

验证成功。

Github项目地址


STM32 UART在循环DMA模式下接收大量不定字长数据并进行乒乓缓存
http://akichen891.github.io/2024/11/25/STM32 UART接收不固定字节数据/
作者
Aki Chen
发布于
2024年11月25日
更新于
2024年12月7日
许可协议