第二章 DMA控制器架构:从PL080到现代SOC
聊DMA,绕不开控制器本身。很多人一开始就扎进描述符环的细节,却忽略了底层硬件的设计哲学。我个人觉得,先搞清楚DMA控制器长什么样、怎么工作的,后面理解缓存一致性问题会轻松不少。
这一章,我们把三类有代表性的DMA控制器拎出来:ARM的PL080/PL330、Intel的8237、还有现代SOC里集成的DMA。它们代表了三个时代的设计思路。
2.1 ARM PL080:初代微控制器DMA
PL080是ARM在PrimeCell系列里推出的第一代DMA控制器。我最早接触它是在一个基于STM32的项目里。说白了,它就是个“勤快的数据搬运工”,但脑子不太灵活。
核心特性:
- 固定通道数:典型配置是8通道。每个通道独立配置源地址、目的地址、传输宽度(8/16/32位)和突发长度。
- 简单DMA请求线:每个通道对应一条外设DMA请求线。外设拉高请求线,DMA就干一次活。
- 无描述符链:这最重要。PL080不支持描述符链表。每个通道只有一个控制块。一次传输结束,必须由CPU重新配置才能做下一次。
- 固定优先级仲裁:通道0优先级最高,通道7最低。高优先级请求会打断正在进行的低优先级传输?不,它不会打断,但它会插队。等当前传输完成,仲裁器才切换。
我当初踩过的坑:曾经在一个项目中,外设UART中断和DMA完成中断共用了一个优先级。结果DMA完成中断迟迟得不到响应,导致下一批数据无法启动传输。嗯,这里要注意——PL080的中断处理和DMA引擎是串行的,中断处理期间DMA不干活。
2.2 Intel 8237:PC界的常青树
8237是Intel在70年代推出的经典DMA控制器。你想想看,那时候CPU还是8086,主频才4.77MHz。8237的设计影响了后来几十年的PC兼容机。
| 特性 | 8237 | PL080 |
|---|---|---|
| 通道数 | 4通道(可级联到16通道) | 8通道(不可级联) |
| 寻址空间 | 16位(64KB),通过页面寄存器扩展 | 32位 |
| 传输模式 | 单字节、块、请求、级联 | 单次、突发 |
| 描述符支持 | 无,需CPU重载 | 无,需CPU重载 |
| 自动初始化 | 支持(循环缓冲场景) | 不支持 |
8237有个很有意思的特性叫“自动初始化”。当传输计数到零时,它会自动把当前寄存器值恢复成初始值。这个设计当时是为了支持磁盘控制器做连续传输。现代DMA的描述符环,说白了就是把这个思想发挥到极致了。
级联模式也值得一提。你可以把两个8237串起来,变成8个通道。具体操作是:从片的HRQ接到主片的某个DREQ上。这样主片认为从片是一个外设,从片控制自己的通道。我在调试一台古董PC的ISA声卡时见过这用法——主片通道0接管从片的DMA请求。
注意:8237不支持分散-聚集。如果你要从一个不连续的缓冲区读数据,要么CPU手动拼接,要么用多个DMA请求。这在现代系统中简直不敢想象。但当年的软件就是这么活的。
2.3 ARM PL330:向描述符环迈进
PL330是ARM的第二代DMA控制器。它弥补了PL080的很多短板。我到现在还在用——很多手机基带芯片的内部DMA就是PL330的变体。
和PL080的关键区别:
- 微程序引擎:PL330内置了一个简单的微程序处理器,可以执行DMA程序指令。每条指令其实就是一个控制块(描述符)。
- 描述符链支持:把多个控制块链接起来,形成链表。DMA完成一个,自动加载下一个。CPU只需要在初始化时写好链表头。
- 事件和DMA请求分离:不再直接映射外设请求。改用事件号。配置更灵活,但编程也更复杂。
- 多线程:虽然只有几个物理通道,但PL330支持多个DMA线程。可以暂停一个线程,切换执行另一个,实现类似DMA多任务的效果。
/* PL330 微程序示例:循环缓冲 */
/* 这是一个四描述符的循环传递 */
/* 每个节点传输完成,跳转到下一个 */
/* 最后一个节点的 next 指向第一个 */
/* 描述符 0 */
DMAADDH dst_addr_0, 0 /* 设置目的地址 */
DMAADDS src_addr_0, 0 /* 设置源地址 */
DMALP count_0 /* 设置传输计数 */
DMAGO wait_event, 0 /* 等待事件触发 */
DMAMOV next_ptr, desc_1 /* 下一跳 */
/* 描述符 1 */
DMAADDH dst_addr_1, 0
DMAADDS src_addr_1, 0
DMALP count_1
DMAGO wait_event, 1
DMAMOV next_ptr, desc_2
/* ... 直到描述符3 */
DMAMOV next_ptr, desc_0 /* 回到开头 */
注意看,PL330的“微程序”其实已经很像现代描述符结构了。只是指令集比较原始,每个场都需要独立配置。没点功底,容易配置错。
2.4 现代SOC集成DMA的设计思路
好了,终于到了今天的重点。现代SOC里的DMA不再是孤立的外设。它跟CPU、缓存、内存、I/O总线深度耦合。设计思路也变了。
从图里可以看出现代DMA的几个核心设计点:
2.5 设计点一:描述符环放在系统内存中
以前的PL080/8237,控制块在DMA内部寄存器里。现代DMA不是这样。描述符放在DDR里,DMA引擎通过总线去读。
这样做的好处很明显:描述符数量基本不受硬件限制。你可以分配几千个描述符,形成一个大环。坏处也直接——访问内存有延迟,比内部寄存器慢多了。但DMA引擎会用预取缓存来解决:提前把下一个描述符读到内部FIFO。
实际项目中的教训:我在一个PCIe DMA项目中,描述符环被放在NUMA远端内存。结果DMA每次读描述符都要跨socket访问,延迟从几个周期飙到几百个周期。后来被迫把描述符环绑定到本地内存池。这告诉我们,描述符的位置会严重影响性能。
2.6 设计点二:虚拟通道与描述符调度
物理通道(即DMA硬件的独立执行单元)现在很宝贵。一般SOC只提供4-16个。但软件可能有几十个设备要同时使用DMA。怎么解决?靠虚拟通道。
虚拟通道是个软件概念。每个外设或任务分配一个虚拟通道号。DMA驱动维护一个描述符环池。当物理通道空闲时,驱动从所有虚拟通道中挑一个待处理的描述符环,挂载到物理通道上执行。执行完再换下一个。
这其实就是操作系统的调度思想——把DMA引擎当作一个共享资源,让软件做调度。
| 参数 | PL080 时代 | 现代DMA |
|---|---|---|
| 通道分配 | 静态分配,外设独占通道 | 动态分配,虚拟通道共享物理通道 |
| 描述符维护 | CPU每传输完重新配置 | 描述符环预建立,DMA自动遍历 |
| 缓存一致性 | 不关心,CPU和DMA直接访问内存 | 核心问题,需要硬件或软件处理 |
| 错误处理 | 通道挂死,CPU轮询超时 | 描述符返回状态字,描述符链可跳过错误节点 |
| 中断频率 | 每次传输完成中断一次 | 可配置完成中断间隔(每N个描述符一次) |
2.7 设计点三:缓存一致性的引入
现代SOC里,CPU有缓存,DMA不经过缓存。这就创造了缓存一致性问题。但这块我不在这里展开——这个课程的好几章都是讲这个的。这里你只要记住:现代DMA的地址配置会让设计人员面对两个世界——CPU看见的虚拟地址、DMA看见的总线地址。它们可能不一样。
为什么会这样?因为现代内存系统有MMU、有IOMMU、有SMMU。CPU访问内存用的是虚拟地址,经过MMU翻译。DMA如果直接发物理地址,没问题。但描述符本身如果被缓存污染了,那就完了。
我曾经因此折腾了两天:在给一个FPGA写DMA驱动时,描述符环的地址在DDR里被CPU缓存了。DMA引擎去读描述符时拿到了脏数据,一直卡在“传输中”状态。最后手动flush了描述符所在的cacheline,问题解决。从那时起,我再也不信任“描述符自动更新”这种话了,写驱动时总是明确flush。
2.8 设计点四:外设与DMA的松耦合
以前的外设和DMA绑定得死死的。比如UART的DMA请求线直接连到PL080的通道0。现代SOC不是这样。外设通过事件ID或信号路由矩阵来触发DMA。
举个例子:一个外设产生“数据就绪”事件。DMA引擎内部有个事件路由表,根据事件号找到对应的描述符环地址,然后开始传输。这种设计让系统更灵活——同一个外设可以在不同时刻使用不同的DMA通道。