linux字符设备文件的打开操作
2.7 字符设备文件的打开操作(1)
作为例子,这里假定前面对应于/dev/demodev设备节点的驱动程序在自己的代码里实现了如下的struct file_operations对象fops:
- static struct file_operations fops = {
- .open = demoopen,
- .read = demoread,
- .write = demowrite,
- .ioctl = demoioctl,
- };
用户空间open函数的原型为:
- int open(const char *filename, int flags, mode_t mode);
这个函数如果成功,将返回一个文件描述符,否则返回-1。函数的第一个参数filename表示要打开的文件名,第二个参数flags用于指定文件的打开或者创建模式,本书在后续"字符设备的高级操作"一章中会讨论其中一些常见取值对驱动程序的影响,最后一个参数mode只在创建一个新文件时才使用,用于指定新建文件的访问权限,比如可读、可写及可执行等权限。
位于内核空间的驱动程序中open函数的原型为:
- <include/linux/fs.h>
- struct file_operations {
- …
- int (*open) (struct inode *, struct file *);
- …
- };
两者相比差异很大。接下来我们将描述从用户态的open是如何一步一步调用到驱动程序提供的open函数(在我们的例子中,它的具体实现是demoopen)的。如同设备文件节点的生成一样,透彻了解这里的每一个步骤也需要掌握全面的Linux下文件系统的技术细节。从设备驱动程序员的角度,我们依然将重点放在两者如何建立联系的关键点上。
用户程序调用open函数返回的文件描述符,本文用fd表示,这是个int型的变量,会被用户程序后续的read、write和ioctl等函数所使用。同时可以看到,在驱动程序中的demodev_read、demodev_write和demodev_ioctl等函数其第一个参数都是struct file *filp。显然内核需要在打开设备文件时为fd与filp建立某种联系,其次是为filp与驱动程序中的fops建立关联。
用户空间程序调用open函数,将发起一个系统调用,通过sys_open函数进入内核空间,其中一系列关键的函数调用关系如图2-8所示:
图2-8 sys_open到chrdev_open调用流程 |
do_sys_open函数首先通过get_unused_fd_flags为本次的open操作分配一个未使用过的文件描述符fd :
- <fs/open.c>
- long do_sys_open(int dfd, const char __user *filename, int flags, int mode)
- {
- …
- fd = get_unused_fd_flags(flags);
- …
- }
get_unused_fd_flags实际上是封装了alloc_fd的一个宏,真正分配fd的操作发生在alloc_fd函数中,后者会涉及大量文件系统方面的细节,这不是本书的主题。读者这里只需知道alloc_fd将会为本次的open操作分配一个新的fd。
do_sys_open随后调用do_filp_open函数,后者会首先查找"/dev/demodev"设备文件所对应的inode。在Linux文件系统中,每个文件都有一个inode与之对应。从文件名查找对应的inode这一过程,同样会涉及大量文件系统方面的细节。
do_filp_open在成功查找到"/dev/demodev"设备文件对应的inode之后,接着会调用函数get_empty_filp,后者会为每个打开的文件分配一个新的struct file类型的内存空间(本书将把指向该结构体对象的内存指针简写为filp):
- <fs/namei.c>
- struct file *do_filp_open(int dfd, const char *pathname,
- const struct open_flags *op, int flags)
- {
- struct nameidata nd;
- struct file *filp;
- filp = path_openat(dfd, pathname, &nd, op, flags | LOOKUP_RCU);
- …
- return filp;
- }
内核用struct file对象来描述进程打开的每一个文件的视图,即使是打开同一文件,内核也会为之生成一个新的struct file对象,用来表示当前操作的文件的相关信息,其定义为:
- <include/linux/fs.h>
- struct file {
- union {
- struct list_head fu_list;
- struct rcu_head fu_rcuhead;
- } f_u;
- struct path f_path;
- #define f_dentry f_path.dentry
- #define f_vfsmnt f_path.mnt
- const struct file_operations *f_op;
- spinlock_t f_lock;
- atomic_long_t f_count;
- unsigned int f_flags;
- fmode_t f_mode;
- loff_t f_pos;
- struct fown_struct f_owner;
- const struct cred *f_cred;
- struct file_ra_state f_ra;
- u64 f_version;
- #ifdef CONFIG_SECURITY
- void *f_security;
- #endif
- /* needed for tty driver, and maybe others */
- void *private_data;
- #ifdef CONFIG_EPOLL
- /* Used by fs/eventpoll.c to link all the hooks to this file */
- struct list_head f_ep_links;
- #endif /* #ifdef CONFIG_EPOLL */
- struct address_space *f_mapping;
- };
2.7 字符设备文件的打开操作(2)
这个结构中与设备驱动程序关系最密切的是f_op、f_flags、f_count和private_data成员。f_op指针的类型是struct file_operations,恰好我们的字符设备驱动程序中也需要实现一个该类型的对象,马上我们将看到这两者之间是如何建立联系的。f_flags用于记录当前文件被open时所指定的打开模式,这个成员将会影响后续的read/write等函数的行为模式。成员f_count用于对struct file对象的使用计数,当close一个文件时,只有struct file对象中f_count成员为0才真正执行关闭操作。private_data常被用来记录设备驱动程序自身定义的数据,因为filp指针会在驱动程序实现的file_operations对象其他成员函数之间传递,所以可以通过filp中的private_data成员在某一个特定文件视图的基础上共享数据。
进程为文件操作维护一个文件描述符表(current->files->fdt),正如在本节开始部分看到的那样,对设备文件的打开,最终会得到一个文件描述符fd,然后用该描述符fd作为进程维护的文件描述符表(指向struct file *类型数组)的索引值,将之前新分配的struct file空间地址赋值给它:
- current->files->fdt->pfd[fd] = filp;
这样,用户空间程序在后续的read、write、ioctl等函数调用中利用fd就可以找到对应的filp,如图2-9所示:
(点击查看大图)图2-9 fd与filp的关联 |
在do_sys_open的后半部分,会调用__dentry_open函数将"/dev/demodev"对应节点的inode中的i_fop赋值给filp->f_op,然后调用i_fop中的open函数:
- <fs/open.c>
- static struct file *__dentry_open(struct dentry *dentry, struct vfsmount *mnt,
- struct file *f,
- int (*open)(struct inode *, struct file *),
- const struct cred *cred)
- {
- struct inode *inode;
- …
- f->f_op = fops_get(inode->i_fop);
- …
- if (!open && f->f_op)
- open = f->f_op->open;
- if (open) {
- error = open(inode, f);
- …
- }
- …
- }
__dentry_open函数当初在nameidata_to_filp中被调用时,第四个实参是NULL,所以在__dentry_open中,open = f->f_op->open。在上节设备文件节点的生成中,我们知道inode->i_fop = &def_chr_fops,这样filp->f_op = &def_chr_fops。接下来会利用filp中的这个新的f_op作调用:filp->f_op->open(inode, filp),于是chrdev_open函数将被调用到。该函数非常重要,为了突出其主线,下面先将它改写成以下简单几行:
- <fs/char_dev.c>
- static int chrdev_open(struct inode *inode, struct file *filp)
- {
- int ret = 0, idx;
- struct kobject *kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
- struct cdev *new = container_of(kobj, struct cdev, kobj);
- inode->i_cdev = new;
- list_add(&inode->i_devices, &new->list);
- filp->f_op = new->ops;
- if (filp->f_op->open) {
- ret = filp->f_op->open(inode,filp);
- }
- return ret;
- }
函数首先通过kobj_lookup在cdev_map中用inode->i_rdev来查找设备号所对应的设备new,这里展示了设备号的作用。成功查找到设备后,通过filp->f_op = new->ops这行代码将设备对象new中的ops指针(前面曾讨论过,驱动程序通过调用cdev_init将其实现的file_operations对象的指针赋值给设备对象cdev的ops成员)赋值给filp对象中的f_op成员,此处展示了如何将驱动程序中实现的struct file_operations与filp关联起来,从此图2-9中的filp->f_op将指向驱动程序中实现的struct file_operations对象。
接下来函数会检查驱动程序中是否实现了open函数(if (filp->f_op->open)),如果实现了,就调用设备驱动程序中实现的open函数。打开一个字符设备节点的大体流程如图2-10所示:
图2-10 开一个字符设备节点的功能流程 |
2.7 字符设备文件的打开操作(3)
图中,当应用程序打开一个设备文件时,将通过系统调用sys_open进入内核空间。在内核空间将主要由do_sys_open函数负责发起整个设备文件打开操作,它首先要获得该设备文件所对应的inode,然后调用其中的i_fop函数,对字符设备节点的inode而言,i_fop函数就是chrdev_open(图中标号1的线段),后者通过inode中的i_rdev成员在cdev_map中查找该设备文件所对应的设备对象cdev(图中标号2的线段),在成功找到了该设备对象之后,将inode的i_cdev成员指向该字符设备对象(图中标号3的线段),这样下次再对该设备文件节点进行打开操作时,就可以直接通过i_cdev成员得到设备节点所对应的字符设备对象,而无须再通过cdev_map进行查找。内核在每次打开一个设备文件时,都会产生一个整型的文件描述符fd和一个新的struct file对象filp来跟踪对该文件的这一次操作,在打开设备文件时,内核会将filp和fd关联起来,同时会将cdev中的ops赋值给filp->f_op(图中标号4的线段)。最后,sys_open系统调用将设备文件描述符fd返回到用户空间,如此在用户空间对后续的文件操作read、write和ioctl等函数的调用,将会通过该fd获得文件所对应的filp,根据filp中的f_op就可以调用到该文件所对应的设备驱动上实现的函数。
通过以上过程,我们看到了设备号在其中的重要作用。当设备驱动程序通过cdev_add把一个字符设备对象加入到系统时,需要一个设备号来标记该对象在cdev_map中的位置信息。当我们在用户空间通过mknod来生成一个设备文件节点时,也需要在命令行中提供设备号的信息,内核会将该设备号信息记录到设备文件节点所对应inode的i_rdev成员中。当我们的应用程序打开一个设备文件时,系统将会根据设备文件对应的inode->i_rdev信息在cdev_map中寻找设备。所以在这个过程中务必要保证设备文件节点的inode->i_rdev数据和设备驱动程序使用的设备号完全一致,否则就会发生严重问题。对应到现实世界的操作,那就是在用mknod生成设备节点时所提供的设备号信息一定要与设备驱动程序中分配使用的设备号一致。
在上述open一个设备文件的基础上,接下来不妨看看它的相反操作close。有了前面对open操作技术细节讨论所打下的良好基础,现在理解起close并不困难,在此读者也正好可以看看用户空间open函数返回的文件描述符fd如何被close等函数使用。
用户空间close函数的原型为:
- int close(unsigned int fd);
针对close的系统调用函数为sys_close,这里将其核心代码重新整理如下:
- <fs/open.c>
- int sys_close(unsigned int fd)
- {
- struct file * filp;
- struct files_struct *files = current->files;
- struct fdtable *fdt;
- int retval;
- …
- fdt = files_fdtable(files);
- …
- filp = fdt->fd[fd];
- …
- retval = filp_close(filp, files);
- …
- return retval;
- }
从fd得到filp这段代码,请读者参考本章2-9。接下来调用filp_close函数,close函数的大部分秘密都隐藏在其中,有必要看看其主要代码片段:
- <fs/open.c>
- int filp_close(struct file *filp, fl_owner_t id)
- {
- int retval = 0;
- if (!file_count(filp)) {
- printk(KERN_ERR "VFS: Close: file count is 0\n");
- return 0;
- }
- if (filp->f_op && filp->f_op->flush)
- retval = filp->f_op->flush(filp, id);
- …
- fput(filp);
- return retval;
- }
if (!file_count(filp))用来判断filp中的f_count成员是否为0,如果针对同一个设备文件close的次数多于open次数,就会出现这种情况,此时函数直接返回0,因为实质性的工作都被前面的close做完了。接下来的情况有点意思,如果设备驱动程序定义了flush函数,那么在release函数被调用前,会首先调用flush,这是为了确保在把文件关闭前缓存在系统中的数据被真正写回到硬件中。字符设备很少会出现这种情况,因为这种设备的慢速I/O特性决定了它无须使用这种缓冲机制来提升系统性能,但是块设备就不一样了,比如SCSI硬盘会和系统进行大量数据的传输,为此内核为块设备驱动程序设计了高速缓存机制,这种情况下为了保证文件数据的完整性,必须在文件关闭前将高速缓存中的数据写回到磁盘中。不过这是后话了,块设备驱动程序的这种机制将在"块设备驱动程序"一章中讨论。
函数的最后调用fput,貌似很简单的一个函数,其实内涵却很丰富:
- <fs/file_table.c>
- void fput(struct file *file)
- {
- if (atomic_long_dec_and_test(&file->f_count))
- __fput(file);
- }
函数中的那个atomic_long_dec_and_test是个体系架构相关的原子测试操作,就是说,如果file->f_count的值为1,那么它将返回true,这意味着可以真正关闭当前的文件了,所以__fput将被调用,并最终完成文件关闭的任务,它的一些关键调用节点如下所示:
- <fs/file_table.c>
- static void __fput(struct file *file)
- {
- …
- if (unlikely(file->f_flags & FASYNC)) {
- if (file->f_op && file->f_op->fasync)
- file->f_op->fasync(-1, file, 0);
- }
- if (file->f_op && file->f_op->release)
- file->f_op->release(inode, file);
- …
- fops_put(file->f_op);
- file_free(file);
- }
注意上面的FASYNC标志位,在本书后面的章节会讨论到file_operations中的一些常用的函数实现。然后函数调用到了设备驱动程序中提供的release函数,接下来是一些系统资源的释放。可见,对于应用程序的一个close调用,并非必然对应着release函数的调用,只有在当前文件的所有副本都关闭之后,release函数才会被调用。
2.8 本章小结
本章描述了字符设备驱动程序内核框架的技术细节。基本上可以看到,字符设备驱动内核框架的展开是按照两条线进行的:一条是设备与系统的关系,一个字符设备对象cdev通过cdev_add加入到系统中(由cdev_map所管理的哈希链表),此时设备号作为哈希索引值;另一条是设备与文件系统的关系,设备通过设备号以设备文件的形式向用户空间宣示其存在。这两条线间的联系通过文件系统接口去打开一个字符设备文件而建立:
mknod命令将为字符设备创建一个设备节点,mknod的系统调用将会为此设备节点产生一个inode,mknod命令行中给出的设备号将被记录到inode->i_rdev中,同时inode的i_fop会将open成员指向chrdev_open函数。
当用户空间open一个设备文件时,open函数通过系统进入内核空间。在内核空间,首先找到该设备节点所对应的inode,然后调用inode->i_fop->open(),我们知道这将导致chrdev_open函数被调用。同时,open的系统调用还将产生一个(fd, filp)二元组来标识本次的文件打开操作,这个二元组是一一对应的关系。
chrdev_open通过inode->i_rdev在cdev_map中查找inode对应的字符设备,cdev_map中记录着所有通过cdev_add加入系统的字符设备。
当在cdev_map中成功查找到该字符设备时,chrdev_open将inode->i_cdev指向找到的字符设备对象,同时将cdev->ops赋值给filp->f_op。
字符设备驱动程序负责实现struct file_operations对象,在字符设备对象初始化时cdev_init函数负责将字符设备对象cdev->ops指向该file_operations对象。
用户空间对字符设备的后续操作,比如read、write和ioctl等,将通过open函数返回的fd找到对应的filp,然后调用filp->f_op中实现的各类字符设备操作函数。
以上就是内核为字符设备驱动程序设计的大体框架,从中可以看到设备号在沟通用户空间的设备文件与内核中的设备对象之间所起的重要作用。
另外,对于字符设备驱动程序本身而言,核心的工作是实现struct file_operations对象中的各类函数,file_operations结构中虽然定义了众多的函数指针,但是现实中设备驱动程序并不需要为它的每一个函数指针都提供相应的实现。本书后面的"字符设备的高级操作"一章会详细讨论其中一些重要函数的作用和实现原理。