4、DMA传输在UART中的应用:发送DMA、接收DMA、超时处理、环形缓冲区设计

讲完了UART的基础轮询和中断模式,咱们得聊聊真正能解放CPU的东西——DMA。说白了,DMA就是让数据自己走,不用CPU一字节一字节地搬。我刚开始接触嵌入式时,总觉得中断已经够快了,直到有一次做高速数据采集,CPU被UART中断拖得连主循环都跑不动……嗯,从那以后,我对DMA的态度就变成了“能上就上”。

4.1 为什么UART需要DMA?

先看一个简单的对比:

传输方式 CPU参与度 每字节开销 适合场景
轮询 100%占用 等待+读取+存储 调试、低速率
中断 每字节一次中断 入栈+出栈+处理 中低速、不定长
DMA 仅在完成时中断 几乎为零 高速、批量传输

你想想看,如果波特率是921600,每秒要传约92KB数据。用中断模式,每字节进一次中断,CPU光上下文切换就够呛。DMA呢?它自己从UART接收寄存器把数据搬到内存,搬完一整批才通知你。我个人习惯是:只要单次传输超过16字节,就上DMA。

核心思路: DMA传输的本质是“内存 ↔ 外设”之间的数据搬运,CPU只需要配置好源地址、目的地址和传输长度,然后就可以去干别的事了。

4.2 发送DMA:让数据自己飞出去

发送DMA的逻辑相对简单。你把要发的数据放在内存缓冲区里,告诉DMA控制器:“从这儿开始,搬N个字节到UART的数据寄存器。”DMA就会自动把数据一个个塞进UART的发送FIFO。

配置要点:

  • 源地址: 内存中的发送缓冲区,地址递增
  • 目的地址: UART的数据寄存器(DR),地址固定
  • 传输宽度: 通常为8位,与UART数据位一致
  • 触发源: UART发送FIFO空(TXE)或发送完成(TC)

我在项目中遇到过一个问题:DMA传输完成后,UART的发送移位寄存器里可能还卡着最后一个字节。如果这时候直接关DMA或进低功耗模式,最后一个字节就丢了。解决办法是——等TC标志位。

避坑指南: 我曾经在某个量产项目中,因为没等TC标志就直接切换DMA缓冲区,导致最后一个字节丢失,整包数据CRC校验失败。后来加了一个“等待发送完成”的循环,问题解决。记住:DMA完成 ≠ 数据已经发完。

4.3 接收DMA:数据来了,谁来接?

接收DMA比发送复杂一些。因为发送是你主动的,你知道什么时候发、发多少。接收是被动的——你不知道数据什么时候来,也不知道来多少。

接收DMA的基本配置:

  • 源地址: UART的数据寄存器(DR),地址固定
  • 目的地址: 内存中的接收缓冲区,地址递增
  • 传输宽度: 8位
  • 触发源: UART接收FIFO非空(RXNE)

这里有个关键问题:DMA需要知道传输长度。如果你事先知道要收多少字节(比如固定长度的协议帧),直接设置DMA传输长度就行。但如果是不定长数据呢?

常用的做法有两种:

  1. 空闲中断 + DMA: 当UART总线空闲超过一个字节时间时,触发中断。在中断里判断DMA已经收了多少字节。
  2. 超时定时器: 用硬件定时器监控接收间隔,超时则认为一帧结束。

我个人更推荐第一种,因为很多MCU的UART外设本身就支持空闲检测,不需要额外占用定时器。

4.4 超时处理:数据什么时候算“收完了”?

超时处理是不定长DMA接收的核心。你想想看,DMA一直在收,但你不确定对方什么时候停。如果一直等,CPU就卡死了。所以需要一种机制来判定“这一帧结束了”。

常见的超时策略:

策略 实现方式 优点 缺点
空闲中断 UART硬件检测总线空闲 无需额外定时器 某些MCU不支持
定时器轮询 定时器周期性检查DMA剩余计数 通用性强 占用定时器资源
字符超时 每收到一个字节重置定时器 精度高 实现稍复杂

我在一个项目中用过字符超时方案:用硬件定时器,每次UART收到字节时在中断里重置定时器。如果定时器溢出,就认为数据收完了。这个方案的精度取决于定时器的分辨率,一般设置为3-5个字节的传输时间比较稳妥。

注意: 超时时间不能设得太短,否则在连续发送时会被误判为帧结束。也不能设得太长,否则接收延迟太大。我一般取“当前波特率下3个字节的传输时间”作为基准值。

4.5 环形缓冲区设计:让DMA和CPU各干各的

环形缓冲区(Ring Buffer)是DMA接收的黄金搭档。为什么?因为DMA和CPU是异步工作的——DMA往缓冲区里写数据,CPU从缓冲区里读数据。如果不用环形缓冲区,要么CPU被频繁打断,要么数据被覆盖。

环形缓冲区的核心结构:

// 环形缓冲区结构体
typedef struct {
    uint8_t *buffer;    // 缓冲区基地址
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;  // 写指针(DMA更新)
    volatile uint32_t tail;  // 读指针(CPU更新)
} ring_buffer_t;

关键设计原则:

  • 缓冲区大小必须是2的幂: 这样可以用位与运算代替取模,提高效率
  • head和tail用volatile修饰: 因为它们在中断和主循环中被同时访问
  • DMA只写head,CPU只读tail: 避免竞争条件

DMA接收和环形缓冲区的配合流程:

  1. DMA接收完成或超时触发中断
  2. 在中断中更新head指针(head += 收到的字节数)
  3. 主循环检查head != tail,说明有新数据
  4. 从tail位置读取数据,处理完后更新tail
经验之谈: 我曾经在环形缓冲区上栽过跟头——缓冲区大小设成了256字节,但DMA传输长度也设成了256。结果缓冲区满了之后,DMA继续写,把tail指针的位置给覆盖了。后来我把缓冲区大小设为DMA传输长度的2倍以上,再也没出过问题。

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

下面这张图展示了DMA传输在UART中的完整数据流:

UART DMA传输架构图 UART DMA传输架构图 发送缓冲区 (内存) DMA控制器 (发送通道) UART外设 (发送) DMA搬运 自动发送 UART外设 (接收) DMA控制器 (接收通道) 环形缓冲区 (内存) 触发DMA DMA写入 CPU(主循环) 准备数据 读取数据 超时检测 触发帧结束 发送:CPU准备 → DMA搬运 → UART发送 接收:UART接收 → DMA写入 → CPU读取

从这张图可以看出发送和接收两条路径是独立的。发送路径上,CPU准备好数据后,DMA负责搬运到UART;接收路径上,DMA把数据搬进环形缓冲区,CPU在空闲时去取。超时检测模块负责在接收路径上判定帧结束。

4.7 代码骨架:DMA发送与接收初始化

最后给一个简化的初始化代码,帮你把上面的概念串起来:

// DMA发送初始化
void uart_dma_tx_init(UART_HandleTypeDef *huart, uint8_t *tx_buf, uint32_t len)
{
    // 配置DMA:内存到外设,地址递增(源),固定(目的)
    // 触发源:UART发送FIFO空
    // 传输完成中断使能
    HAL_DMA_Start(huart->hdmatx, (uint32_t)tx_buf, 
                  (uint32_t)&huart->Instance->DR, len);
    __HAL_UART_ENABLE(huart, UART_CR3_DMAT);
}

// DMA接收初始化(配合环形缓冲区)
void uart_dma_rx_init(UART_HandleTypeDef *huart, ring_buffer_t *rb)
{
    // 配置DMA:外设到内存,地址固定(源),递增(目的)
    // 触发源:UART接收FIFO非空
    // 设置为循环模式(Circular Mode)
    HAL_DMA_Start(huart->hdmarx, (uint32_t)&huart->Instance->DR,
                  (uint32_t)rb->buffer, rb->size);
    __HAL_UART_ENABLE(huart, UART_CR3_DMAR);
    
    // 使能空闲中断
    __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
}

// 空闲中断处理
void uart_idle_irq_handler(UART_HandleTypeDef *huart, ring_buffer_t *rb)
{
    if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(huart);
        
        // 计算本次接收的字节数
        uint32_t remaining = __HAL_DMA_GET_COUNTER(huart->hdmarx);
        uint32_t received = rb->size - remaining;
        
        // 更新环形缓冲区head指针
        rb->head = (rb->head + received) & (rb->size - 1);
        
        // 重新启动DMA(循环模式下不需要)
        // 如果使用普通模式,需要重新配置DMA
    }
}
重要提醒: 上面的代码是简化版,实际项目中还需要处理DMA传输完成中断、错误中断、缓冲区溢出保护等。特别是环形缓冲区的head和tail指针,一定要用volatile修饰,并且在中断和主循环中做好临界区保护。

嗯,DMA传输这部分内容比较多,但核心就三件事:发送DMA让数据自己飞出去,接收DMA配合超时判断帧结束,环形缓冲区让DMA和CPU各干各的。把这三点吃透了,UART的DMA驱动就没什么大问题了。