《趣谈Linux》总结六:输入输出系统

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 总结

块设备比字符设备复杂,涉及三个文件系统,工作过程如下:

  1. 所有的块设备被一个map结构管理从dev_t到gendisk的映射;
  2. 所有的block_device表示的设备或者分区都在bdev文件系统的inode列表中;
  3. mknod创建出来的块设备文件在devtemfs文件系统里面,特殊inode里面有块设备号;
  4. mount一个块设备上的文件系统,调用这个文件系统的mount接口;
  5. 通过按照/dev/xxx在文件系统devtmpfs文件系统上搜索到特殊inode,得到块设备号;
  6. 根据特殊inode里面的dev_t在bdev文件系统里面找到inode;
  7. 根据bdev文件系统上的inode找到对应的block_device,根据dev_t在map中找到gendisk,将两者关联起
    来;
  8. 找到block_device后打开设备,调用和block_device关联的gendisk里面的block_device_operations打开设
    备;
  9. 创建被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函数,它用于从队列里面取出请求来,写入外部
设备。
在这里插入图片描述
整个写入文件的过程涉及系统调用、内存管理、文件系统和输入输出,非常复杂

发布了235 篇原创文章 · 获赞 264 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_41594698/article/details/103155991