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串口烧录数据流。你看一眼就明白了。
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,效果立竿见影。