26 输入与输出
输入输出系统是一个生态,类比售前售后生态体系,这不仅仅是招聘一些售前和售后员工,而是应该建立一套体系让供应商、渠道帮着卖,形成一个生态。
计算机系统的输入和输出系统有:键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD等等,多种多样。
这样方便用户使用了,但是对于操作系统来讲,却是一件复杂的事情,因为这么多设备,形状、用法、功能都不一样,怎么才能统一管理起来呢?
核心思想:加中间层
26.1 用设备控制器屏蔽设备差异
CPU并不直接和设备打交道,它们中间有一个叫作设备控制器(Device ControlUnit)的组件,例如硬盘有磁盘控制器、USB有USB控制器、显示器有视频控制器等。这些控制器就像代理商一样,它们知道如何应对硬盘、鼠标、键盘、显示器的行为。(代理商机制)
控制器类似一台小电脑;
它有它的芯片,类似小CPU,执行自己的逻辑;
它也有它的寄存器,这样CPU就可以通过写这些寄存器,对控制器下发指令,通过读这些寄存器,查看控制器对于设备的操作状态。
输入输出设备大致可以分为两类:块设备(Block Device)和字符设备(Character Device):
块设备将信息存储在固定大小的块中,每个块都有自己的地址。硬盘就是常见的块设备。
字符设备发送或接受的是字节流。而不用考虑任何块结构,没有办法寻址。鼠标就是常见的字符设备。
由于块设备传输的数据量比较大,控制器里往往会有缓冲区。
CPU写入缓冲区的数据攒够一部分,才会发给设备;
CPU读取的数据,也需要在缓冲区攒够一部分,才拷贝到内存。
CPU如何同控制器的寄存器和数据缓冲区进行通信?
每个控制寄存器被分配一个I/O端口,通过特殊的汇编指令(例如in/out类似的指令)操作这些寄存器。
数据缓冲区,则可以内存映射I/O,分配一段内存空间给它,就像读写内存一样读写数据缓冲区;
内存空间的区域ioremap就是做这个的。
对于CPU来讲,这些外部设备都有自己的大脑,可以自行处理一些事情;
那么当用户给设备发了一个指令,让它读取一些数据,它读完的时候,怎么通知用户呢?
控制器的寄存器一般会有状态标志位,可以通过检测状态标志位,来确定输入或者输出操作是否完成。
第一种方式就是轮询等待,就是一直查一直查,直到完成。当然这种方式很不好;
于是有了第二种方式:通过中断的方式,通知操作系统输入输出操作已经完成;
为了响应中断,一般会有一个硬件的中断控制器,当设备完成任务后出发中断到中断控制器,中断控制器就通知CPU,一个中断产生了,CPU需要停下当前手里的事情来处理中断。中断有两种,一种软中断,例如32位系统中代码调用INT指令触发;
一种是硬件中断,就是硬件通过中断控制器触发的;
因此中断也是一种系统调用
有的设备需要读取或者写入大量数据。
如果所有过程都让CPU协调的话,就需要占用CPU大量的时间,如磁盘就是这样的。
这种类型的设备需要支持DMA功能,也就是说,允许设备在CPU不参与的情况下,能够自行完成对内存的读写。实现DMA机制需要有个DMA控制器帮你的CPU来做协调,如图:
CPU只需要对DMA控制器下指令,告诉它想读取多少数据,放在内存的某个地方就可以了;
接下来DMA控制器会发指令给磁盘控制器,读取磁盘上的数据到指定的内存位置,传输完毕之后,DMA控制器发中断通知CPU指令完成,CPU就可以直接用内存里面现成的数据了。
26.2 用驱动程序屏蔽设备控制器差异
虽然设备控制器能够屏蔽很多设备的细节,但是从上面的描述可以看出,由于每种设备的控制器的寄存器、缓冲区等使用模式,指令都不同;
所以对于操作系统来讲,需要有个设备专门对接设备控制器,向其他子系统屏蔽设备控制器的差异
设备驱动程序就用来对接各个设备控制器
需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。
操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。
设备驱动程序中是一些面向特殊设备控制器的代码,不同的设备代码不同。
但是对于操作系统其它部分的代码而言,设备驱动程序应该有统一的接口,不同的设备驱动程序,可以以同样的方式接入操作系统,而操作系统的其它部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序:
驱动程序分为字符设备驱动程序和块设备驱动程序,所有设备驱动程序都要按照同样的规则实现同样的方法。
设备做完了事情后,要通过中断来通知操作系统。
那操作系统就需要有一个地方处理这个中断,既然设备驱动程序是用来对接设备控制器的,中断处理就应该在设备驱动里面完成。
然而中断的触发最终会到达CPU,会中断操作系统当前运行的程序,所以操作系统也要有一个统一的流程来处理中断,使得不同设备的中断使用统一的流程:
一般的流程是:
一个设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。
进程切换一节提过,中断返回的那一刻是进程切换的时机。
中断的时候,触发的函数是do_IRQ。这个函数是中断处理的统一入口,在这个函数里面,可以找到设备驱动程序注册的中断处理函数Handler,然后执行它进行中断处理:
另外,对于块设备来讲,在驱动程序之上,文件系统之下,还需要一层通用设备层。
比如文件系统,里面的逻辑和磁盘设备没有什么关系,可以说是通用的逻辑。
且块设备类型非常多,而Linux操作系统里面一切是文件,我们也不想文件系统以下,就直接对接各种各样的块设备驱动程序,这样会使得文件系统的复杂度非常高。
所以在中间加了一层通用块层,将与块设备相关的通用逻辑放在这一层,维护与设备无关的块的大小,然后通用块层下面对接各种各样的驱动程序:
26.3 用文件系统接口屏蔽驱动程序的差异
从硬件设备到设备控制器,到驱动程序,到通用块层,到文件系统,层层屏蔽不同的设备的差别,最终到这里涉及对用户使用接口,也要统一;
虽然操作设备都是使用基于文件系统的接口,也要有一个统一的标准。
有了文件系统接口之后,我们不但可以通过文件系统的命令行操作设备,也可以通过程序,调用read、write函数,像读写文件一样操作设备。
但是有些任务只使用读写很难完成,例如检查特定于设备的功能和属性,超出了通用文件系统的限制。
所以,对于设备来讲,还有一种接口称为ioctl,表示输入输出控制接口,是用于配置和修改特定设备属性的通用接口
26.4 总结
输入输出设备需要层层屏蔽差异化的部分,给上层提供标准化的部分,最终到用户态,给用户提供了基于文件系统的统一的接口:
27 字符设备驱动
27.1 (上):主流功能
即一个设备能够被打开、能够读写的主流功能
解析一下两个比较简单的字符设备驱动:
一个是输入字符设备,鼠标。代码在drivers/input/mouse/logibm.c;
一个是输出字符设备,打印机,代码drivers/char/lp.c这里。
27.1.1 内核模块
设备驱动程序是一个内核模块,以ko的文件形式存在,可以通过insmod加载到内核中。
如何构建一个内核模块?
第一部分,头文件部分
第二部分,定义一些函数,用于处理内核模块的主要逻辑;例如打开、关闭、读取、写入设备的函数或者响
应中断的函数。
第三部分,定义一个file_operations结构,用来被文件系统的接口操作
第四部分,定义整个模块的初始化函数和退出函数
第五部分,调用module_init和module_exit
第六部分,声明一下lisense,调用MODULE_LICENSE
字符设备这个内核模块做的事情:以打开一个字符设备为例
相关知识:
dentry:维护文件和inode之间的关联关系的结构
在进程里面调用open函数,最终对调用到这个特殊的inode的open函数,也就是chrdev_open。
样打开一个字符设备之后,接下来就是对这个设备的读写:类似文件的读写
实现:
先是调用copy_from_user将数据从用户态拷贝到内核态的缓存中,然后调用parport_write写入外部设备。
内部还有一个schedule函数,也即写入的过程中,给其他线程抢占CPU的机会。
然后,如果数据还没有写完,那就接着copy_from_user、parport_write,直到写完为止。
27.1.2 使用IOCTL控制设备
对于I/O设备来讲,除了读写设备,还会调用ioctl,做一些特殊的I/O操作
ioctl也是一个系统调用,fd是这个设备的文件描述符,cmd是传给这个设备的命令,arg是命令的参数。
其中,对于命令和命令的参数,使用ioctl系统调用的用户和驱动程序的开发人员约定好行为即可。
对于打印机程序来讲,它会根据不同的cmd,做不同的操作。
27.1.3 总结
一个字符设备要能够工作,需要三部分配合:
第一,有一个设备驱动程序的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函数的时候,修改为指向设备操作函数,从而读写一个字符设备文件就会变成读写外部设备。
27.2 (下):中断处理机制
如果一个设备有事情需要通知操作系统,会通过中断和设备驱动程序进行交互
如鼠标就是通过中断,将自己的位置和按键信息,传递给设备驱动程序。
要处理中断,需要有一个中断处理函数:
//irq是一个整数,是中断信号。
//dev_id是一个void *的通用指针,主要用于区分同一个中断处理函数对于不同设备的处理。
irqreturn_t (*irq_handler_t)(int irq, void * dev_id);
//返回值有三种:
//IRQ_NONE表示不是我的中断,不归我管;
//IRQ_HANDLED表示处理完了的中断;
//IRQ_WAKE_THREAD表示有一个进程正在等待这个中断,中断处理完了,应该唤醒它。
enum irqreturn {
IRQ_NONE = (0 << 0),
IRQ_HANDLED = (1 << 0),
IRQ_WAKE_THREAD = (1 << 1),
};
当一个中断信号A触发后,正在处理的过程中,这个中断信号A应该是暂时关闭的,这样是为了防止再来一个中断信号A,在当前的中断信号A的处理过程中插一杠子。
这个暂时关闭的时间值得权衡:
如果太短了,应该原子化处理完毕的没有处理完毕,又被另一个中断信号A中断了,很多操作就不正确了;
如果太长了,一直关闭着,新的中断信号A进不来,系统就显得很慢。
所以,很多中断处理程序将整个中断要做的事情分成两部分,称为上半部和下半部,或者称为关键处理部分和延迟处理部分。
在中断处理函数中,仅仅处理关键部分,完成了就将中断信号打开,使得新的中断可以进来,需要比较长时间处理的部分,也即延迟部分,往往通过工作队列等方式慢慢处理。
有了中断处理函数,接下来要调用request_irq来注册这个中断处理函数,注册到struct irq_desc:根据中断信号irq,找到基数树上对应的irq_desc,然后将新的irqaction挂在链表上。
对于每一个中断,都有一个对中断的描述结构struct irq_desc。
它有一个重要的成员变量是struct irqaction,用于表示处理这个中断的动作。
struct irq_desc结构里面有next指针,也就是说,这是一个链表,对于这个中断的所有处理动作,都串在这个链表上。
每一个中断处理动作的结构为struct irqaction
根据中断信号查找中断描述结构:
一般在连续的情况下,所有的struct irq_desc都放在一个数组里面,直接按下标查找就可以了。
如果配置了CONFIG_SPARSE_IRQ,那中断号是不连续的,可以将此结构放在一棵基数树上,这种结构对于从某个整型key找到value速度很快,通过它,我们很快就能定位到对应的struct irq_desc。
为什么中断信号会有稀疏,也就是不连续的情况呢?
因为这里的irq并不是真正的、物理的中断信号,而是一个抽象的、虚拟的中断信号;
不使用物理的中断信号是因为它和硬件关联比较大,中断控制器也是各种各样的,作为内核,不可能在写程序的时候适配各种各样的硬件中断控制器,因而就需要有一层中断抽象层;
这里虚拟中断信号到中断描述结构的映射,就是抽象中断层的主要逻辑;
而真正中断响应的时候,会涉及物理中断信号;
如果只有一个CPU,一个中断控制器,则基本能够保证从物理中断信号到虚拟中断信号的映射是线性的,这样用数组表示就没什么问题;
但是如果有多个CPU,多个中断控制器,每个中断控制器各有各的物理中断信号,就没办法保证虚拟中断信号是连续的,所以就要用到基数树了
27.2.1 真正的中断
真正中断的发生要从硬件开始。这里面有四个层次:
第一个层次是外部设备给中断控制器发送物理中断信号。
第二个层次是中断控制器将物理中断信号转换成为中断向量interrupt vector,发给各个CPU。
第三个层次是每个CPU都会有一个中断向量表,根据interrupt vector调用一个IRQ处理函数。
到了这一层还是CPU硬件的要求第四个层次是在IRQ处理函数中,将interrupt vector转化为抽象中断层的中断信号irq,调用中断信号irq
对应的中断描述结构里面的irq_handler_t。
不解析硬件的部分,从CPU收到中断向量开始分析。
CPU能够处理的中断总共256个
为了处理中断,CPU硬件要求每一个CPU都有一个中断向量表,通过load_idt加载,里面记录着每一个中断
对应的处理方法,这个中断向量表定义在文件arch/x86/kernel/traps.c中。
27.2.2 总结
中断是从外部设备发起的,会形成外部中断。
外部中断会到达中断控制器,中断控制器会发送中断向量Interrupt Vector给CPU。
对于每一个CPU,都要求有一个idt_table中断向量表,里面存放了不同的中断向量的处理函数。
中断向量表中已经填好了前32位,外加一位32位系统调用,其他的都是用于设备中断。
硬件中断的处理函数是do_IRQ进行统一处理,在这里会让中断向量通过vector_irq映射为irq_desc。
irq_desc是一个用于描述用户注册的中断处理函数的结构,为了能够根据中断向量得到irq_desc结构,会把
这些结构放在一个基数树里面,方便查找。
irq_desc里面有一个成员是irqaction,指向设备驱动程序里面注册的中断处理函数。
28 块设备驱动
28.1 上
讲解块设备的mknod、打开流程,以及文件系统和下层的硬盘设备的读写流程。
块设备一般会被格式化为文件系统,块设备涉及三种文件系统
mknod会创建在/dev路径下面,这一点和字符设备一样。/dev路径下面是devtmpfs文件系统。这是块
设备遇到的第一个文件系统。
接下来,要调用mount,将这个块设备文件挂载到一个文件夹下面。如果这个块设备原来被格式化为一
种文件系统的格式,例如ext4,那调用的就是ext4相应的mount操作。这是块设备遇到的第二个文件系
统,也是向这个块设备读写文件,需要基于的主流文件系统。
在bdget中遇到了第三个文件系统:bdev伪文件系统。bdget函数根据传进来的dev_t,在
blockdev_superblock这个文件系统里面找到inode;
所有表示块设备的inode都保存在伪文件系统 bdev中,这些对用户层不可见,主要为了方便块设备的管理。
设备文件/dev/xxx在devtmpfs文件系统中,找到devtmpfs文件系统中的inode,里面有dev_t。
可以通过dev_t,在伪文件系统 bdev中找到对应的inode,然后根据struct bdev_inode找到关联的
block_device。
28.1.1 总结
块设备比字符设备复杂,涉及三个文件系统,工作过程如下:
- 所有的块设备被一个map结构管理从dev_t到gendisk的映射;
- 所有的block_device表示的设备或者分区都在bdev文件系统的inode列表中;
- mknod创建出来的块设备文件在devtemfs文件系统里面,特殊inode里面有块设备号;
- mount一个块设备上的文件系统,调用这个文件系统的mount接口;
- 通过按照/dev/xxx在文件系统devtmpfs文件系统上搜索到特殊inode,得到块设备号;
- 根据特殊inode里面的dev_t在bdev文件系统里面找到inode;
- 根据bdev文件系统上的inode找到对应的block_device,根据dev_t在map中找到gendisk,将两者关联起
来; - 找到block_device后打开设备,调用和block_device关联的gendisk里面的block_device_operations打开设
备; - 创建被mount的文件系统的super_block。
28.2 下
讲解如何将块设备I/O请求送达到外部设备
将I/O的调用分成两种情况:
第一是直接I/O。最终调用的是generic_file_direct_write,这里调用的是mapping->a_ops->direct_IO,
实际调用的是ext4_direct_IO,往设备层写入数据。
第二种是缓存I/O。最终会将数据从应用拷贝到内存缓存中,但是这个时候,并不执行真正的I/O操作。
它们只将整个页或其中部分标记为脏。写操作由一个timer触发,那个时候,才调用wb_workfn往硬盘写入
页面。
接下来的调用链为:
wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes-__writeback_single_inode->do_writepages。
在do_writepages中,我们要调用mapping->a_ops-writepages,但实际调用的是ext4_writepages,往设备层写入数据。
内部存储:队列中的每个对象包含多个BIO对象
28.2.1 总结
对于块设备的I/O操作分为两种,一种是直接I/O,另一种是缓存I/O。无论是哪种I/O,最终都会调用
submit_bio提交块设备I/O请求。
对于每一种块设备,都有一个gendisk表示这个设备,它有一个请求队列request_queue,这个队列是一系列的request对象,每个request对象里面包含多个BIO对象,指向page cache。
写入块设备,就是I/O将page cache里面的数据写入硬盘。
对于请求队列来讲,还有两个函数
一个函数叫make_request_fn函数,用于将请求放入队列。submit_bio会调用generic_make_request,然后调用这个函数。
另一个函数往往在设备驱动程序里实现,我们叫request_fn函数,它用于从队列里面取出请求来,写入外部
设备。
整个写入文件的过程涉及系统调用、内存管理、文件系统和输入输出,非常复杂