4. MCU外设驱动封装实战(UART):UART的HAL层封装

好,咱们进入第四章。这一章我打算跟你聊聊UART的HAL层封装。说实话,UART这玩意儿在单片机开发里太常见了,但很多人写驱动的方式,我看了真是着急。

你想想看,一个项目里,UART可能用来打印日志、跟蓝牙模块通信、接GPS、甚至做固件升级。如果每次换芯片都要重写一遍收发逻辑,那得多痛苦?我早期做项目时就吃过这个亏——换个MCU型号,整个串口代码几乎重写,调试了整整两天。

所以,咱们今天的目标很明确:把UART的轮询、中断、DMA三种模式,封装成一套统一的HAL接口。上层应用只管调用,底层实现随便换。

核心思想: 应用层不关心你是用轮询还是DMA,它只关心“发数据”和“收数据”。HAL层就是中间那个翻译官。

4.1 为什么需要HAL层封装?

说白了,HAL层就是硬件抽象层。它的作用是把MCU厂商提供的底层寄存器操作,包装成一组通用的API。

我见过不少工程师,直接在main函数里操作UART寄存器。比如这样:

// 直接操作寄存器——换芯片就废了
UART1->DR = 'A';
while(!(UART1->SR & (1 << 6)));  // 等待发送完成

这种写法,换个芯片就得全部重写。而HAL层封装后,上层代码应该是这样的:

// HAL层封装后——换芯片只需改底层
UART_Transmit(&huart1, (uint8_t*)"Hello", 5, 1000);

看到了吗?接口完全一样,底层实现可以随时替换。这就是分层的好处。

4.2 HAL层接口设计

我个人习惯,把UART的HAL层接口分成三组:初始化、发送、接收。每种模式(轮询/中断/DMA)都提供对应的函数。

先看接口定义:

/* ========== UART HAL层接口 ========== */

// 初始化
void UART_Init(UART_HandleTypeDef *huart, uint32_t baudrate);

// 轮询模式
HAL_StatusTypeDef UART_Transmit_Polling(UART_HandleTypeDef *huart, 
                                         uint8_t *pData, uint16_t Size, 
                                         uint32_t Timeout);
HAL_StatusTypeDef UART_Receive_Polling(UART_HandleTypeDef *huart, 
                                        uint8_t *pData, uint16_t Size, 
                                        uint32_t Timeout);

// 中断模式
HAL_StatusTypeDef UART_Transmit_IT(UART_HandleTypeDef *huart, 
                                    uint8_t *pData, uint16_t Size);
HAL_StatusTypeDef UART_Receive_IT(UART_HandleTypeDef *huart, 
                                   uint8_t *pData, uint16_t Size);

// DMA模式
HAL_StatusTypeDef UART_Transmit_DMA(UART_HandleTypeDef *huart, 
                                     uint8_t *pData, uint16_t Size);
HAL_StatusTypeDef UART_Receive_DMA(UART_HandleTypeDef *huart, 
                                    uint8_t *pData, uint16_t Size);

// 中断/DMA回调(由底层调用,上层实现)
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart);
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);

这里有个关键点:UART_HandleTypeDef 这个结构体。它包含了UART的所有状态信息,比如当前波特率、收发缓冲区指针、数据长度、模式标志等。我建议你把它定义成:

typedef struct {
    USART_TypeDef *Instance;    // 寄存器基地址
    uint32_t       Baudrate;    // 波特率
    uint8_t       *pTxBuffPtr;  // 发送缓冲区指针
    uint16_t       TxXferSize;  // 发送数据长度
    uint16_t       TxXferCount; // 剩余发送字节数
    uint8_t       *pRxBuffPtr;  // 接收缓冲区指针
    uint16_t       RxXferSize;  // 接收数据长度
    uint16_t       RxXferCount; // 剩余接收字节数
    volatile uint8_t TxState;   // 发送状态
    volatile uint8_t RxState;   // 接收状态
    volatile uint8_t Mode;      // 当前模式:轮询/中断/DMA
} UART_HandleTypeDef;
我的经验: 状态标志一定要用 volatile 修饰。我在调试一个DMA接收时,发现状态位一直不更新,查了半天才发现是编译器优化把变量缓存到寄存器里了。加上 volatile 立刻解决。

4.3 三种模式实现要点

咱们分别看看三种模式怎么实现。嗯,这里要注意,每种模式的实现思路完全不同。

4.3.1 轮询模式

轮询模式最简单,就是死等。适合短数据、非实时场景。

HAL_StatusTypeDef UART_Transmit_Polling(UART_HandleTypeDef *huart,
                                         uint8_t *pData, uint16_t Size,
                                         uint32_t Timeout) {
    uint32_t tickstart = GetTick();
    
    while(Size--) {
        // 等待发送寄存器空
        while(!(huart->Instance->SR & USART_SR_TXE)) {
            if((GetTick() - tickstart) > Timeout) {
                return HAL_TIMEOUT;  // 超时退出
            }
        }
        huart->Instance->DR = (*pData++ & 0xFF);
    }
    
    // 等待发送完成
    while(!(huart->Instance->SR & USART_SR_TC)) {
        if((GetTick() - tickstart) > Timeout) {
            return HAL_TIMEOUT;
        }
    }
    
    return HAL_OK;
}

这里我加了个超时机制。为什么?因为我在项目里遇到过硬件故障导致UART卡死的情况,没有超时的话,整个系统就挂在那了。

4.3.2 中断模式

中断模式适合不定长数据接收,或者不想让CPU空等的情况。

实现思路是这样的:

  1. 调用 UART_Transmit_IT 时,只配置好缓冲区指针和长度,然后使能发送中断。
  2. 每次进入中断,发送一个字节,更新计数。
  3. 全部发完后,调用回调函数通知上层。
// 中断发送启动函数
HAL_StatusTypeDef UART_Transmit_IT(UART_HandleTypeDef *huart,
                                    uint8_t *pData, uint16_t Size) {
    huart->pTxBuffPtr  = pData;
    huart->TxXferSize  = Size;
    huart->TxXferCount = Size;
    huart->TxState     = HAL_UART_STATE_BUSY_TX;
    
    // 使能发送寄存器空中断
    __HAL_UART_ENABLE_IT(huart, UART_IT_TXE);
    
    return HAL_OK;
}

// 中断服务函数(在UART中断向量中调用)
void UART_IRQHandler(UART_HandleTypeDef *huart) {
    if(__HAL_UART_GET_IT(huart, UART_IT_TXE)) {
        if(huart->TxXferCount == 0) {
            // 发送完成,关闭中断,调用回调
            __HAL_UART_DISABLE_IT(huart, UART_IT_TXE);
            __HAL_UART_ENABLE_IT(huart, UART_IT_TC);  // 等待发送完成
        } else {
            huart->Instance->DR = (*huart->pTxBuffPtr++ & 0xFF);
            huart->TxXferCount--;
        }
    }
    
    if(__HAL_UART_GET_IT(huart, UART_IT_TC)) {
        __HAL_UART_DISABLE_IT(huart, UART_IT_TC);
        huart->TxState = HAL_UART_STATE_READY;
        HAL_UART_TxCpltCallback(huart);  // 通知上层
    }
}
我曾经踩过的坑: 中断服务函数里千万不要做耗时操作!比如在中断里直接调用 printf 或者处理协议解析。正确的做法是:中断里只做数据搬运和状态更新,回调函数里用消息队列或标志位通知任务去处理。

4.3.3 DMA模式

DMA模式是最高效的,适合大数据量传输。CPU只需要配置好DMA,然后就可以去干别的事了。

实现要点:

  • DMA传输完成后,会产生DMA中断,在DMA中断里调用UART的回调。
  • UART的DMA发送,需要配置DMA的源地址为数据缓冲区,目的地址为UART的DR寄存器。
  • UART的DMA接收,需要配置DMA的源地址为UART的DR寄存器,目的地址为数据缓冲区。
HAL_StatusTypeDef UART_Transmit_DMA(UART_HandleTypeDef *huart,
                                     uint8_t *pData, uint16_t Size) {
    huart->pTxBuffPtr  = pData;
    huart->TxXferSize  = Size;
    huart->TxXferCount = Size;
    huart->TxState     = HAL_UART_STATE_BUSY_TX;
    huart->Mode        = UART_MODE_DMA;
    
    // 配置DMA:内存到外设,地址递增
    // ... DMA配置代码(略,因芯片而异)
    
    // 使能UART的DMA发送请求
    huart->Instance->CR3 |= USART_CR3_DMAT;
    
    // 启动DMA传输
    DMA_Start(huart->hdmatx, (uint32_t)pData, 
              (uint32_t)&huart->Instance->DR, Size);
    
    return HAL_OK;
}

4.4 三种模式对比

我整理了一个表格,方便你选型时参考:

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(一直等待) 中(仅中断时占用) 低(几乎不占用)
实时性 差(阻塞) 好(非阻塞) 好(非阻塞)
数据量 小(<100字节) 中(<1KB) 大(任意)
实现复杂度 简单 中等 复杂
适用场景 调试打印、简单控制 不定长接收、命令响应 文件传输、大数据流

4.5 整体架构图

下面这张图,是我设计的UART HAL层整体架构。你看一眼就能明白各层之间的关系:

UART HAL层分层架构 应用层(用户代码) HAL API层(统一接口) 模式分发层(根据调用选择模式) UART_Transmit / UART_Receive 轮询模式实现 中断模式实现 DMA模式实现 MCU硬件层(UART外设寄存器 + DMA控制器) 说明:上层应用只与HAL API层交互,底层实现可随时切换,无需修改应用代码

4.6 避坑指南

最后,分享几个我实际项目中遇到的坑:

坑1:DMA缓冲区对齐问题
有些MCU的DMA要求源地址和目的地址必须按字对齐(4字节对齐)。我曾在STM32F4上调试DMA接收,数据总是错位,最后发现是缓冲区地址没对齐。解决办法:用 __attribute__((aligned(4))) 声明缓冲区。
坑2:中断优先级配置
如果UART中断优先级设置不当,可能导致数据丢失。比如,高优先级中断长时间占用CPU,UART接收缓冲区溢出。我建议UART中断优先级不要设得太低,至少比定时器中断高。
我的习惯: 在HAL层里加一个调试开关。通过宏定义控制是否打印调试信息。这样在开发阶段可以打开调试,量产时关闭,不影响性能。

好了,这一章的内容就到这。UART的HAL层封装,说白了就是给上层提供一个干净的接口,把底层那些寄存器操作、中断处理、DMA配置都藏起来。你只要把接口设计好,以后换芯片、换模式,都只是换底层实现而已。