字符设备驱动程序

本章的目标是编写一个完整的字符设备驱动程序。由于这类驱动程序适合于大多数简单
的硬件设备,我们首先开放一个字符设备驱动程序。字符也相对比较好理解,比如说块
设备驱动程序。我们的最终目标是写一个模块化的字符设备驱动程序,但本章我们不再
讲述有关模块化的问题。

本章通篇都是从一个真实的设备驱动程序截取出的代码块:这个设备就是scull,是“Si
mple Character Utility for Loading Localities”的缩写。尽管scull是一个设备,
但它却是操作内存的字符设备。这种情况的一个副作用就是,只要涉及scull,“设备”
这个词就可以同“scull使用的内存区”互换使用。

scull的优点是,由于每台电脑都有内存,所以它与硬件无关。scull用kmalloc分配内存
,而且仅仅操作内存。任何人都可以编译和运行scull,而且scull可以移植到所有Linux
,而且仅仅操作内存。任何人都可以编译和运行scull,而且scull可以移植到所有Linux
支持的平台上。但另一方面,除了演示内核于字符设备驱动程序间的交互过程,可以让
用户运行某些测试例程外,scull做不了“有用的”事。

scull的设计
编写设备驱动程序的第一步就是定义驱动程序提供给用户程序的能力(“机制”)。由
于我们的“设备”是电脑内存的一部分,我做什么都可以。它可以是顺便存取设备,也
可以是随机存取设备,可以是一个设备,也可以是多个,等等。

为了是scull更有用,可以成为编写真实设备的驱动程序的模板,我将向你展示如何在电
脑的内存之上实现若干设备抽象操作,每一种操作都有自己的特点。

scull的源码实现如下设备。由模块实现的每一种设备都涉及一种类型:

scull0-3

4个设备,共保护了4片内存区,都是全局性的和持久性的。“全局性”是指,如果打开
设备多次,所有打开它的文件描述符共享其中的数据。“持久性”是指,如果设备关闭
后再次打开,数据不丢失。由于可以使用常用命令访问这个设备,如cp,cat以及shell
I/O重定向等,这个设备操作非常有趣;本章将深入探讨它的内部结构。

scullpipe0-3


4个“fifo”设备,操作起来有点象管道。一个进程读取另一个进程写入的数据。如果有
多个进程读同一个设备,他们彼此间竞争数据。通过scullpipe的内部结构可以了解阻塞
型和非阻塞型读/写是如何实现的;没有中断也会出现这样的情况。尽管真实的驱动程序
利用中断与它们的设备同步,但阻塞型和非阻塞型操作是非常重要的内容,从概念上讲
与中断处理(第9章,中断处理,介绍)无关。

scullsingle

scullpriv

sculluid

scullwuid

这些设备与scull0相似,但在何时允许open操作时都不同方式的限制。第一个(scullsi
ngle)只允许一次一个进程使用驱动程序,而scullpriv对每个虚拟控制台是私有的(每
个设备对虚拟控制台是私有的)。sculluid和scullwuid可以多次打开,但每次只能有一
个用户;如果另一个用户锁住了设备,前者返回-EBUSY,而后者则实现为阻塞型open。
通过这些可以展示如何实现不同的访问策略。

每一个scull设备都展示了驱动程序不同的功能,而且都不同的难度。本章主要讲解scul
l0-3的内部结构;第5章,字符设备驱动程序的扩展操作,将介绍更复杂的设备: “一
l0-3的内部结构;第5章,字符设备驱动程序的扩展操作,将介绍更复杂的设备: “一
个样例实现:scullpipe”介绍scullpipe,“设备文件的访问控制”介绍其他设备。

主设备号和次设备号
通过访问文件系统的名字(或“节点”)访问字符设备,通常这些文件位于/dev目录。
设备文件是特殊文件,这一点可以通过ls -l输出的第一列中的“c”标明,它说明它们
是字符节点。/dev下还有块设备,但它们的第一列是“b”;尽管如下介绍的某些内容也
同样适用于块设备,现在我们只关注字符设备。如果你执行ls命令,在设备文件条目的
最新修改日期前你会看到两个数(用逗号分隔),这个位置通常显示文件长度。这些数
就是相应设备的主设备号和次设备号。下面的列表给出了我使用的系统上的一些设备。
它们的主设备号是10,1和4,而次设备号是0,3,5,64-65和128-129。

(代码)

主设备号标识设备对应的驱动程序。例如,/dev/null和/dev/zero都有驱动程序1管理,
而所有的tty和pty都由驱动程序4管理。内核利用主设备号将设备与相应的驱动程序对应
起来。

次设备号只由设备驱动程序使用;内核的其他部分不使用它,仅将它传递给驱动程序。
一个驱动程序控制若干个设备并不为奇(如上面的例子所示)――次顺便号提供了一种
区分它们的方法。

向系统增加一个驱动程序意味着要赋予它一个主设备号。这一赋值过程应该在驱动程序
向系统增加一个驱动程序意味着要赋予它一个主设备号。这一赋值过程应该在驱动程序
(模块)的初始化过程中完成,它调用如下函数,这个函数定义在<linux/fs.h>:

(代码)

返回值是错误码。当出错时返回一个负值;成功时返回零或正值。参数major是所请求的
主设备号,name是你的设备的名字,它将在/proc/devices中出现,fops是一个指向跳转
表的指针,利用这个跳转表完成对设备函数的调用,本章稍后将在“文件操作”一节中
介绍这些函数。

主设备号是一个用来索引静态字符设备数组的整数。在1.2.13和早期的2.x内核中,这个
数组有64项,而2.0.6到2.1.11的内核则升至128。由于只有设备才处理次设备号,regis
ter_chrdev不传递次设备号。

一旦设备已经注册到内核表中,无论何时操作与你的设备驱动程序的主设备号匹配的设
备文件,内核都会通过在fops跳转表索引调用驱动程序中的正确函数。

接下来的问题就是如何给程序一个它们可以请求你的设备驱动程序的名字。这个名字必
须插入到/dev目录中,并与你的驱动程序的主设备号和次设备号相连。

在文件系统上创建一个设备节点的命令是mknod,而且你必须是超级用户才能创建设备。
除了要创建的节点名字外,该命令还带三个参数。例如,命令:


(代码)

创建一个字符设备(c),主设备号是127,次设备号是0。由于历史原因,次设备号应该
在0-255范围内,有时它们存储在一个字节中。存在很多原因扩展可使用的次设备号的范
围,但就现在而言,仍然有8位限制。

动态分配主设备号
某些主设备号已经静态地分配给了大部分公用设备。在内核源码树的Documentation/dev
ice.txt文件中可以找到这些设备的列表。由于许多数字已经分配了,为新设备选择一个
唯一的号码是很困难的――不同的设备要不主设备号多得多。

很幸运(或是感谢某些人天才),你可以动态分配主设备号了。如果你调用register_ch
rdev时的major为零的话,这个函数就会选择一个空闲号码并做为返回值返回。主设备号
总是正的,因此不会和错误码混淆。

我强烈推荐你不要随便选择一个一个当前不用的设备号做为主设备号,而使用动态分配
机制获取你的主设备号。

动态分配的缺点是,由于分配给你的主设备号不能保证总是一样的,无法事先创建设备
节点。然而这不是什么问题,这是因为一旦分配了设备号,你就可以从/proc/devices读
到。为了加载一个设备驱动程序,对insmod的调用被替换为一个简单的脚本,它通过/pr
oc/devices获得新分配的主设备号,并创建节点。
oc/devices获得新分配的主设备号,并创建节点。

/proc/devices一般如下所示:

(代码)

加载动态分配主设备号驱动程序的脚本可以利用象awk这类工具从/proc/devices中获取
信息,并在/dev中创建文件。

下面这个脚本,scull_load,是scull发行中的一部分。使用以模块形式发行的驱动程序
的用户可以在/etc/rc.d/rc.local中调用这个脚本,或是在需要模块时手工调用。此外
还有另一种方法:使用kerneld。这个方法和其他模块的高级功能将在第11章“Kerneld
和高级模块化”中介绍。

(代码)

这个脚本同样可以适用于其他驱动程序,只要重新定义变量和调整mknod那几行就可以了
。上面那个脚本创建4个设备,4是scull源码中的默认值。

脚本的最后两行看起来有点怪怪的:为什么要改变设备的组和权限呢?原因是这样的,
由root创建的节点自然也属于root。默认权限位只允许root对其有写访问权,而其他只
有读权限。正常情况下,设备节点需要不同的策略,因此需要进行某些修改。通常允许
一组用户访问对设备,但实现细节却依赖于设备和系统管理员。安全是个大问题,这超
一组用户访问对设备,但实现细节却依赖于设备和系统管理员。安全是个大问题,这超
出了本书的范围。scull_load中的chmod和chgrp那两行仅仅是最为处理权限问题的一点
提示。稍后,在第5章的“设备文件的访问控制”一节中将介绍sculluid源码,展示设备
驱动程序如何实现自己的设备访问授权。

如果重复地创建和删除/dev节点似乎有点过分的话,有一个解决的方法。如果你看了内
核源码fs/devices.c的话,你可以看到动态设备号是从127(或63)之后开始的,你可以
用127做为主设备号创建一个长命节点,同时可以避免在每次相关设备加载时调用脚本。
如果你使用了几个动态设备,或是新版本的内核改变了动态分配的特性,这个技巧就不
能用了。(如果内核发生了修改,基于内核内部结构编写的代码并不能保证继续可以工
作。)不管怎样,由于开发期间模块要不断地加载 托对 ,你会发现这一技术在开发期
间还是很有用的。

就我看来,分配主设备号的最佳方式是,默认采用动态分配,同时留给你在加载时,甚
至是编译时,指定主设备号的余地。使用我建议的代码将与自动端口探测的代码十分相
似。scull的实现使用了一个全局变量,scull_major,来保存所选择的设备号。该变量
的默认值是SCULL_MAJOR,在所发行的源码中为0,即“选择动态分配”。用户可以使用
这个默认值或选择某个特定的主设备号,既可以在编译前修改宏定义,也可以在ins_mod
命令行中指定。最后,通过使用scull_load脚本,用户可以在scull_load中命令行中将
参数传递给insmod。

这里是我在scull.c中使用的获取主设备号的代码:


(代码)

从系统中删除设备驱动程序
当从系统中卸载一个模块时,应该释放主设备号。这一操作可以在cleanup_module中调
用如下函数完成:

(代码)

参数是要释放的主设备号和相应的设备名。内核对这个名字和设备号对应的名字进行比
较:如果不同,返回-ENINVAL。如果主设备号超出了所允许的范围或是并未分配给这个
设备,内核一样返回-EINVAL。在cleanup_module中注销资源失败会有非常不号的后果。
下次读取/proc/devices时,由于其中一个name字串仍然指向模块内存,而那片内存已经
不存在了,系统将产生一次失效。这种失效称为Oops*,内核在访问无效地址时将打印这
样的消息。

当你卸载驱动程序而又无法注销主设备号时,这种情况是无法恢复的,即便为此专门写
一个“补救”模块也无济于事,因为unregister_chrdev中调用了strcmp,而strcmp将使
用未映射的name字串,当释放设备时就会使系统Oops。无需说明,任何视图打开这个异
常的设备号对应的设备的操作都会Oops。

除了卸载模块,你还经常需要在卸载驱动程序时删除设备节点。如果设备节点是在加载
时创建的,可以写一个简单的脚本在卸载时删除它们。对于我们的样例设备,脚本scull
时创建的,可以写一个简单的脚本在卸载时删除它们。对于我们的样例设备,脚本scull
_unload完成这个工作。如果动态节点没有从/dev中删除,就会有可能造成不可预期的错
误:如果动态分配的主设备号相同,开发者计算机上的一个空闲/dev/framegrabber就有
可能在一个月后引用一个火警设备。“没有这个文件或目录”要比这个新设备所产生的
后果要好得多。

dev_t和kdev_t
到目前为止,我们已经谈论了主设备号。现在是讨论次设备号和驱动程序如何使用次设
备号来区分设备的时候了。

每次内核调用一个设备驱动程序时,它都告诉驱动程序它正在操作哪个设备。主设备号
和次设备号合在一起构成一个数据类型并用来标别某个设备。设备号的组合(主设备号
和次设备号合在一起)驻留在稍后介绍的“inode”结构的i_rdev域中。每个驱动程序接
收一个指向struct inode的指针做为第一个参数。这个指针通常也称为inode,函数可以
通过查看inode->i_rdev分解出设备号。

历史上,Unix使用dev_t保存设备号。dev_t通常是<sys/types.h>中定义的一个16位整数
。而现在有时需要超过256个次设备号,但是由于有许多应用(包括C库在内)都了解dev
_t的内部结构,改变dev_t是很困难的,如果改变dev_t的内部结构就会造成这些应用无
法运行。因此,dev_t类型一直没有改变;它仍是一个16位整数,而且次设备号仍限制在
0-255内。然而,在Linux内核内部却使用了一个新类型,kdev_t。对于每一个内核函数
来说,这个新类型被设计为一个黑箱。它的想法是让用户程序不能了解kdev_t。如果kde
v_t一直是隐藏的,它可以在内核的不同版本间任意变化,而不必修改每个人的设备驱动
v_t一直是隐藏的,它可以在内核的不同版本间任意变化,而不必修改每个人的设备驱动
程序。

有关kdev_t的信息被禁闭在<linux/kdev_t.h>中,其中大部分是注释。如果你对代码后
的哲学感兴趣的话,这个头文件是一段很有指导性的代码。因为<linux/fs.h>已经包含
了这个头文件,没有必要显式地包含这个文件。

不幸的是,kdev_t类型是一个“现代”概念,在内核版本1.2中没有这个类型。在较新的
内核中,所有的引用设备的内核变量和结构字段都是kdev_t的,但是在1.2.13中同样的
变量却是dev_t的。如果你的驱动程序只使用它接收的结构字段,而不声明自己的变量的
话,这不会有什么问题的。如果你需要声明自己的设备类型变量,为了可移植性你应该
在你的头文件中加入如下几行:

(代码)

这段代码是样例源码中的sysdep.h头文件的一部分。我不会在源码中在引用dev_t,但是
要假设前一个条件语句已经执行了。

如下这些宏和函数是你可以对kdev_t执行的操作:

MAJOR(kdev_t dev);

从kdev_t结构中分解出主设备号。
从kdev_t结构中分解出主设备号。

MINOR(kdev_t dev);

分解出次设备号。

MKDEV(int ma, int mi);

通过主设备号和次设备号返回kdev_t。

kdev_t_to_nr(kdev_t dev);

将kdev_t转换为一个整数(dev_t)。

to_kdev_t(int dev);

将一个整数转换为kdev_t。注意,核心态中没有定义dev_t,因此使用了int。

与Linux 1.2相关的头文件定义了同样的操作dev_t的函数,但没有那两个转换函数,这
也就是为什么上面那个条件代码简单地将它们定义返回它们的参数值。

文件操作
在接下来的几节中,我们将看看驱动程序能够对它管理的设备能够完成哪些不同的操作
在接下来的几节中,我们将看看驱动程序能够对它管理的设备能够完成哪些不同的操作
。在内核内部用一个file结构标别设备,而且内核使用file_operations结构访问驱动程
序的函数。这一设计是我们所看到的Linux内核面向对象设计的第一个例证。我们将在以
后看到更多的面向对象设计的例证。file_operations结构是一个定义在<linux/fs.h>中
的数指针表。结构struct file将在以后介绍。

我们已经register_chrdev调用中有一个参数是fops,它是一个指向一组操作(open,re
ad等等)表的指针。这个表的每一个项都指向由驱动程序定义的处理相应请求的函数。
对于你不支持的操作,该表可以包含NULL指针。对于不同函数的NULL指针,内核具体的
处理行为是不同的,下一节将逐一介绍。

随着新功能不断加入内核,file_operations结构已逐渐变得越来越大(尽管从1.2.0到2
..0.x并没有增加新字段)。这种增长应该不会有什么副作用,因为在发现任何尺寸不匹
配时,C编译器会将全局或静态struct变量中的未初始化字段填0。新的字段都加到结构
的末尾*,所以在编译时会插入一个NULL指针,系统会选择默认行为(记住,对于所有模
块需要加载的新内核,都要重新编译一次模块)。

在2.1开发用内核中,有些与fops字段相关的函数原型发生了变化。这些变化将在第17章
“近期发展”的“文件操作”一节中介绍。

纵览不同操作
下面的列表将介绍应用程序能够对设备调用的所有操作。这些操作通常称为“方法”,
用面向对象的编程术语来说就是说明一个对象声明可以操作在自身的动作。
用面向对象的编程术语来说就是说明一个对象声明可以操作在自身的动作。

为了使这张列表可以用来当作索引,我尽量使它简洁,仅仅介绍每个操作的梗概以及当
使用NULL时的内核默认行为。你可以在初次阅读时跳过这张列表,以后再来查阅。

在介绍完另一个重要数据结构(file)后,本章的其余部分将讲解最重要的一些操作并
提供一些提示,告诫和真实的代码样例。由于我们尚不能深入探讨内存管理和异步触发
机制,我们将在以后的章节中介绍这些更为复杂操作。

struct file_operations中的操作按如下顺序出现,除非注明,它们的返回0时表示成功
,发生错误时返回一个负的错误编码:

int (*lseek)(struct inode *, struct file *, off_t, int);

方法lseek用来修改一个文件的当前读写位置,并将新位置做为(正的)返回值返回。出
错时返回一个负的返回值。如果驱动程序没有设置这个函数,相对与文件尾的定位操作
失败,其他定位操作修改file结构(在“file结构”中介绍)中的位置计数器,并成功
返回。2.1.0中该函数的原型发生了变化,第17章“原型变化”将讲解这些内容。

int (*read)(struct inode *, struct file *, char *, int);

用来从设备中读取数据。当其为NULL指针时将引起read系统调用返回-EINVAL(“非法参
数”)。函数返回一个非负值表示成功的读取了多少字节。
数”)。函数返回一个非负值表示成功的读取了多少字节。

int (*write)(struct inode *, struct file *, const char *, int);

向设备发送数据。如果没有这个函数,write系统调用向调用程序返回一个-EINVAL。注
意,版本1.2的头文件中没有const这个说明符。如果你自己在write方法中加入了const
,当与旧头文件编译时会产生一个警告。如果你没有包含const,新版本的内核也会产生
一个警告;在这两种情况你都可以简单地忽略这些警告。如果返回值非负,它就表示成
功地写入的字节数。

int (*readdir)(struct inode *, struct file *, void *, filldir_t);

对于设备节点来说,这个字段应该为NULL;它仅用于目录。

int (*select)(struct inode *, struct file *, int, select_table *);

select一般用于程序询问设备是否可读和可写,或是否一个“异常”条件发生了。如果
指针为NULL,系统假设设备总是可读和可写的,而且没有异常需要处理。“异常”的具
体含义是和设备相关的。在当前的2.1开发用内核中,select的实现方法完全不同。(见
第17章的“poll方法”)。返回值告诉系统条件满足(1)或不满足(0)。

int (*ioctl)(struct inode *, struct file *, unsigned int, unsigned long);


系统调用ioctl提供一中调用设备相关命令的方法(如软盘的格式化一个磁道,这既不是
读操作也不是写操作)。另外,内核还识别一部分ioctl命令,而不必调用fops表中的io
ctl。如果设备不提供ioctl入口点,对于任何内核没有定义的请求,ioctl系统调用将返
回-EINVAL。当调用成功时,返回给调用程序一个非负返回值。

int (*mmap)(struct inode *, struct file *, struct vm_area_struct *);

mmap用来将设备内存映射到进程内存中。如果设备不支持这个方法,mmap系统调用将返
回-ENODEV。

int (*open)(struct inode *, struct file *);

尽管这总是操作在设备节点上的第一个操作,然而并不要求驱动程序一定要声明这个方
法。如果该项为NULL,设备的打开操作永远成功,但系统不会通知你的驱动程序。

void (*release)(struct inode *, struct file *);

当节点被关闭时调用这个操作。与open相仿,release也可以没有。在2.0和更早的核心
中,close系统调用从不失败;这种情况在版本2.1.31中有所变化(见第17章)。

int (*fsync)(struct inode *, struct file *);


刷新设备。如果驱动程序不支持,fsync系统调用返回-EINVAL。

int (*fasync)(struct inode *, struct file *, int);

这个操作用来通知设备它的FASYNC标志的变化。异步触发是比较先进的话题,将在第5章
的“异步触发”一节中介绍。如果设备不支持异步触发,该字段可以是NULL。

int (*check_media_change)(kdev_t dev);

check_media_change只用于块设备,尤其是象软盘这类可移动介质。内核调用这个方法
判断设备中的物理介质(如软盘)自最近一次操作以来发生了变化(返回1)或是没有(
0)。字符设备无需实现这个函数。

int (*revalidate)(kdev_t dev);

这是最后一项,与前面提到的那个方法一样,也只适用于块设备。revalidate与缓冲区
高速。缓存有关。我们将在第12章“加载块设备驱动程序”的“可移动设备”中介绍rev
alidate。

scull驱动程序中适用的file_operations结构如下:

(代码)
(代码)

在最新的开发用内核中,某些原型已经发生了变化。该列表是从2.0.x的头文件中提炼出
来的,这里给出的原型对于大多数内核而言都是正确的。内核2.1引入的变化(以及为了
使我们的模块可移植所进行的修改)在针对不同操作的每一节和第17章的“文件操作”
中详细介绍。

file结构
在<linux/fs.h>中定义的struct file是设备驱动程序所适用的又一个最重要的数据结构
。注意,file与用户程序中的FILE没有任何关联。FILE是在C库中定义且从不出现在内核
代码中。而struct file是一个内核结构,从不出现在用户程序中。

file结构代表一个“打开的文件”。它有内核在open时创建而且在close前做为参数传递
给如何操作在设备上的函数。在文件关闭后,内核释放这个数据结构。一个“打开的文
件”与由struct inode表示的“磁盘文件”有所不同。

在内核源码中,指向struct file的指针通常称为file或filp(“文件指针”)。为了与
这个结构相混淆,我将一直称指针为filp-flip是一个指针(同样,它也是设备方法的
参数之一),而file是结构本身。

struct file中的最重要的字段罗列如下。与上节相似,这张列表在首次阅读时可以略过
。在下一节中,我们将看到一些真正的C代码,我将讨论某些字段,到时你可以反过来查
阅这张列表。
阅这张列表。

mode_t f_mode;

文件模式由FMODE_READ和FMODE_WRITE标别。你可能需要在你的ioctl函数中查看这个域
来来检查读/写权限,但由于内核在调用你的驱动程序的read和write前已经检查了权限
,你无需检查在这两个方法中检查权限。例如,一个不允许的写操作在驱动程序还不知
道的情况下就被已经内核拒绝了。

loff_t f_ops;

当然读/写位置。loff_t是一个64位数值(用gcc的术语就是long long)。如果驱动程序
需要知道这个值,可以直接读取这个字段。如果定义了lseek方法,应该更新f_pos的值
。当传输数据时,read和write也应该更新这个值。

unsigned short f_flags;

文件标志,如O_RDONLY,O_NONBLOCK和O_SYNC。驱动程序为了支持非阻塞型操作需要检
查这个标志,而其他标志很少用到。注意,检查读/写权限应该查看f_mode而不是f_flag
s。所有这些标志都定义在<linux/fcntl.h>中。

struct inode *f_inode;


打开文件所对应的i节点。inode指针是内核传递给所有文件操作的第一个参数,所以你
一般不需要访问file结构的这个字段。在某些特殊情况下你只能访问struct file时,你
可以通过这个字段找到相应的i节点。

struct file_operations *f_op;

与文件对应的操作。内核在完成open时对这个指针赋值,以后需要分派操作时就读这些
数据。filp->f_op中的值从不保存供以后引用;这也就是说你可以在需要的事后修改你
的文件所对应的操作,下一次再操作那个打开文件的相应操作时就会调用新方法。例如
,主设备号为1的设备(/dev/null,/dev/zero等等)的open代码根据要打开的次设备号
替换filp->f_op中的操作。这种技巧有助于在不增加系统调用负担的情况下方便识别主
设备号相同的设备。能够替换文件操作的能力在面向对象编程技术中称为“方法重载”


void *private_data;

系统调用open在调用驱动程序的open方法前将这个指针置为NULL。驱动程序可以将这个
字段用于任意目的或者忽略简单忽略这个字段。驱动程序可以用这个字段指向已分配的
数据,但是一定要在内核释放file结构前的release方法中清除它。private_data是跨系
统调用保存状态信息的非常有用的资源,在我们的大部分样例都使用了这个资源。

实际的结构里还有其他一些字段,但它们对于驱动程序并不是特别有用。由于驱动程序
实际的结构里还有其他一些字段,但它们对于驱动程序并不是特别有用。由于驱动程序
从不填写file结构;它们只是简单地访问别处创建的结构,我们可以大胆地忽略这些字
段。

Open和Close
现在让我们已经走马观花地看了一遍这些字段,下面我们将开始在实际的scull函数中使
用这些字段。

Open方法
open方法是驱动程序用来为以后的操作完成初始化准备工作的。此外,open还会增加设
备计数,以便防止文件在关闭前模块被卸载出内核。

在大部分驱动程序中,open完成如下工作:

l        检查设备相关错误(诸如设备未就绪或相似的硬件问题)。

l        如果是首次打开,初始化设备。

l        标别次设备号,如有必要更新f_op指针。

l        分配和填写要放在filp->private_data里的数据结构。

l        增加使用计数。
l        增加使用计数。

在scull中,上面的大部分操作都要依赖于被打开设备的次设备号。因此,首先要做的事
就是标别要操作的是哪个设备。我们可以通过查看inode->i_rdev完成。

我们已经谈到内核是如何不使用次设备号的了,因此驱动程序可以随意使用次设备号。
事实上,利用不同的次设备号访问不同的设备,或以不同的方式打开同一个设备。例如
,/dev/ttyS0和/dev/ttyS1是两个不同的串口,而/dev/cua0的物理设备与/dev/ttyS0相
同,仅仅是操作行为不同。cua是“调出”设备;它们不是终端,而且它们也没有终端所
需要的所有软件支持(即,它们没有加入行律*)。所有的串口设备都有许多不同的次设
备号,这样驱动程序就区分它们了:ttyS与cua不一样。

驱动程序从来都不知道被打开的设备的名字,它仅仅知道设备号――而且用户可以按照
自己的规范给用设备起别名,而完全不用原有的名字。如果你看看/dev目录就会知道,
你将发现对应相同主/次设备号的不同名字;设备只有一个而且是相同的,而且没有方法
区分它们。例如,在许多系统中,/dev/psaux和/dev/bmouseps2都存在,而且它们有同
样的设备号;它们可以互换使用。后者是“历史遗迹”,你的系统里可以没有。

scull驱动程序是这样使用次设备号的:最高4位标别设备类型个体(personality),如
果该类型可以支持多实例(scull0-3和scullpipe0-3),低4位可以供你标别这些设备。
因此,scull0的高4位与scullpipe0不同,而scull0的低4位与scull1不同*。源码中定义
了两个宏(TYPE和NUM)从设备号中分解出这些位,我们马上就看到这些宏。


对于每一设备类型,scull定义了一个相关的file_operations结构,并在open时替换fil
p->f_op。下面的代码就是位切分和多fops是如何实现的:

(代码)

内核根据主设备号调用open;scull用上面给出的宏处理次设备号。接着用TYPE索引scul
l_fop_array数组,从中分解出被打开设备的方法集。

我在scull中所做的就是根据次设备号的类型给filp->f_op赋上正确的值。然后调用新的
fops中定义的open方法。通常,驱动程序不必调用自己的fops,它只有内核分配正确的
驱动程序方法时调用。但当你的open方法不得不处理不同设备类型时,在根据被打开设
备次设备号修改fops指针后就需要调用fops->open了。

scull_open的实际代码如下。它使用了前面那段代码中定义的TYPE和NUM两个宏来切分次
设备号:

(代码)

这里给一点解释。用来保存内存区的数据结构是Scull_Dev,这里简要介绍一下。Scull_
Dev和scull_trim(“Scull的内存使用”一节中讨论)的内部结构这里并没有使用。全
局变量scull_nr_devs和scull_devices[](全部小写)分别是可用设备数和指向Scull_D
ev的指针数组。
ev的指针数组。

这段代码看起来工作很少,这是因为当调用open时它没做任何针对某个设备的处理。由
于scull0-3设备被设计为全局的和永久性的,这段代码无需做什么。特别是,由于我们
无法维护scull的打开计数,也就是模块的使用计数,因此没有类似于“首次打开时初始
化设备”这类动作。

唯一实际操作在设备上的操作是,当设备写打开时将设备截断为长度0。截断是scull设
计的一部分:用一个较短的文件覆盖设备,以便缩小设备数据区,这与普通文件写打开
截断为0很相似。

但“打开是截断”有一个严重的缺点:若干因为某些原因设备内存正在使用,释放这些
内存会导致系统失效。尽管可能性不大,这种情况总会发生:如果read和write方法在数
据传输时睡眠了,另一个进程可能写打开这个设备,这时麻烦就来了。处理竞争条件是
一个相当高级的主题,我将在第9章的“竞争条件”中讲解。scull处理这个问题的简单
方法就是在内存还是使用时不释放内存,“Scull的内存使用”一节中将说明。

以后我们看到其他scull个体(personality)时将会看到一个真正的初始化工作如何完
成。

release方法
release方法的作用正好与open相反。这个设备方法有时也称为close。它应该:


l        使用计数减1。

l        释放open分配在filp->private_data中的内存。

l        在最后一次关闭操作时关闭设备。

scull的基本模型无需进行关闭设备动作,所以所需代码是很少的*:

(代码)

使用计数减1是非常重要的,因为如果使用计数不归0,内核是不会卸载模块的。

如果某个时刻一个从没被打开的文件被关闭了计数将如何保证一致呢?我们都知道,dup
和fork都会在不调用open的情况下,将一个打开文件复制为2个,但每一个都会在程序终
止时关闭。例如,大多数程序从来不打开它们的stdin文件(或设备),但它们都会在终
止关闭它。

答案很简单。如果open没有调用,release也不会调。内核维护一个file结构被使用了多
少次的使用计数。无论是fork还是dup都不创建新的数据结构;它们仅是增加已有结构的
计数。

新的struct file仅由open创建。只有在该结构的计数归0时close系统调用才会执行clos
新的struct file仅由open创建。只有在该结构的计数归0时close系统调用才会执行clos
e方法,这只有在删除这个结构时才会进行。close方法与close系统调用间的关系保证了
模块使用计数永远是一致的。

Scull的内存使用
在介绍读写操作以前,我们最好先看看scull如何完成内存分配以及为什么要完成内存分
配。为了全面理解代码我们需要知道“如何分配”,而“为什么”则反映了驱动程序编
写者需要做出的选择,尽管scull绝不是一个典型设备,但同样需要。

本节只讲解scull中的内存分配策略,而不会讲解你写实际驱动程序时需要的硬件管理技
巧。这些技巧将在第8章“硬件管理”和第9章中介绍。因此,如果你对针对内存操作的s
cull驱动程序的内部工作原理不感兴趣的话,你可以跳过这一节。

scull使用的内存,这里也称为“设备”,是变长的。你写的越多,它就增长得越多;消
减的过程只在用短文件覆盖设备时发生。

所选的实现scull的方法不是很聪明。实现较聪明的源码会更难读,而且本节的目的只是
讲解read和write,而不是内存管理。这也就是为什么虽然整个页面分配会更有效,但代
码只使用了kmalloc和kfree,而没有涉及整个页面的分配的操作。

而另一面,从理论和实际角度考虑,我又不想限制“设备”区的尺寸。理论上将,给所
管理的数据项强加任何限制总是很糟糕的想法。从实际出发,为了测试系统在内存短缺
时的性能,scull可以帮助将系统的剩余内存用光。进行这样的测试有助于你理解系统的
时的性能,scull可以帮助将系统的剩余内存用光。进行这样的测试有助于你理解系统的
内部行为。你可以使用命令cp /dev/zero /dev/scull用光所有的物理内存,而且你也可
以用工具dd选择复制到scull设备中多少数据。

在scull中,每个设备都是一组指针的链表,而每一个指针又指向一个Scull_Dev结构。
每一个这样的结构通过一个中间级指针数组最多可引用4,000,000个字节。发行的源码中
使用了一个有1000个指针的数组,每个指针指向4000个字节。我把每一个内存区称为一
个“量子”,数组(或它的长度)称为“量子集”。scull设备和它的内存区如图3-1所
示。

所选择的数字是这样的,向scull写一个字节就会消耗内存8000了字节:每个量子4个,
量子集4个(在大多数平台上,一个指针是4个字节;当在Alpha平台编译时量子集本身就
会耗费8000个字节,在Alpha平台上指针是8个字节)。但另一方面,如果你向scull写大
量的数据,由于每4MB数据只对应一个表项,而且设备的最大尺寸只限于若干MB,不可能
超出计算机内存的大小,遍历这张链表的代价不是很大。

为量子和量子集选择合适的数值是一个策略问题,而非机制问题,而且最优数值依赖于
如何使用设备。源码中为处理这些问题允许用户修改这些值:

l        在编译时,可以修改scull.h中的SCULL_QUANTUM和SCULL_QSET。

l        在加载时,可以利用insmod修改scull_quantum和scull_qset整数值。


l        在运行时,用ioctl方法改变默认值和当前值。ioctl将在第5章的“ioctl”一
节中介绍。

使用宏和整数值进行编译时和加载时配置让人想起前面提到的如何选择主设备号。无论
何时驱动程序需要一个随意的数值或这个数值与策略相关,我都使用这种技术。

留下来的唯一问题就是如何选择默认数值。尽管有时驱动程序编写者也需要事先调整配
置参数,但他们在编写自己的模块时不会碰到同样的问题。在这个特殊的例子里,问题
的关键在于寻找因未填满的量子和量子集导致的内存浪费和量子和量子集太小带来的分
配、释放和指针连接等操作的代价之间的平衡。

此外,还必须考虑kmalloc的内部设计。现在我们还无法讲述太多的细节,只能简单规定
“比2次幂稍小一点是最佳尺寸”比较好。kmalloc的内部结构将在第7章“Getting
Hold of Memory”的“The Real Story of kmalloc”一节中探讨。

默认数值的选择基于这样的假设,大部分程序员不会受限与4MB的物理内存,那样大的数
据量有可能会写到scull中。一台内存很多的计算机的属主可能因测试向设备写数十MB的
数据。因此,所选的默认值是为了优化中等规模的系统和大数据量的使用。

保存设备信息的数据机构如下:

(代码)
(代码)

下面的代码给出了实际工作时是如何利用Scull_Dev保存数据的。其中给出的函数负责释
放整个数据区,并且在文件写打开时由scull_open调用。如果当前设备内存正在使用,
该函数就不释放这些内存(象“open方法”中所说那样);否则,它简单地遍历链表,
释放所有找到的量子和量子集。

(代码)

读和写
读写scull设备也就意味着要完成内核空间和用户进程空间的数据传输。由于指针只能在
当前地址空间操作,而驱动程序运行在内核空间,数据缓冲区则在用户空间,这一操作
不能通过通常利用指针或memcpy完成。

由于驱动程序不过怎样都要在内核空间和用户缓冲区间复制数据,如果目标设备不是RAM
而是扩展卡,也有同样的问题。事实上,设备驱动程序的主要作用就是管理设备(内核
空间)和应用(用户空间)间的数据传输。

在Linux里,跨空间复制是通过定义在<asm/segment.h>里的特殊函数实现的。完成这种
操作函数针对不同数据尺寸(char,short,int,long)进行了优化;它们中的大部分
将在第5章的“使用ioctl参数”一节中介绍。

scull中read和write的驱动程序代码需要完成到用户空间和来自用户空间的整个数据段
scull中read和write的驱动程序代码需要完成到用户空间和来自用户空间的整个数据段
的复制。下面这些提供这些功能,它们可以传输任意字节:

(代码)

这两个函数的名字可以追溯到第1版Linux,那时唯一支持的体系结构是i386,而且C代码
中还可以窥见许多汇编码。在Intel平台上,Linux通过FS段寄存器访问用户空间,到Lin
ux 2.0时仍沿用了以前的名字。在Linux 2.1中它们改变了,但是2.0是本书的主要目标
。详情可见第17章的“访问用户空间”。

尽管上面介绍的函数看起来很象正常的memcpy函数,但当在内核代码中访问用户空间时
必须额外注意一些问题;正在被访问的用户页面现在可能不在内存中,而且页面失效处
理函数有可能在传送页面的时候让进程进入睡眠状态。例如,必须从交换区读取页面时
会发生这种情况。对驱动程序编写者来说,静效果就是对于任何访问用户空间的函数都
必须是可重入的,而且能够与其他驱动程序函数并发执行。这就是为什么scull实现中不
允许在dev->usage不为0时释放设备:read和write方法在它们使用memcpy函数前先完成u
sage计数加1。

现在谈谈实际的设备方法,读方法的任务是将数据从设备复制到用户空间(使用memcpy_
tofs),而写方法必须将数据从用户空间复制到设备(使用memcpy_tofs)。每一个read
或write系统调用请求传输一定量的字节,但驱动程序可以随意传送其中一部分数据――
读与写的具体规则稍有不同。

允许在dev->usage不为0时释放设备:read和write方法在它们使用memcpy函数前先完成u
sage计数加1。

现在谈谈实际的设备方法,读方法的任务是将数据从设备复制到用户空间(使用memcpy_
tofs),而写方法必须将数据从用户空间复制到设备(使用memcpy_tofs)。每一个read
或write系统调用请求传输一定量的字节,但驱动程序可以随意传送其中一部分数据――
读与写的具体规则稍有不同。

如果有错误发生,read和write都返回一个负值。返回给调用程序一个大于等于0的数值
,告诉它成功传输了多少字节。如果某个数据成功地传输了,随后发生了错误,返回值
必须是成功传输
 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值