2.1 程序员眼中的计算机系统

计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。虽然系统的具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都由相似的硬件和软件组成,它们又执行着相似的功能,特别是在从程序员的角度看计算机系统时,情况更是如此。

柯尼汉(B. Kernighan)和里奇(D. Ritchie)在他们的C语言程序设计的经典著作中,设计了一个著名的hello程序,并用它来作为介绍C语言的典型样例,我们就借用它来帮助了解计算机系统,该程序如例2.1所示。

例2.1 柯尼汉和里奇设计的C语言hello程序的源代码(hello.c)如下所示(行号是为了帮助说明问题而添加的)。

            1.   #include<stdio.h>
            2.
            3.   int main()
            4.   {
            5.       printf(“hello,world\n”);
            6.   }

尽管hello程序是一个非常简单的程序,但是为了完成它的执行,系统的每个主要组成部分都要协调工作。了解计算机系统的结构和工作原理,可以从了解hello程序的运行过程开始,我们需要弄清楚:当我们在系统上执行hello程序时,系统发生了什么及为什么会如此运作。我们通过跟踪hello程序的运行过程来学习计算机系统的基本知识。

假设已经把hello.c源程序编译成了可执行目标文件hello,并存放在磁盘上。在UNIX系统中,可以按照例2-1-2所示的操作方法运行该可执行文件,所要做的事情是将它的文件名输入到被称为shell的应用程序中。

例2.2 在UNIX系统的shell环境中,可以通过以下方式运行hello程序(行号是为了帮助说明问题而添加的)。

            1.   unix>./hello
            2.   hello,world
            3.   unix>

第1行表示用户在shell的提示符下输入执行hello程序的命令,第2行表示该程序执行时输出的信息,第3行表示该程序执行结束。

shell是一种命令行解释器,它输出一个提示符,等待用户输入一行命令,然后执行这个命令。如果该命令行的第一个单词不是一个内置的shell命令,那么shell就会假设这是一个可执行文件的名字,要加载和执行该文件。在我们的例子中,shell将加载和执行hello程序,然后等待程序终止。hello程序在屏幕上输出它的信息,然后终止。shell随后输出一个提示符,等待下一个输入的命令行。

2.1.1 计算机系统的硬件组成

为了了解运行hello程序时发生了什么,我们需要了解一个典型的计算机系统的硬件组成,如图2.1所示。该图是Intel Pentium系统产品族的模型,但是所有其他系统也有相同的外观和特性。

图2.1 一个典型的计算机系统的硬件组成

根据图2.1容易看出,一个计算机系统由总线、I/O设备、主存和处理器等硬件组件组成。

1.总线

贯穿整个系统的是一组电子线路,称做总线,它携带信息字节并负责在各个部件间传递。总线通常被设计成传送定长的字节块,也就是字(Word)。字中的字节数(即字长)是一个基本的系统参数,各个系统不尽相同。例如,Intel Pentium系统的字长为4字节,而服务器类的系统,如Intel Itaniums和高端的Sun公司的SPARCS的字长为8字节。用于汽车等工业中的嵌入式控制器之类的较小的系统的字长往往只有1或2字节。为了便于描述,我们假设字长为4字节,并且假设总线一次只传送1个字。

2.I/O设备

I/O(输入/输出)设备是系统与外界的联系通道。我们的示例系统包括4个I/O设备:作为用户输入的键盘和鼠标、作为用户输出的显示器及用于长期存储数据和程序的磁盘驱动器(简单地说就是磁盘)。最开始,可执行程序hello就放在磁盘上。

每个I/O设备都是通过一个控制器或适配器与I/O总线连接起来的。控制器和适配器之间的区别主要在于它们的组成方式。控制器是I/O设备本身或是系统的主印制电路板(通常被称做主板)上的芯片组,而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是在I/O总线和I/O设备之间传递信息。

3.主存

主存是一个临时存储设备,在处理器执行程序时,用于存放程序和程序处理的数据。从物理上说,主存是由一组DRAM(动态随机访问存储器)芯片组成的。从逻辑上说,存储器是由一个线性的字节数组构成的,每个字节都有自己唯一的地址(数组索引),这些地址是从零开始的。一般来说,组成程序的每条机器指令都由不定量的字节构成。与C程序变量相对应的数据项的大小是根据类型变化的。例如,在运行Linux的Intel机器上,short类型的数据需要2个字节,int、float和long类型则需要4个字节,而double类型则需要8个字节。

4.处理器

中央处理单元(CPU)简称处理器,是解释(或执行)存储在主存中的指令的引擎。处理器的核心是一个称为程序计数器(PC)的字长大小的存储设备,即寄存器(Register)。在任何一个时间点上,PC都指向主存中的某条机器语言指令(内含其地址)。

从系统通电开始,直到系统断电,处理器一直在不假思索地重复执行相同的基本任务:从程序计数器(PC)指向的存储器处读取指令,解释指令中的位,执行指令指示的简单操作(Operation),然后更新程序计数器,使它指向下一条指令,而这条指令并不一定和存储器中刚刚执行的指令相邻。

这样的简单操作的数目并不多,它们在主存、寄存器组(Register File)和算术逻辑单元(ALU)之间循环。寄存器组是一个小的存储设备,由一些字长大小的寄存器组成,这些寄存器每个都有唯一的名字。ALU计算新的数据和地址值。下面是一些简单操作的例子,CPU在指令的要求下可能会执行这些操作。

(1)加载:从主存复制一个字节或一个字到寄存器,覆盖寄存器原来的内容。

(2)存储:从寄存器复制一个字节或一个字到主存的某个位置,覆盖这个位置上原来的内容。

(3)更新:复制两个寄存器的内容到ALU,ALU将两个字相加,并将结果存放到一个寄存器中,覆盖该寄存器中原来的内容。

(4)I/O读:从一个I/O设备中复制一个字节或一个字到一个寄存器。

(5)I/O写:从一个寄存器中复制一个字节或一个字到一个I/O设备。

(6)跳转:从指令本身抽取一个字,并将这个字复制到程序计数器(PC)中,覆盖PC中原来的值。

2.1.2 执行hello程序

通过对系统的硬件组成和操作的简单学习,我们可以开始了解运行示例程序时发生了什么。在这里必须忽略很多细节,稍后会做一些补充,但是现在我们将很满意于这种粗略的描述。

首先,shell程序执行它的指令,等待用户输入命令。当用户通过键盘输入字符串“./hello”后,shell程序就逐一读取字符到寄存器,再把它存放到存储器中。从键盘上读取hello命令如图2.2所示。

图2.2 从键盘上读取hello命令

在键盘上按下回车键时,shell就知道已经结束了命令的输入。然后shell执行一系列指令,这些指令将hello目标文件中的代码和数据从磁盘复制到主存,从而加载hello文件。数据包括最终会被输出的字符串“hello,world\n”。

利用称为DMA(直接存储器访问)的技术,数据可以不通过处理器而直接从磁盘到达主存。从磁盘加载可执行文件到主存如图2.3所示。

一旦hello目标文件中的代码和数据被加载到了存储器,处理器就开始执行hello程序的主程序中的机器语言指令。这些指令将“hello,world\n”字符串中的字节从存储器中复制到寄存器组,再从寄存器中把文件复制到显示设备,最终显示在屏幕上。从存储器写输出串到显示器如图2.4所示。

图2.3 从磁盘加载可执行文件到主存

图2.4 从存储器写输出串到显示器

2.1.3 高速缓存

通过这个简单的示例我们了解到重要的一点,那就是系统花费了大量的时间把信息从一个地方挪到另一个地方。hello程序的机器指令最初是存放在磁盘上的;当程序加载时,它被复制到主存;当处理器运行程序时,指令又从主存复制到处理器。相似地,数据串“hello,world\n”开始时在磁盘上,接着被复制到主存,然后从主存复制到显示设备。从一个程序员的角度来看,大量的复制减慢了程序的实际工作速度。因此,系统设计者的一个主要目标就是使这些复制操作尽可能地快。

根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于低速同类设备。例如,一个典型系统上的磁盘驱动器可能比主存大100 倍,但是对处理器而言,从磁盘驱动器上读取一个字的时间开销要比从主存中读取的开销大1000万倍。

类似地,一个典型的寄存器组只存储几百字节的信息,与此相反,主存里可存放几百万字节。然而,处理器从寄存器组中读取数据比从主存中读取要快几乎100 倍。更麻烦的是,随着这些年半导体技术的进步,这种处理器与主存之间的速度差距(Processor Memory Gap)还在持续增大。加快处理器的运行速度比加快主存的处理速度要容易和便宜得多。

针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓冲存储器(Cache Memory,简称高速缓存),它们被用来作为暂时的集结区域,存放处理器在不久的将来可能需要的信息。图2.5 展示了一个典型系统中的高速缓冲存储器。位于处理器芯片上的L1 高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器组一样快。一个容量为数十万到数百万的更大的L2 高速缓存是通过一条特殊的总线连接到处理器的。进程访问L2的时间开销要比访问L1的开销大5倍,但是这仍然比访问主存的时间快5~10 倍。L1 和L2 高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的。

应用程序开发人员通过理解高速缓冲存储器的机理,能够利用这些知识极大地提高程序的性能。

图2.5 高速缓冲存储器

2.1.4 层次结构的存储设备

在处理器和—个较大、较慢的设备(如主存储器)之间插入一个较小、较快的存储设备(如高速缓冲存储器)的想法成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被组织成一个存储器层次结构(Memory Hierarchy),就像图2.6所展示的那样。在这个层次结构中,从上至下,设备变得更慢、更大,并且每字节的造价也更便宜。寄存器组在层次结构中位于最顶部,也就是第0 级或记为L0;L1 高速缓存处在第一层(所以称为L1);L2高速缓存占据第二层;主存在第三层;以此类推。

存储器分层结构的主要思想是某个层次上的存储器可作为下一层次上的存储器的高速缓存。因此,寄存器组可作为L1的高速缓存,而L1又可作为L2的高速缓存,L2可作为主存的高速缓存,主存可作为磁盘的高速缓存。在某些带分布式文件系统的网络系统中,本地磁盘可作为存储在其他系统中的磁盘上的数据的高速缓存。

图2.6 一个存储器层次结构的示例

就像程序员可以运用L1和L2的知识来提高程序的性能一样,程序员同样可以利用对整个存储器层次结构的理解来提高程序的性能。

2.1.5 操作系统管理硬件

让我们回到hello程序的例子。当shell加载和运行hello程序时,当hello程序输出自己的消息时,程序没有直接访问键盘、显示器、磁盘或主存储器。取而代之的是,它们依靠操作系统(Operating System)提供的服务。我们可以把操作系统看成是在应用程序和硬件之间插入的一层软件。计算机系统的分层视图如图2.7所示。所有应用程序对硬件的操作尝试都必须通过操作系统进行。

图2.7 计算机系统的分层视图

操作系统有两个主要用途:一是防止硬件被失控的应用程序滥用;二是在控制复杂而通常又颇具差异的低级硬件设备方面,为应用程序提供简单一致的方法。操作系统通过如图2.8所示的几个基本的抽象概念(进程、虚拟存储器和文件)实现这两个用途。如图2.8所示,文件是对I/O设备的抽象表示,虚拟存储器是对主存储器和磁盘I/O设备的抽象表示,进程则是对处理器、主存储器和I/O设备的抽象表示。下面将依次讨论每种抽象表示。

图2.8 操作系统提供的抽象表示

1.进程

像hello这样的程序在现代计算机系统上运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去独占地使用处理器、主存储器(主存)和I/O设备,而处理器看上去就好像在不间断地一条接一条地执行该程序中的指令。该程序的代码和数据就好像是系统存储器中唯一的对象。这些假象是通过进程的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。

进程是操作系统对运行的程序的一种抽象。在一个计算机系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件,我们称之为并发运行,实际上,一个进程的指令和另一个进程的指令是交错执行的。操作系统中实现这种交错执行功能的机制称为环境切换(Context Switching)。

操作系统保存进程运行所需的所有状态信息。这种状态,也就是环境(Context),包括许多信息,如PC和寄存器组的当前值及主存的内容。在任何一个时刻,系统上都只有一个进程正在运行。当操作系统决定把控制权从当前进程转移给某个新进程时,它就会进行环境切换,即保存当前进程的环境、恢复新进程的环境,然后将控制权转移给新进程。新进程就会从它上次停止的地方开始执行。图2.9展示了示例hello运行的基本场景中进程的环境切换。

实现进程这个抽象概念需要低级硬件和操作系统软件的紧密合作。

进程这个抽象概念还暗示着由于不同的进程交错执行,打乱了时间的概念,使得程序员很难获得运行时间的准确和可重复测量。现代计算机系统建立了各种时间概念,并支持用来获得准确测量值的技术。

图2.9 进程的环境切换

2.线程

尽管通常认为一个进程只有单一的控制流,但是在现代计算机系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的环境中,并共享同样的代码和全局数据。由于网络服务器中对并行处理的要求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般情况下都比进程更高效。

3.虚拟存储器

虚拟存储器是一个抽象概念,它为每个进程提供了一个假象,好像每个进程都在独占地使用主存。每个进程看到的存储器都是一致的,称之为虚拟地址空间。图2.10所示的是Linux进程的虚拟地址空间(其他UNIX系统的设计也与此类似)。在Linux系统中,最上面的四分之一的地址空间是预留给操作系统中的代码和数据的,这对所有进程都一样。底部的四分之三的地址空间用来存放用户进程定义的代码和数据。请注意:图中的地址是从下往上增大的。

图2.10 Linux进程的虚拟地址空间

每个进程看到的虚拟地址空间由大量准确定义的区(Area)构成,每个区都有专门的功能。让我们简单地看一看每个区,从最低的地址开始,逐步向上研究将是非常有益的。

(1)程序代码和数据:代码是从同一固定地址开始的,紧接着的是和C全局变量相对应的数据区。代码和数据区是由可执行目标文件直接初始化的,在我们的示例中就是可执行文件hello。

(2)堆:代码和数据区后紧随着的是运行时堆。代码和数据区是在进程开始运行时就被指定了大小的,与此不同,作为调用像malloc和free这样的C标准库函数的结果,堆可以在运行时动态地扩展和收缩。虚拟存储器管理对堆有详细的研究。

(3)共享库:在地址空间的中间附近是一块用来存放像C标准库和数学库这样的共享库的代码和数据的区域。共享库的概念非常强大,但是也是个相当难懂的概念。共享库借助动态链接技术进行工作。

(4)栈:位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别地,每调用一个函数,栈就会增长;每次从函数返回时,栈就会收缩。编译器确定栈的使用方法。

(5)内核虚拟存储器:内核是操作系统中总是驻留在存储器中的部分。地址空间顶部的四分之一部分是为内核预留的。应用程序不允许读/写这个区域的内容或直接调用内核代码定义的函数。

虚拟存储器的运作需要硬件和操作系统软件间的精密复杂的互相合作,包括对处理器生成的每个地址的硬件翻译。基本思想是把一个进程虚拟存储器的内容存储在磁盘上,然后把主存作为磁盘的高速缓存。

4.文件

文件就是字节序列。每个I/O设备,包括磁盘、键盘、显示器,甚至网络,都可以被看成是文件。在UNIX系统中,所有的输入/输出都是通过使用称为UNIX I/O的一小组系统调用函数读/写文件来实现的。

文件这个简单而精致的概念是非常强大的,因为它使得应用程序能够统一地看待系统中可能含有的所有各式各样的I/O设备。例如,处理磁盘文件内容的应用程序员无须了解具体的磁盘技术。进一步说,同一个程序可以在使用不同磁盘技术的不同系统上运行。

2.1.6 通过网络与其他系统通信

在前面的系统漫游过程中,一直把计算机系统视为一个孤立的硬件和软件的集合体。实际上,现代计算机系统经常是通过网络和其他系统连接在一起的。从一个单独的系统来看,网络可被视为又一个I/O设备,如图2.11所示。当系统从主存复制一串字符到网络适配器时,数据流经过网络到达另一台机器,而不是到达本地磁盘驱动器。相似地,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。

随着像因特网这样的全球网络的出现,从一台主机复制信息到另外一台主机已经成为计算机系统最重要的用途之一。例如,像电子邮件、即时消息传送、万维网、FTP和telnet这样的应用都是基于通过网络复制信息的功能的。

回到hello示例,可以使用熟悉的telnet应用在一个远程主机上运行hello程序。假设用本地主机上的telnet客户端连接远程主机上的telnet服务器。在登录到远程主机并运行shell后,远端的shell就在等待接收输入的命令。从这点上来看,在远端运行hello程序包括如图2.12所示的五个基本步骤。

图2.11 网络也是一种I/O设备

图2.12 利用telnet跨越网络远程运行hello程序

当在telnet客户端输入“hello”字符串并按下回车键后,客户端软件就会将这个字符串发送到telnet的服务器。telnet服务器从网络上接收到这个字符串之后,会把它传递给远端的shell程序。接下来,远端的shell运行hello程序,并将输出行返回给telnet服务器。最后,telnet服务器通过网络把输出串转发给telnet客户端,客户端就将输出串输出到本地终端上。

这种在客户端和服务器之间交互的类型在所有的网络应用中都是非常典型的。