Linux 设备驱动程序(三)(中)

简介: Linux 设备驱动程序(三)

Linux 设备驱动程序(三)(上):https://developer.aliyun.com/article/1597476

(4)使用 nopage 映射内存

    虽然 remap_page_range 在许多情况下工作良好,但并不能适应大多数的情况。有时驱动程序对 mmap 的实现必须具有更好的灵活性。在这种情形下,提倡使用 VMAnopage 方法实现内存映射。

   当应用程序要改变一个映射区域所绑定的地址时,会使用 mremap 系统调用,此时是使用 nopage 映射的最好的时机。当它发生时,内核并不直接告诉驱动动程序什么时候 mremap 改变了映射 VMA。如果 VMA 的尺寸变小了,内核将会刷新不必要的页,而不通知驱动程序。相反,如果 VMA 尺寸变大了,当调用 nopage 时为新页进行设置时,驱动程序最终会发现这个情况,因此没有必要做额外的通知工作。如果要支持 mremap 系统调用,就必须实现 nopage 函数。这里提供了设备中 nopage 的一个简单实现。


пopage 函数具有以下原型:

struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);
  • 1

    当用户要访问 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 被调用,以更新模块的使用计数和设备的活动映射计数。

openclose 函数只是简单地跟踪这些计数,其定义如下:

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 的实现中,nopageaddress 参数用来计算设备里的偏移量、然后使用该编移量在 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 的函数,它定义在  中,并有以下原型:

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(&current ->mm->mmap_sem);
  result = get_user_pages(current, current->mm, -..);
  up_read(&current->mm->mmap_sem);

   返回的值是实际被映射的页数,它可能会比请求的数量少(但是大于 0)。


   如果调用成功,调用者就会拥有一个指向用户空间缓冲区的页数组,它将被锁在内存中。为了能直接操作缓冲区,内核空间的代码必须用 kmap 或者 kmap_atomic 函数将每个 page 结构指针转换成内核虚拟地址。使用直接 I/O 的设备通常使用 DMA 操作,因此驱动程序要从 page 结构指针数组中创建一个分散/聚集链表。我们将在 “分散/聚集映射” 一节中对其进行详细讲述。


   一旦直接 I/O 操作完成,就必须释放用户内存页。在释放前,如果改变了这些页中的内容,则必须通知内核,否则内核会认为这些页是 “干净” 的,也就是说,内核会认为它们与交换设备中的拷贝是匹配的,因此,无需回存就能释放它们。因此,如果改变了页(响应用户空间的读取请求),则必须使用下面的函数标记出每个被改变的页:

void SetPageDirty(struct page *page);

    这个宏定义在 中。执行该操作的大多数代码首先要检查页,以确保该页不在内存映射的保留区内,因为这个区的页是不会被交换出去的,因此有如下代码:

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 的驱动程序应该包含  。 有三个用于实现异步 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_readaio_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 内核通过输出在  中定义的一些函数、提供了可移植的方案。我们不推荐使用这些函数,因为只有在那些拥有非常简单 I/O 的体系架构中,它们才能工作正常;虽然如此,在阅读内核代码时还是会遇到它们。

unsigned long virt_to_bus(volatile void *address);
void *bus_to_virt(unsigned long address);

    这些函数在内核逻辑地址和总线地址间执行了简单的转换。但对于必须使用 I/O 内存管理单元或者必须使用回弹缓冲区的情况下,它们将不能工作。执行这些转换的正确方法是使用通用 DMA 层,因此现在来讨论这个主题。

Linux 设备驱动程序(三)(下):https://developer.aliyun.com/article/1597516

目录
相关文章
|
2月前
|
Linux 程序员 编译器
Linux内核驱动程序接口 【ChatGPT】
Linux内核驱动程序接口 【ChatGPT】
|
3月前
|
Java Linux API
Linux设备驱动开发详解2
Linux设备驱动开发详解
44 6
|
3月前
|
消息中间件 算法 Unix
Linux设备驱动开发详解1
Linux设备驱动开发详解
49 5
|
3月前
|
存储 缓存 Unix
Linux 设备驱动程序(三)(上)
Linux 设备驱动程序(三)
38 3
|
3月前
|
缓存 安全 Linux
Linux 设备驱动程序(一)((下)
Linux 设备驱动程序(一)
33 3
|
3月前
|
安全 数据管理 Linux
Linux 设备驱动程序(一)(中)
Linux 设备驱动程序(一)
28 2
|
3月前
|
Linux
Linux 设备驱动程序(四)
Linux 设备驱动程序(四)
23 1
|
3月前
|
存储 前端开发 大数据
Linux 设备驱动程序(二)(中)
Linux 设备驱动程序(二)
29 1
|
3月前
|
缓存 安全 Linux
Linux 设备驱动程序(二)(上)
Linux 设备驱动程序(二)
40 1
|
3月前
|
存储 缓存 安全
Linux 设备驱动程序(三)(下)
Linux 设备驱动程序(三)
32 0