4. UART DMA驱动与高级应用

UART 用轮询还是中断,其实大部分场景都够用。但一旦数据量上来,比如每秒几百 KB 的日志输出,或者跟 WiFi 模块高速通信,CPU 就会被频繁的中断拖死。这时候,DMA 就该登场了。

说白了,DMA 就是让数据绕过 CPU,直接在外设和内存之间搬运。CPU 只需要告诉 DMA:「从 UART 接收寄存器搬 1024 字节到 buffer_A」,然后就可以去干别的活了。搬完了,DMA 才发个中断通知你。

核心收益: 一次中断处理一批数据,而不是每个字节都中断一次。CPU 负载从 80% 降到 5% 是常有的事。

4.1 DMA 原理与配置要点

DMA 控制器内部其实是一组通道(Channel),每个通道可以独立配置源地址、目标地址、传输宽度和传输计数。我习惯把 DMA 通道想象成「智能搬运工」——它自己会数数,搬完指定字节数就停下来或者自动重载。

配置 DMA 时,有几个坑必须注意:

  • 地址增量模式: 外设寄存器地址通常是固定的(比如 USART1->DR),所以源地址要设成固定模式;而内存 buffer 地址要自增。
  • 数据宽度对齐: UART 数据寄存器是 8 位或 9 位,DMA 传输宽度必须匹配。我见过有人把宽度设成 32 位,结果一次搬了 4 个字节,接收端直接乱码。
  • 循环模式 vs 单次模式: 连续接收用循环模式,buffer 满了自动从头开始;不定长接收用单次模式,每次接收完重新配置。
参数 UART 发送 DMA UART 接收 DMA
源地址 内存 buffer(自增) UART 数据寄存器(固定)
目标地址 UART 数据寄存器(固定) 内存 buffer(自增)
传输方向 内存 → 外设 外设 → 内存
中断触发 传输完成中断 传输完成 / 半传输中断

4.2 UART DMA 收发实现

先看发送。发送 DMA 其实最简单:把要发的数据放到 buffer,启动 DMA,然后等着传输完成中断就行。我曾经在一个项目里用 DMA 发送 64 字节的日志帧,CPU 只在启动 DMA 时干预一次,后面完全解放。

// 伪代码:UART DMA 发送
uint8_t tx_buffer[] = "Hello DMA!\r\n";
DMA_Channel->CPAR = (uint32_t)&USART1->DR;   // 外设地址
DMA_Channel->CMAR = (uint32_t)tx_buffer;    // 内存地址
DMA_Channel->CNDTR = sizeof(tx_buffer);     // 传输字节数
DMA_Channel->CCR |= DMA_CCR_MINC;           // 内存地址自增
DMA_Channel->CCR |= DMA_CCR_EN;             // 启动 DMA
// CPU 可以去做其他任务了
// 传输完成后进入 DMA 中断,关闭通道或重新装载

接收稍微复杂一点。接收 DMA 需要提前准备一个环形 buffer,DMA 不断往里面填数据。我一般开一个 256 字节或 512 字节的 buffer,配合循环模式使用。

小技巧: 接收 buffer 大小建议设为 2 的幂次(256、512、1024),这样取模运算可以用位运算代替,效率更高。

4.3 DMA 中断 + 空闲中断:不定长接收的黄金组合

这是 UART DMA 最实用的场景。你想想看,如果对方发来的数据长度不确定,DMA 怎么知道什么时候该停下来?答案是:靠 UART 的空闲中断(IDLE Interrupt)。

原理很简单:DMA 一直在后台收数据,当 UART 总线空闲超过一个字节时间时,硬件自动触发空闲中断。在中断服务函数里,我们读取 DMA 当前还剩下多少字节没搬(CNDTR 寄存器),就能算出实际收到了多少数据。

// 伪代码:空闲中断 + DMA 不定长接收
void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_IDLE) {
        // 清除空闲标志(读 SR 再读 DR)
        uint8_t tmp = USART1->DR;
        (void)tmp;

        // 计算已接收字节数
        uint16_t remain = DMA1_Channel5->CNDTR;
        uint16_t received = RX_BUFFER_SIZE - remain;

        // 处理 received 字节的数据
        process_rx_data(rx_buffer, received);

        // 重新配置 DMA,准备下一次接收
        DMA1_Channel5->CNDTR = RX_BUFFER_SIZE;
        DMA1_Channel5->CCR |= DMA_CCR_EN;
    }
}
注意: 空闲中断标志的清除方式因芯片而异。STM32 需要先读 SR 再读 DR,有些芯片需要写 1 清除。务必查阅参考手册,否则会一直进中断。

我在一个物联网网关项目里用过这个方案,对接 4G 模块的 AT 指令。模块返回的数据长度从几个字节到几百个字节不等,用空闲中断 + DMA 完美解决,CPU 占用率不到 3%。

4.4 低功耗串口设计

低功耗是嵌入式绕不开的话题。UART 在空闲时怎么省电?我总结了几个实战经验:

  • 时钟门控: 不通信时关闭 UART 外设时钟,需要时再打开。注意重新打开后要重新配置波特率。
  • DMA 与睡眠模式结合: 让 CPU 进入睡眠模式,DMA 在后台接收数据。数据到达后,DMA 传输完成中断唤醒 CPU。
  • 波特率动态调整: 低速通信时用 9600,高速传输时切换到 115200。我做过一个电池供电的传感器,平时 9600 波特率待机,上传数据时切到 460800,功耗降了 40%。
策略 功耗节省 代价
时钟门控 中等(关闭外设时钟) 重新初始化延迟
DMA + 睡眠 高(CPU 深度睡眠) 需要 DMA 唤醒支持
动态波特率 中等(缩短传输时间) 协议需要协商波特率

嗯,这里要特别提醒:低功耗设计一定要考虑唤醒时间。有些芯片从停止模式唤醒需要几百微秒,如果波特率太高,前几个字节可能会丢失。我一般会在数据包前面加几个字节的前导码,给 MCU 留足唤醒时间。

UART DMA 不定长接收流程 发送端(主机/传感器) UART 总线 UART 外设 DMA 控制器 DMA 请求 数据搬运 接收 Buffer 空闲中断 总线空闲 通知 CPU CPU 处理数据 关键流程说明: 1. 发送端通过 UART 总线发送不定长数据 2. DMA 自动将数据从 UART 外设搬运到内存 Buffer,CPU 不参与 3. 总线空闲时触发空闲中断,CPU 读取 DMA 剩余计数,计算实际接收长度

最后说一句,DMA 虽然强大,但不是万能的。如果数据量很小(比如几个字节),中断方式的效率反而更高——因为 DMA 的初始化开销可能比中断处理还大。我一般以 16 字节为分界线,小于 16 字节用中断,大于 16 字节用 DMA。这个阈值你可以根据实际项目调优。