系列文章目录
Linux 内核设计与实现
深入理解 Linux 内核
Linux 设备驱动程序(一)
Linux 设备驱动程序(二)
Linux 设备驱动程序(三)
Linux 设备驱动程序(四)
Linux设备驱动开发详解
深入理解Linux虚拟内存管理
第十五章 内存映射和DMA
1、 Linux的内存管理
(1)地址类型
-
用户虚拟地址
这是在用户空间程序所能看到的常规地址。用户地址或者是 32 位的,或者是 64 位的,这取决于硬件的体系架构。每个进程都有自己的虚拟地址空间。 -
物理地址
该地址在处理器和系统内存之间使用。物理地址也是 32 位或者 64 位长的,在某些情况下甚至 32 位系统也能使用 64 位的物理内存。 -
总线地址
该地址在外围总线和内存之间使用。通常它们与处理器使用的物理地址相同,但这么做并不是必需的。一些计算机体系架构提供了 I/O 内存管理单元(IOMMU),它实现总线和主内存之间的重新映射。IOMMU 可以用很多种方式让事情变得简单(比如使内存中的分散缓冲区对设备来说是连续的),但是当设置 DMA 操作时,编写 IOMMU 相关的代码是一个必需的额外步骤。当然总线地址是与体系架构密切相关的。 -
内核逻辑地址
内核逻辑地址组成了内核的常规地址空间。该地址映射了部分(或者全部)内存,并经常被视为物理地址。在大多数体系架构中,逻辑地址和与其相关联的物理地址的不同,仅仅是在它们之间存在一个固定的偏多量。逻辑地址使用硬件内建的指针大小,因此在安装了大量内存的 32 位系统中,它无法寻址全部的物理地址。逻辑地址通常保存在 unsigned long 或者 void * 这样类型的变量中。kmalloc 返回的内存就是内核逻辑地址。 -
内核虚拟地址
内核虚拟地址和逻辑地址的相同之处在于,它们都将内核空间的地址映射到物理地址上。内核虚拟地址与物理地址的映射不必是线性的和一对一的,而这是逻辑地址空间的特点。所有的逻辑地址都是内核虚拟地址,但是许多内核虚拟地址不是逻辑地址。举个例子,vmalloc 分配的内存具有一个虚拟地址(但并不存在直接的物理映射)。kmap 函数(在本章后面论述)也返回一个虚拟地址。虚拟地址通常保存在指针变量中。
如果有一个逻辑地址,宏 __pa()(在 <asm/page.h> 中定义)返回其对应的物理地址;使用宏 __va() 也能将物理地址逆向映射到逻辑地址,但这只对低端内存页有效。
不同的内核函数需要不同类型的地址。如果在 C 中已经定义好了不同的类型,那么需要的地址类型将很明确,但现实不是这样的。在本章中,将明确表述在何处使用何种类型的地址。
(2)物理地址和页
物理地址被分成离散的单元,称之为页。系统内部许多对内存的操作都是基于单个页的。每个页的大小随体系架构的不同而不同,但是目前大多数系统都使用每页 4096 个字节。常量 PAGE_SIZE(在 <asm/page.h> 中定义)给出了在任何指定体系架构下的页大小。
仔细观察内存地址,无论是虚拟的还是物理的,它们都被分为页号和一个页内的偏移量。举个例子,如果使用每页 4096 个字节,那么最后的 12 位是偏移量,而剩余的高位则指定了页号。如果忽略了地址偏移量,并将除去偏移量的剩余位移到右端,称该结果为页帧数。移动位以在页帧数和地址间进行转换是一个常用操作;宏 PAGE_SHIFT 将告诉程序员,必须移动多少位才能完成这个转换。
(3)高端与低端内存
在装有大量内存的 32 位系统中,逻辑和内核虚拟地址的不同将非常突出。使用 32 位只能在 4GB 的内存中寻址。由于这种建立虚拟地址空间的问题,直到最近,32 位系统的 Linux 仍被限制使用少于 4GB 的内存。
内核(在 x86 体系架构中,这是默认的设置)将 4GB 的虚拟地址空间分割为用户空间和内核空间;在二者的上下文中使用同样的映射。一个典型的分割是将 3GB 分配给用户空间,1GB 分配给内核空间(注 1)。内核代码和数据结构必须与这样的空间相匹配,但是占用内核地址空间最大的部分是物理内存的虚拟映射。内核无法直接操作没有映射到内核地址空间的内存。换句话说,内核对任何内存的访问,都需要使用自己的虚拟地址。因此许多年来,由内核所能处理的最大物理内存数量,就是将映射至虚拟地址空间内核部分的大小,再减去内核代码自身所占用的空间。因此,基于 x86 的 Linux 系统所能使用的最大物理内存,会比 1GB 小一点。
注 1: 许多非x86 的体系架构不需需要这里描述的内核 / 用户空间分割即可有效工作,因此,这些体系架构在 32 位系统上就能获得 4GB 的内核地址空间。但是,这一小节描述的限制对安装有多于 4GB 内存的系统仍然适用。
为了应对商业压力,在不破坏 32 位应用程序和系统兼容性的情况下,为了能使用更多的内存,处理器制造厂家为他们的产品增添了 “地址扩展” 特性。其结果是在许多情况下,即使 32 位的处理器都可以在大于 4GB 的物理地址空间寻址。然而有多少内存可以直接映射到逻辑地址的限制依然存在。只有内存的低端部分(依赖与硬件和内核的设置,一般为 1 到 2GB)拥有逻辑地址(注 2);剩余的部分(高端内存)是没有的。在访问特定的高端内存页前,内核必须建立明确的虚拟映射,使该页可在内核地址空间中被访问。因此,许多内核数据结构必须被放置在低端内存中;而高端内存更趋向于为用户空间进程页所保留。
注 2: 2.6 内核通过一个补丁可在 x86 硬件上持 “4G/4G” 模式,它可以让内核和用户虚拟地址空间变大,但会引入微小的性能代价。
术语 “高端内存” 可能对一些人来说理解起来比较困难、特别是在 PC 世界中,它还有着其他的含义。因此为了弄清这个问题,这里先对它进行定义:
- 低端内存
存在于内核空间上的逻辑地址内存。几乎所有现在读者遇到的系统,它全部的内存都是低端内存。 - 高端内存
是指那些不存在逻辑地址的内存,这是因为它们处于内核虚拟地址之上。
在 i386 系统中,虽然在内核配置的时候能够改变低端内存和高端内存的界限,但是通常将该界限设置为小于 1GB。这个界限与早期 PC 中的 640K 限制没有任何关系,并且它的设置也与硬件无关。相反它是由内核设置的,把 32 位地址空间分割成内核空间与用户空间。
在后面的部分中,将指出使用高端内存的限制。
(4)内存映射和页结构
由于历史的关系,内核使用逻辑地址来引用物理内存中的页。然而由于支持了高端内存,就暴露出一个明显的问题 —— 在高端内存中将无法使用逻辑地址。因此内核中处理内存的函数趋向使用指向 page 结构的指针(在 <linux/mm.h> 中定义)。该数据结构用来保存内核需要知道的所有物理内存信息;对系统中每个物理页,都有一个 page 结构相对应。下面介绍该结构中包含的几个成员:
// include/linux/mm_types.h
struct page {
unsigned long flags; /* Atomic flags, some possibly
* updated asynchronously */
atomic_t _count; /* Usage count, see below. */
union {
atomic_t _mapcount; /* Count of ptes mapped in mms,
* to show when page is mapped
* & limit reverse map searches.
*/
struct { /* SLUB */
u16 inuse;
u16 objects;
};
};
union {
struct {
unsigned long private; /* Mapping-private opaque data:
* usually used for buffer_heads
* if PagePrivate set; used for
* swp_entry_t if PageSwapCache;
* indicates order in the buddy
* system if PG_buddy is set.
*/
struct address_space *mapping; /* If low bit clear, points to
* inode address_space, or NULL.
* If page mapped as anonymous
* memory, low bit is set, and
* it points to anon_vma object:
* see PAGE_MAPPING_ANON below.
*/
};
#if USE_SPLIT_PTLOCKS
spinlock_t ptl;
#endif
struct kmem_cache *slab; /* SLUB: Pointer to slab */
struct page *first_page; /* Compound tail pages */
};
union {
pgoff_t index; /* Our offset within mapping. */
void *freelist; /* SLUB: freelist req. slab lock */
};
struct list_head lru; /* Pageout list, eg. active_list
* protected by zone->lru_lock !
*/
/*
* On machines where all RAM is mapped into kernel address space,
* we can simply calculate the virtual address. On machines with
* highmem some memory is mapped into kernel virtual memory
* dynamically, so we need a place to store that address.
* Note that this field could be 16 bits on x86 ... ;)
*
* Architectures with slow multiplication can define
* WANT_PAGE_VIRTUAL in asm/page.h
*/
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
#ifdef CONFIG_WANT_PAGE_DEBUG_FLAGS
unsigned long debug_flags; /* Use atomic bitops on this */
#endif
#ifdef CONFIG_KMEMCHECK
/*
* kmemcheck wants to track the status of each byte in a page; this
* is a pointer to such a status block. NULL if not tracked.
*/
void *shadow;
#endif
};
- atomic_t count;
对该页的访问计数。当计数值为 0 时,该页将返回给空闲链表。 - void *virtual;
如果页面被映射,则指向页的内核虚拟地址;如果未被映射则为 NULL。低端内存页总是被映射;而高端内存页通常不被映射。并不是在所有体系架构中都有该成员;只有在页的内核虚拟地址不容易被计算时,它才被编译。如果要访问该成员,正确的方法是使用下面讲述的 page_address 宏。 - unsigned long flags;
描述页状态的一系列标志。其中,PG_locked 表示内存中的页已经被锁住,而 PG_reserved 表示禁止内存管理系统访问该页。
在 page 结构中还包含了许多信息,但这是深层次内存管理所关心的问题,而驱动程序作者不必要了解。
内核维护了一个或者多个 page 结构数组,用来跟踪系统中的物理内存。在一些系统中,有一个单独的数组称之为 mem_map。在另外一些系统中,情况将会复杂很多。非一致性内存访问(Nonuniform Memory Access,NUMA)系统和有大量不连续物理内存的系统会有多个内存映射数组,因此从可移植性考虑,代码不要直接访问那些数组。幸运的是,通常只需要使用 page 结构的指针,而不需要了解它们是怎么来的。
有一些函数和宏用来在 page 结构指针与虚拟地址之间进行转换:
// arch/x86/include/asm/page.h
struct page *virt_to_page(void *kaddr);
#define virt_to_page(kaddr) pfn_to_page(__pa(kaddr) >> PAGE_SHIFT)
#define __pa(x) __phys_addr((unsigned long)(x))
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
// arch/x86/include/asm/page_types.h
#define PAGE_SHIFT 12
该宏在 <asm/page.h> 中定义,负责将内核逻辑地址转换为相应的 page 结构指针。由于它需要一个逻辑地址,因此它不能操作 vmalloc 生成的地址以及高端内存。
// include/asm-generic/memory_model.h
struct page *pfn_to_page(int pfn);
#define __pfn_to_page(pfn) (vmemmap + (pfn))
#define __page_to_pfn(page) (unsigned long)((page) - vmemmap)
// arch/x86/include/asm/page_64_types.h
#define vmemmap ((struct page *)VMEMMAP_START)
// arch/x86/include/asm/pgtable_64_types.h
#define VMEMMAP_START _AC(0xffffea0000000000, UL)
// include/linux/const.h
#define __AC(X,Y) (X##Y)
#define _AC(X,Y) __AC(X,Y)
针对给定的页帧号,返回 page 结构指针。如果需要的话,在将页帧号传递给 pfn_to_page 前,使用 pfn_valid 检查页帧号的合法性。
void *page_address(struct page *page);
// include/linux/mm.h
#define page_address(page) lowmem_page_address(page)
static __always_inline void *lowmem_page_address(struct page *page)
{
return __va(page_to_pfn(page) << PAGE_SHIFT);
}
如果地址存在的话,则返回页的内核虚拟地址。对于高端内存来说,只有当内存页被映射后该地址才存在。该函数定义在 <linux/mm.h> 中。在大多数情况下,要使用 kmap 而不是 page_address。
#include <linux/highmem.h>
void *kmap(struct page *page);
void kunmap(struct page *page);
kmap 为系统中的页返回内核虚拟地址。对于低端内存页来说,它只返回页的逻辑地址;对于高端内存,kmap 在专用的内核地址空间创建特殊的映射。由 kmap 创建的映射需要用 kunmap 释放;对该种映射的数量是有限的,因此不要持有映射过长的时间。kmap 调用维护了一个计数器,因此如果两个或是多个函数对同一页调用 kmap,操作也是正常的。请注意当没有映射的时候,kmap 将会休眠。
#include <linux/highmem.h>
#include <asm/kmap_types.h>
void *kmap_atomic(struct page *page, enum km_type type);
void kunmap_atomic(void *addr, enum km_type type);
kmap_atomic 是 kmap 的高性能版本。每个体系架构都为原子的 kmap 维护着一个槽(专用页表入口)的列表;kmap_atomic 的调用者必须告诉系统,type 参数使用的是哪个槽。对驱动程序有意义的槽只有 KM_USER0 和 KM_USER1(针对在用户空间中直接运行的代码),KM_IRQ0 和 KM_IRQ1(针对中断处理程序)。要注意的是原子的 kmap 必须原子地处理,也就是说,在拥有它的时候,代码不能进入睡眠状态。还要注意的是在内核中,没有任何机制能防止这两个函数使用相同的槽,以及防止它们之间的相互干涉(虽然对每个 CPU 都有一套特定的槽)。在实际情况中,对原子的 kmap 槽的争夺并不会引起什么问题。
在研究例子代码时,以及在本章及后面的章节中,读者会看到如何使用这些面数。
(5)页表
在任何现代的系统中,处理器必须使用某种机制、将虚拟地址转换为相应的物理地址。这种机制被称为页表;它基本上是一个多层树形结构,结构化的数组中包含了虚拟地址到物理地址的映射和相关的标志位。即使在不直接使用这种页表的体系架构中,Linux 内核也维护了一系列的页表。
设备驱动程序执行了大量操作,用来处理页表。幸运的是,对驱动程序作者来说,在 2.6 版内核中删除了对页表直接操作的需求。因此这里不对它们做详细讲解;富有好奇心的读者可以阅读 Daniel P. Bovet 和 Marco Cesati 编写的《Understanding The Linux Kernel》(O’Reilly)一书了解详细情况。
(6)虚拟内存区
虚拟内存区(VMA)用于管理进程地址空间中不同区域的内核数据结构。一个 VMA 表示在进程的虚拟内存中的一个同类区域: 拥有同样权限标志位和被同样对象(一个文件或者交换空间)备份的一个连续的虚拟内存地址范围。它符合更宽泛的 “段” 的概念,但是把其描述成 “拥有自身属性的内存对象” 更为贴切。进程的内存映射(至少)包含下面这些区域:
- 程序的可执行代码(通常称为 text)区域。
- 多个数据区,其中包含初始化数据(在开始执行的时候就拥有明确的值)、非初始化数据(BSS,注 3)以及程序堆栈。
- 与每个活动的内存映射对应的区域。
注 3: BSS 这一名称是一个历史遗物,来自一条老的汇编操作称为 “block started by symbol(符号定义的块)”。可执行文件的 BSS 段并不会存储在磁盘上,而是由内核将零页映射到 BSS 地址范围。
查看 /proc/<pid/maps>(其中的 pid 要替换为具体的进程 ID)文件就能了解进程的内存区域。/proc/self 是一个特殊的文件,因为它始终指向当前进程。下面是多个内存映射的例子(注释以斜体的方式给出):
每行都是用下面的形式表示的:
start-end perm offset major:minor inode image
在 /proc/*/maps 中的每个成员(除映像名外)都与 vm_area_struct 结构中的一个成员相对应:
- start
- end
该内存区域的起始处和结束处的虚拟地址。 - perm
内存区域的读、写和执行权限的位掩码。该成员描述了允许什么样的进程能访问属于该区域的页。该成员的最后一个字母或者是 p 表示私有,或者是 s 表示共享。 - offset
表示内存区域在映射文件中的起始位置。偏移量为 0 表示内存区域的起始位置映射到文件的开始位置。 - major
- minor
拥有映射文件的设备的主设备号和次设备号。对于设备映射来说,主设备号和次设备号指的是包含设备特殊文件的磁盘分区,该文件由用户而非设备自身打开。 - inode
被映射的文件的索引节点号。 - image
被映射文件(通常是一个可执行映像)的名称。
(a)vm_area_struct 结构
当用户空间进程调用 mmap、将设备内存映射到它的地址空间时,系统通过创建一个表示该映射的新 VMA 作为响应。支持 mmap 的驱动程序(当然要实现 mmap 方法)需要帮助进程完成 VMA 的初始化。因此驱动程序作者为了能支持 mmap,需要对 VMA 有所了解。
现在来学习 vm_area_struct 结构(在 <linux/mm.h> 中定义)中最重要的成员。在设备驱动程序对 mmap 的实现中会使用到这些成员。请注意,为优化查找方法,内核维护了 VMA 的链表和树型结构,而 vm_area_struct 中的许多成员都是用来维护这个结构的。因此驱动程序不能任意创建 VMA,或者打破这种组织结构。VMA 的主要成员如下所示(请注意这些成员和刚才看到的 /proc 文件输出之间的区别):
// include/linux/mm_types.h
struct vm_area_struct {
struct mm_struct * vm_mm; /* The address space we belong to. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, see mm.h. */
struct rb_node vm_rb;
/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap prio tree, or
* linkage to the list of like vmas hanging off its node, or
* linkage of vma in the address_space->i_mmap_nonlinear list.
*/
union {
struct {
struct list_head list;
void *parent; /* aligns with prio_tree_node parent */
struct vm_area_struct *head;
} vm_set;
struct raw_prio_tree_node prio_tree_node;
} shared;
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_chain; /* Serialized by mmap_sem &
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
unsigned long vm_truncate_count;/* truncate_count or restart_addr */
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
};
- unsigned long vm_start;
- unsigned long vm_end;
该 VMA 所覆盖的虚拟地址范围。这是 /proc/*/maps 中最前面的两个成员。 - struct file *vm_file;
指向与该区域(如果存在的话)相关联的 file 结构指针。 - unsigned long vm_pgoff;
以页为单位,文件中该区域的偏移量。当映射一个文件或者设备时,它是该区域中被映射的第一页在文件中的位置。 - unsigned long vm_flags;
描述该区域的一套标志。驱动程序最感兴趣的标志是 VM_IO 和 VM_RESERVED。 VM_IO 将 VMA 设置成一个内存映射 I/O 区域。VM_IO 会阻止系统将该区域包含在进程的核心转储中。VM_RESERVED 告诉内存管理系统不要将该 VMA 交换出去;大多数设备映射中都设置该标志。 - struct vm_operations_struct *vm_ops;
内核能调用的一套函数,用来对该内存区进行操作。它的存在表示内存区域是一个内核 “对象” ,这点和在本书中使用的 file 结构很相似。 - void *vm_private_data;
驱动程序用来保存自身信息的成员。
与 vm_area_struct 结构类似,vm_operations_struct 结构也定义在 <linux/mm.h> 中,其中包含了下面列出的函数。这些操作只是用来处理进程的内存需求,并按照声明的顺序将它们列了出来。在本章后面的部分,将介绍如何实现其中的几个函数。
// include/linux/mm.h
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
/* notification that a previously read-only page is about to become
* writable, if an error is returned it will cause a SIGBUS */
int (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
/* called by access_process_vm when get_user_pages() fails, typically
* for use by special VMAs that can switch between memory and hardware
*/
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);
#ifdef CONFIG_NUMA
/*
* set_policy() op must add a reference to any non-NULL @new mempolicy
* to hold the policy upon return. Caller should pass NULL @new to
* remove a policy and fall back to surrounding context--i.e. do not
* install a MPOL_DEFAULT policy, nor the task or system default
* mempolicy.
*/
int (*set_policy)(struct vm_area_struct *vma, struct mempolicy *new);
/*
* get_policy() op must add reference [mpol_get()] to any policy at
* (vma,addr) marked as MPOL_SHARED. The shared policy infrastructure
* in mm/mempolicy.c will do this automatically.
* get_policy() must NOT add a ref if the policy at (vma,addr) is not
* marked as MPOL_SHARED. vma policies are protected by the mmap_sem.
* If no [shared/vma] mempolicy exists at the addr, get_policy() op
* must return NULL--i.e., do not "fallback" to task or system default
* policy.
*/
struct mempolicy *(*get_policy)(struct vm_area_struct *vma,
unsigned long addr);
int (*migrate)(struct vm_area_struct *vma, const nodemask_t *from,
const nodemask_t *to, unsigned long flags);
#endif
};
void (*open)(struct vm_area_struct *vma);
内核调用 open 函数,以允许实现 VMA 的子系统初始化该区域。当对 VMA 产生一个新的引用时(比如 fork 进程时),则调用这个函数。唯一的例外发生在 mmap 第一次创建 VMA 时;在这种情况下,需要调用驱动程序的 mmap 方法。
void (*close)(struct vm_area_struct *vma);
当销毁一个区域时,内核将调用 close 操作。请注意由于 VMA 没有使用相应的计数,所以每个使用区域的进程都只能打开和关闭它一次。
struct page *(*nopage) (struct vm_area_struct *vma, unsigned long address, int *type);
当一个进程要访问属于合法 VMA 的页,但该页又不在内存中时,则为相关区域调用 nopage 函数(如果定义了的话)。在将物理页从辅助存储器中读入后,该函数返回指向物理页的 page 结构指针。如果在该区域没有定义 nopage 函数,则内核将为其分配一个空页。
int (*populate)(struct vm_area_struct *vm, unsigned long address, unsigned long len,
pgprot_t prot, unsigned long pgoff, int nonblock);
在用户空间访问页前,该函数允许内核将这些页预先装入内存。一般来说,驱动程序不必实现 populate 方法。
(7)内存映射处理
最后一个内存管理难题是处理内存映射结构,它负责整合所有其他的数据结构。在系统中的每个进程(除了内核空间的一些辅助线程外)都拥有一个 struct mm_struct 结构(在 <linux/sched.h> 中定义),其中包含了虚拟内存区域链表、页表以及其他大量内存管理信息,还包含一个信号灯(mmap_sea)和一个自旋锁(page_table_lock)。在 task 结构中能找到该结构的指针;在少数情况下当驱动程序需要访问它时,常用的办法是使用 current->mm。请注意,多个进程可以共享内存管理结构,Linux 就是用这种方法实现线程的。
为了能对 Linux 内存管理数据结构有一个通盘的了解,现在首先看看 mmap 系统调用是如何实现的。
// include/linux/mm_types.h
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct rb_root mm_rb;
struct vm_area_struct * mmap_cache; /* last find_vma result */
#ifdef CONFIG_MMU
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
void (*unmap_area) (struct mm_struct *mm, unsigned long addr);
#endif
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
unsigned long cached_hole_size; /* if non-zero, the largest hole below free_area_cache */
unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */
pgd_t * pgd;
atomic_t mm_users; /* How many users with user space? */
atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */
int map_count; /* number of VMAs */
struct rw_semaphore mmap_sem;
spinlock_t page_table_lock; /* Protects page tables and some counters */
struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung
* together off init_mm.mmlist, and are protected
* by mmlist_lock
*/
unsigned long hiwater_rss; /* High-watermark of RSS usage */
unsigned long hiwater_vm; /* High-water virtual memory usage */
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
/*
* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
struct mm_rss_stat rss_stat;
struct linux_binfmt *binfmt;
cpumask_t cpu_vm_mask;
/* Architecture-specific MM context */
mm_context_t context;
/* Swap token stuff */
/*
* Last value of global fault stamp as seen by this process.
* In other words, this value gives an indication of how long
* it has been since this task got the token.
* Look at mm/thrash.c
*/
unsigned int faultstamp;
unsigned int token_priority;
unsigned int last_interval;
unsigned long flags; /* Must use atomic bitops to access the bits */
struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
spinlock_t ioctx_lock;
struct hlist_head ioctx_list;
#endif
#ifdef CONFIG_MM_OWNER
/*
* "owner" points to a task that is regarded as the canonical
* user/owner of this mm. All of the following must be true in
* order for it to be changed:
*
* current == mm->owner
* current->mm != mm
* new_owner->mm == mm
* new_owner->alloc_lock is held
*/
struct task_struct *owner;
#endif
#ifdef CONFIG_PROC_FS
/* store ref to file /proc/<pid>/exe symlink points to */
struct file *exe_file;
unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
struct mmu_notifier_mm *mmu_notifier_mm;
#endif
};
2、mmap 设备操作
在现代 Unix 系统中,内存映射是最吸引人的特征。对于驱动程序来说,内存映射可以提供给用户程序直接访问设备内存的能力。
使用 mmap 的一个例子是看一下 X Window 系统服务器的部分虚拟内存区域:
X 服务器完整的 VMA 的清单很长,但这里对其中的大部分内容都不感兴趣。可以看到,有四个独立的 /dev/mem 的映射,它为我们揭示了 X 服务器如何使用显示卡工作的内幕。第一个映射开始位置是 a0000,这是在 640KB ISA 结构中显示 RAM 的标准位置。往下可以看到更大的一块映射区域 e8000000,其地址位于系统最大 RAM 地址之上。这是对显示适配器中显存的直接映射。
在 /proc/iomem 中也可以看到:
映射一个设备意味着将用户空间的一段内存与设备内存关联起来。无论何时当程序在分配的地址范围内读写时,实际上访问的就是设备。在 X 服务器例子中,使用 mmap 就能迅速而便捷地访问显卡内存。对于那些与此类似、性能要求苛刻的应用程序,直接访问能显著提高性能。
正如读者怀疑的那样,不是所有的设备都能进行 mmap 抽象的。比如像串口和其他面向流的设备就不能做这样的抽象。对 mmap 的另外一个限制是:必须以 PAGE_SIZE 为单位进行映射。内核只能在页表一级上对虚拟地址进行管理,因此那些被映射的区域必须是 PAGE_SIZE 的整数倍,并且在物理内存中的起始地址也要求是 PAGE_SIZE 的整数倍。如果区域的大小不是页的整数倍,则内核强制指定比区域稍大一点的尺寸作为映射的粒度。
对于驱动程序来说这些限制并不是什么大问题,因为访问设备的程序都是与设备相关的。由于程序必须知道设备的工作过程,因此程序员不会被诸如页边界之类的需求所困扰。在某些非 x86 平台上工作的 ISA 设备面临更大的制约,因为它们的 ISA 硬件视图是不连续的。比如一些 Alpha 计算机视 ISA 内存为不可直接映射的 8 位、16 位或者 32 位的离散项的集合。在这种情况下,根本无法使用 mmap。无法将 ISA 地址直接映射到 Alpha 地址,是由于这两种系统间,存在着不兼容的数据传输规则。虽然早期的 Alpha 处理器只能解决 32 位和 64 位内存访问问题,但是对于 ISA 来说只能进行 8 位和 16 位的传输,没有办法透明地将一个协议映射到另外一个协议上。
当灵活使用 mmap 时,它具有很大的优势。比如在 X 服务器例子中,它负责和显存间读写大量数据;与使用 lseek/write 相比,将图形显示映射到用户空间极大地提高了吞吐量。另外一个典型例子是控制 PCI 设备的程序。大多数 PCI 外围设备将它们的控制寄存器映射到内存地址中、高性能的应用程序更愿意直接访问寄存器,而不是不停的调用 ioctl 去获得需要的信息。
mmap 方法是 file_operations 结构的一部分,并且执行 mmap 系统调用时将调用该方法。使用 mmap,内核在调用实际函数之前,就能完成大量的工作,因此该方法的原型与系统调用有着很大的不同。它也与诸如 ioctl 和 poll 不同,内核在调用那些函数前不用做什么工作。
系统调用有着以下声明(在 mmap(2) 手册页中描述):
mmap(caddr_t addr, size_t len, int prot, int flags, int fd, off_t offset)
但是文件操作声明如下:
int (*mmap) (struct file *filp, struct vm_area_struct *vma);
该函数中的 filp 参数与第三章中介绍的一样,vma 包含了用于访问设备的虚拟地址的信息。因此大量的工作由内核完成;为了执行 mmap,驱动程序只需要为该地址范围建立合适的页表,并将 vma->vm_ops 替换为一系列的新操作就可以了。
有两种建立页表的方法:使用 remap_pfn_range 函数一次全部建立,或者通过 nopage VMA 方法每次建立一个页表。这两种方法有它各自的优势和局限性。这里首先介绍一次全部建立的方法,因为它最简单。从这开始,将会为实际的实现方法逐渐增加其复杂性:
(1)使用 remap_pfn_range
remap_pfn_range 和 io_remap_page_range 负责为一段物理地址建立新的页表,它们有着如下的原型:
int remap_pfn_range(struct vm_area_struct *vma,
unsigned long virt_addr, unsigned long pfn,
unsigned long size, pgprot_t prot);
int io_remap_page_range(struct vm_area_struct *vma,
unsigned long virt_addr, unsigned long phys_addr,
unsigned long size, pgprot_t prot);
通常函数的返回值是 0,或者是个负的错误码。现在来看看各参数的含义:
- vma
虚拟内存区域,在一定范围内的页将被映射到该区域内。 - virt_addr
重新映射时的起始用户虚拟地址。该函为处于 virt_addr 和 virt_addr+size 之间的虚拟地址建立页表。 - pfn
与物理内存对应的页帧号,虚拟内存将要被映射到该物理内存上。页帧号只是将物理地址右移 PAGE_SHIFT 位。在多数情况下,VMA 结构中的 vm_pgoff 成员包含了用户需要的值。该函数对处于(pfn<<PAGE_SHIFT)到 (pfn<<PAGE_SHIFT)+ size 之间的物理地址有效。 - size
以字节为单位,被重新映射的区域大小。 - prot
新 VMA 要求的 “保护(protection)” 属性。驱动程序能够(也应该)使用 vma->vm_page_prot 中的值。
remap_pfn_range 函数的参数非常简单,当调用 mmap 函数的时候,它们中大部分的值在 VMA 中提供。也许读者会奇怪,为什么会有两个函数呢? 第一个函数(remap_pfn_range)是在 pfn 指向实际系统 RAM 的时候使用,而 io_remap_page_range 是在 phys_addr 指向 I/O 内存的时候使用。在实际应用中,除了 SPARC 外,对每个体系架构这两个函数是等价的,而在大多数情况下会使用 remap_pfn_range 函数。对于有可移植性要求的驱动程序,要使用与特定情形相符的 remap_pfn_range 变种。
另外复杂性也表现在缓存上: 对设备内存的引用通常不能被处理器所缓存。系统的 BIOS 会正确设置缓存,但是也可以通过 protection 成员禁止缓存特定的 VMA。不幸的是,在这个层面上的禁止缓存是与处理器高度相关的。好奇的读者可以参考 drivers/char/mem.c 中的 pgprot_noncached 函数以了解其中细节。本书不对这个主题进行讨论。
(2)一个简单的实现
如果驱动程序要将设备内存线性地映射到用户地址空间中,程序员基本上就只需要调用 remap_pfn_range 函数。下面的代码来自 drivers/char/mem.c,并且揭示了在一个被称为 simple(Simple Implementation Mapping Pages with Little Enthusiasm) 的典型模块中,该任务是如何被完成的:
static int simple_remap_mmap(struct file *filp, struct vm_area_struct *vma) {
if (remap_pfn_range(vma, vma->vm_start, vm->vm_pgoff,
vma->vm_end - vma->vm_start,
vma->vm_page_prot))
return -EAGAIN;
vma->vm_ops = &simple_remap_vm_ops;
simple_vma_open(vma);
return 0;
}
可见,重新映射内存就是调用 remap_pfn_range 函数创建所需的页表。
(3)为 VMA 添加操作
如上所述,vm_area_struct 结构包含了一系列针对 VMA 的操作。现在来看看如何简单实现这些函数。本节提供了针对 VMA 的 open 和 close 操作。当进程打开或者关闭 VMA 时,会调用这些操作;特别是当 fork 进程或者创建一个新的对 VMA 引用时,随时都会调用 open 函数。对 VMA 函数 open 和 close 的调用由内核处理,因此它们没有必要重复内核中的工作。它们存在的意义在于为驱动程序处理其他所需要的事情。
除此之外,一个诸如 simple 这样的简单驱动程序不需再做什么特别的事情了。这里创建了open 和 close 函数,它们负责向系统日志中输入信息,告诉系统它们被调用了。此外没有其他特殊用途了,不过它能告诉读者: 如何提供这些函数以及何时调用它们。
因此,代码中用调用 printk 的新操作,覆盖了默认的 vma->vm_ops:
void simple_vma_open(struct vm_area_struct *vma) {
printk(KERN_NOTICE "Simple VMA open, virt %1x, phys %1x\n",
vma->vm_start, vma->vm_pgoff << PAGE_SHIFT);
}
void simple_vma_close(struct vm_area_struct *vma) {
printk(KERN_NOTICE "Simple VMA close.\n");
}
static struct vm_operations_struct simple_remap_vm_ops =
.open = simple_vma_open,
.close = simple_vma_close,
};
为了使这些操作对特定的映射有效,需要在相关 VMA 的 vm_ops 成员中保存指向 simple_remap_vm_ops 的指针。这通常在 mmap 方法中完成。如果回过头去看 simple_remap_mmap 示例,能看到如下代码:
vma->vm_ops = &simple_remap_vm_ops;
simple_vma_open(vma);
请注意对 simple_vma_open 函数的显式调用。由于在原来的 mmap 中没有调用 open 函数,因此必须显式调用它才能使其正常运行。
(4)使用 nopage 映射内存
虽然 remap_page_range 在许多情况下工作良好,但并不能适应大多数的情况。有时驱动程序对 mmap 的实现必须具有更好的灵活性。在这种情形下,提倡使用 VMA 的 nopage 方法实现内存映射。
当应用程序要改变一个映射区域所绑定的地址时,会使用 mremap 系统调用,此时是使用 nopage 映射的最好的时机。当它发生时,内核并不直接告诉驱动动程序什么时候 mremap 改变了映射 VMA。如果 VMA 的尺寸变小了,内核将会刷新不必要的页,而不通知驱动程序。相反,如果 VMA 尺寸变大了,当调用 nopage 时为新页进行设置时,驱动程序最终会发现这个情况,因此没有必要做额外的通知工作。如果要支持 mremap 系统调用,就必须实现 nopage 函数。这里提供了设备中 nopage 的一个简单实现。
пopage 函数具有以下原型:
struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);
当用户要访问 VMA 中的页,而该页又不在内存中时,将调用相关的 nopage 函数。 address 参数包含了引起错误的虚拟地址,它已经被向下圆整到页的开始位置。nopage 函数必须定位并返回指向用户所需要页的 page 结构指针。该函数还调用 get_page 宏,用来增加返回的内存页的使用计数:
get_page(struct page *pageptr);
该步骤对于保证映射页引用计数的正确性是非常必要的。内核为每个内存页都维护了该计数;当计数值为 0 时,内核将把该页放到空闲列表中。当 VMA 解除映射时,内核为区域内的每个内存页减小使用计数。如果驱动程序向区域添加内存页时不增加使用计数,则使用的计数值永远为 0,这将破坏系统的完整性。
nopage 方法还能在 type 参数所指定的位置中保存错误的类型 —— 但是只有在 type 参数不为 NULL 的时候才行。在设备驱动程序中,type 的正确值应该总是 VM_FAULT_MINOR。
如果使用了 nopage,在调用 mmap 的时候,通常只需做一点点工作。示例代码如下:
static int simple_nopage_mmap(struct file *filp, struct vm_area_struct *vma) {
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
if (offset >= __pa(high_memory) || (filp->f_flags & O_SYNC))
vma->vm_flags |= VM_IO;
vma->vm_flags |= VM_RESERVED;
vma->vm_ops = &simple_nopage_vm_ops;
simple_vma_open(vma);
return 0;
}
mmap 函数的主要工作是将默认的 vm_ops 指针替换为自己的操作。然后 nopage 函数小心地每次 “重新映射” 一页,并且返回它的 page 结构指针。因为在这里实现了一个物理内存的窗口,重新映射的步骤非常简单: 只是为需要的地址定位并返回了 page 结构的指针。nopage 函数的例子程序如下:
struct page *simple_vma_nopage(struct vm_area_struct *vma,
unsigned long address, int *type) {
struct page *pageptr;
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
unsigned long physaddr = address - vma->vm_start + offset;
unsigned long pageframe = physaddidr >> PAGE_SHIFT;
if (!pfn_valid(pageframe))
return NOPAGE_SIGBUS;
pageptr = pfn_to_page(pageframe);
get_page(pageptr);
if (type)
*type = VM_FAULT_MINOR;
return pageptr;
}
再一次强调,这里只是简单映射了主内存,nopage 函数需要为失效地址查找正确的 page 结构,并且增加它的引用计数。因此所需要的步骤顺序是:首先计算物理地址,然后通过右移 PAGE_SHIFT 位,将它转换成页帧号。由于用户空间能为用户提供它所拥有的任何地址,因此必须保证所用的页帧号合法:pfn_valid 函数可以做这件事。如果地址超出了范围,将返回 NOPAGE_SIGBUS,这会导致向调用进程发送一个总线信号。否则 pfn_to_page 函数获得所需要的 page 结构指针,这时,我们可以增加它的引用计数(使用 get_page 函数)并将其返回。
通常 nopage 方法返回一个指向 page 结构的指针。如果出于某些原因,不能返回一个正常的页(比如请求的地址超过了设备的内存区域),将返回 NOPAGE_SIGBUS 表示错误;这就是上面代码所做的事。nopage 还能返回 NOPAGE_OOM,表示由于资源紧张而造成的错误。
请注意,这个实现对 ISA 内存区域工作正常,但是不能在 PCI 总线上工作。PCI 内存被映射到系统内存最高端之上,因此在系统内存映射中没有这些地址的入口。因为无法返回一个指向 page 结构的指针,所以 nopage 不能用于此种情形;在这种情况下,必须使
用 remap_page_range。
如果 nopage 函数是 NULL,则负责处理页错误的内核代码将把零内存页映射到失效虚拟地址上。零内存页是一个写拷贝内存页,读它时会返回 0,它被用于映射 BSS 段。任何一个引用零内存页的进程都会看到:一个充满了零的内存页。如果进程对内存页进行写操作,将最终修改私有拷贝。因此,如果一个进程调用 mremap 扩充一个映射区域,而驱动程序没有实现 nopage,则进程将最终得到一块全是零的内存,而不会产生段故障错误。
(5)重映射特定的 I/O 区域
这今为止,所有例子都是对 /dev/mem 的再次实现;它们把物理内存重新映射到用户空间中。一个典型的驱动程序只映射与其外围设备相关的一小段地址,而不是映射全部地址。为了向用户空间只映射部分内存的需要,驱动程序只需要使用偏移量即可。下面的代码揭示了驱动程序如何对起始于物理地址 simple_region_start(页对齐)、大小为 simple_region_size 字节的区域进行映射的工作过程:
unsigned long off = vma->vm_pgoff << PAGE_SHIFT;
unsigned long physical = simple_region_start + off;
unsigned long vsize = vma->vm_end - vma->vm_start;
unsigned long psize = simple_region_size - off;
if (vsize > psize)
return -EINVAL; /* 跨度过大 */
remap_pfn_range(vma, vma->vm_start, physical, vsize, vma->vm_page_prot);
当应用程序要映射比目标设备可用 I/O 区域大的内存时,除了计算偏移量,代码还检查参数的合法性并报告错误。在代码中,psize 是偏移了指定距离后,剩下的物理 I/O 大小,vsize 是虚拟内存需要的大小;该函数拒绝映射超出许可内存范围的地址。
请注意:用户进程总是使用 mremap 对映射进行扩展,有可能超过了物理内存区域的尾部。如果驱动程序没有定义一个 пopage 函数,它将不会获得这个扩展的通知,并且多出的区域将被映射到零内存页上。作为驱动程序作者,应该尽量避免这种情况的发生;将零内存页映射到区域的末端并非一件坏事,但是程序员也不愿意看到这种现象。
为防止扩展映射最简单的办法是实现一个简单的 nopage 方法,它会产生一个总线信号传递给故障进程。该函数有着类似于下面的形式:
struct page *simple_nopage(struct vm_area_struct *vma, unsigned long address, int *type)
{ return NOPAGE_SIGBUS; /* 发送 SIGBUS */}
如上所示,只有当进程抛弃那些存在于已知 VMA 中,但没有当前合法页表入口的地址时,才会调用 nopage 函数。如果使用 remap_pfn_range 映射全部的设备区域,将会为超过该区域的部分调用上面的 nopage 函数。因此它能安全返回 NOPAGE_SIGBUS,通知错误的发生。当然一个更为彻底的 nopage 函数的实现会检查失效的地址是否在设备区域内,如果在设备区域内,它会执行重新映射。再强调一次,nopage 函数不能对 PCI 内存进行操作,因此对 PCI 映射的扩展是不可能实现的。
(6)重新映射 RAM
对 remap_pfn_range 函数的一个限制是:它只能访问保留页和超出物理内存的物理地址。在 Linux 中,在内存映射时,物理地址页被标记为 “保留的” (reserved),表示内存管理对其不起作用。比如在 PC 中,在 640KB 和 11MB 之间的内存被标记为保留的,因为这个范围位于内核自身代码的内部。保留页在内存中被锁住,并且是唯一可安全映射到用户空间的内存页;这个限制是保证系统稳定性的基本需求。
因此 remap_pfn_range 不允许重新映射常规地址,这包括调用 get_free_page 函数所获得的地址。相反它能映射零内存页。进程能访问私有的、零填充的内存页,而不是访问所期望的重新映射的 RAM,除了这点外,一切工作正常。虽然如此,该函数还是做了大多数硬件设备驱动程序需要做的事,因为它能重新映射高端 PCI 缓冲区和 ISA 内存。
能够通过运行 mapper 看到对 remap_page_range 的限制,它是 O’Reilly FTP 服务器上 misc-progs 目录中的一个例子程序。mapper 是一个用来快速检测 mmap 系统调用的易用工具;它根据命令行选项映射一个文件中的只读部分,并把映射区域的内容列在标准输出上。比如在下面的会话中,显示了 /dev/mem 没有映射在 64KB 处的物理页,而是看到了一个全是零的内存页(运行该例子程序的主机是台 PC,但在其他硬件平台上运行的结果应该一样):
remap_pfn_range 函数无法处理 RAM 表明 : 像 scull 这样基于内存的设备无法简单地实现 mmap,因为它的设备内存是通用的 RAM,而不是 I/O 内存。幸运的是,对任何需要将 RAM 映射到用户空间的驱动程序来说,有一种简单的方法可以达到目的,这就是前面介绍过的 nopage 函数。
(a)使用 nopage 方法重映射 RAM
将实际的 RAM 映射到用户空间的方法是:使用 vm_ops->nopage 一次处理一个页错误。在第八章的 scullp 模块中,有一个实现该功能的例子。
scullp 是一个面向内存页的字符设备。由于是面向内存页的,因此能对内存执行 mmap。在执行内存映射的代码中,使用了在 “Linux 中的内存管理” 一节中介绍的一些概念。
在学习代码前,先来看看影响 scullp 中 mmap 实现的一些设计选择:
-
只要映射了设备,scullp 就不会释放设备内存。与其说是需求,还不如说是一种机制,这使得scull 和其他类似设备有着很大的不同,因为打开它们进行写操作时,会把它们的长度截短为 0。禁止释放一个被映射的 scullp 设备,使得一个进程改写被另外一个进程映射的区域成为可能,因此可以看到进程和设备内存是如何互动的。为了避免释放一个被映射的设备,驱动动程序必须保存活动映射的计数;在 device 结构中的 vmas 成员的作用就是完成这一功能。
-
只有当 scullp 的 order 参数(在加载模块时设置)为 0 的时候,才执行内存映射。该参数控制了对 __get_free_pages 的调用(参看第八章中 “get_free_page 及相关函数” 一节)。在 scullp 使用的分配函数 —— __get_free_pages 函数内部实现体现了 0 幂次的限制(它强制每次只分配一个内存页,而不是一组)。为使分配性能最大化,Linux 内核为每一个分配幕次维护了一个闲置页列表,而且只有簇中的第一个页的页计数可以由 get_free_pages 增加,并由 free_pages 减少。如果分配幕次大于 0,则对 scullp 设备禁止使用 mmap 函数。因为 nopage 只处理单页而不处理一簇页面。scullp 不知道如何为内存页正确管理引用计数,这是更高分配幕次的一部分(如果需要复习一下 scullp 和内存分配幕次的值,可以返回到第八章的 “使用一整页的 scull: scullp” 一节)。
0 幂次的限制尽可能地简化了代码。通过处理页的使用计数,也有可能为多页分配正确地实现 mmap,但是这会增加例子的复杂性,却不能引入任何有趣的信息。
如果代码想要按照上面描述的规则来映射 RAM,就需要实现 open、close 和 nopage 等 VMA 方法,它也需要访问内存映像来调整页的使用计数。
scullp_mmap 的实现很是简短,因为它依赖 nopage 函数来完成所有的工作:
int scullp_mmap(struct file *filp, struct vm_area_struct *vma) {
struct inode *inode = filp->f_dentry->d_inode;
/* 如果幕次不是 0,则禁止映射 */
if (scullp_devices[iminor(inode)].order)
return -ENODEV;
/* 这里不做任何事情,"nopage" 将填补这个空白 */
vma->vm_ops = &scullp_vm_ops;
vma->vm_flags |= VM_RESERVED;
vma->vm_private_data = filp->private_data;
scullp_vma_open(vma);
return 0;
}
if 语句的目的是为了了避免映射分配幂次不为 0 的设备。scullp 的操作被存储在 vm_ops 成员中,而且一个指向 device 结构的指针被存储在 vm_private_data 成员中。最后 vm_ops->open 被调用,以更新模块的使用计数和设备的活动映射计数。
open 和 close 函数只是简单地跟踪这些计数,其定义如下:
void scullp_vma_open(struct vm_area_struct *vma) {
struct scullp_dev *dev = vma->vm_private_data;
dev->vmas++;
}
void scullp_vma_close(struct vm_area_struct *vma) {
struct scullp_dev *dev = vma->vm_private_data;
dev->vmas--;
}
大部分工作由 nopage 函数完成。在 scullp 的实现中,nopage 的 address 参数用来计算设备里的偏移量、然后使用该编移量在 scullp 的内存树中查找正确的页:
struct page *scullp_vma_nopage(struct vm_area_struct *vma,
unsigned long address, int *type) {
unsigned long offset;
struct scullp_dev *ptr, *dev = vma->vm_private_data;
struct page *page = NOPAGE_SIGBUS;
void *pageptr = NULL; /* 默认值是 "没有" */
down(&dev->sem);
offset = (address - vma->vm_start) + (vma->vm_pgoff << PAGE_SHIFT);
if (offset >= dev->size) goto out; /* 超出范围 */
/*
* 现在从链表中获得了 scullp 设备以及内存页。
* 如果设备有空白区,当进程访问这些空白区时,进程会收到 SIGBUS。
*/
offset >>= PAGE_SHIFT; /* offset 是页号 */
for (ptr = dev; ptr && offset >= dev->qset;) {
ptr = ptr->next;
offset -= dev->qset;
}
if (ptr && ptr->data) pageptr = ptr->data[offset];
if (!pageptr) goto out; /* 空白区或者是文件末尾 */
page = virt_to_page(pageptr);
/* 获得该值,现在可以增加计数了 */
get_page(page);
if(type)
*type = VM_FAULT_MINOR;
out:
up(&dev->sem);
return page;
}
scullp 使用了由 get_free_pages 函数获得的内存。该内存使用逻辑地址寻址,因此 scullp_nopage 要做的全部工作就是调用 virt_to_page 来获得 page 结构的指针。
scullp 设备现在如预期的那样工作了,下面是 mapper 工具的输出。这里发送一个 /dev(很长)目录清单给 scullp 设备,然后使用 mapper 工具查看 mmap 生成的清单片段:
(7)重新映射内核虚拟地址
虽然很少需要重新映射内核虚拟地址,但是知道驱动程序是如何使用 mmap 将内核虚拟地址映射到用户空间的,也是一件有趣的事。一个真正的内核虚拟地址,就是诸如 vmalloc 这样的函数返回的地址 —— 也就是说,是一个映射到内核页表的虚拟地址。本节中的代码是从 scullv 中抽取出来的,scullv 是一个与 scullp 类似的模块,但它是通过 vmalloc 分配存储空间的。
除了不需要检查控制内存分配的 order 参数之外,scullv 中的大多数实现与前面讨论的 scullp 中的一样。这是因为 vmalloc 每次只分配一个内存页,而单页分配比多页分配成功的可能性更高一些,因此分配的幕次问题在 vmalloc 所分配的空间中不存在。
除了上述部分,只有 scullp 和 scullv 所实现的 nopage 函数是不一样的。请记住 scullp 一且发现了感兴趣的页,将调用 virt_to_page 获得相应的 page 结构指针。但是该函数不能在内核虚拟空间中使用,因此必须使用 vmalloc_to_page 替换它。scullv 版本的 nopage 函数的最后部分如下:
/*
* 在 scullv 查找之后,"page" 现在是当前进程所需要的页地址。
* 由于它是一个 vmalloc 返回的地址,将其转化为一个 page 结构。
*/
page = vmalloc_to_page(pageptr);
/* 获得该值,现在增加它的计数 */
get_page(page);
if (type)
*type = VM_FAULT_MINOR;
out:
up(&dev->sem);
return page;
出于对上述讨论内容的考虑,读者可能想要将 ioremap 返回的地址映射到用户空间上。但这么做是错误的,这是因为 ioremap 返回的地址比较特殊,不能把它当作普通的内核虚拟地址,应该使用 remap_pfn_range 函数将 I/O 内存重新映射到用户空间上。
3、执行直接 I/O 访问
内核缓冲了大多数 I/O 操作。对内核空间缓冲区的使用,在一定程度上分隔了用户空间和实际设备;这种分隔在许多情况下使得程序更容易实现,并且提高了性能。然而有些时候,直接对用户空间缓冲区执行 I/O 操作效果也是很好的。如果需要传输的数据量非常大,直接进行数据传输,而不需要额外地从内核空间拷贝数据操作的参与,这将会大大提高速度。
在 2.6 内核中一个使用直接 I/O 操作的例子是 SCSI 磁带机驱动程序。数据磁带会把大量数据传递给系统,而磁带的传输通常是面向记录的,因此在内核中缓冲数据的收益非常小。因此当条件成熟(比如用户空间缓冲区很大)的时候,SCSI 磁带机驱动程序不通过数据拷贝,直接执行它的 I/O 操作。
然而必须要清醒的认识到,直接 I/O 并不能像人们期望的那样,总是能提供性能上的飞跃。设置直接 I/O(这包括减少和约束相关的用户页)的开销非常巨大,而又没有使用缓存 I/O 的优势。比如,使用直接 I/O 需要 write 系统调用同步执行;否则应用程序将会不知道什么时候能再次使用它的 I/O 缓冲区。在每个写操作完成之前不能停止应用程序,这样会导致关闭程序缓慢,这就是为什么使用直接 I/O 的应用程序也使用异步 I/O 的原因。
无论如何,在字符设备中执行直接 I/O 是不可行的,也是有害的。只有确定设置缓冲 I/O 的开销特别巨大,才使用直接 I/O。请注意块设备和网络设备根本不用担心实现直接 I/O 的问题;在这两种情况中,内核中高层代码设置和使用了直接 I/O,而驱动程序级的代码甚至不需要知道已经执行了直接 I/O。
在 2.6 内核中,实现直接 I/O 的关键是名为 get_user_pages 的函数,它定义在 <linux/mm.h> 中,并有以下原型:
int get_user_pages(struct task_struct *tsk,
struct mm_struct *mm,
unsigned long start,
int len,
int write,
int force,
struct page **pages,
struct vm_area_struct **vmas);
该函数有许多参数:
- tsk
指向执行 I/O 的任务指针;它的主要目的是告诉内核,当设置缓冲区时,谁负责解决页错误的问题。该参数几乎总是 current。 - mm
指向描述被映射地址空间的内存管理结构的指针。mm_struct 结构用来聚合进程虚拟地址空间中的 VMA。对驱动程序来说,该参数总是 current->mm。 - start
- len
start 是用户空间缓冲区的地址(页对齐),len 是页内的缓冲区长度。 - write
- force
如果 write 非零,对映射的页有写权限(意味着用户空间执行了读操作)。force 标志告诉 get_user_pages 函数不要考虑对指定内存页的保护,直接提供所请求的访问;驱动程序对该参数总是设置为 0。 - pages
- vmas
输出参数。如果调用成功,pages 中包含了一个描述用户空间缓冲区 page 结构的指针列表,vmas 包含了相应 VMA 的指针。显然这些参数指向的数组至少包含了 len 个指针。这两个参数都可以为 NULL,但至少 page 结构指针要对缓冲区进行实际的操作。
get_user_pages 函数是一个底层内存管理函数,使用了比较复杂的接口。它还需要在调用前,将 mmap 为获得地址空间的读取者 / 写入者信号量设置为读模式。因此,对 get_user_pages 的调用有类似以下的代码:
down_read(¤t ->mm->mmap_sem);
result = get_user_pages(current, current->mm, -..);
up_read(¤t->mm->mmap_sem);
返回的值是实际被映射的页数,它可能会比请求的数量少(但是大于 0)。
如果调用成功,调用者就会拥有一个指向用户空间缓冲区的页数组,它将被锁在内存中。为了能直接操作缓冲区,内核空间的代码必须用 kmap 或者 kmap_atomic 函数将每个 page 结构指针转换成内核虚拟地址。使用直接 I/O 的设备通常使用 DMA 操作,因此驱动程序要从 page 结构指针数组中创建一个分散/聚集链表。我们将在 “分散/聚集映射” 一节中对其进行详细讲述。
一旦直接 I/O 操作完成,就必须释放用户内存页。在释放前,如果改变了这些页中的内容,则必须通知内核,否则内核会认为这些页是 “干净” 的,也就是说,内核会认为它们与交换设备中的拷贝是匹配的,因此,无需回存就能释放它们。因此,如果改变了页(响应用户空间的读取请求),则必须使用下面的函数标记出每个被改变的页:
void SetPageDirty(struct page *page);
这个宏定义在 <linux/page-flags.h> 中。执行该操作的大多数代码首先要检查页,以确保该页不在内存映射的保留区内,因为这个区的页是不会被交换出去的,因此有如下代码:
if (!PageReserved (page))
SetPageDirty(page);
由于用户空间内存通常不会被标记为保留,因此这个检查并不是严格要求的。但是,在对内存管理子系统有更深入的了解前,最好谨慎和细致些。
不管页是否被改变,它们都必须从页缓存中释放,否则它们会永远存在在那里。所需要使用的函数是:
void page_cache_release(struct page *page);
当然,如果需要的话,在页被标记为改变(dirty)后,应该执行该调用。
(1)异步 I/O
添加到 2.6 内核中的一个 新特性是异步 I/O。异步 I/O 允许用户空间初始化操作,但不必等待它们完成,这样,当 I/O 在执行时,应用程序可以进行其他的操作。一个复杂的、高性能的应用程序也能使用异步 I/O,让多个操作同时进行。
异步 I/O 的实现是可选的,只有少数驱动程序作者需要考虑这个问题,大多数设备并不能从异步操作中获得好处。在后面的几章中,块设备和网络设备驱动程序是完全异步操作的,因此只有字符设备驱动程序需要清楚地表示需要异步 I/O 的支持。如果有恰当的理由需要在同一时刻执行多于一个的 I/O 操作,则字符设备将会从异步 I/O 中受益。一个良好的例子是磁带机驱动程序,如果它的 I/O 操作不能以足够快的速度执行,则驱动器会显著变慢。一个为了获得该驱动器最优性能的应用程序应该使用异步 I/O,同时准备执行多个操作。
针对于少数需要实现异步 I/O 的驱动程序作者 ,我们这里对异步 I/O 的工作过程做一个简要的介绍。在本章中讲述异步 I/O 的原因,是由于它的实现总是包含直接 I/O 操作(如果在内核中缓冲数据,则可以实现异步操作,而不给用户空间增加复杂程度)。
支持异步 I/O 的驱动程序应该包含 <linux/aio.h> 。 有三个用于实现异步 I/O 的 file_operations 方法:
// include/linux/aio.h
struct kiocb {
struct list_head ki_run_list;
unsigned long ki_flags;
int ki_users;
unsigned ki_key; /* id of this request */
struct file *ki_filp;
struct kioctx *ki_ctx; /* may be NULL for sync ops */
int (*ki_cancel)(struct kiocb *, struct io_event *);
ssize_t (*ki_retry)(struct kiocb *);
void (*ki_dtor)(struct kiocb *);
union {
void __user *user;
struct task_struct *tsk;
} ki_obj;
__u64 ki_user_data; /* user's data for completion */
loff_t ki_pos;
void *private;
/* State that we remember to be able to restart/retry */
unsigned short ki_opcode;
size_t ki_nbytes; /* copy of iocb->aio_nbytes */
char __user *ki_buf; /* remaining iocb->aio_buf */
size_t ki_left; /* remaining bytes */
struct iovec ki_inline_vec; /* inline vector */
struct iovec *ki_iovec;
unsigned long ki_nr_segs;
unsigned long ki_cur_seg;
struct list_head ki_list; /* the aio core uses this
* for cancellation */
/*
* If the aio_resfd field of the userspace iocb is not zero,
* this is the underlying eventfd context to deliver events to.
*/
struct eventfd_ctx *ki_eventfd;
};
ssize_t (*aio_read) (struct kiocb *iocb, char *buffer, size_t count, loff_t offset);
ssize_t (*aio_write) (struct kiocb *iocb, const char *buffer, size_t count,
loff_t offset);
int (*aio_fsync) (struct kiocb *iocb, int datasync);
aio_fsync 操作只对文件系统有意义,因此不作深入讨论。另外两个函数,aio_read 和 aio_write 与常用的 read 和 write 函数非常类似,但是也有一些不同。其中一个不同是:传递的 offset 参数是一个值;异步操作从不改变文件的位置,因此没有必要向它传递指针。这两个函数都使用 iocb(I/O 控制块, I/O control block)参数,一会将讨论它。
aio_read 和 aio_write 函数的目的是初始化读和写操作,在这两个函数完成时,读写操作可能已经完成,也可能尚未完成。如果操作立刻完成,则函数将返回常规状态:传输的字节数或者是负的错误码。因此如果驱动程序作者自己的 read 函数称为 my_read,下面的 aio_read 函数就是完全正确的(虽然是无意义的):
static ssize_t my_aio_read(struct kiocb *iocb, char *buffer,
ssize_t count, loff_t offset) {
return my_read(iocb->ki_filp, buffer, count, &offset);
}
请注意 file 结构指针保存在 kiocb 结构中的 ki_filp 成员里。如果支持异步 I/O,则必须知道一个事实:内核有时会创建 “同步 IOCB” 。也就是说异步操作实际上必须同步执行。读者也许会问:为什么会这样? 但最好还是适应内核的要求。同步操作会在 IOCB 中标识,因此,驱动程序应该使用下面的函数进行查询:
int is_sync_kiocb(struct kiocb *iocb);
如果该函数返回非零值,则驱动程序必须执行同步操作。
最后的关键点是如何允许异步操作。如果驱动程序可以开始操作(或者简单点,将操作压入队列,等待未来某个时刻执行),它必须做两件事:记住与操作相关的所有信息,并且返回 -EIOCBQUEUED 给调用者。记住操作的信息包括了安排对用户空间缓冲区的访问;一旦返回,因为要运行在调用进程的上下文中,所以将不能再访问这个缓冲区。通常这意味着建立直接的内核映射(使用 get_user_pages )或者 DMA 映射。-EIOCBQUEUED 错误码表明操作还没有完成,它最终的状态将在未来某个时刻公布。
当未来某个时刻到来时,驱动程序必须通知内核操作已经完成。这需要使用 aio_complete 函数:
int aio_complete(struct kiocb *iocb, long res, long res2);
这里,iocb 与最初传递给我们的 IOCB 相同,res 是操作的结果状态,res2 是返回给用户空间的第二状态码,大多数异步 I/O 会将 res2 设置为 0。一旦调用了 aio_complete,就不能再访问 IOCB 或者用户缓冲区了。
(a)异步 I/O 例子
在例子源代码中,面向内存页的 scullp 驱动程序实现了异步 I/O。该实现非常简单,但对于揭示异步操作是如何进行的,就已经足够了。
aio_read 和 aio_write 函数实际上没做什么事:
static ssize_t scullp_aio_read(struct kiocb *iocb, char *buf, size_t count,
loff_t pos) {
return scullp_defer_op(0, iocb, buf, count, pos);
}
static ssize_t scullp_aio_write(struct kiocb *iocb, const char *buf,
size_t count, loff_t pos) {
return scullp_defer_op(1, iocb, (char *) buf, count, pos);
}
这些函数只是简单地调用了一个常用函数:
struct async_work {
struct kiocb *iocb;
int result;
struct work_struct work;
};
static int scullp_defer_op(int write, struct kiocb *iocb, char *buf,
size_t count, loff_t pos) {
struct async_work *stuff;
int result;
/* 虽然可以访问缓冲区,但现在要进行拷贝操作 */
if (write)
result = scullp_write(iocb->ki_filp, buf, count, &pos);
else
result = scullp_read(iocb->ki_filp, buf, count, &pos);
/* 如果这是一个同步的 IOCB,则现在返回状态值 */
if (is_sync_kiocb(iocb))
return result;
/* 否则把完成操作向后推迟几毫秒 */
stuff = kmalloc(sizeof (*stuff), GFP_KERNEL);
if (stuff == NULL)
return result;
/* 没有可用内存了,使之完成 */
stuff->iocb = iocb;
stuff->result = result;
INIT_WORK(&stuff->work, scullp_do_deferred_op, stuff);
schedule_delayed_work(&stuff->work, HZ/100);
return -EIOCBQUEUED;
}
一个更完整的实现应该使用 get_user_pages 函数,以便将用户缓冲区映射到内核空间,为了简单起见,这里只是从起始位置拷贝了数据。然后调用 is_sync_kiocb 函数检查操作是否必须以同步方式完成。如果是,返回结果状态;如果不是,将相关信息保存在一个小结构中,然后安排作业队列,接着返回 -EIOCBQUEUED。
到此为止,将控制权返回给了用户空间。接着、作业队列执行了完整操作:
static void scullp_do_deferred_op(void *p) {
struct async_work *stuff = (struct async_work *) p;
aio_complete(stuff->iocb, stuff->result, 0);
kfree(stuff);
}
这里仅仅使用保存的信息调用了 aio_complete 函数。一个实际驱动程序的异步 I/O 实现当然比这复杂,但是其基本模式不会变。
4、 直接内存访问
直接内存访问,或者 DMA,是关于内存问题讨论的高级部分。DMA 是一种硬件机制,它允许外围设备和主内存之间直接传输它们的 I/O 数据,而不需要系统处理器的参与使用这种机制可以大大提高与设备通信的吞吐量,因为免除了大量的计算开销。
(1)DMA 数据传输概览
在介绍编程细节之前,先回顾一下 DMA 传输是如何发生的,为了简化问题,只考虑输入传输。
有两种方式引发数据传输:或者是软件对数据的请求(比如通过 read 函数),或者是硬件异步地将数据传递给系统。
在第一种情况中,所需要的步骤概括如下:
- 当进程调用 read,驱动程序函数分配一个 DMA 缓冲区,并让硬件将数据传输到这个缓冲区中。进程处于睡眠状态。
- 硬件将数据写入到 DMA 缓冲区中,当写入完毕,产生一个中断。
- 中断处理程序获得输入的数据,应答中断,并且唤醒进程,该进程现在即可读取数据。
第二种情况发生在异步使用 DMA 时。比如对于一个数据采集设备,即使没有进程读取数据,它也不断地写入数据。此时,驱动程序应该维护一个缓冲区,其后的 read 调用将返回所有积累的数据给用户空间。这种传输方式的步骤有所不同:
- 硬件产生中断,宣告新数据的到来。
- 中断处理程序分配一个缓冲区,并且告诉硬件向哪里传输数据。
- 外围设备将数据写入缓冲区,完成后产生另外一个中断。
- 处理程序分发新数据、唤醒任何相关进程,然后执行清理工作。
另一种异步方法可在网卡中看到。网卡期望在内存中建有一个循环缓冲区(通常叫做 DMA 环形缓冲区),并与处理器共享; 每个输入的数据包都放入缓冲器环中的下一个可用缓冲器中,然后引发中断。接着驱动程序将数据包发送给内核其他部分处理,并在环形缓冲区中放置一个新的 DMA 缓冲区。
上述情况的处理步骤强调,高效的 DMA 处理依赖于中断报告。虽然可以使用轮询的驱动程序实现 DMA,但这没有意义,因为一个轮询驱动程序会将 DMA 相对于简单的处理器驱动 I/O 获得的性能优势抵消掉(注 4)。
注 4: 当然,任何事情都有例外。请阅读第十七章的 “缓解接收中断”,其中说明了如何使用轮询实现最好的高性能网络驱动程序。
这里介绍的另外一个相关术语是 DMA 缓冲区。DMA 需要设备驱动程序分配一个或者多个适合执行 DMA 的特殊缓冲区。请注意许多驱动程序在初始化阶段分配了它们的缓冲区,并且一直使用它们直到关闭 —— 在前面涉及到的 “分配” 一词含义是 “保持一个已经分配的缓冲区”。
(2)分配 DMA 缓冲区
本节主要讨论在低层分配 DMA 缓冲区的方法,很快就会介绍一个较高层的接口,但仍要正确理解这里介绍的内容。
使用 DMA 缓冲区的主要问题是:当大于一页时,它们必须占据连续的物理页、这是因为设备使用 ISA 或者 PCI 系统总线传输数据,而这两种方式使用的都是物理地址。有趣的是,这种限制对 SBus(参看第十二章中 “Sus” 一节)是无效的,因为它在外围总线上使用了虚拟地址。某些体系架构的 PCI 总线也可以使用虚拟地址,但是出于可移植性的考虑,我们不建议使用这个特性。
虽然既可以在系统启动时,也可以在运行时分配 DMA 缓冲区,但是模块只能在运行时刻分配它们的缓冲区(在第八章中论述了该技术,其中的 “获得更多缓冲区” 一节讲述了在系统启动时分配的方法,而 “kmalloc” 和 “get_free_page 及其辅助函数” 一节中描述了运行时分配的方法)。驱动程序作者必须谨慎地为 DMA 操作分配正确的内存类型,因为并不是所有内存区间都适合 DMA 操操作。在实际操作中,一些设备和一些系统中的高端内存不能用于 DMA,这是因为外围设备不能使用高端内存的地址。
在现代总线上的大多数设备能够处理 32 位地址,这意味着常用的内存分配机制能很好地工作。一些 PCI 设备没能实现全部的 PCI 标准,因此不能使用 32 位地址,而一些 ISA 设备还局限在使用 24 位地址的阶段。
对于有这些限制的设备,应使用 GFP_DMA 标志调用 kmalloc 或者 get_free_pages 从 DMA 区间分配内存。当设置了该标志时,只有使用 24 位寻址方式的内存才能被分配。另外,还可以使用通用 DMA 层(不久会讲到)来分配缓冲区,这样也能满足对设备限制的需求。
(a)DIY 分配
读者已经知道 get_free_pages 函数可以分配多达几 M 字节的内存(最高可以达到 MAX_ORDER,目前是 11),但是对较大数量的请求,甚至是远少于 128KB 的请求也通常会失败,这是因为此时系统内存中充满了内存碎片(注 5)。
注 5:“碎片” 一词通常用于磁盘,用来说明在磁介质上,文件并不是连续存放的。相同的概念也可以应用于内存,由于每个虚拟地址空间分散在整个物理 RAM 中,因此当请求 DMA 缓冲区时,也难以获得连续的空闲页。
当内核不能返回请求数量的内存,或者需要大于 128KB 内存(比如 PCI 帧捕获卡的普遍请求)的时候,相对于返回 -ENOMEM,另外一个办法是在引导时分配内存,或者为缓冲区保留顶部物理 RAM。我们已经在第八章的 “获得更多缓冲区” 一节讲述了如何在引导时分配内存,但对模块来说这是不可行的。在引导时,我们可以通过向内核传递 “mem= 参数” 的办法保留顶部的 RAM。比如系统有 256MB 内存,参数 “mem=255M” 将使内核不能使用顶部的 1M 字节。随后,模块可以使用下面的代码获得对该内存的访问权:
dmabuf = ioremap (0xFF00000 /* 255M */ , 0x100000 /* 1M */);
随本书附带的例子代码中有一个分配器 ,它提供了一个 API 用来探测和管理保留的 RAM,并且在多种体系架构中能成功使用。但是该分配器不能在配置有高端内存的系统上使用(比如物理内存数量超出 CPU 地址空间的系统)。
还有一个办法是使用 GFP_NOFAIL 分配标志来为缓冲区分配内存。但是该方法为内存管理子系统带来了相当大的压力,因此为整个系统带来了风险。所以,如果不是实在没有其他更好的方法,最好不要使用这个标志。
如果需要为 DMA 缓冲区分配一大块内存,最好考虑一下是否有替代的方法。如果设备支持分散/聚集 I/O,则可以将缓冲区分配成多个小块,设备会很好地处理它们。当在用户空间中执行直接 I/O 的时候,也可以用分散/聚集 I/O。当需要有一大块缓冲区的时候,这是最好的解决方案。
(3)总线地址
使用 DMA 的设备驱动程序将与连接到总线接口上的硬件通信,硬件使用的是物理地址,而程序代码使用的是虚拟地址。
实际上情况比这还要复杂些。基于 DMA 的硬件使用总线地址,而非物理地址。虽然 ISA 和 PCI 总线地址只是 PC 上的简单物理地址,但是对其他平台来说,却不总是这样。有时接口总线是通过将 I/O 地址映射到不同物理地址的桥接电路连接的。某些系统甚至有页面映射调度,能够使任意页面在外围总线上表现为连续的。
在最底层(一会将要介绍较高层的解决方案),Linux 内核通过输出在 <asm/io.h> 中定义的一些函数、提供了可移植的方案。我们不推荐使用这些函数,因为只有在那些拥有非常简单 I/O 的体系架构中,它们才能工作正常;虽然如此,在阅读内核代码时还是会遇到它们。
unsigned long virt_to_bus(volatile void *address);
void *bus_to_virt(unsigned long address);
这些函数在内核逻辑地址和总线地址间执行了简单的转换。但对于必须使用 I/O 内存管理单元或者必须使用回弹缓冲区的情况下,它们将不能工作。执行这些转换的正确方法是使用通用 DMA 层,因此现在来讨论这个主题。
(4)通用 DMA 层
DMA 操作最终会分配缓冲区,并将总线地址传递给设备。一个可移植的驱动程序要求对所有体系架构都能安全而正确地执行 DMA 操作,编写这样一个驱动程序的难度超出了一般人的想像。不同的系统对处理缓存一致性上有不同的方法;如果不能正确处理该问题,驱动程序会引起内存冲突。一些系统拥有复杂的总线硬件,使得 DMA 任务或变得简单,或变得困难。并且不是所有的系统都能对全部的内存执行 DMA。幸运的是,内核提供了一个与总线体系架构无关的 DMA 层,它会隐藏大多数问题。强烈建议在编写驱动程序时,为 DMA 操作使用该层。
下面许多函数都需要一个指向 device 结构的指针。该结构是在 Linux 设备模型中用来表示设备底层的。驱动程序通常不直接使用该结构,但是在使用通用 DMA 层时,需要使用它。该结构内部隐藏了描述设备的总线细节。比如可以在 pci_device 结构或者 usb_device 结构的 dev 成员中发现它。
使用下列函数的驱动程序都要包含头文件 <linux/dma-mapping.h>。
(a)处理复杂的硬件
在执行 DMA 之前,第一个必须回答的问题是:是否给定的设备在当前主机上具备执行这些操作的能力。出于很多原因,许多设备受限于它们的寻址范围。默认的情况下,内核假设设备都能在 32 位地址上执行 DMA。如果不是这样,应该调用下面的函数通知内核:
int dma_set_mask(struct device *dev, u64 mask);
该掩码显示与设备能寻址能力对应的位。比如设备受限于 24 位寻址,则 mask 应该是 0x0FFFFFF 。如果使用指定的 mask 时 DMA 能正常工作,则返回非零值。如果 dma_set_mask 返回 0,则对该设备不能使用 DMA。因此,一个受限于 24 位 DMA 操作的驱动程序初始化代码有如下的形式:
if (dma_set_mask (dev, 0xffffff))
card->use_dma = 1;
else {
card->use_dma = 0;
/* 不得不在没有 DMA 情况下操作 */
printk (KERN_WARN, "mydev: DMA not supported\n");
}
再强调一遍,如果设备支持常见的 32 位 DMA 操作,则没有必要调用 dma_set_mask。
(b)DMA 映射
一个 DMA 映射是要分配的 DMA 缓冲区与为该缓冲区生成的、设备可访问地址的组合。我们可以通过对 virt_to_bus 函数的调用获得该地址,但是有许多理由建议不要这么做。第一个理由是具有 IOMMU 的硬件为总线提共了一套映射寄存器。IOMMU 在设备可访问的地址范围内规划了物理内存,使得物理上分散的缓冲区对设备来说变成连续的。对 IOMMU 的运用需要使用到通用 DMA 层,而 virt_to_bus 函数不能完成这个任务。
请注意不是所有的体系架构都有 IOMMU;特别是常见的 x86 平台没有对 IOMMU 的支持。但是,一个正确的驱动程序不需要知道其运行系统上的 I/O 支持硬件。
在某些情况下,为设备设置可用地址需要建立回弹缓冲区。当驱动程序要试图在外围设备不可访问的地址上执行 DMA 时(比如高端内存),将创建回弹缓冲区。然后,必要时会将数据写入或者读出回弹缓冲区。对回弹缓冲区的使用势必会降低系统性能,但有的时候却没有其他可替代的方法。
DMA 映射必须解决缓存一致性的问题。现代处理器在内部的快速缓存器中保存了最近访问的内存区域;没有该缓存器,将得不到期望的性能。如果设备改变了主内存中的区域,则任何覆盖该区域的处理器缓存都将无效;否则处理器将使用不正确的主内存映射,从而产生不正确的数据。与此类似,当设备使用 DMA 从主内存中读取数据时,在处理器缓存中的任何改变也必须立刻得到刷新。这些缓存一致性的问题为系统带来诸多不确定因素,如果程序员不细致谨慎的话,这些错误非常难以查找。一些体系架构在硬件中管理缓存的一致性,但是其他一些体系架构则需要软件的支持。通用 DMA 层端尽全力来保证在所有体系架构中都能正常运行,但是必须看到,正确的行为需要一套规则来保障。
DMA 映射建立了一个新的结构类型 —— dma_addr_t 来表示总线地址。dma_addr_t 类型的变量对驱动程序是不透明的;唯一允许的操作是将它们传递给 DMA 支持例程以及设备本身。作为一个总线地址,如果 CPU 直接使用了 dma_addr_t,将会导致发生不可预期的问题。
根据 DMA 缓冲区期望保留的时间长短,PCI 代码区分两种类型的 DMA 映射:
- 一致性 DMA 映射
这种类型的映射存在于驱动程序生命周期中。一致性映射的缓冲区必须可同时被 CPU 和外围设备访问(其他类型的映射,如后面将要讨论的类型,在给定时刻只能被一个设备访问)。因此一致性映射必须保存在一致性缓存中。建立和使用一致性映射的开销是很大的。 - 流式 DMA 映射
通常为单独的操作建立流式映射。当使用流式映射时,一些体系架构可以最大程度地优化性能,但是这些映射也要服从一组更加严格的访问规则。内核开发者建议尽量使用流式映射,然后再考虑一致性映射。这么做有两个原因。第一个原因是在支持映射寄存器的系统中,每个 DMA 映射使用总线上的一个或者多个映射寄存器。一致性映射具有很长的生命周期,因此会在相当长的时间内占用这些寄存器,甚至在不使用它们的时候也不释放所有权。第二个原因是在一些硬件中,流式映射可以被优化,但优化的方法对一致性映射无效。
必须用不同的方法操作这两种映射,下面详细描述操作细节。
(c)建立一致性 DMA 映射
驱动程序可调用 pci_alloc_consistent 函数建立一致性映射:
void *dma_alloc_coherent(struct device *dev, size_t size,
dma_addr_t *dma_handle, int flag);
该函数处理了缓冲区的分配和映射。前两个参数是 device 结构和所需缓冲区的大小。函数在两处返回 DMA 映射的结果。函数的返回值是缓冲区的内核虚拟地址,可以被驱动程序使用;而与其相关的总线地址,返回时保存在 dma_handle 中。该函数对分配的缓冲区做了一些处理,从而缓冲区可用于 DMA;通常只是通过 get_free_pages 函数分配内存(请注意 size 是以字节为单位的,而不是幕次的值)。flag 参数通常是描述如何分配内存的 GFP_ 值;通常是 GFP_KERNEL 或者是 GFP_ATOMIC(在原子上下文中运行时)。
当不再需要缓冲区时(通常在模块卸载的时候),调用 dma_free_coherent 向系统返回缓冲区:
void dma_free_coherent(struct device *dev, size_t size,
void *vaddr, dma_addr_t dma_handle);
请注意该函数与其他通用 DMA 函数一样,需要提供缓冲区大小、CPU 地址、总线地址等参数。
(d)DMA 池
DMA 池是一个生成小型、一致性 DMA 映射的机制。调用 dma_alloc_coherent 函数获得的映射,可能其最小大小为单个页。如果设备需要的 DMA 区域比这还小,就要使用 DMA 池了。在对内嵌于某个大结构中的小型区域执行 DMA 时,也可以使用 DMA 池。一些不容易察觉的驱动程序一致性缓存错误,往往存在于结构中与小型 DMA 区域相邻的成员中。为了避免这一问题的出现,应该总是显式地为 DMA 操作分配区域,而与其他非 DMA 数据结构的操作分开。
在 <linux/dmapool.h> 中定义了 DMA 池的函数。
DMA 池必须在使用前,调用下面的函数创建:
struct dma_pool *dma_pool_create(const char *name, struct device *dev,
size_t size, size_t align,
size_t allocation);
这里,name 是 DMA 池的名字,dev 是 device 结构,size 是从该池中分配的缓冲区的大小,align 是该池分配操作所必须遵守的硬件对齐原则(用字节表示),如果 allocation 不为零、表示内存边界不能超越 allocation。比如传入的 allocation 是 4096,从该池中分配的缓冲区不能跨越 4KB 的界限。
当使用完 DMA 池后,调用下面的函数释放:
void dma_pool_destroy(struct dma_pool *pool);
在销毁前,必须向 DMA 池返回所有分配的内存。
使用 dma_pool_alloc 函数处理分配问题:
void *dma_pool_alloc(struct dma_pool *pool, int mem_flags, dma_addr_t *handle);
在这个函数中,mem_flags 通常设置为GFP_ 分配标志。如果一切正常,将分配并返回内存区域(拥有创建 DMA 池时指定的大小)。像 dma_alloc_coherent 函数一样,返回的 DMA 缓冲区的地址是内核虚拟地址,并作为总线地址保存在 handle 中。
使用下面的通数返回不需要的缓冲区:
void dma_pool_free(struct dma_pool *pool, void *vaddr, dma_addr_t addr);
(e)建立流式 DMA 映射
由于多种原因,流式映射具有比一致性映射更为复杂的接口。这些映射希望能与已经由驱动程序分配的缓冲区协同工作,因而不得不处理那些不是它们选择的地址。在某些体系架构中,流式映射也能够拥有多个不连续的页和多个 “分散 / 聚集” 缓冲区。出于上面这些原因的考虑,流式映射拥有自己的设置函数。
当建立流式映射时,必须告诉内核数据流动的方向。为此定义了一些符号(dma_data_direction 枚举类型):
- DMA_TO_DEVICE
- DMA_FROM_DEVICE
这两个符号的作用很明显。如果数据被发送到设备(可能使用 write 系统调用作为响应),应使 用 DMA_TO_DEVICE ;而如果数据被发送到 CPU ,则使用 DMA_FROM_DEVICE。 - DMA_BIDIRECTIONAL
如果数据可双向移动,则使用 DMA_BIDIRECTIONAL。 - DMA_NONE
提供该符号只是出于调试目的。如果要使用设置了该符号的缓冲区,会导致内核错误。
可能有的读者认为任何时候都使用 DMA_BIDIRECTIONAL 就可以了,但是驱动程序作者不能这么做。在一些体系架构中,可能会为这个选择付出很大的性能代价。
当只有一个缓冲区要被传输的时候,使用 dma_map_single 函数映射它:
dma_addr_t dma_map_single(struct device *dev, void *buffer, size_t size,
enum dma_data_direction direction);
返回值是总线地址,可以把它传递给设备;如果执行遇到错误,则返回 NULL。
当传输完毕后,使用 dma_unmap_single 函数删除映射:
void dma_unmap_single(struct device *dev, dma_addr_t dma_addr, size_t size,
enum dma_data_direction direction);
在该函数中,size 和 direction 参数必须与映射缓冲区的参数相匹配。有几条非常重要的原则用于流式 DMA 映射:
- 缓冲区只能用于这样的传送,即其传送方向匹配于映射时给定的方向值。
- 一旦缓冲区被映射,它将属于设备,而不是处理器。直到缓冲区被撤销映射前,驱动程序不能以任何方式访问其中的内容。只有当 dma_unmap_single 函数被调用后,驱动程序才能安全访问缓冲区中的内容(还存在一个例外,不久就会看到)。尤其要说明的是,这条规则意味着:在包含了所有要写入的数据之前,不能映射要写入设备的缓冲区。
- 在 DMA 处于活动期间内,不能撤销对缓冲区映射,否则会严重破坏系统的稳定性。
读者可能提出疑问:为什么在缓冲区被映射后,驱动程序不能访问它? 有两个原因来解释这条规则。第一,当一个缓冲区建立 DMA 映射时,内核必须保证在该缓冲区内的全部数据都被写入了内存。当调用 dma_unmap_single 函数时,很可能有一些数据还在处理器的缓存中,因此必须被显式刷新。在刷新动作后,处理器写入缓冲区的数据对设备是不可见的。
第二,如果要映射的缓冲区位于设备不能访问的内存区段时,该怎么办? 一些体系架构会只产生一个错误,但是其他一些体系架构将创建一个回弹缓冲区。回弹缓冲区是内存中的独立区域,它可被设备访问。如果使用 DMA_TO_DEVICE 方向标志映射缓冲区,并且需要使用回弹缓冲区,则在最初缓冲区中的内容作为映射操作的一部分被拷贝。很明显 , 在拷贝操作后,最初缓冲区内容的改变对设备也是不可见的。同样 DMA_FROM_DEVICE 回弹缓冲区被 dma_unmap_single 函数拷贝回最初的缓冲区中,也就是说,直到拷贝操作完成,来自设备的数据才可用。
顺便说一下,为什么获得正确的传输方向是一个重要的问题,回弹缓冲区就是一个解释。DMA_BIDIRECTIONAL 回弹缓冲区在操作前后都要拷贝数据,这通常会浪费不必要的 CPU 指令周期。
有时候,驱动程序需要不经过撤销映射就访问流式 DMA 缓冲区的内容,为此内核提供了如下调用:
void dma_sync_single_for_cpu(struct device *dev, dma_handle_t bus_addr,
size_t size, enum dma_data_direction direction);
应该在处理器访问流式 DMA 缓冲区前调用该函数。一旦调用了该函数,处理器将 “拥有” DMA 缓冲区,并可根据需要对它进行访问。然而在设备访问缓冲区前,应该调用下面的函数将所有权交还给设备:
void dma_sync_single_for_device(struct device *dev, dma_handle_t bus_addr,
size_t size, enum dma_data_direction diredtion);
再次强调,处理器在调用该通数后,不能再访问 DMA 缓冲区了。
(f)单页流式映射
有时候,要为 page 结构指针指向的缓冲区建立映射;这种情况是有可能发生的,比如使用 get_user_pages 映射用户空间缓冲区。使用下面的函数,建立和撤销使用 page 结构指针的流式映射:
dma_addr_t dma_map_page(struct device *dev, struct page *page,
unsigmed long offset, size_t size,
enum dma_data_direction direction);
void dma_unmap_page(struct device *dev, dma_addr_t dma_address,
size_t size, enum dma_data_direction direction);
offset 和 size 参数用于映射一页中的一部分。建议尽量避免映射部分内存页,除非明了其中的原理。如果分配的页是缓存流水线的一部分,则映射部分页会引起一致性问题,比如内存冲突,以及产生非常难以调试的代码缺陷等。
(g)分散 / 聚集映射
分散/集聚集映射是一种特殊类型的流式 DMA 映射。假设有几个缓冲区,它们需要与设备双向传输数据。有几种方式能产生这种情形,包括从 readv 或者 writev 系统调用产生,从集群的磁盘 I/O 请求产生,或者从映射的内核 I/O 缓冲区中的页面链表产生。可以简单地依次映射每一个缓冲区并且执行请求的操作,但是一次映射整个缓冲区表还是很有利的。
许多设备都能接受一个指针数组的分散表,以及它的长度,然后在一次 DMA 操作中把它们全部传输走。比如将所有的数据包放在多个数据单元中,“零拷贝” 网络非常容易实现。把分散表作为一个整体的另外一个原因是,充分利用那些在总线硬件中含有映射寄存器系统的优点。在这些系统中,从设备角度上看、物理上不连续的内存页,可以被组装成一个连续数组。这种技术只能用在分散表中的项在长度上等于页面大小的时候(除了第一个和最后一个之外),但是在其工作时,它能够将多个操作转化成单个 DMA 操作,因而能够加速处理工作。
最后,如果必须用到回弹缓冲区,将整个表接合成一个单个缓冲区是很有意义的(因为无论如何它也会被复制)。
所以现在可以确信在某些情况下分散表的映射是值得做的。映射分散表的第一步是建立并填充一个描述被传送缓冲区的 scatterlist 结构的数组。该结构是与体系架构相关的,并且在头文件 <linux/scatterlist.h> 中描述。然而,该结构会始终包含两个成员:
- struct page *page;
与在 scatter/gather 操作中用到缓冲区相对应的 page 结构指针。 - unsigned int length;
- unsigned int offset;
在页内缓神区的长度和偏移量。
为了映射一个分散/聚集 DMA 操作,驱动程序应当为传输的每个缓冲区在 scatterlist 结构对应入口项上设置 page、offset 和 length 成员。然后调用:
int dma_map_sg(struct device *dev, struct scatterlist *sg, int nents,
enum dma_data_direction direction)
这里的 nents 是传入的分散表入口的数量。返回值是要传送的 DMA 缓冲区数;它可能会小于 nents。
对在输入分散表中的每一个缓冲区,dma_map_sg 函数返回了指定设备的正确的总线地址。作为任务的一部分,它还把内存中相邻的缓冲区接合起来。如果运行驱动程序的系统拥有一个 I/O 内存管理单元,dma_map_sg 函数会对该单元的映射寄存器编程,如果没有发生什么错误,则从设备角度上看,其能够传输一块连续的缓冲区。然而在调用之前是无法知道传输结果的。
驱动程序应该传输由 dma_map_sg 函数返回的每个缓冲区。总线地址和每个缓冲区的长度被保存在 scatterlist 结构中,但是它们在结构中的位置会随体系架构的不同而不同。使用已经定义的两个宏,可用来编写可移植代码:
dma_addr_t sg_dma_address(struct scatterlist *sg);
从该分散表的入口项中返回总线(DMA)地址。
unsigned int sg_dma_len(struct scatterlist *sg);
返回缓冲区的长度。
再次强调,被传输缓冲区的地址和长度与传递给 dma_map_sg 函数的值是不同的。一旦传输完毕,使用 dma_uпmap_sg 函数解除分散 / 聚集映射:
void dma_unmap_sg(struct device *dev, struct scatterlist *list,
int nents, enum dma_data_direction direction);
请注意,nents 一定是先前传递给 dma_map_sg 函数的入口项的数量,而不是函数返回的 DMA 缓冲区的数量。
分散/聚集映射是流式 DMA 映射,因此适用于流式映射的规则也适用于该种映射。如果必须访问映射的分散 / 聚集列表,必须首先对其进行同步:
void dma_sync_sg_for_cpu(struct device *dev, struct scatterlist *sg,
int nents, enum dma_data_direction direction);
void dma_sync_sg_for_device(struct device *dev, struct scatterlist *sg,
int nents, enum dma_data_direction direction);
(h)PCI 双重地址周期映射
通常 DMA 支持层使用 32 位总线地址,其为设备的 DMA 掩码所约束。然而 PCI 总线还支持 64 位地址模式 ,既双重地址周期(DAC)。出于多种原因,通用 DMA 层并不支持。该模式,首先这是 PCI 独有的特性。其次,许多 DAC 的实现都是有缺陷的,而且 DAC 也比常用的 32 位 DMA 要慢,会增加性能开销。虽然如此,还是有一些应用程序能正确使用 DAC;如果设备需要使用放在高端内存的大块缓冲区,可以考虑实现 DAC 支持。这种支持只有对 PCI 总线有效,因此必须使用与 PCI 总线相关的例程。
为了使用 DAC,驱动程序必须包含头文件 <linux/pci.h>,还必须设置一个单独的 DMA 掩码:
int pci_dac_set_dma_mask(struct pci_dev *pdev, u64 mask);
只有该函数返回 0 时,才能使用 DAC 地址。
在 DAC 映射中使用了一个特殊类型(dma64_addr_t)。调 pci_dac_page_to_dma 函数建立一个这样的映射:
dma64_addr_t pci_dac_page_to_dma(struct pci_dev *pdev, struct page *page,
unsigned long offset, int direction);
读者会注意到,可以只使用 page 结构指针(毕竟它们应当保存在高端内存中,否则是毫无意义的)来建立 DAC 映射,而且必须以一次一页的方式创建它们。direction 参数与在通用 DMA 层中使用的 dma_data_direction 枚举类型等价,因此可以取 PCI_DMA_TODEVICE、PCI_DMA_FROMDEVICE 或者 PCI_DMA_BIDIRECTIONAL。
DAC 映射不需要其他另外的资源,因此在使用过后,不需要显式释放它。然而像对待其他流式映射一样对待它是必要的,关于缓冲区所有权的规则也适用于它。有一套用于同步 DMA 缓冲区的函数,其形式如下:
void pci_dac_dma_sync_single_for_cpu(struct pci_dev *pdev,
dma64_addr_t dma_addr,
size_t len,
int direction);
void pci_dac_dma_sync_single_for_device(struct pci_dev *pdev,
dma64_addr_t dma_addr,
size_t len,
int direction);
(i)一个简单的 PCI DMA 例子
这里提供了一个 PCI 设备的 DMA 例子源代码,以说明如何使用 DMA 映射。实际 PCI 总线上的 DMA 操作形式,与它所驱动的设备密切相关,因此这个例子不能应用于任何真实设备。但它是一个假定的叫 dad(DMA Acquisition Device,DMA 获取设备)驱动程序的一部分。该设备的驱动动程序要用类似下面的代码定义传输函数:
int dad_transfer(struct dad_dev *dev, int write, void *buffer, size_t count) {
dma_addr_t bus_addr;
/* 映射 DMA 需要的缓冲区 */
dev->dma_dir = (write ? DMA_TO_DEVICE : DMA_FROM_DEVICE);
dev->dma_size = count;
bus_addr = dma_map_single(&dev->pci_dev->dev, buffer, count,
dev->dma_dir);
dev->dma_addr = bus_addr;
/* 设置设备 */
writeb(dev->registers.command, DAD_CMD_DISABLEDMA);
writeb(dev->registers.command, write ? DAD_CMD_WR : DAD_CMD_RD);
writel(dev->registers.addr, cpu_to_le32(bus_addr));
writel(dev->registers.len, cpu_to_le32(count));
/* 开始操作 */
writeb(dev->registers.command, DAD_CMD_ENABLEDMA);
return 0;
}
该函数映射了准备进行传输的缓冲区并且启动设备操作。另一半工作必须在中断服务例程中完成,它看起来类似下面这样:
void dad_interrupt(int irq, void *dev_id, struct pt_regs *regs) {
struct dad_dev *dev = (struct dad_dev *) dev_id;
/* 确定该中断确实是从对应的设备发来的 */
/* 释放对 DMA 缓冲区的映射 */
dma_unmap_single(dev->pci_dev->dev, dev->dma_addr,
dev->dma_size, dev->dma_dir);
/* 只有到现在这个时候,对缓冲区的访问才是安全的,把它拷贝给用户。 */
// ...
}
显而易见,这个例子忽略了大量细节,包括用来阻止同时开始多个 DMA 操作的必要步骤。
(5)ISA 设备的 DMA
ISA 总线允许两种 DMA 传输:本地(native) DMA 和 ISA 总线控制 (bus-master) DMA。本地 DMA 使用主板上的标准 DMA 控制器电路来驱动 ISA 总线上的信号线。另一方面, ISA 总线控制 DMA 完全由外围设备控制。后一种 DMA 类型很少被使用,并且也不需要在这里讨论,因为至少从设备角度上看,它与 PCI 设备的 DMA 非常类似。一个 ISA 总线控制 DMA 的例子是 1542 SCSI 控制器,它的驱动程序在内核代码 drivers/scsi/ahal542.c 中。
至于这里所关心的本地 DMA,有三种实体涉及到 ISA 总线上的 DMA 数据传输:
- 8237 DMA 控制器(DMAC)
控制器保存了有关 DMA 传输的信息,比如方向、内存地址、传输数据量大小等。它还包含了一个跟踪传送状态的计数器。当控制器接收到一个 DMA 请求信号时,它将获得总线控制权并驱动信号线,这样设备就能读写数据了。 - 外围设备
当设备准备传送数据时,必须激活 DMA 请求信号。DMAC 负责管理实际的传输工作;当控制器选通设备后,硬件设备就可以顺序地读写总线上的数据。当传输结束时,设备通常会产生一个中断。 - 设备驱动程序
需要驱动程序完成的工作很少,它只是负责提供 DMA 控制器的方向、总线地址、传输量的大小等等。它还与外围设备通信、做好传输数据的准备,当 DMA 传输完毕后,响应中断。
在 PC 中使用的早期 DMA 控制器能够管理四个 “通道” ,每个通道都与一套 DMA 寄存器相关联。四个设备可以同时在控制器中保存它们的 DMA 信息。现在的 PC 包含了两个与 DMAC 等价的设备(注 6):第二控制器(主控制器)连接系统的处理器,第一控制
器(从控制器)与第二控制器的 0 通道相连(注7)。
注 6: 这种电路现在是主板芯片组的一部分,但在几年前,它们是两个独立的 8237 芯片。
注 7: 最初的 PC 只有两个控制器,第二个控制器出现在 286 平台中。但是,第二个控制器被连接为主控制器,这是因为它可以处理 16 位传输,而第一个控制器每次只能传输八位,因此仅用于向后兼容。
通道编号从 0 到 7: 通道 4 在内部用来将从属控制器级联到主控制器上、因此对 ISA 外围设备来说,通道 4 不可用。所以可用的通道是从属控制器(8 位通道)上的 0 ~ 3 和主控制器(16 位通道)上的 5 ~ 7。每次 DMA 传输的大小保存在控制器中,它是一个 16 位的数,表示传递所需要的总线周期数。因此最大传输大小对从属控制器来说是 64KB(因为它在一个周期内传输 8 位),对主控制器来说是 128KB(它使用 16 位传输)。
因为 DMA 控制器是系统资源、因此内核协助处理这一资源。内核使用 DMA 注册表为 DMA 通道提供了请求/释放机制,并且提供了一组函数在 DMA 控制器中配置通道信息。
(a)注册 DMA
读者应该对内核注册表很熱悉了,在 I/O 端口和中断号部分就接触过它。DMA 通道注册表与此非常类似。在包含了头文件 <asm/dma.h> 之后,用下面的函数可以获得和释放对 DMA 通道的所有权:
int request_dma(unsigned int channel, const char *name);
void free_dma(unsigned int channel);
channel 参数是 0 到 7 的整数,或者更精确地说,是一个小于 MAX_DMA_CHANNELS 的正整数。在 PC 中,MAX_DMA_CHANNELS 定义为与硬件匹配的 8。name 参数是标识设备的字符串,指定的名字出现在文件 /proc/dma 中,用户程序可以读取该文件。
request_dma 函数返回 0 表示执行成功,返回 -EINVAL 或者 - EBUSY 表示失败。前一个错误码表示所请求的通道超出了范围,后面一个错误码表示通道正为另外一个设备所占用。
建议读者像对待 I/O 端口和中断信号线一样地小心处理 DMA 通道;在 open 操作时请求通道,比在模块初始化函数中请求通道更好一些。延迟请求允许在驱动程序间共享信息;比如声卡和类似的 I/O 接口如果不在同一时间内使用 DMA 通道时,就可以共享同一个 DMA 通道。
这里还建议读者在请求中断信号线之后请求 DMA 通道,并且在中断之前释放通道。这是在请求两种资源时常用的请求顺序;按照这个顺序可以避免可能的死锁。请注意使用 DMA 的每个设备还需要一根 IRQ 线,否则它将无法通知数据已经传输完毕。
在典型应用中,open 函数的代码有类似以下的形式,这段代码使用了假想的 dad 模块。
dad 模块不支持共享 IRQ 信号线,它使用了一个快速中断处理例程。
int dad_open(struct inode *inode, struct file *filp) {
struct dad_device *my_device;
/* ... */
if ( (error = request_irq(my_device->irq, dad_interrupt,
SA_INTERRUPT, "dad", NULL)) )
return error; /* 或者实现阻塞的 open 操作 */
if ( (error = request_dma(my_device->dma, "dad")) ) {
free_irq(my_device->irq, NULL);
return error; /* 或者实现阻塞的 open 操作 */
}
/* ... */
return 0;
}
与 open 函数相匹配的 close 函数的实现如下:
void dad_close(struct inode *inode, struct file *filp) {
struct dad_device *my_device;
/* ... */
free_dma(my_device->dma);
free_irq(my_device->irq, NULL);
/* ... */
}
下面是一个安装了声卡系统的 /proc/dma 文件:
请注意默认的声卡驱动程序在系统启动时获得了DMA 通道,并且不会释放它。cascade 入口是一个占位符,表示通道 4 对驱动程序不可用,其原因在前面讲述过了。
(b)与 DMA 控制器通信
注册之后,驱动程序的主要任务包括为适当的操作配置 DMA 控制器。该任务不是可有可无的,但幸运的是,内核导出了驱动程序所需要的所有函数。
当 read 或者 write 函数被调用时,或者准备异步传输时,驱动程序都要对 DMA 控制器进行配置。根据驱动程序和其实现配置的策略,这一工作可以在调用 open 函数时,或者响应 ioctl 命令时被执行。这里的代码是被 read 和 write 设备方法调用的典型代码。
这一小节提供了 DMA 控制器内部的概貌,这样,读者就能理解这里所给出的代码。如果想要了解更多信息,可阅读 <asm/dma.h> 文件以及描述 PC 体系架构的硬件手册。特别是在这里不处理与 16 位数据传输相对的 8 位传输问题。如果要为 ISA 设备板卡编写驱动程序,应该仔细阅读该设备的硬件手册,以查找相关信息。
DMA 通道是一个可共享的资源,如果多于一个的处理器要同时对其进行编程,则会产生冲突。因此有一个叫作 dma_spin_lock 的自旋锁保护控制器。驱动程序不能直接处理该自旋锁;但是有两个函数能够对它进行操作:
unsigned long claim_dma_lock();
获得 DMA 自旋锁。该函数也阻塞本地处理器上的中断,因此返回值是描述先前中断状态的一系列标志。在重新开中断时,返回值必须传递给下面的函数以恢复中断状态。
void release_dma_lock(unsigned long flags);
返回 DMA 自旋锁并恢复先前的中断状态。
当使用下面描述的函数时,应该拥有自旋锁。但是在实际的 I/O 中却不应该拥有自旋锁。当拥有自旋锁时,驱动程序不能处于休眠状态。
必须被装入控制器的信息包含三个部分: RAM 的地址、必须被传输的原子项个数(以字节或字为单位)以及传输的方向。为达到这个目的,<asm/dma.h> 定义了下面的函数:
void set_dma_mode(unsigned int channel, char mode);
表明是从设备读入通道(DMA_MODE_READ)还是向设备写入数据(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 通道可以被禁用。应该在配置控制器前禁用通道,以防止不正确的操作(否则由于控制器是针对 8 位数据传输编程的,可能会引起冲突,因此前面所介绍的函数都不能原子地执行)。
void enable_dma(unsigned int channel);
该函数告诉控制器,DMA 通道中包含了合法的数据。
int get_dma_residue(unsigned int channel);
驱动程序有时需要知道 DMA 传输是否已经结束。该函数返回还未传输的字节数。如果传输成功,返回值是 0,当控制器正在工作时,返回值并不确定(但不会是 0)。如果使用两个 8 位输入操作来获得一个 16 位的余量,则返回值是不可预测的。
void clear_dma_ff(unsigned int channel)
该函数清除了 DMA 的触发器(flip-flop)。触发器用于控制对 16 位寄存器的访问。我们可以通过两个连续的 8 位操作访问该寄存器,而触发器被用来选择低字节(当其清零时)还是高字节(当其被置位)。当传输完 8 位后,触发器自动反转;程序员必须在访问 DMA 寄存器前清除触发器(将其设置为可知状态)。
使用这些函数,驱动程序可以实现如下函数,为 DMA 传输做准备:
int dad_dma_prepare(int channel, int mode, unsigned int buf, unsigned int count) {
unsigned long flags;
flags = claim_dma_lock();
disable_dma(channel);
clear_dma_ff(channel);
set_dma_mode(channel, mode);
set_dma_addr(channel, virt_to_bus(buf));
set_dma_count (channel, count);
enable_dma(channel);
release_dma_lock(flags);
return 0;
}
接着,下面的代码用来检查是否成功完成 DMA:
int dad_dma_isdone(int channel) {
int residue;
unsigned long flags = claim_dma_lock();
residue = get_dma_residue(channel);
release_dma_lock(flags);
return (residue == 0);
}
剩下需要做的唯一事情是配置设备板卡。这个与设备相关的任务通常包括读写一些 I/O 端口。不同设备实现的方法差异很大。比如一些设备希望程序员告诉硬件 DMA 缓冲区的大小,而有些时候驱动程序不得不读出固化在设备中的数据。为了完成对板卡的配置,硬件手册是程序员唯一的朋友。
5、快速参考
本章介绍了下面这些与内存操作相关的符号。
(1)介绍材料
#include <linux/mm.h>
#include <asm/page.h>
// 在这些头文件中定义了大部分与内存管理相关的函数和结构,并给出了原型。
void *__va(unsigned long physaddr);
unsigned long __pa(void *kaddr);
// 在内核逻辑地址和物理地址之间进行转换的宏。
PAGE_SIZE
PAGE_SHIFT
前者以字节为单位给出在特定硬件中每页的大小,后者表示为获得物理地址,页帧
号需要移动的位数。
struct page
// 在系统内存映射中,表示硬件页的结构。
struct page *virt_to_page(void *kaddr);
void *page_address(struct page *page);
struct page *pfn_to_page(int pfn);
// 负责在内核逻辑地址和与其相关的内存映射入口之间进行转换的宏。page_address
// 只能对已经显式映射的低端内存或者高端内存进行操作。pfn_to_page 将页帧号转
// 换为与其相关的 page 结构指针。
unsigned long kmap(struct page *page);
void kunmap(struct page *page);
// kmap 返回映射到指定页的内核虚拟地址,如果需要的话,还创建映射。kunmap 为
// 指定页删除映射。
#include <linux/highmem.h>
#include <asm/kmap_types.h>
void *kmap_atomic(struct page *page, enum km_type type);
void kunmap_atomic(void *addr, enun km_type type);
// kmap 的高性能版本;只有原子代码才能拥有映射结果。对驱动程序来说,type 可
// 以是 KM_USERO、KM_USER1、KM_IRQ0 或者是 KM_IRQ1。
struct vm_area_struct;
// 描述 VMA 的结构。
(2)mmap 的实现
int remap_pfn_range(struct vm_area_struct *vma, unsigned long virt_add,
unsigned long pfn, unsigned long size, pgprot_t prot);
int io_remap_page_range(struct vm_area_struct *vma, unsigned long virt_add,
unsigned long phys_add, unsigned long size, pgprot_t prot);
// mmap 的核心函数。它们映射了物理地址中从 pfn 表示的页号开始的 size 个字节,
// 到虚拟地址 virt_add 上。相关虚拟地址的保护位在 prot 中指定。如果目标地址
// 是在 I/O 地址空间的话,使用 io_remap_page_range 函数。
struct page *vmalloc_to_page(void *vmaddr);
// 将从 vmalloc 函数返回的内核虚拟地址转化为相对应的 page 结构指针。
(3)直接 I/O 的实现
int get_user_pages(struct task_struct *tsk, struct mm_struct *mm, unsigned
long start, int len, int write, int force, struct page **pages,
struct vm_area_struct **vmas);
// 该函数将用户空间缓冲区锁进内存,并返回相应的 page 结构指针。调用者必须拥
// 有 mm->mmap_sem。
SetPageDirty(struct page *page);
// 将指定内存页标记为 "已经改动过" ,并在释放该页前将其写入后备存储器的宏。
void page_cache_release(struct page *page);
// 从页缓存中释放指定的页。
int is_sync_kiocb(struct kiocb *iocb);
// 如果指定的 IOCB 需要同步执行,该宏返回非零值。
int aio_complete(struct kiocb *iocb, long res, long res2);
// 表明异步 I/O 操作完成的函数。
(4)直接内存访问
#include <asm/io.h>
unsigned long virt_to_bus(volatile void * address);
void * bus_to_virt(unsigned long address);
// 用来在内核、虚拟地址、总线地址之间进行转换的老版本函数。与外围设备通信时,
// 必须使用总线地址。
#include <linux/dma-mapping.h>
// 用来定义通用 DMA 函数的头文件。
int dma_set_mask(struct device *dev, u64 mask);
// 对于那些不能在全部 32 位寻址的外围设备来说,该函数通知内核可寻址的范围,如
// 果 DMA 可行则返回非零值。
void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t
*bus_addr, int flag)
void dma_free_coherent(struct device *dev, size_t size, void *cpuaddr,
dma_handle_t bus_addr);
// 为缓冲区分配和释放一致性 DMA 映射,该缓冲区的生命周期与驱动程序相同。
#include <linux/dmapool.h>
struct dma_pool *dma_pool_create(const char *name, struct device *dev,
size_t size, size_t align, size_t allocation);
void dma_pool_destroy(struct dma_pool *pool);
void *dma_pool_alloc(struct dma_pool *pool, int mem_flags, dma_addr_t
*handle);
void dma_pool_free(struct dma_pool *pool, void *vaddr, dma_addr_t handle);
// 用来创建、销毁和使用 DMA 池的函数,用来管理小型的 DMA 区域。
enum dma_data_direction;
DMA_TO_DEVICE
DMA_FROM_DEVICE
DMA_BIDIRECTIONAL
DMA_NONE
用来告诉流式映射函数的符号,表明数据传输的方向。
dma_addr_t_dma_map_single(struct device *dev, void *buffer, size_t size,
enum dma_data_direction direction);
void dma_unmap_single(struct device *dev, dma_addr_t bus_addr, size_t size,
enum dma_data_direction direction);
// 创建和销毁单个流式 DMA 映射。
void dma_sync_single_for_cpu(struct device *dev, dma_handle_t bus_addr,
size_t size, enum dma_data_direction direction);
void dma_sync_single_for_device(struct device *dev, dma_handle_t bus_addr,
size_t size, enum dma_data_direction direction);
// 同步拥有流式映射的缓冲区。在使用流式映射时,如果处理器要访问缓冲区,则必
// 须使用这些函数。
#include <asm/scatterlist.h>
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 用来获得总线地址和缓冲区长度,并将它们传递
// 给设备。
dma_map_sg(struct device *dev, struct scatterlist *list, int nents,
enum dma_data_direction direction);
dma_unmap_sg(struct device *dev, struct scatterlist *list, int nents,
enum dma_data_direction direction);
void dma_sync_sg_for_cpu(struct_device *dev, struct scatterlist *sg,
int nents, enum dma_data_direction direction);
void dma_sync_sg_for_device(struct device *dev, struct scatterlist *sg,
int nents, enum dma_data_direction direction);
// dma_map_sg 函数映射了分散 / 聚集操作,dma_unmap_sg 函数负责解除映射。当
// 映射处于活动状态而又必须访问缓冲区时,需要使用 dma_sync_sg_* 函数进行同
// 步。
/proc/dma
包含 DMA 控制器中被分配通道信息快照的文本文件。其中不会显示基于 PCI 的。
DMA,因为它们独立工作,不需要在 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 控制器使用触发器并通过两个 8 位操作传输一个 16 位的值。在把数据传递给
// 控制器前,必须清除触发器。