3. DMA控制器原理:传输流程、地址配置与突发传输
DMA这东西,说白了就是给CPU找了个搬运工。CPU只管发号施令,搬数据的事全交给DMA干。我刚开始做嵌入式时,总觉得DMA可有可无,直到有一次项目里CPU被中断占用了80%的时间……嗯,从那以后我再也不敢轻视DMA了。
3.1 DMA传输流程:从触发到完成
一个完整的DMA传输,其实就三步:请求→传输→结束。你想想看,跟你去快递站取包裹差不多——先叫号(请求),然后搬走(传输),最后签收(结束)。
核心流程拆解:
- 外设触发:SPI的RXNE(接收非空)或TXE(发送空)信号拉高,通知DMA控制器“我有数据要搬”。
- DMA仲裁:多个DMA通道同时请求时,仲裁器按优先级决定谁先上。我习惯把SPI的DMA通道设成最高优先级,毕竟SPI时钟不等人。
- 地址递增:DMA从源地址读一个数据,写到目的地址。源/目的地址可以自动加1,也可以固定不变。
- 传输计数递减:每传完一笔,计数器减1。减到0时,传输完成。
- 中断上报:传输完成或出错时,DMA给CPU发中断。我曾经踩过一个坑——忘了清传输完成标志,结果中断一直进,CPU直接卡死。
这里有个细节:DMA传输是字节对齐的。你设了4字节宽度,那传输次数必须是4的倍数。不然最后几个字节会丢,我吃过这个亏。
3.2 源地址与目的地址配置
地址配置是DMA调优的关键。配置错了,数据要么搬错地方,要么搬不全。我个人习惯把地址配置分成三种场景:
| 场景 | 源地址模式 | 目的地址模式 | 典型应用 |
|---|---|---|---|
| 外设→内存 | 固定(外设数据寄存器) | 递增(内存缓冲区) | SPI接收 |
| 内存→外设 | 递增(内存缓冲区) | 固定(外设数据寄存器) | SPI发送 |
| 内存→内存 | 递增(源缓冲区) | 递增(目的缓冲区) | 数据拷贝 |
你可能会问:为什么外设地址要固定?因为SPI的数据寄存器就一个地址,你读它一次,它自动更新数据。地址递增反而会读到别的地方去。
我的配置习惯:
- 外设地址:永远设成固定模式,除非你确定外设有FIFO且支持地址自增。
- 内存地址:环形缓冲区用递增模式,但要注意缓冲区边界回绕。
- 传输方向:从外设角度定义,别搞反了。我见过有人把SPI发送配成从内存读数据到外设,结果DMA一直在往外设写空数据。
3.3 传输宽度:字节、半字还是字?
传输宽度决定了DMA一次搬多少数据。STM32的DMA支持三种宽度:
- 字节(8位):一次搬1字节,适合SPI配置为8位数据帧。
- 半字(16位):一次搬2字节,适合SPI配置为16位数据帧。
- 字(32位):一次搬4字节,适合SPI配置为32位数据帧或内存间拷贝。
这里有个容易翻车的地方:源宽度和目的宽度可以不相等。比如SPI是8位外设,但内存缓冲区是32位的。DMA会自动做数据打包或拆包。但要注意——打包时高位补0,拆包时低位保留。我曾在项目中用8位SPI接收16位音频数据,结果DMA自动打包后,数据顺序全乱了。
宽度不匹配的坑:
- 源宽度 < 目的宽度:DMA会把多个源数据拼成一个目的数据。顺序是LSB先入。
- 源宽度 > 目的宽度:DMA会把一个源数据拆成多个目的数据。只保留低位。
- 建议:尽量保持源和目的宽度一致,避免不必要的字节序处理。
3.4 突发传输:一次搬一车
突发传输(Burst Transfer)是DMA性能的关键。普通传输一次搬一个数据,突发传输一次搬一整个数据块。你想想看,从仓库搬箱子,一次搬一个和一次搬一车,效率能一样吗?
突发传输的配置参数主要有两个:
- 突发大小:4拍、8拍、16拍(具体看芯片支持)。我一般选8拍,平衡总线占用和传输效率。
- 突发类型:固定突发(地址连续)和增量突发(地址跳跃)。SPI用固定突发就够了。
突发传输的好处很明显:
- 减少总线仲裁次数,提高吞吐量。
- 降低CPU干预频率,适合大数据量传输。
但也不是越大越好。突发太大,会长时间占用总线,其他外设(比如定时器、ADC)可能抢不到总线。我有个项目,SPI DMA设了16拍突发,结果定时器中断延迟从5μs飙到50μs,电机控制直接抖成筛子。
突发传输配置建议:
- SPI速率 < 10MHz:用4拍突发就够了。
- SPI速率 10-30MHz:用8拍突发,平衡性能和总线占用。
- SPI速率 > 30MHz:用16拍突发,但务必确认总线带宽够用。
- 多外设共享DMA时:用4拍突发,避免饿死其他外设。
3.5 DMA传输的完整流程(SVG图)
下面这张图,把DMA从触发到完成的整个流程串起来了。我建议你对照着看,比光看文字清楚得多。
3.6 实战配置示例:SPI DMA接收
拿STM32F4系列举例,配置一个SPI DMA接收通道。我习惯用HAL库,但底层寄存器也得懂,不然出了问题不好定位。
// SPI DMA接收配置(以STM32F4为例)
void SPI_DMA_Receive_Config(void)
{
// 1. 使能DMA时钟
__HAL_RCC_DMA2_CLK_ENABLE();
// 2. 配置DMA流(Stream 0,通道3对应SPI1_RX)
hdma_spi_rx.Instance = DMA2_Stream0;
hdma_spi_rx.Init.Channel = DMA_CHANNEL_3;
hdma_spi_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设→内存
hdma_spi_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址固定
hdma_spi_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_spi_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 8位
hdma_spi_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; // 8位
hdma_spi_rx.Init.Mode = DMA_CIRCULAR; // 循环模式(环形缓冲区)
hdma_spi_rx.Init.Priority = DMA_PRIORITY_HIGH; // 高优先级
hdma_spi_rx.Init.FIFOMode = DMA_FIFOMODE_ENABLE; // 使能FIFO
hdma_spi_rx.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL; // 半满触发
hdma_spi_rx.Init.MemBurst = DMA_MBURST_SINGLE; // 单次突发(先保守)
hdma_spi_rx.Init.PeriphBurst = DMA_PBURST_SINGLE;
// 3. 初始化DMA
HAL_DMA_Init(&hdma_spi_rx);
// 4. 链接DMA到SPI
__HAL_LINKDMA(&hspi1, hdmarx, hdma_spi_rx);
// 5. 启动DMA传输(缓冲区地址、长度)
HAL_SPI_Receive_DMA(&hspi1, rx_buffer, BUFFER_SIZE);
}
配置要点:
- 循环模式:配合环形缓冲区使用,传输完自动从头开始,不用手动重配。
- FIFO阈值:设成半满触发,可以减少中断频率。但要注意FIFO深度,别溢出。
- 突发大小:先设成单次,调通后再改大。我习惯逐步增加,直到总线占用报警为止。
3.7 性能调优:突发传输的实战经验
突发传输不是越大越好。我分享一个实际案例:
项目是做高速数据采集,SPI时钟40MHz,DMA把数据搬进环形缓冲区。一开始设了16拍突发,结果发现:
- DMA传输效率确实高,总线利用率到90%。
- 但定时器中断延迟从3μs涨到30μs,PWM输出开始抖动。
- ADC的DMA传输也被堵住,采样率掉了一半。
后来我把突发降到8拍,总线利用率降到70%,但所有外设都正常工作。你想想看,为了10%的效率提升,牺牲系统实时性,值不值?
突发传输避坑指南:
- 我曾经把突发设成16拍,结果SPI的NSS信号被拉高,通信直接断掉——因为DMA占总线太久,SPI超时了。
- 多通道DMA共享总线时,建议所有通道都用4拍或8拍突发,避免优先级低的通道饿死。
- 如果系统里有时钟敏感的外设(如I2S、定时器),突发大小不要超过8拍。
嗯,DMA控制器的原理就这些。记住一句话:配置越简单,系统越稳定。别为了追求极致性能,把系统搞崩了。