4. UART驱动移植:轮询模式、中断模式、DMA模式的HAL层封装与移植要点
UART这个外设,说简单也简单,说复杂也复杂。我见过不少工程师,轮询模式跑得飞起,一到中断就各种丢数据,到了DMA更是直接懵圈。其实这三种模式,本质上就是CPU参与数据传输的深度不同。咱们今天就把它们拆开揉碎了讲。
核心观点: HAL层的UART封装,不是简单地把寄存器操作包一层函数。你要做的是把“怎么发、怎么收”抽象成“发什么、收什么”,让上层应用完全不用关心底层硬件细节。
4.1 三种模式的核心差异
先看一张对比表,心里有个底。
| 特性 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(一直等) | 中(进进出出) | 低(几乎不占) |
| 实时性 | 差(阻塞) | 好(事件驱动) | 极好(硬件搬运) |
| 数据吞吐量 | 低 | 中 | 高 |
| 代码复杂度 | 简单 | 中等 | 较高 |
| 适用场景 | 调试、低速率 | 通用通信 | 大数据量、高速率 |
说白了,轮询就是你自己盯着邮箱,来一封信你拿一次。中断是邮递员按门铃,你去拿。DMA呢?是邮递员直接把信放到你桌上,你连门都不用开。
4.2 轮询模式的HAL封装
轮询模式最简单,但最容易写出“假封装”。我见过有人把while(!(USART->SR & TXE));直接塞进应用层,这哪叫封装?
正确的做法是这样:
// hal_uart.h
typedef struct {
uint32_t baudrate;
uint8_t data_bits; // 7 or 8
uint8_t stop_bits; // 1 or 2
uint8_t parity; // 0: none, 1: odd, 2: even
} UART_Config_t;
typedef enum {
UART_OK = 0,
UART_BUSY,
UART_TIMEOUT,
UART_ERROR
} UART_Status_t;
// hal_uart.c
UART_Status_t UART_Transmit_Poll(UART_Handle_t *huart,
const uint8_t *data,
uint32_t size,
uint32_t timeout) {
uint32_t tick_start = GetTick();
while (size--) {
// 等待发送寄存器空
while (!(huart->instance->SR & USART_SR_TXE)) {
if ((GetTick() - tick_start) > timeout) {
return UART_TIMEOUT;
}
}
huart->instance->DR = *data++;
}
// 等待发送完成
while (!(huart->instance->SR & USART_SR_TC)) {
if ((GetTick() - tick_start) > timeout) {
return UART_TIMEOUT;
}
}
return UART_OK;
}
我的习惯: 轮询函数一定要加timeout参数。别问我为什么,我曾经见过一个产品,因为串口线松了,整个系统卡死在发送循环里,看门狗都救不回来。
4.3 中断模式的HAL封装
中断模式的关键在于状态机。你不能让中断服务函数里做太多事,否则就会丢数据。我建议把中断处理拆成两层:
- 底层ISR: 只做一件事——把数据从寄存器搬到环形缓冲区,或者从缓冲区搬到寄存器。
- 上层回调: 通知应用层“数据来了”或者“数据发完了”。
看代码:
// 中断服务函数(在启动文件中注册)
void USART1_IRQHandler(void) {
UART_Handle_t *huart = &huart1;
if (huart->instance->SR & USART_SR_RXNE) {
uint8_t data = huart->instance->DR;
// 写入环形缓冲区
if (RingBuf_Write(&huart->rx_ring, data) == 0) {
// 通知应用层
huart->rx_callback(huart, data);
} else {
// 缓冲区溢出,置错误标志
huart->error_flags |= UART_ERROR_OVERFLOW;
}
}
if (huart->instance->SR & USART_SR_TC) {
// 发送完成,通知应用层可以发下一包了
huart->tx_complete_callback(huart);
}
}
// 应用层注册回调
void UART_RegisterRxCallback(UART_Handle_t *huart,
void (*callback)(UART_Handle_t *, uint8_t)) {
huart->rx_callback = callback;
}
避坑指南: 我曾经在中断里直接调用
printf做调试,结果递归进中断,栈直接爆了。记住:ISR里不要做任何可能引起阻塞或重入的操作。打印日志?用DMA+标志位,等出了中断再处理。
4.4 DMA模式的HAL封装
DMA模式是性能最好的,但也是最容易出问题的。核心要点有三个:
- DMA与UART的握手配置: 要确保DMA的请求信号和UART的触发条件匹配。比如UART的RXNE信号要能触发DMA的传输请求。
- 缓冲区管理: DMA传输是连续的,你要用双缓冲(ping-pong buffer)或者环形缓冲区来避免数据覆盖。
- 传输完成回调: DMA传输完成会触发中断,你要在这个中断里处理数据,然后重新启动下一次传输。
一个典型的DMA接收配置流程:
// 初始化DMA
void UART_DMA_Init(UART_Handle_t *huart) {
// 1. 配置DMA通道
DMA_Config_t dma_cfg = {
.direction = DMA_PERIPH_TO_MEMORY,
.periph_addr = (uint32_t)&huart->instance->DR,
.memory_addr = (uint32_t)huart->dma_rx_buf,
.size = DMA_BUFFER_SIZE,
.circular = 1 // 循环模式
};
DMA_Init(huart->dma_rx_channel, &dma_cfg);
// 2. 使能UART的DMA接收请求
huart->instance->CR3 |= USART_CR3_DMAR;
// 3. 使能DMA传输完成中断
DMA_EnableIT_TC(huart->dma_rx_channel);
// 4. 启动DMA
DMA_Start(huart->dma_rx_channel);
}
// DMA传输完成中断
void DMA_RX_IRQHandler(void) {
UART_Handle_t *huart = &huart1;
if (DMA_GetITStatus(huart->dma_rx_channel, DMA_IT_TC)) {
// 计算当前接收到的数据量
uint32_t received = DMA_GetCurrDataCounter(huart->dma_rx_channel);
uint32_t processed = DMA_BUFFER_SIZE - received;
// 通知应用层处理数据
huart->dma_rx_callback(huart, huart->dma_rx_buf, processed);
// 清除中断标志
DMA_ClearITPendingBit(huart->dma_rx_channel, DMA_IT_TC);
}
}
关键点: DMA的循环模式(circular mode)配合双缓冲,可以实现“零拷贝”接收。数据一直在DMA和内存之间流转,CPU只需要在缓冲区切换时处理一下。这是高性能通信的基础。
4.5 移植要点总结
不管你是从STM32换到GD32,还是从NXP换到AT32,UART的HAL层移植都遵循同样的套路:
| 移植步骤 | 具体操作 | 常见坑 |
|---|---|---|
| 1. 寄存器映射 | 确认新MCU的UART寄存器地址和位定义 | 不同厂商的寄存器命名可能不同(如SR vs STAT) |
| 2. 时钟配置 | 使能UART和GPIO的时钟,配置波特率发生器 | 时钟源频率不同,波特率计算公式可能不一样 |
| 3. GPIO复用 | 配置TX/RX引脚为复用功能 | 有些MCU需要额外配置上下拉 |
| 4. 中断向量 | 在启动文件中注册ISR,或者使用中断控制器 | 中断优先级分组要统一 |
| 5. DMA通道 | 配置DMA请求映射,确认DMA与UART的握手信号 | DMA的传输宽度(8/16/32位)必须与UART数据寄存器匹配 |
嗯,这里要注意:移植不是重写。你的HAL层接口应该保持完全一致,只是底层实现换了。这样应用层代码一行都不用改。
4.6 三种模式的选择策略
我个人的经验是:
- 调试阶段: 用轮询。简单、可控,出问题一眼就能看出来。
- 通用通信(如AT指令、Modbus): 用中断。响应快,CPU还能干别的。
- 大数据量(如固件升级、日志输出): 用DMA。CPU几乎零负担,吞吐量拉满。
但别忘了,这三种模式可以在同一个项目中混用。比如:初始化时用轮询发AT指令,正常运行时用中断收数据,固件升级时切换到DMA。HAL层封装得好,切换就是改一个配置参数的事。
我的建议: 刚开始做HAL层封装时,先只实现轮询模式。跑通了,再往上叠加中断。最后才上DMA。每一步都验证通过,再走下一步。这样出了问题,你知道该查哪一层。
好了,UART的三种模式就讲到这里。记住:封装的是接口,不是实现。你的HAL层越抽象,移植就越轻松。下次换个MCU,你只需要改底层那几行寄存器操作,上层代码纹丝不动——这才是HAL层设计的精髓。