通过跟踪hello
程序的生命周期来对系统进行学习
$ vim hello.c
#include <stdio.h>
int main(void)
{
printf("hello, world!\n");
return 0;
}
信息就是“位+上下文”
hello
程序的生命周期是从一个源文件开始,也就是程序员利用编辑器创建并保存的文本文件,源程序实际是由值0和1组成的位序列,8位(bit)组成一组称为一个字节(byte),每个字节表示程序中某个文本字符。
大部分现代系统都使用ASCII标准来表示字符,也就是用一个唯一的单字节大小的整数值来表示每个字符。
hello.c
程序以字节序列的方式存储在文件中,每个字节都有一个整数值,而该整数值对应于某个字符。
例如:
- 第1个字节:整数值是35,对应的字符是
#
。 - 第2个字节:整数值是105,对应的字符是
i
。
...
注意:每个文本上都是以一个不可见的换行符\n
来结束的,它所对应的整数值为10。
类似hello.c
这样只由ASCII字符构成的文件成为文本文件,所有其他文件都称为二进制文件。
hello.c
的表示方法说明了一个基本思想:
系统中所有的信息,包括磁盘文件、存储器中的程序、存储器中存放的用户数据、网络上传输的数据等等,都是由一串位表示的。区分不同数据对象的唯一方法是读取数据对象时的上下文。在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或是机器指令。
程序被其他程序翻译成不同的格式
hello
程序的生命周期是从一个高级语言C程序开始的,因为这种形式能够被人读懂。然后,为了在系统中运行hello
程序,每条C语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来,目标程序也称为可执行目标文件。
在UNIX系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:
$ gcc -o hello hello.c
GCC编译器驱动程序读取源文件hello.c
,并将它翻译为一个可执行目标文件hello
。翻译的过程可分为四个阶段,执行这四个阶段的程序分别为:预处理器、编译器、汇编器、链接器,四者一起构成了编译系统(compilation system)。
- 预处理阶段
预处理器cpp
根据字符#
开头的命令,修改原始的C程序,如hello.c
中的#include <stdio.h>
命令告诉预处理器去读取系统头文件stdio.h
的内容,并将它直接插入到程序文本中。结果就得到另外一个C程序,通常是以.i
作为文件扩展名。
- 编译阶段
编译器ccl
将文本文件hello.i
翻译成文本文件hello.s
,它包含一个汇编语言程序。
汇编语言程序中的每条语句都以一种标准的文本格式确切地描述一条低级机器语言指令。汇编语言为不同高级语言的不同编译器提供了通用的输出语言。例如C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。
- 汇编阶段
汇编器as
将hello.s
翻译成机器语言指令,把这写指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o
中。hello.o
文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。如果在文本编辑器中打开hello.o
文件将会看到一堆乱码。
- 链接阶段
hello
程序调用了printf
函数,printf
函数是每个C编译器都会提供的标准C库中的一个函数。printf
函数存在于一个名为printf.o
单独的预编译好的目标文件中,这个文件必须以某种方式合并到hello.o
程序中。
链接器ld
负责处理合并,结果就会得到hello
文件,hello
文件是一个可执行目标文件(可执行文件),可以被加载到内存中由系统执行。
处理器读取并解释存储在存储器中的指令
hello.c
源程序被编译系统翻译成可执行目标文件hello
,并存放在磁盘上。要在UNIX系统中运行可执行目标文件,可以将文件名输入到shell
的应用程序中:
$ ./hello
shell
是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令行。如果命令行的第一个单词不是内置的shell
命令,shell
会假设这是一个可执行文件的名字,会将它加载并运行这个文件。
系统的硬件组成
为了理解运行hello
程序时发生了什么,需要了解一个典型系统的硬件组织。
备注
- CPU:中央处理器
- ALU:算逻单元(算术/逻辑单元)
- PC:程序计数器
- USB:通用串行总线
- 总线
总线是贯穿整个系统的一组电子管道,它携带信息字节并负责在各个部件间传递。总线被设计成传送定长的字节块,也就是字(word)。字中的字节数即字长是一个基本系统参数,在各个系统中情况各不相同。
现代大多数机器字长是4字节32位、8字节64位,为了方便理解假设字长为4个字长并且每次只传送1个字。
- I/O设备
输入/输出(I/O)设备时系统与外界联系的通道,示例中包含4个I/O设备:作为用户输入的键盘和鼠标、作为用户输出的显示器、用于长期存储数据和程序的磁盘驱动器。
每个I/O设备都通过一个控制器或适配器与I/O总线相连,控制器和适配器之间的区别主要在于它们的封装方式。控制器是置于I/O设备本身的或是系统的主印刷电路板(主板)上的芯片组,而适配器则是一块插在主板插槽上的卡。它们都功能都是在I/O总线和I/O设备之间传递信息。
- 主存
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取储存器(DRAM)芯片组成。从逻辑上来说,存储器是一个线性的字节数组,每个字节都由唯一的地址(数组索引),这些地址是从0开始的。
一般来说,组成程序的每条机器指令都由不同数量的字节构成。与C程序变量相对应的数据项的大小是根据类型变化的。例如,在运行Linux的IA32机器上,short
类型的数据需要2个字节,int
、float
、long
类型需要4个字节,而double
类型需要8个字节。
- 处理器
中央处理器(CPU)简称处理器,是解释或执行存储在主存中指令的引擎,处理器的核心是一个字长的存储设备或寄存器称为程序计数器(PC)。在任何时刻PC都指向主存中的某条机器语言指令,即含有该条指令的地址。
从系统通电开始直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。处理器看上去是按照一个非常简单的指令执行模型来操作的,这个模型是由指令集结构决定的。在这个模型中,指令按照严格的顺序执行,而执行一条指令包含执行一系列的步骤。
处理器从程序计数器(PC)指向的存储器处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定与存储器中刚刚执行的指令相邻。
这样的简单操作并不多,而且操作时围绕着主存、寄存器文件(register file)、算术\逻辑单元(ALU)进行的。寄存器文件是一个小的存储设备,由一些1字节长的寄存器组成,每个寄存器都由唯一的名字。ALU计算新的数据和地址值。
一些简单操作中CPU在指令的要求下可能会执行以下操作
- 加载:把一个字节或一个字从主存复制到寄存器,以覆盖寄存器原来的内容。
- 存储:把一个字节或一个字从寄存器复制到主存的某个位置,以覆盖这个位置上原来的内容。
- 操作:把两个寄存器的内容复制到ALU,ALU对这两个字做算术操作,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
- 跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的值。
处理器看上去只是它的指令集结构的简单实现,但是实际上现代处理器使用了非常复杂的机制来加速程序的执行。因此,可以区分处理器为指令集结构和微体系结构:指令集结构描述的是每条机器代码指令的效果,微体系结构描述的是处理器实际上是如何实现的。
运行hello程序
初始时,shell
执行它的指令,等待输入命令。当在键盘上输入./hello
字符串后,shell
将字符逐一读入寄存器,再将其存放到存储器中。当在键盘上敲回车键时,shell
就知道已经结束了命令的输入,然后shell
执行一系列指令来加载可执行的hello
文件,将hello
目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串hello, world\n
。
可利用直接存储器存取(DMA)的技术,使数据可以不通过处理器而直接从磁盘到达主存。
一旦目标文件hello
中的代码和数据被加载到主存,处理器就开始执行hello
程序的main
程序中的机器语言指令。这些指令将hello,world\n
字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。
高速缓存至关重要
系统花费大量时间把信息从一个地方挪到另一个地方:hello
程序的机器指令最初是存放在磁盘上的,当程序加载时它们被复制到主存,当处理器运行程序时,指令又从主存复制到处理器。类似的,数据串hello, world\n
初始时在磁盘上,然后复制到主存,最后从主存上复制到显示设备。从程序的角度来看,这些复制就是开销,减缓了程序真正的工作。因此,系统设计者的一个主要目标就是使这些复制操作尽可能快的完成。
根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备。例如,典型系统上的磁盘驱动器可能比主存大1000倍,但对处理器而言,从磁盘驱动器读取一个字的时间开销要比从主存中读取的开销大1000w倍。
类似的,典型的寄存器文件只存储几百字节的信息,而主存可存放几十亿字节。然而,处理器从寄存器文件中读取数据的速度比从主存中读取几乎要快100倍。更麻烦的是,随着半导体技术的进步,这种处理器与主存之间的差距还在持续增大。加快处理器的运行速度比加快主存的运行速度要容易和便宜的多。
针对处理器与主存之间的差异,系统设计者采用更小、更快的存储设备,即高速缓存存储器(高速缓存)作为暂时集结区域,用来存放处理器近期可能会需要的信息。
位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。一个容量为数十万到数百万字节的更大的L2高速缓存通过一条特殊的总线连接到处理器。进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但仍然比访问主存的时间快5到10倍。L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的。比较新的、处理能力更强大的系统甚至有三级高速缓存:L1、L2、L3。系统可以获得一个很大的存储器,同时访问速度也很快,原因是利用高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能经常访问的数据的方法,大部分存储器操作都能在快速的高速缓存中完成。
存储设备形成层次结构
在处理器和一个又大又慢的设备如主存之间插入一个更小更快的存储设备如高速缓存的想法已经称为一个普遍的观念,实际上每个计算机系统的存储设备都被组织成一个存储层次结构。
在存储设备的层次结构中,从上到下设备访问速度变得越来越慢、容量越来越大,每个字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶端,也就是第0级L0
。
存储器层次结构的主要思想是高一层上的存储器作为低一层存储器的高速缓存,因此,寄存器文件就是L1的高速缓存,L1是L2的高速缓存,L2是L3的高速缓存,L3是主存的高速缓存,主存是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上数据的高速缓存。
操作系统管理硬件
当shell
加载并运行hello
程序后hello
程序输出自己的消息时,shell
和hello
程序都没有直接访问键盘、显示器、磁盘、主存。取而代之的是,它们依靠操作系统提供的服务。可以把操作系统看成是应用程序和硬件之间插入的一层软件。所有应用程序对硬件的操作尝试都必须通过操作系统。
操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂而大相径庭的低级硬件设备。
操作系统通过基本的抽象概念:进程、虚拟存储器、文件来实现这两个功能。
- 文件是对I/O设备的抽象表示
- 虚拟存储器是对主存和磁盘I/O设备的抽象表示
- 进程是对处理器、主存、I/O设备的抽象表示
进程
hello
程序在现代系统上运行时,操纵系统会提供一种假象,就好像系统上只有这个程序在运行,看上去只有这个程序在使用处理器、储存、I/O设备。处理器看上去就像在不间断地一条接着一条地执行程序中的指令,即该程序的代码和数据是系统存储器中唯一的对象。这些假象是通过进程的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。
进程是操作系统对一个正在运行的程序的一种抽象,在一个系统上可以同时运行多个进程,每个进程都好像是独占地使用硬件。而并发运行则是说一个进程的指令和另一个进程的指令是交错执行的。
在大多数系统中,需要运行的进程数量是多于可以运行它们的CPU个数的,传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执行多个程序。无论是在单核还是多核系统中,一个CPU看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息,这种状态就是上下文,它包含了需要信息。例如PC和寄存器文件的当前值以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,也就是保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从上次停止的地方开始。
场景中有两个并发的进程:shell
进程和hello
进程。最初,只有shell
进程在运行,即等待命令行上的输入。当让它运行hello
程序时,shell
通过调用一个专门的函数,即系统调用来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存shell
进程的上下文,并创建一个新的hello
进程以及上下文,然后将控制权传递给新的hello
进程。hello
进程终止后,操作系统恢复shell
进程的上下文,并将控制权传回给它,shell
进程将继续等待下一个命令行输入。
线程
尽管通常认为一个进程只有单一的控制流,但在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。
由于网络服务器对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更加高效。当由多处理器可用的时候,多线程也是一种使程序可以更快运行的方法。
虚拟存储器
虚拟存储器是一种抽象,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的是一致的存储器,称为虚拟地址空间。
在Linux中地址空间最上层的区域是为操作系统中的代码和数据保留的,这对所有进程来说都是一样的。地址空间的底部区域存放用户进程定义的代码和数据。
每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都由专门的功能。从最低的地址开始,逐步向上介绍。
- 程序代码和数据
对于所有的进程来说,代码是从同一固定地址开始,紧接着是和C全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的,在示例中就是可执行文件hello
。 - 堆
代码和数据区后紧随着的是运行时堆,代码和数据区是在进程一开始运行时就被规定了大小,与此不同,当调用如malloc
和free
这样的C标准库函数时,堆可以在运行时动态地扩展和收缩。 - 共享库
大约在地址空间的中间部分是一块用来存放类似C标准库和数学库这样共享库的代码和数据的区域。 - 栈
位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别是每次调用一个函数时,栈会增长。从一个函数返回时栈会收缩。 - 内核虚拟存储器
内核总是驻留在内存中,是操作系统的一部分。地址空间顶部的区域是为内核保留的,不允许应用程序读写这个区域的内容或直接调用内核代码定义的函数。
虚拟存储器的运作需要硬件和操作系统软件之间紧密复杂的交互,包括堆处理器生成的每个地址的硬件翻译,其基本思想是把一个进程虚拟存储器的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。
文件
文件就时字节序列仅此而已,每个I/O设备包括磁盘、键盘、显示器、网络都可以视为文件。系统中所有输入输出都时通过使用一小组称为UNIX I/O的系统函数调用读写文件来实现的。文件向应用程序提供了一个统一的视角,来看待系统中可能含有的所有各式各样的I/O设备。
系统之间利用网络通信
实际上现代系统经常通过网络和其他系统连接到一起,从一个单独的系统来看,网络可视为一个I/O设备。当系统从主存将一串字节复制到网络适配器时,数据流经过网络到达另一台机器,而不时其他地方,例如本地磁盘驱动器。相似的,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。
重要主题
并行和并发
数字计算机的整个历史中,有两个需求是驱动进步的持续动力:
- 我们想要计算机做的更多
- 我们想要计算机运行得更快
当处理器同时能够做更多的事情时,这两个因素都会改进。
- 并发(concurrency):是一个通用的概念,指一个同时具有多个活动的系统。
- 并行(parallelish):指的是用并发使一个系统运行得更快
并行可以在计算机系统的多个抽象层次上运用,在此按照系统层次结构中由高到低的顺序重点强调三个层次;
- 线程级并发
构建进程这个抽象使我们能够设计出同时执行多个程序的系统,这就导致了并发。使用线程甚至能够在一个而进程中执行多个控制流。
从20世纪60年代储器出现时间共享以来,计算机系统中就开始有了对并发执行的支持。传统意义上,这种并发执行只是模拟出来的,是通过一台计算机在它正在执行的进程间快速切换实现的,好像一个杂技演员保持多个球在空中飞舞。这种并发行时允许多个用户同时与系统交互。在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器来完成的,这种配置称为单处理器系统。
当构建一个由单操作系统内核控制的多处理器组成的系统时,就得到了一个多处理器系统。其实从20世纪80年代开始,在大规模的计算中更久采用了这种系统,直到最近随着多核处理器和超线程(hyperthreading)出现,这种系统才变得常见。
多核处理器是将多个CPU核集成到一个集成芯片上,在Intel Core i7处理器的组织结构中,微处理器芯片有4个CPU核,每个核都有自己的L1和L2高速缓存,但它们共享更高层次的高速缓存以及到主存的接口。
超线程又称为同时多线程(simultaneous multi-threading)是一项允许一个CPU执行多个控制流的技术,它涉及CPU某些硬件有多个备份,如程序计数器和寄存器文件,而其他的硬件部分只有一份,如执行浮点算数运算的单元。常规处理器需要20 000个时钟周期做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得CPU能够更好地利用它去处理资源。
假设一个线程必须等到某些数据被装在到高速缓存中,CPU才可以继续去执行另一个线程。Intel Core i7处理器可以让一个核执行两个线程,所以一个4核系统实际上可以并行地执行8个线程。
多处理器的使用可以从两个方面提高系统性能
- 首先,它减少了在执行多个任务时模拟并发的需要。即使是只有一个用户使用的个人计算机也需要并发地执行多个活动。
- 其次,它可以使应用程序运行得更快。当然,这必须要求程序是以多线程方式来书写的,这些线程可以并行地高速执行。
因此,虽然并发原理的形成和研究已经超过50年的时间,但直到多核和超线程系统的出现才极大地激发了人们的一种意愿,即找到书写应用程序的方法利用硬件开发线程级并行性。
2. 指令级并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。早期的微软处理器,如1978年的Intel8086需要多个(310)个时钟周期来执行一条指令。比较先进的处理器可以保持每个时钟周期24条条指令的执行速率。其实每条指令从开始到结束需要长的多的时间,大于20个或更多的周期,但是处理器使用了非常多的技巧来同时处理多达100条的指令。如果处理器可以达到比一个周期一条指令更快的执行效率就称为超标量(superscalar)处理器。
- 单指令、多数据并行
在最低层次上许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以合并执行的操作,称之为单指令多数据,即SIMD并行。
计算机系统中抽象的重要性
抽象的使用是计算机科学中最重要的概念之一,在处理器中指令集结构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像它运行在一个一次只执行一条指令的处理器上。底层的硬件比抽象描述的要复杂精细得多,它并行地执行多条指令,但又总是与那些简单有序得模型保持一致。只要执行模型一样,不同得处理器实现也能执行同样得机器代码,而又提供不同得开销和性能。