4. UART驱动接口抽象:阻塞、中断、DMA三种模式的接口封装

UART 驱动,几乎是每个嵌入式工程师都会碰到的模块。但说实话,很多人写了几年代码,UART 驱动还是「能用就行」的状态——换个芯片平台,重写一遍;换个传输模式,再加一堆 if-else。

我个人习惯是,从一开始就把接口抽象好。三种模式:阻塞、中断、DMA,它们的底层实现天差地别,但对外暴露的接口应该高度统一。这样上层应用代码才能做到「一次编写,到处编译」。

核心原则: 接口与实现分离。上层只关心「发数据」和「收数据」,不关心数据是怎么在硬件上流动的。

4.1 三种模式的核心差异

先看一张对比表,把三种模式的区别理清楚。我在项目中遇到过不少同事,把中断模式和 DMA 模式搞混,结果调试时发现 CPU 占用率居高不下。

特性 阻塞模式 中断模式 DMA 模式
CPU 占用 高(轮询等待) 中(中断处理) 低(硬件搬运)
实时性 差(阻塞调用方) 好(异步通知) 好(后台传输)
数据吞吐量 低 中 高
实现复杂度 低 中 高
适用场景 调试打印、短数据 命令响应、不定长 批量数据、高速传输

你想想看,如果只是每秒打印一次日志,用 DMA 就是杀鸡用牛刀。反过来,如果要从 SD 卡搬运大文件到 UART 输出,用阻塞模式就是自找麻烦。

4.2 接口抽象设计

我建议把 UART 驱动拆成三层:

  1. 硬件抽象层(HAL):直接操作寄存器,与芯片绑定
  2. 驱动核心层(Core):实现三种模式的具体逻辑
  3. 应用接口层(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;
}
注意: 阻塞模式一定要加超时机制!我曾经在一个产品里忘了加,结果硬件故障导致 TXE 标志永远不置位,整个系统卡死。加个简单的超时循环,或者用硬件看门狗兜底。

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 传输的完整链路:

UART DMA 传输流程图 应用层调用 uart_send() 配置 DMA 源/目的/长度 启动 DMA 传输 DMA 从内存搬运到 UART DR DMA 传输完成 → 触发中断 关键点 • DMA 通道需与 UART 匹配 • 传输长度不能超过 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,导致数据卡死。调了两天才发现是优先级配置的问题。嗯,从那以后我养成了习惯——中断优先级一定要画个表格,统一管理。