到这一节,操作系统作为一家外包公司,里面最核心的职能部门差不多都凑齐了。我们有了项目管理部门(进程管理),有为了维护项目执行期间数据的会议室管理部门(内存管理),有项目执行完毕后归档的档案库管理部门(文件系统)。

这一节,我们来规划一下这家公司的售前售后生态体系(输入输出系统)。这里你需要注意“生态”两个字,我们不仅仅是招聘一些售前和售后员工,而是应该建立一套体系让供应商,让渠道帮着我们卖,形成一个生态。

计算机系统的输入和输出系统都有哪些呢?我们能举出来的,例如键盘、鼠标、显示器、网卡、硬盘、打印机、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 指令触发,一种是硬件中断,就是硬件通过中断控制器触发的。所以将中断作为办事大厅的一项服务,没有什么问题。

有的设备需要读取或者写入大量数据。如果所有过程都让 CPU 协调的话,就需要占用 CPU 大量的时间,比方说,磁盘就是这样的。这种类型的设备需要支持 DMA 功能,也就是说,允许设备在 CPU 不参与的情况下,能够自行完成对内存的读写。实现 DMA 机制需要有个 DMA 控制器帮你的 CPU 来做协调,就像下面这个图中显示的一样。

CPU 只需要对 DMA 控制器下指令,说它想读取多少数据,放在内存的某个地方就可以了,接下来 DMA 控制器会发指令给磁盘控制器,读取磁盘上的数据到指定的内存位置,传输完毕之后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了。还记得咱们讲内存的时候,有个 DMA 区域,就是这个作用。

DMA 有点儿像一些比较大的代理商,不但能够帮你代购代销,而且自己有能力售前、售后和技术支持,实施部署都能自己搞定。

用驱动程序屏蔽设备控制器差异

虽然代理商机制能够帮我们屏蔽很多设备的细节,但是从上面的描述我们可以看出,由于每种设备的控制器的寄存器、缓冲区等使用模式,指令都不同,所以对于操作系统这家公司来讲,需要有个部门专门对接代理商,向其他部门屏蔽代理商的差异,类似公司的渠道管理部门。

那什么才是操作系统的渠道管理部门呢?就是用来对接各个设备控制器的设备驱动程序。

这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。

设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其它部分的代码而言,设备驱动程序应该有统一的接口。就像下面图中的一样,不同的设备驱动程序,可以以同样的方式接入操作系统,而操作系统的其它部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序。

接下来两节,我们会讲字符设备驱动程序和块设备驱动程序的模型,从那里我们也可以看出,所有设备驱动程序都要,按照同样的规则,实现同样的方法。

上面咱们说了,设备做完了事情要通过中断来通知操作系统。那操作系统就需要有一个地方处理这个中断,既然设备驱动程序是用来对接设备控制器的,中断处理也应该在设备驱动里面完成。

然而中断的触发最终会到达 CPU,会中断操作系统当前运行的程序,所以操作系统也要有一个统一的流程来处理中断,使得不同设备的中断使用统一的流程。

一般的流程是,一个设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过,中断返回的那一刻是进程切换的时机。不知道你还记不记得,中断的时候,触发的函数是 do_IRQ。这个函数是中断处理的统一入口。在这个函数里面,我们可以找到设备驱动程序注册的中断处理函数 Handler,然后执行它进行中断处理。

另外,对于块设备来讲,在驱动程序之上,文件系统之下,还需要一层通用设备层。比如咱们上一章讲的文件系统,里面的逻辑和磁盘设备没有什么关系,可以说是通用的逻辑。在写文件的最底层,我们看到了 BIO 字眼的函数,但是好像和设备驱动也没有什么关系。是的,因为块设备类型非常多,而 Linux 操作系统里面一切是文件。我们也不想文件系统以下,就直接对接各种各样的块设备驱动程序,这样会使得文件系统的复杂度非常高。所以,我们在中间加了一层通用块层,将与块设备相关的通用逻辑放在这一层,维护与设备无关的块的大小,然后通用块层下面对接各种各样的驱动程序。

用文件系统接口屏蔽驱动程序的差异

上面我们从硬件设备到设备控制器,到驱动程序,到通用块层,到文件系统,层层屏蔽不同的设备的差别,最终到这里涉及对用户使用接口,也要统一。

虽然我们操作设备,都是基于文件系统的接口,也要有一个统一的标准。

首先要统一的是设备名称。所有设备都在 /dev/ 文件夹下面创建一个特殊的设备文件。这个设备特殊文件也有 inode,但是它不关联到硬盘或任何其他存储介质上的数据,而是建立了与某个设备驱动程序的连接。

硬盘设备这里有一点绕。假设是 /dev/sdb,这是一个设备文件。这个文件本身和硬盘上的文件系统没有任何关系。这个设备本身也不对应硬盘上的任何一个文件,/dev/sdb 其实是在一个特殊的文件系统 devtmpfs 中。但是当我们将 /dev/sdb 格式化成一个文件系统 ext4 的时候,就会将它 mount 到一个路径下面。例如在 /mnt/sdb 下面。这个时候 /dev/sdb 还是一个设备文件在特殊文件系统 devtmpfs 中,而 /mnt/sdb 下面的文件才是在 ext4 文件系统中,只不这个设备是在 /dev/sdb 设备上的。

这里我们只关心设备文件,当我们用 ls -l 在 /dev 下面执行的时候,就会有这样的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 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 命令转换为十六进制后查看。

1
2

cat /dev/urandom | od -x

这里还是要明确一下,如果用文件的操作作用于 /dev/sdb 的话,会无法操作文件系统上的文件,操作的这个设备。

如果 Linux 操作系统新添加了一个设备,应该做哪些事情呢?就像咱们使用 Windows 的时候,如果新添加了一种设备,首先要看这个设备有没有相应的驱动。如果没有就需要安装一个驱动,等驱动安装好了,设备就在 Windows 的设备列表中显示出来了。

在 Linux 上面,如果一个新的设备从来没有加载过驱动,也需要安装驱动。Linux 的驱动程序已经被写成和操作系统有标准接口的代码,可以看成一个标准的内核模块。在 Linux 里面,安装驱动程序,其实就是加载一个内核模块。

我们可以用命令 lsmod,查看有没有加载过相应的内核模块。这个列表很长,我这里列举了其中一部分。可以看到,这里面有网络和文件系统的驱动。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 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 的驱动,就要通过下面的命令:

1
2

insmod openvswitch.ko

一旦有了驱动,我们就可以通过命令 mknod 在 /dev 文件夹下面创建设备文件,就像下面这样:

1
2

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 中创建对应的文件。

有了文件系统接口之后,我们不但可以通过文件系统的命令行操作设备,也可以通过程序,调用 read、write 函数,像读写文件一样操作设备。但是有些任务只使用读写很难完成,例如检查特定于设备的功能和属性,超出了通用文件系统的限制。所以,对于设备来讲,还有一种接口称为 ioctl,表示输入输出控制接口,是用于配置和修改特定设备属性的通用接口,这个我们后面几节会详细说。

总结时刻

这一节,我们讲了输入与输出设备的管理,内容比较多。输入输出设备就像管理代理商一样。因为代理商复杂多变,代理商管理也同样复杂多变,需要层层屏蔽差异化的部分,给上层提供标准化的部分,最终到用户态,给用户提供了基于文件系统的统一的接口。

课堂练习

如果你手头的 Linux 是一台物理机,试着插进一块 U 盘,看文件系统中设备的变化。如果你没有 Linux 物理机,可以使用公有云的云主机,添加一块硬盘,看文件系统中设备的变化。

欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。