我们已经一起了解和使用了串口的中断模式进行数据收发,可以省出不少CPU时间,那有没有更省CPU的方案呢?另外,如何收发不定长数据呢?接下来就让我们一起来学习一下吧。
通过上次的学习,我们知道了在串口的中断模式下,发送数据寄存器每传递一字节数据给发送移位寄存器,都会触发一次发送数据寄存器空中断,把CPU叫回来,从内存变量中搬运下一位数据到发送数据寄存器。

接收数据寄存器每从接收移位寄存器获得一字节数据,都会触发一次,接收数据寄存器非空中断,把CPU叫回来,将数据搬运到内存变量中。

虽然这两个中断的处理流程HAL库已经帮我们搞定了,我们要做的只是调用相应的函数进行发送或者接收,以及在接收到最后一字节数据后,在回调函数中对数据进行分析处理。

但对于CPU本身来说,却是屡屡被打败,疲于在中断搬运数据于处理正常代码间辗转。那有没有一种可能,给CPU找一个小助手,让它来帮着在寄存器与内存间搬运数据呢?答案是有的,它的名字就叫DMA,Direct Memory Access,直接内存访问。DMA的作用非常简单,只要我们创建一条DMA通道,告诉DMA将数据从哪里搬到哪里,DMA就会在合适的时机帮我们进行内存搬运,等全部搬运完成,再通过中断提醒我们。

例如我们只需要为串口的接收和发送创建两条DMA通道,就可以让DMA帮着在串口的寄存器与内存变量间搬运数据了。

DMA通道的创建也非常简单,回到之前的串口工程,来到Cube MX界面,在对USART2的配置界面,我们可以看到除了最基本的参数配置,还有一些其他的配置选项卡,例如NVIC Settings是与USART2相关的中断向量,而DMA Settings就是对USART2的DMA配置,点击Add按钮,就可以添加一个DMA通道,然后为新的通道选择功能,例如选择USART2_TX就是指此DMA用于USART2的串口发送。

然后剩下的参数Cube MX就自动帮我们填好了,DMA1的7号通道是专门为USART2的发送提供服务的通道,数据搬运方向被设置为从内存(Memory)向外设(Peripheral)搬运,也就是从内存变量向发送数据寄存器进行搬运,优先级默认是低,可以进行选择,不过一般默认就好了。

是否进行数据地址的自增,对于寄存器来说只有一字节的长度,那地址也就不需要变化,而从内存变量中搬运是依次搬运,而不是只从一个地方取数据,所以也就勾选了地址自增,每次搬运的数据宽度,我们每次发送都是1字节,所以就是默认的1字节即可,而模式我们也是选择Normal正常模式,循环模式我们在日后更为复杂的DMA例子中再来探索。

所以说,我们只需要指定此DMA通道是用于USART2发送,剩下的Cube MX就自动帮我们设置好了,大大提高了我们的开发效率,接下来我们不妨在为USART2的接收创建一个DMA通道。
点击Add,然后选择USART2_RX,分析就和USART2_TX一样,最后保存并生成代码。

DMA模式的串口发送函数非常简单,只需要把中断发送函数的_IT改为_DMA就好了,参数依旧是要用哪个串口发送,要发的变量,以及要发送的字节数。接收函数也是一样,将中断接收函数的_IT改为_DMA就好了,别忘了程序刚开始初始化的那里也要改一下。
HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
参数:
huart:UART 外设的句柄,用于标识具体的 UART 实例。
pData:待发送数据的缓冲区指针。
Size:待发送数据的长度。
功能:
HAL_UART_Transmit_DMA 函数通过 DMA 方式发送数据到 UART 外设。DMA(Direct Memory Access)是一种直接内存访问技术,可以在不经过 CPU 的情况下实现外设与内存之间的数据传输,提高数据传输效率。
HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
参数:
huart:UART 外设的句柄,用于标识具体的 UART 实例。
pData:接收数据的缓冲区指针。
Size:接收数据的长度。
功能:
HAL_UART_Receive_DMA 函数通过 DMA 方式接收 UART 数据。DMA(Direct Memory Access)是一种直接内存访问技术,可以在不经过 CPU 的情况下实现外设与内存之间的数据传输,提高数据传输效率。

然后我们编译下载,看看程序是否依旧。通过演示我们可以看到还是相同的效果。
需要注意的是,即使是使用了DMA,其实还是有中断参与其中的,例如RxCpltCallback函数还是由中断触发的,当然,这次就不是串口的中断了,而是DMA的,传输完成中断。
|-------|------------------------|------------|
| 中断模式 | 发送数据寄存器空中断 接收数据寄存器非空中断 | 每接收/发送一字节时 |
| DMA模式 | DMA传输完成中断 | 接收/发送完成时 |
说完了DMA,我们来看看如何接收不定长数据,其实,接收不定长数据非常简单,主要靠的就是串口空闲(Idle)中断,此中断的触发条件与接收的字节数无关,只有当RX引脚上无后续数据进入,也就是串口接收从忙碌转为空闲时才会触发,因而我们可以认为空闲(Idle)中断发生时,就是一帧数据包接收完成了,在此时对数据进行分析处理即可

当然,这一块贴心的HAL库也帮我们包好了,我们只需要将串口接收函数替换为HAL库为我们提供的一个扩展函数HAL_UARTEx_ReceiveToIdle_DMA,可以注意到ReceiveToldle函数也是有阻塞、中断和DMA三个版本的。

HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
参数:
huart:UART 外设的句柄,用于标识具体的 UART 实例。
pData:接收数据的缓冲区指针。
Size:接收数据的长度。
功能:
HAL_UARTEx_ReceiveToIdle_DMA 函数通过 DMA 方式接收 UART 数据,直到检测到空闲线(IDLE)事件。该函数会设置接收类型为 HAL_UART_RECEPTION_TOIDLE,然后启动 DMA 接收,并使能 UART 的空闲中断(IDLEIE)。当检测到空闲线事件时,会触发中断处理函数 HAL_UART_IRQHandler,并在其中调用事件回调函数 HAL_UARTEx_RxEventCallback。
我们这里不妨选用DMA,函数名中Ex代表扩展 Idle代表空闲中断,ReceiveToldle函数依旧是三个参数,第一个参数依旧是要用于接收的串口的指针地址,这里当然还是&huart2,第二个参数也还是要用来接收的变量,由于是接收不定长数据,这次我们不妨把数组长度写大一点,我们到PV(私有变量)注释对处,将receiveData数组改大一点,

值得注意的是ReceiveToldle函数的第三个参数,并不是与普通接收函数一样,填写我们想要接收的数据长度,而是填写一次能接收的最大数据长度,一般就是填写接收数组的长度,来避免接收的数据太长来导致数组越界,当然为了方便,我们也可以用sizeof函数取一下接收数组的长度,然后ReceiveToIdle函数对应的回调函数,并不是我们之前使用的RxCpltCallback了,我们还是回到stm32f1xx_it.c文件,找到最后USART2_IRQHandler,按住Ctrl点击进入HAL_UART_IRQHandler所在的stm32f1xx_hal_uart.c,找到HAL_UARTEx_RxEventCallback回调函数。为了方便,建议大家可以在旁边的大纲视图(outline)中寻找。

可以看到RxEventCallback与RxCpltCallback一样,也是个可以重新定义的__weak函数,我们复制其定义,回到main.c进行重新定义。RxEventCallback与RxCpltCallback的一个重要区别,就是多了一个入参Size,因为之前RxCpltCallback是接收定长数据,我们已知了数据长度,但RxEventCallback用于接收不定长数据,所以我们需要通过Size入参,来得知本次到底接收了几字节的数据。
使用中断的一个好习惯是,首先确认是谁触发了回调函数。所以虽然本次工程只用了一个串口,我们还是先判断一下进入此Callback函数的是否是huart2,别忘了在回调函数中重新启动接收,然后我们将收到的数据发送到串口来进行测试,前两个参数照常填写,第三个参数是要发送的数据长度,我们需要填写RxEventCallback函数的Size入参,来发送与接收相同的字节数。
我们来编译下载看看效果,首先我们在发送R1,发现数据返回来了,不过红色小灯没有亮起,这说明使用RxEventCallback函数后,不在调用RxCpltCallback回调,而是使用了RxEventCallback回调函数,我们再试着将数据加长进行发送,数据返回正常,这说明我们完美实现了不定长数据接收,我们也可以试着发送和接收16进制数据,点击此按钮或者使用快捷键Ctrl+s可以对发送数据进行快速格式化,点击发送,也非常完美。

回到代码,有一个细节补充给大家,
这里启动接收,使用普通模式的HAL_UARTEx_ReceiveToIdle或者中断模式的HAL_UARTEx_ReceiveToldle_IT也都是可以的,但我们使用的DMA模式却有一个恼人之处,那就是除了串口空闲中断以外,DMA的"传输过半中断"也会触发RxEventCallback回调函数,就是说如果接收的数据量达到我们设置最大值一半的时候,也会触发一次RxEventCallback回调,这一机制在某些情况下确实有其实用之处。但是对于一般的常见场景来说,其实是比较烦人的,毕竟我们并不像在接收并未完成数据尚未完整时就对其进行处理。
我们刚刚并未察觉是因为我们将receiveData数据设置的比较大,发送的数据又比较短,并未触发DMA传输过半中断。像我们这样使用一个很大的接收数组虽然确实能解决问题,但也是治标不治本,此问题的正确解决方案是关闭"DMA传输过半中断",用于关闭DMA中断的语句是__HAL_DMA_DISABLE_IT,第一个参数我们填写DMA通道的指针地址,也就是USART2的RX DMA通道,第二个参数是要关闭的中断,我们填写DMA传输过半中断DMA_IT_HT(Half Transfer 过半传输),别忘了程序开始时也要关闭


#define __HAL_DMA_DISABLE_IT(__HANDLE__, __INTERRUPT__) (((__HANDLE__)->Instance->CR) &= ~(uint32_t)(__INTERRUPT__))
参数:
__HANDLE__:指向DMA_HandleTypeDef结构体的指针,该结构体包含特定DMA通道的配置信息。
__INTERRUPT__:要禁用的中断类型,可以是以下值之一:
DMA_IT_TC:传输完成中断
DMA_IT_HT:半传输完成中断
DMA_IT_TE:传输错误中断
DMA_IT_DME:直接模式错误中断
总结:
只需要在Cube MX里添加DMA通道

并把发送与接收函数的后缀改为DMA就可以进行DMA模式的串口收发了

把启动串口接收函数改为ReceiveToldle系列,并且将回调函数改为RxEventCallback,就可以实现不定长数据收发。