4. MCU外设驱动封装实战(UART):UART的HAL层封装
好,咱们进入第四章。这一章我打算跟你聊聊UART的HAL层封装。说实话,UART这玩意儿在单片机开发里太常见了,但很多人写驱动的方式,我看了真是着急。
你想想看,一个项目里,UART可能用来打印日志、跟蓝牙模块通信、接GPS、甚至做固件升级。如果每次换芯片都要重写一遍收发逻辑,那得多痛苦?我早期做项目时就吃过这个亏——换个MCU型号,整个串口代码几乎重写,调试了整整两天。
所以,咱们今天的目标很明确:把UART的轮询、中断、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;
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空等的情况。
实现思路是这样的:
- 调用 UART_Transmit_IT 时,只配置好缓冲区指针和长度,然后使能发送中断。
- 每次进入中断,发送一个字节,更新计数。
- 全部发完后,调用回调函数通知上层。
// 中断发送启动函数
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); // 通知上层
}
}
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层整体架构。你看一眼就能明白各层之间的关系:
4.6 避坑指南
最后,分享几个我实际项目中遇到的坑:
有些MCU的DMA要求源地址和目的地址必须按字对齐(4字节对齐)。我曾在STM32F4上调试DMA接收,数据总是错位,最后发现是缓冲区地址没对齐。解决办法:用 __attribute__((aligned(4))) 声明缓冲区。
如果UART中断优先级设置不当,可能导致数据丢失。比如,高优先级中断长时间占用CPU,UART接收缓冲区溢出。我建议UART中断优先级不要设得太低,至少比定时器中断高。
好了,这一章的内容就到这。UART的HAL层封装,说白了就是给上层提供一个干净的接口,把底层那些寄存器操作、中断处理、DMA配置都藏起来。你只要把接口设计好,以后换芯片、换模式,都只是换底层实现而已。