5. 设备驱动程序
-
Linux 内核是一个比较庞大的系统,深入理解内核可以减少在系统移植中的障碍。在系统移植中设备驱动开发是一项很复杂的工作,由于 Linux 内核提供了一部分源代码,同时还提供了对某些公共部分的支持,例如, USB 驱动对读写 U 盘、键盘、鼠标等设备提供了通用驱动程序,一般情况可以直接使用内核提供的驱动。但是对于复杂的 USB 设备没有现成的驱动,就需要读者对驱动开发过程有一定的认识,必要时参考 Linux 源码重新开发驱动程序。
-
设备驱动,实际上是硬件功能的一个抽象。针对同一个硬件不同的驱动可以将硬件封装成不同的功能。设备驱动是硬件层和应用程序(或者操作系统)的媒介,能够让应用程序或者操作系统使用硬件。
-
在 Linux 操作系统下有 3 类主要的设备文件类型:块设备、字符设备和网络设备。设备驱动程序是指管理某个外围设备的一段代码,它负责传送数据、控制特定类型的物理设备的操作,包括开始和完成 I/O 操作,检测和处理设备出现的错误。
1. 字符设备驱动程序
-
字符设备是一种能像字节流一样进行串行访问的设备,对设备的存取只能按顺序、按字节存取,不能随机访问。字符设备没有请求缓冲区,必须按顺序执行所有的访问请求。常见的字符设备有鼠标、键盘、串口、控制台等。
-
应用程序对字符设备的访问是通过字符设备结点来完成的。字符设备是 Linux 中最简单的设备,可以像文件一样访问。应用程序使用标准系统调用打开、读、写和关闭字符设备,完全可以把它们当做普通文件一样进行操作,甚至被 PPP 守护进程使用,用于将一个 Linux系统连接到网上的 modem,也被看做一个普通文件。
-
当字符设备初始化时,它的设备驱动程序向 Linux 内核注册,向
chrdevs
向量表中增加一个device_struct
数据结构项。 -
通常一种类型设备的主设备标识符是固定的,例如 tty 设备是 4。设备的主设备标识符,用作chrdevs 向量表的索引。
-
向量表中的每一项(即 device_struct 数据结构)包括两个元素:
- 一个是指向登记的设备驱动程序名字的指针;
- 另一个是指向一组文件操作的指针。这组文件操作本身位于这个设备的字符设备驱动程序中,每一个都处理一个特定的文件操作(如打开、读、写和关闭)。
-
用户进程通过设备文件对硬件进行访问,对设备文件的操作方式通过一些系统调用来实现,如
open
、read
、write
和close
等。下面通过一个关键的数据结构file_operations
,将系统调用和驱动程序关联起来:struct file_operations { int (*seek) (struct inode * , struct file *, off_t , int); int (*read) (struct inode * , struct file *, char , int); int (*write) (struct inode * , struct file *, off_t , int); int (*readdir) (struct inode * , struct file *, struct dirent * , int); int (*select) (struct inode * , struct file *, int , select_table *); int (*ioctl) (struct inode * , struct file *, unsined int , unsigned long); int (*mmap) (struct inode * , struct file *, struct vm_area_struct *); int (*open) (struct inode * , struct file *); int (*release) (struct inode * , struct file *); int (*fsync) (struct inode * , struct file *); int (*fasync) (struct inode * , struct file *, int); int (*check_media_change) (struct inode * , struct file *); int (*revalidate) (dev_t dev); };
- 该结构中每一个成员的名字都对应着一个系统调用。用户进程利用系统调用在对设备文件进行诸如 read/write 操作时,系统调用根据设备文件的主设备号找到对应的设备驱动程序,然后读取这个数据结构相应的函数指针,接着把控制权交给该函数。
-
编写驱动程序就是针对上面相应的函数编写具体的实现,然后将它们对应上。编写完驱动后,把驱动程序嵌入内核。驱动程序可以采用两种方式进行编译:一种是编译进内核,驱动被静态加载;另一种是编译成模块(modules),驱动模块需要动态加载。
-
在模块被调入内存时,
init()
函数向系统的字符设备表登记了一个字符设备:int __init chr_dev_init(void) { if (devfs_register_chrdev(CHR_MAJOR,"chr_name",&chr_fops)) printk("unable to get major %d for chr devs\n", MEM_MAJOR); ... return 0; }
-
当
cleanup_chr_dev()
函数被调用时,它释放字符设备 chr_name 在系统字符设备表中占有的表项:void cleanup_chr_dev(void) { unregister_chrdev(CHR_MAJOR, "chr_name"); }
2. 块设备驱动程序
-
块设备具有请求缓冲区,从块设备读取数据时,可以从任意位置读取任意长度,即块设备支持随机访问而不必按照顺序存取数据。例如,可以先存取后面的数据,然后再存取前面的数据,字符设备则不能采用该方式存取数据。 常见的块设备有各种硬盘、 flash 磁盘、 RAM 磁盘等。Linux 下的磁盘设备均为块设备,应用程序访问 Linux 下的块设备结点是通过文件系统及其高速缓存来访问块设备的,并非直接通过设备结点读写块设备上的数据。
-
块设备既可以用做普通的裸设备存放任意数据,也可以将块设备按某种文件系统类型的格式进行格式化,然后根据该文件系统类型的格式进行读取。无论使用哪种方式,访问设备上的数据都必须通过调用设备本身的方法实现。两者的区别在于前者直接调用块设备的操作方法,而后者则间接(通过文件系统)调用块设备的操作方法。
-
块设备用与字符设备类似的方法进行设备的注册与释放。块设备使用
register_blkdev()
函数和block_device_operations
结构的指针,其中定义的open
、release
和ioctl
方法和字符设备的对应方法相同,但没有对 read 和 write 操作定义,因为所有涉及块设备的 I/O 通常由系统进行缓冲处理。 -
块驱动程序最终必须提供完成实际块 I/O 操作的机制,在 Linux 中,用于这些 I/O 操作的方法称为
request
(请求)。 -
注册块设备时,通过
blk_init_queue
来完成对 request 队列的初始化,blk_init_queue
函数创建队列,并将该驱动程序的 request 函数关联到队列。在模块的清除阶段,调用blk_cleanup_queue
函数。 -
初始化块设备的时候,将块设备注册到内核中,下面为块设备的注册函数
mtdblock_release()
的实现:int register_blkdev(unsigned int major, const char *name) { struct blk_major_name **n, *p; int index, ret = 0; mutex_lock(&block_class_lock); /*为块设备指定主设备号,如果指定为 0 则表示由系统来分配*/ if (major == 0) { for (index = ARRAY_SIZE(major_names)-1; index > 0; index--) { if (major_names[index] == NULL) break; } if (index == 0) { printk("register_blkdev: failed to get major for %s\n", name); ret = -EBUSY; goto out; } major = index; ret = major; } /*为块设备名字分配空间*/ p = kmalloc(sizeof(struct blk_major_name), GFP_KERNEL); if (p == NULL) { ret = -ENOMEM; goto out; } p->major = major; strlcpy(p->name, name, sizeof(p->name)); p->next = NULL; index = major_to_index(major); for (n = &major_names[index]; *n; n = &(*n)->next) { if ((*n)->major == major) break; } if (!*n) *n = p; else ret = -EBUSY; if (ret < 0) { printk("register_blkdev: cannot get major %d for %s\n",major, name); kfree(p); } out: mutex_unlock(&block_class_lock); return ret; }
-
块设备被注册到系统后,访问硬件的操作 open 和 release 等就能够被对应的系统调用指针所绑定,应用程序使用系统调用就可以对硬件进行访问了。下面是块设备主要的操作函数 open()和 release():
-
块设备 open()操作函数:
static int mtdblock_open(struct mtd_blktrans_dev *mbd) { struct mtdblk_dev *mtdblk; struct mtd_info *mtd = mbd->mtd; int dev = mbd->devnum; DEBUG(MTD_DEBUG_LEVEL1,"mtdblock_open\n"); if (mtdblks[dev]) { /*如果设备已经打开,则只需要增加其引用计数*/ mtdblks[dev]->count++; return 0; } /*为设备创建 mtdblk_dev 对象保存 mtd 设备的信息*/ mtdblk = kzalloc(sizeof(struct mtdblk_dev), GFP_KERNEL); if (!mtdblk) return -ENOMEM; mtdblk->count = 1; mtdblk->mtd = mtd; mutex_init(&mtdblk->cache_mutex); mtdblk->cache_state = STATE_EMPTY; if ( !(mtdblk->mtd->flags & MTD_NO_ERASE) && mtdblk->mtd->erasesize){ mtdblk->cache_size = mtdblk->mtd->erasesize; mtdblk->cache_data = NULL; } mtdblks[dev] = mtdblk; DEBUG(MTD_DEBUG_LEVEL1, "ok\n"); return 0; }
-
块设备 release()操作函数:
static int mtdblock_release(struct mtd_blktrans_dev *mbd) { int dev = mbd->devnum; struct mtdblk_dev *mtdblk = mtdblks[dev]; DEBUG(MTD_DEBUG_LEVEL1, "mtdblock_release\n"); mutex_lock(&mtdblk->cache_mutex); write_cached_data(mtdblk); mutex_unlock(&mtdblk->cache_mutex); if (!--mtdblk->count) { mtdblks[dev] = NULL; /*用户计数递减为 0 时释放设备*/ if (mtdblk->mtd->sync) mtdblk->mtd->sync(mtdblk->mtd); vfree(mtdblk->cache_data); kfree(mtdblk); } DEBUG(MTD_DEBUG_LEVEL1, "ok\n"); return 0; }
-
3. 网络设备驱动程序
-
网络设备是面向数据报文的、不支持随机访问, 也没有请求缓冲区。在 Linux里网络设备也可以被称为网络接口,如 eth0,应用程序是通过 Socket(套接字),而不是设备结点来访问网络设备,在系统中不存在网络设备结点。
-
网络设备用来与其他设备交换数据,它可以是硬件设备,也可以是纯软件设备,如loopback 接口。网络设备由内核中的网络子系统驱动,负责发送和接收数据包,但它不需要了解每项事务如何映射到实际传送的数据包。 许多网络连接(如TCP连接)是面向流的,但网络设备围绕数据包的传输和接收设计。
-
网络驱动程序不需要知道各个连接的相关信息,它只需处理数据包。字符设备和块设备都有设备号,而网络设备没有设备号,只有一个独一无二的名字,例如 eth0、 eth1 等,这个名字也无须与设备文件结点对应。
-
内核利用一组数据包传输函数与网络设备驱动程序进行通信,它们不同于字符设备和块设备的 read()和 write()方法。
-
Linux 网络设备驱动程序从下到上分为 4 层,依次为”网络设备与媒介层、设备驱动功能层、网络设备接口层和网络协议接口层“。
-
在设计具体的网络设备驱动程序时,需要完成的主要工作是编写设备驱动功能层的相关函数以填充
net_device
数据结构的内容,并将net_device
注册入内核。 -
下面以 DM9000 代码为例说明网络设备驱动的注册、注销等主要过程。
-
驱动的注册(在设备初始化时被调用)
static int __init dm9000_init(void) { printk(KERN_INFO "%s Ethernet Driver, V%s\n", CARDNAME,DRV_VERSION); return platform_driver_register(&dm9000_driver); } int platform_driver_register(struct platform_driver *drv) { drv->driver.bus = &platform_bus_type; if (drv->probe) drv->driver.probe = platform_drv_probe; if (drv->remove) drv->driver.remove = platform_drv_remove; if (drv->shutdown) drv->driver.shutdown = platform_drv_shutdown; if (drv->suspend) drv->driver.suspend = platform_drv_suspend; if (drv->resume) drv->driver.resume = platform_drv_resume; return driver_register(&drv->driver); }
-
驱动的注销(在设备被清除时被调用,其中包括将设备从系统中移除和将驱动从总线上移除。 )
static void __exit dm9000_cleanup(void) { platform_driver_unregister(&dm9000_driver); } void platform_driver_unregister(struct platform_driver *drv) { driver_unregister(&drv->driver); } void driver_unregister(struct device_driver *drv) { driver_remove_groups(drv, drv->groups); bus_remove_driver(drv); } static void driver_remove_groups(struct device_driver *drv, struct attribute_group **groups) { int i; if (groups) for (i = 0; groups[i]; i++) sysfs_remove_group(&drv->p->kobj, groups[i]); } void bus_remove_driver(struct device_driver *drv) { if (!drv->bus) return; remove_bind_files(drv); driver_remove_attrs(drv->bus, drv); driver_remove_file(drv, &driver_attr_uevent); klist_remove(&drv->p->knode_bus); pr_debug("bus: '%s': remove driver %s\n", drv->bus->name,drv->name); driver_detach(drv); module_remove_driver(drv); kobject_put(&drv->p->kobj); bus_put(drv->bus); }
- 有关网络设备驱动的详细接口函数解析和驱动移植将在后面的章节中叙述。
-
4. 内存与I/O操作
-
一般来说,在系统运行时,外设的 I/O 内存资源的物理地址是已知的,由硬件的设计决定。但是 CPU 通常并没有为这些已知的外设 I/O 内存资源的物理地址,预定义虚拟地址范围,驱动程序并不能直接通过物理地址访问 I/O 内存资源,只能先将它们映射到内核的虚拟地址空间内(通过页表),然后才能根据映射的内核虚拟地址范围访问这些 I/O 内存资源。
-
Linux 在
io.h
头文件中声明了函数ioremap()
和iounmap()
,分别用来将 I/O 内存资源的物理地址映射和解映射到核心虚拟地址空间(3GB~4GB)中,原型如下:void * ioremap(unsigned long phys_addr, unsigned long size, unsigned long flags); void iounmap(void * addr);
-
在将 I/O 内存资源的物理地址映射成内核的虚拟地址后,就可以像读写 RAM 那样直接读写 I/O 内存资源了。但为了保证驱动程序跨平台的可移植性,应该使用 Linux 中特定的函数访问 I/O 内存资源,而不是通过指向内核虚拟地址的指针直接访问。如在 ARM平台上,读写 I/O 的函数如下:
#define __raw_base_writeb(val,base,off) __arch_base_putb(val,base,off) #define __raw_base_writew(val,base,off) __arch_base_putw(val,base,off) #define __raw_base_writel(val,base,off) __arch_base_putl(val,base,off) #define __raw_base_readb(base,off) __arch_base_getb(base,off) #define __raw_base_readw(base,off) __arch_base_getw(base,off) #define __raw_base_readl(base,off) __arch_base_getl(base,off)
-
驱动程序中
mmap()
函数的实现原理是,用 mmap 映射一个设备,表示将用户空间的一段地址关联到设备内存上,这样当程序在分配的地址范围内进行读取或者写入时,实际上就是对设备的访问。这一映射原理类似于 Linux 下 mount 命令,将一种类型的文件系统或设备挂载到另外一个文件系统或者目录下时,挂载成功后,对挂载点的任何操作实际上是对被挂载的文件系统和设备的操作。