第 13 章 mmap 和 DMA
本章将深入探讨 Linux 内存管理部分,并强调了对设备驱动程序编写者非常有帮助的技术重点。这一章内容属于高级主题,不需要所有人都掌握它,虽然如此,很多任务只能通过更深入地研究内存管理子系统而做到,同时本章也帮助读者了解内核重要组成部分的工作方式。
本章内容分为三节。第一节讲述了 mmap 系统调用的实现,mmap允许直接将设备内存映射到用户进程的地址空间中。然后我们讨论内核 kiobuf 机制,它能提供从内核空间对用户内存的直接访问,kiobuf 系统可用于为某些种类的设备实现"裸(raw)I/O"。最后一节讲述直接内存访问(DMA)I/O操作,它本质上提供了外围设备直接访问系统内存的能力。
当然,所有的这些技术都需要先了解 Linux 的内存管理是如何工作的,所以我们从内存子系统来开始本章的讨论。
13.1 Linux 的内存管理
这一节不是描述操作系统中内存管理的理论,而是关注于该理论在 Linux 实现中的主要特点。尽管为了实现 mmap,你无需成为 Linux 虚拟内存方面的专家,但是,了解它们工作的基本概况还有很有帮助的。然后我们将用较长的篇幅描述 Linux 用于内存管理的数据结构。一旦具备了必要的背景知识,我们就可以利用这些结构来实现 mmap。
13.1.1 地址类型
Linux 是一个使用虚拟内存的系统,这意味着用户程序看到的地址不是直接对应于硬件使用的物理地址。虚拟内存提出了一个间接的层,这对许多事情都是有利的。如果有虚拟内存,运行在系统上的程序就可以分配到比可用物理内存更多的内存。甚至一个单独的进程都可以拥有比系统的物理内存更大的虚拟地址空间,虚拟内存也能在进程地址空间上使用很多技巧,包括映射设备的内存。
迄今,我们已经讨论了虚拟地址和物理地址,但是很多细节被掩盖而没有提及。Linux 系统使用几种类型的地址,每种都有自己的语义。不幸地是,内核代码中关于哪种类型的地址应该在何种情形下使用的问题一直不是十分清晰,所以程序员必需谨慎地使用。
下面列出了 Linux 用到的地址类型。图 13-1 描述了这些地址类型和物理内存之间的关系。
用户虚拟地址
该地址是用户空间的程序所能看到的常规地址。根据低层硬件体系结构的不同,用户地址可以是 32 位或者 64 位长,并且每个进程拥有自己独立的虚拟地址空间。
物理地址
该地址在处理器和系统内存之间使用。物理地址也是 32 或者 64 位长,在某些情况下,32 位系统也可以使用 64 位的物理地址。
总线地址
该地址在外设总线和内存之间使用。通常情况下,该地址和处理器所使用的物理地址是一样的,但并不总是这样。显然,总线地址非常依赖于体系结构。
内核逻辑地址
内核逻辑地址组成了常规的内核地址空间,这些地址映射了大部分乃至所有的主内存,并被视为物理内存使用。在大多数的体系结构中,逻辑地址及其所关联的物理地址之间的区别,仅仅在于一个常数的偏移量。逻辑地址使用硬件特有的指针大小,所以,在配置有大量内存的 32 位系统上,仅通过逻辑地址可能无法寻址所有的物理内存。在内核中,逻辑地址通常保存在 unsigned long 或者 void * 这样的变量中。由 kmalloc 返回的内存就是逻辑地址。
内核虚拟地址
这种地址和逻辑地址之间的区别在于,前者不一定能够直接映射到物理地址。所有的逻辑地址可看成是内核虚拟地址;由函数 vmalloc 分配的内存具有虚拟地址,这种地址却不一定能直接映射到物理内存。本章后面要讲到的 kmap 函数也返回虚拟地址。虚拟地址通常保存在指针变量中。
如果我们拥有一个逻辑地址,可通过定义在 <asm/page.h> 中的宏 _ _pa() 返回与其关联的物理地址。我们也可以使用 _ _va() 宏将物理地址映射回逻辑地址,但只能用于低端内存页。
不同的内核函数要求不同类型的地址。如果已经存在有定义好的 C 数据类型来明确表达我们所要求的地址类型,代码将变得更为清晰可读,但事实并没有我们想象的那么好。通过本章的学习,我们将会了解到哪种情况下应该使用哪种地址类型。
13.1.2 高端与低端内存
逻辑地址和内核虚拟地址的区别在装配了大量内存的 32 位系统上比较突出。使用 32 位来表示地址,就可以寻址 4GB 大小的内存。到最近为止,在 32 位的系统上 Linux 一直被限制使用少于 4GB 的内存,这是由于设置虚拟地址空间的方式导致的。系统不能处理比设置的逻辑地址更多的内存,因为它需要为全部内存直接映射内核地址。
近来的开发工作已经将这个内存上的限制排除掉了, 32位系统现在能够在超过 4GB 的系统内存(当然需要假定处理器自己能够寻址这些内存)上很好的工作了。但是关于多少内存可以以逻辑地址的形式直接映射的限制还是保留下来了,只有内存的最低一部分(一直到 1 或 2GB,依赖于硬件和内核配置)有逻辑地址,剩下部分没有。高端内存应该需要 64 位物理地址,并且内核必须明确地设置映射的虚拟地址来操作高端内存。这样,就限制很多内核函数只能使用低端内存,高端内存常常是保留给用户空间的进程页。
术语"高端内存"可能造成混淆,特别是它在 PC 方面还具有其他的含义。所以,为了解释清楚,我们将在这里定义这些术语:
低端内存
代表存在于内核空间的逻辑地址的内存。几乎在每种系统上读者都可能遇到,所有的内存都是低端内存。
高端内存
那些不存在逻辑地址的内存,因为相对于能够用 32 位来寻址的内存,系统通常有更多的物理内存。
在 i386 系统上,低端内存和高端内存的之间的界限通常设置为 1GB。这个界限与最初的 PC上老的 640KB 限制没有任何关系,相反,它是内核本身设置的限制,用于将 32 位地址空间分割为内核空间和用户空间。
我们将指出本章中我们遇到的高端内存的限制。
13.1.3 内存映射和页结构
历史上,内核在提到内存页时都是使用逻辑地址。另外的高端内存的支持方法已经暴露了明显的问题――逻辑地址不能用于高端内存。这样,处理内存的内核函数趋向于使用指向 struct page 的指针。这个数据结构用于保存物理内存的所有信息,系统上的每一个物理内存页都有一个 struct page,该结构的部分成员如下:
atomic_t count;
对该页的访问计数。当计数下降到零,该页就返还给空闲链表。
wait_queue_head_t wait;
等待这个页的进程链表。尽管进程能够在内核函数出于某种原因锁住该页时等待,但通常驱动程序不需要考虑等待页。
void *virtual;
如果页面被映射,该成员就是页的内核虚拟地址,否则就是NULL。低端内存页总是被映射的,高端内存页通常不是。
unsigned long flags;
描述页状态的一组位标志。其中包括表明内存中的页已经锁住的 PG_locked,以及完全阻止内存管理系统处理该页的 PG_reserved。
struct page 中还有更多的信息,但它们只是技巧性很强的内存管理的一部分,与驱动程序编写者关系不大。
内核维护了一个或者更多由 struct page 项构成的数组,它们跟踪系统上所有的物理内存。在大多数系统上,只有一个叫做 mem_map 的数组。然而在某些系统上,情况更为复杂,非一致性内存访问(Nonuniform Memory Access,NUMA)系统与具有普遍的不连续的物理内存的系统,都可以有多于一个的内存映射数组,所以可移植代码无论如何都应该避免直接访问数组。令人高兴的是,只是使用struct page指针而不用关心它们是从哪里获得,通常很容易。
一些函数和宏可用来在 struct page 和虚拟地址之间进行转换:
struct page *virt_to_page(void *kaddr);
这个宏在头文件 <asm/page.h> 中定义,它接受一个内核逻辑地址,并返回与其关联的 struct page 指针。因为它需要一个逻辑地址,它对 vmalloc 返回的内存和高端内存无效。
void *page_address(struct page *page);
如果这个地址存在的话,返回该页的内核虚拟地址。对于高端内存,仅在该页已经被映射的情况下,其地址才存在。
#include <linux/highmem.h>
void *kmap(struct page *page);
void kunmap(struct page *page);
对于系统中任意一个页,kmap 都返回一个内核虚拟地址。对于低端内存页,它只是返回页的逻辑地址;对于高端内存页,kmap 建立一个特殊的映射,kmap 建立的映射应该总是使用 kunmap 来释放。有限数量的这种映射是有用的,所以最好不要长时间地持有它们。kmap 调用是附加式的,所以如果两个或者更多的函数对同一页面调用 kmap 也是正确的。注意,如果没有映射可用,kmap 会进入睡眠。
在本章后面我们研究样例代码时,我们将会看到这些函数的一些用法。
13.1.4 页表
当有一个程序对虚拟地址进行查询时,CPU 必须把该虚拟地址转换成物理地址,这样才能对物理内存进行访问。这可以通过把地址分割成位字段(bitfield)的方法来实现。每一个位字段是一个数组的索引,该数组被称为"页表(page table)"。通过这些位字段可以获得下一个页表的地址或保存虚拟地址的物理页的地址。
为了将虚拟地址映射为物理地址,Linux 内核对三级页表进行管理。这种多级管理的方式可使内存范围得到稀疏利用;现代系统将会把进程扩展到一个大范围的虚拟内存上。这种方法很有意义,因为它考虑了内存页处理的运行时灵活性。
需要指出,在只支持两级页表的硬件中,或使用其他不同方法把虚拟地址映射成物理地址的硬件中,都可以使用三级系统。在不依赖于处理器的实现中使用三级系统,可以使 Linux 能够同时支持两级和三级页表的处理器,而不必使用大量的 #ifdef 语句进行编码。当内核在两级处理器中运行时,这种保守的编码并不会导致额外的开销,因为编译器实际上已经对不使用的级进行了优化。
现在来看一下实现内存分页系统所使用的数据结构。下面的列表概述了在 Linux 中三级管理的实现,图表13-2 对此进行了描述。
页目录(PGD)
顶级页表。PGD是由pgd_t项组成的数组,其中每一项指向一个二级页表。每一个进程有自己的页目录,内核空间也有一个自己的页目录。可以把页目录看作一个页对齐的 pgd_t 数组。
中级页目录(PMD)
第二级表,PMD 是页对齐的 pmd_t 数组。一个pmd_t项是指向第三级页表的一个指针,两级处理器没有物理的 PMD,它们将自己的 PMD 作为一个单元素的数组,其值是 PMD 本身。在下面的部分将会看到在 C 语言中是如何解决这个问题,以及编译器怎样对该级进行优化。
页表
一个页对齐项的数组,每一项称为一个页表项,内核为这些项使用 pte_t 类型。一个 pte_t 包含了数据页的物理地址。
这里所介绍的类型在头文件 <asm/page.h> 中定义,每一个有关页处理的源文件必须包含它。
在正常的程序执行过程中,内核不必对页表进行查询,因为这可以由硬件来完成。但是,内核必须合理安排工作,这样硬件才能完成自己的工作。一旦处理器报告了一个页故障,也就是说,处理器所需要的与虚拟地址关联的页当前不在内存中,内核就必须建立页表,并且对它们进行查询。在实现 mmap 时,设备驱动程序必须建立页表并处理页故障。
值得注意的是,软件内存管理是如何利用 CPU 本身所使用的同一页表的。如果CPU不实现页表,这种区别只会隐藏在最底层的体系结构特有的代码中。所以,在Linux内存管理中,我们总是讨论三级页表,而忽略页表和硬件之间的关系。不使用页表的 CPU 家族的一个例子就是PowerPC。PowerPC设计者通过实现一个哈希算法,可以将虚拟地址映射为一个一级页表。在访问一个已位于内存,但其物理地址已经从 CPU 的高速缓冲存储器中去除的页时,CPU只需读一次内存即可,这与在一个多级页表要进行两次或三次的访问是相反的。与多级表相似,在将虚拟地址映射为物理地址的过程中,哈希算法减少了所需要使用的内存数量。
不考虑CPU所使用的机制,Linux 软件实现是建立在三级页表基础上的,可用使用下面的符号来访问它们。必须包含头文件<asm/page.h> 和 <asm/pgtable.h>,以便对所有的符号进行访问。
PTRS_PER_PGD
PTRS_PER_PMD
PTRS_PER_PTE
代表每一个表的大小。两级处理器将PTRS_PER_PMD 设置为1,以避免对中级页目录进行处理。
unsigned pgd_val(pgd_t pgd)
unsigned pmd_val(pmd_t pmd)
unsigned pte_val(pte_t pte)
这三个宏用来从类型化数据项中获得unsigned 值。由于依赖于底层的体系结构和内核配置选项,实际使用的类型也是多样化的;通常可以是unsigned long,也可以是支持高端内存的32位处理器中的unsigned long long。SPARC64处理器使用unsigned int。这些宏有助于在源代码中使用严格的数据类型,而不会引入计算开销。
pgd_t * pgd_offset(struct mm_struct * mm, unsigned long address)
pmd_t * pmd_offset(pgd_t * dir, unsigned long address)
pte_t * pte_offset(pmd_t * dir, unsigned long address)
这些内联函数*用来获得与给定 address 相关的 pgd、pmd 和 pte 项。页表查询以指向 struct mm_struct 的指针为起点,与当前进程的内存映射相关联的指针是 current->mm,而指向内核空间的指针使用 &init_mm 来描述。两级处理器将 pmd_offset(dir,add) 定义为 (pmd_t *)dir,这样可以将 pmd 与 pgd 合并起来。扫描页表的函数总是声明为内联的,这样,编译器就能针对 pmd 查询进行优化。*
|
struct page *pte_page(pte_t pte)
该函数会为页表项中的页返回一个指向 struct page 项的指针。处理页表的代码通常使用 pte_page,而不是 pte_val,这是因为 pte_page 处理依赖于处理器的页表项格式,并且返回struct page指针,而这通常是必须的。
pte_present(pte_t pte)
该宏返回一个布尔值,该布尔值用来表明数据页当前是否在内存中。在访问 pte 低位(这些位被 pte_page 丢弃)的几个函数当中,这是最常用的函数。当然这些页也许并不存在,如果内核已经把它们交换到磁盘,或者它们根本没有被装载的话。然而,页表本身总是出现在当前的Linux实现中。把页表保存在内存中简化了内核代码,因为 pgd_offset 和其它相关项从来都不会失效。另一方面,一个"驻留存储大小"为零的进程也会在RAM中保存它的页表,这样,虽然浪费了一些内存,但总比用在其它方面要好些。
系统中的每个进程都有一个struct mm_struct 结构体,在结构体中包含了进程的页表和许多其它的大量信息。其中也包含一个叫做page_table_lock 的自旋锁,在移动或修改页表时,应该持有该自旋锁。
只了解这些函数还不足以让读者精通 Linux 的内存管理算法。真正的内存管理要复杂的多,而且必须处理其它的复杂情况,比如说高速缓存的一致性。但是前面列出的函数足以给读者一个关于页管理实现的初步印象。作为要经常与页表打交道的设备驱动程序作者来说,这些是必须要了解的。从内核源代码的 include/asm 和 include/mm 子树中可以获得更多的信息。
13.1.5 虚拟内存区域
尽管内存分页位于内存管理的最底层,在能有效的使用计算机资源之前,还是需要了解更多的东西。内核需要一个更高级的机制来处理进程自己的内存,在Linux中,这种机制是通过虚拟内存区域的方法来实现的,它们被称为区域或 VMA。
一个区域是在进程虚拟内存中的一个同构区间,一个具有相同许可标志的地址的连续范围。它和"段"的概念有点相当,尽管将后者描述为"具有自有属性的内存对象"更为贴切些。进程的内存映射由下面几个区域构成:
- 程序的执行代码区域(通常称作text段)。
- 每种类型的数据对应一个区域,其中包括初始化数据(在执行之初已经明确赋值的数据)、未初始化的数据(BSS)*、程序栈。
- 每一个有效的内存映射区域。
|
一个进程的内存区域可以从 /proc/pid/maps 中看到(这里的 pid 也可以用进程的ID来替换)。/proc/self 是 /proc/pid 的特殊情况,因为它总是指向当前的进程。下面是一组内存映射的例子,在#字后面添加了一些短注释:
|
每行中的字段如下:
|
/proc/*/maps (映像名字本身除外)中的每一个字段都与struct vm_area_struct中的一个成员相对应,下面用一个列表对每个字段进行描述。
start
end
该内存区域的起始和结束虚拟地址。
perm
内存区域的读、写和执行许可的位掩码。该字段描述了允许进程对属于该区域的页所能进行的操作。字段中的最后一个字符既可以是 p(代表私有),也可以是 s(代表共享)。
offset
这里是内存区域在被映射文件中的起始位置。零偏移量表示内存区域的第一页与文件的第一页相对应。
major
minor
对应于被映射文件所在设备的主设备号和次设备号。主设备号和次设备号是由用户打开的设备特殊文件所在的磁盘分区来决定的,而不是由设备本身所决定,这一点很容易混淆。
inode
被映射文件的索引节点号。
image
已被映射的文件(通常是一个可执行映像)的名字。
实现 mmap 方法的驱动程序,要在映射其设备的进程的地址空间中填充一个 VMA 结构体。所以,驱动程序作者应该对 VMA 有一点了解,这样才能使用它们。
下面介绍一下在结构vm_area_struct(在头文件 <linux/mm.h> 中定义)中最重要的几个成员。这些成员可能会在 mmap 实现中被设备驱动程序用到。需要指出,内核维护 VMA 链表和树以便优化对区域的查询,vm_area_struct 的几个成员则用来维护这种组织形式。因此,驱动程序不能随便地生成 VMA,否则结构体会遭到破坏。VMA 的主要成员如下(注意这些成员和刚看到的 /proc 输出之间的类似性):
unsigned long vm_start
unsigned long vm_end
VMA 所覆盖的虚拟地址区间。这些成员是在 /proc/*/maps 中最先显示的两个字段。
struct file *vm_file;
指向与该区域(如果有的话)相关联的 struct file 结构体的一个指针。
unsigned long vm_pgoff
文件或页中的区域偏移量。在映射一个文件或设备时,这是在该区域中被映射文件的第一页的位置。
unsigned long vm_flags
一组描述该区域的标记。对设备驱动程序作者来说,最有意思的标志是 VM_IO 和 VM_RESERVED。VM_IO 将一个 VMA 标记为一个内存映射的 I/O 区域。VM_IO 会阻止系统将该区域包含在进程的 core dump 中。VM_RESERVED 会告诉内存管理系统不要试图把该VMA交换出去,在大多数的设备映射中都应该对它进行设置。
struct vm_operations_struct *vm_ops
内核可能调用的一组函数,用来对内存区域进行操作。它的存在说明内存区域是一个内核"对象",就像本书中一直使用的 structure file 一样。
void *vm_private_data
可以被驱动程序用来存储自身信息的成员。
与结构 vm_area_struct 一样,vm_operations_struct 也是在头文件 <linux/mm.h> 中定义的,它包含了下面所列出的操作,这些操作是处理进程内存所必须的。这里按声明顺序将它们列在下面。本章后面将实现其中一些函数,并对它们进行详细的描述。
void (*open)(struct vm_area_struct *vma)
内核调用 open 方法以允许子系统实现 VMA 对区域的初始化,调整引用计数等等。在每次产生一个 VMA 的新引用(例如进程分叉)时,该方法就被调用。一个例外就是用 mmap 首次生成 VMA 的时候,在这种情况下,会调用驱动程序的 mmap 方法。
void (*close)(struct vm_area_struct *vma)
当一个区域被销毁时,内核会调用 close 操作。需要注意的是并没有与VMA相关联的使用计数;区域由使用它的每个进程正确地打开和关闭。
void (*unmap)(struct vm_area_struct *vma, unsigned long addr, size_t len)
内核调用该方法来撤销一个区域的部分或全部映射。如果整个区域的映射被撤销,内核在vm_ops->unmap 返回时调用 vm_ops->close。
void (*protect)(struct vm_area_struct *vma, unsigned long, size_t, unsigned int newprot);
这种方法的目的是改变内存区域的保护权限,但是当前并未被使用。页表负责内存保护,而内核则分别创建各个页表项。
int (*sync)(struct vm_area_struct *vma, unsigned long, size_t, unsigned int flags);
系统调用 msync 会调用该方法将一个脏的内存区域保存到存储介质中。如果返回值为0,则表示该方法成功;如果为负,则表示有错误产生。
struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int write_access);
当一个进程试图访问属于另一个当前并不在内存中的有效 VMA 页时,nopage 方法(如果它被定义的话)就会被调用以处理相关区域。这个方法返回物理页的 struct page 指针,然后就可能从辅助存储器中将其读入。如果没有为该区域定义 nopage 方法,内核就会分配一个空页。第三个参数 write_access 被当作"非共享":一个非零值意味着该页必须为当前进程所有,为零则表示共享是可能的。
struct page *(*wppage)(struct vm_area_struct *vma, unsigned long address, struct page *page);
该方法处理写保护的页故障,但是当前并未使用。内核不需要调用区域特有的回调函数,就可以处理向一个被保护页面上写入的企图。写保护故障用来实现写时复制。一个私有页可以在进程之间共享,直到其中一个进程对该页进行写操作为止。当这种情况发生时,该页被复制,进程会向自己的复制页上写入。如果整个区域被标志为只读,则会向进程发送 -SIGSEGV 信号,并且不执行任何写时复制操作。
int (*swapout)(struct page *page, struct file *file);
当内核选择一个页交换出去时,就会调用该方法。如果返回零值,则表示调用成功,而其他任何的返回值都表示出现错误。在出现错误的情况下,内核会向拥有该页的进程发送一个SIGBUS 信号。对驱动程序来说,没有多少必要去实现 swapout 方法;设备映射并不是内核能写入磁盘的东西。
我们总结了 Linux 内存管理的数据结构的概要,现在我们可以继续讨论"mmap"系统调用的实现了。
13.2 mmap设备操作
内存映射是现代 Unix 系统最有趣的特征之一。对于驱动程序来说,内存映射可以提供给用户程序直接访问设备内存的能力。
看一下X Window系统服务器的虚拟内存区域,就可以看到 mmap 用法的一个明显的例子:
|
X 服务器的VMA的整个列表是很长的,但是这里我们对大部分的项都不感兴趣。然而,确实可以看到 /dev/mem 的三个独立的映射,它可以使我们对 X 服务器怎样与显示卡协同工作有一些了解。第一个映射显示了映射到 fe2fc000 的一个 16KB 区域,这个地址远远高于系统上最高的 RAM 地址,它是 PCI 外围设备(显示卡)的一段内存区域,它是该卡的控制区域。中间的映射位于 a0000,它是在 640KB ISA 空洞中的标准位置。最后的 /dev/mem 映射是位于 f4000000 位置的一个相当大的区域,而且是显示内存本身。这些区域也可以在 /proc/iomem 中看到:
|
映射一个设备,意味着使用户空间的一段地址关联到设备内存上。无论何时,只要程序在分配的地址范围内进行读取或者写入,实际上就是对设备的访问。在 X 服务器的例子中,使用 mmap 可以既快速又简单地访问显示卡的内存。对于象这样的性能要求比较严格的应用来说,直接访问能给我们提供很大不同。
正如读者所怀疑的,并不是所有的设备都能进行 mmap 抽象;例如,象串口设备和其它面向流的设备,就无法实现这种抽象。mmap 的另一个限制是映射都是以 PAGE_SIZE 为单位的。内核只能在页表一级上处理虚拟地址;因此,被映射的区域必须是 PAGE_SIZE 的整数倍,而且必须位于起始于 PAGE_SIZE 整数倍地址的物理内存内。如果区域的大小不是页大小的整数倍,内核可通过生成一个稍微大一些的区域来调节页面大小粒度。
这些限制对于驱动程序来说并不是很大的问题,因为程序访问设备的动作总是依赖于设备的,它需要知道如何使得被映射的内存区域有意义,所以 PAGE_SIZE 对齐不是一个问题。在 ISA 设备用于某些非 x86 平台时存在一个比较大的限制,因为它们对于 ISA 硬件的开发并不一样。例如,某些 Alpha 计算机将 ISA内存看成是不能直接映射的、离散的8位、16位或者32位项的集合。这种情况下,根本不能使用 mmap。不能直接将ISA地址映射到 Alpha 地址的原因,是由于两种系统间不兼容的数据传输规范导致的。早期的 Alpha 处理器只能进行 32 位和 64 位的内存访问,而 ISA 只能进行 8 位和 16 位的数据传输,并且没有透明地将一个协议映射到另一个之上的方法。
在能够使用 mmap 的情况下,使用 mmap 还有另外一个优势。例如,我们已经讨论了 X 服务器,它可以与显示内存进行大量的数据交换。相对于 lseek/write 实现来说,将图形显示映射到用户空间可以显著提高吞吐量,另一个典型的例子是受程序控制的 PCI 设备。大多数 PCI 外围设备都将它们自己的控制寄存器映射到内存地址上,而苛刻的应用程序可能更喜欢直接访问寄存器,而不是重复调用 ioctl 来完成它的工作。
mmap 方法是 file_operations 结构中的一员,并且在执行 mmap系统调用时就会调用该方法。在调用实际方法之前,内核会完成很多工作,而且该方法的原型与其系统调用的原型具有很大区别。这与其它系统调用如 ioctl 和 poll 不同,在调用它们之前内核不需要做太多的工作。
系统调用声明如下(就像在 mmap(2) 手册页中描述的一样):
|
另一方面,文件操作却声明为
|
该方法中的参数 filp 与在第 3 章中介绍的一样,而 vma 包含了用于访问设备虚拟地址区间的信息。大部分工作已经由内核完成了;要实现 mmap,驱动程序只要为这一地址范围构造合适的页表,如果需要的话,用一个新的操作集替换 vma->vm_ops。
有两种建立页表的方法:使用 remap_page_range 函数可一次建立所有的页表,或者通过 nopage VMA 方法每次建立一个页表。这两种方法有它各自的优势,我们从"一次建立所有"的方法开始谈起,因为这个方法很简单。接下来我们会针对实际的实现增加复杂性。
13.2.1 使用remap_page_range
构造用于映射一段物理地址的新页表的工作,是由 remap_page_range 完成的,它的原型如下:
|
函数返回的值通常是 0 或者一个负的错误码。让我们看看该函数参数的确切含义:
virt_add
重映射起始处的用户虚拟地址。函数为虚拟地址 virt_add 和 virt_add + size 之间的区间构造页表。
phys_add
虚拟地址所映射的物理地址。函数影响从 phys_add 到 phys_add + size 的物理地址。
size
被重映射的区域的大小,以字节为单位。
prot
新 VMA 的"保护(protection)"。驱动程序可以(或者应该)使用 vma->vm_page_prot 中的值。
remap_page_range 的参数还算是比较容易理解的,并且在你的 mmap 方法被调用时,它们中的大部分已经在 VMA 中提供给你了。一种复杂一些的情形涉及到高速缓存:通常,对设备内存的引用不应该被处理器缓存。系统的 BIOS 会正确的设置它,但是也可以通过 protection 成员来禁止指定 VMA 的高速缓存。不幸的是,在这一级上禁止高速缓存是高度依赖于处理器的。感兴趣的读者可查看 drivers/char/mem.c 中的函数 pgprot_noncached 来了解这个过程所涉及到的内容。我们在这里不会进一步讨论这个话题。
13.2.2 一个简单的实现
如果读者的驱动程序需要实现一个简单的、设备内存的线性映射到用户地址空间中,调用 remap_page_range 几乎就是需要做的所有工作了。下面的代码是从文件 drivers/char/mem.c 中摘取的,并说明了在一个典型的名为"simple"(Simple Implementation Mapping Pages with Little Enthusiasm)的模块中这个任务是如何执行的:
|
/dev/mem 的代码进行了检查,以查看所请求的偏移量是否超出了物理内存;如果是的话,则设置 VMA 的 VM_IO 标志,以标记该区域为 I/O 内存。VM_RESERVED 标志总是被设置以防止系统将该区域交换出去,然后它必然调用 remap_page_range 来构造必需的页表。
13.2.3 增加VMA操作
正如我们已经看到的,结构 vm_area_struct 包含了一系列可以应用于 VMA 的操作。现在我们会着眼于以一种简单的方法来提供那些操作,更详细的例子会在后面给出。
这里,我们会为我们的VMA提供 open 和 close 操作。这些操作会在进程打开或关闭VMA的任何时候被调用,特别地,open 方法会在进程分叉并创建该 VMA 的新引用时被调用。VMA 的 open 和 close 方法在内核执行的处理之外调用,因此不必在这里重新实现内核完成的这些工作。它们的存在,只是提供给驱动程序程序一个途径,以便完成一些额外的、必需的处理。
我们将使用这些方法,在 VMA 被打开时增加模块的使用计数,而在被关闭时减少使用计数。在现代的内核中,这个工作并不是严格必需的;只要 VMA 保持打开状态,内核就不会调用驱动程序的 release 方法,因此,直到对 VMA 的所有引用都被关闭之后,使用计数才会下降到零。但 2.0 内核中没有执行该跟踪,所以可移植代码仍会希望维护使用计数。
所以,我们会用跟踪使用计数的操作来覆盖默认的 vma->vm_ops。代码相当简单――对模块化 /dev/mem 的一个完整 mmap 实现,如下所示:
|
这段代码依赖于这一事实:在调用 f_op->mmap 之前,内核将最近创建的区域中的 vm_ops 成员初始化为 NULL。为安全起见,上述代码检查了指针的当前值,这在将来的内核中可能需要做一些改动。
出现在这段代码中的 VMA_OFFSET 宏,用来隐藏 vma 结构在不同内核版本之间的差异。因为偏移量在 2.4 中是以页为单位的,而在 2.2 和更早的内核中是以字节为单位的,为此,头文件 <sysdep.h> 声明了这个宏以便这个差异能够被透明处理(其结果以字节表达)。
13.2.4 使用 nopage 映射内存
尽管 remap_page_range 能够在多数情况下工作良好,但并不是能够适合所有的情况。驱动程序的 mmap 实现有时必须具有更好的灵活性。在这种情形下,提倡使用 VMA 的 nopage方法实现内存映射。
nopage 方法具有如下原型:
|
当一个用户进程试图访问当前不在内存中的 VMA 页面时,就会调用关联的 nopage 函数。参数 address 包含导致失效的虚拟地址,该地址向下圆整到所在页的起始地址。函数 nopage 必须定位并返回指向用户所期望的页的 struct page 指针。这个函数还要调用 get_page 宏,增加它返回的页面的使用计数:
|
这一步骤是必要的,以保证被映射页面上的正确引用计数。内核为每个页维护这个计数;当这个计数降为 0 时,内核知道该页应该被置入空闲链表。在一个 VMA 被取消映射时,内核会为该区域中的每一页减少使用计数。如果在向区域中添加一页时,驱动程序没有增加计数,那么使用计数就可能过早地变为零,从而危及到系统的完整性。
nopage 方法在 mremap 系统调用中非常有用。应用程序使用 mremap 来改变一个映射区域的边界地址。如果驱动程序希望处理 mremap,先前的实现就不能正确工作,这是因为驱动程序没有办法知道被映射的区域已经改变了。
Linux的 mremap 实现没有通知驱动程序被映射区域的变化。事实上,当区域减小时,它会通过 unmap 方法通知驱动程序;但如果区域变大,却没有相应的回调函数可以利用。
将区域减少的变化通知驱动程序,这种做法背后的基本思想是,驱动程序(或者将一个常规文件映射到内存的文件系统)需要知道何时区域被撤销映射,以便采取适当的动作,例如将页面刷新到磁盘上等等。另一方面,映射区域的增长,在程序调用 mremap访问新的虚拟地址之前,对于驱动程序来说却没有实际意义。在实际情况中,经常会出现映射区域从来不会被用到的情况(例如,一段无用的程序代码)。因此,在映射区域增长时,Linux 内核并不会通知驱动程序,因为在实际访问这样的页面时,nopage 方法会处理这种情况。
换句话说,在映射区域增长时驱动程序不会得到通知,因为 nopage 方法会在将来完成相应的工作,从而不必在真正需要之前使用内存。这个优化主要时针对常规文件的,因为它们使用真正的 RAM 进行映射。
因此,如果我们要支持 mremap 系统调用,就必须实现 nopage 方法。但是,一旦拥有 nopage 方法,我们就可以选择广泛使用该方法,当然会有一些限制(后面描述)。这个方法在下面的代码段中给出。在这个 mmap 实现中,设备方法仅仅替换了 vma->vm_ops。而 nopage 方法每次重映射一个页面,并返回它的 struct page 结构的地址。因为这里我们只是实现了物理内存之上的一个窗口,因此,重映射步骤非常简单――我们仅需要查找并返回一个指向预期 struct page 地址的指针。
使用 nopage 的 /dev/mem 实现如下所示:
|
这里,我们再次简单地映射主内存,因此,nopage 函数只要找到对应于失效地址的正确 struct page,并增加它的引用计数。这样,必要的处理顺序如下:首先计算想得到的物理地址,然后用 _ _va 将它转换成逻辑地址,最后用 virt_to_page 将逻辑地址转成一个struct page。一般而言,直接从物理地址获得struct page是可能的,但这样的代码很难在不同的体系结构之间移植。如果有人试图映射高端内存,那么这样的代码或许还是需要的,因为高端内存没有逻辑地址。"simple"很简单,因此不需要考虑此种情况。
如果 nopage 方法被置为NULL,处理页故障的内核代码就会将零页映射到失效的虚拟地址。零页是一个读取时为零的写时复制页,可以用来映射 BSS 段。因此,如果一个进程通过调用 mremap 来扩展一个已被映射的区域,并且驱动程序还没有实现 nopage 方法,它就会得到零页而不是段错误。
nopage 方法通常会返回一个指向 struct page 的指针。如果由于某种原因,不能返回正常的页(例如,请求的地址超出了设备的内存区域),就可以返回 NOPAGE_SIGBUS 以报告错误。nopage 也可以返回 NOPAGE_OOM,来指出由于资源限制造成的失败。
注意,对于 ISA 内存区域,这个实现会正常工作,而在 PCI 总线上却不行。PCI 内存被映射到系统内存最高端之上,因此在系统内存映射中没有这些地址的入口。因为无法返回一个指向 struct page 的指针,所以 nopage 不能用于此种情形;这种情况下,读者必须使用 remap_page_range。
13.2.5 重映射特定的 I/O 区域
迄今为止,我们看到的所有例子都是 /dev/mem 的再次实现,它们将物理地址重映射到用户空间。然而,典型的驱动程序只想映射对应外围设备的小地址区间,而不是所有的内存。为了向用户空间映射整个内存区间的一个子集,驱动程序仅仅需要处理偏移量。下面几行为映射起始于物理地址 simple_region_start、大小为 simple_region_size 字节的区域的驱动程序完成了这项工作。
|
除了计算偏移量,这段代码还引入了检测,可以在程序试图映射多于目标设备 I/O 区域可用内存时报告一个错误。在本段代码中,psize 是指定偏移之后剩余的物理 I/O 大小,而vsize 是请求的虚拟内存大小,该函数拒绝映射超出允许内存范围的地址。
注意,用户程序总是能够使用 mremap 来扩展它的映射,从而可能超越物理设备区域的末端。如果驱动程没有 nopage 方法,就永远不会获得关于这个扩展的通知,而且附加的区域会映射到零页。作为驱动程序作者,读者可能希望阻止这种行为;将零页映射到区域的末端并不是一个很糟糕的事情,但是程序员也不希望这种情况出现。
阻止扩展映射的最简单办法是实现一个简单的 nopage 方法,它总是向错误进程发送一个总线错误信号。这个简单的 nopage 方法如下所示:
|
13.2.6 重映射 RAM
当然,一个更彻底的实现应该检查出错的地址是否位于设备区域中,如果是,则执行重映射。然而需要再次说明,nopage 不会处理 PCI 内存区域,所以 PCI 映射的扩充是不可能的。
在Linux中,如果内存映像中的一页物理地址被标记为"reserved(保留的)",就表明该页对内存管理来说不可用。例如在 PC 上,640 KB 到 1 MB 之间的部分被标记为保留的,因为这个范围是位于内核自身代码的页。
remap_page_range 的一个有意思的限制是,它只能对保留页和物理内存之上的物理地址给予访问。保留页被锁在内存中,是仅有的能安全映射到用户空间的页,这个限制是系统稳定性的一个基本要求。
因此,remap_page_range 不会允许重映射常规地址――包括通过调用 get_free_page 获得的页面。它会改为映射到零页,虽然如此,该函数还是做了大多数硬件驱动程序需要它做的事情,因为它能够重映射高端 PCI 缓冲区和 ISA 内存。
remap_page_range 的限制能够通过运行 mapper 看到,mapper是 O'Reilly FTP 站点上提供的 misc-progs 目录下的一个样例程序。mapper 是一个可以用来快速检验 mmap 系统调用的简单工具,它根据命令行选项映射一个文件中的只读部分,并把映射区域的内容列在标准输出上。例如,下面这个会话过程表明,/dev/mem 没有映射位于 64 KB 地址处的物理页――而我们看到的是完全是零的页(这个例子中的主机是 PC,但在其它平台上结果应该是一样的):
|
remap_page_range 对处理 RAM 的无能为力表明,类似 scullp 这样的设备不能简单地实现 mmap,因为它的设备内存是常规 RAM,而不是 I/O 内存。幸运的是,有一种简单的方法可以帮助那些需要映射 RAM 到用户空间的驱动程序,就是使用我们先前看到的 nopage 方法。
使用nopage方法重映射RAM
将实际的 RAM 映射到用户空间的方法是使用 vm_ops->nopage 来处理每个页故障。作为 scullp 模块的一部分,示例实现已经在第 7 章介绍过了。
scullp 是面向页的字符设备。正因为它是面向页的,所以能够在它的内存中实现 mmap。实现内存映射的代码使用一些先前在"Linux 中的内存管理"一节中介绍过的概念。
在查看代码之前,让我们看一下影响 scullp 中 mmap 实现的设计选择。
只要设备是被映射的,scullp 就不会释放设备内存。这与其说是需求,不如说是策略,而且这与 scull 及类似设备的行为不同,因为它们的长度会在写打开时截为零。拒绝释放被映射的 scullp 设备这一行为,能够允许一个进程改写正在被另一个进程映射的区域,这样读者就能够测试并看到进程与设备内存之间是如何交互的。为了避免释放已映射的设备,驱动程序必须保存一个活动映射的计数,这可以使用设备结构中的 vmas 成员实现。
只有在 scullp 的 order 参数为 0 时,才执行映射内存。该参数控制如何调用 get_free_pages(参见第 7 章"get_free_page 及相关函数"),而这种选择是由get_free_pages的内部实现决定的,而这个函数是 scullp 使用的分配引擎。为了取得最佳的分配性能,Linux 内核为每一个分配幂次维护一个空闲页的列表,而且只有簇中的第一个页的页计数可以由 get_free_pages 增加,并由 free_pages 减少。如果分配幂次大于 0,那么对于 scullp 设备来说 mmap 方法是关闭的,因为 nopage 只处理单页而不处理一簇页面(如果读者需要复习一下 scullp 和内存分配幂次的值,可以返回到第 7 章的"使用一整页的 scull: scullp"一节)。
最后一个选择主要是保证代码简洁。通过处理页的使用计数,也有可能为多页分配正确地实现"mmap",但那样只能增加例子的复杂性,而不能带来任何我们感兴趣的内容。
如果代码想要按照上面描述的规则来映射 RAM,就需要实现 open、close 和 nopage 等方法,它也需要访问内存映像来调整页的使用计数。
这个 scullp_mmap 的实现是很简洁的,因为它依赖 nopage 函数来完成所有的工作:
|
开头的条件语句的目的,是为了避免映射分配幂次不为0的设备。scullp 的操作被存储在vm_ops 成员中,而且一个指向设备结构的指针被存储在 vm_private_data 成员中。最后,vm_ops->open 被调用以更新模块的使用计数和设备的活动映射计数。
open 和 close 只是简单地跟踪这些计数,这些方法定义如下:
|
函数 sculls_vma_to_dev 简单地返回成员 vm_private_data 的内容。因为在 2.4 之前的内核版本中没有 vm_private_data 成员,所以 sculls_vma_to_dev 以一个单独的函数的形式提供,实际上它只是用来获得成员 vm_private_data 的指针。更详细的内容请看本章最后部分的"向后兼容性"小节。
大部分工作由 nopage 完成。在 scullp 的实现中,nopage 的参数 address 用来计算设备里的偏移量,然后该偏移量可在 scullp 的内存树中查找正确的页。
|
scullp 使用由 get_free_pages 获得的内存。该内存使用逻辑地址寻址,所以所有的 scullp_nopage 不得不调用 virt_to_page 来获得一个 struct page 指针。
现在 scullp 设备可以按预期工作了,正如读者在工具 mapper 的如下示例输出中所看到的。这里,我们发送一个 /dev (很长的)目录清单给 scullp 设备,然后使用工具 mapper 来查看 mmap 生成的清单片断。
|
13.2.7 重映射虚拟地址
尽管很少需要重映射虚拟地址,但看看驱动程序是如何使用 mmap 将虚拟地址映射到用户空间还是很有意义的。记住,一个真正的虚拟地址是由函数 vmalloc 或者 kmap 等返回的地址――也就是已经被映射到内核页表的虚拟地址。本节中的代码是取自 scullv,这个模块完成与 scullp 类似的工作,只是它是通过 vmalloc 来分配它的存储空间。
scullv 的大部分实现与我们刚刚看到的 scullp 基本类似,除了不需要检查控制内存分配的order参数之外。原因是 vmalloc 每次只分配一页,因为单页分配比多页分配容易成功的多。因此,分配的幂次问题在 vmalloc 分配的空间中不存在。
vmalloc 的大部分工作是构造页表,从而可以象连续地址空间一样访问分配的页。为了向调用者返回一个 struct page 指针,nopage 方法必须将页表打散。因此,scullv 的nopage实现必须扫描页表以取得与页相关联的页映像入口。
除了结尾部分,这个函数与我们在 scullp 中看到的一样。这个代码的节选只包括了 nopage 中与 scullp 不同的部分:
|
页表由本章开头介绍的函数来查询。为这一目的而使用的页目录储存在内核空间的内存结构 init_mm 中。注意,scullv 要在访问页表之前获得 page_table_lock,如果没有持有该锁,在 scullv 查找过程进行的中途,其它的处理器可能会改变页表,从而导致错误的结果。
宏 VMALLOC_VMADDR(pageptr) 返回正确的 unsigned long 值,用于 vmalloc 地址的页表上查询。由于一个内存管理的小问题,对该值的强制类型转换在早于 2.1 的 x86 内核上不能正常工作。2.1.1版本的 x86 内存管理做了改动,和其它平台一样,现在的 VMALLOC_VMADDR 被定义为一个实体函数。然而,为了编写可移植性代码,我们仍然建议使用这个宏。
基于上述讨论,读者可能也希望将 ioremap 返回的地址映射到用户空间。这种映射很容易实现,因为读者可以直接使用 remap_page_range,而不用实现虚拟内存区域的方法。换句话说,remap_page_range 已经可用于构造将 I/O 内存映射到用户空间的新页表,并不需要象我们在 scullv 中那样查看由 vremap 构造的内核页表。
13.3 kiobuf 接口
从版本 2.3.12 开始,Linux 内核支持一种叫做内核 I/O 缓冲区或者 kiobuf 的 I/O 抽象对象。kiobuf 接口用来从设备驱动程序(以及系统的其它 I/O 部分)中隐藏虚拟内存系统的复杂性。开发人员打算为 kiobuf 实现很多特性,但是它们最初用于 2.4 内核是为了便于将用户空间的缓冲区映射到内核。
13.3.1 kiobuf 结构
任何使用 kiobuf 的代码必须包含头文件 <linux/iobuf.h>。该文件定义了 struct kiobuf 结构,它是 kiobuf 接口的核心部分。这个结构描述了构成 I/O 操作的一个页面数组,它的成员有:
int nr_pages;
该 kiobuf 中页的数量
int length;
在缓冲区中数据的字节数量
int offset;
相对于缓冲区中第一个有效字节的偏移量
struct page **maplist;
一个 struct page 结构数组,每一项代表 kiobuf 中的一个数据页。
kiobuf 接口的关键之处就是 maplist 数组。那些用来操作存储在 kiobuf 中的页的函数,直接处理 page 结构,这样,就可以跳过所有的虚拟内存系统开支。这种实现允许驱动程序独立于复杂的内存管理,并且以非常简单的方式运行。
在使用之前,必须对 kiobuf 进行初始化。很少会初始化单个的 kiobuf,但如果需要,这种初始化可通过 kiobuf_init 来执行:
|
通常,kiobuf 以组的方式被分配为一个内核 I/O 向量(即 kiovec)的一部分。通过调用alloc_kiovec,就可以分配和初始化一个 kiovec。
|
照常,返回值是 0 或一个错误码。在代码结束 kiovec 结构的使用时,就应该将它返还给系统:
|
内核提供一对函数来锁住和解锁 kiovec 中被映射的页:
|
然而,对于在设备驱动程序中看到的大多数 kiobuf 应用来说,以这种方式锁住一个 kiobuf 是多余的。
13.3.2 映射用户空间缓冲区以及裸I/O
Unix 系统已经对某些设备提供了"裸"接口――特别是块设备――它直接从用户空间缓冲区执行 I/O,从而避免了经由内核复制数据。在某些情况下可以通过这种方式大大地提高性能,尤其在被传输的数据近期不会被再次使用的情况下。例如,典型的磁盘备份只是从磁盘一次性的读入大量数据,然后就不会处理这些数据了,通过裸接口运行备份会避免无用的数据占用系统缓冲区的高速缓存。
出于很多原因,早期的 Linux 内核没有提供裸接口。然而,由于系统获得了普及,并且更多的希望能处理裸 I/O 的应用程序(例如大型数据库管理系统)被移植过来。所以 2.3 开发系列最终还是增加了裸 I/O;为了增加这种裸 I/O 能力,才有了 kiobuf 接口。
裸 I/O 不是象某些人认为的那样总是高性能的推进措施,驱动程序作者不应该只因为它能够增进性能而总是采用。设置裸传输的开支也是很明显的,而且内核缓冲数据的优势也就丢失了。例如,裸 I/O 操作几乎总是必须同步的――write 系统调用直到操作结束后才能返回。当前,Linux 还缺少可以让用户程序能够在用户缓冲区上安全地执行异步裸 I/O 的机制。
本节中,我们为块设备驱动程序范例 sbull 增加了裸 I/O 能力。在 kiobuf 可用时, sbull 实际上注册了两个设备。我们已经在第 12 章中详细讨论了块的 sbull 设备。我们上一章没有看到的是第二个字符设备(叫做"sbullr"),它提供了对 RAM 磁盘设备的裸访问。这样,/dev/sbull0 和 /dev/sbullr0 访问同一块内存,前面的样例使用传统的缓冲模式,而第二个样例通过 kiobuf 机制提供了裸访问。
在 Linux 系统中值得注意的是,对于块设备驱动程序来说,我们不需要提供这种接口。在源文件drivers/char/raw.c中,raw 设备为所有块设备以精美而通用的方法提供了这种能力。块驱动程序甚至不需要知道它们是否正在处理裸 I/O。出于示范的目的,sbull 中的裸 I/O 代码本质上是 raw 设备代码的简化版本。
块设备的裸 I/O 必须始终是扇区对齐的,而且它的长度必须是扇区大小的整数倍。其它类型的设备,例如磁带驱动程序可以没有这样的限制。sbullr 象块设备一样运转,而且必须符合对齐和长度的要求,为此,它定义了几个符号:
|
裸设备 sbullr 只有在硬扇区尺寸等于 SBULLR_SECTOR 时才会被注册。没有真正的原因为什么不支持大的硬扇区尺寸,只是因为这样会导致示例代码复杂化。
sbullr 实现只是对现存的 sbull 增加了少量代码。特别地,sbull 中的 open 和 close 方法未经修改而直接使用。因为 sbullr 是一个字符设备,所以它需要 read 和 write 方法。它们都被定义为使用一个单独的传输函数,就像下面这样:
|
在将实际的数据传送给另一个函数时,函数 sbullr_transfer 处理所有的组装和拆卸任务。该函数实现如下:
|
在作两个常规检查之后,代码使用 alloc_kiovec 创建了一个kiovec(包含单个 kiobuf)。然后它调用 map_user_kiobuf 将用户缓冲区映射到该 kiovec:
|
如果所有工作正常进行,这个调用的结果是将给定(用户虚拟) address 且长度为 len 的缓冲区映射到给定的 iobuf。该操作可能进入睡眠,因为用户缓冲区很可能会需要经过页故障处理以装入内存。
当然,通过这种方式映射的 kiobuf 最终必须被撤销映射以保持页引用计数的连贯性。在代码中可以看到,这个撤销映射的过程通过将 kiobuf 传递给 unmap_kiobuf 而实现。
迄今为止,我们已经看到如何为 I/O 准备 kiobuf,但没有看到如何去实际执行这个 I/O。最后一个步骤涉及到 kiobuf 中的每一页,并完成所请求的传送;在 sbullr 中,该任务通过 sbullr_rw_iovec 处理。本质上,这个函数遍历每一页,将它拆分成扇区大小的块,并通过一个伪请求结构将这些块传递给 sbull_transfer:
|
这里,kiobuf 结构的 nr_pages 成员告诉我们有多少页需要传送,而 maplist 数组可以让我们访问每一页。这样,我们就能够方便地遍历这些页了。但要注意,kmap 被用于为每一页获得内核的虚拟地址,这样,即使用户缓冲区处在高端内存,函数也能正常工作。
一些对于复制数据的快速测试表明:一次与 sbullr 设备之间的数据复制相比于与 sbull 块设备的同样复制只会大约花费后者三分之二的系统时间。这个时间上的节省是通过避免了额外的经由缓冲区高速缓存的复制而获得的。注意,如果同样的数据被几次重复读取的话,那么这种节省就没有了――特别是对于一个真正的硬件设备。裸设备访问常常不是最好的方法,但是对于某些应用来说,它能够提供很大的性能改进。
尽管kiobuf在内核开发团体中存有争议,在很多情况下使用它们还是很有意思的。例如,有一个用 kiobuf 实现 Unix 管道的补丁――数据被直接从一个进程的地址空间复制到另一个进程的地址空间而根本没有经过内核的缓冲。还有一个补丁,它能够方便地将内核虚拟地址映射到进程的地址空间,这样,就消除了使用前述 nopage 实现的需求。
13.4 直接内存访问和总线控制
直接内存访问,或者 DMA,是我们最后要讨论的高级主题。DMA 是一种硬件机制,它允许外围设备和主内存之间直接传输它们的 I/O 数据,而不需要在传输中使用系统处理器。使用这种机制可以大大提高与设备通讯的吞吐量,因为免除了大量的计算开支。
为了利用硬件的 DMA 能力,设备驱动程序需要能够正确地设置 DMA 传输并能够与硬件同步。不幸的是,由于硬件本身的性质,DMA 是高度依赖于系统的。每一种体系结构都有它自己的管理 DMA 传输的技术,而且彼此的编程接口也是不同的。内核不能提供统一的接口,因为驱动程序很难将底层的硬件机制适当地抽象。然而在最近的内核中,某些步骤已经被向这个方向发展。
本章主要集中在 PCI 总线上,因为它是当前可用的外围总线中最流行的一种,但很多概念是普遍适用的。我们也会简单谈到其它总线处理 DMA 的方式,例如 ISA 和 Sbus。
13.4.1 DMA 数据传输概览
在介绍编程细节之前,让我们回顾一下 DMA 传输是如何进行的。为简化讨论,只考虑输入传输。
数据传输可以以两种方式触发:或者软件请求数据(例如通过函数 read)或者由硬件将数据异步地推向系统。
在第一种情况下,调用的步骤可以概括如下:
1.在进程调用 read 时,驱动程序的方法分配一个 DMA 缓冲区,随后指示硬件传送它的数据。进程进入睡眠。
2.硬件将数据写入 DMA 缓冲区并在完成时产生一个中断。
3.中断处理程序获得输入数据,应答中断,最后唤醒进程,该进程现在可以读取数据了。
第二种情形是在 DMA 被异步使用时发生的。例如,数据采集设备持续地推入数据,即使没有进程读取它。这种情况下,驱动程序应该维护一个缓冲区,使得接下来的 read 调用可以将所有累积的数据取回到用户空间。这种传送的调用步骤稍有不同:
1.硬件发出中断来通知新的数据已经到达。
2.中断处理程序分配一个缓冲区并且通知硬件将数据传往何处。
3.外围设备将数据写入缓冲区,然后在完成时发出另一个中断。
4.处理程序分发新的数据,唤醒任何相关进程,然后处理一些杂务。
不同的异步方法常常可以在网卡的代码中看到。这些网卡经常期望能有一个循环缓冲区(通常叫做 DMA 环形缓冲区)建立在与处理器共享的内存中。每一个输入数据包被放置在环形缓冲区中下一个可用缓冲区,并且发出中断。然后驱动程序将网络数据包传给内核的其它部分处理,并在环形缓冲区中放置一个新的 DMA 缓冲区。
上面这两种情况下的处理步骤都强调:高效的 DMA 处理依赖于中断报告。尽管可以用一个轮询驱动程序来实现 DMA,但这样做没有什么意义,因为一个轮询驱动程序会将 DMA 相对于简单的处理器驱动 I/O 获得的性能优势抵消掉。
这里介绍的另一个相关问题是 DMA 缓冲区。为了利用直接内存访问,设备驱动程序必须能够分配一个或者更多的适合 DMA 的特殊缓冲区。注意,很多驱动程序在初始化时分配它们的缓冲区,并使用它们直到停止运行--因此在前面涉及到的"分配"一词意味着"获取一个先前分配的缓冲区"
13.4.2 分配 DMA 缓冲区
本节主要讨论在较低层的 DMA 缓冲区分配方法,很快我们就会介绍一个较高层的接口,但是正确理解这里介绍的内容还是很重要的。
DMA 缓冲区的主要问题是,当它大于一页时,它必须占据物理内存中的连续页,因为设备使用 ISA 或者 PCI 系统总线传送数据,它们都使用的是物理地址。值得注意的是,这个限制对于 SBus 并不适用(见第 15 章中的"SBus"小节),它在外围总线上使用虚拟地址。一些体系结构也能够在 PCI 总线上使用虚拟地址,但是一个可移植的驱动程序不能依靠这种能力。
尽管 DMA 缓冲区可以在系统引导或者运行时分配,但模块只能在运行时分配它们的缓冲区。第 7 章介绍了这些技术:"系统启动时的内存分配"一节讲述了系统引导时的分配,而"kmalloc 函数的内幕"和"get_free_page 和相关函数"描述了运行时分配。驱动程序作者必须小心分配可以应用于 DMA 操作的正确内存类型--不是所有的内存区段都适合。特别地,高端内存在大多数系统上不能用于 DMA--外围设备不能使用高端地址工作。
现代总线上的大部分设备都能够处理 32 位地址,这就意味着普通的内存分配就会很好地为其工作。然而某些 PCI 设备未能实现完整的 PCI 标准,因而不能使用 32 位地址工作。而ISA 设备却只能限制在 16 位地址上。
对于具有该限制的设备,通过给调用 kmalloc 和 get_free_pages 增加 GFP_DMA 标志就可以从 DMA 区段中分配内存。当该标志存在时,只会分配可使用 16 位寻址的内存。
DIY 分配
我们已经明白为什么 get_free_pages(所以 kmalloc)不能返回多于 128 KB 的连续内存空间(或者更普遍而言,32 页)。即使在分配小于 128 KB 的缓冲区时,这个请求也很容易失败,因为随着时间的推移,系统内存会成为一些碎片。*
|
在内核不能返回要求数量的内存时,或者在我们需要多于 128 KB 的内存时(例如,PCI 帧捕获卡的普遍请求),相对于返回 -ENOMEM,另外一个可选的方法是在引导时分配内存或者为缓冲区保留物理 RAM 的顶部。我们已经在第 7 章的"系统启动时的内存分配"小节描述了引导时的分配,但是这种方法对于模块是不可用的。通过在引导时给内核传递一个"mem="参数可以保留 RAM 的顶部。例如,如果系统有 32MB 内存,参数"mem=31M"阻止内核使用最顶部的一兆字节。稍后,模块可以使用下面的代码来访问这些保留的内存:
|
实际上,还有另一种分配 DMA 空间的方法:不断地执行分配,直到能够获得足够的连续页面来构造缓冲区。如果有任意其它方法可以实现这一目的,则不应该使用这种分配技术。不断地分配会导致很高的系统负荷,如果这种作法没有被正确地调整,也可能导致系统锁住。但另一方面,有时确实没有别的方法可以利用。
在实践中,代码调用 kmalloc(GFP_ATOMIC) 直到失败为止,然后它等待内核释放若干页面,接下来再一次进行分配。如果密切注意已分配的页面池,迟早会发现由连续页面组成的DMA 缓冲区已经出现;这时,我们可以释放除了被选中的缓冲区之外的所有页面。这种行为是相当危险的,因为它会导致死锁。我们建议使用内核定时器来释放每一页,以防在给定时间内分配还不能成功。
这里,我们不准备给出代码,但是读者会在 misc-modules/allocator.c 中找到;代码被注释得很详细,而且被设计为可以被其他模块调用。不同于本书中给出的其他源程序,allocator 适用于 GPL 条款。我们决定将源程序置于 GPL 条款之下的理由既不是由于它特别优美也不是由于它特别有技巧,而是如果有人想要使用它,我们希望代码同模块一起发行。
13.4.3 总线地址
一个使用 DMA 的设备驱动程序通常会与连接到接口总线上的硬件通讯,这些硬件使用物理地址,而程序代码使用虚拟地址。
事实上,情况还要更复杂些。基于 DMA 的硬件使用总线地址而不是物理地址,尽管在 PC 上,ISA 和 PCI 地址与物理地址一样,但并不是所有的平台都这样。有时,接口总线是通过将 I/O 地址映射到不同物理地址的桥接电路连接的。甚至某些系统有一个页面映射方案,能够使任意页面在外围总线上表现为连续的。
在最低层(相对,我们很快就会看到一个高层的解决方案),通过导出下列在头文件 <asm/io.h> 中定义的函数,Linux 内核提供了一个可移植的解决方案:
|
当驱动程序需要向一个 I/O 设备(例如扩展板或者DMA控制器)发送地址信息时,必须使用 virt_to_bus 转换,在接受到来自连接到总线上硬件的地址信息时,必须使用 bus_to_virt 了。
13.4.4 PCI 总线上的 DMA
2.4 内核包含一个支持 PCI DMA的灵活机制(也称作"总线控制")。它处理缓冲区分配的细节,也能够为支持多页传送的硬件进行总线硬件设置。这些代码也能处理缓冲区位于不具有 DMA 能力的内存区域的情形,尽管这只在某些平台上实现,并且还有一些额外的计算开支(稍后会看到)。
本节中的函数需要一个用于我们的设备的 struct pci_dev 结构。设置 PCI 设备的细节会在第 15 章中讲述。注意,这里描述的例程也能够用于 ISA 设备,这种情况下,只需将 struct pci_dev 指针赋值为 NULL。
使用下面这些函数的驱动程序应该包含头文件 <linux/pci.h>。
处理不同硬件
在执行 DMA 之前,第一个必须回答的问题是:是否给定的设备在当前主机上具备执行这些操作的能力。很多 PCI 设备不能实现完整的 32 位总线地址空间,常常是因为它们其实是老式 ISA 硬件的修订版本。Linux 内核会试图与这些设备协同工作,但不总是能成功的。
函数 pci_dma_supported 应该为任何具有地址限制的设备所调用:
|
这里,mask仅仅是描述哪些地址位可以被设备使用的位掩码。如果返回值非零,表示 DMA 可用,我们的驱动程序应该将 PCI 设备结构中的 dma_mask 成员设置为该掩码值(即mask)。对于只能处理 16 位地址的设备,我们应该使用类似如下的调用:
|
内核 2.4.3 中提供了一个新的函数 pci_set_dma_mask。这个函数具有如下原型:
|
如果使用给定的掩码能够支持 DMA,这个函数返回 0 并且设置 dma_mask 成员;否则,返回 -EIO。
对于能够处理 32 位地址的设备,就没有调用 pci_dma_supported 函数的必要了。
DMA 映射
一个DMA映射就是分配一个 DMA 缓冲区并为该缓冲区生成一个能够被设备访问的地址的组合操作。很多情况下,简单地调用 virt_to_bus 就可以获得需要的地址,然而有些硬件要求映射寄存器也被设置在总线硬件中。映射寄存器(mapping register)是一个类似于外围设备的虚拟内存等价物。在使用这些寄存器的系统上,外围设备有一个相对较小的、专用的地址区段,可以在此区段执行 DMA。通过映射寄存器,这些地址被重映射到系统 RAM。映射寄存器具有一些好的特性,包括使分散的页面在设备地址空间看起来是连续的。但不是所有的体系结构都有映射寄存器,特别地,PC 平台没有映射寄存器。
在某些情况下,为设备设置有用的地址也意味着需要构造一个反弹(bounce)缓冲区。例如,当驱动程序试图在一个不能被外围设备访问的地址(一个高端内存地址)上执行 DMA 时,反弹缓冲区被创建。然后,按照需要,数据被复制到反弹缓冲区,或者从反弹缓冲区复制。如果想要代码在反弹缓冲区上正常工作,就应该符合某些规则,正如我们很快会看到的。
DMA 映射提出了一个新的类型(dma_addr_t)来表示总线地址。dma_addr_t 类型的变量应该被驱动程序作为不透明物来对待;只有允许的操作会被传给 DMA 支持例程或者传给驱动程序自己。
根据 DMA 缓冲区期望保留的时间长短,PCI 代码区分两种类型的 DMA 映射:
- 一致 DMA 映射 它们存在于驱动程序的生命周期内。一个被一致映射的缓冲区必须同时可被 CPU 和外围设备访问(正如我们稍后会看到的,其他类型的映射在给定的时间只能用于一个或另一个)。如果可能,缓冲区也应该没有高速缓存问题--即能够造成一个(如 CPU)不会看到另一个(如外设)所作的更新的问题。
- 流式 DMA映射 流式DMA映射是为单个操作进行的设置。在使用流式映射时,某些体系结构允许重要优化,但是,正如我们会看到的,这些映射也要服从一组更加严格的访问规则。内核开发者推荐应尽可能使用流式映射,而不是一致映射。这个推荐是基于两个原因。首先,在支持一致映射的系统上,每个 DMA 映射会使用总线上一个或多个映射寄存器。具有较长生命周期的一致映射,会独占这些寄存器很长时间――即使它们没有被使用。其次,在某些硬件上,流式映射能够可以以某种方式优化,而一致映射却不能。
两种映射类型必须以不同的方法操作,现在让我们看一下细节。
建立一致 DMA 映射
驱动程序可调用 pci_alloc_consistent 设置一致映射:
|
这个函数能够处理缓冲区的分配和映射。前两个参数是我们的 PCI 设备结构以及所需缓冲区大小,函数在两处返回 DMA 映射的结果,返回值是缓冲区的内核虚拟地址,它可以被驱动程序使用;而相关的总线地址在 bus_addr 中返回。该函数对分配的缓冲区做了一些处理,从而缓冲区可用于 DMA;通常只是通过 get_free_pages 分配内存(但是要注意,大小以字节计算而不是幂次的值)。
大多数支持 PCI 的体系结构以 GFP_ATOMIC 优先级执行分配,而且这样不会睡眠。但是,内核的 ARM 移植是个例外。
当不再需要缓冲区时(通常在模块卸载时),应该调用 pci_free_consitent 将它返还给系统:
|
注意这个函数需要提供 CPU 地址和总线地址。
建立流式 DMA 映射
由于多种原因,流式映射具有比一致映射更复杂的接口。这些映射希望能与已经由驱动程序分配的缓冲区协同工作,因而不得不处理它们没有选择的地址。在某些体系结构上,流式映射也能够由多个不连续的页和多个"分散/集中"缓冲区。
在设置流式映射时,我们必须通知内核数据将向哪个方向传送。已经为此定义了如下符号:
PCI_DMA_TODEVICE
PCI_DMA_FROMDEVICE
这两个符号无需多做说明。如果数据被发送到设备(也许为响应系统调用 write),应该使用 PCI_DMA_TODEVICE;相反,如果数据将发送到 CPU,则应标记 PCI_DMA_FROMDEVICE。
PCI_DMA_BIDIRECTIONAL
如果数据能够进行两个方向的移动,就使用 PCI_DMA_BIDIRECTIONAL。
PCI_DMA_NONE
这个符号只是为帮助调试而提供。试图以这个"方向"使用缓冲区会造成内核 panic。
出于许多我们很快就会遇到的原因,为流式 DMA 映射选取正确的方向值是很重要的。虽然任何时候都选取 PCI_DMA_BIDIRECTIONAL 是很诱人的,但在某些体系结构上,会因这种选择而损失性能。
在只有单个用于传送的缓冲区时,应该使用 pci_map_single 来映射它:
|
返回值是可以传递给设备的总线地址,如果出错的话就为 NULL。
一旦传送完成,应该使用 pci_unmap_single 删除映射:
|
这里,size 和 direction 参数必须匹配于它们用来映射缓冲区时的值。
下面是一些应用于流式 DMA 映射的重要规则:
- 缓冲区只能用于这样的传送,即其传送方向匹配于映射时给定的方向值。
- 一旦缓冲区已经被映射,它就属于设备而不再属于处理器了。在缓冲区被撤销映射之前,驱动程序不应该以任何方式触及其内容。只有在 pci_unmap_single 被调用之后,对驱动程序来说,访问缓冲区内容才是安全的(但我们将很快看到一个例外)。尤其要说明的是,这条规则意味着要写入设备的缓冲区在包含所有要写入的数据之前,不能映射该缓冲区。
- 在 DMA 仍然进行时,缓冲区不能被撤销映射,否则会造成严重的系统不稳定性。
读者可能会觉得奇怪,为什么一旦缓冲区被映射驱动程序就不能够再使用它。实际上有两个原因使得出现这个规则。第一,在缓冲区为 DMA 映射时,内核必须确保缓冲区中所有的数据已经被实际写到内存。可能有些数据还会保留在处理器的高速缓冲存储器中,因此必须显式刷新。在刷新之后,由处理器写入缓冲区的数据对设备来说也许是不可见的。
第二,如果欲映射的缓冲区位于设备不能访问的内存区段时,我们考虑会产生什么结果。这种情况下,某些体系结构仅仅会操作失败,而其它的体系结构会创建一个反弹缓冲区。反弹缓冲区只是一个可被设备访问的独立内存区域。如果一个缓冲区使用 PCI_DMA_TODEVICE 方向映射,并且需要一个反弹缓冲区,则原始缓冲区的内容作为映射操作的一部分被复制。很明显,原始缓冲区在复制之后的变化对设备来说是不可见的。同样地,PCI_DMA_FROMDEVICE 反弹缓冲区通过 pci_unmap_single 被复制回原始缓冲区;直到复制完成后,来自设备的数据才可用。
顺便提及,反弹缓冲的存在,是"为什么获得正确的方向很重要的"一个理由。PCI_DMA_BIDIRECTIONAL 反弹缓冲区会在操作的前后被复制,而这常常是一种不必要的CPU 时钟周期的浪费。
有时候,驱动程序需要不经过撤销映射就访问流式 DMA 缓冲区的内容,为此,内核提供了如下调用:
|
该函数应该在处理器访问 PCI_DMA_FROMDEVICE 缓冲区之前,或者在访问 PCI_DMA_TODEVICE 缓冲区之后调用。
分散/集中映射
分散/集中映射是流式 DMA 映射的一种特例。假设你有几个缓冲区,而它们需要传送到设备或者从设备传送回来。这种情形可能以几种途径产生,包括从 readv 或者 writev 系统调用产生,从集群的磁盘 I/O 请求产生,或者从映射的内核 I/O 缓冲区中的页面表产生。我们可以简单地依次映射每一个缓冲区并且执行请求的操作,但是一次映射整个缓冲区表还是很有利的。
一个原因是一些设计巧妙的设备能够接受由数组指针和长度组成的"分散表(scatterlist)"并在一个 DMA 操作中传送所有数据;例如,如果数据包能够组装成多块,那么"零拷贝"网络是很容易的实现的。Linux将来很可能会很好地利用这些设备。另一个整个映射分散表的原因是,可以利用总线硬件上具有映射寄存器的系统。在这样的系统上,物理上不连续的页面能够被装配成从设备角度看是单个的连续数组。这种技术只能用在分散表中的项在长度上等于页面大小的时候(除了第一个和最后一个之外),但是在其工作时,它能够将多个操作转化成单个 DMA 操作,因而能够加速处理工作。
最后,如果必须使用反弹缓冲区,将整个表接合成一个单个缓冲区是很有意义的(因为无论如何它也会被复制)。
所以现在你可以确信在某些情况下分散表的映射是值得做的。映射分散表的第一步是建立并填充一个描述被传送缓冲区的struct scatterlist数组。该结构是体系结构相关的,并且在头文件 <linux/scatterlist.h> 中描述。然而,该结构会始终包含两个成员:
char *address;
用在分散/集中操作中的缓冲区地址
unsigned int length;
该缓冲区的长度
为了映射一个分散/集中的 DMA 操作,驱动程序应该为每个欲传送的缓冲区准备的struct scatterlist项中设置 address 和 length 成员。然后调用:
|
返回值是要传送的 DMA 缓冲区数;它可能会小于 nents,也就是传入的分散表项的数量。
驱动程序应该传送每一个 pci_map_sg 返回的缓冲区。每一个缓冲区的总线地址和长度会被存储在 struct scatterlist 项中,但是它们在结构中的位置在不同的体系结构中是不同的。已经定义的两个宏使得编写可移植代码成为可能:
dma_addr_t sg_dma_address(struct scatterlist *sg);
从该分散表项中返回总线地址
unsigned int sg_dma_len(struct scatterlist *sg);
返回该缓冲区的长度
此外,记住准备传送的缓冲区的地址和长度可能会不同于传入 pci_map_sg 的值。
一旦传输完成,分散/集中映射通过调用 pci_unmap_sg 来撤销映射:
|
注意,nents 必须是原先传给 pci_map_sg 的项的数量,而不是函数返回给我们的 DMA 缓冲区的数量。
分散/集中映射是流式 DMA 映射,关于单个种类,同样的访问规则适用于它们。如果读者必须访问一个已映射的分散/集中链表,就必须首先同步它:
|
支持 PCI DMA 的不同体系结构
正如我们在本节开始是说明的,DMA 是硬件特有的操作。我们刚才描述的 PCI DMA 接口试图将很多硬件依赖性尽可能地抽象出来,然而还有一些问题还没有解决。
M68K
S/390
Super-H
到 2.4.0 版本为止,这些体系结构不支持 PCI 总线。
IA-32 (x86)
MIPS
PowerPC
ARM
这些平台支持 PCI DMA 接口,但是其接口主要是骗人的外表。总线接口中没有映射寄存器,所以分散表不能被组合而且不能使用虚拟地址。也没有反弹缓冲区支持,所以不能完成高端地址的映射。ARM 体系结构上的映射函数能够睡眠,而在其它平台上这些函数不能睡眠。
IA-64
Itanium 体系结构也缺少映射寄存器。这个 64 位体系结构能够容易地生成 PCI 外围设备不能使用的地址,因而在此平台上的 PCI 接口实现了反弹缓冲区,允许任意地址被 DMA 操作所使用(表面上)。
Alpha
MIPS64
SPARC
这些体系结构支持 I/O 内存管理单元。自 2.4.0 起,MIPS64 内核实际不再利用这个功能,所以它的 PCI DMA 实现看起来就象 IA-32 的实现。尽管 Alpha 和 SPARC 内核能够利用正确的分散/集中支持来实现完整的缓冲区映射。
这里列出的区别对于大多数驱动程序作者都不是问题,只要遵从接口的一些规则即可。
一个简单的 PCI DMA 例子
在 PCI 总线上 DMA 操作的实际形式非常依赖于被驱动的设备。这样,这个例子不能应用于任何真实设备;它只是一个叫做"dad(DMA Acquisition Device)"的假想设备的一部分。该设备的驱动程序定义了一个这样的传输函数:
|
该函数映射了准备进行传输的缓冲区并且开始设备操作。另一半工作必须在中断服务例程中完成,它看起来有点类似这样:
|
显而易见地,在这个例子中已经忽略了大量细节,包括用来阻止同时开始多个 DMA 操作的必要步骤。
简单看看 Sbus 上的情况
传统上,基于 SPARC 的系统包括一个由 Sun 公司设计的叫做 SBus 的总线。该总线超出了本章的讨论范围,但是简单的讨论还是值得的。有一组在 Sbus 总线上执行 DMA 映射的函数(在头文件 <asm/sbus.h> 中描述);它们的名称类似 sbus_alloc_consistent 和 sbus_map_sg。换句话说,SBus 总线的 DMA API 看起来很象 PCI 接口。在使用 SBus 总线上的 DMA 之前,还是需要详细地看看这些函数的定义,但是概念会与先前对 PCI 总线的讨论相似。
13.4.5 ISA 设备的 DMA
ISA 总线允许两种 DMA 传输:本地(native)DMA 和 ISA 总线控制(bus-master)DMA。本地 DMA 使用主板上的标准 DMA 控制器电路驱动 ISA 总线上的信号线;另一方面,ISA 总线控制 DMA 则完全由外围设备处理。后一种 DMA 类型很少使用,所以就不在这里不讨论了,因为它类似于 PCI 设备的 DMA,至少从驱动程序的角度看是这样的。ISA 总线控制的一个例子是 1542 SCSI 控制器,它的驱动程序是内核源代码中的 drivers/scsi/aha1542.c。
至于本地 DMA,有三种实体涉及到 ISA 总线上的DMA数据传输:
- 8237 DMA 控制器(DMAC) 控制器存有有关 DMA 传送的信息,例如传送方向、内存地址和传送大小。它也包含一个跟踪传送状态的计数器。在控制器收到一个 DMA 请求信号时,它获得总线的控制权并且驱动信号线以使设备能够读写数据。
- 外围设备 设备在准备好传送数据时,必须激活 DMA 请求信号。实际的传输由 DMAC 负责管理,当控制器选通设备后,硬件设备就可以顺序地读/写总线上的数据。传输结束时,设备通常会发出中断。
- 设备驱动程序 驱动程序只需做好如下几点:它向 DMA 控制器提供方向、总线地址和传送大小。它还告诉外围设备准备好传送数据,并在 DMA 结束时响应中断。
原先在 PC 中使用的 DMA 控制器能够管理 4 个通道,每一个通道与一组 DMA 寄存器关联。因此 4 个设备能够同时在控制器中保存它们的 DMA 信息。新的 PC 有两套相当于 DMAC 的设备:*第二个(主)控制器被连接到系统处理器上,第一个(从)控制器被连接到第二个控制器的通道0上。*
|
通道编号为 0 到 7。因为通道 4 是内部用于将从控制器级联到主控制器上的,所以它对于 ISA 外围设备不可用。这样,可用的通道是从控制器上的 0 到 3(8 位通道)和主控制器上的 5 到 7(16位通道)。每次 DMA 传送的大小保存在控制器中,是一个 16 位的数值,表示总线周期数。因此,从控制器的最大传输大小为 64 KB,主控制器的最大传输大小为 128 KB。
因为 DMA 控制器是一个系统级的资源,所以内核协助处理这一资源。内核使用 DMA 注册表为 DMA 通道提供了请求/释放机制,并且提供了一组函数在 DMA 控制器中配置通道信息。
注册 DMA 的方法
读者应该对内核注册表(registry)很熟悉了--我们已经在 I/O 端口和中断信号线部分接触过它们,DMA 通道的注册与它们很相似。在包含头文件 <asm/dma.h> 之后,就可以使用下面的函数来获取和释放 DMA 通道的所有权:
|
参数 channel 是一个0到7的数值,确切的说,是一个小于 MAX_DMA_CHANNELS 的正数。在 PC 上,为了和硬件相匹配,MAX_DMA_CHANNELS 被定义为8。参数 name 是一个用来识别设备的字符串,它所标识的名字出现在文件 /proc/dma 中,该文件可以被用户程序读取。
request_dma 函数的返回值可能是:0 表示成功,-EINVAL 或者 -EBUSY 表示失败。返回 -EINVAL 表示请求的通道超出范围,返回 -EBUSY 表示该通道正在被其它设备所使用。
我们建议读者象对待 I/O 端口和中断信号线一样地小心处理 DMA 通道。在 open 时请求 DMA 通道要比在模块初始化时请求更为有利,推迟请求会为驱动程序间共享 DMA 通道创造条件,例如,声卡可以和相类似的 I/O 接口共享同一 DMA 通道,只要它们不在同一时间使用该通道。
我们同样建议读者在请求了中断线之后请求 DMA 通道,并且在释放中断线之前释放它。这是请求两种资源的通常顺序,依照惯例是为了避免可能的死锁。注意,每一个使用 DMA 的设备同时也需要中断信号线,否则就无法发出数据传输完成的通知。
典型的情况下的 open 代码如下所示。这段代码引用了我们假想的 dad 模块,dad 设备使用了一个快速中断处理并且不支持共享 IRQ 信号线。
|
与 open 相对应的 close 实现如下所示:
|
关注一下 /proc/dma 文件,这是安装了声卡的系统上该文件的内容:
|
值得注意的是,默认的声卡驱动程序在系统启动时就获取了 DMA 通道,并且一直没有释放它。正如前面解释过得那样,显示的cascade 项是一个占位符,表示通道 4 不能被其他设备所使用。
与 DMA 控制器通讯
在注册之后,驱动程序的主要工作包括配置 DMA 控制器以使其正常工作。这一工作非常重要,但幸运的是内核导出了典型驱动程序需要的所有函数。
在调用 read 或write时,或者准备进行异步传输时,驱动程序都需要配置 DMA 控制器。依驱动程序和其所实现的策略的不同,这一工作可以在打开设备时,或者在应答 ioctl 命令时进行。这里给出的代码是驱动程序的 read 和 write 方法所调用的典型代码。
这一小节提供了 DMA 控制器内部的概貌,这样,读者就能理解这里所给出的代码。如果读者想学习关于这部分的更多知识,我们强烈推荐读者去阅读头文件 <asm/dma.h> 以及一些描述 PC 体系结构的硬件手册。尤其是,我们不会在这里处理与 16 位数据传输相对的 8 位传输问题,如果你正在为 ISA 设备板卡编写设备驱动程序,则应该在该设备的硬件手册中查找相关的信息。
DMA 控制器是一个共享资源,如果多个处理器同时试图对其进行操作,将会引起混乱。有鉴于此,DMA 控制器被一个叫做 dma_spin_lock 的自旋锁所保护。然而,驱动程序不应直接对该锁进行操作,内核提供了两个对其操作的函数:
unsigned long claim_dma_lock();
获取 DMA 自旋锁,该函数会阻塞本地处理器上的中断,因此,其返回值是"标志"值,在重新打开中断时必须使用该值。
void release_dma_lock(unsigned long flags);
释放 DMA 自旋锁,并且恢复以前的中断状态。
在使用接下来描述的那些函数时,应该持有自旋锁。然而,在驱动程序做真正的 I/O 操作时,不应该持有自旋锁。驱动程序在持有自旋锁时绝对不能进入睡眠。
必须装载到 DMA 控制器的信息由三部分组成:RAM 地址、必须传输的原子项个数(以字节或字为单位),以及传输的方向。最后,头文件 <asm/dma.h> 导出了下面几个函数:
void set_dma_mode(unsigned int channel, char mode);
指出通道要从设备读出(DMA_MODE_WRITE)数据,还是向设备写入数据。还存在有第三种模式,即 DMA_MODE_CASCADE,用于释放对总线的控制。级联就是将第一个控制器连接到第二个控制器的顶端的方式,但是也能够被真正的 ISA 总线控制设备使用。这里,我们不讨论总线控制。
void set_dma_addr(unsigned int channel, unsigned int addr);
该函数给 DMA 缓冲区的地址赋值。该函数将 addr 的最低 24 位存储到控制器中。参数 addr 必须是总线地址(参照本章前面"总线地址"小节)。
void set_dma_count(unsigned int channel, unsigned int count);
该函数对传输的字节数赋值。参数 count 也代表 16 位通道的字节数,在此情况下,这个数字必须是偶数。
除这些函数之外,在处理 DMA 设备时,还必须使用很多处理杂项的工具函数:
void disable_dma(unsigned int channel);
DMA 通道可以在控制器内被禁止掉。通道应该在配置 DMA 控制器之前被禁止,以防止进行不正确的操作(控制器是通过 8 位数据传送进行编程的,这样,前面所有的函数都不会原子地执行)。
void enable_dma(unsigned int channel);
该函数通知 DMA 控制器 DMA 通道中包含了合法的数据。
int get_dma_residue(unsigned int channel);
有时,驱动程序需要知道一个 DMA 传输是否已经完成。该函数返回尚未传送的字节数。函数在传输成功时的返回值是0,当控制器正在工作时的返回值是不可预知的(但不是0)。返回值的不可预测表明这样一个事实,即剩余量是一个 16 位数,它是通过两个 8 位输入操作获取的。
void clear_dma_ff(unsigned int channel)
该函数清除 DMA 触发器(flip-flop),该触发器用来控制对 16 位寄存器的访问。可以通过两个连续的 8 位操作来访问这些寄存器,触发器被清除时用来选择低字节,触发器被置位时用来选择高字节。在传输 8 位后,触发器会自动反转;在访问 DMA 寄存器之前,程序员必须清除触发器(将它设置为某个已知状态)。
使用这些函数,驱动程序可以实现一个类似下面代码所示的函数为 DMA 传输作准备:
|
然后,类似下面的函数会被用来检查 DMA 传输是否成功结束。
|
剩下的唯一要做的事情就是配置设备板卡了。这种设备特有的任务通常仅仅是对少数 I/O 端口的读写操作,但不同的设备之间存在很大的差别。例如,某些设备需要程序员通知硬件 DMA 的缓冲区有多大,而有时,驱动程序不得不读出固化在设备里的值。配置板卡时,硬件手册是我们唯一的朋友。
13.5 向后兼容性
随着时间的推移,象内核中的其它部分一样,内存映射和 DMA 也发生了很多改变。本节将描述一些驱动程序作者在编写可移植代码时需要注意的事项。
13.5.1 内存管理部分的改变
2.3 开发系列的主要变化发生在内存管理部分。2.2 内核很大程度上受限于它能使用的内存数量,尤其在 32 位处理器上这种情况尤为突出。对于 2.4 版本,这种限制被减轻了;现在,Linux 能够管理处理器所能够寻址的所有内存,而且某些事情不得不为此改变以使其实现这种能力;但是,API 层的改动比例是很小的。
正如我们所看到的,2.4 版内核广泛使用了指向 page 结构的指针来在内存中查阅特定的页面。这个结构已经存在于Linux中很长时间了,但是先前并没有用这个结构来指代页面本身,相反,内核使用的是逻辑地址。
例如,pte_page 返回一个 unsigned long 值而不是 struct page *。宏 virt_to_page 根本就不存在了,如果需要找到一个 struct page 项,就不得不直接从内存映射中查找。宏 MAP_NR 会将逻辑地址变成 mem_map 中的索引;这样,当前的 virt_to_page 宏可以被如下定义(在示例代码的 sysdep.h 头文件中):
|
在 virt_to_page 被引入时,宏 MAP_NR 就没有用了。宏 get_page 在 2.4 内核之前也不存在,所以 sysdep.h 如下定义它:
|
struct page 也已经随时间而改变,特别,virtual 成员仅出现在 2.4 内核的 Linux中。
page_table_lock 在 2.3.10 版本中引入。先前的代码在穿越页表之前应该获得"大的内核锁"(在遍历页表前后调用 lock_kernle 和 unlock_kernel)。
结构 vm_area_struct 在 2.3 开发系列中发生了很多变化,在2.1系列中变化更多。这些变化包括:
- 在 2.2 和之前的版本中,成员 vm_pgoff 叫做 vm_offset。它是字节的偏移量而不是页面的偏移量。
- 成员 vm_private_data 在 2.2 版本的 linux 中并不存在,所以驱动程序并没有在 VMA 中保存自身信息的方法。许多驱动程序使用 vm_pte 成员。但是,从 vm_file 中获得次设备号,并使用它来获取需要的信息,这种方法更安全些。
2.4 版本内核在调用 mmap 方法之前初始化 vm_file 指针。在 2.2 版本的内核中,驱动程序不得不自己赋值,并使用 file 结构作为参数传递进入。 - 在 2.0 版本的内核中,vm_file 指针更本不存在;替代地,有一个 vm_inode 指针指向 inode 结构。该成员需要由驱动程序赋值,它也必须在 mmap 方法中增加成员 inode->i_count 的值。
- 标志 VM_RESERVED 在内核版本 2.4.0-test10 中加入。
对于存储在 VMA 中的各个 vm_ops 方法来说,发生的改变如下:
- 2.2 和之前版本的内核有一个叫做 advise 的方法,但实际上内核从来没有使用它。还有一个 swapin 方法,它用来将数据从备用存储器上读入内存,通常,这对于驱动程序作者来说没有什么意义。
- 在 2.2 版本的内核中,nopage 和 wppage 方法返回 unsigned long,而不是 struct page *。
- NOPAGE_SIGBUS 和 NOPAGE_OOM 返回代码表示 nopage 并不存在。nopage 简单地返回 0 来指出问题并给受影响的进程发送一个总线信号。
因为 nopage 以前返回 unsigned long,它的工作是返回页的逻辑地址而不是它的 mem_map 项。
当然,在老内核中没有高端内存支持。所有内存都有逻辑地址,而且 kmap 和 kunmap 函数也不存在。
在 2.0 版本内核中,结构 init_mm 没有为模块导出。这样,想要访问 init_mm 的模块为了找到它不得不在任务表中搜索(作为"init"进程的一部分)。在 2.0 内核上运行时,scullp 通过下面这段代码找到 init_mm:
|
2.0 版本内核在逻辑地址和物理地址之间也缺少明显的区别,所以宏 _ _va 和 _ _pa 也不存在。那时也不需要它们。
在 2.0 版本的内核中不存在的另一个处理就是,在映射内存区域中并没有维护模块的使用计数。在低于 2.0 版本的内核中,实现了 mmap 的驱动程序需要提供 open 和 close VMA 操作并调整使用计数。实现了 mmap 的示例模块提供了这些操作。
最后,就像大多数其它的方法,驱动程序的 mmap 方法的 2.0 版本有一个 struct inode 参数,方法的原型是:
|
13.5.2 DMA 的变化
正如先前描述的,PCI 的 DMA 接口在 2.3.41 版本之前并不存在。然而之前,DMA 以更直接的(而且依赖于系统的)方式处理。缓冲区通过调用 virt_to_bus 来映射,而且没有通用接口来处理总线映射寄存器。
对于那些想要写可移植的 PCI 驱动程序的人来说,样例代码中文件 sysdep.h 包括有 2.4 版本的 DMA 接口的一个简单实现,该实现可用于老的内核。
另一方面,ISA 接口自从 2.0 版本的 Linux 以来几乎没有改变过。ISA 是一种老的体系结构,毕竟,有许许多多的变化没有跟上。在 2.2 版本中唯一增加的是 DMA 自旋锁,在该内核之前,不需要对 DMA 控制器的冲突访问进行保护。这些函数的版本已经在文件 sysdep.h 中定义,它们禁止和恢复中断,而不执行其他功能。
13.6 快速参考
本章介绍了与内存处理有关的下列符号。因为第一节本身是一个大的列表并且它们的符号对于设备驱动程序来说很少使用,所以在第一节中介绍的符号没有在下面列出。
#include <linux/mm.h>
所有与内存管理有关的函数和结构在这个头文件中定义并给出原型。
int remap_page_range(unsigned long virt_add, unsigned long phys_add, unsigned long size, pgprot_t prot);
这些函数是 mmap 的核心。它将大小为 size 字节、起始地址为 phys_addr 的物理内存映射到虚拟地址 virt_add。与虚拟空间相联系的保护位在 prot 中指定。
struct page *virt_to_page(void *kaddr);
void *page_address(struct page *page);
这些宏在内核逻辑地址以及与它们相关联的内存映射入口之间进行转换。page_address 只能处理低端内存页或者已经被显式映射的高端内存页。
void *_ _va(unsigned long physaddr);
unsigned long _ _pa(void *kaddr);
这些宏在内核逻辑地址和物理地址之间进行转换。
unsigned long kmap(struct page *page);
void kunmap(struct page *page);
kmap 返回一个被映射到给定页的内核虚拟地址,如果需要的话,建立该映射。kunmap 删除给定页的映射。
#include <linux/iobuf.h>
void kiobuf_init(struct kiobuf *iobuf);
int alloc_kiovec(int number, struct kiobuf **iobuf);
void free_kiovec(int number, struct kiobuf **iobuf);
这些函数处理内核 I/O 缓冲区的分配、初始化和释放。kiobuf_init 初始化单个 kiobuf,但是很少使用。替代地,可使用 alloc_kiovec 来分配并初始化一个 kiobuf 向量,并使用 free_kiovec 释放 kiobuf 向量。
int lock_kiovec(int nr, struct kiobuf *iovec[], int wait);
int unlock_kiovec(int nr, struct kiobuf *iovec[]);
这两个函数分别锁住并释放内存中的 kiovec。在使用kiobuf进行用户空间内存的 I/O 时,不必使用这两个函数。
int map_user_kiobuf(int rw, struct kiobuf *iobuf, unsigned long address, size_t len);
void unmap_kiobuf(struct kiobuf *iobuf);
map_user_kiobuf 将一个用户空间的缓冲区映射成给定的内核 I/O 缓冲区,unmap_kiobuf 撤销该映射。
#include <asm/io.h>
unsigned long virt_to_bus(volatile void * address);
void * bus_to_virt(unsigned long address);
这些函数完成内核虚拟地址和总线地址之间的转换。必须使用总线地址来和外围设备对话。
#include <linux/pci.h>
使用下面这些函数时,必须包含该头文件。
int pci_dma_supported(struct pci_dev *pdev, dma_addr_t mask);
对于那些不能对全部的 32 位地址空间寻址的外围设备来说,这个函数决定了主机系统上是否支持 DMA。
void *pci_alloc_consistent(struct pci_dev *pdev, size_t size, dma_addr_t *bus_addr)
void pci_free_consistent(struct pci_dev *pdev, size_t size, void *cpuaddr, dma_handle_t bus_addr);
这些函数为驱动程序生命期中一直有效的缓冲区分配和释放一致 DMA 映射。
PCI_DMA_TODEVICE
PCI_DMA_FROMDEVICE
PCI_DMA_BIDIRECTIONAL
PCI_DMA_NONE
这些符号用来告诉流式映射函数数据从缓冲区中移入或者移出的方向。
dma_addr_t pci_map_single(struct pci_dev *pdev, void *buffer, size_t size, int direction);
void pci_unmap_single(struct pci_dev *pdev, dma_addr_t bus_addr, size_t size, int direction);
建立和销毁一个独家使用的流式 DMA 映射。
void pci_sync_single(struct pci_dev *pdev, dma_handle_t bus_addr, size_t size, int direction)
同步一个具有流式映射的缓冲区。如果处理器必须访问一个正在使用流式映射的缓冲区(亦即,设备拥有该缓冲区),则必须使用这个函数。
struct scatterlist { /* ... */ };
dma_addr_t sg_dma_address(struct scatterlist *sg);
unsigned int sg_dma_len(struct scatterlist *sg);
scatterlist 结构描述了调用多于一个缓冲区的 I/O 操作。在实现分散/集中操作时,宏 sg_dma_address 和 sg_dma_len 可以用来获得总线地址和缓冲区长度,并传递给驱动程序。
pci_map_sg(struct pci_dev *pdev, struct scatterlist *list, int nents, int direction);
pci_unmap_sg(struct pci_dev *pdev, struct scatterlist *list, int nents, int direction);
pci_dma_sync_sg(struct pci_dev *pdev, struct scatterlist *sg, int nents, int direction)
pci_map_sg 映射一个分散/集中操作,并且由 pci_unmap_sg 来取消映射。如果需要在映射有效时访问缓冲区,pci_map_sync_sg 可用来同步上述操作。
/proc/dma
这个文件包含了 DMA 控制器中已分配通道的文本快照。由于每个 PCI 板卡独立工作并且不需要在 DMA 控制器中分配通道,所以基于 PCI 的 DMA 不会显示在这个文件中。
#include <asm/dma.h>
所有关于 DMA 的函数和宏,都在这个头文件定义或给出了原型。如果要使用下述符号,就必须包含这个头文件。
int request_dma(unsigned int channel, const char *name);
void free_dma(unsigned int channel);
这些函数访问 DMA 注册表。注册必须在使用 ISA DMA 通道之前执行。
unsigned long claim_dma_lock();
void release_dma_lock(unsigned long flags);
这些函数获得和释放 DMA 自旋锁。在调用其他的 ISA DMA 函数(后面描述)期间必须持有该自旋锁。它们同时在本地处理器上禁止并重新打开中断。
void set_dma_mode(unsigned int channel, char mode);
void set_dma_addr(unsigned int channel, unsigned int addr);
void set_dma_count(unsigned int channel, unsigned int count);
这些函数用来在 DMA 控制器中设置 DMA信息。addr是总线地址。
void disable_dma(unsigned int channel);
void enable_dma(unsigned int channel);
在配置期间,DMA 通道必须被禁止。这些函数用来改变 DMA 通道的状态。
int get_dma_residue(unsigned int channel);
如果驱动程序需要了解DMA传输进行的当前状态,则可以调用这个函数。该函数返回尚未传输的数据的数量。在 DMA 成功完成之后,函数返回 0;在数据传输正在进行时,这个值是不可预知的。
void clear_dma_ff(unsigned int channel)
控制器用 DMA 触发器来传输 16 位值,这可以由两个 8 位的操作来进行。在给控制器发送任何数据之前必须将该触发器清零