第一章计算机系统漫游
本章通过跟踪hello程序的生命周期来开始对系统的学习--从它被程序员创建开始,到在系统上运行,输出简单的信息,然后终止。我们将沿着这个程序的生命周期,简要地介绍一些逐步出现的关键概念、专业术语和组成部分。
1.1 信息就是位+上下文
hello程序的生命周期是从一个源程序开始的,即程序员通过编辑器创建并保存的文本文件,文件名是hello.c。源程序实际上就是一个由值0和1组成的位(又称为比特)序列,8个位被组织成一组,称为字节。
大部分的现代计算机系统都使用ASCII标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值印来表示每个字符。
hello.c程序是以字节序列的方式储存在文件中的。每个字节都有一个整数值,对应于某些字符。
hello.c的表示方法说明了一个基本思想:系统中所有的信息--包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。
1.2 程序被其他程序翻译成不同的格式
其实为了让程序最终在操作系统上运行,实际上就是设置一个人和计算机沟通的过程。人类可以理解的就是英文+十进制,计算机可以理解的就是二进制,也就是0和1,所以为了让程序可以运行,就需要经过把英文和数字转变为二进制的过程。
hello程序的生命周期是从一个高级C语言程序开始的,因为这种形式能够被人读懂。然而,为了在系统上运行hello.c程序,每条C语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。
在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:
linux> gcc -o hello hello.c
在这里,GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个翻译过程可分为四个阶段完成,预处理器->编译器->汇编器->链接器,执行这四个阶段的程序,一起称为编译系统。
编译系统:
- 预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,生成hello.i的文本文件。
- 编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.o,它包含一个汇编语言程序。该程序包含函数main的定义,如下所示
- 汇编阶段:接下来,汇编器(as)将hello.c翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果存放在目标文件hello.o中。该文件是一个二进制文件。
- 链接阶段:由于hello程序调用了printf函数,而该函数是一个标准C库中的函数,存在于一个名为printf.o的单独的预编译好的目标文件中,该过程就是把这个文件合并到hello.o中。结果就得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。
1.3 了解编译系统如何工作是大有益处的
对于像 hello.c这样简单的程序,我们可以依靠编译系统生成正确有效的机器代码。但是,有一些重要的原因促使程序员必须知道编译系统是如何工作的。
- 优化程序性能
- 理解链接时出现的错误
- 避免安全漏洞
1.4 处理器读并解释储存在内存中的指令
此刻,hello.c源程序已经被编译系统翻译成了可执行目标文件hello,并被存放在磁盘上。要想在 Unix系统上运行该可执行文件,我们将它的文件名输入到称为shell的应用程序中∶
linux> ./hello
hello.world
linux>
shell是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。
1.4.1 系统的硬件组成
为了理解运行hello程序时发生了什么,我们需要了解一个典型系统的硬件组织,如下图所示:
1.4.1.1 总线
携带信息字节并负责在各个部件见传递。
1.4.1.2 I/O设备
I/O(输入/输出)设备是系统与外部世界的联系通道。我们的示例系统包括四个I/O设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序的磁盘驱动器(磁盘)。最开始,可执行程序hello就存放在磁盘上。
1.4.1.3 主存
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
1.4.1.4 处理器
中央处理单元(CPU),简称处理器,是执行存储在主存中指令的引擎。处理器组成如下:
- PC:程序计数器,大小为一个字节的存储设备,在任何时刻,PC都指向主存中的某条机器语言指令
- 寄存器文件:是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字
- ALU:算术/逻辑单元,计算新的数据和地址值。
下面是一些简单操作的例子:
- 加载∶ 从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来的内容。
- 存储∶ 从寄存器复制一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容。
- 操作∶把两个寄存器的内容复制到ALU,ALU对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
- 跳转∶从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖 PC中原来的值。
1.4.2 运行hello程序
初始时,shell程序执行它的命令,等待我们输入一个命令,当我们在键盘上输入字符串“./hello”后,shell程序将字符逐一读入寄存器,再把它放到内存中,如图所示:
当我们在键盘上敲下回车时,shell程序就知道我们已经结束了命令的输入。然后shell执行一系列指令来加载可执行的hello文件,这些指令将hello目标文件中的代码和数据从磁盘复制到内存中。利用DMA技术,数据可以不通过处理器而直接从磁盘到达内存。这个步骤如下图所示:
一旦目标文件hello中的数据和代码被加载到主存中,处理器就开始执行hello程序的main程序中的机器语言指令。这些指令将"hello,world\n"字符串中的字节从主存复制到寄存器中,再从寄存器文件中复制到显示设备,最终显示在屏幕上。步骤如下图所示:
1.5 高速缓存至关重要
这个简单的示例揭示了一个重要的问题,即系统花费了大量的时间把信息从一个地方挪到另一个地方。hell6程序的机器指令最初是存放在磁盘上,当程序加载时,它们被复制到主存;当处理器运行程序时,指令又从主存复制到处理器。相似地,数据串"hel- 1o,world/n"开始时在磁盘上,然后被复制到主存,最后从主存上复制到显示设备。从程序员的角度来看,这些复制就是开销,减慢了程序"真正"的工作。因此,系统设计者的一个主要目标就是使这些复制操作尽可能快地完成。
根据机械设备原理,较大的存储设备要比较小的存储设备运行的慢,而快速设备的造价远高于同类的低速设备。比如说,从磁盘读取一个字节的时间开销要比从主存中读取的开销大1000万倍;处理器从寄存器文件读数据比从主存中读取几乎要快100万倍。
针对这种处理器与主存之间的差异,系统设计者设计出了高速缓存,作为暂时的集结区域。下图展示了一个典型的高速缓存存储器。
主要包括:
- L1高速缓存:容量可以达到数万字节,位于处理器芯片上,访问速度几乎和访问寄存器文件一样
- L2高速缓存:容量为数十万到数百万字节,通过一条特殊的总线连接到处理器,进程访问L2高速缓存的时间是L1的5倍
- L3高速缓存:最低级别的缓存,所有内核之间共享
本书得出的重要结论之一就是,意识到高速缓存存储器存在的应用程序员能够利用高速缓存将程序的性能提高一个数量级。
1.6 存储设备形成层次结构
在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如下图所示。在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价越来越便宜。
1.7 操作系统管理硬件
回到之前的示例程序,当shell加载和运行hello程序时,以及hello程序输出信息时,shell和hello程序都没有直接访问键盘、显示器、磁盘或者主存,而是由操作系统提供的服务进行访问。我们可以把操作系统看成是应用程序和硬件之间的抽象。
操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备
操作系统通过几个基本的抽象(进程、虚拟内存和文件)来实现这两个功能。
1.7.1 进程
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以运行多个进程,而每个进程都好像在独占地使用硬件。并发运行是指,一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多余可以运行它们的CPU个数的。先进的多核处理器同时能够执行多个程序。操作系统实现这种交错执行的机制称为上下文切换。
目前只讨论一个CPU的单核处理器系统的情况。
操作系统保持跟踪进程所需的所有状态信息,这种状态,称为上下文。包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单核CPU只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换。
示例场景中有两个并发的进程,即shell进程和hello进程。具体过程如下图所示:
从一个进程到另一个进程的转换,是由操作系统内核管理的。内核是操作系统代码常驻主存的部分。
1.7.2 线程
在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。
1.7.3 虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一种假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。
下图为Linux进程的虚拟地址空间。
主要包括(由最低到最高):
- 程序代码和数据:直接按照可执行目标文件的内容初始化的,示例中的hello文件
- 堆:代码和数据区在进程一开始运行时就被指定了大小
- 共享库:用来存放像C标准库和数学库这样的共享库的代码和数据的区域
- 栈:编译器用它来实现函数调用。每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩
- 内核虚拟内存:不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数
1.7.4 文件
文件就是字节序列。每个I/O设备,包括磁盘、键盘、显示器甚至网络,都可以看成是文件。
1.8 系统之间利用网络通信
现代系统经常通过网络和其他系统连接到一起。从一个单独的系统来看,网络可以看成是一个I/O设备,如下图所示,当系统从主存复制一串字节到网络适配器时,数据流经过网络到达另一台机器,而不是说到达本地磁盘驱动器。相似的,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。
回到hello示例,我们可以使用熟悉的telnet应用在一个远程主机上运行hello程序。假设用本地主机上的telnet客户端连接远程主机上的telnet服务器。在我们登录到远程主机并运行shell后,远端的shell就在等待接收输入命令,此后在远端运行hello程序包括以下五个基本步骤:
1.9 重要主题
系统不仅仅只是硬件,是硬件和系统软件互相交织的集合体。
1.9.1 Amdahl定律
主要观点为,要想显著加速整个系统,必须提升全系统中相当大的部分的速度。
1.9.2 并发和并行
数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算机做的更多;另一个是我们想要计算机运行的更快。当处理器能够同时做更多的事情时,这两个因素都会改进。
- 并发:指一个同时具有多个活动的系统
- 并行:指的是用并发来使一个系统运行的更快
1.9.2.1 线程级并发
构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了并发。使用线程,我们甚至能够在一个进程中执行多个控制流。
大多数实际的计算由一个处理器来完成的,称为单核处理器系统。
当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到了一个多处理器系统。下图是不同处理器的分类:
多核处理器是将多个CPU集成到一个集成电路芯片上,下图描述的是一个典型多核处理器的组织结构,其中微处理器芯片有4个CPU核,每个核都有自己的L1和L2高速缓存,其中的L1高速缓存分为两个部分--一个保存最近取到的指令,另一个放数据。
超线程,是运行一个CPU执行多个控制流的技术。
1.9.2.2 指令级并行
可以同时执行多条指令的属性称为指令级并行。早期的Intel 8086,需要多个时钟周期来执行一条指令。最近的处理器可以保持每个时钟周期2-4条指令的执行速率。
如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为超标量。
1.9.2.3 单指令、多数据并行
在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式为单指令。
1.9.3 计算机系统中抽象的重要性
文件是对I/O设备的抽象、虚拟内存是对程序存储器的抽象,而进程是对一个正在运行的程序的抽象,虚拟机提供对整个计算机的抽象。