在 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 之后,内核中各个结构的状态吧:
