4、UART驱动HAL封装:轮询、中断、DMA三种模式下的HAL接口设计
UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂在于不同场景下对吞吐量和实时性的要求天差地别。我这些年调过的UART驱动少说也有几十个,从8位MCU到多核ARM SoC,踩过的坑真不少。
今天咱们就聊聊UART的HAL层该怎么封装。核心就三件事:轮询模式、中断模式、DMA模式。这三种模式不是互斥的,而是互补的。好的HAL设计,应该让上层应用能无缝切换,而不需要关心底层寄存器细节。
核心设计原则:HAL层要提供统一的接口语义,屏蔽硬件差异。上层调用的API签名不变,底层根据配置选择不同的传输路径。
4.1 三种模式的核心差异
先看一张对比表,把三种模式的区别理清楚。我习惯在设计前先列这个表,避免后面接口设计走偏。
| 特性 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(忙等) | 低(仅进出中断) | 极低(硬件搬运) |
| 实时性 | 取决于轮询频率 | 中断响应延迟 | DMA传输完成中断 |
| 吞吐量 | 低(受CPU速度限制) | 中(受中断频率限制) | 高(接近总线极限) |
| 适用场景 | 调试、低速率、简单系统 | 中低速、事件驱动 | 大数据块、高速传输 |
| 代码复杂度 | 最低 | 中等 | 较高(需配置DMA控制器) |
| 内存开销 | 无额外 | 中断栈 | DMA描述符、缓冲区 |
说白了,没有银弹。轮询模式适合调试阶段快速验证,中断模式是大多数产品的默认选择,DMA模式则是高性能场景的必选项。我见过不少工程师一上来就上DMA,结果小数据包场景下DMA配置开销比传输本身还大,得不偿失。
4.2 HAL接口设计:统一抽象层
接口设计的关键在于分层清晰。我一般把UART的HAL分成三层:
- 底层硬件操作层:直接操作寄存器,与具体芯片绑定
- 模式调度层:根据配置选择轮询/中断/DMA路径
- 上层应用接口层:提供Send/Receive/Control等标准API
来看核心的数据结构和接口定义。这是我个人比较喜欢的一种风格,清晰且易于扩展。
/* UART配置结构体 */
typedef struct {
uint32_t baud_rate; /* 波特率 */
uint8_t data_bits; /* 数据位:5/6/7/8 */
uint8_t stop_bits; /* 停止位:1/2 */
uint8_t parity; /* 校验:NONE/ODD/EVEN */
uint8_t mode; /* 模式:POLL/IRQ/DMA */
uint32_t tx_buf_size; /* 发送缓冲区大小(中断/DMA模式) */
uint32_t rx_buf_size; /* 接收缓冲区大小(中断/DMA模式) */
void (*rx_callback)(uint8_t data); /* 接收回调 */
void (*tx_callback)(void); /* 发送完成回调 */
} uart_config_t;
/* UART句柄结构体 */
typedef struct {
uart_regs_t *regs; /* 寄存器基地址 */
uart_config_t config; /* 配置参数 */
uint8_t *tx_buf; /* 发送环形缓冲区 */
uint8_t *rx_buf; /* 接收环形缓冲区 */
volatile uint32_t tx_head, tx_tail; /* 发送缓冲区指针 */
volatile uint32_t rx_head, rx_tail; /* 接收缓冲区指针 */
volatile uint8_t tx_busy; /* 发送忙标志 */
volatile uint8_t rx_busy; /* 接收忙标志 */
} uart_handle_t;
个人经验:句柄结构体里的volatile关键字不能省。我在一个项目里就因为漏了tx_busy的volatile,导致中断和主循环之间数据竞争,排查了两天才找到问题。中断服务程序和主循环共享的变量,一定要加volatile。
标准API接口设计如下。注意看,三个模式共用同一套API签名,上层代码完全不需要知道底层用的是哪种模式。
/* 标准API接口 */
hal_status_t uart_init(uart_handle_t *handle, uart_config_t *config);
hal_status_t uart_send(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout);
hal_status_t uart_receive(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout);
hal_status_t uart_send_async(uart_handle_t *handle, uint8_t *data, uint32_t len);
hal_status_t uart_receive_async(uart_handle_t *handle, uint8_t *data, uint32_t len);
uint32_t uart_available(uart_handle_t *handle); /* 获取可读数据长度 */
hal_status_t uart_flush(uart_handle_t *handle); /* 清空缓冲区 */
hal_status_t uart_deinit(uart_handle_t *handle);
你可能会问,为什么要有同步和异步两套接口?嗯,这里有个设计考量:同步接口(带timeout)适合轮询模式和简单的阻塞调用;异步接口(不带timeout)适合中断和DMA模式,数据收发在后台进行,通过回调通知上层。
4.3 轮询模式实现要点
轮询模式最简单,但容易写错。核心就是死等状态寄存器。我见过有人用while(1)不加超时保护,一旦硬件异常,整个系统就卡死了。
/* 轮询模式发送实现 */
hal_status_t uart_send_poll(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
uint32_t timeout;
for (uint32_t i = 0; i < len; i++) {
/* 等待发送缓冲区空,带超时保护 */
timeout = UART_POLL_TIMEOUT;
while (!(handle->regs->SR & UART_SR_TXE)) {
if (--timeout == 0) {
return HAL_TIMEOUT; /* 超时返回 */
}
}
handle->regs->DR = data[i];
}
/* 等待发送完成(最后一个字节) */
timeout = UART_POLL_TIMEOUT;
while (!(handle->regs->SR & UART_SR_TC)) {
if (--timeout == 0) {
return HAL_TIMEOUT;
}
}
return HAL_OK;
}
避坑指南:我曾经在一个项目中,轮询发送时只检查了TXE(发送缓冲区空),没检查TC(发送完成)。结果最后一个字节还没从移位寄存器发出去,我就关了UART时钟,导致数据丢失。记住:TXE表示数据进了移位寄存器,TC表示数据已经从引脚上发出去了。
4.4 中断模式实现要点
中断模式的核心是环形缓冲区。生产者(中断服务程序)往缓冲区写数据,消费者(主循环)从缓冲区读数据。设计好环形缓冲区的读写指针是关键。
/* 中断模式接收中断服务程序 */
void uart_rx_irq_handler(uart_handle_t *handle)
{
uint8_t data;
uint32_t next_tail;
/* 读取数据 */
data = handle->regs->DR;
/* 计算下一个尾指针位置 */
next_tail = (handle->rx_tail + 1) % handle->config.rx_buf_size;
/* 检查缓冲区是否满 */
if (next_tail != handle->rx_head) {
handle->rx_buf[handle->rx_tail] = data;
handle->rx_tail = next_tail;
/* 调用上层回调 */
if (handle->config.rx_callback) {
handle->config.rx_callback(data);
}
} else {
/* 缓冲区溢出,记录错误 */
handle->overflow_flag = 1;
}
}
中断模式有个常见问题:中断频率过高。如果波特率是115200,每接收一个字节就触发一次中断,那每秒要进中断约11520次。每次中断都有上下文切换开销,CPU占用率不容忽视。这时候就该考虑DMA了。
4.5 DMA模式实现要点
DMA模式把数据搬运工作交给硬件,CPU只在传输完成时收到一次中断。对于大数据块传输,效率提升非常明显。
/* DMA模式配置示例 */
hal_status_t uart_dma_config(uart_handle_t *handle)
{
/* 配置DMA通道 */
dma_channel_config_t dma_cfg;
/* 发送通道:内存到外设 */
dma_cfg.src_addr = (uint32_t)handle->tx_buf;
dma_cfg.dst_addr = (uint32_t)&handle->regs->DR;
dma_cfg.src_inc = DMA_ADDR_INC_ENABLE; /* 源地址递增 */
dma_cfg.dst_inc = DMA_ADDR_INC_DISABLE; /* 目标地址固定 */
dma_cfg.data_width = DMA_DATA_WIDTH_8BIT;
dma_cfg.transfer_mode = DMA_MODE_NORMAL;
dma_channel_init(DMA_CH_UART_TX, &dma_cfg);
/* 接收通道:外设到内存 */
dma_cfg.src_addr = (uint32_t)&handle->regs->DR;
dma_cfg.dst_addr = (uint32_t)handle->rx_buf;
dma_cfg.src_inc = DMA_ADDR_INC_DISABLE;
dma_cfg.dst_inc = DMA_ADDR_INC_ENABLE;
dma_cfg.data_width = DMA_DATA_WIDTH_8BIT;
dma_cfg.transfer_mode = DMA_MODE_CIRCULAR; /* 循环模式,持续接收 */
dma_channel_init(DMA_CH_UART_RX, &dma_cfg);
/* 使能UART的DMA请求 */
handle->regs->CR3 |= UART_CR3_DMAT | UART_CR3_DMAR;
return HAL_OK;
}
关键点:DMA接收建议使用循环模式(Circular Mode),配合半传输中断和完成中断,可以实现双缓冲机制。上层在处理一半数据时,DMA继续往另一半写数据,互不干扰。
4.6 三种模式的统一调度
现在来看HAL层如何根据配置自动选择模式。我习惯在uart_send和uart_receive内部做一个模式分发。
/* 统一发送接口 */
hal_status_t uart_send(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout)
{
if (handle == NULL || data == NULL || len == 0) {
return HAL_INVALID_PARAM;
}
switch (handle->config.mode) {
case UART_MODE_POLL:
return uart_send_poll(handle, data, len, timeout);
case UART_MODE_IRQ:
return uart_send_irq(handle, data, len);
case UART_MODE_DMA:
return uart_send_dma(handle, data, len);
default:
return HAL_ERROR;
}
}
这种设计的好处是:上层应用只需要调用uart_send,底层模式切换对上层完全透明。我在一个项目中,调试阶段用轮询模式,性能测试时切到DMA模式,上层代码一行没改。
4.7 知识结构总览
下面这张图把UART HAL封装的整体结构画出来了。三种模式从配置到接口,再到底层实现,一目了然。
4.8 性能对比与选型建议
最后给个实际测试数据。我在一个Cortex-M4平台上,用1MHz的UART波特率,传输1KB数据,测了三种模式的性能。
| 模式 | CPU占用率 | 传输时间 | 中断次数 | 推荐场景 |
|---|---|---|---|---|
| 轮询 | 100% | ~10ms | 0 | 调试、初始化自检 |
| 中断 | ~15% | ~10ms | 1024 | 通用、小数据包 |
| DMA | <1% | ~10ms | 2 | 大数据块、低功耗 |
选型建议:如果数据包小于64字节,中断模式比DMA模式更高效。因为DMA的配置开销(描述符初始化、通道使能、中断处理)在小数据量下占比太高。我一般以128字节为分界线:小于128用中断,大于等于128用DMA。