4、UART抽象层:HAL_UART_Init/Transmit/Receive函数详解、中断与DMA模式下的UART收发

UART,说白了就是串口。做嵌入式的人,没人离得开它。调试打印、跟传感器通信、跟蓝牙模块对接,全得靠它。STM32的HAL库把UART封装得挺完善,但你要是只停留在“调库”的层面,遇到性能瓶颈或者诡异bug,就会很被动。

这一章,我带你深入看看HAL_UART的核心函数,再聊聊中断和DMA这两种收发模式。嗯,这里要注意,理解清楚它们的差异,你才能在项目中选对路子。

4.1 HAL_UART_Init:初始化背后的门道

调用HAL_UART_Init()之前,你得先搞定两个东西:一个是UART_HandleTypeDef结构体,另一个是GPIO的复用配置。我个人习惯把这两步放在一个独立的UART_Init()函数里,方便后期移植。

来看看这个结构体里最关键的几个成员:

成员 说明 避坑点
Instance 指定USART外设(如USART1、USART2) 别跟LPUART搞混,时钟源不同
Init.BaudRate 波特率,常用115200、9600 高速时注意时钟精度,别超±2%
Init.WordLength 数据位,8位或9位 9位模式常用于多机通信
Init.StopBits 停止位,1位或2位 2位停止位会增加传输开销
Init.Parity 校验位,无/奇/偶校验 使能校验后,数据位要相应调整
Init.Mode TX/RX模式选择 只收不发时,可以关掉TX省电
Init.HwFlowCtl 硬件流控(RTS/CTS) 模块间通信常用,但线多了两根
我曾经在一个项目中,因为忽略了GPIO的复用配置,直接调了HAL_UART_Init(),结果串口死活没输出。查了半天才发现,是GPIO的AF模式没设对。记住:HAL_UART_MspInit()里一定要把时钟和GPIO初始化好。

4.2 HAL_UART_Transmit:发送数据的正确姿势

发送函数HAL_UART_Transmit(),原型长这样:

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart,
                                    uint8_t *pData,
                                    uint16_t Size,
                                    uint32_t Timeout);

四个参数,简单明了。但有几个细节,我建议你留意一下:

  • Timeout参数:单位是毫秒。如果设为HAL_MAX_DELAY,函数会一直等,直到发完。这在调试时很方便,但产品代码里最好设个具体值,防止死锁。
  • 阻塞模式:这个函数是阻塞的,也就是说,数据没发完之前,CPU就耗在这儿了。如果你在主循环里频繁调用,会影响其他任务的响应。
  • 数据长度:Size是字节数,不是位数。别搞混了。
小技巧:发送短数据(比如几十个字节)时,阻塞模式完全够用。但如果你要发几百上千字节,或者系统对实时性要求高,就该考虑中断或DMA了。

4.3 HAL_UART_Receive:接收数据的陷阱

接收函数HAL_UART_Receive(),跟发送是对称的:

HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart,
                                   uint8_t *pData,
                                   uint16_t Size,
                                   uint32_t Timeout);

这里有个大坑——它必须收到指定数量的字节才会返回。你设了Size=10,那它就死等10个字节。如果对方只发了5个,你就永远卡在那儿了。

我在项目中遇到过这种情况:跟一个不靠谱的传感器通信,它偶尔会丢字节。结果我的接收函数就卡死了,整个系统跟着瘫痪。后来怎么解决的?改用中断接收,配合超时判断。

4.4 中断模式下的UART收发

中断模式,说白了就是“发完/收到一个字节,就通知你一下”。CPU不用死等,可以干别的事。

发送用HAL_UART_Transmit_IT(),接收用HAL_UART_Receive_IT()。调用后,函数立即返回,数据在后台传输。传输完成时,会触发回调函数:

  • HAL_UART_TxCpltCallback():发送完成回调
  • HAL_UART_RxCpltCallback():接收完成回调

你想想看,这比阻塞模式灵活多了吧?但要注意一点:中断频率。波特率115200时,每字节大约87微秒就触发一次中断。如果系统里中断很多,频繁进出中断会消耗不少CPU时间。

核心思路:中断模式适合“不定长”或“交互式”通信。比如你发一个命令,等对方回复,回复长度不确定。这时用中断接收,配合一个空闲中断或超时定时器,就能优雅地处理。

4.5 DMA模式下的UART收发

DMA模式,是真正的“解放CPU”。数据从内存到外设,或者从外设到内存,全由DMA控制器搞定,CPU只管睡大觉。

调用方式:

  • HAL_UART_Transmit_DMA()
  • HAL_UART_Receive_DMA()

DMA模式有几个明显的优势:

  • CPU占用极低:传输过程中,CPU几乎零开销。
  • 适合大数据量:比如日志输出、固件升级、文件传输。
  • 配合环形缓冲区:可以实现“一边收一边处理”的流水线操作。

但DMA也不是万能的。我记得有一次,我用DMA接收不定长数据,结果发现DMA不知道什么时候该停止——它只管收,不管“收够了没”。后来我配合了UART的空闲中断(IDLE Line Interrupt),才完美解决。

注意:DMA模式需要额外的DMA通道,会占用DMA资源。STM32的DMA通道是有限的,用之前先看看你的芯片有几个DMA控制器,每个控制器有几个通道。

4.6 三种模式对比:选型指南

为了让你一目了然,我把三种模式的核心差异整理成了一张表:

特性 阻塞模式 中断模式 DMA模式
CPU占用 高(全程等待) 中(每字节中断) 极低(后台传输)
实时性 差(阻塞其他任务) 好(可响应其他中断) 最好(几乎无干扰)
适用场景 短数据、调试 交互式、不定长 大数据量、高速率
代码复杂度 低 中(需处理回调) 高(需配置DMA)
资源消耗 无额外资源 占用NVIC中断 占用DMA通道+中断

说白了,没有银弹。你得根据项目需求来选。我个人习惯是:调试用阻塞,交互用中断,大数据用DMA。

4.7 UART收发模式选择流程图

下面这张图,帮你快速决策:

UART收发模式选择流程图 UART收发模式选择流程 开始选择模式 数据量大小? (单次 < 50字节 或 > 50字节) 小数据 阻塞模式 大数据 DMA模式 实时性要求高? (能否容忍阻塞等待?) 是 中断模式 否 阻塞模式 数据长度固定? (接收时是否知道要收多少字节) 固定 DMA/中断 不固定 中断+空闲中断 确定模式,开始编码 说明:实际项目中,建议先评估数据量和实时性,再选择模式。DMA+空闲中断是处理不定长数据的最佳组合。 —— 来自一位踩过坑的嵌入式工程师

4.8 实战建议:如何写出可移植的UART抽象层

最后,分享一点我的经验。写UART抽象层时,别把代码写死。我建议你这样做:

  1. 定义统一的接口:比如UART_Send(uint8_t *data, uint16_t len),内部再根据配置选择阻塞/中断/DMA。
  2. 用回调函数解耦:接收完成时,调用一个注册的回调,而不是直接处理数据。这样上层逻辑和底层驱动就分开了。
  3. 预留超时机制:不管是阻塞还是中断,都要有超时保护。我曾经因为没加超时,导致系统在接收时死锁,最后只能看门狗复位。
  4. 环形缓冲区是标配:中断或DMA接收的数据,先丢进环形缓冲区,主循环再慢慢取。这样不会丢数据,也不会阻塞中断。
嗯,这里再啰嗦一句:移植代码时,最怕的就是硬件依赖。把UART的初始化参数(波特率、引脚、模式)放到一个配置结构体里,换平台时只改这个结构体,其他代码不动。这才是“抽象层”该有的样子。