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。
4.2 发送DMA:让数据自己飞出去
发送DMA的逻辑相对简单。你把要发的数据放在内存缓冲区里,告诉DMA控制器:“从这儿开始,搬N个字节到UART的数据寄存器。”DMA就会自动把数据一个个塞进UART的发送FIFO。
配置要点:
- 源地址: 内存中的发送缓冲区,地址递增
- 目的地址: UART的数据寄存器(DR),地址固定
- 传输宽度: 通常为8位,与UART数据位一致
- 触发源: UART发送FIFO空(TXE)或发送完成(TC)
我在项目中遇到过一个问题:DMA传输完成后,UART的发送移位寄存器里可能还卡着最后一个字节。如果这时候直接关DMA或进低功耗模式,最后一个字节就丢了。解决办法是——等TC标志位。
4.3 接收DMA:数据来了,谁来接?
接收DMA比发送复杂一些。因为发送是你主动的,你知道什么时候发、发多少。接收是被动的——你不知道数据什么时候来,也不知道来多少。
接收DMA的基本配置:
- 源地址: UART的数据寄存器(DR),地址固定
- 目的地址: 内存中的接收缓冲区,地址递增
- 传输宽度: 8位
- 触发源: UART接收FIFO非空(RXNE)
这里有个关键问题:DMA需要知道传输长度。如果你事先知道要收多少字节(比如固定长度的协议帧),直接设置DMA传输长度就行。但如果是不定长数据呢?
常用的做法有两种:
- 空闲中断 + DMA: 当UART总线空闲超过一个字节时间时,触发中断。在中断里判断DMA已经收了多少字节。
- 超时定时器: 用硬件定时器监控接收间隔,超时则认为一帧结束。
我个人更推荐第一种,因为很多MCU的UART外设本身就支持空闲检测,不需要额外占用定时器。
4.4 超时处理:数据什么时候算“收完了”?
超时处理是不定长DMA接收的核心。你想想看,DMA一直在收,但你不确定对方什么时候停。如果一直等,CPU就卡死了。所以需要一种机制来判定“这一帧结束了”。
常见的超时策略:
| 策略 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 空闲中断 | UART硬件检测总线空闲 | 无需额外定时器 | 某些MCU不支持 |
| 定时器轮询 | 定时器周期性检查DMA剩余计数 | 通用性强 | 占用定时器资源 |
| 字符超时 | 每收到一个字节重置定时器 | 精度高 | 实现稍复杂 |
我在一个项目中用过字符超时方案:用硬件定时器,每次UART收到字节时在中断里重置定时器。如果定时器溢出,就认为数据收完了。这个方案的精度取决于定时器的分辨率,一般设置为3-5个字节的传输时间比较稳妥。
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接收和环形缓冲区的配合流程:
- DMA接收完成或超时触发中断
- 在中断中更新head指针(head += 收到的字节数)
- 主循环检查head != tail,说明有新数据
- 从tail位置读取数据,处理完后更新tail
4.6 整体架构:一张图说清楚
下面这张图展示了DMA传输在UART中的完整数据流:
从这张图可以看出发送和接收两条路径是独立的。发送路径上,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传输这部分内容比较多,但核心就三件事:发送DMA让数据自己飞出去,接收DMA配合超时判断帧结束,环形缓冲区让DMA和CPU各干各的。把这三点吃透了,UART的DMA驱动就没什么大问题了。