Linux设备驱动程序层次结构
作为Unix操作系统的一个变种,Linux操作系统实现了大多数Unix操作系
统的系统设施。系统将所有的设备(不仅仅是磁盘上的文件)都看成文件,并纳入文件系统得范畴,通过文件系统界面对设备进行操作。下图是针对Linux系统中普通设备(非磁盘文件)的驱动程序层次结构。
设备文件
应用程序如果要想访问设备,首先要在文件系统中建立一个设备文件节
点,这可以通过mknod()系统调用实现,也可以用用户命令工具mknod 完成。
注意虽然说Unix类系统大多都将对设备的访问也纳到文件系统的范畴,在文件系统接口层都是一样的,但是对设备的访问与对普通磁盘文件的访问在内部实现上还是有很大的差异的,也就是内部的描述也不相同。所以在创建这两种文件的时候所使用的系统调用也就不一样了。
为什么称之为“设备文件节点“?这是为了区别普通磁盘文件。因为这个文件本身不会存放真正的数据,而只是存放了一些设备相关属性描述,和如何访问其映射的设备(指示文件系统层如何找到设备驱动程序的入口)。所以用户可以将其看成访问设备的窗口。
mknod工具实际上是间接调用了mknod()系统调用。然后应用程序可以像读写一般的文件一样对这个设备文件进行操作,操作系统会将具体的操作映射到特定的设备的设备驱动程序上。Linux使用了主设备号(major)和次设备号(minor)对同一类设备中的不同设备进行区分。Linux系统对字符设备主设备号进行动态分配是从127开始的(最大255),0~127是系统静态指定的,而且通常已经使用了。
如果系统没有其他更多的设备要加入,可以直接使用127~255之间的字符设备的主设备号,不会出现冲突。而一般一个设备驱动程序只是针对了一个物理设备(含有次设备的设备除外),因此仅仅需要一个次设备号,0~255之间可任选,我们选用了第0号次设备号。因此在建立文件节点时可以按照如下方式进行:
命令行方式: mknod /dev/mydev c 127 0
系统调用方式: mknod( “/dev/ mydev”, S_IFCHR|0666 , (127<<8) | (0) )
设备驱动程序的编写
驱动程序是直接与硬件设备打交道的,涉及到的大部分代码都是各种设
备读写逻辑,针对具体的设备都是不一样的,因此我们在这里只对与操作系
统文件系统接口的那一部分机制进行说明。这里以编写一个字符设备驱动程序为例。
文件系统接口
文件系统管理中有一个数据结构file_operations, 要让应用程序可以通过
文件系统对设备进行访问,我们必须把驱动程序的各种读写控制函数的地址
注册到对应的位置上去。比较完整的驱动程序至少应该提供基本的打开、关
闭和读写操作,如open, close, write, read。功能更多的就应该提供ioctl之类的
操作。我们假设选定四个基本的操作,如下:
static struct file_operations mydev_fops={
read: mydev_read,
write: mydev_write,
open: mydev_open,
release: mydev_close,
};
其中, mydev_X 是我们自己定义的函数,这些函数都必须按照指定参数和返回值类型进行定义。具体形式和用法请参考file_operations 数据结构。然后在驱动程序初始化函数中用register_chrdev注册驱动程序。具体用法如下:
register_chrdev( 127, DEVICE_NAME, &mydev_fops );
127是主设备号,DEVICE_NAME是设备名,我们在这里定义为“mydev”,
然后提交mydev_fops结构的首地址以注册操作。内核维护了两个device_struct结构的数组:blkdevs[] 和 chrdevs[],分别用于管理块设备和字符设备。这里的注册操作就是要将mydev_fops地址根据主设备号写入chrdevs[]数组的对应项。之后,只要知道主设备号,我们就可以找到它的file_operations结构,进而找到这种设备的驱动函数。
I/O端口资源
访问设备一定会涉及到端口读写操作,但是我们不能随便使用I/O端口
地址,那将引起系统的不稳定。如果要使用I/O资源,应该先向系统申请,并
占用一定范围的I/O端口资源。这里涉及了两个系统调用check_region和
request_region。check_region用于检查端口是否已经被占用,而request_region
则用于申请I/O端口资源,并在系统中登记。��法如下:
if( check_region(DEV_ADDR, RANGE ) == -EBUSY )
{
printk( "ioports conflict!\n" );
return 1;
}
request_region( DEV_ADDR, RANGE, DEVICE_NAME );
在我们的系统中用了从DEV_ADDR开始的RANGE个I/O端口地址,如果端口已经被其它设备占用,check_region()将会返回-EBUSY。然后用request_region()
申请并注册。
卸载驱动
最后卸载驱动程序用到了unregister_chrdev( Major, DEVICE_NAME )。然
后是释放I/O端口资源release_region( DEV_ADDR, RANGE )。
加载驱动程序
Linux环境下加载设备驱动程序可以有好几种方式。最直接的就是把设备
驱动程序直接编译进内核中,这样就可以在系统引导的第二阶段(设备初始化)
加载。这样虽然省去了很多麻烦事,但是却增大了内核代码。另外一种,也是很常见的一种方式,通过模块的方式。Linux的模块机制为我们增加系统功
能提供了最为便捷和灵活的方式,驱动程序就是最好的例子。我们可以把编
写好的驱动程序(按照模块的形式编译)动态加入到内核当中。
所选择的加载驱动的方式不同,那么程序的编写方式也不相同。
模块方式
2.4.x版本内核允许模块编写者自己定义模块注册和卸载函数名(比如
我们用了mydev_init()和mydev_cleanup()两个函数),然后通过两个宏module_init( mydev_init )和module_exit( mydev_cleanup )来完成登记工作。驱动函数的登记和I/O端口资源的申请将在mydev_init()中完成,而驱动模块的卸载过程中则会调用mydev_cleanup(),它允许我们自己做一些特定的善后工作。编译时应该加上以下几个参数:
__KERNEL__ 告诉编译器代码运行在内核模式下
MODULE 告诉编译器,代码作为模块编译
-c 仅仅进行编译,不连接(不可能完成连接工作)
-O2 要求编译器做一定的优化
最后,如果要想在系统启动时自动加载该模块,可以在启动脚本中加上
mknod /dev/mydev c 127 0
insmod –f mydev.o
直接编译进内核
与作为模块载入不同,驱动程序载入内核之后不需要手动卸载,因此不需要与module_cleanup()对应的函数,系统在退出时会自动完成相应的工作。而初始化函数完成的工作与module_init()一样。
我们的初始化函数形式如下:
int __init mydev_init( void ){}
然后在mem.c中的chr_dev_init函数返回前加上mydev_init(),如下:
int __init chr_dev_init( void )
{ …
mydev_init();
return 0;
}
__init说明该段代码只是在系统初始化时有用,完成相应的工作之后,可以收回其内存空间。