操作系统是如何访问IO设备的?
操作系统访问IO设备的过程,经历过很多个层次,就像网络层协议一样,每一层干每一层的事情,每一层又向上层提供接口,操作系统访问IO设备的层次结构如下图所示:
IO结构层次
因此,本篇文章分为5个部分,逐步阐述操作系统访问IO设备的过程
- IO设备分类
- 设备控制器
- 设备驱动程序
- 内核IO子系统
- 操作系统控制IO设备的流程举例
IO设备分类:
IO设备大致分为块设备,字符设备,网络设备,定时器等,本篇文章主要关注的是块设备,字符设备,网络设备,这些设备是我们程序开发中经常涉及的部分。
块设备:
块设备将数据分隔成多个固定大小的块进行存储,块是数据存储的最小单位,每个块的大小可以为512字节-65536字节,每个块都有唯一的地址,因此块设备是可以寻址的,每次读写块设备的数据时,都是以块为最小单位读取或者写入连续的多个块。
例如磁盘就是块设备,它的块大小通常是512个字节等于磁盘的一个扇区的大小,因此一个扇区就是一个块,磁盘的数据是由很多个扇区构成。
通常块设备可以顺序访问或者随机访问,应用程序访问块设备时,通常不是直接访问的,而是通过文件系统,文件系统对块设备进行统一管理,将块设备的各类细节封装起来,文件系统管理的是逻辑设备即文件,文件系统统一提供read,write,seek等接口间接访问块设备,文件系统将文件名和块设备的每一个块之间的映射关系隐藏起来,因此块设备对于应用程序来说是透明。
当然有些特殊的应用程序例如数据库管理系统可以直接访问块设备,对块设备的每个块采用专用的数据结构进行管理,以达到最佳的读写效率,这些专用的数据结构有B+树,LSVM树等,这些数据结构都有各自的应用场景。
字符设备:
字符设备是不可以存储和不可以寻址的,字符设备每次读入或者写入一个字符,常见的字符设备包括鼠标,键盘,打印机等,操作系统通常会提供一些get或者put的方法来读取或者写入一个字节,通常会有标准库对操作系统提供的get或者put方法进行封装,例如增加了字符缓冲,支持字符流读写,支持按行读,按行写,编辑缓冲数据等。
网络设备:
网络设备通常用于发送或者接受网络数据,这些网络数据通常是网络包的形式,不同于块设备,操作系统会提供专门的网络接口进行网络数据的发送和接受,网络接口通常就是Socket。
Socket实现了传输层的协议,它就像一个电源插座,任何电器都可以插入电源插座,一旦插入电源插座就可以通电,因此应用程序也可以用创建一个Socket,Socket分为服务端Socket和客户端Socket,服务端Socket启动后,会一直监听来自客户端Socket的来电,一旦接到来电就可以转给一个接线员,这个接线员也是一个Socket,由它来负责与客户端Socket的通信。
Socket通常支持BIO,NIO,AIO几种模式,不同的操作系统支持的模式不同,通常都支持BIO和NIO,根据不同的应用场景可以选择不同的模式,没有绝对可言。
定时器:
定时器通常分为硬定时器和软定时器。
硬定时器:
硬定时器通常是由晶体震荡器,计数器和存储寄存器组成,当把一块石英晶体适当地切割后,并在它上加上一定的电压后,它就可以非常精确地产生周期性的时钟信号,这些信号的频率可以是几百赫兹,几千赫兹等,这个频率值跟所选的晶体类型有关。
硬定时器的基本原理是这样的:
每个定时器会在存储寄存器设置一个计数值,每次定时器启动时,将这个计数值设置到计数器中,每次晶体震荡后,产生一个时钟信号,该信号被送入到计数器,计数器就减1,直到计数器变为0,那就表示定时器触发了,它会给CPU发送一个时钟中断,CPU接收到这个中断后会进入时钟处理程序。
时钟处理程序会进行一些防止进程时间片超时,进程CPU使用情况记账,维护计算机时间,计算机硬件监控,进程性能数据剖析等工作。
硬件定时器可以分为一次性定时器和永久性定时器,一次性定时器表示定时器触发后后,就自动停止了,永久性定时器则每次定时器触发后,会将存储寄存器中的计数值重新赋值给计数器,然后重新启动定时器,一直运行,直到计算机关闭。
软定时器:
有些高性能的工作需要高频率地执行,例如一个高性能的网络,网络带宽为千兆级别以上,为了保证高效率地输出,每隔12us就要发送一个数据包,如果采用硬定时器,每隔12us产生中断后,会进行一系列的处理,包括进程上下文,流水线,高速缓冲,TLB,MMU等重新切换等,这一繁杂的切换工作需要的时间可能已经接近12us,那么这就意味着从中断开始到真正开始发送数据包,会经过一段时间,这个时间对于一个高性能的网络来说是不能容忍的,因此这种场景可以采用软定时器,软定时器不需要中断,每次发送数据包时,会根据硬定时器+12us设置一个超时时间,每次发送完数据包切换到用户态后,用户程序检查这个超时时间是不是已经过期了,如果过期了就立即发送一个数据包,对于这类高性能的场景,软定时器比较合适,当然如果没有要求那么高的性能要求的话,一般的硬定时器中断足够应付了。
设备控制器:
操作系统是不直接操作IO设备,常常是通过设备控制器间接访问IO设备,可以说设备控制器是CPU与IO设备的桥梁,设备控制器可以连接单个设备,也可以同时连接多个设备,如果连接多个设备,设备控制器和设备之间需要增加一个仲裁设备,这个仲裁设备负责仲裁设备控制器要和那个IO设备进行交互。
设备控制器负责的工作如下:
- 负责控制IO设备,例如设备的读,写,打开,关闭等。
- 协调CPU和IO设备速度读写速度的不匹配,例如设备控制器增加数据缓冲。
- CPU和IO设备数据信号的转换。
- CPu时钟频率和IO设备时钟频率的协调和同步。
- IO端口地址的译码,例如翻译IO端口地址到具体的设备寄存器。
设备控制器提供了4种寄存器即状态寄存器,数据输入寄存器,数据输出寄存器,控制寄存器,操作系统和设备控制器之间的交互就是通过这些设备寄存器,因此它们的交互方式类似于生产者和消费者的关系。
数据输出寄存器:
操作系统可以将CPU寄存器的数据发送到数据输出寄存器,设备控制器得知数据输出寄存器有数据后,就将数据输出寄存器的数据发送给IO设备。
数据输入寄存器:
设备控制器读入IO设备的数据后,存储在数据输入寄存器,然后操作系统读取数据输入寄存器的数据到CPU寄存器中。
状态寄存器:
存储设备相关的状态,例如设备是否已经就绪,数据输入寄存器是不是有数据了等,操作系统可以读取该寄存器的值来查看设备的各类状态。
控制寄存器:
操作系统可以写入控制寄存器从而告知设备控制器执行什么IO操作。
另外,为了协调CPU和IO设备的读写速度差异,设备控制器通常有数据缓冲,用于缓冲从设备读入的数据,或者从操作系统写入的数据,例如操作系统将数据写入到数据输出寄存器后,设备控制器将数据输出寄存器的数据直接写入到数据缓冲,而不是直接发送到IO设备,这个很好理解,因为设备控制器写入数据到IO设备的速度与CPU的发送速度相比差异太大了。
操作系统怎么读写设备寄存器呢,有两种方式
1.IO指令
每个设备寄存器都有一个地址,它与内存的地址不同,这个地址称为IO端口地址,操作系统支持的IO端口地址有65536个即0~64K-1,IO端口地址的范围叫做IO端口空间,内存地址范围叫做内存空间,因此IO端口空间与内存空间是独立的。
假如CPU要读取一个端口地址对应设备寄存器,过程如下:
1.CPU将IO端口地址放到地址总线上,然后在控制总线上设置一个READ信号,此时只是CPU只是要表明要读取某个地址的数据了,至于这个地址来自哪个空间,还需要下一步操作。
2.CPU设备另外一条信号线,设置信号线要访问IO端口空间。
3.所有的设备控制器接收到该信号线后,发现是访问IO端口空间,就会检查地址总线上的地址是不是自己的寄存器的地址,总有一个设备控制器接受这个地址,不是自己的地址设备控制器直接忽略这个信号。
下面表格为PC设备中常见的设备IO端口地址分布图
IO端口范围(十六进制) |
设备 |
000-00F |
DMA控制器 |
020-021 |
中断控制器 |
200-20F |
游戏控制器 |
320-32F |
硬盘控制器 |
3D0-3DF |
图形控制器 |
3F0-3F7 |
软盘控制器 |
另外,CPU提供了专门的IO指令,通过IO指令发送内容到IO端口地址或者从IO端口地址获取内容来实现设备寄存器的读写,
常见的IO指令有IN和OUT
IN AX,DX
DX寄存器存储的是IO端口,该指令表示获取IO端口指向的设备寄存器的内容到AX寄存器。
OUT DX,AX
DX寄存器存储的IO端口,该指令表示将AX寄存器的内容写入到IO端口指向的设备寄存器。
2.内存映射IO
CPU访问任何数据都是通过地址总线,一个32位的地址总线可以寻址的范围是4个G,从上文知道,如果有IO端口地址空间的话,需要单独的一个信号线表明地址线访问的那个空间,假设该信号线访问的是内存空间。
这个内存空间内大部分地址分给了主存,还有一小部分地址落在了其它的内存或者设备寄存器,例如图形控制器的数据缓冲,BIOS的ROM等,通常这部分地址在地址总线范围的低地址处,如下图
内存映射IO
因此通过内存映射IO,我们不需要专门的IO指令,直接通过内存相关的指令例如MOV,LOAD等命令就可以读写设备寄存器或者设备控制器的数据缓冲,当地址总线上写入了地址后,主存,设备控制器,BIOS等都会检查这个地址是不是属于自己的范围,如果是自己的范围内的地址,就会接受这个地址,并且做出响应。
操作系统与设备控制器交互的方式通常有3种:
程序控制IO
程序控制IO也叫轮询,假设CPU写数据到IO设备,通常应用程序会在用户空间分配一个缓冲,用户缓冲要发送的数据,然后进行系统调用,通过系统调用,内核会将用户缓冲拷贝到内核缓冲,这个内存缓冲可以认为是一个发送数组,然后对发送数组的每个字节逐个按照以下流程写入到IO设备,流程如下图所示:
CPU轮询方式会一直检查状态寄存器,一直到状态寄存器不忙时,才会写入发送数组的当前字节到数据输出寄存器,同时设置控制寄存器的写位和就绪位,通知设备控制器要执行一个写操作而且数据已经准备好了。
设备控制器检查到控制寄存器的就绪位已经就绪后,会进一步检查要执行什么操作,发现写位为1,就会从数据输出寄存器获取数据,写到IO设备,完成后,就会清除控制寄存器的就绪位,忙位,故障位,这样CPU就会继续写入数据了。
IO设备的写速度与CPU相比差的十万八千里,因此CPU大部分的时间都在检查状态寄存器的忙位,浪费了大量的CPU时间,这也是轮询方式最大的缺点。
中断驱动IO
假设CPU写数据到IO设备,通常应用程序会在用户空间分配一个缓冲,用户缓冲要发送的数据,然后进行系统调用,通过系统调用,内核会将用户缓冲拷贝到内核缓冲,这个内存缓冲可以认为是一个发送数组,然后对发送数组的每个字节逐个按照以下流程写入到IO设备,假设发送数组剩余要发送的字节数为count,如下图所示为中断驱动IO的流程图
由上图所示分为3个子流程
CPU写入流程:
CPU写入流程与轮询看起来有点类似,不一样的地方在于,用户进程在写入第一个数据到寄存器后,就被阻塞了,CPU此时调度其它进程运行。
设备控制器流程:
这个流程与轮询中的设备控制器流程比较类似,不同的是,设备控制器写数据到IO设备后,会发送一个设备中断信号给CPU,通知CPU可以继续发送数据了。
CPU中断处理程序:
每次CPU执行时,会先检查中断线上是不是有中断信号,如果有中断信号,就会去执行中断处理程序,对于设备的中断处理程序,如果没有发送的数据了,就将用户进程加入到CPU的就绪队列中,CPU后续会调度用户进程,阻塞就解除了,调用就返回了,如果有发送的数据,就继续发送到设备控制器的寄存器中。
中断处理程序返回后,会被切换到被中断的进程,继续执行。
中断驱动IO相比轮询来说,不需要CPU一直等IO设备写入完成,而是可以将写入寄存器后,直接阻塞,调度其它的进程来使用CPU,当IO设备写入完成后,发送中断信号,通知CPU,CPU执行中断处理程序继续写入下一个字节,最终发送完成后,解除阻塞,这样CPU就可以充分利用,减少了CPU的浪费。
DMA
假设CPU写数据到IO设备,通常应用程序会在用户空间分配一个缓冲,用户缓冲要发送的数据,然后进行系统调用,通过系统调用,内核会将用户缓冲拷贝到内核缓冲,这个内存缓冲可以认为是一个发送数组。
DMA的处理流程如下:
DMA
DMA的处理流程也可以分为3个子流程
CPU流程 :
CPU传输发送数组的起始地址,发送数组的大小给DMA,此时用户进程A就从CPU运行队列移除,此时用户进程A阻塞,CPU调度其它的进程运行。
DMA流程:
DMA根据发送数组的起始地址,检查尚未发送的字节数count,如果count<0, 那么就是传输数据给设备寄存器,然后就等待设备控制器的应答,如果 count=0,那么DMA发送完成了,它会给CPU发送一个中断信号,CPU收到这个中断信号后,会执行DMA的中断处理程序,将用户进程A加入到CPU的就绪队列,后续CPU会调度用户进程A去运行,这个时候用户进程A调用返回。
设备控制器流程:
设备控制器的流程与轮询和中断类似,只是设备控制器发送完数据后,会发送一个应答给DMA,DMA收到这个应答后,会将coun-1,然后调度发送下一个字节。
可以看出DMA不需要设备控制器发送中断给CPU,它成为了CPU的代理,CPU可以专注地做其它的事情,只是DMA传输完成后,才会中断一下CPU。
另外DMA每次控制设备控制器写数据时,都会先控制住地址总线,这样就会跟CPU抢时钟周期,这个也是不可以避免的。
DMA控制器可以连接多个设备控制器,通过一定的算法,来切换每次控制的设备控制器,另外复杂一些的DMA控制器也可以一次控制设备控制器发送多个字节,这样一次性占据地址总线的时间就比较长,这样的效率也会更高,只是CPU如果此时需要用地址总线,那就需要等待一段时间了,CPU周期被浪费了。
设备驱动程序
我们上面阐述操作系统通过控制设备控制器来间接控制IO设备,其实就是设备驱动程序来控制设备控制器。
设备驱动程序通常位于内核,它封装对设备控制器操作的所有细节和差异,并且提供了标准的接口供内核的IO子系统调用,这一点类似于网络协议的分层结构,每一层将数据封装好后提供给上一层使用,上一层不用考虑下一层的实现细节。
可以说设备驱动程序将硬件和IO子系统调用分离开来,每一类设备都需要专门的设备驱动程序进行控制,一般这些设备驱动程序都是由设备提供商来负责编写,通常不同的操作系统设备驱动程序都有不同的开发标准,因此只要设备提供商按照操作系统要求的标准开发设备驱动程序,增加任何的IO设备和控制器对于操作系统来说都是透明的,当然这对操作系统来说好处多多,对于设备提供商来说好处不多,他们需要为各种操作系统系统编写驱动程序,这样才能争取不同操作系统的用户。
通常设备驱动程序不添加任何用户策略,只负责与硬件打交道,它通常会将上层IO子系统发送过来的抽象的读写请求,转化为实际的对硬件的IO操作,保证硬件可用并提供硬件原始数据,原始的数据交由IO子系统进行二次加工,IO子系统再根据根据各类用户策略对原始数据进行处理。
IO子系统
IO子系统位于内核,处于设备驱动程序的上一层,它是设备无关的,主要提供文件系统,IO调度,缓冲,高速缓冲,设备保护,错误处理等。
增加新的设备驱动程序对IO子系统没有i影响,因为每个设备驱动程序向IO子系统提供标准的访问接口。
文件系统:
文件系统主要负责对块设备进行统一管理,抽象出文件这个逻辑设备的概念,建立文件与块设备的每个块的映射关系等,文件系统也负责根据文件名定位到设备驱动程序和具体的某个设备,这个以后单独写文章阐述。
IO调度
操作系统为每个设备维护一个IO请求队列,当进程进行IO请求时,会将这些IO请求加入到这个队列中,如果按照先进先出的方式顺序访问设备,从系统总体来讲,并不一定是高效的,通常需要合适的IO调度算法来高效和充分地利用设备。
另外不同的IO请求有不同的优先级,有些IO请求优先级天生比较高,它可以获得优先执行的权利,例如一个缺页IO请求就比其它的普通应用程序的请求更加紧急,也会有更高的优先级。
缓冲
采用缓冲有以下几个理由:
一:设备控制器通常有单独的数据缓冲,应用程序也需要有自己的缓冲,应用程序写数据时,通常写入到自己的缓冲,而不是直接写到设备控制器的缓冲,这是由于IO设备处理数据较慢,设备控制器的缓冲,很容易就满了,因此应用程序设置自己的缓冲,这样即使设备控制器缓冲满了,应用程序照样可以写数据,不至于阻塞,另外应用程序设置自己的缓冲,可以灵活增加缓冲的容量,也可以设置多个缓冲,例如双缓冲,循环缓冲等。
二.通常应用程序自己的缓冲在用户空间,操作系统可以将用户缓冲中的数据发送到设备控制器上的数据缓冲,设备控制器再发送数据缓冲的数据到设备,这样有两个问题,一个是:用户空间的缓冲通常是在用户空间的某些页上,一旦这些页被换出了,操作系统读取这些页的数据时发现页不在就会进行缺页处理,这个很影响效率,另外一个是:操作系统发送数据时,用户空间的缓冲被修改了,此时就造成了数据的破坏,因此通常操作系统会在内核开辟一块空间叫做内核缓冲,应用程序写入数据时,先将用户缓冲复制到内核缓冲,内核缓冲再写入到设备控制器的数据缓冲,此后内核就可以放心地发送数据了,不用担心页被换出或者数据被修改了,这样的复制非常影响效率,因此有些操作系统会采用虚拟内存映射和写时复制的方式将用户缓冲和内核缓冲指向内核的一块共享缓冲,如果用户修改了共享缓冲,会通过写时复制的方式,拷贝修改的部分到用户空间,不影响原来的共享缓冲。
整体来说缓冲不光是写出的缓冲,读入的缓冲也是同样的道理,这里不再阐述。
高速缓存:
操作系统可以开辟一块内存,用于存储热点的设备数据,采用一定的缓存失效算法,剔除失效的缓存,这样可以每次进行IO读取操作时,则可以直接从高速缓存中获取,不在进行任何IO操作。
有时候高速缓存和缓冲可以合二为一,例如一个写缓冲存储了要写出到设备的数据,此时如果再读取这些数据时,就可以把写缓冲当做高速缓冲,直接从写缓冲读取。
设备保护:
IO设备保护有以下几种方式:
1.IO特权指令,IO指令通常都是特权指令,这些指令只有操作系统才可执行,这样就杜绝了应用程序执行IO指令,应用程序可以通过系统调用进行IO操作。
2.通过内存映射IO方式访问设备控制器数据缓冲或者设备寄存器时,地址总线上一部分地址用于映射到设备控制器数据缓冲或者设备寄存器,这部分地址由操作系统的内存保护模块进行控制,禁止用户空间访问这些地址。
3.IO子系统中文件系统中所有的文件都有访问的权限,限制文件的访问就间接地对文件对应的IO设备进行了保护。
错误处理:
IO设备产生的错误往往比较多而且杂,当错误发生时,IO子系统会尽最大努力对这些错误进行处理,许多IO错误与特定的设备有关,这类的错误只能交由设备驱动程序去处理,但IO子系统有个错误处理的流程,按照这个流程进行处理是通用的和设备无关的,以下为错误处理框架中的几个关键环节的处理过程。
一:一些IO类的错误是编程导致的,比如应用程序对键盘,扫描仪,鼠标进行写入操作,这些设备是不支持写入的,还有就是应用程序提供了一个无效的缓冲区或者参数,这些编程导致的错误,IO子系统直接返回相应的错误码给应用程序。
二:另外一种IO类的错误就是设备确实有问题了,例如应用程序试图写入一个损坏的磁盘块,这类错误交由设备驱动程序解决,如果设备驱动程序实在不知道怎么解决,则再返回IO子系统,由IO子系统返回给应用程序。
三:还有一些IO类错误可以交由用户去决定怎么去解决,例如一个读磁盘发生错误,可以由IO子系统告知用户,例如弹出一个对话框,让用户去选择重试次数,忽略错误,或者干脆停掉进程。
操作系统访问IO设备的流程举例
假设应用程序进行一个系统调用read读取磁盘数据,这个read是阻塞的,设备驱动程序与设备控制器交互方式采用DMA,下面来阐述下整个流程如下图所示:
1.IO子系统根据文件名可以定位到设备驱动程序,设备寄存器端口,磁盘物理地址,每个操作系统有各自的实现方式,通常这一部分通常由文件系统来实现。
2.IO子系统根据磁盘物理地址去高速缓冲中查找,如果高速缓冲命中,则将高速缓冲中的数据拷贝到用户空间缓冲,调用返回。
5.IO子系统将应用程序A从CPU的运行队列移除,加入到设备的等待队列,这个时候应用程序阻塞了。
6~8.IO子系统根据设备寄存器端口和磁盘物理地址封装IO请求,将IO请求加入到设备IO请求队列中,IO子系统根据IO请求调度算法,开始调用IO请求,最终将应用进程A的IO请求发送给设备驱动程序。
9~11.设备驱动程序在内核空间开辟一块内存空间,作为内核缓冲,这块内核缓冲用来接收来自磁盘的数据,然后设备驱动程序控制DMA,发送控制请求给DMA,然后设备驱动程序阻塞了,阻塞的方法有很多中,比如wait。
12.DMA控制磁盘控制器,完成读取数据后,发送中断信号给CPU。
13.CPU接受到中断信号后,开始执行中断处理程序,程序会检查IO请求是否已经完成,请求的完成状态等,然后封装IO请求结果,发送给IO子系统。
14. IO子系统收到请求结果后,会唤醒设备驱动程序,唤醒的方式比如singal,同时IO子系统会拷贝内核缓冲的数据到用户缓冲,同时将返回结果设置到应用程序A的用户空间。
15.IO子系统将应用程序A从设备队列中移除,加入到CPU就绪队列中。
16.CPU调度运行应用进程A,应用进程A将返回结果返回。