4. UART驱动封装与DMA集成:环形缓冲区设计,中断与DMA模式切换,波特率自适应

UART这个外设,说简单也简单,说复杂也复杂。很多工程师写了几年单片机代码,UART的收发还是停留在「发一个字节等一个中断」的水平。我早年做一款工业数据采集器时,就吃过这个亏——115200波特率下,每秒要处理约11.5KB的数据,CPU几乎被中断占满,主循环根本跑不动。

后来我彻底重构了UART驱动,核心就三件事:环形缓冲区、中断与DMA动态切换、波特率自适应。今天我把这套方案拆开来讲。

4.1 环形缓冲区:UART驱动的基石

环形缓冲区,说白了就是一个固定大小的数组,配上两个指针——读指针和写指针。数据从写指针写入,从读指针读出,指针绕回数组头部继续循环。

为什么非要用环形缓冲区?你想想看,如果每次收到一个字节就立刻通知应用层去处理,那应用层代码会被打断得七零八落。有了缓冲区,中断只管往里面塞数据,应用层有空了再来取,解耦得干干净净。

核心数据结构

typedef struct {
    uint8_t *buffer;      // 缓冲区基地址
    uint32_t size;        // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;  // 写指针(生产者)
    volatile uint32_t tail;  // 读指针(消费者)
} ring_buffer_t;

这里有个细节:size我强制要求是2的幂。为什么?因为取模运算 head % size 可以用位运算 head & (size - 1) 替代,速度能快一个数量级。我在项目中实测过,在Cortex-M4上,位运算版本比取模版本快了约12个时钟周期——中断服务程序里,这点时间很宝贵。

个人习惯:缓冲区大小我一般设256或512字节。太小容易溢出,太大浪费RAM。对于115200波特率,256字节能缓冲约22ms的数据,足够应用层响应了。

4.2 中断模式与DMA模式的动态切换

很多芯片的UART驱动,要么全程中断,要么全程DMA。但实际项目中,数据流量是动态变化的——空闲时可能几秒才发一个字节,忙时每秒几千字节。固定用一种模式,要么浪费CPU,要么浪费DMA通道。

我的方案是:动态切换。低流量时用中断模式,省DMA通道;高流量时自动切到DMA模式,解放CPU。

特性 中断模式 DMA模式
CPU占用率 高(每个字节都中断) 低(仅在传输完成时中断)
响应延迟 低(字节级实时) 中(块级实时)
适用场景 低速、间歇性通信 高速、持续数据流
DMA通道占用 不需要 需要1~2个通道

切换逻辑其实不复杂:

void uart_mode_switch(uart_handle_t *handle, uart_mode_t new_mode) {
    if (new_mode == handle->current_mode) return;
    
    // 停止当前模式
    if (handle->current_mode == UART_MODE_DMA) {
        // 停止DMA传输,把DMA缓冲区中剩余数据搬回环形缓冲区
        dma_stop(handle->dma_rx_channel);
        uint32_t remaining = dma_get_remaining(handle->dma_rx_channel);
        for (uint32_t i = 0; i < handle->dma_buf_size - remaining; i++) {
            ring_buffer_write(&handle->rx_ring, handle->dma_rx_buf[i]);
        }
    } else {
        // 中断模式直接禁用接收中断
        uart_disable_interrupt(handle->uart, UART_INT_RX);
    }
    
    // 启动新模式
    handle->current_mode = new_mode;
    if (new_mode == UART_MODE_DMA) {
        dma_start(handle->dma_rx_channel, handle->dma_rx_buf, handle->dma_buf_size);
        uart_enable_dma_rx(handle->uart);
    } else {
        uart_enable_interrupt(handle->uart, UART_INT_RX);
    }
}

切换时机怎么定?我一般设两个阈值:

  • 当环形缓冲区中待处理数据量 连续100ms低于10字节,切到中断模式
  • 当环形缓冲区中待处理数据量 连续50ms高于50字节,切到DMA模式

我曾经踩过的坑:切换时DMA缓冲区里可能还有半帧数据没搬完。一定要先把DMA剩余数据搬回环形缓冲区,再切换模式,否则数据就丢了。这个bug我调了整整一个下午才定位到。

4.3 波特率自适应:让设备学会「听」

波特率自适应,说白了就是让设备自动检测对方在用什么速度说话。这在很多场景下是刚需——比如你的设备要兼容不同厂家的传感器,每个厂家的波特率可能都不一样。

我常用的方法是测量起始位宽度。UART的起始位是一个固定宽度的低电平,通过测量这个低电平的持续时间,就能反推出波特率。

具体步骤:

  1. 配置一个定时器,捕获引脚下降沿(起始位开始)
  2. 捕获上升沿(起始位结束),记录定时器计数值
  3. 计数值 ÷ 定时器时钟周期 = 起始位宽度
  4. 波特率 = 1 / 起始位宽度

代码实现大致这样:

uint32_t uart_auto_baud(uart_handle_t *handle, uint32_t timer_freq) {
    // 等待下降沿
    while (!(gpio_get_level(handle->rx_pin) == 0));
    uint32_t start_time = timer_get_count(handle->timer);
    
    // 等待上升沿
    while (!(gpio_get_level(handle->rx_pin) == 1));
    uint32_t end_time = timer_get_count(handle->timer);
    
    // 计算起始位宽度(单位:秒)
    float pulse_width = (float)(end_time - start_time) / timer_freq;
    
    // 波特率 = 1 / 脉冲宽度
    uint32_t baud = (uint32_t)(1.0f / pulse_width);
    
    // 取整到标准波特率
    baud = round_to_standard_baud(baud);
    
    // 配置UART
    uart_set_baudrate(handle->uart, baud);
    handle->baudrate = baud;
    
    return baud;
}

实际项目中要注意:起始位测量受噪声影响很大。我一般会连续测量3~5次,取中位数,再跟标准波特率表(9600、19200、38400、57600、115200)做最近匹配。另外,定时器频率至少要16倍于目标波特率,否则测量精度不够。

4.4 整体架构与数据流

把上面三块拼起来,就是一套完整的UART驱动框架。我画了张图,你看一眼就明白了:

UART驱动封装与DMA集成架构 硬件层 UART外设 | DMA控制器 | 定时器(波特率测量) 驱动层 环形缓冲区管理 ring_buffer_t 中断/DMA模式切换 uart_mode_switch() 波特率自适应 uart_auto_baud() 接口层(API) uart_send() | uart_receive() | uart_set_baud() | uart_config() 应用层 数据流向

数据从硬件层上来,经过驱动层的环形缓冲区缓冲、模式切换、波特率适配,最后通过统一的API交给应用层。应用层根本不需要关心底层是中断还是DMA,也不需要关心波特率是怎么测出来的——这就是封装的魅力。

4.5 避坑指南与性能数据

最后分享几个实战中容易踩的坑:

  • 环形缓冲区溢出:应用层处理速度跟不上中断写入速度时,数据会被覆盖。我一般会在写入时检查 (head - tail) & (size - 1) == size - 1,如果满了就丢弃旧数据或直接报错。
  • DMA传输完成中断与UART空闲中断配合:DMA传输完成不代表一帧数据结束了。我习惯再开一个UART空闲中断,空闲中断来了才通知应用层去取数据。
  • 波特率自适应失败处理:如果连续3次测量结果都不在标准波特率表里,我建议回退到默认波特率(通常是9600),并给应用层一个错误码。

性能对比数据(实测,Cortex-M4 @ 168MHz,115200波特率)

方案 CPU占用率 最大吞吐量 内存开销
纯中断 + 单字节缓冲 35% 11.5 KB/s 4字节
中断 + 环形缓冲区 18% 11.5 KB/s 256字节
DMA + 环形缓冲区 3% 11.5 KB/s 512字节
动态切换(本方案) 3%~18% 11.5 KB/s 512字节

你看,动态切换方案在低负载时CPU占用率跟纯中断差不多,但高负载时能降到3%——这就是我坚持做模式切换的原因。嵌入式系统里,CPU资源永远不嫌多,省下来的算力可以做更有价值的事。