4. UART驱动框架设计:轮询、中断、DMA三种模式封装
UART这个外设,说简单也简单,说复杂也复杂。我见过不少工程师,项目里UART用得很溜,但换个芯片平台,代码就得重写大半。为什么?因为没有把驱动抽象出来。
这一章,我们就来聊聊怎么把UART的三种工作模式——轮询、中断、DMA——封装成一个统一的驱动框架。你想想看,上层应用调一个 uart_send(),底层自动选择用哪种模式,是不是清爽多了?
4.1 三种模式的核心差异
先搞清楚这三种模式到底有什么区别。我习惯用一个表格来对比,一目了然。
| 特性 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(忙等) | 低(触发时介入) | 极低(硬件搬运) |
| 实时性 | 差(阻塞) | 好(异步) | 好(异步) |
| 数据吞吐量 | 低 | 中 | 高 |
| 代码复杂度 | 简单 | 中等 | 较高 |
| 适用场景 | 调试、低速率 | 常规通信 | 大数据块传输 |
说白了,轮询就是CPU死盯着UART寄存器不放;中断是让CPU忙别的,UART有事了喊它一声;DMA更狠,连喊都省了,直接内存到外设自己搬。
核心原则:驱动框架要做的,就是把这三者的差异封装在底层,对外暴露统一的接口。上层应用不需要知道底层是轮询还是DMA。
4.2 驱动分层架构
我个人习惯把UART驱动分成三层。这样设计,换芯片时只需要改最底层。
- 硬件抽象层(HAL):直接操作寄存器,实现收发字节、配置波特率等。
- 模式管理层:封装轮询、中断、DMA三种模式的逻辑,维护环形缓冲区。
- 应用接口层:提供
uart_init()、uart_send()、uart_recv()等API。
下面这张图,画的就是这个分层关系。
4.3 统一接口设计
接口怎么设计?我建议用结构体把操作函数指针集中起来。这样既方便移植,也支持运行时切换模式。
/* uart_driver.h */
typedef struct {
void (*init)(uint32_t baud);
void (*send)(uint8_t *data, uint16_t len);
uint16_t (*recv)(uint8_t *buf, uint16_t max_len);
void (*irq_handler)(void);
} uart_driver_t;
/* 三种模式的驱动实例 */
extern uart_driver_t uart_polling;
extern uart_driver_t uart_interrupt;
extern uart_driver_t uart_dma;
使用时,只需要选择对应的驱动实例:
uart_driver_t *drv = &uart_interrupt;
drv->init(115200);
drv->send("Hello", 5);
小技巧:我习惯在初始化时通过宏定义选择默认模式。比如 #define UART_MODE UART_MODE_INTERRUPT,编译时就定死了,省去运行时判断的开销。
4.4 轮询模式实现要点
轮询模式最简单,但坑也不少。我曾经在一个低功耗项目里用轮询发数据,结果CPU被死死卡住,看门狗都来不及喂。
核心代码就两件事:查状态、发数据。
void polling_send(uint8_t *data, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
/* 等待发送缓冲区空 */
while (!(UART->SR & UART_SR_TXE));
UART->DR = data[i];
}
/* 等待发送完成 */
while (!(UART->SR & UART_SR_TC));
}
注意:轮询模式一定要加超时机制。否则硬件异常时,程序就永远卡死在while循环里。我一般用系统滴答计时器做超时判断。
4.5 中断模式实现要点
中断模式是实际项目中最常用的。核心思路是:发送用中断逐字节触发,接收用环形缓冲区缓存。
我画个流程图,你看看数据是怎么流动的。
中断服务函数怎么写?我给出一个精简版:
void uart_irq_handler(void) {
uint8_t byte;
/* 接收中断 */
if (UART->SR & UART_SR_RXNE) {
byte = UART->DR;
/* 写入环形缓冲区 */
if (!ringbuf_full(&rx_buf)) {
ringbuf_write(&rx_buf, byte);
}
}
/* 发送中断 */
if (UART->SR & UART_SR_TXE) {
if (!ringbuf_empty(&tx_buf)) {
ringbuf_read(&tx_buf, &byte);
UART->DR = byte;
} else {
/* 没有数据了,关闭发送中断 */
UART->CR1 &= ~UART_CR1_TXEIE;
}
}
}
关键点:发送中断一定要在数据发完后及时关闭,否则会一直触发,浪费CPU。接收中断则要保持开启,随时准备接收数据。
4.6 DMA模式实现要点
DMA模式适合大数据块传输,比如用UART刷屏、传文件。CPU只需要配置好DMA通道,然后就可以去干别的事了。
配置DMA时,有几个参数必须设对:
- 传输方向:内存到外设(发送)或外设到内存(接收)
- 数据宽度:UART一般是8位,所以设成字节
- 循环模式:接收时常用,实现连续采集
- 中断触发:传输完成或半完成时产生中断
void dma_send(uint8_t *data, uint16_t len) {
/* 等待上一次传输完成 */
while (DMA->ISR & DMA_ISR_TCIF);
/* 配置DMA */
DMA->CPAR = (uint32_t)&UART->DR; /* 外设地址 */
DMA->CMAR = (uint32_t)data; /* 内存地址 */
DMA->CNDTR = len; /* 传输长度 */
DMA->CCR |= DMA_CCR_EN; /* 使能DMA */
/* 使能UART的DMA发送 */
UART->CR3 |= UART_CR3_DMAT;
}
我曾经踩过的坑:DMA传输完成后,一定要检查UART的发送完成标志(TC位)。因为DMA把数据搬到发送寄存器后,UART可能还没发完。如果此时关闭外设或进入低功耗模式,最后一个字节就丢了。
4.7 三种模式的切换策略
实际项目中,我经常根据数据量动态切换模式。比如:
- 发送1-2个字节的AT指令,用轮询
- 发送几十字节的日志,用中断
- 发送几百字节的固件包,用DMA
怎么实现?可以在发送接口里加一个长度判断:
void uart_send(uint8_t *data, uint16_t len) {
if (len < 4) {
polling_send(data, len);
} else if (len < 64) {
interrupt_send(data, len);
} else {
dma_send(data, len);
}
}
这个阈值可以根据你的芯片性能和实际需求调整。我建议在调试阶段先设大一点,稳定后再逐步优化。
4.8 调试接口的协同设计
最后聊一个容易被忽略的点:调试接口。UART驱动写好了,怎么验证它工作正常?
我习惯在驱动里内置一个回环测试函数:
uint8_t uart_loopback_test(void) {
uint8_t tx_data[] = "UART_OK";
uint8_t rx_data[8] = {0};
uart_send(tx_data, 7);
/* 短接TX和RX引脚后,应该能收到自己发的数据 */
uart_recv(rx_data, 7);
return (memcmp(tx_data, rx_data, 7) == 0);
}
这个函数在板子刚贴片回来时特别有用。我曾经靠它快速定位了一根飞线虚焊的问题,省了大半天排查时间。
建议:把回环测试放到上电自检流程里。如果UART通信异常,直接通过LED闪烁报错,比等程序跑飞了再查快得多。