DMA控制器架构深度解析:通道、信号与传输模式
各位同学,今天我们来啃一块硬骨头——DMA控制器的内部架构。说实话,我刚入行那会儿,觉得DMA就是个“后台搬运工”,配好源地址、目的地址、长度就完事了。直到有一次做高速数据采集,系统吞吐量死活上不去,我才意识到:不懂DMA的底层架构,你连问题出在哪都找不到。
这一节,我会把DMA控制器的核心模块拆开来讲。你跟着我走一遍,以后配DMA心里就有底了。
2.1 DMA通道:独立的数据搬运流水线
DMA控制器内部通常集成多个通道(Channel)。每个通道本质上是一条独立的数据搬运流水线。我习惯把通道想象成“专用车道”——每个车道都有自己的源地址寄存器、目的地址寄存器、传输计数器和控制逻辑。
关键认知:通道之间是硬件并行的。一个通道在搬运UART数据时,另一个通道可以同时搬运SPI数据,互不干扰。这是DMA能提升系统吞吐量的根本原因。
不同MCU的通道数量差异很大。我列个常见配置供你参考:
| MCU系列 | DMA控制器数量 | 每控制器通道数 | 典型应用 |
|---|---|---|---|
| STM32F4 | 2个(DMA1/DMA2) | 8通道 | ADC、SPI、USART |
| i.MX RT1050 | 1个(eDMA) | 16通道 | 以太网、LCD、SDIO |
| NXP LPC546xx | 1个(DMA0) | 24通道 | 多路传感器采集 |
这里有个坑:通道数不是越多越好。通道多了,仲裁逻辑变复杂,响应延迟反而可能增加。我在一个项目中用过32通道的DMA,结果通道间优先级配置不当,低优先级通道的请求被饿死了。嗯,这个后面讲优先级时会细说。
2.2 请求与应答信号:DMA的“握手协议”
DMA不是自己在那闷头搬数据。它需要外设告诉它“我有数据了”或者“我需要数据了”。这个过程靠的就是请求(Request)和应答(Acknowledge)信号。
典型的握手流程是这样的:
- 外设发起请求:比如UART接收寄存器非空,拉高DMA请求线。
- DMA控制器仲裁:如果有多个通道同时请求,DMA根据优先级决定先服务谁。
- DMA发送应答:选中通道后,DMA向外设发送应答信号,表示“我来接手了”。
- 外设释放请求:外设看到应答,把数据放到总线上,然后拉低请求线。
- DMA执行传输:DMA完成一次数据搬运,更新地址和计数器。
实战经验:我曾经调试过一个SPI DMA通信失败的问题。示波器一抓,发现外设的请求信号只维持了1个时钟周期,而DMA的应答信号需要2个时钟周期才能到达。这就是典型的“握手时序不匹配”。解决办法是在DMA配置里插入一个等待周期。
不同厂家的DMA,握手方式略有差异。我整理了一个对比表:
| 特性 | STM32 DMA | NXP eDMA | TI uDMA |
|---|---|---|---|
| 请求信号极性 | 高电平有效 | 可配置(高/低) | 上升沿触发 |
| 应答方式 | 单次应答 | 单次/连续应答 | 单次应答 |
| 请求保持时间 | 至少1个HCLK | 至少2个总线时钟 | 至少1个系统时钟 |
| 支持链式请求 | 否 | 是(通过TCD链接) | 是(通过描述符链) |
2.3 传输粒度:字节、字与突发
DMA一次搬多少数据?这就涉及到传输粒度(Transfer Width/Burst Size)。说白了,就是DMA每次“咬一口”咬多大。
- 字节(8-bit):一次搬1个字节。适合UART、I2C这类按字节传输的外设。
- 半字/字(16/32-bit):一次搬2或4个字节。适合SPI、ADC等并行数据接口。
- 突发(Burst):连续搬多个数据单元。比如一次突发搬4个字(16字节)。
你可能会问:为什么不用最大粒度一直搬?原因有两个:
第一,总线占用。 突发传输会长时间霸占总线,其他外设(比如CPU取指令)就得等着。我在一个音频处理项目里,DMA突发设得太大,结果CPU取指令被频繁阻塞,系统响应延迟从10μs飙升到200μs。
第二,外设FIFO深度。 很多外设的FIFO只有几个字节深。你一次突发搬16个字,外设FIFO根本装不下,数据就丢了。
避坑指南:我曾经在配置SDIO DMA时,把突发大小设成了16字,结果SD卡返回的数据一直错位。查了两天才发现,SDIO的FIFO深度只有8字。突发大小超过FIFO深度,数据会覆盖。记住一个原则:突发大小 ≤ 外设FIFO深度。
不同传输粒度的适用场景,我建议你这样选:
| 传输粒度 | 总线占用时间 | 适用外设 | 注意事项 |
|---|---|---|---|
| 字节(8-bit) | 短 | UART、I2C、低速传感器 | CPU干扰小,但吞吐量低 |
| 字(32-bit) | 中等 | SPI、ADC、DAC | 注意外设数据对齐 |
| 突发(4-16字) | 长 | LCD、以太网、高速存储 | 必须检查FIFO深度 |
2.4 地址模式:线性、循环与链式
地址模式决定了DMA搬完一次数据后,地址怎么变。这是DMA配置里最容易出错的地方。我按自己的理解,把三种模式讲透。
2.4.1 线性模式(Linear Mode)
最简单也最常用。源地址和目的地址按固定步长递增。搬完N个数据后,DMA自动停止。
// 伪代码示例:线性模式配置
dma_channel_config ch_cfg;
ch_cfg.src_addr = (uint32_t)adc_buffer; // 源地址:ADC数据缓冲区
ch_cfg.dst_addr = (uint32_t)ram_buffer; // 目的地址:RAM缓冲区
ch_cfg.src_increment = 4; // 源地址每次+4(32位数据)
ch_cfg.dst_increment = 4; // 目的地址每次+4
ch_cfg.transfer_size = 1024; // 总共搬1024次
ch_cfg.mode = DMA_MODE_LINEAR; // 线性模式
这个模式适合“一次性搬完”的场景,比如把ADC采样结果搬到内存里做FFT。
2.4.2 循环模式(Circular Mode)
地址走到末尾后,自动回到起始地址。说白了,就是个环形缓冲区。
我特别喜欢这个模式。为什么?因为它可以无限循环搬运,不需要CPU反复重配DMA。比如音频播放:DMA从内存循环读取音频数据送到DAC,CPU只管往缓冲区里填新数据就行。
性能要点:循环模式下,DMA传输完成中断可以配置为“半传输完成中断”和“传输完成中断”。利用这两个中断,你可以实现双缓冲(Double Buffering)——CPU在DMA搬后半段时,处理前半段的数据。这样吞吐量直接翻倍。
2.4.3 链式模式(Linked List / Chain Mode)
这是最灵活、也最复杂的模式。DMA搬完一段数据后,自动加载下一个传输描述符(TCD),开始新的传输。多个描述符串成一个链表。
你想想看,这意味着什么?意味着你可以预定义一系列不同的传输任务,DMA自己按顺序执行,CPU完全不用管。
// 链式模式描述符示例(NXP eDMA TCD结构)
typedef struct {
uint32_t src_addr; // 源地址
uint32_t dst_addr; // 目的地址
uint16_t nbytes; // 本次传输字节数
uint8_t src_offset; // 源地址偏移
uint8_t dst_offset; // 目的地址偏移
uint32_t *next_tcd; // 指向下一个TCD的指针
} tcd_t;
// 定义三个传输任务
tcd_t tcd1 = { .src_addr = &adc_val1, .dst_addr = &buf_a, .nbytes = 4, .next_tcd = &tcd2 };
tcd_t tcd2 = { .src_addr = &adc_val2, .dst_addr = &buf_b, .nbytes = 4, .next_tcd = &tcd3 };
tcd_t tcd3 = { .src_addr = &adc_val3, .dst_addr = &buf_c, .nbytes = 4, .next_tcd = NULL };
// 启动链式传输
DMA_StartChain(&tcd1);
我在一个多通道数据采集系统里用过链式模式。32个传感器,每个传感器采集100个点,用链式模式把32个DMA任务串起来。CPU只在所有任务完成后收一次中断。吞吐量比逐通道配置高了将近10倍。
注意:链式模式对内存对齐有要求。TCD描述符通常需要16字节或32字节对齐。我见过有人因为描述符没对齐,DMA加载TCD时直接触发总线错误。嗯,这个坑我踩过。
2.5 三种地址模式对比总结
为了方便你选型,我把三种模式的核心差异整理成一张表:
| 特性 | 线性模式 | 循环模式 | 链式模式 |
|---|---|---|---|
| 地址回绕 | 否 | 是(自动回起始) | 否(跳转到下一描述符) |
| CPU干预频率 | 每次传输结束需重配 | 无需重配,持续运行 | 仅需配置链表头 |
| 适用场景 | 单次批量传输 | 流式数据(音频/视频) | 复杂多段传输 |
| 内存开销 | 低(仅需缓冲区) | 低(仅需缓冲区) | 高(需额外TCD空间) |
| 配置复杂度 | 低 | 中 | 高 |
2.6 本章知识结构图
下面这张图把DMA控制器的核心模块和它们之间的关系画出来了。你对照着看,思路会更清晰。