1. 嵌入式驱动开发经验 · 第 6 期 / DMA
搞嵌入式驱动开发,绕不开的一个话题就是 DMA。你可以不会写文件系统,可以没碰过 USB 协议栈,但只要你的项目里涉及串口高速收发、ADC 连续采样、SPI 大批量数据传输,DMA 就是那道必须迈过去的坎。我见过太多人在这上面栽跟头——串口收数据丢包、ADC 采样数据错位、SPI 传输速度上不去,排查半天最后发现是 DMA 配置的一个小细节没搞对。
这一期我就把这些年折腾 DMA 的经验系统梳理一遍。从 DMA 到底解决了什么问题,到 STM32 上 DMA 的控制器架构、通道映射、传输模式,再到串口 DMA 收发、ADC 连续采样、SPI DMA 传输这几个典型场景的完整配置流程,最后聊聊 DMA 测速怎么做、常见问题怎么排查。内容会以 STM32F103C8T6 和 ESP32 这两个大家最常用的平台为例来展开,但底层逻辑是通用的,换到其他 MCU 上思路一样。
这篇文章适合已经能跑通基本外设驱动、想进一步搞明白 DMA 怎么用好的嵌入式开发者。如果你刚入门,建议先把串口轮询收发和中断收发搞明白再来看,效果会更好。下面进入正题。
2. DMA 到底解决了什么问题
2.1 没有 DMA 的世界是什么样的
先想一个最朴素的场景:你用 STM32 的串口以 115200 波特率接收一帧 128 字节的数据。不用 DMA 的话,常规做法是每收到一个字节触发一次中断,在中断服务函数里把数据读出来存到缓冲区。115200 波特率下,每个字节的传输时间大约是 87 微秒,也就是说 CPU 每 87 微秒就要被打断一次。128 个字节就是 128 次中断,期间 CPU 什么都干不了,只能围着串口转。
这还只是 115200。如果你把波特率提到 921600 甚至更高,中断频率直接翻好几倍,CPU 大部分时间都在进出中断,主循环里的业务逻辑基本没法正常执行。更别说 ADC 连续采样场景了——假设你用 ADC 以 1MHz 的采样率连续采集,每次转换完成都触发中断让 CPU 去读数据,那 CPU 基本上就废了,全在处理 ADC 中断。
这就是 DMA 要解决的核心问题:把数据搬运这件事从 CPU 手里接过来,让 CPU 只负责处理数据,不负责搬数据。DMA 全称 Direct Memory Access,直接内存访问,它就是一个独立于 CPU 的数据搬运工。你告诉它从哪搬、搬到哪、搬多少,它自己就干完了,干完通知你一声就行。
2.2 DMA 的工作原理与核心价值
DMA 控制器本质上是一个独立的外设,它内部有源地址寄存器、目标地址寄存器、传输长度寄存器、控制寄存器等。你配置好这些寄存器之后,启动传输,DMA 控制器就会在总线空闲的时候,一个字节一个字节(或者一个字一个字)地把数据从源地址搬到目标地址,全程不需要 CPU 参与。
这里有个关键点需要理解:DMA 和 CPU 共享总线。当 DMA 在搬运数据时,它会占用总线带宽。如果 DMA 传输速率太高,CPU 访问内存的速度会受到影响。但在绝大多数嵌入式场景下,DMA 传输的数据量相对总线带宽来说很小,这个影响可以忽略。真正需要关注总线争用的是那些高带宽场景,比如摄像头数据采集、高速 ADC 连续采样等。
DMA 带来的价值可以总结为三条:
- 解放 CPU:CPU 不用再逐字节搬运数据,可以把时间花在数据处理、协议解析、控制逻辑上。
- 提高吞吐量:DMA 搬运数据的速度远高于 CPU 逐字节读写,尤其是在高速数据流场景下,DMA 几乎是唯一可行的方案。
- 降低功耗:CPU 不用频繁进出中断,可以在 DMA 搬运数据时进入低功耗模式,等 DMA 传输完成再唤醒。
我个人的经验是,只要你的外设数据速率超过 100kbps,或者数据量超过几十个字节,就应该考虑用 DMA。串口、SPI、I2C、ADC、DAC、定时器这些外设都支持 DMA 请求,用好了能极大提升系统效率。
2.3 DMA 与中断、轮询的对比
为了让你更直观地理解三者的区别,我整理了一个对比表格:
| 方式 | CPU 占用 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 轮询 | 极高 | 极低速、简单场景 | 实现简单,无需中断 | CPU 全程等待,效率极低 |
| 中断 | 中等 | 中低速、数据量小 | 实时性好,CPU 利用率较高 | 高频中断时 CPU 开销大 |
| DMA | 极低 | 高速、大批量数据 | CPU 几乎不参与,吞吐量高 | 配置复杂,调试难度大 |
这个表格不是绝对的。比如串口接收,你可以用“DMA + 空闲中断”的组合,DMA 负责搬数据,空闲中断负责判断一帧结束,这样既高效又可靠。实际项目中,这三种方式往往是混合使用的,关键是根据场景选最合适的方案。
3. STM32F103C8T6 的 DMA 架构与通道映射
3.1 DMA 控制器结构
STM32F103C8T6 内部有 2 个 DMA 控制器,DMA1 和 DMA2。DMA1 有 7 个通道,DMA2 有 5 个通道。每个通道都可以独立配置,但同一时间一个通道只能服务一个外设请求。这一点很重要——如果你把两个外设映射到了同一个 DMA 通道上,它们会冲突,必须分时复用或者换通道。
每个 DMA 通道都有以下几个关键寄存器:
- CPAR(外设地址寄存器):存放外设数据寄存器的地址,比如串口的数据寄存器 USART1->DR。
- CMAR(存储器地址寄存器):存放内存缓冲区的地址。
- CNDTR(数据传输数量寄存器):设置要传输的数据个数,每传输一个数据自动减 1,减到 0 时触发传输完成中断。
- CCR(通道配置寄存器):配置传输方向、数据宽度、优先级、循环模式、中断使能等。
DMA 的传输方向有三种:外设到存储器、存储器到外设、存储器到存储器。串口接收是外设到存储器,串口发送是存储器到外设,内存拷贝是存储器到存储器。注意,STM32F103 的 DMA1 不支持存储器到存储器传输,只有 DMA2 支持,而且必须用通道 1 或者通道 7 的特定配置。
3.2 通道映射关系
STM32F103 的 DMA 通道和外设的映射关系是固定的,不能随意更改。这个映射表在参考手册的 DMA 请求映射章节有详细说明。我挑几个常用的列出来:
| 外设 | DMA 控制器 | 通道 | 传输方向 |
|---|---|---|---|
| USART1_TX | DMA1 | 通道 4 | 存储器到外设 |
| USART1_RX | DMA1 | 通道 5 | 外设到存储器 |
| USART2_TX | DMA1 | 通道 7 | 存储器到外设 |
| USART2_RX | DMA1 | 通道 6 | 外设到存储器 |
| SPI1_TX | DMA1 | 通道 3 | 存储器到外设 |
| SPI1_RX | DMA1 | 通道 2 | 外设到存储器 |
| ADC1 | DMA1 | 通道 1 | 外设到存储器 |
| TIM2_CH3 | DMA1 | 通道 1 | 外设到存储器 |
| TIM4_CH1 | DMA1 | 通道 7 | 外设到存储器 |
这张表建议你打印出来贴在工位上。我刚开始搞 DMA 的时候,经常把通道号记混,配置半天没反应,最后发现是通道选错了。尤其是 USART1 和 USART2 的收发通道,特别容易搞混。
3.3 数据宽度与对齐
DMA 传输时,源地址和目标地址的数据宽度可以独立配置,支持字节(8 位)、半字(16 位)、字(32 位)三种。这里有个坑:如果源和目标的数据宽度不一致,DMA 会按照目标宽度进行对齐处理。
举个例子,你从 ADC 数据寄存器(16 位)搬运到内存缓冲区(定义为 uint16_t 数组),源和目标都是半字,没问题。但如果你把内存缓冲区定义成了 uint8_t 数组,源是半字、目标是字节,DMA 会把每个 16 位数据截断成 8 位存储,高 8 位直接丢掉。这个行为在参考手册里有说明,但很多人不看手册,调试时发现数据不对,还以为是 ADC 配置有问题。
我的建议是:源和目标的数据宽度尽量保持一致。ADC 数据用 uint16_t 存,SPI 数据根据实际情况用 uint8_t 或 uint16_t,串口数据用 uint8_t。这样最不容易出问题。
4. 串口 DMA 收发实战
4.1 串口 DMA 发送配置
串口 DMA 发送是最简单的场景。你只需要配置好 DMA 通道,把内存缓冲区的数据搬到串口数据寄存器就行。以 USART1_TX 为例,用 DMA1 通道 4:
void USART1_DMA_TX_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)tx_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = TX_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel4, &DMA_InitStructure); DMA_Cmd(DMA1_Channel4, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }几个关键点解释一下:
DMA_PeripheralBaseAddr填的是外设数据寄存器的地址,不是外设基地址。USART1 的基地址是 0x40013800,数据寄存器偏移是 0x04,所以是&USART1->DR。DMA_DIR_PeripheralDST表示传输方向是存储器到外设。DMA_PeripheralInc_Disable表示外设地址不递增,因为串口数据寄存器地址固定。DMA_MemoryInc_Enable表示内存地址递增,这样才会一个字节一个字节往后搬。DMA_Mode_Normal表示普通模式,传输完指定数量就停止。如果要循环发送,改成DMA_Mode_Circular。
发送的时候,你只需要把数据填到tx_buffer,设置好传输长度,使能 DMA 通道就行。传输完成后 DMA 会触发中断,在中断里你可以做后续处理,比如发送下一帧或者置标志位。
4.2 串口 DMA 接收与空闲中断
串口 DMA 接收比发送稍微复杂一点,因为接收的数据长度是不确定的。你不可能预知对方会发多少字节过来,所以不能用普通模式等 DMA 传输完成。常规做法是DMA 循环模式 + 串口空闲中断。
DMA 配置成循环模式,缓冲区大小设为最大帧长度(比如 256 字节)。DMA 会一直往缓冲区里填数据,填到末尾自动回到开头继续填。串口空闲中断在总线空闲时触发,此时你读取 DMA 的 CNDTR 寄存器,就能算出收到了多少字节。
void USART1_DMA_RX_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RX_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); }空闲中断的处理函数里,计算接收长度的方式是:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); uint16_t recv_len = RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 处理接收到的数据 process_rx_data(rx_buffer, recv_len); // 重新设置 DMA 传输长度 DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); } }这里有个细节要注意:读取 CNDTR 的时机。空闲中断触发时,DMA 可能还在搬运最后一个字节,所以最好在中断里稍微等一下再读,或者用双缓冲方案。我实测下来,在 115200 波特率下直接读基本没问题,但在更高波特率下偶尔会差一个字节。稳妥的做法是用 DMA 传输完成中断配合空闲中断,或者用双缓冲交替接收。
4.3 串口 DMA 接收的常见坑
第一个坑是缓冲区溢出。如果对方发送的数据超过了缓冲区大小,DMA 循环模式会覆盖之前的数据。解决办法是缓冲区开大一点,或者在空闲中断里及时处理数据。
第二个坑是空闲中断触发时机。串口空闲中断是在总线空闲一个字节时间后触发。如果对方发送的数据帧之间间隔很短,空闲中断可能来不及处理,导致数据被覆盖。这种情况建议用 DMA 传输完成中断,或者用定时器做超时判断。
第三个坑是DMA 和中断的优先级。如果 DMA 传输完成中断和串口空闲中断同时触发,优先级配置不当会导致数据丢失。建议把 DMA 中断优先级设得比串口中断高,确保数据先搬完再处理。
5. ADC 连续采样与 DMA 搬运
5.1 ADC 多通道扫描 + DMA
ADC 连续采样是 DMA 的经典应用场景。假设你要采集 4 个通道的 ADC 数据,用规则组扫描模式,配合 DMA 把数据搬到内存数组里。配置流程如下:
#define ADC_CHANNELS 4 uint16_t adc_values[ADC_CHANNELS]; void ADC_DMA_Init(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // GPIO 配置略 DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_values; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_CHANNELS; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = ENABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = ADC_CHANNELS; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }这个配置里,ADC 工作在连续转换模式,扫描 4 个通道,每次转换完成 DMA 自动把数据搬到adc_values数组。DMA 配置成循环模式,数组填满后自动从头开始覆盖。你只需要在需要的时候读adc_values就行,完全不用管 ADC 中断。
5.2 安富莱 AD7606 的 DMA 应用
安富莱的 AD7606 模块是很多做电力监测、振动分析的朋友常用的。AD7606 是 8 通道 16 位 ADC,支持并行和串行接口。用 STM32 驱动 AD7606 时,如果采样率要求高,必须用 DMA。
AD7606 的并行接口模式下,每次转换完成后,8 个通道的数据会依次出现在数据总线上。你可以用定时器触发 DMA,在每个定时器周期读取一次数据总线,把 8 个通道的数据搬到内存。这里的关键是定时器触发 DMA 的配置。
STM32 的定时器可以产生 DMA 请求,比如 TIM2 的更新事件可以触发 DMA。配置方式是把定时器的 DMA 请求使能,然后在 DMA 通道里配置源地址为 GPIO 数据寄存器,目标地址为内存数组。每次定时器更新,DMA 就搬运一次数据。
这种方案的好处是采样率非常精确,由定时器决定,不受 CPU 负载影响。我用这个方案做过 10kHz 的 8 通道同步采样,数据非常稳定。
5.3 ADC DMA 的注意事项
第一,ADC 的采样时间要足够。如果采样时间太短,ADC 内部采样保持电容没充够电,转换结果会不准。STM32F103 的 ADC 时钟最高 14MHz,采样时间建议至少 55.5 个周期。如果信号源内阻较大,还要加长采样时间。
第二,DMA 缓冲区大小要匹配通道数。如果你扫描 4 个通道,缓冲区大小必须是 4 的整数倍,否则数据会错位。我见过有人缓冲区开了 10 个元素,结果 4 通道数据填进去后,第 3 次扫描就乱了。
第三,ADC 和 DMA 的启动顺序。先配置 DMA,再配置 ADC,最后使能 ADC 的 DMA 请求。如果顺序反了,可能第一次转换的数据丢失。
6. SPI DMA 传输与测速
6.1 SPI DMA 配置要点
SPI 用 DMA 传输,核心是把 SPI 的数据寄存器作为 DMA 的源或目标。以 SPI1 为例,TX 用 DMA1 通道 3,RX 用 DMA1 通道 2。配置和串口类似,但有几个特殊点:
- SPI 是全双工的,发送和接收同时进行。如果你只想发送,可以只配置 TX DMA,但 RX 的数据会被丢弃。如果想同时收发,需要配置两个 DMA 通道。
- SPI 的 DMA 请求使能方式是
SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx | SPI_I2S_DMAReq_Rx, ENABLE)。 - SPI 传输完成后,除了 DMA 传输完成中断,还要等 SPI 的 BUSY 标志清除,确保最后一个字节真正发出去。
void SPI1_DMA_Transfer(uint8_t *tx_data, uint8_t *rx_data, uint16_t len) { DMA_Cmd(DMA1_Channel3, DISABLE); DMA_Cmd(DMA1_Channel2, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, len); DMA_SetCurrDataCounter(DMA1_Channel2, len); DMA1_Channel3->CMAR = (uint32_t)tx_data; DMA1_Channel2->CMAR = (uint32_t)rx_data; DMA_Cmd(DMA1_Channel3, ENABLE); DMA_Cmd(DMA1_Channel2, ENABLE); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx | SPI_I2S_DMAReq_Rx, ENABLE); while(DMA_GetFlagStatus(DMA1_FLAG_TC3) == RESET); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); DMA_ClearFlag(DMA1_FLAG_TC3); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx | SPI_I2S_DMAReq_Rx, DISABLE); }6.2 DMA 测速方法与工具
DMA 测速的目的是搞清楚在当前配置下,DMA 搬运数据的实际速率是多少。这个数据对于评估系统瓶颈、优化传输方案很重要。
测速的基本方法是:在 DMA 传输前后读取系统滴答定时器(SysTick)的计数值,算出传输耗时,再用数据量除以时间得到速率。
uint32_t start_tick, end_tick; uint32_t elapsed_us; float speed_mbps; start_tick = SysTick->VAL; SPI1_DMA_Transfer(tx_buf, rx_buf, 4096); end_tick = SysTick->VAL; if (end_tick < start_tick) elapsed_us = (start_tick - end_tick) / (SystemCoreClock / 1000000); else elapsed_us = (SystemCoreClock / 1000000 - end_tick + start_tick) / (SystemCoreClock / 1000000); speed_mbps = (4096 * 8.0) / elapsed_us;这个方法的精度取决于 SysTick 的频率。72MHz 的 SysTick 每微秒计数 72 次,测几毫秒的传输没问题,但测几十微秒的传输误差就比较大。更精确的做法是用定时器在 PWM 输出模式下测量,或者用逻辑分析仪抓 SPI 时钟线。
我实测过 STM32F103 在 72MHz 主频下,SPI1 用 DMA 传输 4KB 数据,SPI 时钟 18MHz,传输耗时大约 1.9ms,速率约 17.2Mbps。这个速率已经接近 SPI 时钟的理论上限了,说明 DMA 没有成为瓶颈。
6.3 DMA 测速失败的原因排查
测速失败通常有几种表现:测出来速率远低于预期、测速过程中程序卡死、测速结果不稳定。
速率远低于预期,最常见的原因是 DMA 传输模式配置成了普通模式但没重新使能,或者 SPI 时钟分频系数设得太大。检查一下 SPI 的波特率预分频器,确保 SPI 时钟是你想要的频率。
程序卡死,通常是等待 DMA 传输完成标志时死循环了。可能原因包括:DMA 通道没使能、SPI 的 DMA 请求没使能、传输长度设为 0、或者中断优先级冲突导致 DMA 中断没响应。建议在等待循环里加超时计数,超时后打印调试信息。
测速结果不稳定,可能是 SysTick 被其他中断打断,导致计时不准。可以临时关闭全局中断再测速,或者用硬件定时器计时。
7. ESP32 的 DMA 接收与发送端配合
7.1 ESP32 DMA 架构简介
ESP32 的 DMA 架构和 STM32 不太一样。ESP32 的外设 DMA 是通过 GDMA(通用 DMA)控制器实现的,每个外设(SPI、I2S、UART 等)可以连接到特定的 DMA 通道。ESP32 的 UART 支持 DMA 接收,但配置方式比 STM32 复杂一些,通常通过 ESP-IDF 的驱动层来配置。
在 ESP-IDF 里,UART 的 DMA 接收可以通过uart_driver_install的参数来使能。比如:
uart_driver_install(UART_NUM_1, 1024, 1024, 20, &uart_queue, 0);这里的 1024 是 RX 缓冲区大小,20 是事件队列大小。ESP-IDF 内部会自动使用 DMA 来搬运 UART 数据,你只需要从事件队列里读取数据就行。
7.2 发送端需要改变发送方式吗
这个问题在热词里出现了:“esp32 dma接收rx数据发送端需要改变发送方式吗”。答案是:通常不需要。DMA 接收只是接收端的事情,发送端该怎么发还怎么发。DMA 接收对发送端是透明的,发送端不需要知道接收端用的是 DMA 还是中断。
但有一种情况需要注意:如果接收端用 DMA 接收,但发送端发送的数据帧之间没有足够的间隔,接收端的空闲中断可能来不及处理,导致数据被覆盖。这种情况下,发送端需要适当增加帧间延时,或者在协议层加帧头帧尾,让接收端能正确分帧。
另外,如果发送端和接收端的波特率不匹配,DMA 接收会收到错误数据。DMA 本身不校验数据,它只是搬运工。数据校验要靠串口外设的奇偶校验或者协议层的 CRC。
7.3 ESP32 DMA 接收的实操建议
ESP32 的 UART DMA 接收,我建议用事件队列的方式。ESP-IDF 会在收到数据、接收缓冲区满、接收超时等情况下往事件队列里发事件。你在任务里循环读取事件队列,根据事件类型处理数据。
uart_event_t event; while (1) { if (xQueueReceive(uart_queue, &event, portMAX_DELAY)) { switch (event.type) { case UART_DATA: uart_read_bytes(UART_NUM_1, rx_buf, event.size, portMAX_DELAY); process_data(rx_buf, event.size); break; case UART_FIFO_OVF: case UART_BUFFER_FULL: uart_flush_input(UART_NUM_1); xQueueReset(uart_queue); break; default: break; } } }这种方式的优点是可靠性高,ESP-IDF 帮你处理了 DMA 的细节。缺点是灵活性不如直接操作寄存器,但对于大多数应用来说足够了。
8. DMA 常见问题速查与避坑经验
8.1 DMA 配置检查清单
每次配置 DMA 时,按这个清单过一遍,能避免 90% 的问题:
| 检查项 | 说明 |
|---|---|
| 时钟使能 | DMA 控制器时钟、外设时钟都要使能 |
| 通道选择 | 确认外设对应的 DMA 通道号正确 |
| 传输方向 | 外设到存储器还是存储器到外设 |
| 地址递增 | 外设地址通常不递增,存储器地址通常递增 |
| 数据宽度 | 源和目标宽度尽量一致 |
| 传输模式 | 普通模式还是循环模式 |
| 中断配置 | 是否需要传输完成中断 |
| 外设 DMA 请求 | 外设的 DMA 请求要使能 |
8.2 典型问题与解决方法
问题一:DMA 传输一次后就不再传输了。原因是配置成了普通模式,传输完成后 DMA 通道自动关闭。解决办法是改成循环模式,或者在每次传输完成后重新使能 DMA 通道。
问题二:DMA 传输的数据错位。常见原因是数据宽度不匹配,或者存储器地址递增配置错误。检查DMA_MemoryInc和DMA_PeripheralDataSize的设置。
问题三:DMA 传输完成中断不触发。检查中断使能位是否设置,NVIC 中断是否使能,中断优先级是否配置。另外,有些 DMA 通道的传输完成标志需要手动清除,否则下次中断不会触发。
问题四:DMA 和 CPU 同时访问同一块内存导致数据不一致。这种情况通常发生在 DMA 传输过程中 CPU 读取了缓冲区。解决办法是用双缓冲,或者等 DMA 传输完成再读。
问题五:DMA 传输速率上不去。检查总线时钟、外设时钟、DMA 优先级。如果多个 DMA 通道同时工作,高优先级通道会抢占低优先级通道的带宽。
8.3 我的实操心得
第一个心得:DMA 调试一定要用逻辑分析仪或者示波器。光看代码和打印信息很难定位问题。用逻辑分析仪抓 SPI 时钟线和数据线,一眼就能看出是 DMA 没搬数据还是外设没发数据。
第二个心得:DMA 缓冲区用 volatile 修饰。因为 DMA 会在后台修改缓冲区内容,编译器可能会优化掉对缓冲区的读取。加上 volatile 告诉编译器每次都从内存读,不要用寄存器缓存。
第三个心得:DMA 传输长度寄存器在传输过程中是只读的。你不能在 DMA 传输过程中修改 CNDTR,必须先关闭 DMA 通道再修改。这一点在动态调整传输长度时特别重要。
第四个心得:双缓冲是解决数据覆盖的终极方案。STM32F103 的 DMA 不支持硬件双缓冲,但可以用两个缓冲区交替使用。DMA 填缓冲区 A 的时候,CPU 处理缓冲区 B,填满后切换。这样能实现无缝数据流处理。
9. 定时器 DMA Burst 应用
9.1 TIM DMA Burst 是什么
STM32 的高级定时器(TIM1、TIM8)支持 DMA Burst 传输。这个功能允许你在一次 DMA 请求中,连续写入多个定时器寄存器。比如你可以用一次 DMA 传输同时更新 TIM1 的 CCR1、CCR2、CCR3、CCR4 四个比较寄存器,实现多通道 PWM 的同步更新。
DMA Burst 的配置方式是在 TIM 的 DMA 控制寄存器里设置 burst 长度和起始寄存器。比如设置 burst 长度为 4,起始寄存器为 CCR1,那么每次 DMA 请求会连续写入 CCR1 到 CCR4。
这个功能在电机控制、多路 LED 调光等场景下非常有用。传统的做法是每次更新一个 CCR 就要触发一次 DMA,四次更新需要四次 DMA 请求。用 Burst 模式,一次 DMA 请求就搞定,效率高很多。
9.2 TIM DMA Burst 配置示例
void TIM1_DMA_Burst_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&TIM1->DMAR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)pwm_values; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 4; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE); TIM_DMAConfig(TIM1, TIM_DMABase_CCR1, TIM_DMABurstLength_4Transfers); DMA_Cmd(DMA1_Channel5, ENABLE); }这个配置里,TIM_DMABurstLength_4Transfers表示每次 DMA 请求连续传输 4 个半字,分别写入 CCR1、CCR2、CCR3、CCR4。pwm_values数组里存的就是四个通道的 PWM 占空比。
9.3 TIM DMA Burst 的注意事项
第一,Burst 长度要和实际使用的通道数匹配。如果你只用了 3 个通道,但 Burst 长度设成了 4,会多写一个寄存器,可能影响其他功能。
第二,DMA 请求源要选对。TIM 的 DMA 请求可以来自更新事件、捕获比较事件等。PWM 更新通常用更新事件触发。
第三,Burst 传输的寄存器地址是连续的。CCR1 到 CCR4 的地址是连续的,所以 DMA 外设地址不递增也能正确写入。但如果起始寄存器不是 CCR1,比如从 CCR2 开始,那就要注意地址偏移。
10. 收尾
DMA 这个东西,说难不难,说简单也不简单。核心就那几件事:选对通道、配对方向、设好宽度、配好模式。但每个细节没注意到,都可能让你调试半天。我这些年踩过的坑,基本上都在上面列出来了。
如果你刚开始用 DMA,建议从串口 DMA 发送开始,这个最简单,容易看到效果。然后试试 ADC 多通道扫描 + DMA,理解循环模式和普通模式的区别。最后再搞串口 DMA 接收 + 空闲中断,这个是最常用的组合,也是坑最多的。
测速这块,别太纠结绝对数值,关键是看瓶颈在哪。如果 DMA 传输速率远低于外设理论速率,那就要检查总线时钟、DMA 优先级、外设配置。如果接近理论速率,说明 DMA 已经跑满了,再优化就得换更高性能的 MCU 或者换外设接口了。
最后分享一个小技巧:调试 DMA 时,把 DMA 传输完成中断打开,在中断里翻转一个 GPIO,用示波器看这个 GPIO 的波形,就能直观地看到 DMA 传输的频率和耗时。这个方法比打印调试信息靠谱得多,也不会影响 DMA 的实时性。