字符设备文件的打开操作(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函数才会被调用。