块设备驱动程序:中断

作者:Michael K. Johnson

块设备通常用于存放文件系统,可以是中断驱动的,也可以不是。中断驱动的块设备驱动程序比非中断驱动的块设备驱动程序更有可能更快、更有效率。

上个月,我给出了一个非常简单的块设备驱动程序的例子,它一次读取请求队列中的一个项目,依次满足每个请求,直到请求队列为空,然后返回。标准内核中的一些块设备驱动程序就是这样的。ramdisk 驱动程序就是一个明显的例子;它所做的仅仅比我展示的简单块设备驱动程序多一点。对于不经意的观察者来说不太明显的是,很少有 CD-ROM 驱动程序(实际上,在我写这篇文章时,一个也没有)是中断驱动的。通过阅读 drivers/block/blk.h,搜索字符串 DEVICE_INTR,并注意哪些设备使用了它,可以很容易地确定哪些驱动程序是中断驱动的。

我已经厌倦了输入“块设备驱动程序”,您可能也厌倦了阅读它。在本文的其余部分,我将使用“驱动程序”来表示“块设备驱动程序”,除非另有说明。

效率就是速度

中断驱动的驱动程序比非中断驱动的驱动程序更有可能更有效率,因为驱动程序必须花费更少的时间进行忙等待——在一个紧密的循环中等待设备准备就绪或完成执行命令。它们也可能更快,因为有可能安排同时满足多个请求,或者利用硬件的特性。

特别是,SCSI 磁盘驱动程序尝试向 SCSI 磁盘发送一个命令来读取多个扇区,并在每个块的数据从磁盘到达时满足每个请求。考虑到 SCSI 接口的设计方式,这是一个很大的优势;因为启动 SCSI 传输需要一些复杂的协商,所以协商 SCSI 传输需要相当长的时间,当 SCSI 驱动程序可以同时请求多个块时,它只需要协商一次传输,而不是每个块协商一次。

这种复杂的协商使 SCSI 成为一个强大的总线,除了磁盘驱动器之外,它还可以用于许多其他用途。这也使得在编写驱动程序时必须注意时序,以便利用这些可能性,而不会变得非常缓慢。在 Linux 中通用的高级 SCSI 驱动程序中添加某些优化之前,SCSI 性能根本没有达到其理论峰值。这些优化使大多数设备的吞吐量提高了 3 到 10 倍。

另一个例子是,Linux 中的原始软盘驱动程序非常慢。每次它想要一个块时,它都会从介质中读取它。软盘硬件非常慢,并且具有高延迟(它旋转缓慢,如果您想读取刚刚开始经过磁头的块,您必须等到磁盘完成一次完整旋转),这使其非常慢。

在 .12 版本左右,Lawrence Foard 添加了一个磁道缓冲区。由于读取软盘上的整个磁道只比等待您想要读取的块旋转过来并被读取(取决于磁盘类型和请求开始时磁盘的位置)大约多花费 30% 到 50% 的时间,因此当读取一个块时,读取该块所在的整个磁道是有意义的。

一旦请求的块被读取到磁道缓冲区中,它就会被复制到请求缓冲区中,等待它被读取的进程可以继续,并且磁道的其余部分被读取到属于软盘驱动程序的私有缓冲区区域中。下一个对来自该软盘的块的请求通常是针对紧挨着的下一个块,并且该块现在位于磁道缓冲区中,并立即准备好用于满足请求。这大约在 9 次中有 8 次是正确的(假设每磁道 9 个块或 18 个扇区)。这个单一的改变将软盘驱动程序从一个非常慢的驱动程序变成了一个非常快的驱动程序。

好了!够了!

因此,您确信中断驱动的驱动程序具有更大的潜力,并且您想知道如何将您上个月编写的非中断驱动的驱动程序变成中断驱动的驱动程序。我无法在一篇文章中为您提供您需要的所有信息,但我可以帮助您入门,并且在阅读完本文的其余部分后,您将更好地准备好阅读真实驱动程序的源代码,这是编写您自己的驱动程序的最佳准备。

来自非中断驱动的驱动程序的块请求的基本控制流程通常如下所示简化提醒

用户程序调用 read() read() (在内核中)要求缓冲区缓存获取并填充块 缓冲区缓存注意到它在缓存中没有数据 缓冲区缓存要求驱动程序用正确的数据填充一个块 驱动程序满足请求并返回 缓冲区缓存将新填充的块传递回 read() read() 将数据复制到用户程序并返回 用户程序继续 中断驱动的驱动程序的运行方式更像这样 简化提醒: 用户程序调用 read() read() (在内核中)要求缓冲区缓存获取并填充块 缓冲区缓存注意到它在缓存中没有数据 缓冲区缓存要求驱动程序用正确的数据填充一个块 驱动程序开始满足请求的过程并返回 缓冲区缓存等待块被读取,并在事件上休眠 一些其他进程运行一段时间,可能导致设备上的其他 I/O。物理设备具有可用的数据并中断驱动程序 驱动程序从设备读取数据并唤醒缓冲区缓存 缓冲区缓存将新填充的块传递回 read()read() 将数据复制到用户程序并返回 用户程序继续

请注意,read() 不是启动 I/O 的唯一方法。

关于这一点,需要注意的是,在唤醒等待请求完成的进程之前,几乎可以完成任何事情。实际上,其他请求可能会添加到队列中。乍一看,这似乎是一个麻烦的复杂情况,但实际上是使进行一些有价值的优化成为可能的重要因素之一。当我们开始优化驱动程序时,这一点将变得显而易见。但是,我们将从采用我们的非中断驱动的驱动程序并使其使用中断开始。

中断

我将采用我上个月开始开发的 foo 驱动程序,并为其添加中断服务。为假设的和模糊定义的设备编写好的、详细的代码是很困难的,所以(像往常一样)如果您想在阅读本文后更好地理解,请查看一些真实的设备。我建议使用 hd 和 floppy 设备;从 do_hd_request()do_fd_request() 例程开始,并跟踪逻辑。

static void do_foo_request(void) {
  if (foo_busy)
    /* another request is being processed;
       this one will automatically follow */
    return;
    foo_busy = 1;
    foo_initialize_io();
}
static void foo_initialize_io(void) {
  if (CURRENT->cmd == READ) {
    SET_INTR(foo_read_intr);
  } else {
    SET_INTR(foo_write_intr);
  }
    /* send hardware command to start io
       based on request; just a request to
       read if read and preparing data for
       entire write; write takes more code */
}
static void foo_read_intr(void) {
  int error=0;
  CLEAR_INTR;
    /* read data from device and put in
       CURRENT->buffer; set error=1 if error
       This is actually most of the function... */
    /* successful if no error */
    end_request(error?0:1);
    if (!CURRENT)
      /* allow new requests to be processed */
      foo_busy = 0;
    /* INIT_REQUEST will return if no requests */
    INIT_REQUEST;
    /* Now prepare to do I/O on next request */
    foo_initialize_io();
}
static void foo_write_intr(void) {
        int error=0;
  CLEAR_INTR;
  /* data has been written. error=1 if error */
  /* successful if no error */
  end_request(error?0:1);
  if (!CURRENT)
    /* allow new requests to be processed */ foo_busy = 0;
/* INIT_REQUEST will return if no requests */
  INIT_REQUEST;
  /* Now prepare to do I/O on next request */
  foo_initialize_io();
}

在 blk.h 中,我们需要在 FOO_MAJOR 部分添加几行

#elif (MAJOR_NR == FOO_MAJOR)
#define DEVICE_NAME "foobar"
#define DEVICE_REQUEST do_foo_request
#define DEVICE_INTR do_foo
#define DEVICE_NR(device) (MINOR(device) > 6)
#define DEVICE_ON(device)
#define DEVICE_OFF(device)
#endif

请注意,此示例中缺少许多函数;这是理解中断驱动的设备驱动程序的重要部分;如果您愿意,可以称之为“核心”。另请注意,显然,我没有尝试编译或运行这个假设的驱动程序。我可能犯了一些错误——您在编写自己的驱动程序时一定会犯自己的错误,并且在查找这个骨架代码中的错误将为查找您自己的驱动程序中的错误提供良好的练习,如果您有这种意愿。我建议您在编写自己的驱动程序时,从一些其他工作的驱动程序的代码开始,而不是从这个骨架代码开始。

结构

这里有必要解释一下一些新的想法。第一个新想法是(显然,我希望)使用中断例程来完成硬件服务和遍历请求列表的一部分。我为读取和写入使用了单独的例程;这从根本上来说不是必需的,但这通常确实有助于使代码更清晰,并使中断服务例程更小、更易于阅读。真实内核中任何类型的大多数(所有?)中断驱动的设备驱动程序都为读取和写入使用单独的例程。

我们还有一个单独的例程来完成大部分 I/O 设置,而不是在 request() 过程中完成。这是为了使中断例程可以在请求完成时调用单独的例程来设置下一个请求(如果需要)。同样,这是一个设计特性,它使大多数真实世界的驱动程序更小,更易于编写和调试。

上下文

必须注意的是,从中断调用的任何例程都与我到目前为止描述的所有其他例程不同。从中断调用的例程不在任何调用用户级程序的上下文中执行,并且不能写入用户级内存。它们只能写入内核内存。如果它们绝对需要分配内存,则必须使用 GFP_ATOMIC 优先级执行此操作。一般来说,最好是使用优先级为 GFP_KERNEL 的用户进程上下文例程分配的缓冲区,然后在设置中断例程之前将数据写入这些缓冲区。它们还可以唤醒在事件上休眠的进程,如 end_request() 所做的那样,但它们自己不能休眠。

头文件 blk.h 提供了一些这里使用的不错的宏。我不会记录所有这些宏(大多数都在 The Linux Kernel Hackers' Guide,即 KHG 中记录),但我将提及我使用的那些,它们用于管理中断。

与其手动设置中断,不如使用 SET_INTR() 宏,这更容易也更好。(如果您想知道如何手动设置它们,请阅读 blk.h 中 SET_INTR 的定义。)更容易是因为您只需执行 SET_INTR(interrupt_handling_function),更好是因为如果您设置自动超时(我们稍后将介绍),SET_INTR() 会自动设置它们。

然后,当中断得到服务后,中断服务例程(上面的 foo_read_intr()foo_write_intr())清除中断,这样虚假中断就不会传递到认为它应该读取或写入当前请求的过程。提供一个中断例程来处理虚假中断是可能的——只需要稍微多做一点工作。如果您有兴趣,请阅读 hd 驱动程序。

自动超时

在 blk.h 中,提供了一种机制,用于在硬件没有响应时超时。如果 foo 设备在 5 秒后仍未响应请求,则显然存在问题。我们将再次更新 blk.h

#elif (MAJOR_NR == FOO_MAJOR)
#define DEVICE_NAME "foobar"
#define DEVICE_REQUEST do_foo_request
#define DEVICE_INTR do_foo
#define DEVICE_TIMEOUT FOO_TIMER
#define TIMEOUT_VALUE 500
/* 500 == 5 seconds */
#define DEVICE_NR(device) (MINOR(device) > 6)
#define DEVICE_ON(device)
#define DEVICE_OFF(device)
#endif

这就是使用 SET_INTR()CLEAR_INTR 变得有用的地方。只需定义 DEVICE_TIMEOUTSET_INTR 就会被更改为自动设置一个“看门狗定时器”,如果 foo 设备在 5 秒后仍未响应,则该定时器会触发,提供 SET_TIMER 手动设置看门狗定时器,并提供 CLEAR_TIMER 宏来关闭看门狗定时器。唯一需要做的其他三件事是

  1. linux/timer.h 添加一个定时器 FOO_TIMER。这必须是一个尚未使用的 #define 值,并且必须小于 32(只有 32 个静态定时器)。

  2. 在启动时调用以检测和初始化硬件的 foo_init() 函数中,必须添加一行

    timer_table[FOO_TIMER].fn = foo_times_out;
    
  3. 并且(正如您可能从步骤 2 中猜到的)必须编写一个函数 foo_times_out() 来尝试重新启动请求,或以其他方式处理超时情况。

foo_times_out() 函数可能应该重置设备,尝试在适当的情况下重新启动请求,并且应该使用 CURRENT->errors 变量来跟踪该请求上发生了多少错误。它还应该检查是否发生了太多错误,如果是,则调用 end_request(0) 并继续处理下一个请求。

所需的具体步骤取决于硬件设备的行为方式,但 hd 和 floppy 驱动程序都提供了此功能,通过比较和对比它们,您应该能够确定如何为您的设备编写这样的函数。这是一个示例,大致基于 hd.c 中的 hd_times_out() 函数

static void hd_times_out(void)
{
   unsigned int dev;
   SET_INTR(NULL);
   if (!CURRENT)
      /* completely spurious interrupt-
         pretend it didn't happen. */
      return;
   dev = DEVICE_NR(CURRENT->dev);
#ifdef DEBUG
   printk("foo%c: timeout\n", dev+'a');
#endif
   if (++CURRENT->errors >= FOO_MAX_ERRORS) {
#ifdef DEBUG
      printk("foo%c: too many errors\n", dev+'a');
#endif
      /* Tell buffer cache: couldn't fulfill request */
      end_request(0);
      INIT_REQUEST;
   }
   /* Now try the request again */
   foo_initialize_io();
}

SET_INTR(NULL) 阻止此函数被递归调用。接下来的两行忽略在没有发出请求时发生的中断。然后我们检查是否有过多的错误,如果在这个请求上发生了太多错误,我们就会中止它并继续处理下一个请求(如果有);如果没有请求,我们就返回。(请记住,如果没有剩余请求,INIT_REQUEST 宏会导致返回。)

最后,我们要么重试当前请求,要么放弃并继续处理下一个请求,在任何一种情况下,我们都需要重新启动请求。

如果 foo 设备维护一些状态并需要重置,我们可以在调用 foo_initialize_io() 之前立即重置 foo 设备。同样,这取决于您正在为其编写驱动程序的设备的详细信息。

敬请期待...

下个月,我们将讨论优化块设备驱动程序。

其他资源

Michael K. JohnsonLinux Journal 的编辑,也是 Linux Kernel Hackers' Guide (KHG) 的作者。他正在使用这个专栏来开发和扩展 KHG。

加载 Disqus 评论