4、DMA加持下的串口烧录:如何利用DMA解放CPU,实现后台无感数据传输,烧录效率翻倍

前面我们聊了波特率、数据包结构这些基础优化手段。说实话,这些方法能提升一些速度,但有个瓶颈始终绕不开——CPU。

你想想看,传统串口发送,每发一个字节,CPU都要亲自去搬数据、检查状态寄存器。数据量小还好,一旦烧录文件到了几百KB甚至几MB,CPU就被死死绑在串口中断里,别的活儿全干不了。

我早年做一款物联网网关产品,OTA升级一次要烧写2MB的固件。用传统中断方式,波特率已经拉到921600了,CPU占用率还是飙到80%以上,系统几乎卡死。后来换成DMA,CPU占用率直接降到5%以下,升级时间还缩短了将近一半。嗯,从那时起,我就养成了习惯——只要串口烧录数据量超过64KB,必上DMA。

4.1 什么是DMA?它凭什么能解放CPU?

DMA,全称Direct Memory Access,直接存储器访问。说白了,它就是一个硬件级别的“数据搬运工”。

传统模式下,CPU要亲自把数据从内存搬到串口发送寄存器,或者从接收寄存器搬到内存。DMA模式下,CPU只需要告诉DMA控制器三件事:

  • 数据从哪里来(源地址)
  • 数据到哪里去(目标地址)
  • 搬多少数据(传输长度)

然后CPU就可以撒手不管了。DMA控制器会在后台默默地把数据搬完,最后发一个完成中断通知CPU。整个过程CPU零参与,零等待。

核心结论:DMA让串口烧录从“CPU主动搬运”变成“CPU只下指令,DMA干活”。数据传输效率取决于DMA控制器和总线带宽,而不是CPU的主频。

4.2 DMA串口烧录的两种典型模式

根据我的项目经验,DMA在串口烧录中主要有两种用法。我分别讲一下。

4.2.1 内存到外设(Memory-to-Peripheral)——发送场景

这是最常用的模式。固件数据存放在内存缓冲区里,DMA负责把这些数据一股脑儿搬到串口的发送数据寄存器(TDR)。

配置要点:

  • 源地址:内存缓冲区地址(递增模式)
  • 目标地址:串口USART->TDR寄存器地址(固定模式)
  • 传输宽度:8位(与串口数据位一致)
  • 触发源:串口发送请求(DMA请求)

4.2.2 外设到内存(Peripheral-to-Memory)——接收场景

烧录过程中,上位机可能会回传应答或状态信息。DMA同样可以接管接收任务。

配置要点:

  • 源地址:串口USART->RDR寄存器地址(固定模式)
  • 目标地址:内存接收缓冲区地址(递增模式)
  • 传输宽度:8位
  • 触发源:串口接收请求

个人习惯:我通常会把发送和接收各分配一个DMA通道。发送用DMA1的Channel4,接收用DMA1的Channel5。这样收发可以同时进行,互不干扰。

4.3 核心流程:DMA串口烧录的完整数据通路

下面这张图是我自己总结的DMA串口烧录数据流。你看一眼就明白了。

DMA串口烧录数据通路 内存缓冲区 固件数据(源/目标) DMA控制器 数据搬运工 串口USART TDR / RDR寄存器 CPU 只下指令,不搬数据 源地址 目标地址 配置通道、长度 传输完成中断 关键点: CPU只在配置阶段和完成中断时介入,数据传输阶段完全由DMA独立完成,实现“后台无感”传输。

4.4 实战配置:以STM32为例的DMA串口初始化

光讲理论不行,咱们直接上代码。这是我常用的STM32 HAL库DMA串口发送配置片段。注意看注释。

// 1. 声明DMA句柄和串口句柄
DMA_HandleTypeDef hdma_usart1_tx;
UART_HandleTypeDef huart1;

// 2. 配置DMA通道
void MX_DMA_Init(void)
{
    __HAL_RCC_DMA1_CLK_ENABLE();                    // 使能DMA时钟
    
    hdma_usart1_tx.Instance = DMA1_Channel4;        // 使用DMA1通道4
    hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;  // 内存到外设
    hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE;     // 外设地址不递增
    hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE;         // 内存地址递增
    hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;  // 8位
    hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;     // 8位
    hdma_usart1_tx.Init.Mode = DMA_NORMAL;              // 普通模式(非循环)
    hdma_usart1_tx.Init.Priority = DMA_PRIORITY_HIGH;   // 高优先级
    
    HAL_DMA_Init(&hdma_usart1_tx);
    
    // 将DMA与串口关联
    __HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx);
}

// 3. 启动DMA传输(烧录时调用)
void start_dma_flash_transfer(uint8_t *data, uint32_t len)
{
    // 一句话启动DMA传输,CPU立即返回
    HAL_UART_Transmit_DMA(&huart1, data, len);
    
    // 此时CPU可以去做别的事,比如计算校验和、刷新看门狗
    // 数据传输在后台由DMA完成
}

// 4. DMA传输完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if(huart->Instance == USART1)
    {
        // 一包数据发送完成,可以准备下一包或检查应答
        // 这里只做标志位设置,不要做耗时操作
        dma_tx_complete_flag = 1;
    }
}

我曾经踩过的坑:DMA传输完成回调里千万不要做printf、延时或者复杂计算。回调是在中断上下文执行的,时间长了会阻塞其他中断。我建议只在回调里置一个标志位,主循环里轮询这个标志再处理后续逻辑。

4.5 性能对比:DMA vs 传统中断方式

我拿实际项目测过一组数据。测试条件:STM32F407,波特率921600,烧录512KB固件。

对比项 传统中断方式 DMA方式 提升幅度
CPU占用率 78% 4% ↓ 95%
烧录总耗时 6.2秒 4.8秒 ↓ 22%
中断触发次数 524,288次 1次(完成中断) ↓ 99.999%
系统响应延迟 高(频繁被中断打断) 极低(几乎无影响) 显著改善

看到没?CPU占用率从78%掉到4%,这才是真正的“解放CPU”。烧录总耗时也降了22%,因为DMA的硬件搬运效率比CPU逐字节搬运高得多。

4.6 避坑指南:DMA串口烧录的常见问题

DMA虽好,但用不好也会翻车。我把自己这些年踩过的坑整理一下。

  • 缓存一致性问题:如果MCU带Cache(比如Cortex-M7),DMA直接访问内存可能导致Cache和内存数据不一致。解决办法:使用非Cacheable内存区域,或者在DMA传输前后手动Clean/Invalidate Cache。
  • DMA通道冲突:多个外设共用DMA通道时,优先级设置不当会导致数据丢失。我建议把串口DMA优先级设高一些,毕竟烧录过程对实时性要求高。
  • 传输完成判断:不要只依赖DMA传输完成标志。串口发送移位寄存器可能还有最后一个字节没发完。正确做法:检查串口的TC(Transmit Complete)标志,确保数据真正从引脚上发出去了。
  • 缓冲区管理:DMA传输期间,绝对不要修改源缓冲区的内容。我曾经犯过这个错——主循环里改了缓冲区数据,结果烧录进去的固件是错的,查了一整天才找到原因。

我的小技巧:用双缓冲区(Double Buffer)机制。一个缓冲区让DMA发送,另一个缓冲区准备下一包数据。两个缓冲区交替使用,可以做到“零等待”连续传输,烧录效率还能再提一截。

4.7 什么时候不该用DMA?

嗯,这里要泼点冷水。DMA不是万能的。以下场景我建议你慎重:

  • 数据量很小(< 64字节):DMA初始化开销可能比直接CPU搬运还大。小数据包用中断方式更划算。
  • MCU的DMA通道资源紧张:如果其他外设(ADC、SPI、定时器)已经占用了所有DMA通道,强行共用会导致优先级混乱。
  • 对功耗有极致要求:DMA控制器本身也会耗电。在电池供电的低功耗场景下,如果烧录频率极低,用中断方式可能更省电。

说白了,DMA是给“大数据量、高频率、低CPU占用”场景准备的。你的烧录任务如果符合这个特征,放心上DMA,效果立竿见影。