This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="31 | 输入与输出:如何建立售前售后生态体系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/41/959cc496dd14b1e75283c5876ed1db41.mp3"></audio>
到这一节,操作系统作为一家外包公司,里面最核心的职能部门差不多都凑齐了。我们有了项目管理部门(进程管理),有为了维护项目执行期间数据的会议室管理部门(内存管理),有项目执行完毕后归档的档案库管理部门(文件系统)。
这一节,我们来规划一下这家公司的售前售后生态体系(输入输出系统)。这里你需要注意“生态”两个字,我们不仅仅是招聘一些售前和售后员工,而是应该建立一套体系让供应商,让渠道帮着我们卖,形成一个生态。
计算机系统的输入和输出系统都有哪些呢我们能举出来的例如键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD等等多种多样。这样当然方便用户使用了但是对于操作系统来讲却是一件复杂的事情因为这么多设备形状、用法、功能都不一样怎么才能统一管理起来呢
## 用设备控制器屏蔽设备差异
这有点像一家公司要做To B的生意发现客户多种多样众口难调不同的地域不一样不同的行业不一样。如果你不懂某个地方的规矩根本卖不出去东西如果你不懂某个具体行业的使用场景也无法满足客户的需求。怎么办呢一般公司采取的策略就是建立生态设置很多代理商让各个地区和各个行业的代理商帮你屏蔽这些差异化。你和代理商之间只要进行简单的标准产品交付就可以了。
计算机系统也是这样的CPU并不直接和设备打交道它们中间有一个叫作**设备控制器**Device Control Unit的组件例如硬盘有磁盘控制器、USB有USB控制器、显示器有视频控制器等。这些控制器就像代理商一样它们知道如何应对硬盘、鼠标、键盘、显示器的行为。
如果你是一家大公司你的代理商往往是小公司。控制器其实有点儿像一台小电脑。它有它的芯片类似小CPU执行自己的逻辑。它也有它的寄存器。这样CPU就可以通过写这些寄存器对控制器下发指令通过读这些寄存器查看控制器对于设备的操作状态。
CPU对于寄存器的读写可比直接控制硬件要标准和轻松很多。这就相当于你和代理商的标准产品交付。
输入输出设备我们大致可以分为两类:**块设备**Block Device和**字符设备**Character Device
- 块设备将信息存储在固定大小的块中,每个块都有自己的地址。硬盘就是常见的块设备。
- 字符设备发送或接收的是字节流。而不用考虑任何块结构,没有办法寻址。鼠标就是常见的字符设备。
由于块设备传输的数据量比较大控制器里往往会有缓冲区。CPU写入缓冲区的数据攒够一部分才会发给设备。CPU读取的数据也需要在缓冲区攒够一部分才拷贝到内存。
这个也比较好理解,代理商我们也可以分成两种。一种是**集成商模式**,也就是说没有客户的时候,代理商不会在你这里采购产品,每次它遇到一个客户的时候,会带上你,共同应标。你出标准产品,地域的和行业的差异,它来搞定。这有点儿像字符设备。另外一种是**代购代销模式**,也就是说从你这里批量采购一批产品,然后没卖完之前,基本就不会找你了。这有点儿像块设备。
CPU如何同控制器的寄存器和数据缓冲区进行通信呢
- 每个控制寄存器被分配一个I/O端口我们可以通过特殊的汇编指令例如in/out类似的指令操作这些寄存器。
- 数据缓冲区可内存映射I/O可以分配一段内存空间给它就像读写内存一样读写数据缓冲区。如果你去看内存空间的话有一个原来我们没有讲过的区域ioremap就是做这个的。
这有点儿像,如果你要给你的代理商下一个任务,或者询问订单的状态,直接打电话联系他们的负责人就可以了。如果你需要和代理商做大量的交互,共同讨论应标方案,那电话说太麻烦了,你可以把代理商拉到你们公司来,你们直接在一个会议室里面出方案。
对于CPU来讲这些外部设备都有自己的大脑可以自行处理一些事情但是有个问题是当你给设备发了一个指令让它读取一些数据它读完的时候怎么通知你呢
控制器的寄存器一般会有状态标志位,可以通过检测状态标志位,来确定输入或者输出操作是否完成。第一种方式就是**轮询等待**,就是一直查,一直查,直到完成。当然这种方式很不好,于是我们有了第二种方式,就是可以通过**中断**的方式,通知操作系统输入输出操作已经完成。
为了响应中断我们一般会有一个硬件的中断控制器当设备完成任务后触发中断到中断控制器中断控制器就通知CPU一个中断产生了CPU需要停下当前手里的事情来处理中断。
这就像代理商有了新客户客户有了新需求客户交付完毕等事件都需要有一种机制通知你们公司在哪里呢当然是在办事大厅呀。如果你问不对呀办事大厅不是处理系统调用的么还记得32位系统调用是通过INT产生软中断触发的么这就统一起来了中断有两种一种**软中断**例如代码调用INT指令触发一种是**硬件中断**,就是硬件通过中断控制器触发的。所以将中断作为办事大厅的一项服务,没有什么问题。
<img src="https://static001.geekbang.org/resource/image/5d/55/5d9290f08847685d65bc3edd88242855.jpg" alt="">
有的设备需要读取或者写入大量数据。如果所有过程都让CPU协调的话就需要占用CPU大量的时间比方说磁盘就是这样的。这种类型的设备需要支持DMA功能也就是说允许设备在CPU不参与的情况下能够自行完成对内存的读写。实现DMA机制需要有个DMA控制器帮你的CPU来做协调就像下面这个图中显示的一样。
CPU只需要对DMA控制器下指令说它想读取多少数据放在内存的某个地方就可以了接下来DMA控制器会发指令给磁盘控制器读取磁盘上的数据到指定的内存位置传输完毕之后DMA控制器发中断通知CPU指令完成CPU就可以直接用内存里面现成的数据了。还记得咱们讲内存的时候有个DMA区域就是这个作用。
DMA有点儿像一些比较大的代理商不但能够帮你代购代销而且自己有能力售前、售后和技术支持实施部署都能自己搞定。
<img src="https://static001.geekbang.org/resource/image/1e/35/1ef05750bc9ff87a3330104802965335.jpeg" alt="">
## 用驱动程序屏蔽设备控制器差异
虽然代理商机制能够帮我们屏蔽很多设备的细节,但是从上面的描述我们可以看出,由于每种设备的控制器的寄存器、缓冲区等使用模式,指令都不同,所以对于操作系统这家公司来讲,需要有个部门专门对接代理商,向其他部门屏蔽代理商的差异,类似公司的渠道管理部门。
那什么才是操作系统的渠道管理部门呢?就是用来对接各个设备控制器的设备驱动程序。
这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。
设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其它部分的代码而言,设备驱动程序应该有统一的接口。就像下面图中的一样,不同的设备驱动程序,可以以同样的方式接入操作系统,而操作系统的其它部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序。
接下来两节,我们会讲字符设备驱动程序和块设备驱动程序的模型,从那里我们也可以看出,所有设备驱动程序都要,按照同样的规则,实现同样的方法。
<img src="https://static001.geekbang.org/resource/image/7b/68/7bf96d3c8e3a82cdac9c7629b81fa368.png" alt="">
上面咱们说了,设备做完了事情要通过中断来通知操作系统。那操作系统就需要有一个地方处理这个中断,既然设备驱动程序是用来对接设备控制器的,中断处理也应该在设备驱动里面完成。
然而中断的触发最终会到达CPU会中断操作系统当前运行的程序所以操作系统也要有一个统一的流程来处理中断使得不同设备的中断使用统一的流程。
一般的流程是一个设备驱动程序初始化的时候要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过中断返回的那一刻是进程切换的时机。不知道你还记不记得中断的时候触发的函数是do_IRQ。这个函数是中断处理的统一入口。在这个函数里面我们可以找到设备驱动程序注册的中断处理函数Handler然后执行它进行中断处理。
<img src="https://static001.geekbang.org/resource/image/aa/c0/aa9d074d9819f0eb513e11014a5772c0.jpg" alt="">
另外对于块设备来讲在驱动程序之上文件系统之下还需要一层通用设备层。比如咱们上一章讲的文件系统里面的逻辑和磁盘设备没有什么关系可以说是通用的逻辑。在写文件的最底层我们看到了BIO字眼的函数但是好像和设备驱动也没有什么关系。是的因为块设备类型非常多而Linux操作系统里面一切是文件。我们也不想文件系统以下就直接对接各种各样的块设备驱动程序这样会使得文件系统的复杂度非常高。所以我们在中间加了一层通用块层将与块设备相关的通用逻辑放在这一层维护与设备无关的块的大小然后通用块层下面对接各种各样的驱动程序。
<img src="https://static001.geekbang.org/resource/image/3c/73/3c506edf93b15341da3db658e9970773.jpg" alt="">
## 用文件系统接口屏蔽驱动程序的差异
上面我们从硬件设备到设备控制器,到驱动程序,到通用块层,到文件系统,层层屏蔽不同的设备的差别,最终到这里涉及对用户使用接口,也要统一。
虽然我们操作设备,都是基于文件系统的接口,也要有一个统一的标准。
首先要统一的是设备名称。所有设备都在/dev/文件夹下面创建一个特殊的设备文件。这个设备特殊文件也有inode但是它不关联到硬盘或任何其他存储介质上的数据而是建立了与某个设备驱动程序的连接。
硬盘设备这里有一点绕。假设是/dev/sdb这是一个设备文件。这个文件本身和硬盘上的文件系统没有任何关系。这个设备本身也不对应硬盘上的任何一个文件/dev/sdb其实是在一个特殊的文件系统devtmpfs中。但是当我们将/dev/sdb格式化成一个文件系统ext4的时候就会将它mount到一个路径下面。例如在/mnt/sdb下面。这个时候/dev/sdb还是一个设备文件在特殊文件系统devtmpfs中而/mnt/sdb下面的文件才是在ext4文件系统中只不过这个设备是在/dev/sdb设备上的。
这里我们只关心设备文件当我们用ls -l在/dev下面执行的时候就会有这样的结果。
```
# ls -l
crw------- 1 root root 5, 1 Dec 14 19:53 console
crw-r----- 1 root kmem 1, 1 Dec 14 19:53 mem
crw-rw-rw- 1 root root 1, 3 Dec 14 19:53 null
crw-r----- 1 root kmem 1, 4 Dec 14 19:53 port
crw-rw-rw- 1 root root 1, 8 Dec 14 19:53 random
crw--w---- 1 root tty 4, 0 Dec 14 19:53 tty0
crw--w---- 1 root tty 4, 1 Dec 14 19:53 tty1
crw-rw-rw- 1 root root 1, 9 Dec 14 19:53 urandom
brw-rw---- 1 root disk 253, 0 Dec 31 19:18 vda
brw-rw---- 1 root disk 253, 1 Dec 31 19:19 vda1
brw-rw---- 1 root disk 253, 16 Dec 14 19:53 vdb
brw-rw---- 1 root disk 253, 32 Jan 2 11:24 vdc
crw-rw-rw- 1 root root 1, 5 Dec 14 19:53 zero
```
对于设备文件ls出来的内容和我们原来讲过的稍有不同。
首先是第一位字符。如果是字符设备文件则以c开头如果是块设备文件则以b开头。其次是这里面的两个号一个是主设备号一个是次设备号。主设备号定位设备驱动程序次设备号作为参数传给启动程序选择相应的单元。
从上面的列表我们可以看出来mem、null、random、urandom、zero都是用同样的主设备号1也就是它们使用同样的字符设备驱动而vda、vda1、vdb、vdc也是同样的主设备号也就是它们使用同样的块设备驱动。
有了设备文件我们就可以使用对于文件的操作命令和API来操作文件了。例如使用cat命令可以读取/dev/random 和/dev/urandom的数据流可以用od命令转换为十六进制后查看。
```
cat /dev/urandom | od -x
```
这里还是要明确一下,如果用文件的操作作用于/dev/sdb的话会无法操作文件系统上的文件操作的这个设备。
如果Linux操作系统新添加了一个设备应该做哪些事情呢就像咱们使用Windows的时候如果新添加了一种设备首先要看这个设备有没有相应的驱动。如果没有就需要安装一个驱动等驱动安装好了设备就在Windows的设备列表中显示出来了。
在Linux上面如果一个新的设备从来没有加载过驱动也需要安装驱动。Linux的驱动程序已经被写成和操作系统有标准接口的代码可以看成一个标准的内核模块。在Linux里面安装驱动程序其实就是加载一个内核模块。
我们可以用命令lsmod查看有没有加载过相应的内核模块。这个列表很长我这里列举了其中一部分。可以看到这里面有网络和文件系统的驱动。
```
# lsmod
Module Size Used by
iptable_filter 12810 1
bridge 146976 1 br_netfilter
vfat 17461 0
fat 65950 1 vfat
ext4 571716 1
cirrus 24383 1
crct10dif_pclmul 14307 0
crct10dif_common 12595 1 crct10dif_pclmul
```
如果没有安装过相应的驱动可以通过insmod安装内核模块。内核模块的后缀一般是ko。
例如我们要加载openvswitch的驱动就要通过下面的命令
```
insmod openvswitch.ko
```
一旦有了驱动我们就可以通过命令mknod在/dev文件夹下面创建设备文件就像下面这样
```
mknod filename type major minor
```
其中filename就是/dev下面的设备名称type就是c为字符设备b为块设备major就是主设备号minor就是次设备号。一旦执行了这个命令新创建的设备文件就和上面加载过的驱动关联起来这个时候就可以通过操作设备文件来操作驱动程序从而操作设备。
你可能会问人家Windows都说插上设备后一旦安装了驱动就直接在设备列表中出来了你这里怎么还要人来执行命令创建呀能不能智能一点
当然可以,这里就要用到另一个管理设备的文件系统,也就是/sys路径下面的sysfs文件系统。它把实际连接到系统上的设备和总线组成了一个分层的文件系统。这个文件系统是当前系统上实际的设备数的真实反映。
在/sys路径下有下列的文件夹
- /sys/devices是内核对系统中所有设备的分层次的表示
- /sys/dev目录下一个char文件夹一个block文件夹分别维护一个按字符设备和块设备的主次号码(major:minor)链接到真实的设备(/sys/devices下)的符号链接文件;
- /sys/block是系统中当前所有的块设备
- /sys/module有系统中所有模块的信息。
有了sysfs以后我们还需要一个守护进程udev。当一个设备新插入系统的时候内核会检测到这个设备并会创建一个内核对象kobject 。 这个对象通过sysfs文件系统展现到用户层同时内核还向用户空间发送一个热插拔消息。udevd会监听这些消息在/dev中创建对应的文件。
<img src="https://static001.geekbang.org/resource/image/62/90/6234738aac8d5897449e1a541d557090.jpg" alt="">
有了文件系统接口之后我们不但可以通过文件系统的命令行操作设备也可以通过程序调用read、write函数像读写文件一样操作设备。但是有些任务只使用读写很难完成例如检查特定于设备的功能和属性超出了通用文件系统的限制。所以对于设备来讲还有一种接口称为ioctl表示输入输出控制接口是用于配置和修改特定设备属性的通用接口这个我们后面几节会详细说。
## 总结时刻
这一节,我们讲了输入与输出设备的管理,内容比较多。输入输出设备就像管理代理商一样。因为代理商复杂多变,代理商管理也同样复杂多变,需要层层屏蔽差异化的部分,给上层提供标准化的部分,最终到用户态,给用户提供了基于文件系统的统一的接口。
<img src="https://static001.geekbang.org/resource/image/80/7f/80e152fe768e3cb4c84be62ad8d6d07f.jpg" alt="">
## 课堂练习
如果你手头的Linux是一台物理机试着插进一块U盘看文件系统中设备的变化。如果你没有Linux物理机可以使用公有云的云主机添加一块硬盘看文件系统中设备的变化。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,653 @@
<audio id="audio" title="32 | 字符设备(上):如何建立直销模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/e1/655281c771bd57e3bef07e4377af21e1.mp3"></audio>
上一节,我们讲了输入输出设备的层次模型,还是比较复杂的,块设备尤其复杂。这一节为了让你更清晰地了解设备驱动程序的架构,我们先来讲稍微简单一点的字符设备驱动。
这一节我找了两个比较简单的字符设备驱动来解析一下。一个是输入字符设备鼠标。代码在drivers/input/mouse/logibm.c这里。
```
/*
* Logitech Bus Mouse Driver for Linux
*/
module_init(logibm_init);
module_exit(logibm_exit);
```
另外一个是输出字符设备打印机代码drivers/char/lp.c这里。
```
/*
* Generic parallel printer driver
*/
module_init(lp_init_module);
module_exit(lp_cleanup_module);
```
## 内核模块
上一节我们讲过设备驱动程序是一个内核模块以ko的文件形式存在可以通过insmod加载到内核中。那我们首先来看一下怎么样才能构建一个内核模块呢
一个内核模块应该由以下几部分组成。
**第一部分,头文件部分**。一般的内核模块都需要include下面两个头文件
```
#include &lt;linux/module.h&gt;
#include &lt;linux/init.h&gt;
```
如果你去看上面两个驱动程序,都能找到这两个头文件。当然如果需要的话,我们还可以引入更多的头文件。
**第二部分,定义一些函数,用于处理内核模块的主要逻辑**。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。
例如logibm.c里面就定义了logibm_open。logibm_close就是处理打开和关闭的定义了logibm_interrupt就是用来响应中断的。再如lp.c里面就定义了lp_readlp_write就是处理读写的。
**第三部分定义一个file_operations结构**。前面我们讲过设备是可以通过文件系统的接口进行访问的。咱们讲文件系统的时候说过对于某种文件系统的操作都是放在file_operations里面的。例如ext4就定义了这么一个结构里面都是ext4_xxx之类的函数。设备要想被文件系统的接口操作也需要定义这样一个结构。
例如lp.c里面就定义了这样一个结构。
```
static const struct file_operations lp_fops = {
.owner = THIS_MODULE,
.write = lp_write,
.unlocked_ioctl = lp_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = lp_compat_ioctl,
#endif
.open = lp_open,
.release = lp_release,
#ifdef CONFIG_PARPORT_1284
.read = lp_read,
#endif
.llseek = noop_llseek,
};
```
在logibm.c里面我们找不到这样的结构是因为它属于众多输入设备的一种而输入设备的操作被统一定义在drivers/input/input.c里面logibm.c只是定义了一些自己独有的操作。
```
static const struct file_operations input_devices_fileops = {
.owner = THIS_MODULE,
.open = input_proc_devices_open,
.poll = input_proc_devices_poll,
.read = seq_read,
.llseek = seq_lseek,
.release = seq_release,
};
```
**第四部分,定义整个模块的初始化函数和退出函数**用于加载和卸载这个ko的时候调用。
例如lp.c就定义了lp_init_module和lp_cleanup_modulelogibm.c就定义了logibm_init和logibm_exit。
**第五部分调用module_init和module_exit**,分别指向上面两个初始化函数和退出函数。就像本节最开头展示的一样。
**第六部分声明一下lisense调用MODULE_LICENSE**
有了这六部分,一个内核模块就基本合格了,可以工作了。
## 打开字符设备
字符设备可不是一个普通的内核模块,它有自己独特的行为。接下来,我们就沿着打开一个字符设备的过程,看看字符设备这个内核模块做了哪些特殊的事情。
<img src="https://static001.geekbang.org/resource/image/2e/e6/2e29767e84b299324ea7fc524a3dcee6.jpeg" alt="">
要使用一个字符设备我们首先要把写好的内核模块通过insmod加载进内核。这个时候先调用的就是module_init调用的初始化函数。
例如在lp.c的初始化函数lp_init对应的代码如下
```
static int __init lp_init (void)
{
......
if (register_chrdev (LP_MAJOR, &quot;lp&quot;, &amp;lp_fops)) {
printk (KERN_ERR &quot;lp: unable to get major %d\n&quot;, LP_MAJOR);
return -EIO;
}
......
}
int __register_chrdev(unsigned int major, unsigned int baseminor,
unsigned int count, const char *name,
const struct file_operations *fops)
{
struct char_device_struct *cd;
struct cdev *cdev;
int err = -ENOMEM;
......
cd = __register_chrdev_region(major, baseminor, count, name);
cdev = cdev_alloc();
cdev-&gt;owner = fops-&gt;owner;
cdev-&gt;ops = fops;
kobject_set_name(&amp;cdev-&gt;kobj, &quot;%s&quot;, name);
err = cdev_add(cdev, MKDEV(cd-&gt;major, baseminor), count);
cd-&gt;cdev = cdev;
return major ? 0 : cd-&gt;major;
}
```
在字符设备驱动的内核模块加载的时候最重要的一件事情就是注册这个字符设备。注册的方式是调用__register_chrdev_region注册字符设备的主次设备号和名称然后分配一个struct cdev结构将cdev的ops成员变量指向这个模块声明的file_operations。然后cdev_add会将这个字符设备添加到内核中一个叫作struct kobj_map *cdev_map的结构来统一管理所有字符设备。
其中MKDEV(cd-&gt;major, baseminor)表示将主设备号和次设备号生成一个dev_t的整数然后将这个整数dev_t和cdev关联起来。
```
/**
* cdev_add() - add a char device to the system
* @p: the cdev structure for the device
* @dev: the first device number for which this device is responsible
* @count: the number of consecutive minor numbers corresponding to this
* device
*
* cdev_add() adds the device represented by @p to the system, making it
* live immediately. A negative error code is returned on failure.
*/
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
int error;
p-&gt;dev = dev;
p-&gt;count = count;
error = kobj_map(cdev_map, dev, count, NULL,
exact_match, exact_lock, p);
kobject_get(p-&gt;kobj.parent);
return 0;
```
在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也是一个系统调用定义如下
```
SYSCALL_DEFINE3(mknod, const char __user *, filename, umode_t, mode, unsigned, dev)
{
return sys_mknodat(AT_FDCWD, filename, mode, dev);
}
SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode,
unsigned, dev)
{
struct dentry *dentry;
struct path path;
......
dentry = user_path_create(dfd, filename, &amp;path, lookup_flags);
......
switch (mode &amp; S_IFMT) {
......
case S_IFCHR: case S_IFBLK:
error = vfs_mknod(path.dentry-&gt;d_inode,dentry,mode,
new_decode_dev(dev));
break;
......
}
}
```
我们可以在这个系统调用里看到,在文件系统上,顺着路径找到/dev/xxx所在的文件夹然后为这个新创建的设备文件创建一个dentry。这是维护文件和inode之间的关联关系的结构。
接下来如果是字符文件S_IFCHR或者设备文件S_IFBLK我们就调用vfs_mknod。
```
int vfs_mknod(struct inode *dir, struct dentry *dentry, umode_t mode, dev_t dev)
{
......
error = dir-&gt;i_op-&gt;mknod(dir, dentry, mode, dev);
......
}
```
这里需要调用对应的文件系统的inode_operations。应该调用哪个文件系统呢
如果我们在linux下面执行mount命令能看到下面这一行
```
devtmpfs on /dev type devtmpfs (rw,nosuid,size=3989584k,nr_inodes=997396,mode=755)
```
也就是说,/dev下面的文件系统的名称为devtmpfs我们可以在内核中找到它。
```
static struct dentry *dev_mount(struct file_system_type *fs_type, int flags,
const char *dev_name, void *data)
{
#ifdef CONFIG_TMPFS
return mount_single(fs_type, flags, data, shmem_fill_super);
#else
return mount_single(fs_type, flags, data, ramfs_fill_super);
#endif
}
static struct file_system_type dev_fs_type = {
.name = &quot;devtmpfs&quot;,
.mount = dev_mount,
.kill_sb = kill_litter_super,
};
```
从这里可以看出devtmpfs在挂载的时候有两种模式一种是ramfs一种是shmem都是基于内存的文件系统。这里你先不用管基于内存的文件系统具体是怎么回事儿。
```
static const struct inode_operations ramfs_dir_inode_operations = {
......
.mknod = ramfs_mknod,
};
static const struct inode_operations shmem_dir_inode_operations = {
#ifdef CONFIG_TMPFS
......
.mknod = shmem_mknod,
};
```
这两个mknod虽然实现不同但是都会调用到同一个函数init_special_inode。
```
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
inode-&gt;i_mode = mode;
if (S_ISCHR(mode)) {
inode-&gt;i_fop = &amp;def_chr_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode-&gt;i_fop = &amp;def_blk_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode-&gt;i_fop = &amp;pipefifo_fops;
else if (S_ISSOCK(mode))
; /* leave it no_open_fops */
}
```
显然这个文件是个特殊文件inode也是特殊的。这里这个inode可以关联字符设备、块设备、FIFO文件、Socket等。我们这里只看字符设备。
这里的inode的file_operations指向一个def_chr_fops这里面只有一个open就等着你打开它。
另外inode的i_rdev指向这个设备的dev_t。还记得cdev_map吗通过这个dev_t可以找到我们刚在加载的字符设备cdev。
```
const struct file_operations def_chr_fops = {
.open = chrdev_open,
};
```
到目前为止,我们只是创建了/dev下面的一个文件并且和相应的设备号关联起来。但是我们还没有打开这个/dev下面的设备文件。
现在我们来打开它。打开一个文件的流程,我们在[文件系统](https://time.geekbang.org/column/article/97876)那一节讲过了这里不再重复。最终就像打开字符设备的图中一样打开文件的进程的task_struct里有一个数组代表它打开的文件下标就是文件描述符fd每一个打开的文件都有一个struct file结构会指向一个dentry项。dentry可以用来关联inode。这个dentry就是咱们上面mknod的时候创建的。
在进程里面调用open函数最终会调用到这个特殊的inode的open函数也就是chrdev_open。
```
static int chrdev_open(struct inode *inode, struct file *filp)
{
const struct file_operations *fops;
struct cdev *p;
struct cdev *new = NULL;
int ret = 0;
p = inode-&gt;i_cdev;
if (!p) {
struct kobject *kobj;
int idx;
kobj = kobj_lookup(cdev_map, inode-&gt;i_rdev, &amp;idx);
new = container_of(kobj, struct cdev, kobj);
p = inode-&gt;i_cdev;
if (!p) {
inode-&gt;i_cdev = p = new;
list_add(&amp;inode-&gt;i_devices, &amp;p-&gt;list);
new = NULL;
}
}
......
fops = fops_get(p-&gt;ops);
......
replace_fops(filp, fops);
if (filp-&gt;f_op-&gt;open) {
ret = filp-&gt;f_op-&gt;open(inode, filp);
......
}
......
}
```
在这个函数里面我们首先看这个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。这些多和设备相关你不必看懂它们。
## 写入字符设备
当我们像打开一个文件一样打开一个字符设备之后,接下来就是对这个设备的读写。对于文件的读写咱们在文件系统那一章详细讲述过,读写的过程是类似的,所以这里我们只解析打印机驱动写入的过程。
<img src="https://static001.geekbang.org/resource/image/9b/e2/9bd3cd8a8705dbf69f889ba3b2b5c2e2.jpeg" alt="">
写入一个字符设备就是用文件系统的标准接口write参数文件描述符fd在内核里面调用的sys_write在sys_write里面根据文件描述符fd得到struct file结构。接下来再调用vfs_write。
```
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count, loff_t *pos)
{
if (file-&gt;f_op-&gt;write)
return file-&gt;f_op-&gt;write(file, p, count, pos);
else if (file-&gt;f_op-&gt;write_iter)
return new_sync_write(file, p, count, pos);
else
return -EINVAL;
}
```
我们可以看到在__vfs_write里面我们会调用struct file结构里的file_operations的write函数。上面我们打开字符设备的时候已经将struct file结构里面的file_operations指向了设备驱动程序的file_operations结构所以这里的write函数最终会调用到lp_write。
```
static ssize_t lp_write(struct file * file, const char __user * buf,
size_t count, loff_t *ppos)
{
unsigned int minor = iminor(file_inode(file));
struct parport *port = lp_table[minor].dev-&gt;port;
char *kbuf = lp_table[minor].lp_buffer;
ssize_t retv = 0;
ssize_t written;
size_t copy_size = count;
......
/* Need to copy the data from user-space. */
if (copy_size &gt; LP_BUFFER_SIZE)
copy_size = LP_BUFFER_SIZE;
......
if (copy_from_user (kbuf, buf, copy_size)) {
retv = -EFAULT;
goto out_unlock;
}
......
do {
/* Write the data. */
written = parport_write (port, kbuf, copy_size);
if (written &gt; 0) {
copy_size -= written;
count -= written;
buf += written;
retv += written;
}
......
if (need_resched())
schedule ();
if (count) {
copy_size = count;
if (copy_size &gt; LP_BUFFER_SIZE)
copy_size = LP_BUFFER_SIZE;
if (copy_from_user(kbuf, buf, copy_size)) {
if (retv == 0)
retv = -EFAULT;
break;
}
}
} while (count &gt; 0);
......
```
这个设备驱动程序的写入函数的实现还是比较典型的。先是调用copy_from_user将数据从用户态拷贝到内核态的缓存中然后调用parport_write写入外部设备。这里还有一个schedule函数也即写入的过程中给其他线程抢占CPU的机会。然后如果count还是大于0也就是数据还没有写完那我们就接着copy_from_user接着parport_write直到写完为止。
## 使用IOCTL控制设备
对于I/O设备来讲我们前面也说过除了读写设备还会调用ioctl做一些特殊的I/O操作。
<img src="https://static001.geekbang.org/resource/image/c3/1d/c3498dad4f15712529354e0fa123c31d.jpeg" alt="">
ioctl也是一个系统调用它在内核里面的定义如下
```
SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)
{
int error;
struct fd f = fdget(fd);
......
error = do_vfs_ioctl(f.file, fd, cmd, arg);
fdput(f);
return error;
}
```
其中fd是这个设备的文件描述符cmd是传给这个设备的命令arg是命令的参数。其中对于命令和命令的参数使用ioctl系统调用的用户和驱动程序的开发人员约定好行为即可。
其实cmd看起来是一个int其实他的组成比较复杂它由几部分组成
- 最低八位为NR是命令号
- 然后八位是TYPE是类型
- 然后十四位是参数的大小;
- 最高两位是DIR是方向表示写入、读出还是读写。
由于组成比较复杂有一些宏是专门用于组成这个cmd值的。
```
/*
* Used to create numbers.
*/
#define _IO(type,nr) _IOC(_IOC_NONE,(type),(nr),0)
#define _IOR(type,nr,size) _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOW(type,nr,size) _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOWR(type,nr,size) _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
/* used to decode ioctl numbers.. */
#define _IOC_DIR(nr) (((nr) &gt;&gt; _IOC_DIRSHIFT) &amp; _IOC_DIRMASK)
#define _IOC_TYPE(nr) (((nr) &gt;&gt; _IOC_TYPESHIFT) &amp; _IOC_TYPEMASK)
#define _IOC_NR(nr) (((nr) &gt;&gt; _IOC_NRSHIFT) &amp; _IOC_NRMASK)
#define _IOC_SIZE(nr) (((nr) &gt;&gt; _IOC_SIZESHIFT) &amp; _IOC_SIZEMASK)
```
在用户程序中可以通过上面的“Used to create numbers”这些宏根据参数生成cmd在驱动程序中可以通过下面的“used to decode ioctl numbers”这些宏解析cmd后执行指令。
ioctl中会调用do_vfs_ioctl这里面对于已经定义好的cmd进行相应的处理。如果不是默认定义好的cmd则执行默认操作。对于普通文件调用file_ioctl对于其他文件调用vfs_ioctl。
```
int do_vfs_ioctl(struct file *filp, unsigned int fd, unsigned int cmd,
unsigned long arg)
{
int error = 0;
int __user *argp = (int __user *)arg;
struct inode *inode = file_inode(filp);
switch (cmd) {
......
case FIONBIO:
error = ioctl_fionbio(filp, argp);
break;
case FIOASYNC:
error = ioctl_fioasync(fd, filp, argp);
break;
......
case FICLONE:
return ioctl_file_clone(filp, arg, 0, 0, 0);
default:
if (S_ISREG(inode-&gt;i_mode))
error = file_ioctl(filp, cmd, arg);
else
error = vfs_ioctl(filp, cmd, arg);
break;
}
return error;
```
由于咱们这里是设备驱动程序所以调用的是vfs_ioctl。
```
/**
* vfs_ioctl - call filesystem specific ioctl methods
* @filp: open file to invoke ioctl method on
* @cmd: ioctl command to execute
* @arg: command-specific argument for ioctl
*
* Invokes filesystem specific -&gt;unlocked_ioctl, if one exists; otherwise
* returns -ENOTTY.
*
* Returns 0 on success, -errno on error.
*/
long vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
int error = -ENOTTY;
if (!filp-&gt;f_op-&gt;unlocked_ioctl)
goto out;
error = filp-&gt;f_op-&gt;unlocked_ioctl(filp, cmd, arg);
if (error == -ENOIOCTLCMD)
error = -ENOTTY;
out:
return error;
```
这里面调用的是struct file里file_operations的unlocked_ioctl函数。我们前面初始化设备驱动的时候已经将file_operations指向设备驱动的file_operations了。这里调用的是设备驱动的unlocked_ioctl。对于打印机程序来讲调用的是lp_ioctl。可以看出来这里面就是switch语句它会根据不同的cmd做不同的操作。
```
static long lp_ioctl(struct file *file, unsigned int cmd,
unsigned long arg)
{
unsigned int minor;
struct timeval par_timeout;
int ret;
minor = iminor(file_inode(file));
mutex_lock(&amp;lp_mutex);
switch (cmd) {
......
default:
ret = lp_do_ioctl(minor, cmd, arg, (void __user *)arg);
break;
}
mutex_unlock(&amp;lp_mutex);
return ret;
}
static int lp_do_ioctl(unsigned int minor, unsigned int cmd,
unsigned long arg, void __user *argp)
{
int status;
int retval = 0;
switch ( cmd ) {
case LPTIME:
if (arg &gt; UINT_MAX / HZ)
return -EINVAL;
LP_TIME(minor) = arg * HZ/100;
break;
case LPCHAR:
LP_CHAR(minor) = arg;
break;
case LPABORT:
if (arg)
LP_F(minor) |= LP_ABORT;
else
LP_F(minor) &amp;= ~LP_ABORT;
break;
case LPABORTOPEN:
if (arg)
LP_F(minor) |= LP_ABORTOPEN;
else
LP_F(minor) &amp;= ~LP_ABORTOPEN;
break;
case LPCAREFUL:
if (arg)
LP_F(minor) |= LP_CAREFUL;
else
LP_F(minor) &amp;= ~LP_CAREFUL;
break;
case LPWAIT:
LP_WAIT(minor) = arg;
break;
case LPSETIRQ:
return -EINVAL;
break;
case LPGETIRQ:
if (copy_to_user(argp, &amp;LP_IRQ(minor),
sizeof(int)))
return -EFAULT;
break;
case LPGETSTATUS:
if (mutex_lock_interruptible(&amp;lp_table[minor].port_mutex))
return -EINTR;
lp_claim_parport_or_block (&amp;lp_table[minor]);
status = r_str(minor);
lp_release_parport (&amp;lp_table[minor]);
mutex_unlock(&amp;lp_table[minor].port_mutex);
if (copy_to_user(argp, &amp;status, sizeof(int)))
return -EFAULT;
break;
case LPRESET:
lp_reset(minor);
break;
case LPGETFLAGS:
status = LP_F(minor);
if (copy_to_user(argp, &amp;status, sizeof(int)))
return -EFAULT;
break;
default:
retval = -EINVAL;
}
return retval
```
## 总结时刻
这一节我们讲了字符设备的打开、写入和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函数的时候修改为指向设备操作函数从而读写一个字符设备文件就会直接变成读写外部设备了。
<img src="https://static001.geekbang.org/resource/image/fb/cd/fba61fe95e0d2746235b1070eb4c18cd.jpeg" alt="">
## 课堂练习
这节我用打印机驱动程序作为例子来给你讲解字符设备,请你仔细看一下它的代码,设想一下,如果让你自己写一个字符设备驱动程序,应该实现哪些函数呢?
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,625 @@
<audio id="audio" title="33 | 字符设备(下):如何建立直销模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/51/33307f82ecdb7940a6a0003abd72ca51.mp3"></audio>
上一节,我们讲了一个设备能够被打开、能够读写,主流的功能基本就完成了。我们讲输入输出设备的时候说到,如果一个设备有事情需要通知操作系统,会通过中断和设备驱动程序进行交互,今天我们就来解析中断处理机制。
鼠标就是通过中断,将自己的位置和按键信息,传递给设备驱动程序。
```
static int logibm_open(struct input_dev *dev)
{
if (request_irq(logibm_irq, logibm_interrupt, 0, &quot;logibm&quot;, NULL)) {
printk(KERN_ERR &quot;logibm.c: Can't allocate irq %d\n&quot;, logibm_irq);
return -EBUSY;
}
outb(LOGIBM_ENABLE_IRQ, LOGIBM_CONTROL_PORT);
return 0;
}
static irqreturn_t logibm_interrupt(int irq, void *dev_id)
{
char dx, dy;
unsigned char buttons;
outb(LOGIBM_READ_X_LOW, LOGIBM_CONTROL_PORT);
dx = (inb(LOGIBM_DATA_PORT) &amp; 0xf);
outb(LOGIBM_READ_X_HIGH, LOGIBM_CONTROL_PORT);
dx |= (inb(LOGIBM_DATA_PORT) &amp; 0xf) &lt;&lt; 4;
outb(LOGIBM_READ_Y_LOW, LOGIBM_CONTROL_PORT);
dy = (inb(LOGIBM_DATA_PORT) &amp; 0xf);
outb(LOGIBM_READ_Y_HIGH, LOGIBM_CONTROL_PORT);
buttons = inb(LOGIBM_DATA_PORT);
dy |= (buttons &amp; 0xf) &lt;&lt; 4;
buttons = ~buttons &gt;&gt; 5;
input_report_rel(logibm_dev, REL_X, dx);
input_report_rel(logibm_dev, REL_Y, dy);
input_report_key(logibm_dev, BTN_RIGHT, buttons &amp; 1);
input_report_key(logibm_dev, BTN_MIDDLE, buttons &amp; 2);
input_report_key(logibm_dev, BTN_LEFT, buttons &amp; 4);
input_sync(logibm_dev);
outb(LOGIBM_ENABLE_IRQ, LOGIBM_CONTROL_PORT);
return IRQ_HANDLED
```
要处理中断,需要有一个中断处理函数。定义如下:
```
irqreturn_t (*irq_handler_t)(int irq, void * dev_id);
/**
* enum irqreturn
* @IRQ_NONE interrupt was not from this device or was not handled
* @IRQ_HANDLED interrupt was handled by this device
* @IRQ_WAKE_THREAD handler requests to wake the handler thread
*/
enum irqreturn {
IRQ_NONE = (0 &lt;&lt; 0),
IRQ_HANDLED = (1 &lt;&lt; 0),
IRQ_WAKE_THREAD = (1 &lt;&lt; 1),
};
```
其中irq是一个整数是中断信号。dev_id是一个void *的通用指针,主要用于区分同一个中断处理函数对于不同设备的处理。
这里的返回值有三种IRQ_NONE表示不是我的中断不归我管IRQ_HANDLED表示处理完了的中断IRQ_WAKE_THREAD表示有一个进程正在等待这个中断中断处理完了应该唤醒它。
上面的例子中logibm_interrupt这个中断处理函数先是获取了x和y的移动坐标以及左中右的按键上报上去然后返回IRQ_HANDLED这表示处理完毕。
其实写一个真正生产用的中断处理程序还是很复杂的。当一个中断信号A触发后正在处理的过程中这个中断信号A是应该暂时关闭的这样是为了防止再来一个中断信号A在当前的中断信号A的处理过程中插一杠子。但是这个暂时关闭的时间应该多长呢
如果太短了应该原子化处理完毕的没有处理完毕又被另一个中断信号A中断了很多操作就不正确了如果太长了一直关闭着新的中断信号A进不来系统就显得很慢。所以很多中断处理程序将整个中断要做的事情分成两部分称为上半部和下半部或者成为关键处理部分和延迟处理部分。在中断处理函数中仅仅处理关键部分完成了就将中断信号打开使得新的中断可以进来需要比较长时间处理的部分也即延迟部分往往通过工作队列等方式慢慢处理。
这个写起来可以是一本书了推荐你好好读一读《Linux Device Drivers》这本书这里我就不详细介绍了。
有了中断处理函数接下来要调用request_irq来注册这个中断处理函数。request_irq有这样几个参数
- unsigned int irq是中断信号
- irq_handler_t handler是中断处理函数
- unsigned long flags是一些标识位
- const char *name是设备名称
- void *dev这个通用指针应该和中断处理函数的void *dev相对应。
```
static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)
{
return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}
```
中断处理函数被注册到哪里去呢让我们沿着request_irq看下去。request_irq调用的是request_threaded_irq。代码如下
```
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
irq_handler_t thread_fn, unsigned long irqflags,
const char *devname, void *dev_id)
{
struct irqaction *action;
struct irq_desc *desc;
int retval;
......
desc = irq_to_desc(irq);
......
action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
action-&gt;handler = handler;
action-&gt;thread_fn = thread_fn;
action-&gt;flags = irqflags;
action-&gt;name = devname;
action-&gt;dev_id = dev_id;
......
retval = __setup_irq(irq, desc, action);
......
}
```
对于每一个中断都有一个对中断的描述结构struct irq_desc。它有一个重要的成员变量是struct irqaction用于表示处理这个中断的动作。如果我们仔细看这个结构会发现它里面有next指针也就是说这是一个链表对于这个中断的所有处理动作都串在这个链表上。
```
struct irq_desc {
......
struct irqaction *action; /* IRQ action list */
......
struct module *owner;
const char *name;
};
/**
* struct irqaction - per interrupt action descriptor
* @handler: interrupt handler function
* @name: name of the device
* @dev_id: cookie to identify the device
* @percpu_dev_id: cookie to identify the device
* @next: pointer to the next irqaction for shared interrupts
* @irq: interrupt number
* @flags: flags (see IRQF_* above)
* @thread_fn: interrupt handler function for threaded interrupts
* @thread: thread pointer for threaded interrupts
* @secondary: pointer to secondary irqaction (force threading)
* @thread_flags: flags related to @thread
* @thread_mask: bitmask for keeping track of @thread activity
* @dir: pointer to the proc/irq/NN/name entry
*/
struct irqaction {
irq_handler_t handler;
void *dev_id;
void __percpu *percpu_dev_id;
struct irqaction *next;
irq_handler_t thread_fn;
struct task_struct *thread;
struct irqaction *secondary;
unsigned int irq;
unsigned int flags;
unsigned long thread_flags;
unsigned long thread_mask;
const char *name;
struct proc_dir_entry *dir;
};
```
每一个中断处理动作的结构struct irqaction都有以下成员
- 中断处理函数handler
- void *dev_id为设备id
- irq为中断信号
- 如果中断处理函数在单独的线程运行则有thread_fn是线程的执行函数thread是线程的task_struct。
在request_threaded_irq函数中irq_to_desc根据中断信号查找中断描述结构。如何查找呢这就要区分情况。一般情况下所有的struct irq_desc都放在一个数组里面我们直接按下标查找就可以了。如果配置了CONFIG_SPARSE_IRQ那中断号是不连续的就不适合用数组保存了
我们可以放在一棵基数树上。我们不是第一次遇到这个数据结构了。这种结构对于从某个整型key找到value速度很快中断信号irq是这个整数。通过它我们很快就能定位到对应的struct irq_desc。
```
#ifdef CONFIG_SPARSE_IRQ
static RADIX_TREE(irq_desc_tree, GFP_KERNEL);
struct irq_desc *irq_to_desc(unsigned int irq)
{
return radix_tree_lookup(&amp;irq_desc_tree, irq);
}
#else /* !CONFIG_SPARSE_IRQ */
struct irq_desc irq_desc[NR_IRQS] __cacheline_aligned_in_smp = {
[0 ... NR_IRQS-1] = {
}
};
struct irq_desc *irq_to_desc(unsigned int irq)
{
return (irq &lt; NR_IRQS) ? irq_desc + irq : NULL;
}
#endif /* !CONFIG_SPARSE_IRQ */
```
为什么中断信号会有稀疏也就是不连续的情况呢这里需要说明一下这里的irq并不是真正的、物理的中断信号而是一个抽象的、虚拟的中断信号。因为物理的中断信号和硬件关联比较大中断控制器也是各种各样的。
作为内核,我们不可能写程序的时候,适配各种各样的硬件中断控制器,因而就需要有一层中断抽象层。这里虚拟中断信号到中断描述结构的映射,就是抽象中断层的主要逻辑。
下面我们讲真正中断响应的时候会涉及物理中断信号。可以想象如果只有一个CPU一个中断控制器则基本能够保证从物理中断信号到虚拟中断信号的映射是线性的这样用数组表示就没啥问题但是如果有多个CPU多个中断控制器每个中断控制器各有各的物理中断信号就没办法保证虚拟中断信号是连续的所以就要用到基数树了。
接下来request_threaded_irq函数分配了一个struct irqaction并且初始化它接着调用__setup_irq。在这个函数里面如果struct irq_desc里面已经有struct irqaction了我们就将新的struct irqaction挂在链表的末端。如果设定了以单独的线程运行中断处理函数setup_irq_thread就会创建这个内核线程wake_up_process会唤醒它。
```
static int
__setup_irq(unsigned int irq, struct irq_desc *desc, struct irqaction *new)
{
struct irqaction *old, **old_ptr;
unsigned long flags, thread_mask = 0;
int ret, nested, shared = 0;
......
new-&gt;irq = irq;
......
/*
* Create a handler thread when a thread function is supplied
* and the interrupt does not nest into another interrupt
* thread.
*/
if (new-&gt;thread_fn &amp;&amp; !nested) {
ret = setup_irq_thread(new, irq, false);
}
......
old_ptr = &amp;desc-&gt;action;
old = *old_ptr;
if (old) {
/* add new interrupt at end of irq queue */
do {
thread_mask |= old-&gt;thread_mask;
old_ptr = &amp;old-&gt;next;
old = *old_ptr;
} while (old);
}
......
*old_ptr = new;
......
if (new-&gt;thread)
wake_up_process(new-&gt;thread);
......
}
static int
setup_irq_thread(struct irqaction *new, unsigned int irq, bool secondary)
{
struct task_struct *t;
struct sched_param param = {
.sched_priority = MAX_USER_RT_PRIO/2,
};
t = kthread_create(irq_thread, new, &quot;irq/%d-%s&quot;, irq, new-&gt;name);
sched_setscheduler_nocheck(t, SCHED_FIFO, &amp;param);
get_task_struct(t);
new-&gt;thread = t;
......
return 0;
```
至此为止request_irq完成了它的使命。总结来说它就是根据中断信号irq找到基数树上对应的irq_desc然后将新的irqaction挂在链表上。
接下来,我们就来看,真正中断来了的时候,会发生一些什么。
真正中断的发生还是要从硬件开始。这里面有四个层次。
- 第一个层次是外部设备给中断控制器发送物理中断信号。
- 第二个层次是中断控制器将物理中断信号转换成为中断向量interrupt vector发给各个CPU。
- 第三个层次是每个CPU都会有一个中断向量表根据interrupt vector调用一个IRQ处理函数。注意这里的IRQ处理函数还不是咱们上面指定的irq_handler_t到这一层还是CPU硬件的要求。
- 第四个层次是在IRQ处理函数中将interrupt vector转化为抽象中断层的中断信号irq调用中断信号irq对应的中断描述结构里面的irq_handler_t。
<img src="https://static001.geekbang.org/resource/image/dd/13/dd492efdcf956cb22ce3d51592cdc113.png" alt="">
在这里我们不解析硬件的部分我们从CPU收到中断向量开始分析。
CPU收到的中断向量是什么样的呢这个定义在文件arch/x86/include/asm/irq_vectors.h中。这里面的注释非常好建议你仔细阅读。
```
/*
* Linux IRQ vector layout.
*
* There are 256 IDT entries (per CPU - each entry is 8 bytes) which can
* be defined by Linux. They are used as a jump table by the CPU when a
* given vector is triggered - by a CPU-external, CPU-internal or
* software-triggered event.
*
* Linux sets the kernel code address each entry jumps to early during
* bootup, and never changes them. This is the general layout of the
* IDT entries:
*
* Vectors 0 ... 31 : system traps and exceptions - hardcoded events
* Vectors 32 ... 127 : device interrupts
* Vector 128 : legacy int80 syscall interface
* Vectors 129 ... INVALIDATE_TLB_VECTOR_START-1 except 204 : device interrupts
* Vectors INVALIDATE_TLB_VECTOR_START ... 255 : special interrupts
*
* 64-bit x86 has per CPU IDT tables, 32-bit has one shared IDT table.
*
* This file enumerates the exact layout of them:
*/
#define FIRST_EXTERNAL_VECTOR 0x20
#define IA32_SYSCALL_VECTOR 0x80
#define NR_VECTORS 256
#define FIRST_SYSTEM_VECTOR NR_VECTORS
```
通过这些注释我们可以看出CPU能够处理的中断总共256个用宏NR_VECTOR或者FIRST_SYSTEM_VECTOR表示。
为了处理中断CPU硬件要求每一个CPU都有一个中断向量表通过load_idt加载里面记录着每一个中断对应的处理方法这个中断向量表定义在文件arch/x86/kernel/traps.c中。
```
gate_desc idt_table[NR_VECTORS] __page_aligned_bss;
```
对于一个CPU可以处理的中断被分为几个部分第一部分0到31的前32位是系统陷入或者系统异常这些错误无法屏蔽一定要处理。
这些中断的处理函数在系统初始化的时候在start_kernel函数中调用过trap_init()。这个咱们讲系统初始化和系统调用的时候,都大概讲过这个函数,这里还需要仔细看一下。
```
void __init trap_init(void)
{
int i;
...
set_intr_gate(X86_TRAP_DE, divide_error);
//各种各样的set_intr_gate不都贴在这里了只贴一头一尾
...
set_intr_gate(X86_TRAP_XF, simd_coprocessor_error);
/* Reserve all the builtin and the syscall vector: */
for (i = 0; i &lt; FIRST_EXTERNAL_VECTOR; i++)
set_bit(i, used_vectors);
#ifdef CONFIG_X86_32
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
/*
* Set the IDT descriptor to a fixed read-only location, so that the
* &quot;sidt&quot; instruction will not leak the location of the kernel, and
* to defend the IDT against arbitrary memory write vulnerabilities.
* It will be reloaded in cpu_init() */
__set_fixmap(FIX_RO_IDT, __pa_symbol(idt_table), PAGE_KERNEL_RO);
idt_descr.address = fix_to_virt(FIX_RO_IDT);
......
```
我这里贴的代码省略了很多在trap_init函数的一开始调用了大量的set_intr_gate最终都会调用_set_gate代码如下
```
static inline void _set_gate(int gate, unsigned type, void *addr,
unsigned dpl, unsigned ist, unsigned seg)
{
gate_desc s;
pack_gate(&amp;s, type, (unsigned long)addr, dpl, ist, seg);
write_idt_entry(idt_table, gate, &amp;s);
}
```
从代码可以看出set_intr_gate其实就是将每个中断都设置了中断处理函数放在中断向量表idt_table中。
在trap_init中由于set_intr_gate调用的太多容易让人眼花缭乱。其实arch/x86/include/asm/traps.h文件中早就定义好了前32个中断。如果仔细对比一下你会发现这些都在trap_init中使用set_intr_gate设置过了。
```
/* Interrupts/Exceptions */
enum {
X86_TRAP_DE = 0, /* 0, Divide-by-zero */
X86_TRAP_DB, /* 1, Debug */
X86_TRAP_NMI, /* 2, Non-maskable Interrupt */
X86_TRAP_BP, /* 3, Breakpoint */
X86_TRAP_OF, /* 4, Overflow */
X86_TRAP_BR, /* 5, Bound Range Exceeded */
X86_TRAP_UD, /* 6, Invalid Opcode */
X86_TRAP_NM, /* 7, Device Not Available */
X86_TRAP_DF, /* 8, Double Fault */
X86_TRAP_OLD_MF, /* 9, Coprocessor Segment Overrun */
X86_TRAP_TS, /* 10, Invalid TSS */
X86_TRAP_NP, /* 11, Segment Not Present */
X86_TRAP_SS, /* 12, Stack Segment Fault */
X86_TRAP_GP, /* 13, General Protection Fault */
X86_TRAP_PF, /* 14, Page Fault */
X86_TRAP_SPURIOUS, /* 15, Spurious Interrupt */
X86_TRAP_MF, /* 16, x87 Floating-Point Exception */
X86_TRAP_AC, /* 17, Alignment Check */
X86_TRAP_MC, /* 18, Machine Check */
X86_TRAP_XF, /* 19, SIMD Floating-Point Exception */
X86_TRAP_IRET = 32, /* 32, IRET Exception */
};
```
我们回到trap_init中当前32个中断都用set_intr_gate设置完毕。在中断向量表idt_table中填完了之后接下来的for循环for (i = 0; i &lt; FIRST_EXTERNAL_VECTOR; i++)将前32个中断都在used_vectors中标记为1表示这些都设置过中断处理函数了。
接下来trap_init单独调用set_intr_gate来设置32位系统调用的中断。IA32_SYSCALL_VECTOR也即128单独将used_vectors中的第128位标记为1。
在trap_init的最后我们将idt_table放在一个固定的虚拟地址上。trap_init结束后中断向量表中已经填好了前32位外加一位32位系统调用其他的都是用于设备中断。
在start_kernel调用完毕trap_init之后还会调用init_IRQ()来初始化其他的设备中断最终会调用到native_init_IRQ。
```
void __init native_init_IRQ(void)
{
int i;
i = FIRST_EXTERNAL_VECTOR;
#ifndef CONFIG_X86_LOCAL_APIC
#define first_system_vector NR_VECTORS
#endif
for_each_clear_bit_from(i, used_vectors, first_system_vector) {
/* IA32_SYSCALL_VECTOR could be used in trap_init already. */
set_intr_gate(i, irq_entries_start +
8 * (i - FIRST_EXTERNAL_VECTOR));
}
......
}
```
这里面从第32个中断开始到最后NR_VECTORS为止对于used_vectors中没有标记为1的位置都会调用set_intr_gate设置中断向量表。
其实used_vectors中没有标记为1的都是设备中断的部分。
也即所有的设备中断的中断处理函数在中断向量表里面都会设置为从irq_entries_start开始偏移量为i - FIRST_EXTERNAL_VECTOR的一项。
看来中断处理函数是定义在irq_entries_start这个表里面的我们在arch\x86\entry\entry_32.S和arch\x86\entry\entry_64.S都能找到这个函数表的定义。
这又是汇编语言不需要完全看懂但是我们还是能看出来这里面定义了FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR项。每一项都是中断处理函数会跳到common_interrupt去执行。这里会最终调用do_IRQ调用完毕后就从中断返回。这里我们需要区分返回用户态还是内核态。这里会有一个机会触发抢占咱们讲进程切换的时候讲过的。
```
ENTRY(irq_entries_start)
vector=FIRST_EXTERNAL_VECTOR
.rept (FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR)
pushl $(~vector+0x80) /* Note: always in signed byte range */
vector=vector+1
jmp common_interrupt /* 会调用到do_IRQ */
.align 8
.endr
END(irq_entries_start)
common_interrupt:
ASM_CLAC
addq $-0x80, (%rsp) /* Adjust vector to [-256, -1] range */
interrupt do_IRQ
/* 0(%rsp): old RSP */
ret_from_intr:
......
/* Interrupt came from user space */
GLOBAL(retint_user)
......
/* Returning to kernel space */
retint_kernel:
......
```
这样任何一个中断向量到达任何一个CPU最终都会走到do_IRQ。我们来看do_IRQ的实现。
```
/*
* do_IRQ handles all normal device IRQ's (the special
* SMP cross-CPU interrupts have their own specific
* handlers).
*/
__visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs)
{
struct pt_regs *old_regs = set_irq_regs(regs);
struct irq_desc * desc;
/* high bit used in ret_from_ code */
unsigned vector = ~regs-&gt;orig_ax;
......
desc = __this_cpu_read(vector_irq[vector]);
if (!handle_irq(desc, regs)) {
......
}
......
set_irq_regs(old_regs);
return 1;
}
```
在这里面从AX寄存器里面拿到了中断向量vector但是别忘了中断控制器发送给每个CPU的中断向量都是每个CPU局部的而抽象中断处理层的虚拟中断信号irq以及它对应的中断描述结构irq_desc是全局的也即这个CPU的200号的中断向量和另一个CPU的200号中断向量对应的虚拟中断信号irq和中断描述结构irq_desc可能不一样这就需要一个映射关系。这个映射关系放在Per CPU变量vector_irq里面。
```
DECLARE_PER_CPU(vector_irq_t, vector_irq);
```
在系统初始化的时候我们会调用__assign_irq_vector将虚拟中断信号irq分配到某个CPU上的中断向量。
```
static int __assign_irq_vector(int irq, struct apic_chip_data *d,
const struct cpumask *mask,
struct irq_data *irqdata)
{
static int current_vector = FIRST_EXTERNAL_VECTOR + VECTOR_OFFSET_START;
static int current_offset = VECTOR_OFFSET_START % 16;
int cpu, vector;
......
while (cpu &lt; nr_cpu_ids) {
int new_cpu, offset;
......
vector = current_vector;
offset = current_offset;
next:
vector += 16;
if (vector &gt;= first_system_vector) {
offset = (offset + 1) % 16;
vector = FIRST_EXTERNAL_VECTOR + offset;
}
/* If the search wrapped around, try the next cpu */
if (unlikely(current_vector == vector))
goto next_cpu;
if (test_bit(vector, used_vectors))
goto next;
......
/* Found one! */
current_vector = vector;
current_offset = offset;
/* Schedule the old vector for cleanup on all cpus */
if (d-&gt;cfg.vector)
cpumask_copy(d-&gt;old_domain, d-&gt;domain);
for_each_cpu(new_cpu, vector_searchmask)
per_cpu(vector_irq, new_cpu)[vector] = irq_to_desc(irq);
goto update;
next_cpu:
cpumask_or(searched_cpumask, searched_cpumask, vector_cpumask);
cpumask_andnot(vector_cpumask, mask, searched_cpumask);
cpu = cpumask_first_and(vector_cpumask, cpu_online_mask);
continue;
}
....
```
在这里一旦找到某个向量就将CPU的此向量对应的向量描述结构irq_desc设置为虚拟中断信号irq对应的向量描述结构irq_to_desc(irq)。
这样do_IRQ会根据中断向量vector得到对应的irq_desc然后调用handle_irq。handle_irq会调用generic_handle_irq_desc里面调用irq_desc的handle_irq。
```
static inline void generic_handle_irq_desc(struct irq_desc *desc)
{
desc-&gt;handle_irq(desc);
}
```
这里的handle_irq最终会调用__handle_irq_event_percpu。代码如下
```
irqreturn_t __handle_irq_event_percpu(struct irq_desc *desc, unsigned int *flags)
{
irqreturn_t retval = IRQ_NONE;
unsigned int irq = desc-&gt;irq_data.irq;
struct irqaction *action;
record_irq_time(desc);
for_each_action_of_desc(desc, action) {
irqreturn_t res;
res = action-&gt;handler(irq, action-&gt;dev_id);
switch (res) {
case IRQ_WAKE_THREAD:
__irq_wake_thread(desc, action);
case IRQ_HANDLED:
*flags |= action-&gt;flags;
break;
default:
break;
}
retval |= res;
}
return retval;
```
__handle_irq_event_percpu里面调用了irq_desc里每个hander这些hander是我们在所有action列表中注册的这才是我们设置的那个中断处理函数。如果返回值是IRQ_HANDLED就说明处理完毕如果返回值是IRQ_WAKE_THREAD就唤醒线程。
至此,中断的整个过程就结束了。
## 总结时刻
这一节我们讲了中断的整个处理过程。中断是从外部设备发起的会形成外部中断。外部中断会到达中断控制器中断控制器会发送中断向量Interrupt Vector给CPU。
对于每一个CPU都要求有一个idt_table里面存放了不同的中断向量的处理函数。中断向量表中已经填好了前32位外加一位32位系统调用其他的都是用于设备中断。
硬件中断的处理函数是do_IRQ进行统一处理在这里会让中断向量通过vector_irq映射为irq_desc。
irq_desc是一个用于描述用户注册的中断处理函数的结构为了能够根据中断向量得到irq_desc结构会把这些结构放在一个基数树里面方便查找。
irq_desc里面有一个成员是irqaction指向设备驱动程序里面注册的中断处理函数。
<img src="https://static001.geekbang.org/resource/image/26/8f/26bde4fa2279f66098856c5b2b6d308f.png" alt="">
## 课堂练习
你知道如何查看每个CPU都收到了哪些中断吗
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,658 @@
<audio id="audio" title="34 | 块设备(上):如何建立代理商销售模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/a1/f8c4379ed8b62ea91cb95fa48e5839a1.mp3"></audio>
上一章我们解析了文件系统最后讲文件系统读写的流程到达底层的时候没有更深入地分析下去这是因为文件系统再往下就是硬盘设备了。上两节我们解析了字符设备的mknod、打开和读写流程。那这一节我们就来讲块设备的mknod、打开流程以及文件系统和下层的硬盘设备的读写流程。
块设备一般会被格式化为文件系统但是下面的讲述中你可能会有一点困惑。你会看到各种各样的dentry和inode。块设备涉及三种文件系统所以你看到的这些dentry和inode可能都不是一回事儿请注意分辨。
块设备需要mknod吗对于启动盘你可能觉得启动了就在那里了。可是如果我们要插进一块新的USB盘还是要有这个操作的。
mknod还是会创建在/dev路径下面这一点和字符设备一样。/dev路径下面是devtmpfs文件系统。**这是块设备遇到的第一个文件系统**。我们会为这个块设备文件分配一个特殊的inode这一点和字符设备也是一样的。只不过字符设备走S_ISCHR这个分支对应inode的file_operations是def_chr_fops而块设备走S_ISBLK这个分支对应的inode的file_operations是def_blk_fops。这里要注意inode里面的i_rdev被设置成了块设备的设备号dev_t这个我们后面会用到你先记住有这么一回事儿。
```
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
inode-&gt;i_mode = mode;
if (S_ISCHR(mode)) {
inode-&gt;i_fop = &amp;def_chr_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode-&gt;i_fop = &amp;def_blk_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode-&gt;i_fop = &amp;pipefifo_fops;
else if (S_ISSOCK(mode))
; /* leave it no_open_fops */
}
```
特殊inode的默认file_operations是def_blk_fops就像字符设备一样有打开、读写这个块设备文件但是我们常规操作不会这样做。我们会将这个块设备文件mount到一个文件夹下面。
```
const struct file_operations def_blk_fops = {
.open = blkdev_open,
.release = blkdev_close,
.llseek = block_llseek,
.read_iter = blkdev_read_iter,
.write_iter = blkdev_write_iter,
.mmap = generic_file_mmap,
.fsync = blkdev_fsync,
.unlocked_ioctl = block_ioctl,
.splice_read = generic_file_splice_read,
.splice_write = iter_file_splice_write,
.fallocate = blkdev_fallocate,
};
```
不过这里我们还是简单看一下打开这个块设备的操作blkdev_open。它里面调用的是blkdev_get打开这个块设备了解到这一点就可以了。
接下来我们要调用mount将这个块设备文件挂载到一个文件夹下面。如果这个块设备原来被格式化为一种文件系统的格式例如ext4那我们调用的就是ext4相应的mount操作。**这是块设备遇到的第二个文件系统**,也是向这个块设备读写文件,需要基于的主流文件系统。咱们在文件系统那一节解析的对于文件的读写流程,都是基于这个文件系统的。
还记得咱们注册ext4文件系统的时候有下面这样的结构
```
static struct file_system_type ext4_fs_type = {
.owner = THIS_MODULE,
.name = &quot;ext4&quot;,
.mount = ext4_mount,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};
```
在将一个硬盘的块设备mount成为ext4的时候我们会调用ext4_mount-&gt;mount_bdev。
```
static struct dentry *ext4_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data)
{
return mount_bdev(fs_type, flags, dev_name, data, ext4_fill_super);
}
struct dentry *mount_bdev(struct file_system_type *fs_type,
int flags, const char *dev_name, void *data,
int (*fill_super)(struct super_block *, void *, int))
{
struct block_device *bdev;
struct super_block *s;
fmode_t mode = FMODE_READ | FMODE_EXCL;
int error = 0;
if (!(flags &amp; MS_RDONLY))
mode |= FMODE_WRITE;
bdev = blkdev_get_by_path(dev_name, mode, fs_type);
......
s = sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC, bdev);
......
return dget(s-&gt;s_root);
......
}
```
mount_bdev主要做了两件大事情。第一blkdev_get_by_path根据/dev/xxx这个名字找到相应的设备并打开它第二sget根据打开的设备文件填充ext4文件系统的super_block从而以此为基础建立一整套咱们在文件系统那一章讲的体系。
一旦这套体系建立起来以后对于文件的读写都是通过ext4文件系统这个体系进行的创建的inode结构也是指向ext4文件系统的。文件系统那一章我们只解析了这部分由于没有到达底层也就没有关注块设备相关的操作。这一章我们重新回过头来一方面看mount的时候对于块设备都做了哪些操作另一方面看读写的时候到了底层对于块设备做了哪些操作。
这里我们先来看mount_bdev做的第一件大事情通过blkdev_get_by_path根据设备名/dev/xxx得到struct block_device *bdev。
```
/**
* blkdev_get_by_path - open a block device by name
* @path: path to the block device to open
* @mode: FMODE_* mask
* @holder: exclusive holder identifier
*
* Open the blockdevice described by the device file at @path. @mode
* and @holder are identical to blkdev_get().
*
* On success, the returned block_device has reference count of one.
*/
struct block_device *blkdev_get_by_path(const char *path, fmode_t mode,
void *holder)
{
struct block_device *bdev;
int err;
bdev = lookup_bdev(path);
......
err = blkdev_get(bdev, mode, holder);
......
return bdev;
}
```
blkdev_get_by_path干了两件事情。第一个lookup_bdev根据设备路径/dev/xxx得到block_device。第二个打开这个设备调用blkdev_get。
咱们上面分析过def_blk_fops的默认打开设备函数blkdev_open它也是调用blkdev_get的。块设备的打开往往不是直接调用设备文件的打开函数而是调用mount来打开的。
```
/**
* lookup_bdev - lookup a struct block_device by name
* @pathname: special file representing the block device
*
* Get a reference to the blockdevice at @pathname in the current
* namespace if possible and return it. Return ERR_PTR(error)
* otherwise.
*/
struct block_device *lookup_bdev(const char *pathname)
{
struct block_device *bdev;
struct inode *inode;
struct path path;
int error;
if (!pathname || !*pathname)
return ERR_PTR(-EINVAL);
error = kern_path(pathname, LOOKUP_FOLLOW, &amp;path);
if (error)
return ERR_PTR(error);
inode = d_backing_inode(path.dentry);
......
bdev = bd_acquire(inode);
......
goto out;
}
```
lookup_bdev这里的pathname是设备的文件名例如/dev/xxx。这个文件是在devtmpfs文件系统中的kern_path可以在这个文件系统里面一直找到它对应的dentry。接下来d_backing_inode会获得inode。这个inode就是那个init_special_inode生成的特殊inode。
接下来bd_acquire通过这个特殊的inode找到struct block_device。
```
static struct block_device *bd_acquire(struct inode *inode)
{
struct block_device *bdev;
......
bdev = bdget(inode-&gt;i_rdev);
if (bdev) {
spin_lock(&amp;bdev_lock);
if (!inode-&gt;i_bdev) {
/*
* We take an additional reference to bd_inode,
* and it's released in clear_inode() of inode.
* So, we can access it via -&gt;i_mapping always
* without igrab().
*/
bdgrab(bdev);
inode-&gt;i_bdev = bdev;
inode-&gt;i_mapping = bdev-&gt;bd_inode-&gt;i_mapping;
}
}
return bdev;
}
```
bd_acquire中最主要的就是调用bdget它的参数是特殊inode的i_rdev。这里面在mknod的时候放的是设备号dev_t。
```
struct block_device *bdget(dev_t dev)
{
struct block_device *bdev;
struct inode *inode;
inode = iget5_locked(blockdev_superblock, hash(dev),
bdev_test, bdev_set, &amp;dev);
bdev = &amp;BDEV_I(inode)-&gt;bdev;
if (inode-&gt;i_state &amp; I_NEW) {
bdev-&gt;bd_contains = NULL;
bdev-&gt;bd_super = NULL;
bdev-&gt;bd_inode = inode;
bdev-&gt;bd_block_size = i_blocksize(inode);
bdev-&gt;bd_part_count = 0;
bdev-&gt;bd_invalidated = 0;
inode-&gt;i_mode = S_IFBLK;
inode-&gt;i_rdev = dev;
inode-&gt;i_bdev = bdev;
inode-&gt;i_data.a_ops = &amp;def_blk_aops;
mapping_set_gfp_mask(&amp;inode-&gt;i_data, GFP_USER);
spin_lock(&amp;bdev_lock);
list_add(&amp;bdev-&gt;bd_list, &amp;all_bdevs);
spin_unlock(&amp;bdev_lock);
unlock_new_inode(inode);
}
return bdev;
}
```
**在bdget中我们遇到了第三个文件系统bdev伪文件系统**。bdget函数根据传进来的dev_t在blockdev_superblock这个文件系统里面找到inode。这里注意这个inode已经不是devtmpfs文件系统的inode了。blockdev_superblock的初始化在整个系统初始化的时候会调用bdev_cache_init进行初始化。它的定义如下
```
struct super_block *blockdev_superblock __read_mostly;
static struct file_system_type bd_type = {
.name = &quot;bdev&quot;,
.mount = bd_mount,
.kill_sb = kill_anon_super,
};
void __init bdev_cache_init(void)
{
int err;
static struct vfsmount *bd_mnt;
bdev_cachep = kmem_cache_create(&quot;bdev_cache&quot;, sizeof(struct bdev_inode), 0, (SLAB_HWCACHE_ALIGN|SLAB_RECLAIM_ACCOUNT|SLAB_MEM_SPREAD|SLAB_ACCOUNT|SLAB_PANIC), init_once);
err = register_filesystem(&amp;bd_type);
if (err)
panic(&quot;Cannot register bdev pseudo-fs&quot;);
bd_mnt = kern_mount(&amp;bd_type);
if (IS_ERR(bd_mnt))
panic(&quot;Cannot create bdev pseudo-fs&quot;);
blockdev_superblock = bd_mnt-&gt;mnt_sb; /* For writeback */
}
```
所有表示块设备的inode都保存在伪文件系统 bdev中这些对用户层不可见主要为了方便块设备的管理。Linux将块设备的block_device和bdev文件系统的块设备的inode通过struct bdev_inode进行关联。所以在bdget中BDEV_I就是通过bdev文件系统的inode获得整个struct bdev_inode结构的地址然后取成员bdev得到block_device。
```
struct bdev_inode {
struct block_device bdev;
struct inode vfs_inode;
};
```
绕了一大圈,我们终于通过设备文件/dev/xxx获得了设备的结构block_device。有点儿绕我们再捋一下。设备文件/dev/xxx在devtmpfs文件系统中找到devtmpfs文件系统中的inode里面有dev_t。我们可以通过dev_t在伪文件系统 bdev中找到对应的inode然后根据struct bdev_inode找到关联的block_device。
接下来blkdev_get_by_path开始做第二件事情在找到block_device之后要调用blkdev_get打开这个设备。blkdev_get会调用__blkdev_get。
在分析打开一个设备之前我们先来看block_device这个结构是什么样的。
```
struct block_device {
dev_t bd_dev; /* not a kdev_t - it's a search key */
int bd_openers;
struct super_block * bd_super;
......
struct block_device * bd_contains;
unsigned bd_block_size;
struct hd_struct * bd_part;
unsigned bd_part_count;
int bd_invalidated;
struct gendisk * bd_disk;
struct request_queue * bd_queue;
struct backing_dev_info *bd_bdi;
struct list_head bd_list;
......
} ;
```
你应该能发现,这个结构和其他几个结构有着千丝万缕的联系,比较复杂。这是因为块设备本身就比较复杂。
比方说,我们有一个磁盘/dev/sda我们既可以把它整个格式化成一个文件系统也可以把它分成多个分区/dev/sda1、 /dev/sda2然后把每个分区格式化成不同的文件系统。如果我们访问某个分区的设备文件/dev/sda2我们应该能知道它是哪个磁盘设备的。按说它们的驱动应该是一样的。如果我们访问整个磁盘的设备文件/dev/sda我们也应该能知道它分了几个区域所以就有了下图这个复杂的关系结构。
<img src="https://static001.geekbang.org/resource/image/85/76/85f4d83e7ebf2aadf7ffcd5fd393b176.png" alt="">
struct gendisk是用来描述整个设备的因而上面的例子中gendisk只有一个实例指向/dev/sda。它的定义如下
```
struct gendisk {
int major; /* major number of driver */
int first_minor;
int minors; /* maximum number of minors, =1 for disks that can't be partitioned. */
char disk_name[DISK_NAME_LEN]; /* name of major driver */
char *(*devnode)(struct gendisk *gd, umode_t *mode);
......
struct disk_part_tbl __rcu *part_tbl;
struct hd_struct part0;
const struct block_device_operations *fops;
struct request_queue *queue;
void *private_data;
int flags;
struct kobject *slave_dir;
......
};
```
这里major是主设备号first_minor表示第一个分区的从设备号minors表示分区的数目。
disk_name给出了磁盘块设备的名称。
struct disk_part_tbl结构里是一个struct hd_struct的数组用于表示各个分区。struct block_device_operations fops指向对于这个块设备的各种操作。struct request_queue queue是表示在这个块设备上的请求队列。
struct hd_struct是用来表示某个分区的在上面的例子中有两个hd_struct的实例分别指向/dev/sda1、 /dev/sda2。它的定义如下
```
struct hd_struct {
sector_t start_sect;
sector_t nr_sects;
......
struct device __dev;
struct kobject *holder_dir;
int policy, partno;
struct partition_meta_info *info;
......
struct disk_stats dkstats;
struct percpu_ref ref;
struct rcu_head rcu_head;
};
```
在hd_struct中比较重要的成员变量保存了如下的信息从磁盘的哪个扇区开始到哪个扇区结束。
而block_device既可以表示整个块设备也可以表示某个分区所以对于上面的例子block_device有三个实例分别指向/dev/sda1、/dev/sda2、/dev/sda。
block_device的成员变量bd_disk指向的gendisk就是整个块设备。这三个实例都指向同一个gendisk。bd_part指向的某个分区的hd_structbd_contains指向的是整个块设备的block_device。
了解了这些复杂的关系,我们再来看打开设备文件的代码,就会清晰很多。
```
static int __blkdev_get(struct block_device *bdev, fmode_t mode, int for_part)
{
struct gendisk *disk;
struct module *owner;
int ret;
int partno;
int perm = 0;
if (mode &amp; FMODE_READ)
perm |= MAY_READ;
if (mode &amp; FMODE_WRITE)
perm |= MAY_WRITE;
......
disk = get_gendisk(bdev-&gt;bd_dev, &amp;partno);
......
owner = disk-&gt;fops-&gt;owner;
......
if (!bdev-&gt;bd_openers) {
bdev-&gt;bd_disk = disk;
bdev-&gt;bd_queue = disk-&gt;queue;
bdev-&gt;bd_contains = bdev;
if (!partno) {
ret = -ENXIO;
bdev-&gt;bd_part = disk_get_part(disk, partno);
......
if (disk-&gt;fops-&gt;open) {
ret = disk-&gt;fops-&gt;open(bdev, mode);
......
}
if (!ret)
bd_set_size(bdev,(loff_t)get_capacity(disk)&lt;&lt;9);
if (bdev-&gt;bd_invalidated) {
if (!ret)
rescan_partitions(disk, bdev);
......
}
......
} else {
struct block_device *whole;
whole = bdget_disk(disk, 0);
......
ret = __blkdev_get(whole, mode, 1);
......
bdev-&gt;bd_contains = whole;
bdev-&gt;bd_part = disk_get_part(disk, partno);
......
bd_set_size(bdev, (loff_t)bdev-&gt;bd_part-&gt;nr_sects &lt;&lt; 9);
}
}
......
bdev-&gt;bd_openers++;
if (for_part)
bdev-&gt;bd_part_count++;
.....
}
```
在__blkdev_get函数中我们先调用get_gendisk根据block_device获取gendisk。具体代码如下
```
/**
* get_gendisk - get partitioning information for a given device
* @devt: device to get partitioning information for
* @partno: returned partition index
*
* This function gets the structure containing partitioning
* information for the given device @devt.
*/
struct gendisk *get_gendisk(dev_t devt, int *partno)
{
struct gendisk *disk = NULL;
if (MAJOR(devt) != BLOCK_EXT_MAJOR) {
struct kobject *kobj;
kobj = kobj_lookup(bdev_map, devt, partno);
if (kobj)
disk = dev_to_disk(kobj_to_dev(kobj));
} else {
struct hd_struct *part;
part = idr_find(&amp;ext_devt_idr, blk_mangle_minor(MINOR(devt)));
if (part &amp;&amp; get_disk(part_to_disk(part))) {
*partno = part-&gt;partno;
disk = part_to_disk(part);
}
}
return disk;
}
```
我们可以想象这里面有两种情况。第一种情况是block_device是指向整个磁盘设备的。这个时候我们只需要根据dev_t在bdev_map中将对应的gendisk拿出来就好。
bdev_map是干什么的呢前面咱们学习字符设备驱动的时候讲过任何一个字符设备初始化的时候都需要调用__register_chrdev_region注册这个字符设备。对于块设备也是类似的每一个块设备驱动初始化的时候都会调用add_disk注册一个gendisk。
这里需要说明一下gen的意思是general通用的意思也就是说所有的块设备不仅仅是硬盘disk都会用一个gendisk来表示然后通过调用链add_disk-&gt;device_add_disk-&gt;blk_register_region将dev_t和一个gendisk关联起来保存在bdev_map中。
```
static struct kobj_map *bdev_map;
static inline void add_disk(struct gendisk *disk)
{
device_add_disk(NULL, disk);
}
/**
* device_add_disk - add partitioning information to kernel list
* @parent: parent device for the disk
* @disk: per-device partitioning information
*
* This function registers the partitioning information in @disk
* with the kernel.
*/
void device_add_disk(struct device *parent, struct gendisk *disk)
{
......
blk_register_region(disk_devt(disk), disk-&gt;minors, NULL,
exact_match, exact_lock, disk);
.....
}
/*
* Register device numbers dev..(dev+range-1)
* range must be nonzero
* The hash chain is sorted on range, so that subranges can override.
*/
void blk_register_region(dev_t devt, unsigned long range, struct module *module,
struct kobject *(*probe)(dev_t, int *, void *),
int (*lock)(dev_t, void *), void *data)
{
kobj_map(bdev_map, devt, range, module, probe, lock, data);
}
```
get_gendisk要处理的第二种情况是block_device是指向某个分区的。这个时候我们要先得到hd_struct然后通过hd_struct找到对应的整个设备的gendisk并且把partno设置为分区号。
我们再回到__blkdev_get函数中得到gendisk。接下来我们可以分两种情况。
如果partno为0也就是说打开的是整个设备而不是分区那我们就调用disk_get_part获取gendisk中的分区数组然后调用block_device_operations里面的open函数打开设备。
如果partno不为0也就是说打开的是分区那我们就获取整个设备的block_device赋值给变量struct block_device *whole然后调用递归__blkdev_get打开whole代表的整个设备将bd_contains设置为变量whole。
block_device_operations就是在驱动层了。例如在drivers/scsi/sd.c里面也就是MODULE_DESCRIPTION(“SCSI disk (sd) driver”)中,就有这样的定义。
```
static const struct block_device_operations sd_fops = {
.owner = THIS_MODULE,
.open = sd_open,
.release = sd_release,
.ioctl = sd_ioctl,
.getgeo = sd_getgeo,
#ifdef CONFIG_COMPAT
.compat_ioctl = sd_compat_ioctl,
#endif
.check_events = sd_check_events,
.revalidate_disk = sd_revalidate_disk,
.unlock_native_capacity = sd_unlock_native_capacity,
.pr_ops = &amp;sd_pr_ops,
};
/**
* sd_open - open a scsi disk device
* @bdev: Block device of the scsi disk to open
* @mode: FMODE_* mask
*
* Returns 0 if successful. Returns a negated errno value in case
* of error.
**/
static int sd_open(struct block_device *bdev, fmode_t mode)
{
......
}
```
在驱动层打开了磁盘设备之后我们可以看到在这个过程中block_device相应的成员变量该填的都填上了这才完成了mount_bdev的第一件大事通过blkdev_get_by_path得到block_device。
接下来就是第二件大事情我们要通过sget将block_device塞进superblock里面。注意调用sget的时候有一个参数是一个函数set_bdev_super。这里面将block_device设置进了super_block。而sget要做的就是分配一个super_block然后调用set_bdev_super这个callback函数。这里的super_block是ext4文件系统的super_block。
sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC, bdev);
```
static int set_bdev_super(struct super_block *s, void *data)
{
s-&gt;s_bdev = data;
s-&gt;s_dev = s-&gt;s_bdev-&gt;bd_dev;
s-&gt;s_bdi = bdi_get(s-&gt;s_bdev-&gt;bd_bdi);
return 0;
}
/**
* sget - find or create a superblock
* @type: filesystem type superblock should belong to
* @test: comparison callback
* @set: setup callback
* @flags: mount flags
* @data: argument to each of them
*/
struct super_block *sget(struct file_system_type *type,
int (*test)(struct super_block *,void *),
int (*set)(struct super_block *,void *),
int flags,
void *data)
{
......
return sget_userns(type, test, set, flags, user_ns, data);
}
/**
* sget_userns - find or create a superblock
* @type: filesystem type superblock should belong to
* @test: comparison callback
* @set: setup callback
* @flags: mount flags
* @user_ns: User namespace for the super_block
* @data: argument to each of them
*/
struct super_block *sget_userns(struct file_system_type *type,
int (*test)(struct super_block *,void *),
int (*set)(struct super_block *,void *),
int flags, struct user_namespace *user_ns,
void *data)
{
struct super_block *s = NULL;
struct super_block *old;
int err;
......
if (!s) {
s = alloc_super(type, (flags &amp; ~MS_SUBMOUNT), user_ns);
......
}
err = set(s, data);
......
s-&gt;s_type = type;
strlcpy(s-&gt;s_id, type-&gt;name, sizeof(s-&gt;s_id));
list_add_tail(&amp;s-&gt;s_list, &amp;super_blocks);
hlist_add_head(&amp;s-&gt;s_instances, &amp;type-&gt;fs_supers);
spin_unlock(&amp;sb_lock);
get_filesystem(type);
register_shrinker(&amp;s-&gt;s_shrink);
return s;
}
```
好了到此为止mount中一个块设备的过程就结束了。设备打开了形成了block_device结构并且塞到了super_block中。
有了ext4文件系统的super_block之后接下来对于文件的读写过程就和文件系统那一章的过程一摸一样了。只要不涉及真正写入设备的代码super_block中的这个block_device就没啥用处。这也是为什么文件系统那一章我们丝毫感觉不到它的存在但是一旦到了底层就到了block_device起作用的时候了这个我们下一节仔细分析。
## 总结时刻
从这一节我们可以看出,块设备比字符设备复杂多了,涉及三个文件系统,工作过程我用一张图总结了一下,下面带你总结一下。
1. 所有的块设备被一个map结构管理从dev_t到gendisk的映射
1. 所有的block_device表示的设备或者分区都在bdev文件系统的inode列表中
1. mknod创建出来的块设备文件在devtemfs文件系统里面特殊inode里面有块设备号
1. mount一个块设备上的文件系统调用这个文件系统的mount接口
1. 通过按照/dev/xxx在文件系统devtmpfs文件系统上搜索到特殊inode得到块设备号
1. 根据特殊inode里面的dev_t在bdev文件系统里面找到inode
1. 根据bdev文件系统上的inode找到对应的block_device根据dev_t在map中找到gendisk将两者关联起来
1. 找到block_device后打开设备调用和block_device关联的gendisk里面的block_device_operations打开设备
1. 创建被mount的文件系统的super_block。
<img src="https://static001.geekbang.org/resource/image/62/20/6290b73283063f99d6eb728c26339620.png" alt="">
## 课堂练习
到这里你是否真的体会到了Linux里面“一切皆文件”了呢那个特殊的inode除了能够表示字符设备和块设备还能表示什么呢请你看代码分析一下。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,756 @@
<audio id="audio" title="35 | 块设备(下):如何建立代理商销售模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/a2/34dabb36478dc91257ee4d70ffd21da2.mp3"></audio>
在[文件系统](https://time.geekbang.org/column/article/97876)那一节我们讲了文件的写入到了设备驱动这一层就没有再往下分析。上一节我们又讲了mount一个块设备将block_device信息放到了ext4文件系统的super_block里面有了这些基础是时候把整个写入的故事串起来了。
还记得咱们在文件系统那一节分析写入流程的时候对于ext4文件系统最后调用的是ext4_file_write_iter它将I/O的调用分成两种情况
第一是**直接I/O**。最终我们调用的是generic_file_direct_write这里调用的是mapping-&gt;a_ops-&gt;direct_IO实际调用的是ext4_direct_IO往设备层写入数据。
第二种是**缓存I/O**。最终我们会将数据从应用拷贝到内存缓存中但是这个时候并不执行真正的I/O操作。它们只将整个页或其中部分标记为脏。写操作由一个timer触发那个时候才调用wb_workfn往硬盘写入页面。
接下来的调用链为wb_workfn-&gt;wb_do_writeback-&gt;wb_writeback-&gt;writeback_sb_inodes-&gt;__writeback_single_inode-&gt;do_writepages。在do_writepages中我们要调用mapping-&gt;a_ops-&gt;writepages但实际调用的是ext4_writepages往设备层写入数据。
这一节,我们就沿着这两种情况分析下去。
## 直接I/O如何访问块设备
我们先来看第一种情况直接I/O调用到ext4_direct_IO。
```
static ssize_t ext4_direct_IO(struct kiocb *iocb, struct iov_iter *iter)
{
struct file *file = iocb-&gt;ki_filp;
struct inode *inode = file-&gt;f_mapping-&gt;host;
size_t count = iov_iter_count(iter);
loff_t offset = iocb-&gt;ki_pos;
ssize_t ret;
......
ret = ext4_direct_IO_write(iocb, iter);
......
}
static ssize_t ext4_direct_IO_write(struct kiocb *iocb, struct iov_iter *iter)
{
struct file *file = iocb-&gt;ki_filp;
struct inode *inode = file-&gt;f_mapping-&gt;host;
struct ext4_inode_info *ei = EXT4_I(inode);
ssize_t ret;
loff_t offset = iocb-&gt;ki_pos;
size_t count = iov_iter_count(iter);
......
ret = __blockdev_direct_IO(iocb, inode, inode-&gt;i_sb-&gt;s_bdev, iter,
get_block_func, ext4_end_io_dio, NULL,
dio_flags);
……
}
```
在ext4_direct_IO_write调用__blockdev_direct_IO有个参数你需要特别注意一下那就是inode-&gt;i_sb-&gt;s_bdev。通过当前文件的inode我们可以得到super_block。这个super_block中的s_bdev就是咱们上一节填进去的那个block_device。
__blockdev_direct_IO会调用do_blockdev_direct_IO在这里面我们要准备一个struct dio结构和struct dio_submit结构用来描述将要发生的写入请求。
```
static inline ssize_t
do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
struct block_device *bdev, struct iov_iter *iter,
get_block_t get_block, dio_iodone_t end_io,
dio_submit_t submit_io, int flags)
{
unsigned i_blkbits = ACCESS_ONCE(inode-&gt;i_blkbits);
unsigned blkbits = i_blkbits;
unsigned blocksize_mask = (1 &lt;&lt; blkbits) - 1;
ssize_t retval = -EINVAL;
size_t count = iov_iter_count(iter);
loff_t offset = iocb-&gt;ki_pos;
loff_t end = offset + count;
struct dio *dio;
struct dio_submit sdio = { 0, };
struct buffer_head map_bh = { 0, };
......
dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);
dio-&gt;flags = flags;
dio-&gt;i_size = i_size_read(inode);
dio-&gt;inode = inode;
if (iov_iter_rw(iter) == WRITE) {
dio-&gt;op = REQ_OP_WRITE;
dio-&gt;op_flags = REQ_SYNC | REQ_IDLE;
if (iocb-&gt;ki_flags &amp; IOCB_NOWAIT)
dio-&gt;op_flags |= REQ_NOWAIT;
} else {
dio-&gt;op = REQ_OP_READ;
}
sdio.blkbits = blkbits;
sdio.blkfactor = i_blkbits - blkbits;
sdio.block_in_file = offset &gt;&gt; blkbits;
sdio.get_block = get_block;
dio-&gt;end_io = end_io;
sdio.submit_io = submit_io;
sdio.final_block_in_bio = -1;
sdio.next_block_for_io = -1;
dio-&gt;iocb = iocb;
dio-&gt;refcount = 1;
sdio.iter = iter;
sdio.final_block_in_request =
(offset + iov_iter_count(iter)) &gt;&gt; blkbits;
......
sdio.pages_in_io += iov_iter_npages(iter, INT_MAX);
retval = do_direct_IO(dio, &amp;sdio, &amp;map_bh);
.....
}
```
do_direct_IO里面有两层循环第一层循环是依次处理这次要写入的所有块。对于每一块取出对应的内存中的页page在这一块中有写入的起始地址from和终止地址to所以第二层循环就是依次处理from到to的数据调用submit_page_section提交到块设备层进行写入。
```
static int do_direct_IO(struct dio *dio, struct dio_submit *sdio,
struct buffer_head *map_bh)
{
const unsigned blkbits = sdio-&gt;blkbits;
const unsigned i_blkbits = blkbits + sdio-&gt;blkfactor;
int ret = 0;
while (sdio-&gt;block_in_file &lt; sdio-&gt;final_block_in_request) {
struct page *page;
size_t from, to;
page = dio_get_page(dio, sdio);
from = sdio-&gt;head ? 0 : sdio-&gt;from;
to = (sdio-&gt;head == sdio-&gt;tail - 1) ? sdio-&gt;to : PAGE_SIZE;
sdio-&gt;head++;
while (from &lt; to) {
unsigned this_chunk_bytes; /* # of bytes mapped */
unsigned this_chunk_blocks; /* # of blocks */
......
ret = submit_page_section(dio, sdio, page,
from,
this_chunk_bytes,
sdio-&gt;next_block_for_io,
map_bh);
......
sdio-&gt;next_block_for_io += this_chunk_blocks;
sdio-&gt;block_in_file += this_chunk_blocks;
from += this_chunk_bytes;
dio-&gt;result += this_chunk_bytes;
sdio-&gt;blocks_available -= this_chunk_blocks;
if (sdio-&gt;block_in_file == sdio-&gt;final_block_in_request)
break;
......
}
}
}
```
submit_page_section会调用dio_bio_submit进而调用submit_bio向块设备层提交数据。其中参数struct bio是将数据传给块设备的通用传输对象。定义如下
```
/**
* submit_bio - submit a bio to the block device layer for I/O
* @bio: The &amp;struct bio which describes the I/O
*/
blk_qc_t submit_bio(struct bio *bio)
{
......
return generic_make_request(bio);
}
```
## 缓存I/O如何访问块设备
我们再来看第二种情况缓存I/O调用到ext4_writepages。这个函数比较长我们这里只截取最重要的部分来讲解。
```
static int ext4_writepages(struct address_space *mapping,
struct writeback_control *wbc)
{
......
struct mpage_da_data mpd;
struct inode *inode = mapping-&gt;host;
struct ext4_sb_info *sbi = EXT4_SB(mapping-&gt;host-&gt;i_sb);
......
mpd.do_map = 0;
mpd.io_submit.io_end = ext4_init_io_end(inode, GFP_KERNEL);
ret = mpage_prepare_extent_to_map(&amp;mpd);
/* Submit prepared bio */
ext4_io_submit(&amp;mpd.io_submit);
......
}
```
这里比较重要的一个数据结构是struct mpage_da_data。这里面有文件的inode、要写入的页的偏移量还有一个重要的struct ext4_io_submit里面有通用传输对象bio。
```
struct mpage_da_data {
struct inode *inode;
......
pgoff_t first_page; /* The first page to write */
pgoff_t next_page; /* Current page to examine */
pgoff_t last_page; /* Last page to examine */
struct ext4_map_blocks map;
struct ext4_io_submit io_submit; /* IO submission data */
unsigned int do_map:1;
};
struct ext4_io_submit {
......
struct bio *io_bio;
ext4_io_end_t *io_end;
sector_t io_next_block;
};
```
在ext4_writepages中mpage_prepare_extent_to_map用于初始化这个struct mpage_da_data结构。接下来的调用链为mpage_prepare_extent_to_map-&gt;mpage_process_page_bufs-&gt;mpage_submit_page-&gt;ext4_bio_write_page-&gt;io_submit_add_bh。
在io_submit_add_bh中此时的bio还是空的因而我们要调用io_submit_init_bio初始化bio。
```
static int io_submit_init_bio(struct ext4_io_submit *io,
struct buffer_head *bh)
{
struct bio *bio;
bio = bio_alloc(GFP_NOIO, BIO_MAX_PAGES);
if (!bio)
return -ENOMEM;
wbc_init_bio(io-&gt;io_wbc, bio);
bio-&gt;bi_iter.bi_sector = bh-&gt;b_blocknr * (bh-&gt;b_size &gt;&gt; 9);
bio-&gt;bi_bdev = bh-&gt;b_bdev;
bio-&gt;bi_end_io = ext4_end_bio;
bio-&gt;bi_private = ext4_get_io_end(io-&gt;io_end);
io-&gt;io_bio = bio;
io-&gt;io_next_block = bh-&gt;b_blocknr;
return 0;
}
```
我们再回到ext4_writepages中。在bio初始化完之后我们要调用ext4_io_submit提交I/O。在这里我们又是调用submit_bio向块设备层传输数据。ext4_io_submit的实现如下
```
void ext4_io_submit(struct ext4_io_submit *io)
{
struct bio *bio = io-&gt;io_bio;
if (bio) {
int io_op_flags = io-&gt;io_wbc-&gt;sync_mode == WB_SYNC_ALL ?
REQ_SYNC : 0;
io-&gt;io_bio-&gt;bi_write_hint = io-&gt;io_end-&gt;inode-&gt;i_write_hint;
bio_set_op_attrs(io-&gt;io_bio, REQ_OP_WRITE, io_op_flags);
submit_bio(io-&gt;io_bio);
}
io-&gt;io_bio = NULL;
}
```
## 如何向块设备层提交请求?
既然不管是直接I/O还是缓存I/O最后都到了submit_bio里面那我们就来重点分析一下它。
submit_bio会调用generic_make_request。代码如下
```
blk_qc_t generic_make_request(struct bio *bio)
{
/*
* bio_list_on_stack[0] contains bios submitted by the current
* make_request_fn.
* bio_list_on_stack[1] contains bios that were submitted before
* the current make_request_fn, but that haven't been processed
* yet.
*/
struct bio_list bio_list_on_stack[2];
blk_qc_t ret = BLK_QC_T_NONE;
......
if (current-&gt;bio_list) {
bio_list_add(&amp;current-&gt;bio_list[0], bio);
goto out;
}
bio_list_init(&amp;bio_list_on_stack[0]);
current-&gt;bio_list = bio_list_on_stack;
do {
struct request_queue *q = bdev_get_queue(bio-&gt;bi_bdev);
if (likely(blk_queue_enter(q, bio-&gt;bi_opf &amp; REQ_NOWAIT) == 0)) {
struct bio_list lower, same;
/* Create a fresh bio_list for all subordinate requests */
bio_list_on_stack[1] = bio_list_on_stack[0];
bio_list_init(&amp;bio_list_on_stack[0]);
ret = q-&gt;make_request_fn(q, bio);
blk_queue_exit(q);
/* sort new bios into those for a lower level
* and those for the same level
*/
bio_list_init(&amp;lower);
bio_list_init(&amp;same);
while ((bio = bio_list_pop(&amp;bio_list_on_stack[0])) != NULL)
if (q == bdev_get_queue(bio-&gt;bi_bdev))
bio_list_add(&amp;same, bio);
else
bio_list_add(&amp;lower, bio);
/* now assemble so we handle the lowest level first */
bio_list_merge(&amp;bio_list_on_stack[0], &amp;lower);
bio_list_merge(&amp;bio_list_on_stack[0], &amp;same);
bio_list_merge(&amp;bio_list_on_stack[0], &amp;bio_list_on_stack[1]);
}
......
bio = bio_list_pop(&amp;bio_list_on_stack[0]);
} while (bio);
current-&gt;bio_list = NULL; /* deactivate */
out:
return ret;
}
```
这里的逻辑有点复杂我们先来看大的逻辑。在do-while中我们先是获取一个请求队列request_queue然后调用这个队列的make_request_fn函数。
### 块设备队列结构
如果再来看struct block_device结构和struct gendisk结构我们会发现每个块设备都有一个请求队列struct request_queue用于处理上层发来的请求。
在每个块设备的驱动程序初始化的时候会生成一个request_queue。
```
struct request_queue {
/*
* Together with queue_head for cacheline sharing
*/
struct list_head queue_head;
struct request *last_merge;
struct elevator_queue *elevator;
......
request_fn_proc *request_fn;
make_request_fn *make_request_fn;
......
}
```
在请求队列request_queue上首先是有一个链表list_head保存请求request。
```
struct request {
struct list_head queuelist;
......
struct request_queue *q;
......
struct bio *bio;
struct bio *biotail;
......
}
```
每个request包括一个链表的struct bio有指针指向一头一尾。
```
struct bio {
struct bio *bi_next; /* request queue link */
struct block_device *bi_bdev;
blk_status_t bi_status;
......
struct bvec_iter bi_iter;
unsigned short bi_vcnt; /* how many bio_vec's */
unsigned short bi_max_vecs; /* max bvl_vecs we can hold */
atomic_t __bi_cnt; /* pin count */
struct bio_vec *bi_io_vec; /* the actual vec list */
......
};
struct bio_vec {
struct page *bv_page;
unsigned int bv_len;
unsigned int bv_offset;
}
```
在bio中bi_next是链表中的下一项struct bio_vec指向一组页面。
<img src="https://static001.geekbang.org/resource/image/3c/0e/3c473d163b6e90985d7301f115ab660e.jpeg" alt="">
在请求队列request_queue上还有两个重要的函数一个是make_request_fn函数用于生成request另一个是request_fn函数用于处理request。
### 块设备的初始化
我们还是以scsi驱动为例。在初始化设备驱动的时候我们会调用scsi_alloc_queue把request_fn设置为scsi_request_fn。我们还会调用blk_init_allocated_queue-&gt;blk_queue_make_request把make_request_fn设置为blk_queue_bio。
```
/**
* scsi_alloc_sdev - allocate and setup a scsi_Device
* @starget: which target to allocate a &amp;scsi_device for
* @lun: which lun
* @hostdata: usually NULL and set by -&gt;slave_alloc instead
*
* Description:
* Allocate, initialize for io, and return a pointer to a scsi_Device.
* Stores the @shost, @channel, @id, and @lun in the scsi_Device, and
* adds scsi_Device to the appropriate list.
*
* Return value:
* scsi_Device pointer, or NULL on failure.
**/
static struct scsi_device *scsi_alloc_sdev(struct scsi_target *starget,
u64 lun, void *hostdata)
{
struct scsi_device *sdev;
sdev = kzalloc(sizeof(*sdev) + shost-&gt;transportt-&gt;device_size,
GFP_ATOMIC);
......
sdev-&gt;request_queue = scsi_alloc_queue(sdev);
......
}
struct request_queue *scsi_alloc_queue(struct scsi_device *sdev)
{
struct Scsi_Host *shost = sdev-&gt;host;
struct request_queue *q;
q = blk_alloc_queue_node(GFP_KERNEL, NUMA_NO_NODE);
if (!q)
return NULL;
q-&gt;cmd_size = sizeof(struct scsi_cmnd) + shost-&gt;hostt-&gt;cmd_size;
q-&gt;rq_alloc_data = shost;
q-&gt;request_fn = scsi_request_fn;
q-&gt;init_rq_fn = scsi_init_rq;
q-&gt;exit_rq_fn = scsi_exit_rq;
q-&gt;initialize_rq_fn = scsi_initialize_rq;
//调用blk_queue_make_request(q, blk_queue_bio);
if (blk_init_allocated_queue(q) &lt; 0) {
blk_cleanup_queue(q);
return NULL;
}
__scsi_init_queue(shost, q);
......
return q
}
```
在blk_init_allocated_queue中除了初始化make_request_fn函数我们还要做一件很重要的事情就是初始化I/O的电梯算法。
```
int blk_init_allocated_queue(struct request_queue *q)
{
q-&gt;fq = blk_alloc_flush_queue(q, NUMA_NO_NODE, q-&gt;cmd_size);
......
blk_queue_make_request(q, blk_queue_bio);
......
/* init elevator */
if (elevator_init(q, NULL)) {
......
}
......
}
```
电梯算法有很多种类型定义为elevator_type。下面我来逐一说一下。
- **struct elevator_type elevator_noop**
Noop调度算法是最简单的IO调度算法它将IO请求放入到一个FIFO队列中然后逐个执行这些IO请求。
- **struct elevator_type iosched_deadline**
Deadline算法要保证每个IO请求在一定的时间内一定要被服务到以此来避免某个请求饥饿。为了完成这个目标算法中引入了两类队列一类队列用来对请求按起始扇区序号进行排序通过红黑树来组织我们称为sort_list按照此队列传输性能会比较高另一类队列对请求按它们的生成时间进行排序由链表来组织称为fifo_list并且每一个请求都有一个期限值。
- **struct elevator_type iosched_cfq**
又看到了熟悉的CFQ完全公平调度算法。所有的请求会在多个队列中排序。同一个进程的请求总是在同一队列中处理。时间片会分配到每个队列通过轮询算法我们保证了I/O带宽以公平的方式在不同队列之间进行共享。
elevator_init中会根据名称来指定电梯算法如果没有选择那就默认使用iosched_cfq。
### 请求提交与调度
接下来我们回到generic_make_request函数中。调用队列的make_request_fn函数其实就是调用blk_queue_bio。
```
static blk_qc_t blk_queue_bio(struct request_queue *q, struct bio *bio)
{
struct request *req, *free;
unsigned int request_count = 0;
......
switch (elv_merge(q, &amp;req, bio)) {
case ELEVATOR_BACK_MERGE:
if (!bio_attempt_back_merge(q, req, bio))
break;
elv_bio_merged(q, req, bio);
free = attempt_back_merge(q, req);
if (free)
__blk_put_request(q, free);
else
elv_merged_request(q, req, ELEVATOR_BACK_MERGE);
goto out_unlock;
case ELEVATOR_FRONT_MERGE:
if (!bio_attempt_front_merge(q, req, bio))
break;
elv_bio_merged(q, req, bio);
free = attempt_front_merge(q, req);
if (free)
__blk_put_request(q, free);
else
elv_merged_request(q, req, ELEVATOR_FRONT_MERGE);
goto out_unlock;
default:
break;
}
get_rq:
req = get_request(q, bio-&gt;bi_opf, bio, GFP_NOIO);
......
blk_init_request_from_bio(req, bio);
......
add_acct_request(q, req, where);
__blk_run_queue(q);
out_unlock:
......
return BLK_QC_T_NONE;
}
```
blk_queue_bio首先做的一件事情是调用elv_merge来判断当前这个bio请求是否能够和目前已有的request合并起来成为同一批I/O操作从而提高读取和写入的性能。
判断标准和struct bio的成员struct bvec_iter有关它里面有两个变量一个是起始磁盘簇bi_sector另一个是大小bi_size。
```
enum elv_merge elv_merge(struct request_queue *q, struct request **req,
struct bio *bio)
{
struct elevator_queue *e = q-&gt;elevator;
struct request *__rq;
......
if (q-&gt;last_merge &amp;&amp; elv_bio_merge_ok(q-&gt;last_merge, bio)) {
enum elv_merge ret = blk_try_merge(q-&gt;last_merge, bio);
if (ret != ELEVATOR_NO_MERGE) {
*req = q-&gt;last_merge;
return ret;
}
}
......
__rq = elv_rqhash_find(q, bio-&gt;bi_iter.bi_sector);
if (__rq &amp;&amp; elv_bio_merge_ok(__rq, bio)) {
*req = __rq;
return ELEVATOR_BACK_MERGE;
}
if (e-&gt;uses_mq &amp;&amp; e-&gt;type-&gt;ops.mq.request_merge)
return e-&gt;type-&gt;ops.mq.request_merge(q, req, bio);
else if (!e-&gt;uses_mq &amp;&amp; e-&gt;type-&gt;ops.sq.elevator_merge_fn)
return e-&gt;type-&gt;ops.sq.elevator_merge_fn(q, req, bio);
return ELEVATOR_NO_MERGE;
}
```
elv_merge尝试了三次合并。
第一次它先判断和上一次合并的request能不能再次合并看看能不能赶上马上要走的这部电梯。在blk_try_merge主要做了这样的判断如果blk_rq_pos(rq) + blk_rq_sectors(rq) == bio-&gt;bi_iter.bi_sector也就是说这个request的起始地址加上它的大小其实是这个request的结束地址如果和bio的起始地址能接得上那就把bio放在request的最后我们称为ELEVATOR_BACK_MERGE。
如果blk_rq_pos(rq) - bio_sectors(bio) == bio-&gt;bi_iter.bi_sector也就是说这个request的起始地址减去bio的大小等于bio的起始地址这说明bio放在request的最前面能够接得上那就把bio放在request的最前面我们称为ELEVATOR_FRONT_MERGE。否则那就不合并我们称为ELEVATOR_NO_MERGE。
```
enum elv_merge blk_try_merge(struct request *rq, struct bio *bio)
{
......
if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio-&gt;bi_iter.bi_sector)
return ELEVATOR_BACK_MERGE;
else if (blk_rq_pos(rq) - bio_sectors(bio) == bio-&gt;bi_iter.bi_sector)
return ELEVATOR_FRONT_MERGE;
return ELEVATOR_NO_MERGE;
}
```
第二次如果和上一个合并过的request无法合并那我们就调用elv_rqhash_find。然后按照bio的起始地址查找request看有没有能够合并的。如果有的话因为是按照起始地址找的应该接在人家的后面所以是ELEVATOR_BACK_MERGE。
第三次调用elevator_merge_fn试图合并。对于iosched_cfq调用的是cfq_merge。在这里面cfq_find_rq_fmerge会调用elv_rb_find函数里面的参数是bio的结束地址。我们还是要看能不能找到可以合并的。如果有的话因为是按照结束地址找的应该接在人家前面所以是ELEVATOR_FRONT_MERGE。
```
static enum elv_merge cfq_merge(struct request_queue *q, struct request **req,
struct bio *bio)
{
struct cfq_data *cfqd = q-&gt;elevator-&gt;elevator_data;
struct request *__rq;
__rq = cfq_find_rq_fmerge(cfqd, bio);
if (__rq &amp;&amp; elv_bio_merge_ok(__rq, bio)) {
*req = __rq;
return ELEVATOR_FRONT_MERGE;
}
return ELEVATOR_NO_MERGE;
}
static struct request *
cfq_find_rq_fmerge(struct cfq_data *cfqd, struct bio *bio)
{
struct task_struct *tsk = current;
struct cfq_io_cq *cic;
struct cfq_queue *cfqq;
cic = cfq_cic_lookup(cfqd, tsk-&gt;io_context);
if (!cic)
return NULL;
cfqq = cic_to_cfqq(cic, op_is_sync(bio-&gt;bi_opf));
if (cfqq)
return elv_rb_find(&amp;cfqq-&gt;sort_list, bio_end_sector(bio));
return NUL
}
```
等从elv_merge返回blk_queue_bio的时候我们就知道应该做哪种类型的合并接着就要进行真的合并。如果没有办法合并那就调用get_request创建一个新的request调用blk_init_request_from_bio将bio放到新的request里面然后调用add_acct_request把新的request加到request_queue队列中。
至此我们解析完了generic_make_request中最重要的两大逻辑获取一个请求队列request_queue和调用这个队列的make_request_fn函数。
其实generic_make_request其他部分也很令人困惑。感觉里面有特别多的struct bio_list倒腾过来倒腾过去的。这是因为很多块设备是有层次的。
比如我们用两块硬盘组成RAID两个RAID盘组成LVM然后我们就可以在LVM上创建一个块设备给用户用我们称接近用户的块设备为**高层次的块设备**,接近底层的块设备为**低层次**lower**的块设备**。这样generic_make_request把I/O请求发送给高层次的块设备的时候会调用高层块设备的make_request_fn高层块设备又要调用generic_make_request将请求发送给低层次的块设备。虽然块设备的层次不会太多但是对于代码generic_make_request来讲这可是递归的调用一不小心就会递归过深无法正常退出而且内核栈的大小又非常有限所以要比较小心。
这里你是否理解了struct bio_list bio_list_on_stack[2]的名字为什么叫stack呢其实将栈的操作变成对于队列的操作队列不在栈里面会大很多。每次generic_make_request被当前任务调用的时候将current-&gt;bio_list设置为bio_list_on_stack并在generic_make_request的一开始就判断current-&gt;bio_list是否为空。如果不为空说明已经在generic_make_request的调用里面了就不必调用make_request_fn进行递归了直接把请求加入到bio_list里面就可以了这就实现了递归的及时退出。
如果current-&gt;bio_list为空那我们就将current-&gt;bio_list设置为bio_list_on_stack后进入do-while循环做咱们分析过的generic_make_request的两大逻辑。但是当前的队列调用make_request_fn的时候在make_request_fn的具体实现中会生成新的bio。调用更底层的块设备也会生成新的bio都会放在bio_list_on_stack的队列中是一个边处理还边创建的过程。
bio_list_on_stack[1] = bio_list_on_stack[0]这一句在make_request_fn之前将之前队列里面遗留没有处理的保存下来接着bio_list_init将bio_list_on_stack[0]设置为空然后调用make_request_fn在make_request_fn里面如果有新的bio生成都会加到bio_list_on_stack[0]这个队列里面来。
make_request_fn执行完毕后可以想象bio_list_on_stack[0]可能又多了一些bio了接下来的循环中调用bio_list_pop将bio_list_on_stack[0]积攒的bio拿出来分别放在两个队列lower和same中顾名思义lower就是更低层次的块设备的biosame是同层次的块设备的bio。
接下来我们能将lower、same以及bio_list_on_stack[1] 都取出来放在bio_list_on_stack[0]统一进行处理。当然应该lower优先了因为只有底层的块设备的I/O做完了上层的块设备的I/O才能做完。
到这里generic_make_request的逻辑才算解析完毕。对于写入的数据来讲其实仅仅是将bio请求放在请求队列上设备驱动程序还没往设备里面写呢。
### 请求的处理
设备驱动程序往设备里面写调用的是请求队列request_queue的另外一个函数request_fn。对于scsi设备来讲调用的是scsi_request_fn。
```
static void scsi_request_fn(struct request_queue *q)
__releases(q-&gt;queue_lock)
__acquires(q-&gt;queue_lock)
{
struct scsi_device *sdev = q-&gt;queuedata;
struct Scsi_Host *shost;
struct scsi_cmnd *cmd;
struct request *req;
/*
* To start with, we keep looping until the queue is empty, or until
* the host is no longer able to accept any more requests.
*/
shost = sdev-&gt;host;
for (;;) {
int rtn;
/*
* get next queueable request. We do this early to make sure
* that the request is fully prepared even if we cannot
* accept it.
*/
req = blk_peek_request(q);
......
/*
* Remove the request from the request list.
*/
if (!(blk_queue_tagged(q) &amp;&amp; !blk_queue_start_tag(q, req)))
blk_start_request(req);
.....
cmd = req-&gt;special;
......
/*
* Dispatch the command to the low-level driver.
*/
cmd-&gt;scsi_done = scsi_done;
rtn = scsi_dispatch_cmd(cmd);
......
}
return;
......
}
```
在这里面是一个for无限循环从request_queue中读取request然后封装更加底层的指令给设备控制器下指令实施真正的I/O操作。
## 总结时刻
这一节我们讲了如何将块设备I/O请求送达到外部设备。
对于块设备的I/O操作分为两种一种是直接I/O另一种是缓存I/O。无论是哪种I/O最终都会调用submit_bio提交块设备I/O请求。
对于每一种块设备都有一个gendisk表示这个设备它有一个请求队列这个队列是一系列的request对象。每个request对象里面包含多个BIO对象指向page cache。所谓的写入块设备I/O就是将page cache里面的数据写入硬盘。
对于请求队列来讲还有两个函数一个函数叫make_request_fn函数用于将请求放入队列。submit_bio会调用generic_make_request然后调用这个函数。
另一个函数往往在设备驱动程序里实现我们叫request_fn函数它用于从队列里面取出请求来写入外部设备。
<img src="https://static001.geekbang.org/resource/image/c9/3c/c9f6a08075ba4eae3314523fa258363c.png" alt="">
至此,整个写入文件的过程才算完全结束。这真是个复杂的过程,涉及系统调用、内存管理、文件系统和输入输出。这足以说明,操作系统真的是一个非常复杂的体系,环环相扣,需要分层次层层展开来学习。
到这里,专栏已经过半了,你应该能发现,很多我之前说“后面会细讲”的东西,现在正在一点一点解释清楚,而文中越来越多出现“前面我们讲过”的字眼,你是否当时学习前面知识的时候,没有在意,导致学习后面的知识产生困惑了呢?没关系,及时倒回去复习,再回过头去看,当初学过的很多知识会变得清晰很多。
## 课堂练习
你知道如何查看磁盘调度算法、修改磁盘调度算法以及I/O队列的长度吗
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">