实验9 设备驱动程序
在钻研Linux内核的人当中,大多数人都是在写设备驱动程序。尽管由于设备的特殊性,使得每个驱动程序都不一样。但是编写设备驱动程序的许多原则和基本技巧都是一样的,甚至Windows下的设备驱动程序从原理上讲也与之相通。在这一章的练习中,我们将通过分析一个典型的块设备RAM-DISK的驱动程序,学习编写设备驱动程序的一般过程。作为练习,需要将这个RAM-DISK的程序改造成为U盘的驱动程序,并通过它来使用你的U盘。
值得注意的是,在目前常用的Linux内核2.4版本中,设备驱动程序的编写方法发生了比较大的变化,然而目前大部分参考书中仍然以内核2.2甚至2.0版本作为例子。如果本章的内容与其他参考书不符,请留意是否由于内核版本的不同而造成了这些不一致。
9.1 Linux下设备驱动程序的基本结构
我们知道,系统调用是操作系统内核和应用程序之间的接口。设备驱动程序是操作系统内核和机器硬件之间的接口,设备驱动程序为应用程序屏蔽了硬件的细节,这样在应用程序看来,硬件设备只是一个设备文件,应用程序可以象操作普通文件一样对硬件设备进行操作。
同样,在Linux系统里,对用户程序来说,设备驱动程序隐藏了设备的具体细节,对各种不同设备提供了一致的接口和虚拟的设备文件,用户程序可以象对其它文件一样对设备文件进行操作。Linux对硬件设备支持两个标准接口:块设备文件和字符设备文件,通过这些设备文件存取的设备称为块设备或字符设备。
块设备接口仅支持面向块的I/O操作,所有I/O操作都通过在内核地址空间中的I/O缓冲区进行,它可以支持几乎任意长度和任意位置上的I/O请求,即提供随机存取的功能。
字符设备接口支持面向字符的I/O操作,它不经过系统的快速缓存,所以它们负责管理自己的缓冲区结构。字符设备接口只支持顺序存取的功能,一般不能进行任意长度的I/O请求,而是限制I/O请求的长度必须是设备要求的基本块长的倍数。
设备由一个主设备号和一个次设备号标识。主设备号唯一标识了设备类型,即设备驱动程序类型,它是块设备表或字符设备表中设备表项的索引。次设备号仅由设备驱动程序解释,一般用于识别在若干可能的硬件设备中,I/O请求所涉及到的那个设备。比如有两个软盘,就可以用从设备号来区分他们。设备文件的的主设备号必须与设备驱动程序在登记时申请的主设备号一致,一些常见的设备如硬盘,其主设备号都是固定的。
最后必须提到的是,在用户进程调用驱动程序时,系统进入核心态。这时不再是抢先式调度。也就是说,系统必须在你的驱动程序的子函数返回后才能进行其他的工作。如果你的驱动程序陷入死循环,那么整个系统也会死机,这时只能重新启动计算机。
设备驱动程序可以分为三个主要组成部分:
(1) 自动配置和初始化子程序,负责检测所要驱动的硬件设备是否存在和是否能正常工作。如果该设备正常,则对这个设备及其相关的、设备驱动程序需要的软件状态进行初始化。这部分驱动程序仅在初始化的时候被调用一次。
(2) 服务于I/O请求的子程序,又称为驱动程序的上半部分。调用这部分是由于系统调用的结果。这部分程序在执行的时候,系统仍认为是和进行调用的进程属于同一个进程,只是由用户态变成了核心态,具有进行此系统调用的用户程序的运行环境,因此可以在其中调用sleep()等与进程运行环境有关的函数。
(3) 中断服务子程序,又称为驱动程序的下半部分。在Linux系统中,并不是直接从中断向量表中调用设备驱动程序的中断服务子程序,而是由Linux系统来接收硬件中断,再由系统调用中断服务子程序。中断可以产生在任何一个进程运行的时候,因此在中断服务程序被调用的时候,不能依赖于任何进程的状态,也就不能调用任何与进程运行环境有关的函数。因为设备驱动程序一般支持同一类型的若干设备,所以一般在系统调用中断服务子程序的时候,都带有一个或多个参数,以标识请求服务的设备。
在系统内部,I/O设备的存取通过一组固定的入口点来进行,这组入口点是由每个设备的设备驱动程序提供的。一般来说,设备驱动程序需要提供如下几个入口点:
(1) open入口点:打开设备准备I/O操作。当应用程序对设备文件进行打开操作,都会调用设备的open入口点。open子程序必须对将要进行的I/O操作做好必要的准备工作,如清除缓冲区等。如果设备是独占的,即同一时刻只能有一个程序访问此设备,则open子程序必须设置一些标志以表示设备处于忙状态。
(2) close入口点:关闭一个设备。当最后一次使用设备终结后,调用close子程序。独占设备必须标记设备可再次使用。
(3) read入口点:一般用于字符设备,从设备上读数据。对于有缓冲区的I/O操作,一般是从缓冲区里读数据。
(4) write入口点:一般用于字符设备,往设备上写数据。对于有缓冲区的I/O操作,一般是把数据写入缓冲区里。
(5) ioctl入口点:执行读、写之外的一些特别定义的操作,如查询设备参数等等。
具体到Linux系统里,设备驱动程序所提供的这组入口点由一个结构来向系统进行说明,此结构定义为:
struct file_operations {
int (*lseek)(struct inode *inode,struct file *filp, off_t off,int pos);
int (*read)(struct inode *inode,struct file *filp, char *buf, int count);
int (*write)(struct inode *inode,struct file *filp, char *buf,int count);
int (*readdir)(struct inode *inode,struct file *filp, struct dirent *dirent,int count);
int (*select)(struct inode *inode,struct file *filp, int sel_type,select_table *wait);
int (*ioctl) (struct inode *inode,struct file *filp, unsigned int cmd,unsigned int arg);
int (*mmap) (void);
int (*open) (struct inode *inode, struct file *filp);
void (*release) (struct inode *inode, struct file *filp);
int (*fsync) (struct inode *inode, struct file *filp);
};
在这个结构里,指出了设备驱动程序所提供的入口点位置,分别是:
(1) lseek:移动文件指针的位置,显然只能用于可以随机存取的设备。
(2) read:进行读操作,参数buf为存放读取结果的缓冲区,count为所要读取的数据长度。返回值为负表示读取操作发生错误,否则返回实际读取的字节数。
(3) write:进行写操作,与read类似。
(4) readdir:取得下一个目录入口点,只有与文件系统相关的设备驱动程序才使用。
(5) select:进行选择操作,如果驱动程序没有提供select入口,select操作将会认为设备已经准备好进行任何的I/O操作。
(6) ioctl:进行读、写以外的其它特殊操作,参数cmd为自定义的的命令。
(7) mmap:用于把设备的内容映射到地址空间,一般只有块设备驱动程序使用。
(8) open:打开设备准备进行I/O操作。返回0表示打开成功,返回负数表示失败。如果驱动程序没有提供open入口,则只要/dev/driver文件存在就认为打开成功。
(9) release:即close操作。
上述入口点在设备驱动程序初始化的时候向系统进行登记,以便系统在适当的时候调用。比如在Linux系统里,通过调用register_chrdev向系统注册字符型设备驱动程序。register_chrdev定义为:
int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);
其中,major是为设备驱动程序向系统申请的主设备号,如果为0则系统为此驱动程序动态地分配一个主设备号。name是设备名。fops就是前面所说的对各个调用的入口点的说明。此函数返回0表示成功。返回-EINVAL表示申请的主设备号非法,一般来说是主设备号大于系统所允许的最大设备号。返回-EBUSY表示所申请的主设备号正在被其它设备驱动程序使用。如果是动态分配主设备号成功,此函数将返回所分配的主设备号。如果register_chrdev操作成功,设备名就会出现在/proc/devices文件里。
9.2 编写Linux设备驱动程序的基本方法
设备驱动程序一般都要申请和使用系统资源,包括内存、时钟、I/O端口等,在这些资源不用的时候,应该释放它们,以利于资源的共享。下面首先简单叙述这些系统资源的使用方法:
(1) 内存
作为系统核心的一部分,设备驱动程序在申请和释放内存时不能调用标准C库的malloc和free,而代之以调用kmalloc和kfree(这两个系统调用的用法可以参考附录五),它们被定义为:
void * kmalloc(unsigned int len, int priority);
void kfree(void * obj);
参数len为希望申请的字节数,obj为要释放的内存指针。priority为分配内存操作的优先级,一般用GFP_KERNEL。
(2) IO端口
与内存不同,使用一个没有申请的I/O端口不会使CPU产生异常,也就不会导致诸如“segmentation fault"一类的错误发生。任何进程都可以访问任何一个I/O端口。此时系统无法保证对I/O端口的操作不会发生冲突,甚至会因此而使系统崩溃。因此,在使用I/O端口前,也应该检查此I/O端口是否已有别的程序在使用,若没有,再把此端口标记为正在使用,在使用完以后释放它。
这样需要用到如下几个函数:
int check_region(unsigned int from, unsigned int extent);
void request_region(unsigned int from, unsigned int extent, const char *name);
void release_region(unsigned int from, unsigned int extent);
调用这些函数时的参数为:from表示所申请的I/O端口的起始地址;extent为所要申请的从from开始的端口数;name为设备名,将会出现在/proc/ioports文件里。check_region返回0表示I/O端口空闲,否则表示正在被使用。
在申请了I/O端口之后,就可以采用如下几个函数来访问I/O端口:
inline unsigned int inb(unsigned short port);
inline unsigned int inb_p(unsigned short port);
inline void outb(char value, unsigned short port);
inline void outb_p(char value, unsigned short port);
这几个函数在x86体系结构的机器上,被编译成为对应的IN/OUT指令。略有不同的是,其中inb_p和outb_p插入了一定的延时以适应某些慢的I/O端口。
(3) 时钟
在设备驱动程序里,可能需要用到计时机制。在Linux系统中,时钟是由系统接管,设备驱动程序可以向系统申请时钟或者定时器。与时钟或定时器有关的系统调用有:
void add_timer(struct timer_list * timer);
int del_timer(struct timer_list * timer);
inline void init_timer(struct timer_list * timer);
其中struct timer_list的定义为:
struct timer_list
{
struct timer_list *next;
struct timer_list *prev;
unsigned long expires;
unsigned long data;
void (*function)(unsigned long d);
};
expires的含义是,要求系统在这个时间立即执行我们自己定义的function。系统核心有一个全局变量JIFFIES表示当前时间,一般在调用add_timer时jiffies=JIFFIES+num,表示在num个系统最小时间间隔后执行function。系统最小时间间隔与所用的硬件平台有关,在核心里定义了常数HZ表示一秒内最小时间间隔的数目,则num*HZ表示num秒。系统计时到预定时间就调用function,并把此子程序从定时队列里删除,因此如果想要每隔一定时间间隔执行一次的话,就必须在function里再一次调用add_timer。function的参数d即为timer里面的data项。
(4)其他
在设备驱动程序里,还可能会用到如下的一些系统函数:
#define cli() asm volatile (“cli”:😃
#define sti() asm volatile (“sti”:😃
这两个函数在x86的机器上被编译成为CLI/STI指令,负责打开和关闭中断。
void memcpy_fromfs(void * to,const void * from,unsigned long n);
void memcpy_tofs(void * to,const void * from,unsigned long n);
在用户程序调用read 、write时,因为进程的运行状态由用户态变为核心态,地址空间也变为核心地址空间。而read、write中参数buf是指向用户程序的私有地址空间的,所以不能直接访问,必须通过上述两个系统函数来访问用户程序的私有地址空间。memcpy_fromfs由用户程序地址空间往核心地址空间复制,memcpy_tofs则反之。参数to为复制的目的指针,from为源指针,n为要复制的字节数。
最后,在设备驱动程序里,经常需要调用printk来打印一些调试信息,这个函数的用法与标准C库中的printf类似。不过printk打印的信息通常记录在log文件里,在命令行下面输入dmesg命令可以查看log文件。
在Linux里,一般不直接修改系统内核的源代码,而是把设备驱动程序作为可加载的模块,由系统管理员去加载它,使之成为内核的一部分。也可以由系统管理员把已加载的模块动态的卸载下来。Linux中,模块可以用C语言编写,用gcc编译成目标文件(不进行链接,作为*.o文件存在),为此需要在gcc命令行里加上-c的参数。在编译时,还应该在gcc的命令行里加上这样的参数:-D__KERNEL__ -DMODULE。由于在不链接时,gcc只允许一个输入文件,因此一个模块的所有部分都必须在一个文件里实现。编译成功的模块,可以用insmod命令加载,用rmmod命令来卸载,并可以用lsmod命令来查看所有已加载的模块及其状态。
编写模块程序的时候,必须提供两个函数,一个是int init_module(void),供insmod在加载此模块的时候自动调用,负责进行设备驱动程序的初始化工作。另一个函数是void cleanup_module (void),在模块被卸载时调用,负责进行设备驱动程序的清除工作。关于编写模块的知识,可以参考第三章的内容。
用户进程通过位于/dev目录下的设备文件来访问设备。请在你的Linux系统下打开一个终端并输入cd /dev来到/dev目录,然后输入ls –l命令,查看当前计算机上面的所有设备。
下图给出了笔者使用的系统上的一些设备:
通过ls -l输出的第一列中的“c”可以看出,这个设备文件对应的是字符设备。同样还有块设备,它们的第一列是“b”;在设备文件条目的最新修改日期前你会看到两个数(用逗号分隔)。这些数就是相应设备的主设备号和次设备号。如图中设备的主设备号分别是10,14和29等等,而次设备号则分别是10,175,4,7等等。
在成功的向系统注册了设备驱动程序后(调用register_chrdev成功后),就可以用mknod命令来建立一个设备文件,应用程序需要使用这个设备的时候,只要对此文件进行操作就行了。比如我们输入mknod /dev/radimo b 42 0这个命令,可以在/dev目录下建立一个叫radimo的块设备文件,其主设备号为42,次设备号为0。
9.3 RADIMO:一个块设备驱动程序的例子
前面讲过,设备驱动程序所管理的设备包括字符设备和块设备。这一节我们将详细讲述块设备驱动程序的编写方法,字符设备的驱动程序与之相比大同小异,本章不再赘述。
所谓面向块的设备是指数据传输是以块为单位的,典型的块设备包括软盘、硬盘、U盘等,这里硬件的块一般被称作“扇区(Sector)”。而名词“块”常用来指软件上的概念:驱动程序常常使用1KB大小的块,而扇区大小一般为512字节。在这一节,我们将构造一个叫radimo的简单的块设备驱动程序,这个程序的是使用计算机的内存作为硬件设备。换句话说,它是一个RAM-disk的驱动程序,可以从内存中划分出2MB的空间,虚拟出一个磁盘(确切的说,无论是RAM-disk,还是光盘,U盘,都不是使用磁介质进行存储的,因此不能将其称为“磁盘”,但是为了便于理解,我们不妨用“磁盘”作为这些存储设备的统称),可以对这个虚拟磁盘进行格式化,复制文件,查看文件等操作。
Radimo程序的全部源代码可以在http://www.vrbrothers.com/cr/radimo.zip下载,并且为了方便阅读,笔者在其中增加了大量的中文注释。为了对设备驱动程序有一个定性的认识,便于下面的讲解,我们先来下载这个程序,并且编译、加载、测试。然后带着对这个程序的基本认识,去学习它的工作原理。
首先我们下载到这个zip包,解开后有三个文件radimo.c,radimo.h和makefile,将这三个文件复制到Linux的某个目录下,并且在终端窗口用cd命令来到该目录下,按照下面的步骤依次执行。在每个步骤执行完毕之后都可以输入dmesg命令,查看系统的log文件。
输入make<回车>,意思是调用gcc编译这个驱动程序。
编译结束后,用ls命令可以看到当前路径下多了一个叫radimo.o的文件,这是编译后得到的设备驱动程序。
输入mknod /dev/radimo b 42 0<回车>,意思是建立一个叫radimo的块设备文件,主设备号为42,次设备号为0。主设备号42一般在Linux系统中是空闲的,可以供我们使用。现在我们有一个叫radimo的设备,可以把它看作一个空白的磁盘。这个设备目前还没有驱动程序。
输入insmod radimo.o<回车>,意思是让操作系统加载这个设备驱动程序。
输入lsmod<回车>,可以看到目前操作系统中已经加载的模块,其中应该包括我们的radimo。
输入mke2fs /dev/radimo<回车>,意思是在radimo这个空白磁盘上建立一个EXT2文件系统。相当于Windows下的格式化磁盘――不用担心,这里格式化的是一个虚拟盘,不会对我们的硬盘造成任何影响。
输入mount /dev/radimo /mnt<回车>,意思是把这个磁盘挂接在文件系统的/mnt目录下。
如果以上步骤都顺利执行完毕,那么恭喜你,现在你的/mnt目录就是一个容量2MB的虚拟磁盘,可以象操作其他盘一样建立子目录,复制文件等等。只是这个盘是虚拟的,在我们的驱动程序卸载后,里面的内容就会被清除。
输入df<回车>,意思是查看系统中目前挂接的磁盘(更确切的说法,应该是目前挂接的文件系统,但是“磁盘”的说法比较容易理解,下同)。这时应该可以看到至少两个磁盘,一个就是我们的硬盘,另一个就是刚才挂接的radimo虚拟盘。另外,如果你的系统中还挂接了软盘,光盘,U盘等等,也应该在这里有所体现。
输入cp radimo.c /mnt<回车>,意思是把radimo.c这个文件复制到虚拟盘上面,当然这里也可以是其他大小不超过2MB的文件。
输入mkdir /mnt/dir1<回车>,意思是在虚拟盘上面建立一个叫dir1的子目录,当然这个子目录的名字可以改成其他你喜欢的名字。
输入ls /mnt<回车>,可以看到我们刚才复制的文件,以及建立的子目录。如下图所示:
输入umount /dev/radimo<回车>,意思是从系统中卸载这个磁盘。这时再用ls命令,已经不能看到上面复制的文件和建立的子目录。