0%

【经典问题】在 Linux 上打开一个字符设备文件会经历哪些过程?

在 Linux 上打开一个字符设备文件会经历哪些过程?这是一道非常经典的面试题,面试官之所以爱问,是因为这个问题如果想要回答好,至少需要掌握 Linux 系统调用、文件系统、字符设备驱动程序等模块的实现原理。下面我们就沿着内核注册字符设备驱动程序,到用户层打开设备文件,再到调用到内核的驱动程序,最后返回到用户空间这一过程,试着回答下这个问题。

从驱动程序开始

这里默认读者都有编写简单字符设备驱动程序的经验,所以很多细枝末节的驱动实现我们就忽略不谈,只来看看,驱动程序是如何暴露给用户层使用的。

cdev_add 添加字符设备

我们都知道,在使用 总线-设备-驱动 模型编写字符设备驱动程序时,需要在 probe 函数中进行设备的初始化工作,以及今天的主题—— cdev_add 向内核注册/添加一个 cdev 字符设备。下面是 cdev 结构体的抽象:

1
2
3
4
5
6
7
struct cdev {
...
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};

可以看到,cdev 结构体十分简单,其中:

  • opsfile_operations 是一个由函数指针构成的接口,定义了一组对文件的统一操作,如 open, read, write, release
  • list:链表节点,用于将内核中的 cdev 以链表的形式组织起来
  • dev_t:设备号,其中主设备号代表设备使用的驱动程序,次设备号代表具体设备
  • count:与该设备相对应的连续次设备号的数量

那么内核是如何将 cdev 组织起来,进行管理的呢?这里我们力求简单,画了一个简单的示意图:

熟悉数据结构的读者肯定看出来了,这就是一个以 n % 255 作为哈希函数的哈希表,利用链表解决地址冲突。这个哈希表名为 cdev_map 是一个全局变量,内核就是利用这个哈希表查找和插入 cdev 的,值得注意的是,其中哈希表的键是 cdev 主设备号或者直接说是设备号 dev_t。

现在看 cdev_addcdev_del 这两个函数就好理解了,无非就是根据设备号 dev_t 将 cdev 插入哈希表而已。

mdev 与 udev

如果了解过 Linux 设备模型的话,读者应该知道,classdevice 从设备功能的角度在 sysfs 文系统中将类和设备暴露给用户空间。我们可以在 /sys/class 目录下查看内核导出的所有类,在 /sys/class/xxx/ 下看到指定类下的设备。

在字符设备的驱动程序中,使用 class_createdevice_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
2
3
4
5
6
-->mknod
-->sys_mknod
-->sys_mknodat
-->vfs_mknod
-->dir->i_op->mknod(回调具体文件系统的 mknod)
-->最终创建一个 inode 实例

inode 结构体

inode 结构体是系统中一个真实存在文件的抽象,当作为一个字符设备文件的抽象时,它的如下字段比较重要:

1
2
3
4
5
6
7
8
9
10
struct inode {
...
dev_t i_rdev;
const struct file_operations *i_fop;
union {
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev;
struct cdev *i_cdev;
};
}
  • i_rdev :对应设备的 设备号
  • i_fopfile_operations 前面提到过
  • i_cdev :对应的字符设备

那么,当 mknod 系统调用执行完后,创建出来的 inode 各个字段被初始化成什么呢?

这里以 ext4 文件系统为例,查看内核创建和初始 inode 的代码

1
2
3
4
5
6
7
8
9
10
11
static int ext4_create(struct inode *dir, struct dentry *dentry, umode_t mode, bool excl)
{
...
/* 创建 字符设备 inode */
inode = ext4_new_inode_start_handle(dir, mode, &dentry->d_name, 0,
NULL, EXT4_HT_DIR, credits);
if (!IS_ERR(inode)) {
/* 初始化 inode */
init_special_inode(inode, inode->i_mode, rdev);
...
}

我们比较关系和字符设备相关的字段是如何被初始的,看如下的代码:

1
2
3
4
5
6
7
8
9
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
...
if (S_ISCHR(mode)) {
inode->i_fop = &def_chr_fops;
inode->i_rdev = rdev;
}
...
}

可以看到,i_fop 被初始化成了 def_chr_fopsi_rdev 被初始化成了 rdev ,而 i_cdev 没有被初始化。其中,最好理解的是 rdev ,因为这就是 mknod() 系统调用从用户空间传递的参数。那么 def_chr_f_ops 是什么呢?继续看代码:

1
2
3
4
const struct file_operations def_chr_fops = {
.open = chrdev_open,
.llseek = noop_llseek,
};

简单的不可思议,只有两个成员的 file_operations 结构体,这里我们先不深究它们的实现,因为后面还会遇到。OK,到这里,第一部分的工作就到此结束了,我们做好了打开设备文件前的所有准备工作,小小总结下:

  • 在驱动程序中,创建了字符设备 cdev ,其中包含了我们自己定义的 file_operations 操作
  • 将字符设备添加到了内核的一个全局哈希表中,通过设备号 dev_t 进行索引
  • 在驱动程序中,通过创建 classdevice 将设备信息,特别是设备号,暴露给用户空间
  • 在用户空间中有一个应用程序,它根据我们暴露的设备号,利用 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
2
3
4
5
6
7
8
int drv_open(struct inode *inode, struct file *filp)
{
...
}
struct file_operations fops = {
...
.open = drv_open
};

两者相比差异很大,从设备驱动程序员的角度,我们将重点放在两者如何建立联系的关键点上。

用户程序调用 open 函数返回的文件描述符 fd,这是个 int 型的变量,会被用户程序后续的 read、write 和ioctl 等函数所使用。同时可以看到,在驱动程序中的 read、write 和 ioctl 等函数其第一个参数都是 struct file *filp ,显然,内核需要在打开设备文件 open 时:

  • fdfilp 建立联系
  • filp 与驱动程序中的 fops 建立联系

接下来我们将描述从用户态的 open 是如何一步一步调用到驱动程序提供的 open 函数并且建立上述联系的。

从用户空间到内核空间会逐步调用下面的函数:

1
2
3
4
5
6
7
8
——>open
——>sys_open
——>do_sys_open
——>get_unused_fd_flags (创建文件描述符 fd)
——>do_filp_open
——>path_openat (查找 inode)
——>get_empty_filp (创建 struct file)
——>__entry_open (根据 inode 初始化 file 并调用 f->f_ops->open)

file 结构体

file 结构体在内核中代表一个打开的文件 ,一个文件被多次 open ,就会创建多个 file 结构体。和设备驱动程序有关联的字段如下:

1
2
3
4
5
6
7
8
struct file {
...
const struct file_operations *f_op;
atomic_long_t f_count;
unsigned int f_flags;
void *private_data;
...
}
  • f_opfile_operations 结构体,同上
  • f_count :引用计数,当 close 一个文件时,只有 file 对象中 f_count == 0 时才真正执行关闭操作。
  • f_flags :记录文件被 open 时所指定的打开模式,这个成员将会影响后续的 read/write 等函数的行为模式
  • private_data :常被用来记录设备驱动程序自身定义的数据,因为 filp 指针会在驱动程序实现的file_operations 对象其他成员函数之间传递,所以可以通过 private_data 成员在某一个特定文件视图的基础上共享数据

进程打开的文件是通过 fd 表示的,那么它们是如何建立关联的呢?可以看下图:

image-20240711185728622

点到为止,我们继续回到 open 系统调用的主线,现在内核已经创建了一个file 结构体,那么接下来是如何初始化的呢?初始化代码在 __entry_open 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static struct file *__dentry_open(struct dentry *dentry, struct vfsmount *mnt,  
struct file *f,
int (*open)(struct inode *, struct file *),
const struct cred *cred)
{
...
/* 将 inode 中的 i_fop 赋值给 filp->f_op */
f->f_op = fops_get(inode->i_fop);

if (!open && f->f_op)
open = f->f_op->open;
if (open) {
/* 调用 */
error = open(inode, f);
}
...
}

我们看到,这里将查找到的 inode 中的 file_operations 结构体指针赋值给了 file ,然后调用了其中的 open 函数。回忆前面的内容,inode 中的 i_fop 是使用 def_chr_fops 进行初始的,我们来看看其中的 open 函数究竟做什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int chrdev_open(struct inode *inode, struct file *filp)  
{
int ret = 0, idx;
/* 根据 inode 中的设备号在全局哈希表 cdev_map 中查找 cdev */
struct kobject *kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
struct cdev *new = container_of(kobj, struct cdev, kobj);

/* 在 inode 中保存 cdev */
inode->i_cdev = new;

/* 将 cdev 中的 ops 赋值给 file->f_op */
filp->f_op = new->ops;
if (filp->f_op->open) {
/* 调用我们在驱动中实现的 open */
ret = filp->f_op->open(inode,filp);
}
return ret;
}

可见,该函数打通了 inodecdevfile 三者之间的关系,不可谓不重要!!最后,我们总结一下打开文件的过程:

  • 创建/申请一个文件描述符 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 之后,内核中各个结构的状态吧:

image-20240711214043086