DMA控制器架构
我做了这么些年嵌入式底层,见过不少人一上来就调DMA,结果数据错乱、中断风暴、总线占死……其实很多坑都跟没搞懂控制器内部结构有关。这一章我们就把DMA控制器扒开看看——里面到底是什么零件、怎么连线、搬运的步子迈多大。
2.1 内部组成:DMA引擎到底有几大件?
一颗典型的DMA控制器,你可以把它想象成一个“专属数据搬运工”。它的内部可以拆成这么几块:
- 控制逻辑单元(Controller Logic):大脑,负责解析软件配置的传输描述符,协调其他模块工作。我见过一次坑,就是控制逻辑的有限状态机没处理好总线锁定信号,导致多通道同时请求时卡死。
- 地址寄存器组:至少两套——源地址寄存器和目的地址寄存器。支持自增/自减/固定模式。你在做乒乓缓冲时,地址自增就是靠它完成的。
- 传输计数器(Byte/Word Counter):还剩多少数据没搬完?它就负责递减。到0时触发完成中断或链表跳转。
- 状态与中断寄存器:记录传输完成、错误、超时等事件。我习惯在调试时先读一下状态寄存器,比瞎猜快多了。
- 数据缓冲(FIFO/Buffer):临时缓存,用来桥接不同时钟域或总线位宽。字长不匹配时全靠它垫着。
核心一句:地址+计数器+控制逻辑=DMA的最小可行单元。每个通道都拥有这样一套独立资源。
这里我画了一张内部组成框架图,你看一眼就清楚了:
个人小经验:我在移植某个ST MCU的DMA库时,发现FIFO深度只有4个字节,但外设每次要送64字节——所以必须把传输粒度切成32位突发才能跑满带宽。这玩意儿不看数据手册单靠猜,肯定要摔跟头。
2.2 通道与请求信号:外设怎么“喊”DMA干活?
DMA通道就像多个专用车道。每个通道可以绑定一个外设的请求信号。举个例子:UART RX接到通道0,SPI TX接到通道1,ADC接到通道2。外设准备好了,就拉高一条硬件请求线(DMA_REQ),DMA控制器看到后就发起一次总线传输。
请求信号类型:
- 硬件触发(Hardware DMA Request):由外设直接拉。典型场景——UART收到一个字节,RXNE标志上沿同时触发DMA请求。
- 软件触发(Software DMA Request):写寄存器就能启动。常用于memory-to-memory搬运,或者测试自环。
- 链式请求(Chained Request):一个传输完成自动触发下一个描述符,不经过CPU。这属于多队列的雏形。
关于仲裁——多通道同时请求时,谁先拿到总线?我建议你固定用“固定优先级+轮询”混合模式。曾经在某个录像机项目里,我全部设成最高优先级,结果低优先级通道的音频一直饿死,画面卡、声音断……嗯,优先级设计得千万要留有余地。
规避提醒:很多芯片的DMA请求信号是电平敏感而不是边沿敏感的。如果你外设的中断服务程序耗时太长,可能导致同一个数据被DMA反复搬运——我就吃过这个亏,最后在ISR里手动清了请求标志。
2.3 传输粒度:字节、字、突发——步子到底迈多大?
传输粒度决定了DMA每次向总线发起请求时搬运多少数据。这是个微妙的trade-off。
| 粒度 | 每次传输位数 | 典型场景 | 总线利用率 | 潜在风险 |
|---|---|---|---|---|
| 字节(8-bit) | 8 | UART、I2C、低速传感器 | 低(每次都要仲裁) | 总线占用次数多 |
| 字(16/32-bit) | 16 或 32 | SPI、内存批量拷贝 | 中等 | 可能导致外设FIFO溢出(如果字太大) |
| 突发(Burst,4/8/16字) | 多个字连续 | 高速ADC、LCD驱动、PCIE | 很高(减少仲裁次数) | 锁住总线时间过长,影响其他主设备 |
为什么要用突发?说白了,突发能减少DMA每次申请总线的开销。一次握手,然后一连串拍子啪啪啪传完。但如果你系统里同时有CPU、GPU、USB在抢总线,突发太长就会让其他人等得心焦。
我记得做一款工业相机时,CMOS图像数据用32字节突发,结果CPU那边的以太网中断延迟从5μs飙到了80μs——直接导致TCP超时重传。后来砍成8字节突发,两边才勉强和睦。
经验法则:
- 外设FIFO深度 ≤ 1字节 → 用字节粒度。
- 外设FIFO深度 = 4~16字节 → 用字或短突发。
- 总线有独占锁或冲突较少时 → 用长突发提带宽。
还有一个坑——地址对齐。突发传输通常要求源地址和目的地址对齐到突发长度。你想想看,如果地址是0x01,你要每次传4个32位字(16字节),总线会直接报错或者默默做成非对齐访问,性能掉一半。我曾经用裸机调试时,直接对0x1001做一个32位字读取,结果读回来的数据错位了四个字节。从那以后,我所有DMA缓冲区都强制cache line对齐。
// 一个典型配置示例(以STM32 HAL为例):
DMA_InitTypeDef dma_init;
dma_init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 外设16位
dma_init.MemDataAlignment = DMA_MDATAALIGN_WORD; // 内存32位
dma_init.Mode = DMA_CIRCULAR; // 循环模式
dma_init.Priority = DMA_PRIORITY_HIGH;
dma_init.FIFOMode = DMA_FIFOMODE_ENABLE;
dma_init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL; // FIFO满才触发传输
// 注意:如果外设地址不是字对齐的,MemDataAlignment就不能设为WORD,否则会hardfault。
重要:当外设和内存数据宽度不一致时,DMA内部FIFO会自动做打包/拆包。但前提是外设地址递增必须连续。我曾经遇到过外设地址是固定的(比如只读寄存器),配置成递增就出错了——那叫一个懵。
2.4 典型传输模式对比
| 模式 | 触发方式 | 粒度匹配 | 应用 |
|---|---|---|---|
| 单次传输 | 每次请求只传一个粒度单位 | 字节/字 | UART字符收发 |
| 块传输 | 一次请求传完整个计数块 | 突发 | 音频Buffer填充 |
| 链式传输 | 描述符链,每完成一个自动跳转 | 任意 | 多缓冲区、双缓冲 |
| 循环传输 | 传输计数器自动重装载 | 任意 | 传感器连续数据采集 |
你想想看,是不是串口用单次、音频用块传输、图像用链式、ADC用循环?差不多就是这样。做产品时我习惯把粒度、优先级、模式做成一个枚举配置表,不同外设各取所需。这样后期调试只需要改表,不用动核心逻辑。
2.5 本章小结
DMA控制器内部无非就是控制逻辑+地址计数器+FIFO+仲裁器。理解通道请求信号是硬件触发还是软件触发,决定了初始化代码怎么写。传输粒度的选择就像吃饭用小勺还是大勺——外设胃口小你就小口喂,外设胃口大你大口喂,但别噎着总线上的别人。