from: http://www.cstc.net.cn/index.php?item=docs&sub=view&id=285

原著:Dennis M. Ritchie

翻译:寒蝉退士



译者声明:译者对译文不做任何担保,译者对译文不拥有任何权利并且不负担任何责任和义务。



本文给出对 I/O 系统的是如何工作的概括描述。它着眼于为驱动程序作者提供指导,并且更加面向于描述设备驱动程序的环境和本质,而不是处理普通文件的文件系统的那部分实现。 假定读者对在“UNIX 分时系统”一文中讨论的整体结构有良好的了解。在“UNIX 实现”中有更详细的讨论,本文重申了其中的这一部分,但更加详细一些。本文基本上就是源代码的一份注释,所以最适合与系统代码联合使用。



设备类别 有两类设备: 块设备和字符设备。块接口适用于如磁盘、磁带、和 DECtape 这样的设备,它们工作或可以工作在 512 字节块的寻址方式下。把普通的磁带勉强归入此类的原因是:尽管只能在磁带的末尾处写块,但通过使用前向和后向间隔(space:越过几个文件或记录)可以读任意的块。块设备潜在的至少包含一个可挂装的文件系统。块设备的接口是高度结构化的;这些设备的驱动程序共享一个缓冲池和大量例程。 字符类型设备有更加直接的接口,但更多的工作必须由驱动程序自己来做。 两种类型的设备都用一个主和一个次设备号来命名。这些编号通常存储为一个整数,次设备号在低端 8 位中而主设备号在邻近的高 8 位中;可利用宏 major 和 minor 来访问这些编号。主设备号选择哪个驱动程序处理这个设备;次设备号不由系统的其余部分使用而是在适当的时候传递给驱动程序。典型的次设备号选择连结到一个给定控制器上的一个子设备,或者是类似的一些硬件接口。 块和字符设备把主设备号作为在各自独立的表中的索引,它们都开始于 0 故此重叠。



I/O 概述 open 和 creat 系统调用的目的是在三个独立的系统表中设置条目。其中第一个是 u_ofile 表,它存储在系统的每进程一个的数据区 u 中。 这个表用 open 或 creat 返回的文件描述符作为索引,并在 read、write、或在打开的文件上的其他操作期间被访问。其中的条目只包含到 file 表的相应条目的一个指针,file 表是每系统一个的数据库。每个 open 或 creat 的实例在 file 表中都有一个条目。这个表每系统一个的原因是,在打开文件之后 fork 出来的多个进程之间共享打开文件的同一个实例。一个 file 表条目包含一个标志,它指示打开文件是用来读或是用来读写还是用作一个管道的;和一个计数,用来决定何时使用这个条目的所有进程都已经终止或关闭了这个文件(这样就可以放弃这个条目)。还有一个 32 位文件偏移量,用来指示在文件中何处发生下一次读写。最后,还有到 inode 表中的条目的一个指针,inode 表包含这个文件的 i-node 的一个复件。 可以把特定的打开文件指派为“复用”文件,对这样的通道可以应用许多其他标志。在这种情况下,不再使用偏移量,转而使用到相关的多路通道表的一个指针。本文不讨论多路通道。 在 file 表中的一个条目严格的对应着 open 或 creat 的一个实例;如果同一个文件已经被多次打开,则它在这个表中有多个条目。但是,对于一个给定的文件在 inode 表中最多只有一个条目。还有,一个文件进入 inode 表的原因不止是被打开了,可能的原因还有:它是一些进程的当前目录,或者它是包含一个当前挂装的文件系统的一个特殊文件。 在 inode 表中的一个条目与存储在磁盘上相应的 i-node 有些区别,对表中的条目扩充了一个标志字,包含关于这个条目的信息,一个计数,用来决定何时允许它消失,和这个条目对应的 i-node 的设备及 i 编号。还有,一些块编号给出文件的寻址信息,这个文件是从磁盘上使用的 3 字节压缩格式展开成完全的长数量的。 在处理对特殊文件的 open 或 creat 调用期间,为了顾及所要求的特殊处理(比如回绕一个磁带,开启调制解调器的数据中断就绪线路,等等),系统总是调用这个设备的 open 例程。但是,只在最后一个进程关闭一个文件,也就是在收回这个 i-node 表条目的时候才调用 close 例程。因此让设备维护或依赖一个它自己的用户计数是不可行的,但实现在关闭之前不能被重新打开的一个独占设备是完全可能的。 当 read 或 write 发生时,使用这个用户实际参数和这个 file 文件表条目来设置变量 u.u_base、 u.u_count、和 u.u_offset,它们分别包含目标区域的(用户)地址,要传送的字节计数,和在文件中的当前位置。如果这个文件引用一个字符类型的特殊文件,则调用适当的读或写例程;如下面讨论的那样,它负责正确的传送数据并更改计数和当前位置。否则,使用当前位置来计算在这个文件的一个逻辑块编号。如果这个文件是一个普通文件,则必须把这个逻辑块号(可能使用了间接块)映射到一个物理块编号;而一个块设备特殊文件不需要映射。这个映射由 bmap 例程完成。如下面讨论的那样,在所有情况下都使用结果的物理块编号来读或写适当的设备。



字符设备驱动程序  cdevsw 表指定字符设备提供的接口例程。每个设备提供 5 个例程: open、close、read、write、和特殊功能(用来实现 ioctl 系统调用)。可以缺少其中任何一个。如果应该忽略在这个例程上的一个调用,(比如,在不需要设置的一个非独占设备上的 open) cdevsw 条目可以给出为 nulldev; 如果它应该被考虑为一个错误,(比如在一个只读设备上写)则使用 nodev。对于终端,cdevsw 结构总是包含到与这个终端相关联的 tty 结构的一个指针。 在每次打开文件的时候,以完整的设备编号作为参数来调用 open 例程。第二个参数是一个标志,只在要在这个设备上写的时候它才是非零。 只在最后一次关闭文件的的时候调用 close 例程,此时在其中打开了这个文件的最后一个进程关闭了这个文件。这意味着驱动程序不可能维护它的用户的自己的计数。第一个参数是设备编号;第二个参数是一个标志,如果在进行最终的 close 的进程中为了写而打开了这个文件,则这个标志是非零。 在调用 write 的时候,提供设备编号作为参数。设置每用户变量 u.u_count 为由用户指出的字符的数目;对于字符设备,这个数最初可以是 0。u.u_base 是用户提供的地址,以它为开始接受字符。系统可以内部调用这个例程,所以提供了用做指示的标志 u.u_segflg,如果是 on,则 u.u_base 引用系统地址空间而不是用户地址空间。 write 例程将从这个用户的缓冲区向这个设备复制直到 u.u_count 个字符,为每个传送的字符减少 u.u_count,write 例程一次操作一个字符,使用例程 cpass( ) 从用户的缓冲区获取字符。连续的调用它来返回要写的字符,直到 u.u_count 变成 0 或发生了一个错误,发生错误时它返回 -1。cpass 负责查询(takes care of interrogating) u.u_segflg 并更新 u.u_count。 Write 例程还可以使用例程 iomove(buffer, offset, count, flag) 来把可能大量的字符传送到一个内部缓冲区,在必须移动许多字符的时候会更快。iomove 把直到 count 个字符传送到 buffer 的从这个缓冲区的开始处偏移 offset 字节的地方;在写的情况下 flag 应当是 B_WRITE (就是 0)。警告: 调用者负责确保这个计数不过长并且是非零。出于效率而做一个提示,如果 buffer+offset、count 或 u.u_base 中的任何一个是奇数,则 iomove 会非常慢。 调用设备的 read 例程的情况类似于 write,但是要保证 u.u_count 是非零。要向用户返回字符,可使用例程 passc(c);它担负与 cpass 类似的工作,并且对由 u.u_count 指定的最后一个字符向用户返回 -1;此前返回 0。对于 write 也可以使用 iomove;标志应当是 B_READ,但提出的警告相同。 “特殊功能”例程由 stty 和 gtty 系统调用以如下方式来调用: (*p) (dev, v),这里的 p 是到这个设备的例程的一个指针,dev 是设备编号,而 v 是一个向量。在 gtty 的情况下,假定这个设备把最多 3 个字的状态信息放置到这个向量中;把它返回给调用者。在 stty 的情况下,v 是 0;设备将从数组 u.u_arg[0...2] 取得最多 3 个字的控制信息。 最后,每个设备都应该有适当的中断时间(interrupt-time)例程。在中断发生的时候,进入在这个设备的中断例程上的一个 C 相容调用。中断捕获机制制作中断的陷入向量中的“新 PS”字的低端四位,中断处理程序将获得它。处理多个类似的设备的驱动程序可以方便的用它来编码从设备号。在中断处理已经被处理了之后,把中断处理程序的返回作为中断自身的返回。 字符设备驱动程序可以获得一些有用的例程。例如,多数这种处理程序需要在内部接口中有一个地方用来在它们的“顶半部分”(读/写)和“底半部分”(中断)例程之间缓冲字符。对于相对低数据率的设备,最好的机制是 getc 和 putc 例程维护的字符队列。队列头部有如下结构 struct { int c_cc; /* 字符记数 */ char *c_cf; /* 第一个字符 */ char *c_cl; /* 最后一个字符 */ } queue; 通过 putc(c, &queue) 把一个字符一个字符放置在队列的尾部,这里的 c 是一个字符而 queue 是这个队列的头部。如果没有空间放置这个字符则例程返回 -1,否则返回 0。通过 getc(&queue) 可以取回这个队列上的第一个字符,它返回(非负的)字符,或者在队列为空时返回 -1。 注意给队列中字符的空间由系统中所有的设备共享,并且在标准系统中只能获得 600 个字符槽。所以设备处理器,特别是写例程,必须小心避免吞下超额数目的字符。 对于设备处理程序可获得的其他主要帮助是睡眠-唤醒机制。调用 sleep(event, priority) 导致进程等待(允许其他进程运行)直到事件发生;此时,把进程标记为准备好运行,并在没有更高优先级的进程的时候返回。 调用 wakeup(event) 指示事件已经发生了,就是说,导致在这个事件上睡眠的进程被唤醒。事件是由睡眠者和唤醒者达成协议的一个任意数量。为了方便,它是驱动程序使用某个数据区的地址,其守侯的事件是唯一的。 在一个事件上睡眠的进程不应该假定这个事件已经发生;它应该检查导致它们睡眠的条件是否不再保持着。 优先级的范围是 0 到 127;越大的数值指示越少优惠的调度状况。在睡眠在小于参数 PZERO 的优先级的进程和数值上更大的优先级的进程之间有区别。前者尽管有可能被交换出去,但不能被信号所中断。所以在可能永远不发生的一个事件上以小于 PZERO 的优先级睡眠是个坏主意。在另一方面,如果进程同时被机器中的某个信号所终止,则大优先级的到 sleep 的调用可能会永不返回。顺便提一下,在中断时间调用的例程中调用 sleep 是不可原谅的错误,因为正在运行的进程必定不是应该去睡眠的进程。同样的,中断例程不应该触及用户数据区“u”中的变量,更不用说变动了。 如果一个设备驱动程序想要等待的事件是非常规的或不可能提供一个 wakeup,(例如,一个在线设备,它通常不导致一个中断),则可以给出 sleep(&lbolt, priority) 调用。lbolt 是一个外部单元,时钟中断例程每 4 秒钟唤醒它的地址一次。 可获得例程 spl4()、spl5()、spl6()、spl7() 把处理器设置为指定的特权级别,以此避免来自设备的不方便处理的中断。 如果一个设备需要知道实时间隔,可以使用 timeout(func, arg, interval)。这个例程安排在 interval 个 60 分之一秒之后, 将用 arg 作为参数调用 func,函数样式是 (*func)(arg)。使用超时函数的例子有,在打字机输出中的功能字符象换行和 tab 之后提供实时延迟,如果在一个指定的秒数之内没有响应则终止读 201 Dataphone dp 的尝试。注意 60 分之一秒的数目被限制为 32767,因为它必须出现为正数,并且一次只能进行有限数目的 timeout。还有指定的 func 在时钟中断时调用,所以它必须符合中断例程的一般要求。



块设备接口 块设备的处理由管理一组缓冲区的一系列例程作为中介,缓冲区包含在各种设备上的数据块的映像。这些例程的最重要的功能是保证:访问相同设备相同块的、多道程序方式下的多个进程、对在这个块中数据保持一致的视觉(view)。次要但仍很重要的作用是通过对经常访问的块保持块的内存复件来增加系统的效率。这个机制的主要数据库是缓冲区表 buf。每个缓冲区头部包含一对指针(b_forw, b_back),它们维持与特定块设备相关联的缓冲区的一个双向链表,和另一对指针(av_forw, av_back),它们通常维持“空闲”块的一个双向链表,就是说,这些块适合于重新分配给其他事务。有 I/O 在进行中或忙于其他目的的缓冲区不在这个列表中出现。这个缓冲区头部还包含这个缓冲区所参照的设备号和块号,和到与这个缓冲区相关联的实际存储的一个指针。有一个字记数,它是要传送到缓冲区或从缓冲区传送出的字的数目的一个负数;还有一个错误字节和一个剩余字记数,把它们用做从一个 I/O 例程到它的调用者的通信信息。最后,有一个标志字,它的位指示这个缓冲区的状态。后面将讨论这些标志。 七个例程构成与系统的余下部分之间的接口的最重要的部分。给出一个设备号和块号,bread 和 getblk 二者返回到这个块的缓冲区头部的一个指针;区别是 bread 确保返回的缓冲区实际上包含这个块的当前数据的,而 getblk 返回的缓冲区只在这个块已经在内存中的情况下包含这个块的数据(是否在内存中由后面讨论的 B_DONE 位来指示)。在二者中任意情况下,这个缓冲区和相应的设备块都被标记为“忙”,所以引用这个块的其他进程被迫等待直到它变成空闲。例如,在一个块要被完全重写的时候使用 getblk,故此它以前的内容不再有用;仍然不允许其他进程引用这个块直到把新数据放置到其中。 使用 breada 例程来实现预先读。它在逻辑上类似于 bread,但是接受作为一个补充参数的(在同一个设备上的)一个块的编号,在指定要求的块可获得之后要异步读取它。 给出到一个缓冲区的一个指针,brelse 例程使这个缓冲区对其他进程再次可获得。例如,在使用 bread 提取了数据之后调用它。有三个有细微差别的写例程,它们都接受一个缓冲区指针作为参数,并且它们都通过把缓冲区放置到空闲列表上来在逻辑上释放它被其他用户使用。bwrite 在正确的设备队列上发放置缓冲区,等待写完成,如果需要的话设置用户的错误标志。bawrite(异步写)把缓冲区放置在设备队列上,但是不等待完成,所以错误不能直接反应给用户。bdwrite(延迟写)根本不启动任何 I/O 操作,而是仅标记这个缓冲区,这样如果碰巧从空闲列表上获取了它用于持有来自其他块的数据,则首先写出在它内部的数据。 当你想要确保 I/O 正确的进行的时候使用 bwrite,并且把错误反映给适当的用户;例如,在更新 i-node 的时候使用它。bawrite 在想要更多重叠的时候有用(因为不要求等待 I/O 完成),但要在有理由确定真正需要这次写的时候。在不能确定这次写立刻就需要的时候使用 bdwrite。例如,当 write 系统调用的最后的字节达不到块的尽头的时候调用 bdwrite,这假定将要给出重新使用同一个块的另一次写。在另一方面, 在达到了一个块的尽头的时候调用 bawrite,因为这个块可能不会被马上再次访问,并且你也希望尽可能的快的开始写处理。 在任何情况下,注意例程 getblk 和 bread 确定给定块对调用者是独占式使用的,并使其他人等待,直到最后调用 brelse、bwrite、bawrite、或 bdwrite 中的一个来释放这个块来由别人使用。 如上所述,每个缓冲区头部包含一个标志字,它指示这个缓冲区的状态。因为它们在驱动程序和块 I/O 系统之间提供了信息的一个重要通道,理解这些标志是非常重要的。下面的名字是列出的常量,它们选择相关的标志位。 B_READ:当把缓冲区交给这个设备的策略例程(见后)的时候设置此位来指示读操作。符号 B_WRITE 被定义为 0,而且不定义标志;提供它作为对例程的调用者的一种助记方便,比如 swap 例程有一个独立的参数指示读或写。 B_DONE:当把缓冲区交给这个设备的策略例程的时候设置此位为 0,并且在这个操作完成的时候开启此位,通常也作为发生错误时的结果。还使用它作为 getblk 的返回参数的一部分,来指示若是 1 则返回的缓冲区实际上包含了在要求的块中的数据。 B_ERROR:当设置了 B_DONE 来指示发生了一个 I/O 或其他错误的时候设置此位为 1。如果设置了此位,缓冲区头部的 b_error 字节若是非零则包含一个错误代码。如果 b_error 是 0 则未指定这个错误的性质。实际上目前还没有驱动程序设置 b_error;提供后者以备将来改进,籍此实现更详细的错误报告方案。 B_BUSY:此位指示缓冲区头部不在空闲列表上,就是说,指示某人独占使用。这个缓冲区仍被连接到与它的设备相关联的块列表上。当 getblk (或者是调用它的 bread) 查找给定设备的缓冲区列表找到要求的块并在其上发现设置了此位,则睡眠直到此位清除。 B_PHYS:在 PDP 11/70 上需要分配 Unibus 映射的原始 I/O 事务上设置此位。 B_MAP:在分配了 Unibus 映射的缓冲区上设置此位,这样 iodone 例程就知道释放这个映射了。 B_WANTED:这个标志与 B_BUSY 位联合使用。在如上所述睡眠之前,getblk 设置这个标志。 反过来,当释放这个块并且 busy 为关闭(在 brelse 中)的时候,如果 B_WANTED 开启着则对这个块的头部给予一个 wakeup。这个策略避免了每当释放一个缓冲区的时候由于有其他人可能需要它而必须调用 wakeup 所带来的花费。 B_AGE:在释放缓冲区之前在缓冲区上设置此位;如果它是开启的,把这个缓冲区放置到空闲列表的开头部分,而不是结束部分。当调用者判定不会马上再次使用同一块的时候可以使用这个启发性的行动。 B_ASYNC:此位由 bawrite 设置来向适当的驱动程序指示当写完成的时候应当释放这个缓冲区,这通常在中断时间。bwrite 与 bawrite 之间的区别是前者启动 I/O,等待直到完成,并释放这个缓冲区。后者只是设置此位并启动 I/O。此位指示在完成的时候应当对这个缓冲区调用 relse。 B_DELWRI:此位由 bdwrite 在释放这个缓冲区之前设置。当 getblk 在查找空闲块期间,发现在一个缓冲区中此位为 1,它会另外获取,这导致这个块在重新使用之前要被写出。



块设备驱动程序 bdevsw 表持有接口例程的名字和给每个块设备的一个表的名字。 如同对字符设备那样,块设备驱动程序可以为每次打开和最后关闭这个设备分别提供一个 open 和一个 close 例程。不用分开的读和写例程,每个块设备驱动程序都有一个策略(strategy)例程,调用它要把到缓冲区头部的一个指针作为参数。如上所讨论的那样,缓冲区头部包含一个读/写标志,内存地址,块编号,一个(负数)字计数,和主从设备号。策略例程的角色是依据在缓冲区头部中的信息的要求完成操作。当事务 完成的时候应当设置 B_DONE (可能还有 B_ERROR)位。接着如果设置了 B_ASYNC 位,则应当调用 brelse;否则调用 wakeup。在设备有能力在免错操作下传送比要求的更少的字的情况下,这个设备的字计数寄存器应当被放置到这个缓冲区头部的剩余计数槽中;否则,剩余计数应当被设置为 0。这个特殊机制实际上对磁带驱动程序有用处;当读这个设备时记录小于要求是很正常的,并且用户应当被告之记录的实际长度。 尽管给策略例程的最普通的参数是按上述讨论分配的一个真实的缓冲区头部,实际上需要的参数是到包含正确信息的地方的一个指针。例如管理内核映象的移动进出交换设备的 swap 例程,使用这个设备的策略例程。小心在这个标志字中没有无关的位被开启了。 设备的表由 bdevsw 指定,它的组成包括包含一个活跃标志的一个字节和一个错误计数,一对连接,它们构成这个设备的缓冲区链表的头部(b_forw, b_back),和设备队列的首端和末端指针。在这些东西中,除了缓冲区链表指针之外,都只被设备驱动程序自身使用。典型的这个标志编码这个设备的状态,最小的用处是指示这个设备当前忙于传送信息并且不应发起新的命令。错误计数用于在错误发生时统计重试的数目。设备队列用来记住入栈的请求,在最简单的情况下它可以维持为一个先入先出列表。因为已经被交给策略例程的缓冲区不在空闲缓冲区列表上,在缓冲区中的维持空闲列表的指针(av_forw, av_back)也被用来持有维持这个设备队列的指针。 提供了一组对块设备驱动程序有用的例程。iodone(bp) 安排把 bp 指向的缓冲区在适当的时候释放和唤醒,这时策略模块已经完成了这个缓冲区,要么正常要么有错误。(在后一种情况大概会设置 B_ERROR 位。) 可以使用例程 geterror(bp) 来检查在缓冲区头部中的错误位,并安排把在那里发现的任何错误提示反映给用户。它只能在驱动程序的非中断部分调用,这时 I/O 已经完成(B_DONE 已经设置)。



原始块设备 I/O 设立了一种方案让块设备驱动程序可以提供在用户内存映象和设备之间的直接传送信息的能力,而不需要使用缓冲区并且块的大小同调用者需要的一样大。这种方法包括设置对应于这个原始设备的一个字符类型的特殊文件,并提供 read 和 write 例程,它们用正确的信息设置专有的、非共享的缓冲区头部并调用这个设备的策略例程。如果需要的话,可以提供单独的 open 和 close 例程,但通常不需要。一个特殊功能例程应当唾手可得,特别是对于磁带。 需要做大量的工作生成“正确的信息”来放置到这个策略模块的参数缓冲区中;最差的部分是把重新部署了的用户地址映射到物理地址。多数这种工作由 physio(strat, bp, dev, rw) 完成,它的参数是策略例程的名字 strat、缓冲区指针 bp、设备号 dev、和读-写标志 rw、它的值是 B_READ 或 B_WRITE。physio 确保这个用户的基地址和计数是偶数(因为多数设备工作以字为单位)并且影响的内存区域在物理空间上是连续的;它延迟直到缓冲区不忙,并在操作进行中是它为忙;还有它设置用户错误返回信息。