设备IO之一(mmap、直接IO以及异步IO)

现在,在linux中经常可以看到在用户空间编写的驱动程序,比如X服务器,一些厂商的私有驱动等等,这就意味着用户空间取得了对硬件的访问能力,这通常是通过mmap将设备内存映射到了用户进程空间,从而使得用户可以通过读写这些内存来获取对硬件的访问能力。
内核一般会对I/O操作进行缓冲以获取更好的性能,但是也提供了直接I/O以及异步I/O的能力。
在和硬件进行数据交互时,有的硬件支持DMA,DMA可以降低处理器负担;有的硬件的内存空间无法直接读写,需要使用特殊的指令。

一、虚拟内存区

当使用mmap时,需要将内核的地址块映射到用户的地址空间,这就涉及到一个很关键的数据结构VMA,一个VMA表示在进程的虚拟地址空间中的同一类区域:拥有同样的权限并且被同样的对象(一个文件或者交换空间)所备份的一个连续的虚拟地址范围。
可以通过查看/proc/${pid}/maps来查看进程的内存区域,其格式为:
start-end perm offset major:minor inode image
下边是一个init进程的maps片段:
00000000-00000000 r-xp 00000000 01:00 149505                             /sbin/init.sysvinit
00000000-00000000 rw-p 00000000 01:00 149505                             /sbin/init.sysvinit
00000000-00000000 ---p 00000000 00:00 0 
00000000-00000000 rw-p 00000000 00:00 0                                  [heap]
00000000-00000000 rw-p 00000000 00:00 0 
00000000-00000000 rw-p 00000000 00:00 0                                  [stack]
其中各部分的含义如下:
  • start end:该片内存区的开始和结束虚拟地址.
  • perm:内存区的读,写和执行许可的位掩码,perm的最后一个字符要么是p表示是私有的,要么是s表示是共享的。
  • offset:内存区在映射文件中的偏移量(内存区是被映射到了一个文件中)
  • major minor:拥有映射文件的设备的主次编号。对设备映射来说,主次设备号指的是磁盘中代表该设备的磁盘文件的主次设备号,而不是内核分配给该真实设备的主次设备号。
  • inode:映射文件的inode 号.
  • image:映射文件名
VMA对应的数据结构为vm_area_struct,其中包含了如下几个函数指针:

1.1 open

其原型为:void (*open)(struct vm_area_struct *vma)

内核会在产生对一个VMA的新的引用时,调用它,以使得实现该VMA的内核部件有机会做自己的初始化。不过在创建新的VMA时不会调用它,而是会调用内核部件提供的mmap函数。

1.2 close

其原型为:void (*close)(struct vm_area_struct *vma)

内存区被销毁时被调用,VMA没有引用计数,因而一个进程只能打开和关闭一个VMA区域一次。

1.3 nopage

 其原型为:struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);

当一个进程试图存取一个合法的VMA页,但是该页当前不在内存中时,则内核会为该VMA调用它的nopage函数。该函数返回指向物理页的page指针。如果该VMA没有定义自己的nopage接口,则内核会为它分配一个空页。

二、mmap

mmap使得可以将设备内存映射到用户空间,从而使得用户程序获得访问硬件的能力,mmap的动作需要由内核中的驱动来实现。在使用mmap映射后,用户程序对给定范围的内存的读写就变成了对设备内存的读写,也就是在访问设备了。
并不是所有的硬件都支持mmap,比如串口设备就不支持mmap。mmap存在一个限制,就是它映射的粒度为PAGE_SIZE,因而内核只能在页表一级对虚拟内存地址进行管理,因而使用mmap将设备内存映射到用户进程的虚拟内存空间时必须以页为单位,并且内核被映射的物理地址也必须起始于PAGE_SIZE的整数倍,即被映射的物理地址的起始地址必须对齐到PAGE_SIZE上。
大多数PCI外设将其控制寄存器映射到了内存地址中,对于这类设备只需要通过将这种内存映射到用户空间就可以获得对硬件的控制能力,相对于通过常规的ioctl方法,这是非常诱人的。
mmap是file_operations结构的一部分。由于在*nix中,一切皆文件,因而内核部件很容易借助该结构来实现自己的mmap。
用户空间程序通过系统调用:
mmap (caddr_t addr, size_t len, int prot, int flags, int fd, off_t offset)
来调用fd上的mmap函数。当使用mmap系统调用时,内核会在调用fd上的mmap之前做一些准备工作。fd上的mmap函数在内核中的原型如下:
int (*mmap) (struct file *filp, struct vm_area_struct *vma);
filp为映射文件,vma包含了访问设备的虚拟地址的信息。fd上的mmap需要完成的是为vma包含的虚拟地址范围建立合适的页表,并且初始化vma中的函数指针,以便后续可以使用适当的函数。

2.1 建立页表

建立页表是mmap需要完成的最重要的工作。有两种方法可以用来建立页表:
  1. 调用remap_pfn_range 函数一次全部建成
  2. 通过nopage函数一次一页的建立

2.1.1 使用 remap_pfn_range

remap_pfn_range 和 io_remap_page_range负责为一段物理地址建立新的页表,它们的原型如下:
int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr,
   unsigned long pfn, unsigned long size, pgprot_t prot);
它将从pfn开始的长度为size大小(size会向上对齐到页PAGE_SIZE)的地址空间映射到vma中从addr开始的位置。因为size可变,因而它可以用于映射整个区域,也可以用于仅映射其中的一部分。
  • vma: 物理地址要映射到的用户VMA
  • addr: 物理地址映射到用户VMA地址空间时的用户空间起始地址(通常为vam->start),但是也可以不为vam->start。
  • pfn: 映射的内核物理地址
  • size: 区域大小
  • prot: 该映射中的页面的保护模式
int ioremap_page_range(unsigned long addr, unsigned long end,
      phys_addr_t phys_addr, pgprot_t prot);
它将从phys_addr开始的大小为(end-addr+1并且向上对齐到PAGE_SIZE)的I/O内存映射到从addr开始的虚拟地址。
  • addr:虚拟地址起始值
  • end:虚拟地址结束值
  • phys_addr:物理地址起始值
  • prot:该区域的保护模式
二者的区别在于,当要映射到用户空间的地址是真正的RAM时,使用remap_pfn_range,如果要映射到用户空间的地址是I/O内存的时候用ioremap_page_range。需要注意的是如果是I/O内存,则内核通常不会对它进行缓存。

2.1.2 使用 nopage 映射内存

一次性建立好整个页表在大多数情况下是不错的选择,但是在有的情况下时候nopage更合适。因为它更灵活。使用nopage的两种典型场景如下:
  1. 应用程序调用mremap系统调用改变映射区域。当这个调用导致VMA区域变小时,内核不会通知驱动,而是会将不必要的页刷新掉;但是当这个调用导致VMA变大时,内核就会调用nopage方法来申请新页。因此从这个意义上来说如果要支持该系统调用,就必须实现nopage方法。
  2. 当用户访问VMA中的页,但是该页又不在内存中时,nopage函数会被调用。
nopage函数要返回所获得的page的指针,并增加它的引用计数表明有 人在使用该页。
如果nopage的参数type不为NULL,则它可用于返回错误不同于返回值所返回的,一般为VM_FAULT_MINOR。由于nopage需要返回指向所获得内存的page指针,但是PCI的存储空间是没有page指针的,因而nopage方法不适用于PCI地址空间。
当调用成功时nopage地返回一个指向 struct page 的指针。否则nopage将返回一个错误。如果 nopage 函数为NULL,则负责处理页错误的内核代码将零内存页映射到失效的虚拟地址上。零内存页是一个特殊的页,读它将返回0,写它将修改进程的私有拷贝。

2.2 添加VMA 的操作

mmap的另一个重要的动作是更新VMA的函数指针。也就是nopage,open,close等函数指针。

2.3 重新映射 RAM

remap_pfn_range只能用于保留页以及在物理内存顶之上的物理地址,实际上就是不被内存管理系统管理的内存。也就是说常规的内存是不能用它来映射的,包括用__get_free_page获得的内存。因此如果想用用它来映射一片内存,就要在系统启动时将这部分内存给预留出来(因为经过remap_pfn_range映射后,进程就可以发起对它的直接读写,而由内内核内存管理系统管理的内存可能会被分配做其它用途,这就存在潜在的冲突)。
虽然无法使用remap_pfn_range来映射RAM到用户空间,但是有变通的方法,可以使用VMA的nopage方法来将RAM映射到用户地址空间,也就是一次一页的向用户空间映射内存。如果一个内核部件想要将RAM地址映射到用户地址空间,就要实现nopage函数接口,并且在该函数中一次一页的返回取到的页。

需要注意的是使用nopage函数来返回page时,需要的是真正的page,因此需要找到真正的page指针,对于常规内核内存可以通过virt_to_page来获取其page,但是对于vmalloc返回的地址,则要通过vmalloc_to_page来获取page。

三、直接I/O

大部分I/O操作都要经过内核缓冲,这是为了提高I/O的效率,但是有的场景中缓冲并不一定能得到很好的性能。因此内核也为不想使用缓冲的场景提供了API,如果一个外设的驱动不想使用内核的缓冲机制,可以使用如下API:
long get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
unsigned long start, unsigned long nr_pages, int write,
int force, struct page **pages, struct vm_area_struct **vmas)
该函数将用户进程的页映射到内核的地址空间,然后内核中的代码就可以直接访问这些页了。其参数的含义:
  • tsk:一个指向进行 I/O 的任务的指针,作用在于告知内核谁该为页错误负责,如果不需要记录则可以设置为NULL
  • mm:一个内存管理结构的指针,描述被映射的地址空间
  • start:用户空间起始地址
  • nr_pages页数
  • write:调用者是否要往这部分页中写入数据whether pages will be written to by the caller
  • force:如果设置了它,则即便使用的是只读的用户映射进程映射区,也会强制进行写入,通常这不是所想要的效果
  • pages:指向获得的page的指针数组,该数组大小应该至少为nr_pages,或者如果不想获得这些信息就设置为NULL。
  • vmas:指向与每个page相对应的vma区域的指针数组。如果调用者不想要这些信息,则可以为NULL。
由于该函数需要建立页表进行映射,因而它还是比较耗时的,而且直接I/O忽略了内核的缓冲,由于缺少了内核缓冲,因而使用直接I/O的往往也会同时使用异步I/O,否则直接IO的使用者为了知道它的操作什么时候完成了,它什么时候可以重用它向内核提交数据的缓存等信息就必须等待IO完成,这显然在大部分情况下都不是使用者所期望的(因为IO本身是比较耗时的,在IO上等待将浪费宝贵的CPU时间)。
事实上对于块设备驱动以及网络驱动,相关框架的代码已经在合适的时候使用了直接I/O,因而驱动编写者基本不需要考试直接I/O,而对于字符驱动来说,显然直接I/O并没有什么吸引力(字符流不是以PAGE为单位的)。
特别强调的是该函数必须在mmap_sem被持有的情况下调用。
在直接 I/O 操作完成后,这些页必须被释放,另外如果这些页被修改了,则必须调用SetPageDirty标记页为脏的,否则内核会假设页的内容没有发生变化,因而不会将它的内容同步到它对应的设备或者文件中,这通常是错误的。
释放页通过函数page_cache_release完成。

四、异步I/O(AIO)

除了直接I/O外,内核还提供了另外一种I/O特性,异步I/O。异步 I/O 允许用户程序来发起一个或多个I/O操作而不必等待操作的完成,内核提供了一套API来支持用户程序发起AIO。

4.1 用户接口

内核提供给用户空间的API及其接口如下:
  • io_setup:为当前进程创建一个异步I/O上下文,它有一个参数可以指定该上下文最多可以提交多少个异步I/O。
  • io_submit:提交一个或多个异步I/O请求
  • io_getevents:获取已经提交的异步I/O请求的完成状态
  • io_cancel:取消提交的异步I/O请求
  • io_destroy:清除为本进程创建的异步I/O上下文
这几个接口定义在aio.h和aio.c中,都是系统调用。这些API的含义很明显,需要使用异步I/O的应用需要首先创建一个异步I/O上下文,然后在该上下文上提交异步I/O请求,一个进程可以创建多个异步I/O上下文,这些上下文会保存在task_struct->mm->ioctx_list中。随后用户进程即可在它所提交的上下文上提交异步/IO请求。如果想获取异步I/O的状态可以用io_getevents来获取,进程也可以选择用io_cancel来取消一个已经提交的异步I/O。在使用完后,可以用io_destroy来清除异步I/O上下文。

4.2 内核实现

4.2.1 异步I/O上下文

内核使用kioctx来表示异步I/O上下文,用户创建异步I/O上下文时的信息都保存在这里,在成功创建一个异步I/O上下文后,内核会返回一个id个用户进程,随后用户进程用该id即可使用这个上下文。在创建异步I/O上下文时,内核会创建一个AIO ring。
AIO ring对应用户态进程地址空间的一段内存缓存区,用户态进程可以访问,内核也可访问。内核的做法是调用get_user_pages获得用户页。AIO ring是一个环形缓冲区,内核用它来报告异步IO的完成情况,用户态进程也可以直接检查异步IO完成情况,从而避免系统调用的开销。

4.2.2 异步I/O请求

内核使用kiocb来表示一个异步I/O请求,而用户进程使用数据结构iocb来表示一个异步I/O请求,内核会完成二者之间的转换。
在io_submit时,用户可以一次提交多个异步I/O请求,内核会根据请求的模式依次处理每个异步I/O请求(可以设置),这里最终会调用到文件操作指针file_operations里的异步IO操作函数,如果函数返回了非EIOCBQUEUED的值,则AIO框架和会直接调用aio_complete并返回,否则就是一个真正的异步I/O,file_operations里的返回EIOCBQUEUED的部件要负责在处理完该I/O请求后调用aio_complete来最终完成该异步I/O。
从内核实现可以看出对于支持异步I/O的部件来说,它所需要做的就是正确的实现file_operations中的异步I/O接口(可以通过workqueue等机制来实现自己的异步I/O),并且在完成异步I/O后调用aio_complete即可。

4.2.3 收集异步I/O状态

当用户进程通过系统调用收集异步I/O状态时,内核会通过read_events来响应该请求,内核会在相应的上下文的等待队列上等待,该等待由aio_complete唤醒或者被中断打断或者超时结束。

4.2.4 取消一个异步I/O请求

如果想要支持取消异步I/O请求,则I/O操作的实现者需要调用kiocb_set_cancel_fn设置其取消函数,这样当用户发起取消I/O操作的请求时,AIO框架就会调用该取消函数来取消指定的异步I/O请求。

4.2.5 清除异步I/O上下文

清除一个异步I/O上下文时,AIO框架会为通过kill_ioctx主动唤醒在该上下文等待的所有进程,然后释放相关数据结构。
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值