4. UART驱动框架设计:轮询、中断、DMA三种模式封装

UART这个外设,说简单也简单,说复杂也复杂。我见过不少工程师,项目里UART用得很溜,但换个芯片平台,代码就得重写大半。为什么?因为没有把驱动抽象出来。

这一章,我们就来聊聊怎么把UART的三种工作模式——轮询、中断、DMA——封装成一个统一的驱动框架。你想想看,上层应用调一个 uart_send(),底层自动选择用哪种模式,是不是清爽多了?

4.1 三种模式的核心差异

先搞清楚这三种模式到底有什么区别。我习惯用一个表格来对比,一目了然。

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(忙等) 低(触发时介入) 极低(硬件搬运)
实时性 差(阻塞) 好(异步) 好(异步)
数据吞吐量 低 中 高
代码复杂度 简单 中等 较高
适用场景 调试、低速率 常规通信 大数据块传输

说白了,轮询就是CPU死盯着UART寄存器不放;中断是让CPU忙别的,UART有事了喊它一声;DMA更狠,连喊都省了,直接内存到外设自己搬。

核心原则:驱动框架要做的,就是把这三者的差异封装在底层,对外暴露统一的接口。上层应用不需要知道底层是轮询还是DMA。

4.2 驱动分层架构

我个人习惯把UART驱动分成三层。这样设计,换芯片时只需要改最底层。

  1. 硬件抽象层(HAL):直接操作寄存器,实现收发字节、配置波特率等。
  2. 模式管理层:封装轮询、中断、DMA三种模式的逻辑,维护环形缓冲区。
  3. 应用接口层:提供 uart_init()、uart_send()、uart_recv() 等API。

下面这张图,画的就是这个分层关系。

UART驱动三层架构 应用接口层 uart_init() | uart_send() | uart_recv() | uart_irq_handler() 模式管理层 轮询调度 | 中断服务 | DMA传输控制 环形缓冲区管理 | 状态机 硬件抽象层(HAL) 寄存器操作 | 波特率配置 | 引脚复用 换芯片时只改HAL层,上层代码无需变动

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 中断模式实现要点

中断模式是实际项目中最常用的。核心思路是:发送用中断逐字节触发,接收用环形缓冲区缓存。

我画个流程图,你看看数据是怎么流动的。

中断模式数据流 应用层 发送环形缓冲区 接收环形缓冲区 UART外设 中断服务函数负责在发送/接收缓冲区与外设之间搬运数据

中断服务函数怎么写?我给出一个精简版:

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闪烁报错,比等程序跑飞了再查快得多。