Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.4 操作系统做什么

操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。

计算机硬件的能力是有限的,比如一个CPU一秒钟能够执行的指令条数是1亿条或是1GB的内存能够最多同时存储1GB的数据。无论你是否使用它,资源总是那么多。当然我们不希望自己花钱买回来的硬件成为摆设,充分挖掘硬件的能力,使得计算机运行得更有效率,在更短的时间内处理更多的任务,才是我们的目标。这对于早期动辄数百万美元的古董计算机来说更是如此,人们挖空心思让计算机硬件发挥所有潜能。一个计算机中的资源主要分CPU、存储器(包括内存和磁盘)和I/O设备,我们分别从这三个方面来看看如何挖掘它们的潜力。

1.4.1 不要让CPU打盹

在计算机发展早期,CPU资源十分昂贵,如果一个CPU只能运行一个程序,那么当程序读写磁盘(当时可能是磁带)时,CPU就空闲下来了,这在当时简直就是暴殄天物。于是人们很快编写了一个监控程序,当某个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU资源的程序启动,使得CPU能够充分地利用起来。这种被称为多道程序(Multiprogramming)的方法看似很原始,但是它当时的确大大提高了CPU的利用率。不过这种原始的多道程序技术存在最大的问题是程序之间的调度策略太粗糙。对于多道程序来说,程序之间不分轻重缓急,如果有些程序急需使用CPU来完成一些任务(比如用户交互的任务),那么很有可能很长时间后才有机会分配到CPU。这对于有些响应时间要求高的程序来说是很致命的,想象一下你在Windows上面点击鼠标10分钟以后系统才有反应,那该是多么沮丧的事。

经过稍微改进,程序运行模式变成了一种协作的模式,即每个程序运行一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都有机会运行一小段时间。这对于一些交互式的任务尤为重要,比如点击一下鼠标或按下一个键盘按键后,程序所要处理的任务可能并不多,但是它需要尽快地被处理,使得用户能够立即看到效果。这种程序协作模式叫做分时系统(Time-Sharing System),这时候的监控程序已经比多道程序要复杂多了,完整的操作系统雏形已经逐渐形成了。Windows的早期版本(Windows 95和Windows NT之前),Mac OS X之前的Mac OS版本都是采用这种分时系统的方式来调度程序的。比如在Windows 3.1中,程序调用Yield、GetMessage或PeekMessage这几个系统调用时,Windows 3.1操作系统会判断是否有其他程序正在等待CPU,如果有,则可能暂停执行当前的程序,把CPU让出来给其他程序。如果一个程序在进行一个很耗时的计算,一直霸占着CPU不放,那么操作系统也没办法,其他程序都只有等着,整个系统看过去好像死机了一样。比如一个程序进入了一个while(1)的死循环,那么整个系统都停止了。

这在现在看来是很荒唐的事,系统中的任何一个程序死循环都会导致系统死机,这是无法令人接受的。当然当时的PC硬件处理能力本身就很弱,PC上的应用也大多是比较低端的应用,所以这种分时方式勉强也能应付一下当时的交互式环境了。此前在高端领域,非PC的大中小型机领域,其实已经在研究一种更为先进的操作系统模式了。这种模式就是我们现在很熟悉的多任务(Multi-tasking)系统,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式即所谓的抢占式(Preemptive),操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同时运行的假象。目前几乎所有现代的操作系统都是采用这种方式,比如我们熟悉的UNIX、Linux、Windows NT,以及Mac OS X等流行的操作系统。

1.4.2 设备驱动

操作系统作为硬件层的上层,它是对硬件的管理和抽象。对于操作系统上面的运行库和应用程序来说,它们希望看到的是一个统一的硬件访问模式。作为应用程序的开发者,我们不希望在开发应用程序的时候直接读写硬件端口、处理硬件中断等这些繁琐的事情。由于硬件之间千差万别,它们的操作方式和访问方式都有区别。比如我们希望在显示器上画一条直线,对于程序员来说,最好的方式是不管计算机使用什么显卡、什么显示器,多少大小多少分辨率,我们都只要调用一个统一的LineTo()函数,具体的实现方式由操作系统来完成。试想一下如果程序员需要关心具体的硬件,那么结果会是这样:对于A型号的显卡来说,需要往I/O端口0x1001写一个命令0x1111,然后从端口0x1002中读取一个4字节的显存地址,然后使用DDA(一种画直线的图形算法)逐个地在显存上画点……如果是B型号的显卡,可能完全是另外一种方式。这简直就是灾难。不过在操作系统成熟之前,的确存在这样的情况,就是应用程序的程序员需要直接跟硬件打交道。

当成熟的操作系统出现以后,硬件逐渐被抽象成了一系列概念。在UNIX中,硬件设备的访问形式跟访问普通的文件形式一样;在Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了DirectX对象;磁盘被抽象成了普通文件系统,等等。程序员逐渐从硬件细节中解放出来,可以更多地关注应用程序本身的开发。这些繁琐的硬件细节全都交给了操作系统,具体地讲是操作系统中的硬件驱动(Device Driver)程序来完成。驱动程序可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性。因为PC的硬件多如牛毛,操作系统开发者不可能为每个硬件开发一个驱动程序,这些驱动程序的开发工作通常由硬件生产厂商完成。操作系统开发者为硬件生产厂商提供了一系列接口和框架,凡是按照这个接口和框架开发的驱动程序都可以在该操作系统上使用。让我们以一个读取文件为例子来看看操作系统和驱动程序在这个过程中扮演了什么样的角色。

提到文件的读取,那么不得不提到文件系统这个操作系统中最为重要的组成部分之一。文件系统管理着磁盘中文件的存储方式,比如我们在Linux系统下有一个文件"/home/user/test.dat",长度为8000个字节。那么我们在创建这个文件的时候,Linux的ext3文件系统有可能将这个文件按照这样的方式存储在磁盘中:文件的前4096字节存储在磁盘的1000号扇区到1007号扇区,每个扇区512字节,8个扇区刚好4096字节;文件的第4097个字节到第8000字节共3904个字节,存储在磁盘的2000号扇区到2007号扇区,8个扇区也是4 096字节,只不过只存储了3904个有效的字节,剩下的192个字节无效。如果把这个文件的存储方式看作是一个链状的结构,它的结构如图1-4所示。

图1-4 文件在磁盘中的结构
图1-4 文件在磁盘中的结构

这里我们先穿插一个关于硬盘的结构介绍,关于硬盘结构可能很多读者已经有一个大概的了解,那就是硬盘基本存储单位为扇区(Sector),每个扇区一般为512字节。一个硬盘往往有多个盘片,每个盘片分两面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。比如一个硬盘有2个盘片,每个盘面分65536磁道,每个磁道分1024个扇区,那么硬盘的容量就是2 * 2 * 65 536 * 1024 * 512 = 137438953472字节(128GB)。但是我们可以想象,每个盘面上同心圆的周长不一样,如果按照每个磁道都拥有相同数量的扇区,那么靠近盘面外围的磁道密度肯定比内圈更加稀疏,这样是比较浪费空间的。但是如果不同的磁道扇区数又不同,计算起来就十分麻烦。为了屏蔽这些复杂的硬件细节,现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。逻辑扇区号抛弃了所有复杂的磁道、盘面之类的概念。当我们给出一个逻辑的扇区号时,硬盘的电子设备会将其转换成实际的盘面、磁道等这些位置。

文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证磁盘中的扇区能够有效地组织和利用。那么当我们在Linux操作系统中,要读取这个文件的前4096个字节时,我们会使用一个read的系统调用来实现。文件系统收到read请求之后,判断出文件的前4096个字节位于磁盘的1000号逻辑扇区到1007号逻辑扇区。然后文件系统就向硬盘驱动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,磁盘驱动程序收到这个请求以后就向硬盘发出硬件命令。向硬件发送I/O命令的方式有很多种,其中最为常见的一种就是通过读写I/O端口寄存器来实现。在x86平台上,共有65536个硬件端口寄存器,不同的硬件被分配到了不同的I/O端口地址。CPU提供了两条专门的指令“in”和“out”来实现对硬件端口的读和写。

对IDE接口来说,它有两个通道,分别为IDE0和IDE1,每个通道上可以连接两个设备,分别为Master和Slave,一个PC中最多可以有4个IDE设备。假设我们的文件位于IDE0的Master硬盘上,这也是正常情况下硬盘所在的位置。在PC中,IDE0通道的I/O端口地址是0x1F0~0x1F7及0x376~0x377。通过读写这些端口地址就能与IDE硬盘进行通信。这些端口的作用和操作方式十分复杂,我们以实现读取1000号逻辑扇区开始的8个扇区为例:

  • 第0x1F3~0x1F6 4个字节的端口地址是用来写入LBA地址的,那么1000号逻辑扇区的LBA地址为0x000003E8,所以我们需要往0x1F3、0x1F4写入0x00,往0x1F5写入0x03,往0x1F6写入0xE8。
  • 0x1F2这个地址用来写入命令所需要读写的扇区数。比如读取8个扇区即写入8。
  • 0x1F7这个地址用来写入要执行的操作的命令码,对于读取操作来说,命令字为0x20。

所以我们要执行的指令为:

out 0x1F3, 0x00
out 0x1F4, 0x00
out 0x1F5, 0x03
out 0x1F6, 0xE8
out 0x1F2, 0x08
out 0x1F7, 0x20

在硬盘收到这个命令以后,它就会执行相应的操作,并且将数据读取到事先设置好的内存地址中(这个内存地址也是通过类似的命令方式设置的)。当然这里的例子中只是最简单的情况,实际情况比这个复杂得多,驱动程序须要考虑硬件的状态(是否忙碌或读取错误)、调度和分配各个请求以达到最高的性能等。