计算机系统的输入和输出系统都有哪些呢?有键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD 等等,多种多样。但是对于操作系统来讲,却是一件复杂的事情,因为这么多设备,形状、用法、功能都不一样,怎么才能统一管理起来呢?
可以体会到操作系统设计的思想,和网络差不多,都是分层,每一层做自己的事并且屏蔽差异。
用设备控制器屏蔽差异
计算机系统也是这样的,CPU并不直接和设备打交道,它们中间有一个叫做设备控制器(device control unit)的组件。
控制器其实有点儿像一台小电脑。它有它的芯片,类似小CPU,执行自己的逻辑。它也有自己的寄存器。CPU对于寄存器的读写,比直接控制硬件,要标准和轻松很多。这就相当于和代理商的标准产品交付
输入输出设备大致可以分为两类:块设备(Block Device)和字符设备(Character Device)。
块设备将信息存储在固定大小的块中,每个块都有自己的地址。硬盘就是常见的块设备
字符设备发送或者接收的是字节流,而不用考虑任何块结构,没有办法寻址。鼠标就是常见的字符设备
由于块设备传输的数据量比较大,控制器里面往往会有缓冲区。CPU写入缓冲区的数据攒够一部分,才会发给设备。CPU读取的数据,也需要在缓冲区攒够一部分,才拷贝到内存。
CPU如何同控制器的寄存器和数据缓冲区进行通信呢?
每个控制寄存器被分配一个IO端口,我们可以通过特殊的汇编指令(比如in/out类似的指令)操作这些寄存器;
数据缓冲区,可内存映射IO,可以分配一段内存空间给它,就像读写内存一样读写数据缓冲区。内存空间的区域ioremap就是干这个的;
当你给设备发了一个指令,让它读取一些数据,它读完的时候,怎么通知你呢?
当设备完成任务后发中断到中断控制器,中断控制器就通知CPU,一个中断就产生了,CPU需要停下当前手中的事情来处理中断。
一种是软中断,比如代码调用INT指令触发(比如系统调用,溢出等);一种是硬件中断,就是硬件通过中断控制器触发(IO操作完成)
DMA
有的设备需要读取或者写入大量数据。如果所有过程都让CPU协调的话,就需要占用CPU大量时间。CPU只需要对DMA控制器下指令,说它想读取多少数据,放在内存的某个地方就可以了
用驱动程序屏蔽设备控制器差异
由于每种设备的控制器的寄存器、缓冲区等使用模式、指令都不同。这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分;不同的设备驱动程序,可以用同样的方式接入操作系统,而操作系统的其他部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序。设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。当有中断到达时,就是进程切换的时机。中断的时候,触发的函数是do_IRQ,这个函数是中断处理的统一入口。
总结:
可以看到,从设备控制器屏蔽设备的差异,设备驱动程序屏蔽设备的设备控制器的差异。驱动会注册中断处理函数,中断分为软终端和硬中断。中断过程就是CPU收到中断信号,保存上下文,进入do_IRQ,执行中断处理程序,恢复上下文,继续执行。 为了减少CPU对于中断的频繁响应,DMA可以实现自动化。
用文件系统接口屏蔽驱动程序的差异保证用户使用的接口统一
首先要统一的是设备名称。所有设备都在 /dev/ 文件夹下面创建一个特殊的设备文件。这个设备特殊文件也有 inode,但是它不关联到硬盘或任何其他存储介质上的数据,而是建立了与某个设备驱动程序的连接。
对于设备文件,ls 出来的内容和我们原来讲过的稍有不同。首先是第一位字符。如果是字符设备文件,则以 c 开头,如果是块设备文件,则以 b 开头。其次是这里面的两个号,一个是主设备号,一个是次设备号。用来定位设备驱动程序。
有了设备文件,我们就可以使用对于文件的操作命令和API来操作文件了。
如果linux操作系统新添加了一个设备,应该做哪些事情呢?
在linux上面,如果一个新的设备从来没有加载过驱动,也需要安装驱动。在linux里面,安装驱动程序,其实就是加载一个内核模块
我们可以用lsmod,查看有没有加载过相应的内核模块:
如果没有安装过相应的驱动,可以通过 insmod 安装内核模块。内核模块的后缀一般是 ko
一旦有了驱动,我们就可以通过命令 mknod 在 /dev 文件夹下面创建设备文件,就像下面这样:
mknod filename type major minor
其中 filename 就是 /dev 下面的设备名称,type 就是 c 为字符设备,b 为块设备,major 就是主设备号,minor 就是次设备号。一旦执行了这个命令,新创建的设备文件就和上面加载过的驱动关联起来,这个时候就可以操作设备文件来操作驱动程序,从而操作设备
(安装驱动,创建设备文件)
通过设备文件,建立和某个设备驱动程序的连接,从而控制设备。
字符设备输入的比如鼠标键盘,输出的比如打印机
设备驱动程序是一个内核模块。怎么构建一个内核模块呢?
第一部分,头文件部分。一般的内核模块,都需要include下面两个头文件
#include <linux/module.h>
#include <linux/init.h>
第二部分,定义一些函数,用于处理内核模块的主要逻辑。比如打开、关闭、读取、写入设备的函数或者响应中断的函数。
第三部分,定义一个file_operations函数。设备是可以通过文件系统的接口进行访问的。对于某种文件系统的操作,都是放在file_operations里面的。设备要想被文件系统的接口操作,也需要定义一个这样的结构
第四部分,定义整个模块的初始化函数和退出函数,用于加载和卸载这个ko的时候调用
打开字符设备
字符设备可不是一个普通的内核模块,它有自己独特的行为。
1.首先要加载驱动(内核模块)这个时候,先调用的就是module_init调用的初始化函数。最重要的一件事情就是,注册这个字符设备。注册的方式是调用__register_chrdev_region,注册字符设备的主次设备号和名称,cdev_add会将这个字符设备添加到内核中一个叫做struct kobj_map *cdev_map的结构,来统一管理所有字符设备。
2、内核模块加载完毕后,接下来要通过 mknod 在 /dev 下面创建一个设备文件,只有有了这个设备文件,我们才能通过文件系统的接口,对这个设备文件进行操作。mknod 也是一个系统调用。在文件系统上,顺着路径找到/dev/xxx所在的文件夹,然后为这个新创建的设备文件创建一个dentry。这是维护文件和inode之间的关联关系的结构。
3、打开字符设备。打开文件的进程的task_struct里,有一个数组代表它打开的文件,下标就是文件描述符fd,每一个打开的文件都有一个struct file结构,会指向一个dentry项。dentry可以用来关联inode。这个dentry就是上面mknod时创建的。在进程里面调用open函数,最终对调用到这个特殊的inode的open函数,也就是chrdev_open
(打开文件的操作是通用的。首先一个文件描述符,和文件结构绑定,文件结构里有一个dentry项,它会指向inode,找到文件名和inode映射。没有文件就新建,新创建文件就是调用 dir_inode,从位图中找一个空闲的分配)
写入设备驱动
先调用copy_from_user将数据从用户态拷贝到内核态的缓存中,然后调用parport_write 写入外部表设备。这里还有一个schedule 函数,也就是写入的过程中,给其他的线程抢占CPU的机会。然后,如果count还是大于0,也就是数据还没有写完,就接着 copy_from_user,接着 parport_write,直到写完为止。(跟写缓存差不多,只不过写缓存需要日志相关、映射到内核虚拟地址,脏页标记,回写等;而写设备就是先写到内核缓存,再写到设备中)
对于IO设备来讲,除了读写设备,还会调用ioctl,做一些特殊的IO操作。ioctl 也是一个系统调用。参数会传一个命令是一个int整数
它由几部分组成:
最低八位为 NR,是命令号;
然后八位是 TYPE,是类型;
然后十四位是参数的大小;
最高两位是 DIR,是方向,表示写入、读出,还是读写。
由于组成比较复杂,有一些宏是专门用于组成这个 cmd 值的。
中断处理机制
鼠标就是通过中断,把自己的位置和案件信息,传递给设备驱动程序。
其实中断的处理过程也是屏蔽差异的。
中断首先要保存现场;比如系统调用就保存在内核栈的ptg结构体中。外部中断也就是进入驱动程序的中断处理函数,也是要进入内核态,保存到ptg结构体中。
1、中断是从外部设备发起的,会形成外部中断。外部中断会到达中断控制器,中断控制器会发送中断向量Interrupt Vector给CPU
2、对于每一个CPU,都要求有一个idt_table,里面存放了不同的中断向量的处理函数。中断向量表已经填好了前32位,外加一个32位系统调用,其他都是用于设备中断。(包括系统调用和异常,是必须要处理的,在系统初始化trap_init的时候就已经写好了)
3、因为每个CPU的中断向量是局部的,需要转换成全局的。硬件中断的处理函数是 do_IRQ 进行统一处理,在这里会让中断向量,通过 vector_irq 映射为 irq_desc(irq_desc 是一个用于描述用户注册的中断处理函数的结构,为了能够根据中断向量得到 irq_desc 结构,会把这些结构放在一个基数树里面,方便查找。)
4 irq_desc 里面有一个成员是 irqaction,指向设备驱动程序里面注册的中断处理函数。
处理完成后要恢复现场。
硬中断:保存现场-转换成中断向量-转换成全局的中断向量-找到设备驱动程序里面注册的中断处理函数-恢复现场;
如果是软中断:保存现场-直接进入trap_inti中断门指定的处理函数-恢复现场。
块设备
1、注册管理。这点和字符设备差不多。都需要加载驱动,mkond注册设备文件;
2、mount挂载。devtmpfs文件系统转换为ext4文件系统;
3、在将一个硬盘的块设备 mount 成为 ext4 的时候,我们会调用 ext4_mount->mount_bdev。这点是有区别的。转变成了bdev 伪文件系统。
有点儿绕,我们再捋一下。设备文件 /dev/xxx 在 devtmpfs 文件系统中,找到 devtmpfs 文件系统中的 inode,里面有 dev_t。我们可以通过 dev_t,在伪文件系统 bdev 中找到对应的 inode,然后根据 struct bdev_inode 找到关联的 block_device。
4,在找到 block_device 之后,要调用 blkdev_get 打开这个设备。blkdev_get 会调用 __blkdev_get。
也就是说文件会有file_struct,而块设备就有*block_device。这个结构和其他几个结构有着千丝万缕的联系,比较复杂。这是因为块设备本身就比较复杂。(比如一个硬盘不同区可以分成不同的文件系统)
通过 gendisk 里面的 block_device_operations 打开设备。
struct gendisk 是用来描述整个设备的(包括主设备号,分区设备号和数目以及分区对应的操作比如文件打开)
读写块设备
如何将块设备 I/O 请求送达到外部设备。 这里涉及到了通用块设备层BIO
之前说了IO有直接和缓存的。
我们先来看第一种情况,直接 I/O 调用到 ext4_direct_IO。do_direct_IO 里面有两层循环,第一层循环是依次处理这次要写入的所有块。对于每一块,取出对应的内存中的页 page,在这一块中,有写入的起始地址 from 和终止地址 to,所以,第二层循环就是依次处理 from 到 to 的数据,调用 submit_page_section,提交到块设备层进行写入 submit_bio 。
(通过inode的特殊类型,就可以知道是普通的文件直接写还是块设备直接写了)
缓存IO读写
无论是哪种 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 函数,它用于从队列里面取出请求来,写入外部设备。
总结一下文件系统,设备的读写区别之处
打开的区别
首先,文件的打开open,是有个task_struct结构,里面保存了fd,以及文件结构体记录文件信息的。文件结构里有一个dentry项,它会指向inode,找到文件名和inode映射(其中还有cache哈希来快速查找,lru动态淘汰)。没有文件就新建,新创建文件就是调用 dir_inode,从位图中找一个空闲的分配。
而字符设备的打开需要先注册mknod,挂载它,它会形成ext4文件系统的设备文件,其中有dentry实现名字到inode的映射。打开字符设备和打开文件一样的,用了他自己的open函数。只不过字符设备inode里面记录的是设备驱动程序的连接。目录也是文件,打开一样的。
块设备的打开就复杂一点,因为中间还涉及到一个伪文件系统。设备文件 /dev/xxx 在 devtmpfs 文件系统中,找到 devtmpfs 文件系统中的 inode,里面有 dev_t。我们可以通过 dev_t,在伪文件系统 bdev 中找到对应的 inode,然后根据 struct bdev_inode 找到关联的 block_device。块设备结构体是比较复杂的,因为它本身就复杂(比如一个硬盘不同分区可以是不同的文件系统)所以有一个gendisk来描述整个设备的(包括主设备号,分区设备号和数目以及分区对应的操作比如文件打开),通过操作gendisk的open操作来打开块设备。()
读写的区别
共同之处都有缓存IO和直接IO。主要讨论缓存IO
读会简单一点。对于读文件来说,如果是直接IO,就跨过了缓存层,直接到了文件系统的设备驱动层。由于文件系统是块文件,所以这个调用的是blockdev相关的函数。如果是缓存IO,就会先在页缓存读,没有再放到缓存,并把内核缓存页拷贝到用户内存空间;
文件写,一个循环写所有页,循环里面先日志相关,再内核态映射,再标记脏页完成,回写;而字符设备写就是先写写到内核态映射,再写到设备中;
块设备写麻烦一点,有一个通用块设备层,找到并写缓存后,要提交IO,这里会有一个请求队列,里面是request对象,每个对象包含多个BIO请求。这里面还涉及到IO请求的调度算法比如电梯算法等。