1、第 5 章 字符设备驱动程序的扩展操作在关于字符设备驱动程序的那一章中,我们构建了一个完整的设备驱动程序,从中用户可以读也可以写。但实际一个驱动程序通常会提供比同步 read 和 write 更多的功能。现在如果出了什么毛病,我已经配备了调试工具,我们可以大胆的实验并实现新操作。通过补充设备读写操作的功能之一就是控制硬件,最常用的通过设备驱动程序完成控制动作的方法就是实现 ioctl 方法。另一种方法是检查写到设备中的数据流,使用特殊序列做为控制命令。尽管有时也使用后者,但应该尽量避免这样使用。不过稍后我们还是会在本章的“非 ioctl 设备控制”一节中介绍这项技术。正如我在前一章中所猜想的,
2、ioctl 系统调用为驱动程序执行“命令”提供了一个设备相关的入口点。与 read 和其他方法不同,ioctl 是设备相关的,它允许应用程序访问被驱动硬件的特殊功能配置设备以及进入或退出操作模式。这些“控制操作”通常无法通过read/write 文件操作完成。例如,你向串口写的所有数据都通过串口发送出去了,你无法通过写设备改变波特率。这就是 ioctl 所要做的:控制 I/O 通道。实际设备(与 scull 不同)的另一个重要功能是,读或写的数据需要同其他硬件交互,需要某些同步机制。阻塞型 I/O 和异步触发的概念将满足这些需求,本章将通过一个改写的scull 设备介绍这些内容。驱动程序利用不
3、同进程间的交互产生异步事件。与最初的 scull 相同,你无需特殊硬件来测试驱动程序是否可以工作。直到第 8 章“硬件管理”我才会真正去与硬件打交道。ioctl在用户空间内调用 ioctl 函数的原型大致如下:(代码)由于使用了一连串的“.”的缘故,该原型在 Unix 系统调用列表之中非常突出,这些点代表可变数目参数。但是在实际系统中,系统调用实际上不会有可变数目个参数。因为用户程序只能通过第 2 章“编写和运行模块”的“用户空间和内核空间”一节中介绍的硬件“门”才能访问内核,系统调用必须有精确定义的参数个数。因此,ioctl 的第 3 个参数事实上只是一个可选参数,这里用点只是为了在编译时防
4、止编译器进行类型检查。第 3 个参数的具体情况与要完成的控制命令(第 2 个参数)有关。某些命令不需要参数,某些需要一个整数做参数,而某些则需要一个指针做参数。使用指针通常是可以用来向 ioctl 传递任意数目数据;设备可以从用户空间接收任意大小的数据。系统调用的参数根据方法的声明传递给驱动程序方法:(代码)inode 和 filp 指针是根据应用程序传递的文件描述符 fd 计算而得的,与 read 和 write 的用法一致。参数 cmd 不经修改地传递给驱动程序,可选的 arg 参数无论是指针还是整数值,它都以 unsigned long 的形式传递给驱动程序。如果调用程序没有传递第 3
5、个参数,驱动程序所接收的 arg 没有任何意义。由于附加参数的类型检查被关闭了,如果非法参数传递给 ioctl,编译器无法向你报警,程序员在运行前是无法注意这个错误的。这是我所见到的 ioctl 语义方面的唯一一个问题。如你所想,大多数 ioctl 实现都包括一个 switch 语句来根据 cmd 参数选择正确的操作。不同的命令对应不同的数值,为了简化代码我们通常会使用符号名代替数值。这些符号名都是在预处理中赋值的。不同的驱动程序通常会在它们的头文件中声明这些符号;scull 就在scull.h 中声明了这些符号。选择 ioctl 命令在编写 ioctl 代码之前,你需要选择对应不同命令的命令
6、号。遗憾的是,简单地从 1 开始选择号码是不能奏效的。为了防止对错误的设备使用正确的命令,命令号应该在系统范围内是唯一的。这种失配并不是不很容易发生,程序可能发现自己正在对象 FIFO 和 kmouse 这类非串口输入流修改波特率。如果每一个 ioctl 命令都是唯一的,应用程序就会获得一个 EINVAL 错误,而不是无意间成功地完成了操作。为了达到唯一性的目的,每一个命令号都应该由多个位字段组成。Linux 的第一版使用了一个 16 位整数:高 8 位是与设备相关的“幻”数,低 8 位是一个序列号码,在设备内是唯一的。这是因为,用 Linus 的话说,他有点 “无头绪” ,后来才接收了一个更
7、好的位字段分割方案。遗憾的是,很少有驱动程序使用新的约定,这就挫伤了程序员使用新约定的热情。在我的源码中,为了发掘这种约定都提供了那些功能,同时防止被其他开发人员当成异教徒而禁止,我使用了新的定义命令的方法。为了给我的驱动程序选择 ioctl 号,你应该首先看看 include/asm/ioctl.h 和Documentation/ioctl-number.txt 这两个文件。头文件定义了位字段:类型(幻数) ,基数,传送方向,参数的尺寸等等。ioctl-number.txt 文件中罗列了在内核中使用的幻数。这个文件的新版本(2.0 以及后继内核)也给出了为什么应该使用这个约定的原因。很不幸,
8、在 1.2.x 中发行的头文件没有给出切分 ioctl 位字段宏的全集。如果你需要象我的scull 一样使用这种新方法,同时还要保持向后兼容性,你使用 scull/sysdep.h 中的若干代码行,我在那里给出了解决问题的文档的代码。现在已经不赞成使用的选择 ioctl 号码的旧方法非常简单:选择一个 8 位幻数,比如“k”(十六进制为 0x6b) ,然后加上一个基数,就象这样:(代码)如果应用程序和驱动程序都使用了相同的号码,你只要在驱动程序里实现 switch 语句就可以了。但是,这种在传统 Unix 中有基础的定义 ioctl 号码的方法,不应该再在新约定中使用。这里我介绍就方法只是想给
9、你看看一个 ioctl 号码大致是个什么样子的。新的定义号码的方法使用了 4 个位字段,它们有如下意义。下面我所介绍的新符号都定义在中。类型幻数。选择一个号码,并在整个驱动程序中使用这个号码。这个字段有 8 位宽(_IOC_TYPEBITS) 。号码基(序列)数。它也是 8 位宽(_IOC_NRBITS) 。方向如果该命令有数据传输,它定义数据传输的方向。可以使用的值有,_IOC_NONE(没有数据传输) ,_IOC_READ,_IOC_WRITE 和_IOC_READ | _IOC_WRITE(双向传输数据) 。数据传输是从应用程序的角度看的;IOC_READ 意味着从设备中读数据,驱动程序
10、必须向用户空间写数据。注意,该字段是一个位屏蔽码,因此可以用逻辑 AND 操作从中分解出_IOC_READ 和_IOC_WRITE。尺寸所涉及的数据大小。这个字段的宽度与体系结构有关,当前的范围从 8 位到 14 位不等。你可以在宏_IOC_SIZEBITS 中找到某种体系结构的具体数值。不过,如果你想要你的驱动程序可移植,你只能认为最大尺寸可达 255 个字节。系统并不强制你使用这个字段。如果你需要更大尺度的数据传输,你可以忽略这个字段。下面我们将介绍如何使用这个字段。包含在之中的头文件定义了可以用来构造命令号码的宏:_IO(type,nr),_IOR(type,nr,size),_IOW(
11、type,nr,size)和 IOWR(type,nr,size)。每一个宏都对应一种可能的数据传输方向,其他字段通过参数传递。头文件还定义了解码宏:_IOC_DIR(nr),_IOC_TYPE(nr),_IOC_NR(nr)和_IOC_SIZE(nr)。我不打算详细介绍这些宏,头文件里的定义已经足够清楚了,本节稍后会给出样例。这里是 scull 中如果定义 ioctl 命令的。特别地,这些命令设置并获取驱动程序的配置参数。在标准的宏定义中,要传送的数据项的尺寸有数据项自身的实例代表,而不是 sizeof(item),这是因为 sizeof 是宏扩展后的一部分。(代码)最后一条命令,HARDR
12、ESET,用来将模块使用计数器复位为 0,这样就可以在计数器发生错误时就可以卸载模块了。实际的源码定义了从 IOCHQSET 到 HARDRESET 间的所有命令,但这里没有列出。我选择用两种方法实现整数参数传递通过指针和显式数值,尽管根据已有的约定,ioctl 应该使用指针完成数据交换。同样,这两种方法还用于返回整数:通过指针和设置返回值。如果返回值是正的,这就可以工作;对与任何一个系统调用的返回值,正值是受保护的(如我们在 read 和 write 所见到的) ,而负值则被认为是一个错误值,用其设置用户空间中的 errno 变量。“交换”和“移位”操作并不专用于 scull 设备。我实现“
13、交换”操作是为了给出“方向”字段的所有可能值,而“移位”操作则将“告知”和“查询”操作组合在一起。某些时候是需要原子性 *测试兼设置这类操作的特别是当应用程序需要加锁和解锁时。显式的命令基数没有什么特殊意义。它只是用来区分命令的。事实上,由于 ioctl 号码的“方向”为会有所不同,你甚至可以在读命令和写命令中使用同一个基数。我选择除了在声明中使用基数外,其他地方都不使用它,这样我就不必为符号值赋值了。这也是为什么显式的号码出现在上面的定义中。我只是向你介绍一种使用命令号码的方法,你可以自由地采用不同的方法使用它。当前,参数 cmd 的值内核并没有使用,而且以后也不可能使用。因此,如果你想偷懒
14、,你可以省去上面那些复杂的声明,而直接显式地使用一组 16 位数值。但另一方面,如果你这样做了,你就无法从使用位字段中受益了。头文件就是这种旧风格方法的例子,但是它们并不是因为偷懒才这样做的。修改这个文件需要重新编译许多应用程序。返回值ioctl 的实现通常就是根据命令号码的一个 switch 语句。但是,当命令号码不能匹配任何一个合法操作时,default 选择使用是什么?这个问题是很有争议性的。大多数内核函数返回-EINVAL(“非法参数” ) ,这是由于命令参数确实不是一个合法的参数,这样做是合适的。然而,POSIX 标准上说,如果调用了一个不合适的 ioctl 命令,应该返回-ENOT
15、TY 。对应* 当一段程序代码总是被当做一条指令被执行,且不可能在期间发生其他操作,我们就称这段代码是原子性的。的消息是“不是终端”这不是用户所期望的。你不得不决定是严格依从标准还是一般常识。我们将本章的后面介绍为什么依从 POSIX 标准需要返回 ENOTTY。预定义命令尽管 ioctl 系统调用大部分都用于操作设备,但还有一些命令是由内核识别的。注意,这些命令是在你自己的文件操作前调用的,所以如果你选择了和它们相同的命令号码,你将无法接收到那个命令的请求,而且由于 ioctl 命令的不唯一性,应用程序会请求一些未可知的请求。预定义命令分为 3 组:用于任何文件(普通,设备,FIFO 和套接
16、字文件)的,仅用于普通文件的以及和文件系统相关的;最后一组命令只能在宿主机文件系统上执行(间 chattr 命令) 。设备驱动程序编写者仅对第 1 组感兴趣就可以了,它们的幻数是“T” 。分析其他组的工作将留做读者的练习;ext2_ioctl 是其中最有意思的函数(尽管比你想象的要容易得多),它实现了只追加标志和不可变标志。下列 ioctl 命令对任何文件都是预定义的:FIOCLEX设置 exec 时关闭标志(File IOctl Close on EXec) 。FIONCLEX清除 exec 时关闭标志。FIOASYNC设置或复位文件的同步写。Linux 中没有实现同步写;但这个调用存在,这
17、样请求同步写的应用程序就可以编译和运行了。如果你不知道同步写是怎么回事,你也不用费神去了解它了:你不需要它。FIONBIO“File IOctl Nonblocking I/O(文件 ioctl 非阻塞型 I/O) ”(稍后在“阻塞型和非阻塞型操作”一节中介绍) 。该调用修改 filp-f_flags 中的 O_NONBLOCK 标志。传递给系统调用的第 3个参数用来表明该标志是设置还是清除。我们将在本章后面谈到它的作用。注意,fcntl 系统调用使用 F_SETFL 命令也可以修改这个标志。列表中的最后一项引入了一个新系统调用 fcntl,它看起来和 ioctl 很象。事实上,fcntl 调
18、用与 ioctl 非常相似,它也有一个命令参数和额外(可选的)一个参数。它和 ioctl 分开主要是由于历史原因:当 Unix 开发人员面对“控制”I/O 操作的问题时,他们决定文件和设备应该是不同的。那时,唯一的设备是终端,这也就解释了为什么-ENOTTY 是标准的非法ioctl 命令的返回值。这个问题是是否保持向后兼容性的老问题。使用 ioctl 参数我们需要讲解的最后一点是,在分析 scull 驱动程序的 ioctl 代码前,首先弄明白如何使用那个额外的参数。如果它是一个整数就非常简单了:可以直接使用它。如果它是一个指针,就必须注意一些问题了。当用一个指针引用用户空间时,我们首先要确保它
19、指向了合法的用户空间,并且对应页面当前恰在映射中。如果内核代码试图访问范围之外的地址,处理器就会发出一个异常。内核代码中的异常将由上至 2.0.x 的内核转换为 oops 消息。设备驱动程序应该通过验证将要访问的用户地址空间的合法性来防止这种失效的发生,如果地址是非法的应该返回一个错误码。Linux 2.1 中引入新功能之一就是内核代码的异常处理。遗憾的是,正确的实现需要驱动程序-内核接口的较大改动。本章给出的非法只适用于旧内核,从 1.2.13 到 2.0.x。新接口将在第 17 章“近期发展”的“处理内核空间失效”一节中介绍,那里给出的例子通过某些预处理宏将使支持的内核扩展到 2.1.43
20、。内核 1.x.y 和 2.0.x 的地址验证是通过函数 verify_area 实现的,它的原型定义在 中:(代码)第一个参数应该是 VERIFY_READ 或 VERIFY_WRITE,这取决于你要在内存区上完成读还是写操作。ptr 参数是一个用户空间地址,extent 是一个字节计数。例如,如果 ioctl 需要从用户空间读一个整数,extent 就是 sizeof(int)。如果在指定的地址上进行读和写操作,使用 VERIFY_WRITE,它是 VERIFY_READ 的超集。验证读只检查地址是否是合法的:除此之外,验证写要好检查只读和 copy-on-write 页面。copy-on
21、-write 页面一个共享可写页面,它还没有被任何共享进程写过;当你验证写时,verify_area 完成 “复制兼完成可写配置 ”操作。很有意思的是,这里无需检查页面是否“在”内存中,这是由于合法页面将由失效函数正确地进行处理,甚至从内核代码中调用也可以。我们已经在第 3 章“字符设备”的“Scull 的内存使用 ”一节中看到内核代码可以成功地完成页面失效处理。象大多数函数一样,verify_area 返回一个整数值:0 意味着成功,负值代表一个错误,应该将这个错误返回给调用者。scull 源码在 switch 之前分析 ioctl 号码的各个位字段:(代码)在调用 verify_area
22、之后,再有驱动程序完成真正的数据传送。除了 memcpy_tofs 和memcpy_fromfs 函数外,程序员还可以使用两个专为常用数据尺寸(1,2 和 4 个字节,在以及 64 位平台上的 8 个字节)优化的函数。这些函数定义在 中。put_user(datum, ptr)实际上它是一个最终调用_put_user 的宏;编译时将其扩展为一条机器指令。驱动程序应该尽可能使用 put_user,而不是 memcpy_tofs。由于在宏表达式中不进行类型检查,你可以传递给 put_user 任何类型的数据指针,不过它应该是一个用户空间地址。数据传输的尺寸依赖于 ptr 参数的类型,这是在编译时通
23、过特殊的 gcc 伪函数实现的,这里没有介绍的必要。结果,如果 ptr 是一个字符指针,就传递 1 个字节,依此类推分别有 2,4 和 8 个字节。如果被指引的数据不是所支持的尺寸,被编译的代码就会调用函数 bad_user_access_length。如果这些编译代码是一个模块,由于这个符号没有开放,模块就不能加载了。get_user(ptr)这个宏用来从用户空间获取一个数据。除了数据传输的方向不同外,它与 put_user 是一样的。当 insmod 不能解析符号时,bad_user_access_length 的又臭又长的名字可以当作一个很有意义的错误信息。这样,开发人员就可以在向大众分
24、布模块前加载和测试模块,他会很快找到并修改错误。相反,如果使用了不正确尺寸的 put_user 和 get_user 直接编译到了内核中,bad_user_access_length 就会导致系统 panic。尽管对于尺寸错误的数据传输来说,oops 比其系统 panic 要友好得多,但还是选择了较为激进的方法来尽力杜绝这种错误。scull 的 ioctl 实现只传送设备的可配置参数,其代码非常简单,罗列如下:(代码)还有 6 项是操作 scull_qset 的。这些操作 scull_quantum 的一样,为了节省空间,没有在上面的例子中列出。从调用者的角度看(即从用户空间) ,传递和接收参
25、数的 6 种方法如下所示:(代码)如果你需要写一个可以在 Linux 1.2 里运行的模块,get_user 和 put_user 会是非常棘手的函数,因为它们直到内核 1.3 才引入到系统中。在切换到类型依赖宏之前,程序员使用一些称为 get_user_byte 等等的函数。旧的宏只在内核 1.3 中定义了,在 2.0 内核中,只有你事先使用了#define WE_REALLY_WANT_TO_USE_A_BROKEN_INTERFACE 时才能使用旧的宏。不过为了可移植性,为旧内核定义 put_user 是一种更好的解决方法,于是为了驱动程序可以在旧内核中良好运行,scull/sydep.
26、h 包含了这些宏的定义。非 ioctl 设备控制有时通过向设备自身发送写序列能够更好地完成对设备的控制。例如,这一技术使用在控制台驱动程序中,它称为“escape 序列” ,用来控制光标移动,改变默认颜色,或是完成某些配置任务。用这种方法实现设备控制的好处是,用户仅用写数据就可以完成对设备的控制,无需使用(有时是写)完成设备配置的程序。例如,程序 setterm 通过打印 escape 序列完成对控制台(或其他终端)的配置。这种方法的优点是可以远程控制设备。由于可以简单地重定向数据流完成配置工作,控制程序可以运行在另外一台不同的计算机上,而不一定非要在被控设备的计算机上。你已经在终端上使用了这
27、项技术,但这项技术可以更通用一些。“通过打印控制”的缺点是,它给设备增加了策略限制;例如,只有你确认控制序列不会出现在正常写到设备的数据中时,这项技术才是可用的。对于终端来说,这只能说是部分正确。尽管文本显示只意味着显示 ASCII 字符,但有时控制字符也会出现在正在打印的数据中,因此会影响控制台的配置。例如,当你对二进制文件进行 grep 时可能会发生这样的情况;分解出的字符行可能什么都包含,最后经常会造成控制台的字体错误 *。写控制特别适合这样的设备,不传输数据,仅相应命令,如机器人设备。例如,我所写的驱动程序之一是控制一个在两个轴上的摄像头的移动。在这个驱动程序中,“设备”是一对旧的步进
28、马达,它既不能读也不能写。向步进马达“发送数据流”多少没有多大意义。在这种情况下,驱动程序将所写的数据解释为 ASCII 命令,并将请求转换为脉冲,实现对步进马达的控制。命令可以是任何象“向左移动 14 步” , “达到位置100,43”或“降低默认速度”之类的字串。驱动程序仅将/dev 中的设备节点当作为应用程序设立的命令通道。对该设备直接控制的优点是,你可以使用 cat 来移动摄像头,而无需写并编译发出 ioctl 调用的特殊代码。当编写“面向命令的”驱动程序时,没有理由要实现 ioctl 方法。为解释器多实现一条命令对于实现和使用来说,都更容易。好奇的读者可以看看由 OReilly FT
29、P 站点提供的源码 stepper 目录中的 stepper 驱动程序;由于我认为代码没有太大的意义(而且质量也不是太高) ,这里没有包含它。* Ctrl-N 设置替代字体,它有图形字符组成,因此你的外壳的输入来说是不友好的;如果你遇见了这样的问题,回应一个 Ctrl-O 字符来恢复主字体。阻塞型 I/Oread 的一个问题是,当尚未有数据可读,而又没有到文件尾时如何处理。默认的回答是, “我们必须睡眠等待数据。 ”本节将介绍进程如何睡眠,如何唤醒,以及一个应用程序如何在不阻塞 read 调用的情况下,查看是否有数据。对于写来说也可以适用同样的方法。通常,在我向你介绍真实的代码前,我将解释若干
30、概念。睡眠和唤醒当进程等待事件(可以是输入数据,子进程的终止或是其他什么)时,它需要进入睡眠状态以便其他进程可以使用计算资源。你可以调用如下函数之一让进程进入睡眠状态:(代码)然后用如下函数之一唤醒进程:(代码)在前面的函数中,wait_queue 指针的指针用来代表事件;我们将在“等待队列”一节中详细讨论这个数据结构。从现在开始,唤醒进程需要使用进程睡眠时使用的同一个队列。因此,你需要为每一个可能阻塞进程的事件对应一个等待队列。如果你管理 4 个设备,你需要为阻塞读预备 4 个等待队列,为阻塞写再预备 4 个。存放这些队列的最佳位置是与每个设备相关的硬件数据结构(在我们的例子中就是 Scul
31、l_Dev) 。但“可中断”调用和普通调用有什么区别呢?sleep_on 不能信号取消,但 interruptible_sleep_on 可以。其实,仅在内核的临界区才调用sleep_on;例如,当等待从磁盘上读取交换页面时。没有这些页面进程就无法继续运行,用信号打断这个操作是没有任何意义的。然而,在所谓“长系统调用” ,如 read,中要使用interruptible_sleep_on。当进程正等待键盘输入时,用一个信号将进程杀死是很有意义的。类似地,wake_up 唤醒睡在队列上的任何一个进程,而 wake_up_interruptible 仅唤醒可中断进程。做为一个驱动程序编写人员,由于
32、进程仅在 read 或 write 期间才睡眠在驱动程序代码上,你应该调用 interruptible_sleep_on 和 wake_up_interruptible。不过,事实上由于没有“不可中断”的进程在你的队列上睡眠,你也可以调用 wake_up。但是,出于源代码一致性的考虑,最好不这样做。 (此外,wake_up 比它的搭档来说要稍微慢一点。 )编写可重入的代码当进程睡眠后,驱动程序仍然活着,而且可以由另一个进程调用。让我们一控制台驱动程序为例。当一个应用在 tty1 上等待键盘输入,用户切换到 tty2 上并派生了一个新的外壳。现在,两个外壳都在控制台驱动程序中等待键盘输入,但它们
33、睡在不同的队列上:一个睡在与 tty1 相关的队列上,一个睡在与 tty2 相关的队列上。每个进程都阻塞在interruptible_sleep_on 函数中,但驱动程序让可以继续接收和响应其他 tty 的请求。可以通过编写“可重入代码”轻松地处理这种情况。可重入代码是不在全局变量中保留状态信息的代码,因此能够管理交织在一起的调用,而不会将它们混淆起来。如果所有的状态信息都与进程有关,就不会发生相互干扰。如果需要状态信息,既可以在驱动程序函数的局部变量中保存(每个进程都有不同的堆栈来保存局部变量) ,也可以保存在访问文件用的 filp 中的 private_data 中。由于同一个 filp可
34、能在两个进程间共享(通常是父子进程) ,最好使用局部变量 *。如果你需要保存大规模的状态信息,你可以将指针保存在局部变量中,并用 kmalloc 获取实际存储空间。此时,你千万别忘了 kfree 这些数据,因为当你在内核空间工作时,没有“在进程终止时释放所有资源”的说法。你需要将所有调用了 sleep_on(或是 schedule)的函数写成可重入的,并且包括所有在这个函数调用轨迹中的所有函数。如果 sample_read 调用了 sample_getdata,后者可能会阻塞,由于调用它们的进程睡眠后无法阻止另一个进程调用这些函数,sample_read 和sample_gendata 都必须
35、是可重入的。此外,任何在用户空间和内核空间复制数据的函数也必须是可重入的,这是因为访问用户空间可能会产生页面失效,当内核处理失效页面时,进程可以会进入睡眠状态。等待队列我听见你在问的下一个问题是, “我到底如何使用等待队列呢?”等待队列很容易使用,尽管它的设计很是微妙,但你不需要直到它的内部细节。处理等待队列的最佳方式就是依照如下操作: 声明一个 struct wait_queue *变量。你需要为每一个可以让进程睡眠的事件预备这样一个变量。这就是我建议你放在描述硬件特性数据结构中的数据项。 将该变量的指针做为参数传递给不同的 sleep_on 和 wake_up 函数。这相当容易。例如,让我
36、们想象一下,当进程读你的设备时,你要让这个进程睡眠,然后在某人向设备写数据后唤醒这个进程。下面的代码就可以完成这些工作:(代码)该设备的这段代码就是例子程序中的 sleepy,象往常一样,可以用 cat 或输入/输出重定向等方法测试它。上面列出的两个操作是你唯一操作在等待队列上的两个操作。不过,我知道某些读者对它的内部结构感兴趣,但通过源码掌握它的内部结构很困难。如果你不对更多的细节感兴趣,你可以跳过下一节,你不会损失什么的。注意,我谈论的是“当前”实现(版本 2.0.x) ,但没有什么规定限制内核开发人员必须依照那样的实现。如果出现了更好的实现,内核很容易就会使用新的,由于驱动程序编写人员只
37、能通过那两个合法操作使用等待队列,对他们来说没有什么坏的影响。当前 struct wait_queue 额实现使用了两个字段:一个指向 struct task_struct 结构(等待进程)的指针,和一个指向 struct wait_queue(链表中的下一个结构)的指针。等待队列是循环链表,最后一个结构指向第一个结构。该设计的引入注目的特点是,驱动程序编写人员从来不声明或使用这个结构;他们仅仅传递它的指针或指针的指针。实际的结构是存在的,但只在一个地方:在_sleep_on 函数的局部变量中,上面介绍的两个 sleep_on 函数最终会调用这个函数。这看上去有点奇怪,不过这是一个非常明智的选
38、择,因为无需处理这种结构的分配和释放。进程每次睡在某个队列上,描述其睡眠的数据结构驻留在进程对应的不对换的堆栈页中。当进程加入或从队列中删除时,实际的操作如图 5-1 所示。* 注意,内核栈无法存储太大的数据项。在这种情况下,我建议为大数据分配内存,并将这些空间的地址保存在局部变量中。(图 5-1 等待队列的工作示意)阻塞型和非阻塞型操作在分析功能完整的 read 和 write 方法前,我们还需要看看另外一个问题,这就是 filp-f_flags 中的 O_NONBLOCK 标志。这个标志定义在中,在最近的内核中,这个头文件由自动包含了。如果你在内核 1.2 中编译你的模块,你需要手动包含
39、fcntl.h。这个标志的名字取自“打开-非阻塞” ,因为这个标志可以在打开时指定(而且,最初只能在打开时指定) 。由于进程在等待数据时的正常行为就是睡眠,这个标志默认情况下是复位的。在阻塞型操作的情况下,应该实现下列操作: 如果进程调用 read,但(尚)没有数据,进程必须阻塞。当数据到达时,进程被唤醒,并将数据返回给调用者,即便少于方法的 count 参数中所请求的数据量,也是如此。 如果进程调用了 write,缓冲区又没有空间,进程也必须阻塞,而且它必须使用与用来实现读的等待队列不同的等待队列。当数据写进设备后,输出缓冲区中空出部分空间,唤醒进程,write 调用成功完成,如果缓冲区中没
40、有请求中 count 个字节,则进程可能只是完成了部分写。前面的列表的两个语句都假设,有一个输入和输出缓冲区,而且每个设备驱动程序都有一个。输入缓冲区需要用来在数据达到而又没有人读时避免丢失数据,输出缓冲区用来尽可能增强计算机的性能,尽管这样做不是严格必须的。由于如果系统调用不接收数据的话,数据仍然保存在用户空间的缓冲区中,write 中可以丢失数据。在驱动程序中实现输出缓冲区可以获得一定的性能收益,这主要是通过较少了用户级/内核级转换和上下文切换的数目达到的。如果没有输出缓冲区(假设是一个慢设备) ,每次系统调用只接收一个或很少几个字节,并且当进程在 write 中睡眠时,另一进程就会运行(
41、有一次上下文切换) 。当第一个进程被唤醒后,它恢复运行(又一次上下文切换) ,write 返回(内核/用户转换) ,进程还要继续调用系统调用写更多的数据(内核/ 用户转换) ;然后调用再次被阻塞,再次进行整个循环。如果输出缓冲区足够大,write 首次操作时就成功了;数据在中断时被推送给设备,而不必将控制返回用户空间。适合于设备的输出缓冲区的尺寸显然是和设备相关的。我们没有在 scull 中使用输入缓冲区,这是因为当调用 read 时,数据已经就绪了。类似地,也没有使用输出缓冲区,数据简单地复制到设备对应的内存区中。我们将在第 9 章“中断处理”的“中断驱动的 I/O”一节中介绍缓冲区的使用。
42、如果设置了 O_NONBLOCK 标志,read 和 write 的行为是不同的。此时,如果进程在没有数据就绪时调用了 read,或者在缓冲区没有空间时调用了 write,系统简单地返回-EAGAIN。如你所料,非阻塞型操作立即返回,允许应用查询数据。当使用 stdio 函数处理非阻塞型文件时,由于你很容易误将非阻塞返回认做是 EOF,应用程序应该非常小心。你必须始终检查 errno。你也许可以从它的名字猜到,O_NONBLOCK 在 open 方法也可有作用。当 open 调用可能会阻塞很长时间时,就需要 O_NONBLOCK 了;例如,当打开一个 FIFO 文件而又(尚)无写者时,或是访问
43、一个被锁住的磁盘文件时。通常,打开设备成功或失败,无需等待外部事件。但是,有时打开设备需要需要很长时间的初始化,你可以选择打开O_NONBLOCK,如果设置了标志,在设备开始初始化后,会立即返回一个-EAGAIN(再试一次) 。你还可以为支持访问策略选择实现阻塞型 open,方式与文件锁类似。我们稍后将在“替代 EBUSY 的阻塞型打开”一节中看到这样一种实现。只有 read,write 和 open 文件操作受非阻塞标志的影响。样例实现:scullpipe/dev/scullpipe 设备(默认有 4 个设备)是 scull 模块的一部分,用来展示如何实现阻塞型I/O。在驱动程序内部,阻塞在
44、 read 调用的进程在数据达到时被唤醒;通常会发出一个中断来通知这样一种事件,驱动程序在处理中断时唤醒进程。由于你应该无需任何特殊硬件没有任何中断处理函数,就可以在任何计算机上运行 scull,scull 的目标与传统驱动程序完全不同。我选择的方法是,利用另一个进程产生数据,唤醒读进程;类似地,用读进程唤醒写者。这种实现非常类似与一个 FIFO(或“命名管道” )文件系统节点的实现,设备名就出自此。设备驱动程序使用一个包含两个等待队列和一个缓冲区的设备结构。缓冲区的大小在通常情况下是可以配置的(编译时,加载时和运行时) 。(代码)read 实现管理阻塞型和非阻塞型数据,如下所示:(代码)如你
45、所见,我在代码中留下了 PDEBUG 语句。当你编译驱动程序时,你可以打开消息,这样就可以更容易地看到不同进程间的交互了。跟在 interruptible_sleep_on 后的 if 语句处理信号处理。这条语句保证对信号恰当和预定的处理过程,它会让内核完成系统调用重启或返回-EINTR(内核在内部处理 -ERESTARTSYS,最终返回到用户空间的是 -EINTR) 。我不想让内核对阻塞信号完成这样的处理,主要时我想忽略这些信号。否则,我们可以返回-ERESTARTSYS 错误给内核,让它完成它的处理工作。我们将在所有的 read 和 write 实现中使用一样的语句进行信号处理。write
46、 的实现与 read 非常相似。它唯一的 “特殊”功能时,它从不完全填充缓冲区,总时留下至少一个字节的空洞。因此,当缓冲区空的时候,wp 和 rp 时相等的;当存在数据时,它们是不等的。(代码)正如我所构想的,设备没有实现阻塞型 open,这要比实际的 FIFO 要简单得多。如果你想要看看实际的代码,你可以在内核源码的 fs/pipe.c 中找到那些代码。要测试 scullpipe 设备的阻塞型操作,你可以在其上运行一些应用,象往常一样,可以使用输入/输出重定义等方法。由于普通程序不执行非阻塞型操作,测试非阻塞活动要麻烦些。misc-progs 源码目录中包含了一个很简单的程序,称为 nbtest,用它来测试非阻塞型操作,该程序罗列如下。它所做的就是使用非阻塞型 I/O 复制它的输入和输出,并在期间稍做延迟。延迟时间可以通过命令行传递,默认情况下时 1 秒钟。(代码)Select在使用非阻塞型 I/O 时,应用程序经常要利用 select 系统调用,当涉及设备文件时,它依赖于一个设备方法。这个系统调用还用来实现不同源输入的多路复用。在下面的讨论中,