串口(uart)是一种低速的串行异步通信,适用于低速通信场景,通常使用的波特率小于或等于115200bps。
对于小于或者等于115200bps波特率的,而且数据量不大的通信场景,一般没必要使用DMA,或者说使用DMA并未能充分发挥出DMA的作用。
对于数量大,或者波特率提高时,必须使用DMA以释放CPU资源,因为高波特率可能带来这样的问题:
-
对于发送,使用循环发送,可能阻塞线程,需要消耗大量CPU资源“搬运”数据,浪费CPU
-
对于发送,使用中断发送,不会阻塞线程,但需浪费大量中断资源,CPU频繁响应中断;以115200bps波特率,1s传输11520字节,大约69us需响应一次中断,如波特率再提高,将消耗更多CPU资源
-
对于接收,如仍采用传统的中断模式接收,同样会因为频繁中断导致消耗大量CPU资源 因此,高波特率场景下,串口非常有必要使用DMA。
中断轮询模式(IT)
使用HAL_UARTEx_ReceiveToIdle_IT函数,该函数会持续接收数据,直到缓冲区溢出或触发空闲事件。
/** * @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
/* USER CODE BEGIN 2 */ HAL_UARTEx_ReceiveToIdle_IT(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE);
/* USER CODE END 2 */usart.c
每次接收完数据(指溢出或空闲后)进入该回调,将缓冲区数据传回上位机,然后重新准备下一次接收
/* 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前调用一次以启动接收:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)RxBuffer, RX_BUFFER_SIZE);usart.c
/* 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 波特率
DMA在循环模式下工作时,如果在大规模传输数据时仍旧空闲中断(或传输完成中断)会有风险,因为当DMA传输数据完成,CPU介入开始拷贝DMA通道缓冲区数据时,如果此时UART继续有数据进来,DMA继续搬运数据到缓冲区,就有可能将数据覆盖,因为DMA数据搬运是不受CPU控制的,即使你关闭了CPU中断。
因此严谨的做法需要建立双buffer,CPU和DMA各自使用一块内存交替访问,即乒乓缓存,处理流程为:
- DMA先将数据搬运到buf1,搬运完成通知CPU来拷贝buf1数据
- DMA将数据搬运到buf2,与CPU拷贝buf1数据不会冲突
- buf2数据搬运完成,通知CPU来拷贝buf2数据
- DMA继续开始拷贝新数据
STM32大多数型号不提供现成的双缓存机制,但提供“半满中断”,即数据搬运到buf大小的一半时,可以产生一个中断信号。基于这个机制,我们可以实现双缓存功能,只需将buf空间开辟大一点即可。
- DMA将数据搬运完成buf的前一半时,触发“半满中断”事件,Callback中通知CPU来拷贝buf前半部分数据
- DMA继续将数据搬运到buf的后半部分,与CPU拷贝buf前半部数据不会冲突
- buf后半部分数据搬运完成,触发“溢满中断”,Callback通知CPU来拷贝buf后半部分数据
- DMA循环拷贝新数据
基于上述描述机制,DMA方式接收串口数据,有三种中断场景需要CPU去将buf数据拷贝到final中,分别是:
- DMA通道buf溢满(传输完成)场景,触发满溢中断(
HAL_UARTEx_RxEventCallback) - DMA通道buf半满场景,触发半满中断(
HAL_UART_RxHalfCpltCallback) - 串口空闲中断场景,触发空闲中断(
UART_FLAG_IDLE)


也就是说,代码总共需要考虑以下几种情况:
- 数据量未达到半满,触发空闲中断
- 数据量达到半满,未达到满溢,先触发半满中断,后触发空闲中断
- 数据量刚好达到满溢,先触发半满中断,后触发满溢中断
- 数据量大于缓冲区长度,DMA循环覆盖溢出的字节
对于情况1:在空闲中断中拷贝全部数据
对于情况2:在半满中断中通知CPU拷贝一半的数据,DMA继续接收剩下的数据,最后在空闲中断中拷贝剩下的数据
对于情况3:在半满中断中通知CPU拷贝一半的数据,DMA继续接收剩下的数据,最后在满溢中断中拷贝剩下的一半数据
对于情况4:综合处理
下面这个代码经过试验不太好使,空闲中断和满溢中断似乎有冲突,导致接收完成后MCU进不去满溢中断。
/* 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); } }/* 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 */继续调试,删掉除打印测试信息以外的所有功能性代码:
/* 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会先触发空闲中断,而空闲中断中有这一段代码:
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这种情况进行处理:
/* 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:
/* 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 */中断服务函数:
/* 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回调。
/** * @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会同步更新。
原先的三个中断中的代码可以合到一个中实现:
/* 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循环而引起的溢出可通过取模操作来处理。
验证:
-
输入“12345678\r\n”:

-
输入“123456789abcdefghijklmn”:

-
先输入”123”,再输入”1234567“,再输入”12”,再输入”123456789”:

验证成功。