串口中断与DMA

在前一个章节中,我们说到了串口通信的轮询模式。而且,这个模式具有以下的几个缺点:第一,阻塞主程序运行。第二,只能接收固定长度。

而这几个缺点是由其工作原理决定的。要解决这些问题,就要升级其工作原理。因此,我们需要从轮询模式的工作原理开始说起。

串口轮询模式

在STM32中的每个串口的内部,都有两个寄存器:

  • 发送数据寄存器 TDR

  • 发送移位寄存器

当调用HAL_UART_Transmit(&huart1, data, 5, 100)的时候,STM32的CPU,会将这个要发送的data一个一个的放置到寄存器中。其放置的方法如下:

aaee1e7c4f4c1822e4a04d10528f336.jpg

发送移位寄存器中的数据,将会按照设定的比特率,转化成高低电平,从TX引脚输出。

415ce68ecbd03f51e43e5f648ec756f.jpg

当前一个数据被发送出去后,发送数据寄存器TDR中的数据,会被传到发送移位寄存器。这个时候,CPU就会不断的查询TDR中是否为空,若为空,则将后一个数据传入TDR中。

如果TDR为空,CPU就会不断查询,直到所有的数据都传完(传输了固定大小5),或者超过了设置的等待时间100

使用接收函数HAL_UART_Receive(&huart1, receiveData, 5, 100);的时候,也是同理。CPU会不断查询接收数据寄存器(RDR)中是否为空。

由此可见,弊端就暴露出来了:

在轮询模式下,无论是发送还是接收,CPU会一直忙碌,无法处理其他进程。

我们将一直等待使得程序暂时无法向下运行的状态为:堵塞。

那么,如何解决“堵塞”问题呢?STM32提供了串口中断模式。

串口中断模式

原理

当CPU将数据塞入发送数据寄存器TDR的时候,CPU就可以去处理其他的进程。

69e9d19874b5cc7b1a629383840e048.jpg

而当数据发出,发送数据寄存器TDR位空时,触发 发送寄存器空中断,CPU被唤回来,将后面的数据塞入发送数据寄存器TDR。

8c9f33cea6e423f4bce1c80396eb1c0.jpg

而当数据再进行接收的时候,每当接收数据寄存器(RDR)中有数据的时候,就会触发 接收寄存器非空中断,CPU被唤回,将寄存器中的 数据传入预先设置好的保存数据的变量空间。

1ef50eac23611fb06eecb0c81ffa377.jpg

代码实践

串口发送中断

这一原理不需要我们去手动编写。打开CubeMX,在System Core中找到NVIC,打开所选串口的中断:USART global interrupt

77bd9acd93cf35eeeda724aab9981bf.png

保存并生成代码,就可以了。

使用中断的方式实现串口数据发送,和轮询方式很类似,只是函数名略有不同。使用的函数是HAL_UART_Transmit_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
54
55
56
57
int main(void)
{
/* USER CODE BEGIN 1 */

/* USER CODE END 1 */


/* MCU Configuration--------------------------------------------------------*/

/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();

/* USER CODE BEGIN Init */

/* USER CODE END Init */

/* Configure the system clock */
SystemClock_Config();

/* USER CODE BEGIN SysInit */

/* USER CODE END SysInit */

/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_USART1_UART_Init();
MX_USART6_UART_Init();
/* USER CODE BEGIN 2 */

HAL_GPIO_WritePin(LED_R_GPIO_Port, LED_R_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_G_GPIO_Port, LED_G_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_B_GPIO_Port, LED_B_Pin, GPIO_PIN_RESET);
//enable receive interrupt and idle interrupt
//使能接收中断和空闲中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); //receive interrupt
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); //idle interrupt
__HAL_UART_ENABLE_IT(&huart6, UART_IT_RXNE); //receive interrupt
__HAL_UART_ENABLE_IT(&huart6, UART_IT_IDLE); //idle interrupt

/* USER CODE END 2 */

/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */

/* USER CODE BEGIN 3 */
//send data by usart
//串口发送数据
HAL_UART_Transmit(&huart1, "RoboMaster\r\n", 12, 100);
HAL_Delay(100);
HAL_UART_Transmit_IT(&huart6, "RoboMaster\r\n", 12);
HAL_Delay(100);
}
/* USER CODE END 3 */
}

上面的代码,是根据Robomaster官方的串口通信教程进行一些修改,对比了串口轮询模式和串口中断模式的发送方法。

可以看到,轮询发送函数和中断发送函数有一个区别,就是中断发送函数没有了超时时间。这是因为中断发送不需要长期占用CPU 使程序堵塞,所以也就不再需要设置超时时间了。

串口接收中断

我们接收到了串口的数据,就要对数据进行处理。那么我们该如何处理接收到的数据呢?

打开stm32f4xx_it.c文件,可以找到这样一个中断处理函数

1
2
3
4
5
6
7
8
9
10
void USART1_IRQHandler(void)
{
/* USER CODE BEGIN USART1_IRQn 0 */

/* USER CODE END USART1_IRQn 0 */
HAL_UART_IRQHandler(&huart1);
/* USER CODE BEGIN USART1_IRQn 1 */

/* USER CODE END USART1_IRQn 1 */
}

这就是USART1的中断向量对应的中断处理函数。我们希望把接收到的数据在函数中进行处理。但是,我们不能把代码写在USART1_IRQHandler(void)中了,因为USART的各种中断事件都被连接到同一个中断向量中。我们只希望普通接收中断后处理数据,而不希望别的中断也处理这些数据,否则会出错。

这个中断向量就叫做HAL_UART_IRQHandler(),里面还包括了刚才所说的发送寄存器空中断,还有线路空闲中断等等。而我们要找的,是接收寄存器非空中断

因此,我们来到HAL_UART_IRQHandler()函数,跨过这个函数,找到了如下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @brief Rx Transfer completed callbacks.
* @param huart Pointer to a UART_HandleTypeDef structure that contains
* the configuration information for the specified UART module.
* @retval None
*/
__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
/* Prevent unused argument(s) compilation warning */
UNUSED(huart);
/* NOTE: This function should not be modified, when the callback is needed,
the HAL_UART_RxCpltCallback could be implemented in the user file
*/
}

这里的Cplt是Complete的缩写,表示完成;Callback的意思是回调。接收完成回调函数,我们通常叫它:接收中断回调函数。

顾名思义,这个函数在接收完成的时候执行。

根据原理,接收过程当中,每次接收移位寄存器每完成一次储存,就会触发一次接收中断,但是并不是每次中断都需要处理。这个函数做了优化,只有当所有数据都被接收之后的中断,才会将数据进行处理。

而我们要写的处理代码,也将写在这个函数里。

注意到,这是一个弱定义。一般而言,要把函数放到一个专门的文件中进行定义。但是这一次,我们直接把它放main.c中,看起来更为友善一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */

/* USER CODE END PFP */

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
/**处理数据**/
UAL_UART_Receive_IT(&huart1, receiveData, 5);
}

/* USER CODE END 0 */

这样就可以完成串口中断接收了。

但是我们发现,虽然利用普通的串口中断处理可以解决堵塞问题,但是我们依旧是无法解决发送接收数据必须是定长的问题。那么接下来,就该了解串口的DMA模式了。

串口DMA模式

节省CPU

如果使用串口中断模式,CPU就会疲于不断的切换工作任务。那么是否能够给CPU找一个代工呢?我们使用直接内存访问。

DMA (Direct Memory Access),即直接内存访问。我们只需要给DMA一个带有起点和终点的通道,DMA就可以在合适的时机帮我们搬运内存。等全部搬运完成了,再通过中断提醒。

简单而言,就是创建这样两条通道:

image.png

DMA通道的配置依然可以通过CubeMX。

image.png

打开串口DMA设置,选择Add,可以添加TX和RX通道。剩下的,CubeMX已经帮我们配置好了,不需要修改。

如图所示的TX通道,设置方向位从内存向外设搬运(Memory To Peripheral),即从内存变量,向发送数据寄存器搬运。

而代码方面,只需要把串口中断的_IT改为_DMA就可以,同样需要中断回调函数。

接收不定长数据

接收不定长数据的解决根源,在于改变中断方式——使用串口空闲中断(idle)。其开启中断的方式是,只有当串口接收端从忙碌变为空闲的时候才会触发。因此可以认为串口空闲中断发生时,就是一帧的数据包接收完成了。

我们使用HAL_UARTx_ReceiveToIdle_DMA(huart, receiveData, max_size)其中max_size一般用sizeof(receiveData)取代。

这个函数的回调函数,就不是前面的RxCpltCallback了,而是如下的回调函数:

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.
*/
}

这里多了一个Size变量。因为原先的回调函数是定长的,已知变量。而现在是不定长的,故此需要传入长度。

注:当数据接收到一半的时候,也会触发一次RxEventCallback,对于一般场景很烦人。使用__HAL_DMA_DISABLE_IT(&hdma_usart1_tx, DMA_IT_HT)来取消这一机制。

代码实现

接下来,我们就看看Robomaster的官方代码是如何利用DMA的。

比较特别的是,Robomaster官方代码喜欢将回调函数直接写在IRQHandler里面,这样看起来确实是更简洁的。

等日后续上