一、DMA 简介
CPU 和内存是计算机组成中都是不可或缺的部分。和 CPU 相比,内存是非常慢速的拖油瓶,CPU 的速度被内存限制,同时又不得不等待内存处理而无法去处理其他事件。所以使用 CPU 搬运内存数据,是非常浪费资源的。因此专门用于搬运内存数据的器件 DMA 应运而生。
DMA 是 Direct Memory Access 的缩写,也就是直接内存读写,所谓的直接,也就是内存到内存,不通过 CPU 。 DMA 支持内存到外设、外设到内存、内存到内存的数据交互,必要时节省很多 CPU 资源。当然,虽然 DMA 几乎不占用 CPU ,但还是会占用系统总线的。
二、Linux 内核DMA驱动框架
dma 子系统的框架图:
dma slave driver: 这个是指使用 dma 功能的设备驱动,比如 支持DMA功能的SPI设备,那么这个SPI设备在驱动初始化时,会写 dma slave driver 的代码,这样就可以使用DMA功能。具体如何写,后面在介绍代码;
dma core : 这就是Linux 内核驱动的套路,把通用的部分代码整合成驱动框架,驱动开发工程师直接调用接口,屏蔽dma 控制器驱动的代码。不展开这个套路了。
从内核代码Makefile中,可以看出包含以下驱动:
dmaengine:
是DMA子系统的核心,为DMA Device Driver提供DMA设备注册的API,为DMA调用者(Device Driver)提供屏蔽DMA设备实现细节的统一接口API;
virt-dma:
为DMA子系统提供虚拟DMA channel的支持;
of-dma:
为DMA子系统提供设备树描述DMA信息传入的支持;
acpi-dma:
为DMA子系统提供ACPI 表DMA信息传入的支持;
三、总线地址、物理地址、虚拟地址 的关系介绍
在具体分析DMA驱动源码之前,先熟悉上面三个地址之间的硬件转换方式。
内核通常使用的地址是虚拟地址。我们调用kmalloc()、vmalloc()或者类似的接口返回的地址都是虚拟地址,保存在"void *"的变量中。
虚拟内存系统(TLB、页表等)将虚拟地址(程序角度)翻译成物理地址(CPU角度),物理地址保存在“phys_addr_t”或“resource_size_t”的变量中。对于一个硬件设备上的寄存器等设备资源,内核是按照物理地址来管理的。通过/proc/iomem,你可以看到这些和设备IO 相关的物理地址。当然,驱动并不能直接使用这些物理地址,必须首先通过ioremap()接口将这些物理地址映射到内核虚拟地址空间上去。
I/O设备使用第三种地址:“总线地址”。如果设备在MMIO地址空间中有若干的寄存器,或者该设备足够的智能,它可以通过DMA执行读写系统内存的操作,这些情况下,设备使用的地址就是总线地址。在某些系统中,总线地址与CPU物理地址相同,但一般来说它们不是。iommus和host bridge可以在物理地址和总线地址之间进行映射。
从设备的角度来看,DMA控制器使用总线地址空间,不过可能仅限于总线空间的一个子集。例如:即便是一个系统支持64位地址内存和64 位地址的PCI bar,但是DMA可以不使用全部的64 bit地址,通过IOMMU的映射,PCI设备上的DMA可以只使用32位DMA地址。
我们用下面这样的系统结构来说明各种地址的概念:
在PCI设备枚举(初始化)过程中,内核了解了所有的IO device及其对应的MMIO地址空间(MMIO是物理地址空间的子集),并且也了解了是PCI主桥设备将这些PCI device和系统连接在一起。PCI设备会有BAR(base address register),表示自己在PCI总线上的地址,CPU并不能通过总线地址A(位于BAR范围内)直接访问总线上的PCI设备,PCI host bridge会在MMIO(即物理地址)和总线地址之间进行mapping。因此,对于CPU,它实际上是可以通过B地址(位于MMIO地址空间)访问PCI设备(反正PCI host bridge会进行翻译)。地址B的信息保存在struct resource变量中,并可以通过/proc/iomem开放给用户空间。对于驱动程序,它往往是通过ioremap()把物理地址B映射成虚拟地址C,这时候,驱动程序就可以通过ioread32(C)来访问PCI总线上的地址A了。
如果PCI设备支持DMA,那么在驱动中我们可以通过kmalloc或者其他类似接口分配一个DMA buffer,并且返回了虚拟地址X,MMU将X地址映射成了物理地址Y,从而定位了DMA buffer在系统内存中的位置。因此,驱动可以通过访问地址X来操作DMA buffer,但是PCI 设备并不能通过X地址来访问DMA buffer,因为MMU对设备不可见,而且系统内存所在的系统总线和PCI总线属于不同的地址空间。
在一些简单的系统中,设备可以通过DMA直接访问物理地址Y,但是在大多数的系统中,有一个IOMMU的硬件block用来将DMA可访问的总线地址翻译成物理地址,也就是把上图中的地址Z翻译成Y。理解了这些底层硬件,你也就知道类似dma_map_single这样的DMA API是在做什么了。驱动在调用dma_map_single这样的接口函数的时候会传递一个虚拟地址X,在这个函数中会设定IOMMU的页表,将地址X映射到Z,并且将返回z这个总线地址。驱动可以把Z这个总线地址设定到设备上的DMA相关的寄存器中。这样,当设备发起对地址Z开始的DMA操作的时候,IOMMU可以进行地址映射,并将DMA操作定位到Y地址开始的DMA buffer。
补充:IOMMU
我们都知道,在带有 MMU 的 Soc 上,对于程序来说,虚拟地址空间 是 可连续访问的。
因为 MMU 帮我们完成了从 虚拟地址空间 到 物理地址空间 的映射,这样做固然对于程序来说可以大大提高 内存管理 的效率,但同时也带来了 物理内存空间碎片化 的结果,找到 可连续访问 的 物理地址空间 的难度将大大增加。
而当 Soc 上的 设备 使用 DMA 访问内存时,需要 可连续访问 的 物理地址空间。
一般情况下,有 2 种办法可以让 DMA 访问 连续的物理地址空间:
- 在初始化 内核时,将 一部分物理空间 保留下来,不进行虚拟空间的映射。当使用到 DMA 的时候,将所需要的数据放置到 内存空间。再让DMA去访问这段 物理内存。这种方法简单直接,但会使得 内存空间的使用率并不高。
- DMA 带上 MMU,让其在访问 虚拟空间 时 自动完成虚拟地址到物理地址的映射,此时 DMA 可以在不保留 连续物理地址空间 的情况下 访问连续的虚拟空间 。
ARM 使用了第二种方法,增加了一个特殊的 MMU,即 IOMMU。IOMMU 在 ARM架构 中称为 SMMU。SMMU 和 MMU 一样,在配置后可以进行 translation table walk。
总结 IOMMU 的 2 个用处:
- 映射总线地址到物理地址
- 提高物理内存的使用率
根据上面的描述我们可以得出这样的结论:Linux可以使用动态DMA 映射(dynamic DMA mapping)的方法,当然,这需要一些来自驱动的协助。所谓动态DMA 映射是指只有在使用的时候,才建立DMA buffer虚拟地址到总线地址的映射,一旦DMA传输完毕,就将之前建立的映射关系销毁。
虽然上面的例子使用IOMMU为例描述,不过本文随后描述的API也可以在没有IOMMU硬件的平台上运行。
顺便说明一点:DMA API适用于各种CPU arch,各种总线类型,DMA mapping framework已经屏蔽了底层硬件的细节。对于驱动工程师而言,你应该使用通用的DMA API