32 | 字符设备(上):如何建立直销模式?
下载APP
关闭
渠道合作
推荐作者
32 | 字符设备(上):如何建立直销模式?
2019-06-10 刘超 来自北京
《趣谈Linux操作系统》
课程介绍
讲述:刘超
时长16:49大小15.36M
上一节,我们讲了输入输出设备的层次模型,还是比较复杂的,块设备尤其复杂。这一节为了让你更清晰地了解设备驱动程序的架构,我们先来讲稍微简单一点的字符设备驱动。
这一节,我找了两个比较简单的字符设备驱动来解析一下。一个是输入字符设备,鼠标。代码在 drivers/input/mouse/logibm.c 这里。
另外一个是输出字符设备,打印机,代码 drivers/char/lp.c 这里。
内核模块
上一节,我们讲过,设备驱动程序是一个内核模块,以 ko 的文件形式存在,可以通过 insmod 加载到内核中。那我们首先来看一下,怎么样才能构建一个内核模块呢?
一个内核模块应该由以下几部分组成。
第一部分,头文件部分。一般的内核模块,都需要 include 下面两个头文件:
如果你去看上面两个驱动程序,都能找到这两个头文件。当然如果需要的话,我们还可以引入更多的头文件。
第二部分,定义一些函数,用于处理内核模块的主要逻辑。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。
例如,logibm.c 里面就定义了 logibm_open。logibm_close 就是处理打开和关闭的,定义了 logibm_interrupt 就是用来响应中断的。再如,lp.c 里面就定义了 lp_read,lp_write 就是处理读写的。
第三部分,定义一个 file_operations 结构。前面我们讲过,设备是可以通过文件系统的接口进行访问的。咱们讲文件系统的时候说过,对于某种文件系统的操作,都是放在 file_operations 里面的。例如 ext4 就定义了这么一个结构,里面都是 ext4_xxx 之类的函数。设备要想被文件系统的接口操作,也需要定义这样一个结构。
例如,lp.c 里面就定义了这样一个结构。
在 logibm.c 里面,我们找不到这样的结构,是因为它属于众多输入设备的一种,而输入设备的操作被统一定义在 drivers/input/input.c 里面,logibm.c 只是定义了一些自己独有的操作。
第四部分,定义整个模块的初始化函数和退出函数,用于加载和卸载这个 ko 的时候调用。
例如 lp.c 就定义了 lp_init_module 和 lp_cleanup_module,logibm.c 就定义了 logibm_init 和 logibm_exit。
第五部分,调用 module_init 和 module_exit,分别指向上面两个初始化函数和退出函数。就像本节最开头展示的一样。
第六部分,声明一下 lisense,调用 MODULE_LICENSE。
有了这六部分,一个内核模块就基本合格了,可以工作了。
打开字符设备
字符设备可不是一个普通的内核模块,它有自己独特的行为。接下来,我们就沿着打开一个字符设备的过程,看看字符设备这个内核模块做了哪些特殊的事情。
要使用一个字符设备,我们首先要把写好的内核模块,通过 insmod 加载进内核。这个时候,先调用的就是 module_init 调用的初始化函数。
例如,在 lp.c 的初始化函数 lp_init 对应的代码如下:
在字符设备驱动的内核模块加载的时候,最重要的一件事情就是,注册这个字符设备。注册的方式是调用 __register_chrdev_region,注册字符设备的主次设备号和名称,然后分配一个 struct cdev 结构,将 cdev 的 ops 成员变量指向这个模块声明的 file_operations。然后,cdev_add 会将这个字符设备添加到内核中一个叫作 struct kobj_map *cdev_map 的结构,来统一管理所有字符设备。
其中,MKDEV(cd->major, baseminor) 表示将主设备号和次设备号生成一个 dev_t 的整数,然后将这个整数 dev_t 和 cdev 关联起来。
在 logibm.c 中,我们在 logibm_init 找不到注册字符设备,这是因为 input.c 里面的初始化函数 input_init 会调用 register_chrdev_region,注册输入的字符设备,会在 logibm_init 中调用 input_register_device,将 logibm.c 这个字符设备注册到 input.c 里面去,这就相当于 input.c 对多个输入字符设备进行统一的管理。
内核模块加载完毕后,接下来要通过 mknod 在 /dev 下面创建一个设备文件,只有有了这个设备文件,我们才能通过文件系统的接口,对这个设备文件进行操作。
mknod 也是一个系统调用,定义如下:
我们可以在这个系统调用里看到,在文件系统上,顺着路径找到 /dev/xxx 所在的文件夹,然后为这个新创建的设备文件创建一个 dentry。这是维护文件和 inode 之间的关联关系的结构。
接下来,如果是字符文件 S_IFCHR 或者设备文件 S_IFBLK,我们就调用 vfs_mknod。
这里需要调用对应的文件系统的 inode_operations。应该调用哪个文件系统呢?
如果我们在 linux 下面执行 mount 命令,能看到下面这一行:
也就是说,/dev 下面的文件系统的名称为 devtmpfs,我们可以在内核中找到它。
从这里可以看出,devtmpfs 在挂载的时候,有两种模式,一种是 ramfs,一种是 shmem 都是基于内存的文件系统。这里你先不用管,基于内存的文件系统具体是怎么回事儿。
这两个 mknod 虽然实现不同,但是都会调用到同一个函数 init_special_inode。
显然这个文件是个特殊文件,inode 也是特殊的。这里这个 inode 可以关联字符设备、块设备、FIFO 文件、Socket 等。我们这里只看字符设备。
这里的 inode 的 file_operations 指向一个 def_chr_fops,这里面只有一个 open,就等着你打开它。
另外,inode 的 i_rdev 指向这个设备的 dev_t。还记得 cdev_map 吗?通过这个 dev_t,可以找到我们刚在加载的字符设备 cdev。
到目前为止,我们只是创建了 /dev 下面的一个文件,并且和相应的设备号关联起来。但是,我们还没有打开这个 /dev 下面的设备文件。
现在我们来打开它。打开一个文件的流程,我们在文件系统那一节讲过了,这里不再重复。最终就像打开字符设备的图中一样,打开文件的进程的 task_struct 里,有一个数组代表它打开的文件,下标就是文件描述符 fd,每一个打开的文件都有一个 struct file 结构,会指向一个 dentry 项。dentry 可以用来关联 inode。这个 dentry 就是咱们上面 mknod 的时候创建的。
在进程里面调用 open 函数,最终会调用到这个特殊的 inode 的 open 函数,也就是 chrdev_open。
在这个函数里面,我们首先看这个 inode 的 i_cdev,是否已经关联到 cdev。如果第一次打开,当然没有。没有没关系,inode 里面有 i_rdev 呀,也就是有 dev_t。我们可以通过它在 cdev_map 中找 cdev。咱们上面注册过了,所以肯定能够找到。找到后我们就将 inode 的 i_cdev,关联到找到的 cdev new。
找到 cdev 就好办了。cdev 里面有 file_operations,这是设备驱动程序自己定义的。我们可以通过它来操作设备驱动程序,把它付给 struct file 里面的 file_operations。这样以后操作文件描述符,就是直接操作设备了。
最后,我们需要调用设备驱动程序的 file_operations 的 open 函数,真正打开设备。对于打印机,调用的是 lp_open。对于鼠标调用的是 input_proc_devices_open,最终会调用到 logibm_open。这些多和设备相关,你不必看懂它们。
写入字符设备
当我们像打开一个文件一样打开一个字符设备之后,接下来就是对这个设备的读写。对于文件的读写咱们在文件系统那一章详细讲述过,读写的过程是类似的,所以这里我们只解析打印机驱动写入的过程。
写入一个字符设备,就是用文件系统的标准接口 write,参数文件描述符 fd,在内核里面调用的 sys_write,在 sys_write 里面根据文件描述符 fd 得到 struct file 结构。接下来再调用 vfs_write。
我们可以看到,在 __vfs_write 里面,我们会调用 struct file 结构里的 file_operations 的 write 函数。上面我们打开字符设备的时候,已经将 struct file 结构里面的 file_operations 指向了设备驱动程序的 file_operations 结构,所以这里的 write 函数最终会调用到 lp_write。
这个设备驱动程序的写入函数的实现还是比较典型的。先是调用 copy_from_user 将数据从用户态拷贝到内核态的缓存中,然后调用 parport_write 写入外部设备。这里还有一个 schedule 函数,也即写入的过程中,给其他线程抢占 CPU 的机会。然后,如果 count 还是大于 0,也就是数据还没有写完,那我们就接着 copy_from_user,接着 parport_write,直到写完为止。
使用 IOCTL 控制设备
对于 I/O 设备来讲,我们前面也说过,除了读写设备,还会调用 ioctl,做一些特殊的 I/O 操作。
ioctl 也是一个系统调用,它在内核里面的定义如下:
其中,fd 是这个设备的文件描述符,cmd 是传给这个设备的命令,arg 是命令的参数。其中,对于命令和命令的参数,使用 ioctl 系统调用的用户和驱动程序的开发人员约定好行为即可。
其实 cmd 看起来是一个 int,其实他的组成比较复杂,它由几部分组成:
最低八位为 NR,是命令号;
然后八位是 TYPE,是类型;
然后十四位是参数的大小;
最高两位是 DIR,是方向,表示写入、读出,还是读写。
由于组成比较复杂,有一些宏是专门用于组成这个 cmd 值的。
在用户程序中,可以通过上面的“Used to create numbers”这些宏,根据参数生成 cmd,在驱动程序中,可以通过下面的“used to decode ioctl numbers”这些宏,解析 cmd 后,执行指令。
ioctl 中会调用 do_vfs_ioctl,这里面对于已经定义好的 cmd,进行相应的处理。如果不是默认定义好的 cmd,则执行默认操作。对于普通文件,调用 file_ioctl;对于其他文件调用 vfs_ioctl。
由于咱们这里是设备驱动程序,所以调用的是 vfs_ioctl。
这里面调用的是 struct file 里 file_operations 的 unlocked_ioctl 函数。我们前面初始化设备驱动的时候,已经将 file_operations 指向设备驱动的 file_operations 了。这里调用的是设备驱动的 unlocked_ioctl。对于打印机程序来讲,调用的是 lp_ioctl。可以看出来,这里面就是 switch 语句,它会根据不同的 cmd,做不同的操作。
总结时刻
这一节我们讲了字符设备的打开、写入和 ioctl 等最常见的操作。一个字符设备要能够工作,需要三部分配合。
第一,有一个设备驱动程序的 ko 模块,里面有模块初始化函数、中断处理函数、设备操作函数。这里面封装了对于外部设备的操作。加载设备驱动程序模块的时候,模块初始化函数会被调用。在内核维护所有字符设备驱动的数据结构 cdev_map 里面注册,我们就可以很容易根据设备号,找到相应的设备驱动程序。
第二,在 /dev 目录下有一个文件表示这个设备,这个文件在特殊的 devtmpfs 文件系统上,因而也有相应的 dentry 和 inode。这里的 inode 是一个特殊的 inode,里面有设备号。通过它,我们可以在 cdev_map 中找到设备驱动程序,里面还有针对字符设备文件的默认操作 def_chr_fops。
第三,打开一个字符设备文件和打开一个普通的文件有类似的数据结构,有文件描述符、有 struct file、指向字符设备文件的 dentry 和 inode。字符设备文件的相关操作 file_operations 一开始指向 def_chr_fops,在调用 def_chr_fops 里面的 chrdev_open 函数的时候,修改为指向设备操作函数,从而读写一个字符设备文件就会直接变成读写外部设备了。
课堂练习
这节我用打印机驱动程序作为例子来给你讲解字符设备,请你仔细看一下它的代码,设想一下,如果让你自己写一个字符设备驱动程序,应该实现哪些函数呢?
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 10
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
31 | 输入与输出:如何建立售前售后生态体系?
下一篇
33 | 字符设备(下):如何建立直销模式?
精选留言(13)
- 西山薄凉2020-03-01课代表不在了,我来当课代表。 ## 字符设备 ### 内核模块 - 驱动程序的内核模块,以 ko 的文件形式存在,可以通过 insmod 加载到内核中 - 一个内核模块应该由以下几部分组成 - 头文件部分:include <linux/module.h> 及 <linux/init.h> - 定义以内科模块处理逻辑的函数,如开、关、读写及响应中断。 - 定义一个 file_operations 接口,使得对上层接口统一 - 定义整个模块的初始化和退出函数 - 调用 module_init 和 module_exit,分别指向上面两个初始化函数和退出函数 - 声明一下 lisense,调用 MODULE_LICENSE ### 打开字符设备 - 打开字符设备 - 注册字符设备:通过 insmod 加载进内核 - 调用 __register_chrdev_region - 注册设备的主次设备号和名称 - 初始化 cdev 结构体,将其 ops 成员指向设备定义的 file_operations - 调用 cdev_add 将设备添加到内核中的 cdev_map,统一管理字符设备 - 创建设备文件:通过 mknod 在 /dev 下面创建一个设备文件 - 找到设备文件所在的文件夹,然后为这个新创建的设备文件创建一个 dentry,用于关联文件和 inode - 创建特殊 inode,用于关联设备(还可关联FIFO文件、socket等) - 打开设备文件:调用 inode 的 open 函数 - 如果 cdev 还没有关联,从 cdev_map 中找到 cdev 并关联 - 找到 cdev 的 file_operations,将其设置给文件描述符 - 调用设备驱动程序的 file_operations 的 open 函数,真正打开设备 ### 写入字符设备 - 写入字符设备 - 调用文件系统标准接口 write,参数为设备的文件描述符 - 由于已经将 file_operations 替换成了设备的,所以会直接调用设备定义的 write(多态) ### IOCTL控制设备 - 发送 IOCTL 信令控制设备 - cmd 组成(32位): - 最低 8 位为 NR,是命令号; - 然后 8 位是 TYPE,是类型; - 然后 14 位是参数的大小; - 最高 2 位是 DIR,是方向,表示写入、读出,还是读写。 - 有对应的宏方便操作 cmd - 调用 do_vfs_ioctl,分支判断 cmd 执行对应操作,分为以下几种 - 默认定义好的 cmd,执行系统默认操作 - 普通文件,调用 file_ioctl - 其他文件调用 vfs_ioctl - vfs_ioctl 内部还是会直接调用设备定义的 cmd 对应的接收函数,里面对不同 cmd 执行不同操作展开共 2 条评论28
- Leon📷2019-06-10另外贴上一个字符设备或者块设备都有一个主设备号和次设备号。主设备号和次设备号统称为设备号。主设备号用来表示一个特定的驱动程序。次设备号用来表示使用该驱动程序的各设备。
作者回复: 赞
共 2 条评论6 - 有铭2019-06-10问一下老师你的这些图是用啥工具画的?
作者回复: draw.io
4 - Leon📷2019-06-10还有个收获就是程序设计的面向对象的思想,之前开发一个音视频推流服务器,一个音视频类型的操作编码解码都是类中完成,现在看来可以把操作单独设计成一个基类,各种类型的音视频操作都可以继承这个类,代码可以设计的更为优雅
作者回复: 是的
3 - 小鳄鱼2022-05-19虚拟文件系统,统一所有设备操作。而这个设计,看起来只是使用多态,但是实际上这是抽象出来的统一操作层。有这个想法,要实现对各种各样的设备,繁杂的功能而言,并不简单!而抽象本身,就很复杂了,值得继续深入!!!1
- 曾泽浩2022-11-25 来自广东驱动程序里面的write方法是怎么操作打印机的?是不是要把数据写到指定io端口中?
- 核桃2021-05-05字符设备驱动程序没有了解过,但是在github上面找到过实现自定义文件系统的hellofs,这个可以核心关键还是自定义的file operations和注册这些,大同小异的
- 耿长学2019-12-11/proc文件系统的原理是实现是基于什么?/proc里面这些文件使用的是内存存放还是磁盘存放的,当命令终止后这些文件又去了哪里,怎么销毁的,谢谢,之前买了网络协议的学了不去网络原理
- 耿长学2019-12-11您好老师,我想请教一个问题,nohub ping www.baidu.com &的输出存放到哪里去了,如果长时间ping使用kill终止的时候为什么会引起内存升高呢?共 1 条评论
- 莫名2019-11-24老师讲得很赞
- Sharry2019-06-13老师, 在系统初始化和本节的内容中, 我都看到了基于内存文件系统, 让我很好奇它是如何运作的, 不知老师什么时候可以出与之相关的文章呢?
- ezra.xu2019-06-11除了open,close,read,write等,是不是可以加入些异常捕获,异步操作,多线程的函数……
作者回复: 这就太复杂啦,每一篇文章都找到一本书
- Leon📷2019-06-10这节意外的收获是学会了怎么画项目结构流程图和各种色调搭配,老师真是多面小能手