4. UART外设抽象:驱动框架、环形缓冲区与DMA传输

UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂在于——你要在HAL层把它抽象好,让上层应用不用关心底层是哪个MCU、中断怎么配、DMA用哪个通道。

我个人习惯把UART抽象分成三层:驱动框架层、数据缓冲层、传输引擎层。今天咱们就一层层拆开来看。

4.1 UART驱动框架:从寄存器到接口

先看驱动框架。说白了,就是定义一组标准操作,让上层调起来像在操作一个“串口对象”,而不是一堆寄存器。

我一般会这样设计基础结构体:

typedef struct {
    uint32_t        base_addr;      // 寄存器基地址
    uint32_t        baud_rate;      // 波特率
    uint8_t         data_bits;      // 数据位
    uint8_t         stop_bits;      // 停止位
    uint8_t         parity;         // 校验位
    uint8_t         flow_ctrl;      // 流控模式
    void            (*irq_handler)(void);  // 中断处理函数指针
} uart_hw_config_t;

typedef struct {
    uart_hw_config_t    hw_cfg;
    uint8_t             *rx_buf;     // 接收缓冲区指针
    uint32_t            rx_buf_size;
    uint8_t             *tx_buf;     // 发送缓冲区指针
    uint32_t            tx_buf_size;
    void                *priv;       // 私有数据(给底层驱动用)
} uart_dev_t;

嗯,这里要注意:priv指针是留给底层BSP用的。我在项目里见过有人把所有配置都塞进结构体,结果换芯片时改得想哭。留个void指针,底层想挂什么就挂什么,上层完全无感。

驱动接口我习惯定义成这样:

typedef struct {
    int32_t (*init)(uart_dev_t *dev);
    int32_t (*deinit)(uart_dev_t *dev);
    int32_t (*send)(uart_dev_t *dev, const uint8_t *data, uint32_t len);
    int32_t (*receive)(uart_dev_t *dev, uint8_t *data, uint32_t len);
    int32_t (*ioctl)(uart_dev_t *dev, uint32_t cmd, void *arg);
} uart_ops_t;

为什么要有ioctl?因为总有些特殊操作——比如改波特率、清FIFO、使能硬件流控。你不可能把所有功能都做成独立接口,那样接口表会膨胀到没法看。

关键设计原则:驱动框架只定义“做什么”,不定义“怎么做”。具体怎么操作寄存器,那是底层BSP的事。

4.2 环形缓冲区设计:解决数据粘包与丢失

做嵌入式通信,最头疼的问题是什么?数据来了,你还没处理完上一条。或者中断太频繁,丢数据了。

环形缓冲区就是干这个用的。它像一个循环队列,写指针追着读指针跑。只要缓冲区够大,CPU忙的时候数据不会丢。

我常用的环形缓冲区结构:

typedef struct {
    uint8_t     *buffer;        // 内存池
    uint32_t    size;           // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;     // 写指针
    volatile uint32_t tail;     // 读指针
} ring_buf_t;

这里有个小技巧:size必须是2的幂。为什么?因为可以用位运算代替取模,速度能快一个数量级。

// 入队
int32_t ring_buf_push(ring_buf_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) & (rb->size - 1);
    if (next == rb->tail) {
        return -1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return 0;
}

// 出队
int32_t ring_buf_pop(ring_buf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    return 0;
}

你看,& (size - 1)代替了% size。我在一个项目中把波特率从115200提到921600,就是因为中断服务函数里省掉了取模运算。

避坑指南:我曾经在环形缓冲区里用volatile修饰head和tail,但忘了在中断和主循环之间加内存屏障。结果优化等级开-O2后,读指针永远看不到写指针更新。嗯,从那以后我养成了习惯——跨中断/任务访问的共享变量,要么加屏障,要么用原子操作。

4.3 DMA传输抽象:让CPU喘口气

UART用中断收发,CPU频繁进出中断,大量时间浪费在上下文切换上。尤其在高波特率下,CPU几乎被UART中断占满。

DMA就是解决方案。它让数据在UART和内存之间直接搬运,搬完了才通知CPU。

DMA传输抽象的核心是描述符链。每个描述符包含:源地址、目的地址、传输长度、完成标志、下一个描述符指针。

typedef struct {
    uint32_t    src_addr;       // 源地址
    uint32_t    dst_addr;       // 目的地址
    uint32_t    length;         // 传输长度
    uint32_t    flags;          // 控制标志(完成中断使能、循环模式等)
    void        *next;          // 下一个描述符
    void        (*callback)(void *arg);  // 传输完成回调
    void        *cb_arg;        // 回调参数
} dma_desc_t;

我习惯把DMA抽象成两层:

  • DMA控制器层:负责通道分配、描述符提交、中断处理
  • UART DMA适配层:把UART的收发操作映射到DMA传输

适配层的接口大概长这样:

typedef struct {
    dma_channel_t   *tx_ch;      // 发送DMA通道
    dma_channel_t   *rx_ch;      // 接收DMA通道
    ring_buf_t      *rx_ring;    // 接收环形缓冲区
    uint32_t        rx_timeout;  // 接收超时(用于帧结束判断)
} uart_dma_adapter_t;

int32_t uart_dma_send(uart_dev_t *dev, const uint8_t *data, uint32_t len);
int32_t uart_dma_receive(uart_dev_t *dev, uint8_t *data, uint32_t len, uint32_t timeout);

这里有个设计取舍:接收用DMA还是中断?

场景 推荐方式 原因
高波特率(>1Mbps) DMA + 环形缓冲区 减少中断频率,避免丢数据
不定长数据帧 DMA + 空闲中断 利用空闲中断判断帧结束
低功耗场景 中断 + 小缓冲区 DMA会增加额外功耗
实时性要求高 中断 + 双缓冲 DMA延迟比中断大

你想想看,如果每次收到一个字节就进一次中断,115200波特率下大约每8.7微秒一次中断。CPU光保存恢复现场了,哪还有时间做业务逻辑?

个人经验:我在一个物联网网关项目里,用DMA + 环形缓冲区 + 空闲中断的组合,把UART接收的CPU占用率从35%降到了3%以下。代价是多花了4KB内存做缓冲区。嗯,这个交易很划算。

4.4 整体架构:一张图说清楚

下面这张图展示了UART外设抽象的整体架构。从上层应用到硬件寄存器,每一层各司其职。

UART外设抽象架构图 应用层(AT指令、Modbus、自定义协议) 驱动接口层(uart_ops_t) 数据缓冲层(环形缓冲区) 传输引擎层(中断模式 / DMA模式) 硬件抽象层(寄存器操作、中断向量、DMA通道) 说明:上层只依赖驱动接口层,不关心底层是中断还是DMA。换芯片只需重写硬件抽象层。

你看这个分层结构,每一层只做一件事。应用层不关心数据怎么收发的,驱动接口层不关心缓冲区怎么管理的,传输引擎层不关心寄存器地址是多少的。

这就是HAL层的核心价值——解耦。我在一个项目里从STM32F4换到GD32F4,只改了硬件抽象层不到200行代码,上层纹丝不动。

4.5 性能调优:几个实测数据

最后分享几个我在实际项目中测出来的数据,供你参考:

传输方式 波特率 CPU占用率 最大吞吐量 适用场景
轮询 115200 100% 11.5KB/s 调试打印
中断 + 单字节 115200 35% 11.5KB/s 低数据量通信
中断 + 环形缓冲区 115200 12% 11.5KB/s 通用场景
DMA + 环形缓冲区 115200 2% 11.5KB/s 高数据量通信
DMA + 双缓冲 921600 5% 92KB/s 高速数据采集

看到没?同样的波特率,不同的传输方式,CPU占用率能差十几倍。选对方案,你的系统就能多跑很多业务逻辑。

核心总结:UART外设抽象不是把寄存器封装一下就完事了。你要考虑的是——框架怎么设计才能跨平台?缓冲区怎么设计才能不丢数据?传输引擎怎么选才能平衡性能和功耗?这三个问题想清楚了,你的HAL层就成功了一大半。