在 Linux 上打开一个字符设备文件会经历哪些过程?这是一道非常经典的面试题,面试官之所以爱问,是因为这个问题如果想要回答好,至少需要掌握 Linux 系统调用、文件系统、字符设备驱动程序等模块的实现原理。下面我们就沿着内核注册字符设备驱动程序,到用户层打开设备文件,再到调用到内核的驱动程序,最后返回到用户空间这一过程,试着回答下这个问题。
从驱动程序开始
这里默认读者都有编写简单字符设备驱动程序的经验,所以很多细枝末节的驱动实现我们就忽略不谈,只来看看,驱动程序是如何暴露给用户层使用的。
cdev_add 添加字符设备
我们都知道,在使用 总线
-设备
-驱动
模型编写字符设备驱动程序时,需要在 probe
函数中进行设备的初始化工作,以及今天的主题—— cdev_add
向内核注册/添加一个 cdev
字符设备。下面是 cdev
结构体的抽象:
1 | struct cdev { |
可以看到,cdev
结构体十分简单,其中:
- ops :
file_operations
是一个由函数指针构成的接口,定义了一组对文件的统一操作,如open
,read
,write
,release
等 - list:链表节点,用于将内核中的
cdev
以链表的形式组织起来 - dev_t:设备号,其中主设备号代表设备使用的驱动程序,次设备号代表具体设备
- count:与该设备相对应的连续次设备号的数量
那么内核是如何将 cdev
组织起来,进行管理的呢?这里我们力求简单,画了一个简单的示意图:
熟悉数据结构的读者肯定看出来了,这就是一个以 n % 255
作为哈希函数的哈希表,利用链表解决地址冲突。这个哈希表名为 cdev_map
是一个全局变量,内核就是利用这个哈希表查找和插入 cdev
的,值得注意的是,其中哈希表的键是 cdev
主设备号或者直接说是设备号 dev_t。
现在看 cdev_add
,cdev_del
这两个函数就好理解了,无非就是根据设备号 dev_t 将 cdev
插入哈希表而已。
mdev 与 udev
如果了解过 Linux 设备模型的话,读者应该知道,class
和 device
从设备功能的角度在 sysfs 文系统中将类和设备暴露给用户空间。我们可以在 /sys/class
目录下查看内核导出的所有类,在 /sys/class/xxx/
下看到指定类下的设备。
在字符设备的驱动程序中,使用 class_create
和 device_create
两个函数可以实现上述功能。将信息导出到用户空间之后,谁会来使用呢?这就要提到我们要讨论的主角 mdev (mdev 是 udev 在嵌入式系统中的替代品)了——一个运行在用户空间的应用程序,仅此而已。
mdev 的实现原理我们就不赘述了,目前我们只要知道他的一个功能就可以:
- 执行
mdev -s
命令时,mdev
扫描/sys/block
和/sys/class
两个目录下dev
属性的文件,从该dev
属性的文件中获取到设备编号 - 以包含该
dev
属性文件的目录名称作为设备名在/dev
目录下创建相应的设备文件
具体的,在 mdev 的实现中使用了类似的语句:
1 | mknod(device_name, mode | type, makedev(major, minor)); |
其中 mknod
是一个系统调用,我们看看其调用路径
1 | -->mknod |
inode 结构体
inode 结构体是系统中一个真实存在文件的抽象,当作为一个字符设备文件的抽象时,它的如下字段比较重要:
1 | struct inode { |
- i_rdev :对应设备的 设备号
- i_fop:
file_operations
前面提到过 - i_cdev :对应的字符设备
那么,当 mknod
系统调用执行完后,创建出来的 inode
各个字段被初始化成什么呢?
这里以 ext4
文件系统为例,查看内核创建和初始 inode
的代码
1 | static int ext4_create(struct inode *dir, struct dentry *dentry, umode_t mode, bool excl) |
我们比较关系和字符设备相关的字段是如何被初始的,看如下的代码:
1 | void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) |
可以看到,i_fop
被初始化成了 def_chr_fops
,i_rdev
被初始化成了 rdev
,而 i_cdev
没有被初始化。其中,最好理解的是 rdev
,因为这就是 mknod()
系统调用从用户空间传递的参数。那么 def_chr_f_ops
是什么呢?继续看代码:
1 | const struct file_operations def_chr_fops = { |
简单的不可思议,只有两个成员的 file_operations
结构体,这里我们先不深究它们的实现,因为后面还会遇到。OK,到这里,第一部分的工作就到此结束了,我们做好了打开设备文件前的所有准备工作,小小总结下:
- 在驱动程序中,创建了字符设备
cdev
,其中包含了我们自己定义的file_operations
操作 - 将字符设备添加到了内核的一个全局哈希表中,通过设备号
dev_t
进行索引 - 在驱动程序中,通过创建
class
和device
将设备信息,特别是设备号,暴露给用户空间 - 在用户空间中有一个应用程序,它根据我们暴露的设备号,利用
mknod
系统调用,创建了一个设备文件 - 该设备文件在内核中有一个与之对应的
inode
,这个inode
被设备号和def_chr_fops
初始化
打开设备文件
open 打开一个文件
用户空间 open
函数的原型为:
1 | int open(const char *filename, int flags, mode_t mode); |
这个函数如果成功,将返回一个文件描述符 fd
,否则返回 -1。
- 函数的第一个参数
filename
表示要打开的文件名 - 第二个参数
flags
用于指定文件的打开或者创建模式 - 最后一个参数
mode
只在创建时才使用,用于指定新建文件的访问权限,比如可读、可写及可执行等权限
而在驱动程序中, open
函数的原型为:
1 | int drv_open(struct inode *inode, struct file *filp) |
两者相比差异很大,从设备驱动程序员的角度,我们将重点放在两者如何建立联系的关键点上。
用户程序调用 open
函数返回的文件描述符 fd
,这是个 int
型的变量,会被用户程序后续的 read、write 和ioctl 等函数所使用。同时可以看到,在驱动程序中的 read、write 和 ioctl 等函数其第一个参数都是 struct file *filp
,显然,内核需要在打开设备文件 open
时:
- 为
fd
与filp
建立联系 - 为
filp
与驱动程序中的fops
建立联系
接下来我们将描述从用户态的 open
是如何一步一步调用到驱动程序提供的 open
函数并且建立上述联系的。
从用户空间到内核空间会逐步调用下面的函数:
1 | ——>open |
file 结构体
file
结构体在内核中代表一个打开的文件 ,一个文件被多次 open
,就会创建多个 file
结构体。和设备驱动程序有关联的字段如下:
1 | struct file { |
- f_op:
file_operations
结构体,同上 - f_count :引用计数,当
close
一个文件时,只有file
对象中f_count == 0
时才真正执行关闭操作。 - f_flags :记录文件被
open
时所指定的打开模式,这个成员将会影响后续的read/write
等函数的行为模式 - private_data :常被用来记录设备驱动程序自身定义的数据,因为
filp
指针会在驱动程序实现的file_operations
对象其他成员函数之间传递,所以可以通过private_data
成员在某一个特定文件视图的基础上共享数据
进程打开的文件是通过 fd
表示的,那么它们是如何建立关联的呢?可以看下图:
点到为止,我们继续回到 open
系统调用的主线,现在内核已经创建了一个file
结构体,那么接下来是如何初始化的呢?初始化代码在 __entry_open
函数中:
1 | static struct file *__dentry_open(struct dentry *dentry, struct vfsmount *mnt, |
我们看到,这里将查找到的 inode
中的 file_operations
结构体指针赋值给了 file
,然后调用了其中的 open
函数。回忆前面的内容,inode
中的 i_fop
是使用 def_chr_fops
进行初始的,我们来看看其中的 open
函数究竟做什么:
1 | static int chrdev_open(struct inode *inode, struct file *filp) |
可见,该函数打通了 inode
,cdev
,file
三者之间的关系,不可谓不重要!!最后,我们总结一下打开文件的过程:
创建/申请一个文件描述符
fd
,其代表了一个打开的文件在打开文件表 中的索引申请一个空的
struct file
,用来代表一个打开的文件,会被加入到打开文件表中根据传入的文件路径 ,找到
inode
,并用其来初始化file
调用
filp->f_op->open
,该函数就是inode
中的i_fop->open
,再进一步,其实就是chardev_open
最后,在
chrdev_open
中- 根据
inode
中保存到设备号找到cdev
- 在
inode
中保存cdev
- 将
cdev
中的ops
赋值给file->f_op
- 调用
file->f_op->open
,这就是我们在驱动程序中实现的open
函数
- 根据
最后的最后,我们来看一下 open
之后,内核中各个结构的状态吧: