1.1 操作系统基础
操作系统是一层特殊的软件,它的职责是管理硬件资源,同时为上层应用软件提供各种服务。所以,操作系统直接跟硬件打交道,自然地,它使用机器能理解的语言,即机器语言。因此,操作系统的发行版本一定是与特定的硬件体系结构相关的,比如针对Intel x86的操作系统。虽然操作系统的运行版本使用的一定是机器语言,但操作系统本身不必是用机器语言或汇编语言来编写的,目前最常见的是用C语言来编写的(主要原因是,C语言与机器语言有较好的对应性,所以程序员容易控制目标代码)。在本书中,我们主要参考的也是C代码,只在个别地方由于硬件相关性,会用到汇编语言。
接下来先讨论操作系统的硬件资源管理职责,再介绍它为应用程序提供的执行环境。
1.1.1 计算机系统的硬件资源管理
操作系统管理哪些硬件资源,分别又是怎么管理的呢?最主要的资源是计算资源和存储资源。计算资源即CPU(Central Processing Unit,中央处理单元),现在主流的计算机通常有一个或多个CPU,或者一个CPU中有多个核(即多核CPU)。从操作系统的角度来看,有多个CPU或一个多核CPU意味着可以同时执行多个任务。所以,操作系统必须合理地安排和调度任务,使得多个CPU或多核尽可能地利用起来,避免出现竞争或闲置的情形。在支持多任务并发的操作系统中,这一职责称为任务调度。在现代操作系统中,由于任务是由进程或线程来完成的,操作系统的这部分功能也称为进程调度或线程调度。因为任务的数量可能超过CPU或核的数量,所以,多个任务可能共用同一个CPU或核,这就需要有一种硬件机制能够让操作系统在不同的任务之间实现切换,这是任务调度的硬件基础。通常,计算机提供的时钟中断可以让操作系统很方便地做到这一点,也就是说,每隔一定的时间,硬件系统会触发一个中断;操作系统截获此中断,按照某种算法暂停当前正在执行的任务,并选择一个新的任务,从而实现任务的切换;到下一个时钟中断到来时,再继续这样的切换过程。因此,多个任务可以在一个CPU或核中被轮流执行。操作系统可以设定时钟中断间隔的长度,也可以选择不同的算法来安排这些任务被先后执行,这样就形成了各种不同的调度算法。本书将在第3章讨论Windows的线程调度方案。
存储资源通常包括内存(RAM,随机访问存储器)和外存(也称为辅助存储器)。由于外存是通过标准的I/O(输入/输出)来管理的,而内存是CPU直接通过系统总线来访问的,所以,我们将在讨论输入/输出的时候再介绍外存,现在仅考虑内存资源的管理。内存是CPU执行一个任务的物质基础,CPU内部的寄存器具备计算的能力,但计算的数据从哪里来呢?除了寄存器(其本身也是一种存储资源)以外,数据的来源是系统内存。在现代操作系统中,每个任务都有其独立的内存空间,从而避免任务之间产生不必要的干扰,所以操作系统有责任为每个任务提供相对独立的内存空间。把连续编址的物理内存划分成独立的内存空间,典型的做法是段式内存寻址和页式虚拟内存管理。不同的硬件体系结构可能支持不同的方案。Intel x86体系结构同时支持段式寻址和页式虚拟内存映射,但是,可在Intel x86上运行的操作系统几乎都选择了虚拟内存映射作为内存管理的硬件基础。Windows和Linux便是典型的例子。
在支持多任务的系统中,若所有任务的内存需求加起来的总量超过了当前系统的物理内存总量,那么,系统要么停掉一些任务,要么把一些任务转移到外存(如磁盘)中,以后当内存空闲时再把这些任务转换回来。或者系统有选择地把部分不常用的内存转换到外存,并且根据适当的规则将来再慢慢地转换回来。虚拟内存的映射以及物理内存不足时的换出和换入操作,这都是操作系统管理内存资源的重要任务。前者依赖于硬件提供的机制,而后者则更多地由操作系统自己来控制。
除了计算资源和内存资源的管理以外,操作系统对其他资源都通过I/O来管理。例如,上面提到的外存资源,像磁盘,在现代计算机中是不可或缺的部件;另外,键盘和鼠标通常是标准的输入设备,而显示器和打印机往往是标准的输出设备。操作系统为了跟I/O设备打交道,需要三方面的技术保障:CPU通过特定的指令来控制I/O设备,I/O设备通知CPU发生了特定的事情,以及在系统主内存和设备之间传输数据。
通常,CPU直接访问设备的寄存器来操作一个设备,在Intel x86系统上,CPU通过in和out指令能够做到这一点。设备寄存器是另一个地址空间,CPU通过I/O端口(I/O port)来访问它们。在现代计算机中,I/O端口的分配跟软件和硬件都有关系。不同的硬件设备会使用不同的端口编号,现代的设备大都可以通过软件方式来设置其端口号,而过去一些老的设备可能需要通过硬件跳线来改变端口号的设置。对硬件设备进行恰当的设置,也是操作系统管理硬件设备的任务之一。另外,CPU怎么知道或检测设备的工作状态呢?一种做法是,通过不停地查询设备的状态寄存器来获知其工作状态;但是,更有效的做法是,当设备的状态发生变化时,它能够主动地通知CPU,从而操作系统可以采取相应的措施。这后者即是设备中断机制。比如说,当键盘设备接收到按键动作时,它产生一个中断,告诉CPU(和操作系统),当前哪个键被按下了。中断也有编号,中断的编号被视为系统全局资源,在早期计算机的中断控制器中,不同设备的中断号不能冲突,否则设备无法正常工作。现代计算机通常允许多个设备共享中断号,操作系统和设备驱动程序可以协商设备的中断号。
计算机的计算处理能力往往仅限于在CPU内部寄存器和主内存之间进行,但是为了实现基本算术计算以外的其他各种能力,通常有必要让设备中的数据也参与到计算中来,所以,在设备与CPU寄存器或主内存之间传输数据往往是必要的功能。例如,计算机通过磁盘设备可以实现永久存储,通过显示控制器实现彩色显示甚至三维模拟。实现数据传输的方法有多种,如果设备本身的数据量很小,则可以直接通过in指令来读取设备中的数据,或通过out指令输出到设备中。或者也可以映射一段地址范围到设备中,这样,当CPU访问这块地址范围时,实际上是在访问设备的内存,而不是系统的主内存。另外一种适合于大块数据传输的技术是DMA(Direct Memory Access,直接内存访问)。像硬盘控制器和网络控制器就通常采用DMA方式来传输数据,CPU只须设置好数据传输的方向、位置、数量等信息,就可以启动DMA传输了。DMA传输可以与CPU计算同时进行,但是DMA使用的总线不能与CPU使用的发生冲突,它可以趁CPU不用总线的时刻来传送数据,也可能会因此而阻塞CPU指令的执行。DMA传输影响CPU执行指令的程度取决于DMA控制器的传输策略。
从操作系统的角度来看,考虑到I/O设备的多样性和出现新设备的可能性,操作系统有必要定义一个框架来容纳各种各样的I/O设备,并且允许操作系统发布之后还能够为新的设备提供支持。所以,除了专用操作系统以外,现代操作系统都会提供一个I/O模型,允许设备厂商按照此模型编写设备驱动程序(Device Driver),并加载到操作系统中。I/O模型通常具有广泛的适用性,能够支持各种类型的设备,包括对硬件设备的控制能力,以及对数据传输的支持。可以这么来概括I/O模型,它对下提供了控制硬件设备的能力,对上为应用程序访问硬件提供了一个标准接口,同时I/O模型也必须能够让操作系统有效地管理设备驱动程序。在Windows系统中,第三方厂商可以使用Windows的I/O模型来编写设备驱动程序。Windows本身在发行时,已经随带了大量主流设备的驱动程序,所以,Windows系统在安装阶段可以自动将识别出来的设备的驱动程序安装到系统中。因此,用户并不需要手工下载或安装这些驱动程序。另一方面,由于驱动程序需要直接访问硬件设备,它执行的许多指令(包括in和out)是特权指令,所以,驱动程序对于系统的稳定性和安全性有至关重要的影响,操作系统有必要对其执行严格的筛查措施,以避免恶意代码趁机闯入系统中。
1.1.2 为应用程序提供执行环境
操作系统之所以要管理各种硬件资源,是为了更好地为上层应用程序提供服务。应用程序并不直接与机器的各种硬件设备及资源打交道,而是运行在一个抽象层上,即操作系统提供的功能语义层。操作系统提供什么样的语义层,将决定应用程序应该怎么来构建它们的功能逻辑。尽管不同的操作系统提供的这一功能语义层的接口可能不一致,甚至相距甚远,但现代操作系统的语义层在概念上仍然比较一致,下面我们从应用程序的构建需求来介绍一些核心概念。
首先,对应用程序的任务作最基本的抽象,这就是进程和线程的概念。尽管不同的操作系统对于进程和线程的定义不尽相同,但是每个任务都应该有它自己的执行环境,即该任务的控制流以及函数调用的层次递进痕迹(假设CPU的指令体系中支持函数调用的语义)。比如,在UNIX中,一个进程代表了一个应用任务,它记录了该任务的执行状态;在Windows中,一个线程代表了一个任务,其中也记录了该任务的执行状态。现代操作系统都支持多个任务并发执行,任务的数量可以远远大于CPU或核的数量,所以,每个任务实际上只是分到了一部分CPU执行时间,多个任务可以共享一个CPU。因此,在这种多任务操作系统模型下,操作系统除了为每个任务(进程或线程)维护好其状态以外,还必须有恰当的算法来决定什么时候让哪个任务执行,什么时候暂停一个任务。正如本章前面所提到的,这便是任务调度,或者称为进程调度或线程调度(取决于操作系统是以进程还是线程的方式来管理任务)。简单地轮流执行每个任务,这种调度算法往往难以满足实际应用程序的各种需求。通常操作系统会采用基于优先级的抢占式调度算法,甚至根据任务的各种特性进行优先级的动态微调,这使得任务调度算法趋向于复杂化。Windows的线程调度方案属于这一类型。
其次,在支持多任务的基础上,如果这些任务是相互独立的,则操作系统总是可以采用适当的方式让它们获得执行机会,但在实践中,应用程序为了实现各种功能逻辑,不同任务之间往往有一些逻辑上的关联性。比如,任务之间必须强加某种时序关系,才能保证每个任务的状态是有效的;当多个任务竞争某些稀有资源(注意,系统的硬件资源,像打印机和显示器等,是共享的)时,系统必须确保这些任务有序执行。所以,凡是存在共享资源的地方,操作系统都必须提供恰当的方法来同步应用程序对它的访问。现代操作系统通常会提供多种同步机制,例如互斥体(mutex)、信号量(semaphore)、临界区(critical section)等。应用程序可以有选择地使用这些同步机制,以确保多个任务有序地共享资源。
除了任务和同步的概念,每个应用程序还必须有它自己相对独立的执行空间。在现代操作系统中,进程也代表了一个应用程序和它的执行空间。不同进程的空间是相互隔离的,这是现代操作系统的基本需求。操作系统必须在处理器的硬件特性基础之上,实现一套行之有效的空间隔离方案。每个进程有它自己的内存空间,并且无法直接访问其他进程的内存空间。进程之间如果要共享数据,则必须通过操作系统提供的机制来进行。Intel x86体系结构上的操作系统基本上都利用硬件的虚拟内存映射机制来隔离每个进程的内存空间。所以,操作系统的职责是为每个进程维护好从虚拟地址到物理地址的映射关系,并且管理好物理内存的分配和回收。另一方面,除了进程的空间隔离性,操作系统还必须提供相应的机制让不同的进程可以相互通信,毕竟,很多软件需要进程之间的协作来完成一些上层功能。同步机制和跨进程地共享内存是典型的进程间通信(IPC,Inter-Process Communication)手段。
前面提到,现代操作系统往往以统一的框架来管理I/O设备,这也隐含了操作系统向应用程序暴露的I/O接口是统一的这样一层意思。应用程序通过此接口来访问系统的外部设备,而操作系统不仅要管理好应用程序访问外设的各种请求,包括它们的时序,还必须将应用程序的请求发送到对应的设备驱动程序中,最终由设备驱动程序来处理这些请求,而处理的结果也必须以某种方式回送给应用程序。操作系统通常以句柄(handle)来代表一个可访问的抽象设备,抽象设备可能与物理设备连接,也可能并不存在对应的物理设备或资源。操作系统还提供在一个句柄上读(read)、写(write)数据,以及发送控制命令的能力。所以,应用程序与系统设备打交道的方式非常简洁明了:打开设备获得句柄、向设备发送命令、读或写设备,以及关闭设备。操作系统的任务是管理这些设备和驱动程序,以及传递或解释应用程序的命令。
一类特殊的设备是磁盘,磁盘具有随机读写的能力,尽管其随机读写的性能比顺序读写要差很多。通常,磁盘的驱动程序负责操纵磁盘控制器,以便从磁盘中读取数据或者向磁盘上写入数据,但磁盘上的数据如何有效地组织起来以便不同的应用程序可以共享同一个磁盘,这一任务是由文件系统(file system)来完成的。在绝大多数操作系统中,文件系统是不可缺少的,它通过一个树状名字空间来管理磁盘上的存储空间,允许应用程序通过名称来访问磁盘上的数据。这种结构奠定了现代计算机的信息存储系统的基础。
所以,应用程序实际上工作在操作系统提供的抽象层上,它无须管理系统的硬件设备和资源,但必须使用操作系统提供的机制或语义来实现其自身的功能逻辑。这也暗示着,操作系统在某种程度上决定了上层应用程序应该如何使用它所提供的服务才是最有效和方便的,同时,应用程序也有足够的灵活性来完成其自身的上层功能。这就形成了在同一个操作系统上存在大量风格迥异的应用程序的局面,并且,由于不同操作系统提供的抽象层在概念上有一定的互通性,所以,很多应用软件能够便捷地从一个操作系统平台移植到另一个上。
我们通常所指的操作系统的范畴包括上面提到的资源管理功能,以及为应用程序提供的各种抽象概念和抽象接口的具体实现。但是,在应用程序和操作系统核心模块之间,往往还存在很多预封装好的模块,以便于应用开发人员可以复用这些模块,从而高效地开发应用软件,缩短应用软件的开发周期。这些模块既可能是操作系统厂商提供的,也可能是独立软件厂商提供的。它们既可能构成一个通用的中间件(middleware),也可能只是随编译器而提供的一个库模块(静态的或动态的)。例如,Windows操作系统的发行版本包含大量这样的中间模块(例如COM/COM+、Winsock2、.NET等),它们也成为了Windows的一部分,但是,在本书中,我们将把焦点集中在Windows操作系统的核心功能和模块上,只在必要的地方提及这些中间模块。