字符设备驱动程序的扩展操作

来源:互联网 发布:淘宝关闭订单运费险 编辑:程序博客网 时间:2024/04/30 19:17
在关于字符设备驱动程序的那一章中,我们构建了一个完整的设备驱动程序,从中用户
可以读也可以写。但实际一个驱动程序通常会提供比同步read和write更多的功能。现在
如果出了什么毛病,我已经配备了调试工具,我们可以大胆的实验并实现新操作。

通过补充设备读写操作的功能之一就是控制硬件,最常用的通过设备驱动程序完成控制
动作的方法就是实现ioctl方法。另一种方法是检查写到设备中的数据流,使用特殊序列
做为控制命令。尽管有时也使用后者,但应该尽量避免这样使用。不过稍后我们还是会
在本章的“非ioctl设备控制”一节中介绍这项技术。

正如我在前一章中所猜想的,ioctl系统调用为驱动程序执行“命令”提供了一个设备相
关的入口点。与read和其他方法不同,ioctl是设备相关的,它允许应用程序访问被驱动
硬件的特殊功能――配置设备以及进入或退出操作模式。这些“控制操作”通常无法通
硬件的特殊功能――配置设备以及进入或退出操作模式。这些“控制操作”通常无法通
过read/write文件操作完成。例如,你向串口写的所有数据都通过串口发送出去了,你
无法通过写设备改变波特率。这就是ioctl所要做的:控制I/O通道。

实际设备(与scull不同)的另一个重要功能是,读或写的数据需要同其他硬件交互,需
要某些同步机制。阻塞型I/O和异步触发的概念将满足这些需求,本章将通过一个改写的
scull设备介绍这些内容。驱动程序利用不同进程间的交互产生异步事件。与最初的scul
l相同,你无需特殊硬件来测试驱动程序是否可以工作。直到第8章“硬件管理”我才会
真正去与硬件打交道。

ioctl
在用户空间内调用ioctl函数的原型大致如下:

(代码)

由于使用了一连串的“.”的缘故,该原型在Unix系统调用列表之中非常突出,这些点代
表可变数目参数。但是在实际系统中,系统调用实际上不会有可变数目个参数。因为用
户程序只能通过第2章“编写和运行模块”的“用户空间和内核空间”一节中介绍的硬件
“门”才能访问内核,系统调用必须有精确定义的参数个数。因此,ioctl的第3个参数
事实上只是一个可选参数,这里用点只是为了在编译时防止编译器进行类型检查。第3个
参数的具体情况与要完成的控制命令(第2个参数)有关。某些命令不需要参数,某些需
要一个整数做参数,而某些则需要一个指针做参数。使用指针通常是可以用来向ioctl传
递任意数目数据;设备可以从用户空间接收任意大小的数据。
递任意数目数据;设备可以从用户空间接收任意大小的数据。

系统调用的参数根据方法的声明传递给驱动程序方法:

(代码)

inode和filp指针是根据应用程序传递的文件描述符fd计算而得的,与read和write的用
法一致。参数cmd不经修改地传递给驱动程序,可选的arg参数无论是指针还是整数值,
它都以unsigned long的形式传递给驱动程序。如果调用程序没有传递第3个参数,驱动
程序所接收的arg没有任何意义。

由于附加参数的类型检查被关闭了,如果非法参数传递给ioctl,编译器无法向你报警,
程序员在运行前是无法注意这个错误的。这是我所见到的ioctl语义方面的唯一一个问题


如你所想,大多数ioctl实现都包括一个switch语句来根据cmd参数选择正确的操作。不
同的命令对应不同的数值,为了简化代码我们通常会使用符号名代替数值。这些符号名
都是在预处理中赋值的。不同的驱动程序通常会在它们的头文件中声明这些符号;scull
就在scull.h中声明了这些符号。

选择ioctl命令
在编写ioctl代码之前,你需要选择对应不同命令的命令号。遗憾的是,简单地从1开始
选择号码是不能奏效的。
选择号码是不能奏效的。

为了防止对错误的设备使用正确的命令,命令号应该在系统范围内是唯一的。这种失配
并不是不很容易发生,程序可能发现自己正在对象FIFO和kmouse这类非串口输入流修改
波特率。如果每一个ioctl命令都是唯一的,应用程序就会获得一个EINVAL错误,而不是
无意间成功地完成了操作。

为了达到唯一性的目的,每一个命令号都应该由多个位字段组成。Linux的第一版使用了
一个16位整数:高8位是与设备相关的“幻”数,低8位是一个序列号码,在设备内是唯
一的。这是因为,用Linus的话说,他有点“无头绪”,后来才接收了一个更好的位字段
分割方案。遗憾的是,很少有驱动程序使用新的约定,这就挫伤了程序员使用新约定的
热情。在我的源码中,为了发掘这种约定都提供了那些功能,同时防止被其他开发人员
当成异教徒而禁止,我使用了新的定义命令的方法。

为了给我的驱动程序选择ioctl号,你应该首先看看include/asm/ioctl.h和Documentati
on/ioctl-number.txt这两个文件。头文件定义了位字段:类型(幻数),基数,传送方
向,参数的尺寸等等。ioctl-number.txt文件中罗列了在内核中使用的幻数。这个文件
的新版本(2.0以及后继内核)也给出了为什么应该使用这个约定的原因。

很不幸,在1.2.x中发行的头文件没有给出切分ioctl位字段宏的全集。如果你需要象我
的scull一样使用这种新方法,同时还要保持向后兼容性,你使用scull/sysdep.h中的若
干代码行,我在那里给出了解决问题的文档的代码。


现在已经不赞成使用的选择ioctl号码的旧方法非常简单:选择一个8位幻数,比如“k”
(十六进制为0x6b),然后加上一个基数,就象这样:

(代码)

如果应用程序和驱动程序都使用了相同的号码,你只要在驱动程序里实现switch语句就
可以了。但是,这种在传统Unix中有基础的定义ioctl号码的方法,不应该再在新约定中
使用。这里我介绍就方法只是想给你看看一个ioctl号码大致是个什么样子的。

新的定义号码的方法使用了4个位字段,它们有如下意义。下面我所介绍的新符号都定义
在<linux/ioctl.h>中。

类型

幻数。选择一个号码,并在整个驱动程序中使用这个号码。这个字段有8位宽(_IOC_TYP
EBITS)。

号码

基(序列)数。它也是8位宽(_IOC_NRBITS)。

方向
方向

如果该命令有数据传输,它定义数据传输的方向。可以使用的值有,_IOC_NONE(没有数
据传输),_IOC_READ,_IOC_WRITE和_IOC_READ | _IOC_WRITE(双向传输数据)。数据
传输是从应用程序的角度看的;IOC_READ意味着从设备中读数据,驱动程序必须向用户
空间写数据。注意,该字段是一个位屏蔽码,因此可以用逻辑AND操作从中分解出_IOC_R
EAD和_IOC_WRITE。

尺寸

所涉及的数据大小。这个字段的宽度与体系结构有关,当前的范围从8位到14位不等。你
可以在宏_IOC_SIZEBITS中找到某种体系结构的具体数值。不过,如果你想要你的驱动程
序可移植,你只能认为最大尺寸可达255个字节。系统并不强制你使用这个字段。如果你
需要更大尺度的数据传输,你可以忽略这个字段。下面我们将介绍如何使用这个字段。

包含在<linux/ioctl.h>之中的头文件<asm/ioctl.h>定义了可以用来构造命令号码的宏
:_IO(type,nr),_IOR(type,nr,size),_IOW(type,nr,size)和IOWR(type,nr,size)。
每一个宏都对应一种可能的数据传输方向,其他字段通过参数传递。头文件还定义了解
码宏:_IOC_DIR(nr),_IOC_TYPE(nr),_IOC_NR(nr)和_IOC_SIZE(nr)。我不打算详细介
绍这些宏,头文件里的定义已经足够清楚了,本节稍后会给出样例。

这里是scull中如果定义ioctl命令的。特别地,这些命令设置并获取驱动程序的配置参
数。在标准的宏定义中,要传送的数据项的尺寸有数据项自身的实例代表,而不是sizeo
数。在标准的宏定义中,要传送的数据项的尺寸有数据项自身的实例代表,而不是sizeo
f(item),这是因为sizeof是宏扩展后的一部分。

(代码)

最后一条命令,HARDRESET,用来将模块使用计数器复位为0,这样就可以在计数器发生
错误时就可以卸载模块了。实际的源码定义了从IOCHQSET到HARDRESET间的所有命令,但
这里没有列出。

我选择用两种方法实现整数参数传 莰D―通过指针和显式数值,尽管根据已有的约定,i
octl应该使用指针完成数据交换。同样,这两种方法还用于返回整数:通过指针和设置
返回值。如果返回值是正的,这就可以工作;对与任何一个系统调用的返回值,正值是
受保护的(如我们在read和write所见到的),而负值则被认为是一个错误值,用其设置
用户空间中的errno变量。

“交换”和“移位”操作并不专用于scull设备。我实现“交换”操作是为了给出“方向
”字段的所有可能值,而“移位”操作则将“告知”和“查询”操作组合在一起。某些
时候是需要原子性*测试兼设置这类操作的――特别是当应用程序需要加锁和解锁时。

显式的命令基数没有什么特殊意义。它只是用来区分命令的。事实上,由于ioctl号码的
“方向”为会有所不同,你甚至可以在读命令和写命令中使用同一个基数。我选择除了
在声明中使用基数外,其他地方都不使用它,这样我就不必为符号值赋值了。这也是为
什么显式的号码出现在上面的定义中。我只是向你介绍一种使用命令号码的方法,你可
什么显式的号码出现在上面的定义中。我只是向你介绍一种使用命令号码的方法,你可
以自由地采用不同的方法使用它。

当前,参数cmd的值内核并没有使用,而且以后也不可能使用。因此,如果你想偷懒,你
可以省去上面那些复杂的声明,而直接显式地使用一组16位数值。但另一方面,如果你
这样做了,你就无法从使用位字段中受益了。头文件<linux/kd.h>就是这种旧风格方法
的例子,但是它们并不是因为偷懒才这样做的。修改这个文件需要重新编译许多应用程
序。

返回值
ioctl的实现通常就是根据命令号码的一个switch语句。但是,当命令号码不能匹配任何
一个合法操作时,default选择使用是什么?这个问题是很有争议性的。大多数内核函数
返回-EINVAL(“非法参数”),这是由于命令参数确实不是一个合法的参数,这样做是
合适的。然而,POSIX标准上说,如果调用了一个不合适的ioctl命令,应该返回-ENOTTY
。对应的消息是“不是终端”――这不是用户所期望的。你不得不决定是严格依从标准
还是一般常识。我们将本章的后面介绍为什么依从POSIX标准需要返回ENOTTY。

预定义命令
尽管ioctl系统调用大部分都用于操作设备,但还有一些命令是由内核识别的。注意,这
些命令是在你自己的文件操作前调用的,所以如果你选择了和它们相同的命令号码,你
将无法接收到那个命令的请求,而且由于ioctl命令的不唯一性,应用程序会请求一些未
可知的请求。


预定义命令分为3组:用于任何文件(普通,设备,FIFO和套接字文件)的,仅用于普通
文件的以及和文件系统相关的;最后一组命令只能在宿主机文件系统上执行(间chattr
命令)。设备驱动程序编写者仅对第1组感兴趣就可以了,它们的幻数是“T”。分析其
他组的工作将留做读者的练习;ext2_ioctl是其中最有意思的函数(尽管比你想象的要
容易得多),它实现了只追加标志和不可变标志。

下列ioctl命令对任何文件都是预定义的:

FIOCLEX

设置exec时关闭标志(File IOctl Close on EXec)。

FIONCLEX

清除exec时关闭标志。

FIOASYNC

设置或复位文件的同步写。Linux中没有实现同步写;但这个调用存在,这样请求同步写
的应用程序就可以编译和运行了。如果你不知道同步写是怎么回事,你也不用费神去了
解它了:你不需要它。


FIONBIO

“File IOctl Nonblocking I/O(文件ioctl非阻塞型I/O)”(稍后在“阻塞型和非阻
塞型操作”一节中介绍)。该调用修改filp->f_flags中的O_NONBLOCK标志。传递给系统
调用的第3个参数用来表明该标志是设置还是清除。我们将在本章后面谈到它的作用。注
意,fcntl系统调用使用F_SETFL命令也可以修改这个标志。

列表中的最后一项引入了一个新系统调用fcntl,它看起来和ioctl很象。事实上,fcntl
调用与ioctl非常相似,它也有一个命令参数和额外(可选的)一个参数。它和ioctl分
开主要是由于历史原因:当Unix开发人员面对“控制”I/O操作的问题时,他们决定文件
和设备应该是不同的。那时,唯一的设备是终端,这也就解释了为什么-ENOTTY是标准的
非法ioctl命令的返回值。这个问题是是否保持向后兼容性的老问题。

使用ioctl参数
我们需要讲解的最后一点是,在分析scull驱动程序的ioctl代码前,首先弄明白如何使
用那个额外的参数。如果它是一个整数就非常简单了:可以直接使用它。如果它是一个
指针,就必须注意一些问题了。

当用一个指针引用用户空间时,我们首先要确保它指向了合法的用户空间,并且对应页
面当前恰在映射中。如果内核代码试图访问范围之外的地址,处理器就会发出一个异常
。内核代码中的异常将由上至2.0.x的内核转换为oops消息。设备驱动程序应该通过验证
将要访问的用户地址空间的合法性来防止这种失效的发生,如果地址是非法的应该返回
将要访问的用户地址空间的合法性来防止这种失效的发生,如果地址是非法的应该返回
一个错误码。

Linux 2.1中引入新功能之一就是内核代码的异常处理。遗憾的是,正确的实现需要驱动
程序-内核接口的较大改动。本章给出的非法只适用于旧内核,从1.2.13到2.0.x。新接
口将在第17章“近期发展”的“处理内核空间失效”一节中介绍,那里给出的例子通过
某些预处理宏将使支持的内核扩展到2.1.43。

内核1.x.y和2.0.x的地址验证是通过函数verify_area实现的,它的原型定义在<linux/m
m.h>中:

(代码)

第一个参数应该是VERIFY_READ或VERIFY_WRITE,这取决于你要在内存区上完成读还是写
操作。ptr参数是一个用户空间地址,extent是一个字节计数。例如,如果ioctl需要从
用户空间读一个整数,extent就是sizeof(int)。如果在指定的地址上进行读和写操作,
使用VERIFY_WRITE,它是VERIFY_READ的超集。

验证读只检查地址是否是合法的:除此之外,验证写要好检查只读和copy-on-write页面
。copy-on-write页面一个共享可写页面,它还没有被任何共享进程写过;当你验证写时
,verify_area完成“复制兼完成可写配置”操作。很有意思的是,这里无需检查页面是
否“在”内存中,这是由于合法页面将由失效函数正确地进行处理,甚至从内核代码中
调用也可以。我们已经在第3章“字符设备”的“Scull的内存使用”一节中看到内核代
调用也可以。我们已经在第3章“字符设备”的“Scull的内存使用”一节中看到内核代
码可以成功地完成页面失效处理。

象大多数函数一样,verify_area返回一个整数值:0意味着成功,负值代表一个错误,
应该将这个错误返回给调用者。

scull源码在switch之前分析ioctl号码的各个位字段:

(代码)

在调用verify_area之后,再有驱动程序完成真正的数据传送。除了memcpy_tofs和memcp
y_fromfs函数外,程序员还可以使用两个专为常用数据尺寸(1,2和4个字节,在以及64
位平台上的8个字节)优化的函数。这些函数定义在<asm/segment.h>中。

put_user(datum, ptr)

实际上它是一个最终调用__put_user的宏;编译时将其扩展为一条机器指令。驱动程序
应该尽可能使用put_user,而不是memcpy_tofs。由于在宏表达式中不进行类型检查,你
可以传递给put_user任何类型的数据指针,不过它应该是一个用户空间地址。数据传输
的尺寸依赖于ptr参数的类型,这是在编译时通过特殊的gcc伪函数实现的,这里没有介
绍的必要。结果,如果ptr是一个字符指针,就传递1个字节,依此类推分别有2,4和8个
字节。如果被指引的数据不是所支持的尺寸,被编译的代码就会调用函数bad_user_acce
ss_length。如果这些编译代码是一个模块,由于这个符号没有开放,模块就不能加载了
ss_length。如果这些编译代码是一个模块,由于这个符号没有开放,模块就不能加载了


get_user(ptr)

这个宏用来从用户空间获取一个数据。除了数据传输的方向不同外,它与put_user是一
样的。

当insmod不能解析符号时,bad_user_access_length的又臭又长的名字可以当作一个很
有意义的错误信息。这样,开发人员就可以在向大众分布模块前加载和测试模块,他会
很快找到并修改错误。相反,如果使用了不正确尺寸的put_user和get_user直接编译到
了内核中,bad_user_access_length就会导致系统panic。尽管对于尺寸错误的数据传输
来说,oops比其系统panic要友好得多,但还是选择了较为激进的方法来尽力杜绝这种错
误。

scull的ioctl实现只传送设备的可配置参数,其代码非常简单,罗列如下:

(代码)

还有6项是操作scull_qset的。这些操作scull_quantum的一样,为了节省空间,没有在
上面的例子中列出。

从调用者的角度看(即从用户空间),传递和接收参数的6种方法如下所示:
从调用者的角度看(即从用户空间),传递和接收参数的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.h包含了这些宏的定义。

非ioctl设备控制
有时通过向设备自身发送写序列能够更好地完成对设备的控制。例如,这一技术使用在
控制台驱动程序中,它称为“escape序列”,用来控制光标移动,改变默认颜色,或是
完成某些配置任务。用这种方法实现设备控制的好处是,用户仅用写数据就可以完成对
设备的控制,无需使用(有时是写)完成设备配置的程序。

例如,程序setterm通过打印escape序列完成对控制台(或其他终端)的配置。这种方法
的优点是可以远程控制设备。由于可以简单地重定向数据流完成配置工作,控制程序可
以运行在另外一台不同的计算机上,而不一定非要在被控设备的计算机上。你已经在终
端上使用了这项技术,但这项技术可以更通用一些。

“通过打印控制”的缺点是,它给设备增加了策略限制;例如,只有你确认控制序列不
“通过打印控制”的缺点是,它给设备增加了策略限制;例如,只有你确认控制序列不
会出现在正常写到设备的数据中时,这项技术才是可用的。对于终端来说,这只能说是
部分正确。尽管文本显示只意味着显示ASCII字符,但有时控制字符也会出现在正在打印
的数据中,因此会影响控制台的配置。例如,当你对二进制文件进行grep时可能会发生
这样的情况;分解出的字符行可能什么都包含,最后经常会造成控制台的字体错误*。

写控制特别适合这样的设备,不传输数据,仅相应命令,如机器人设备。

例如,我所写的驱动程序之一是控制一个在两个轴上的摄像头的移动。在这个驱动程序
中,“设备”是一对旧的步进马达,它既不能读也不能写。向步进马达“发送数据流”
多少没有多大意义。在这种情况下,驱动程序将所写的数据解释为ASCII命令,并将请求
转换为脉冲,实现对步进马达的控制。命令可以是任何象“向左移动14步”,“达到位
置100,43”或“降低默认速度”之类的字串。驱动程序仅将/dev中的设备节点当作为应
用程序设立的命令通道。对该设备直接控制的优点是,你可以使用cat来移动摄像头,而
无需写并编译发出ioctl调用的特殊代码。

当编写“面向命令的”驱动程序时,没有理由要实现ioctl方法。为解释器多实现一条命
令对于实现和使用来说,都更容易。

好奇的读者可以看看由O’Reilly FTP站点提供的源码stepper目录中的stepper驱动程序
;由于我认为代码没有太大的意义(而且质量也不是太高),这里没有包含它。

阻塞型I/O
阻塞型I/O
read的一个问题是,当尚未有数据可读,而又没有到文件尾时如何处理。

默认的回答是,“我们必须睡眠等待数据。”本节将介绍进程如何睡眠,如何唤醒,以
及一个应用程序如何在不阻塞read调用的情况下,查看是否有数据。对于写来说也可以
适用同样的方法。

通常,在我向你介绍真实的代码前,我将解释若干概念。

睡眠和唤醒
当进程等待事件(可以是输入数据,子进程的终止或是其他什么)时,它需要进入睡眠
状态以便其他进程可以使用计算资源。你可以调用如下函数之一让进程进入睡眠状态:

(代码)

然后用如下函数之一唤醒进程:

(代码)

在前面的函数中,wait_queue指针的指针用来代表事件;我们将在“等待队列”一节中
详细讨论这个数据结构。从现在开始,唤醒进程需要使用进程睡眠时使用的同一个队列
。因此,你需要为每一个可能阻塞进程的事件对应一个等待队列。如果你管理4个设备,
你需要为阻塞读预备4个等待队列,为阻塞写再预备4个。存放这些队列的最佳位置是与
你需要为阻塞读预备4个等待队列,为阻塞写再预备4个。存放这些队列的最佳位置是与
每个设备相关的硬件数据结构(在我们的例子中就是Scull_Dev)。

但“可中断”调用和普通调用有什么区别呢?

sleep_on不能信号取消,但interruptible_sleep_on可以。其实,仅在内核的临界区才
调用sleep_on;例如,当等待从磁盘上读取交换页面时。没有这些页面进程就无法继续
运行,用信号打断这个操作是没有任何意义的。然而,在所谓“长系统调用”,如read
,中要使用interruptible_sleep_on。当进程正等待键盘输入时,用一个信号将进程杀
死是很有意义的。

类似地,wake_up唤醒睡在队列上的任何一个进程,而wake_up_interruptible仅唤醒可
中断进程。

做为一个驱动程序编写人员,由于进程仅在read或write期间才睡眠在驱动程序代码上,
你应该调用interruptible_sleep_on和wake_up_interruptible。不过,事实上由于没有
“不可中断”的进程在你的队列上睡眠,你也可以调用wake_up。但是,出于源代码一致
性的考虑,最好不这样做。(此外,wake_up比它的搭档来说要稍微慢一点。)

编写可重入的代码
当进程睡眠后,驱动程序仍然活着,而且可以由另一个进程调用。让我们一控制台驱动
程序为例。当一个应用在tty1上等待键盘输入,用户切换到tty2上并派生了一个新的外
壳。现在,两个外壳都在控制台驱动程序中等待键盘输入,但它们睡在不同的队列上:
壳。现在,两个外壳都在控制台驱动程序中等待键盘输入,但它们睡在不同的队列上:
一个睡在与tty1相关的队列上,一个睡在与tty2相关的队列上。每个进程都阻塞在inter
ruptible_sleep_on函数中,但驱动程序让可以继续接收和响应其他tty的请求。

可以通过编写“可重入代码”轻松地处理这种情况。可重入代码是不在全局变量中保留
状态信息的代码,因此能够管理交织在一起的调用,而不会将它们混淆起来。如果所有
的状态信息都与进程有关,就不会发生相互干扰。

如果需要状态信息,既可以在驱动程序函数的局部变量中保存(每个进程都有不同的堆
栈来保存局部变量),也可以保存在访问文件用的filp中的private_data中。由于同一
个filp可能在两个进程间共享(通常是父子进程),最好使用局部变量*。

如果你需要保存大规模的状态信息,你可以将指针保存在局部变量中,并用kmalloc获取
实际存储空间。此时,你千万别忘了kfree这些数据,因为当你在内核空间工作时,没有
“在进程终止时释放所有资源”的说法。

你需要将所有调用了sleep_on(或是schedule)的函数写成可重入的,并且包括所有在
这个函数调用轨迹中的所有函数。如果sample_read调用了sample_getdata,后者可能会
阻塞,由于调用它们的进程睡眠后无法阻止另一个进程调用这些函数,sample_read和sa
mple_gendata都必须是可重入的。此外,任何在用户空间和内核空间复制数据的函数也
必须是可重入的,这是因为访问用户空间可能会产生页面失效,当内核处理失效页面时
,进程可以会进入睡眠状态。


等待队列
我听见你在问的下一个问题是,“我到底如何使用等待队列呢?”

等待队列很容易使用,尽管它的设计很是微妙,但你不需要直到它的内部细节。处理等
待队列的最佳方式就是依照如下操作:

l        声明一个struct wait_queue *变量。你需要为每一个可以让进程睡眠的事件
预备这样一个变量。这就是我建议你放在描述硬件特性数据结构中的数据项。

l        将该变量的指针做为参数传递给不同的sleep_on和wake_up函数。

这相当容易。例如,让我们想象一下,当进程读你的设备时,你要让这个进程睡眠,然
后在某人向设备写数据后唤醒这个进程。下面的代码就可以完成这些工作:

(代码)

该设备的这段代码就是例子程序中的sleepy,象往常一样,可以用cat或输入/输出重定
向等方法测试它。

上面列出的两个操作是你唯一操作在等待队列上的两个操作。不过,我知道某些读者对
它的内部结构感兴趣,但通过源码掌握它的内部结构很困难。如果你不对更多的细节感
兴趣,你可以跳过下一节,你不会损失什么的。注意,我谈论的是“当前”实现(版本2
兴趣,你可以跳过下一节,你不会损失什么的。注意,我谈论的是“当前”实现(版本2
..0.x),但没有什么规定限制内核开发人员必须依照那样的实现。如果出现了更好的实
现,内核很容易就会使用新的,由于驱动程序编写人员只能通过那两个合法操作使用等
待队列,对他们来说没有什么坏的影响。

当前struct wait_queue额实现使用了两个字段:一个指向struct task_struct结构(等
待进程)的指针,和一个指向struct wait_queue(链表中的下一个结构)的指针。等待
队列是循环链表,最后一个结构指向第一个结构。

该设计的引入注目的特点是,驱动程序编写人员从来不声明或使用这个结构;他们仅仅
传递它的指针或指针的指针。实际的结构是存在的,但只在一个地方:在__sleep_on函
数的局部变量中,上面介绍的两个sleep_on函数最终会调用这个函数。

这看上去有点奇怪,不过这是一个非常明智的选择,因为无需处理这种结构的分配和释
放。进程每次睡在某个队列上,描述其睡眠的数据结构驻留在进程对应的不对换的堆栈
页中。

当进程加入或从队列中删除时,实际的操作如图5-1所示。

(图5-1 等待队列的工作示意)

阻塞型和非阻塞型操作
在分析功能完整的read和write方法前,我们还需要看看另外一个问题,这就是filp->f_
在分析功能完整的read和write方法前,我们还需要看看另外一个问题,这就是filp->f_
flags中的O_NONBLOCK标志。这个标志定义在<linux/fcntl.h>中,在最近的内核中,这
个头文件由<linux/fs.h>自动包含了。如果你在内核1.2中编译你的模块,你需要手动包
含fcntl.h。

这个标志的名字取自“打开-非阻塞”,因为这个标志可以在打开时指定(而且,最初只
能在打开时指定)。由于进程在等待数据时的正常行为就是睡眠,这个标志默认情况下
是复位的。在阻塞型操作的情况下,应该实现下列操作:

l        如果进程调用read,但(尚)没有数据,进程必须阻塞。当数据到达时,进程
被唤醒,并将数据返回给调用者,即便少于方法的count参数中所请求的数据量,也是如
此。

l        如果进程调用了write,缓冲区又没有空间,进程也必须阻塞,而且它必须使
用与用来实现读的等待队列不同的等待队列。当数据写进设备后,输出缓冲区中空出部
分空间,唤醒进程,write调用成功完成,如果缓冲区中没有请求中count个字节,则进
程可能只是完成了部分写。

前面的列表的两个语句都假设,有一个输入和输出缓冲区,而且每个设备驱动程序都有
一个。输入缓冲区需要用来在数据达到而又没有人读时避免丢失数据,输出缓冲区用来
尽可能增强计算机的性能,尽管这样做不是严格必须的。由于如果系统调用不接收数据
的话,数据仍然保存在用户空间的缓冲区中,write中可以丢失数据。


在驱动程序中实现输出缓冲区可以获得一定的性能收益,这主要是通过较少了用户级/内
核级转换和上下文切换的数目达到的。如果没有输出缓冲区(假设是一个慢设备),每
次系统调用只接收一个或很少几个字节,并且当进程在write中睡眠时,另一进程就会运
行(有一次上下文切换)。当第一个进程被唤醒后,它恢复运行(又一次上下文切换)
,write返回(内核/用户转换),进程还要继续调用系统调用写更多的数据(内核/用户
转换);然后调用再次被阻塞,再次进行整个循环。如果输出缓冲区足够大,write首次
操作时就成功了;数据在中断时被推送给设备,而不必将控制返回用户空间。适合于设
备的输出缓冲区的尺寸显然是和设备相关的。

我们没有在scull中使用输入缓冲区,这是因为当调用read时,数据已经就绪了。类似地
,也没有使用输出缓冲区,数据简单地复制到设备对应的内存区中。我们将在第9章“中
断处理”的“中断驱动的I/O”一节中介绍缓冲区的使用。

如果设置了O_NONBLOCK标志,read和write的行为是不同的。此时,如果进程在没有数据
就绪时调用了read,或者在缓冲区没有空间时调用了write,系统简单地返回-EAGAIN。

如你所料,非阻塞型操作立即返回,允许应用查询数据。当使用stdio函数处理非阻塞型
文件时,由于你很容易误将非阻塞返回认做是EOF,应用程序应该非常小心。你必须始终
检查errno。

你也许可以从它的名字猜到,O_NONBLOCK在open方法也可有作用。当open调用可能会阻
塞很长时间时,就需要O_NONBLOCK了;例如,当打开一个FIFO文件而又(尚)无写者时
塞很长时间时,就需要O_NONBLOCK了;例如,当打开一个FIFO文件而又(尚)无写者时
,或是访问一个被锁住的磁盘文件时。通常,打开设备成功或失败,无需等待外部事件
。但是,有时打开设备需要需要很长时间的初始化,你可以选择打开O_NONBLOCK,如果
设置了标志,在设备开始初始化后,会立即返回一个-EAGAIN(再试一次)。你还可以为
支持访问策略选择实现阻塞型open,方式与文件锁类似。我们稍后将在“替代EBUSY的阻
塞型打开”一节中看到这样一种实现。

只有read,write和open文件操作受非阻塞标志的影响。

样例实现:scullpipe
/dev/scullpipe设备(默认有4个设备)是scull模块的一部分,用来展示如何实现阻塞
型I/O。

在驱动程序内部,阻塞在read调用的进程在数据达到时被唤醒;通常会发出一个中断来
通知这样一种事件,驱动程序在处理中断时唤醒进程。由于你应该无需任何特殊硬件―
―没有任何中断处理函数,就可以在任何计算机上运行scull,scull的目标与传统驱动
程序完全不同。我选择的方法是,利用另一个进程产生数据,唤醒读进程;类似地,用
读进程唤醒写者。这种实现非常类似与一个FIFO(或“命名管道”)文件系统节点的实
现,设备名就出自此。

设备驱动程序使用一个包含两个等待队列和一个缓冲区的设备结构。缓冲区的大小在通
常情况下是可以配置的(编译时,加载时和运行时)。


(代码)

read实现管理阻塞型和非阻塞型数据,如下所示:

(代码)

如你所见,我在代码中留下了PDEBUG语句。当你编译驱动程序时,你可以打开消息,这
样就可以更容易地看到不同进程间的交互了。

跟在interruptible_sleep_on后的if语句处理信号处理。这条语句保证对信号恰当和预
定的处理过程,它会让内核完成系统调用重启或返回-EINTR(内核在内部处理-ERESTART
SYS,最终返回到用户空间的是-EINTR)。我不想让内核对阻塞信号完成这样的处理,主
要时我想忽略这些信号。否则,我们可以返回-ERESTARTSYS错误给内核,让它完成它的
处理工作。我们将在所有的read和write实现中使用一样的语句进行信号处理。

write的实现与read非常相似。它唯一的“特殊”功能时,它从不完全填充缓冲区,总时
留下至少一个字节的空洞。因此,当缓冲区空的时候,wp和rp时相等的;当存在数据时
,它们是不等的。

(代码)

正如我所构想的,设备没有实现阻塞型open,这要比实际的FIFO要简单得多。如果你想
正如我所构想的,设备没有实现阻塞型open,这要比实际的FIFO要简单得多。如果你想
要看看实际的代码,你可以在内核源码的fs/pipe.c中找到那些代码。

要测试scullpipe设备的阻塞型操作,你可以在其上运行一些应用,象往常一样,可以使
用输入/输出重定义等方法。由于普通程序不执行非阻塞型操作,测试非阻塞活动要麻烦
些。misc-progs源码目录中包含了一个很简单的程序,称为nbtest,用它来测试非阻塞
型操作,该程序罗列如下。它所做的就是使用非阻塞型I/O复制它的输入和输出,并在期
间稍做延迟。延迟时间可以通过命令行传递,默认情况下时1秒钟。

(代码)

Select
在使用非阻塞型I/O时,应用程序经常要利用select系统调用,当涉及设备文件时,它依
赖于一个设备方法。这个系统调用还用来实现不同源输入的多路复用。在下面的讨论中
,我假设你知道在用户空间中select的语义的用法。注意,内核2.1.23引入了poll系统
调用,因此为了支持这两个系统调用,它改变驱动程序方法的工作方式。

为了保存所有正在等待文件(或设备)的信息,Linux 2.0的select系统调用的实现使用
了select_table结构。再次提醒你,你无需了解它的内部结构(但不管怎样,我们一会
会稍做介绍),而且只允许调用操作该结构的函数。

当select方法发现无需阻塞时,它返回1;当进程应该等待,它应该“几乎”进入睡眠状
态。在这种情况下,要在select_table结构中加入等待队列,并且返回0。
态。在这种情况下,要在select_table结构中加入等待队列,并且返回0。

仅当选择的文件中没有一个可以接收或返回数据时,进程才真正进入睡眠状态。这一过
程发生在fs/select.c的sys_select中。

写select操作的代码要比介绍它要容易得多,现在就可以scull中时如何实现的:

(代码)

这里没有代码处理“第3种形式的选择”,选择异常。这种形式的选择时通过mode ==
SEL_EX标别的,但大多数时候你都将其编写为默认情况,在其他选择均失败时执行。异
常事件的含义与设备有关,所以你可以选择是否在你自己的驱动程序中实现它们。这种
功能将只会为专为你的驱动程序设计的程序使用,但那并不它的初衷。在这方面,它与
依赖于设备的ioctl调用很相似。在实际使用中,select中异常条件的主要用途是,通知
网络连接上带外(加急)数据的达到,但它还用在终端层以及管道/FIFO实现中(你可以
查看fs/pipe.c中的SEL_EX)。不过要注意,其他Unix系统的管道和FIFO没有实现异常条
件。

这里给出的select代码缺少对文件尾的支持。当read调用达到文件尾时,它应该返回0,
select必须通过通告设备可读来支持这种行为,这样应用程序就不会永远等待调用read
了。例如,在实际的FIFO中,当所有的写者都关闭了文件时,读者会看到文件尾,而在s
cullpipe中,读者永远也看不到文件尾。设计这种不同行为的原因时,一般将FIFO当做
两个进程间的通信通道,而scullpipe是一个只要至少有一个读者,所有人就都可以输入
两个进程间的通信通道,而scullpipe是一个只要至少有一个读者,所有人就都可以输入
数据的垃圾筒。此外,也没有必要重新实现内核中已经有了的设备。

象FIFO那样实现文件尾意味着要在read和读select中检查dev->nwriters,并做相应处理
。不过很遗憾,如果读者在写者前打开设备,它马上就看到文件尾了,没有机会等待数
据到达。最好的解决这个问题的方法是,实现阻塞型open,这个任务做为练习留给读者


与read和write的交互
select调用的目的是事先判断是否有I/O操作会阻塞。从这个方面说,它时对read和writ
e的补充。由于select可以让驱动程序同时等待多个数据流(但这与这里的情况无关),
select在这方面也时很有用途的。

为了让应用正确工作,正确实现这3个调用时非常重要的。尽管下面的规则已经多多少少
谈过了一些,我还要在这里再总结一下。

从设备读取数据
如果在输入缓冲区中有数据,即便比所请求的数据少,而且驱动程序可以保证剩下的数
据会马上达到,read调用应该不经过任何可以察觉的延迟立即返回。如果你至少可以返
回1个字节,而且很方便的话,你总可以返回比请求少的数据(我们在scull就是这样做
的)。当前内核中总线鼠标的实现在这方面就时错的,某些程序(如dd)无法正确读取
这些设备。


如果输入缓冲区中没有数据,在至少有一个字节可读前read必须阻塞,除非设置了O_NON
BLOCK。非阻塞型read立即返回-EAGAIN(尽管在这种情况下某些旧的System V会返回0)
。在至少有一个字节可读前,select必须报告设备不可读。只要有数据可读,我们就使
用上一条规则。

如果我们到了文件尾,,无论是否有O_NONBLOCK,read都应该立即返回0。select应该报
告说文件可读。

向设备写数据
如果输出缓冲区有空间,write应该不做任何延迟返回。它可以接收少于请求数目的数据
,但是它须接收至少一个字节。在这种情况下,select应该报告设备可写。

如果输出缓冲区是满的,在空间释放前write一直阻塞,除非设置了O_NONBLOCK标志。非
阻塞型write立即返回,返回值为-EAGAIN(或者在某些条件为0,如前面旧版本的System
 V所说)。select应该报告文件不可写。但另一方面,如果设备不能接收任何数据,无
论是否设置了O_NONBLOCK,write都返回-ENOSPC(“设备无可用空间”)。

如果使用设备的程序需要确保等候在输出队列中的数据真的完成了传送,驱动程序必须
提供一个fsync方法。例如,可移动设备使用提供fsync入口点。千万不要在调用返回前
让write调用等待数据传送结束。这是因为,需要应用程序都可用select检查设备是否时
可以写的。如果设备报告可以写,write调用应该保持一致,不能阻塞。


刷新待处理输出
我们已经看到write方法为什么不能满足所有数据输出的需求。通过同名系统调用调用的
fsync函数弥补了这一空缺。

如果某些应用需要确保数据传送到设备上,设备就必须实现fsync方法。无论是否设置了
O_NONBLOCK标志,fsync调用应该仅在设备已经完全刷新数据后才能返回,甚至花些时间
也要如此。

fsync方法没有什么不寻常的功能。调用不是时间关键的,所以每个设备驱动程序都可以
 
原创粉丝点击