4. UART驱动接口抽象:阻塞、中断、DMA三种模式的接口封装
UART 驱动,几乎是每个嵌入式工程师都会碰到的模块。但说实话,很多人写了几年代码,UART 驱动还是「能用就行」的状态——换个芯片平台,重写一遍;换个传输模式,再加一堆 if-else。
我个人习惯是,从一开始就把接口抽象好。三种模式:阻塞、中断、DMA,它们的底层实现天差地别,但对外暴露的接口应该高度统一。这样上层应用代码才能做到「一次编写,到处编译」。
4.1 三种模式的核心差异
先看一张对比表,把三种模式的区别理清楚。我在项目中遇到过不少同事,把中断模式和 DMA 模式搞混,结果调试时发现 CPU 占用率居高不下。
| 特性 | 阻塞模式 | 中断模式 | DMA 模式 |
|---|---|---|---|
| CPU 占用 | 高(轮询等待) | 中(中断处理) | 低(硬件搬运) |
| 实时性 | 差(阻塞调用方) | 好(异步通知) | 好(后台传输) |
| 数据吞吐量 | 低 | 中 | 高 |
| 实现复杂度 | 低 | 中 | 高 |
| 适用场景 | 调试打印、短数据 | 命令响应、不定长 | 批量数据、高速传输 |
你想想看,如果只是每秒打印一次日志,用 DMA 就是杀鸡用牛刀。反过来,如果要从 SD 卡搬运大文件到 UART 输出,用阻塞模式就是自找麻烦。
4.2 接口抽象设计
我建议把 UART 驱动拆成三层:
- 硬件抽象层(HAL):直接操作寄存器,与芯片绑定
- 驱动核心层(Core):实现三种模式的具体逻辑
- 应用接口层(API):对外暴露的统一接口
下面是我常用的接口定义,用 C 语言写的。注意看,不管底层是阻塞、中断还是 DMA,上层调用的函数名完全一样。
/* uart_interface.h - 应用接口层 */
#ifndef UART_INTERFACE_H
#define UART_INTERFACE_H
#include <stdint.h>
#include <stddef.h>
/* 传输模式枚举 */
typedef enum {
UART_MODE_BLOCKING = 0,
UART_MODE_INTERRUPT,
UART_MODE_DMA
} uart_mode_t;
/* 回调函数类型(中断/DMA 模式使用) */
typedef void (*uart_rx_callback_t)(uint8_t data);
typedef void (*uart_tx_callback_t)(void);
/* 配置结构体 */
typedef struct {
uint32_t baudrate;
uint8_t data_bits; /* 5, 6, 7, 8 */
uint8_t stop_bits; /* 1, 2 */
uint8_t parity; /* 0: none, 1: odd, 2: even */
uart_mode_t mode;
uart_rx_callback_t rx_callback; /* 中断/DMA 接收回调 */
uart_tx_callback_t tx_callback; /* 中断/DMA 发送完成回调 */
} uart_config_t;
/* 统一 API */
int uart_init(uint8_t port, const uart_config_t *config);
int uart_send(uint8_t port, const uint8_t *data, size_t len);
int uart_receive(uint8_t port, uint8_t *buffer, size_t len);
void uart_irq_handler(uint8_t port); /* 中断入口 */
int uart_deinit(uint8_t port);
#endif /* UART_INTERFACE_H */
rx_callback 会在 ISR 上下文中被调用,所以回调里绝对不能做耗时操作——我曾经因为回调里调了 printf,导致系统直接死锁。
4.3 阻塞模式实现要点
阻塞模式最简单,但坑也不少。说白了就是「死等」——等发送寄存器空,等接收数据到位。
/* 阻塞发送 - 以 STM32 为例 */
int uart_send_blocking(uint8_t port, const uint8_t *data, size_t len)
{
for (size_t i = 0; i < len; i++) {
/* 等待发送寄存器为空 */
while (!(USART->SR & USART_SR_TXE));
USART->DR = data[i];
}
/* 等待发送完成(最后一个字节) */
while (!(USART->SR & USART_SR_TC));
return 0;
}
4.4 中断模式实现要点
中断模式的核心是「乒乓缓冲」或「环形缓冲」。我习惯用环形缓冲,因为实现简单,而且能处理不定长数据。
/* 环形缓冲结构 */
typedef struct {
uint8_t buffer[UART_RX_BUF_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
} uart_ringbuf_t;
/* 中断接收处理 */
void uart_rx_irq_handler(uart_ringbuf_t *rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % UART_RX_BUF_SIZE;
if (next != rb->tail) { /* 缓冲区未满 */
rb->buffer[rb->head] = data;
rb->head = next;
}
/* 如果满了,直接丢弃(或置溢出标志) */
}
这里有个细节:head 和 tail 一定要加 volatile。为什么?因为它们在中断和主循环中被同时访问,编译器优化会搞乱顺序。我踩过这个坑,调试了两天才发现是变量被优化掉了。
4.5 DMA 模式实现要点
DMA 模式是性能最好的,但配置也最复杂。你需要协调 UART 外设、DMA 控制器和中断系统三者的时序。
我画了一张流程图,帮你理清 DMA 传输的完整链路:
DMA 模式下,CPU 只在开始和结束时参与。中间的数据搬运完全由硬件完成。但要注意:DMA 传输完成中断里,一定要清标志位,否则会一直进中断。嗯,这个坑我帮别人排查过好几次。
/* DMA 发送完成中断处理 */
void dma_tx_complete_irq(void)
{
/* 1. 清 DMA 中断标志 */
DMA->IFCR |= DMA_IFCR_CTCIFx;
/* 2. 停止 UART DMA 请求 */
USART->CR3 &= ~USART_CR3_DMAT;
/* 3. 调用上层回调 */
if (tx_callback) {
tx_callback();
}
}
4.6 三种模式的统一封装
最后,把三种模式封装到统一的 API 里。我用的方法是函数指针表:
/* 驱动函数表 */
typedef struct {
int (*init)(uint8_t port, const uart_config_t *config);
int (*send)(uint8_t port, const uint8_t *data, size_t len);
int (*receive)(uint8_t port, uint8_t *buffer, size_t len);
void (*irq_handler)(uint8_t port);
int (*deinit)(uint8_t port);
} uart_driver_t;
/* 三种模式的驱动表实例 */
static const uart_driver_t uart_driver_blocking = {
.init = uart_blocking_init,
.send = uart_blocking_send,
.receive = uart_blocking_receive,
.irq_handler = NULL, /* 阻塞模式无中断 */
.deinit = uart_blocking_deinit
};
static const uart_driver_t uart_driver_interrupt = {
.init = uart_interrupt_init,
.send = uart_interrupt_send,
.receive = uart_interrupt_receive,
.irq_handler = uart_interrupt_irq_handler,
.deinit = uart_interrupt_deinit
};
static const uart_driver_t uart_driver_dma = {
.init = uart_dma_init,
.send = uart_dma_send,
.receive = uart_dma_receive,
.irq_handler = uart_dma_irq_handler,
.deinit = uart_dma_deinit
};
/* 根据模式选择驱动 */
const uart_driver_t* uart_get_driver(uart_mode_t mode)
{
switch (mode) {
case UART_MODE_BLOCKING: return &uart_driver_blocking;
case UART_MODE_INTERRUPT: return &uart_driver_interrupt;
case UART_MODE_DMA: return &uart_driver_dma;
default: return NULL;
}
}
mode 字段,驱动代码一行都不用改。这就是接口抽象的威力。
4.7 避坑指南
- 缓冲区大小要合理:中断模式缓冲区太小会丢数据,太大浪费内存。我一般按最大包长的 2 倍来设。
- DMA 内存对齐:有些芯片要求 DMA 缓冲区地址对齐到 4 字节或 16 字节。不对齐的话,DMA 会传输错误数据。
- 中断优先级:UART 中断优先级不要设得太高,否则会饿死低优先级任务。但也不能太低,否则会丢数据。
- 临界区保护:在中断模式和主循环共享数据时,一定要关中断或使用信号量保护。
我曾经在一个项目里,把 UART 中断优先级设成了最高,结果 DMA 传输完成中断一直抢不到 CPU,导致数据卡死。调了两天才发现是优先级配置的问题。嗯,从那以后我养成了习惯——中断优先级一定要画个表格,统一管理。