CSAPP学习总结
CSAPP旨在阐述计算机系统的核心概念——为后续操作系统、编译原理、网络编程的深入学习作了很好的引入
现在就要开始一次有趣的漫游历程了。如果你全力投身学习本书中的概念,完全理解底层计 算机系统以及它对应用程序的影响,那么你将会逐渐成为凤毛麟角的“权威”程序员。
第一章 计算机系统漫游
跟踪程序的生命周期开始对系统的学习——创建、运行、输出。
以最简单的入门程序hello world为例跟踪其生命周期,会是一个很有趣的过程并会引入许多重要的系统概念。
信息就是位+上下文
-------------------------------- code/intro/hello. c
#include <stdio.h>
int main()
{
printf("hello, world\nH);
}
------------------------------- code/intro/hello. c
hello程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员利用编辑器创 、建并保存的文本文件,文件名是hello.co源程序实际上就是一个由值0和1组成的位(bit) 序列,8个位被组织成一组,称为字节。每个字节表示程序中某个文本字符。
大部分的现代系统都使用ASCII标准来表示文本字符,这种方式实际上就是用一个唯一的 单字节大小的整数值来表示每个字符。
hello.c的ASCII文本表示
每个文本行都是以一个不可见的换行符来结束的,它对应的整数值为10。像hello.c这样只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件。
hello.c的表示方法说明了一个基本的思想:系统中所有的信息一包括磁盘文件、存储器中的程序、存储器中存放的用户数据以及网络上传送的数据,都是由一串位表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文 比如,在不同的上下文中,一个同样 的字节序列可能表示一个整数、浮点数、字符串或者机器指令。
作为程序员,我们需要了解数字的机器表示方式,因为它们与实际的整数和实数是不同的。
编译系统
为了在系统上运行hello.c程序,每条C语句都必须被其他程序转化为一系列的低级机器 语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。
- 预处理阶段。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。hello.c 中第1行的#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函数,它是每个C编译器都会提供的标准C库中的一个函数。printf函数存在于一个名为printf. o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(Id)就 负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
GNU与Linux
Linux命令行
此刻,hello.c源程序已经被编译系统翻译成了可执行目标文件hello,并存放在磁盘 ±o要想在Unix系统上运行该可执行文件,我们将它的文件名输入到称为外壳(shell)的应 用程序中:
unix> ./hello
hello, world
unix>
外壳是一个命令行解释器,它输出一个提示符,等待你输入一个命令行,然后执行这个命令。如果该命令行的第一个单词不是一个内置的外壳命令,那么外壳就会假设这是一个可执行文件的名字,它将加载并运行这个文件。所以在此例中,外壳将加载并运行hello。程序,然后等 待程序终止hello。程序在屏幕上输出它的信息,然后终止。外壳随后输出一个提示符,等待 下一个输入的命令行。
详见配套实验SHLAB
系统的硬件组成
-
典型的冯诺依曼体系结构——冯·诺依曼结构的处理器使用同一个存储器,经由同一个总线传输。
-
哈佛结构是一种将程序指令存储和数据存储分开的存储器结构。
与冯.诺曼结构处理器比较,哈佛结构处理器有两个明显的特点: (1)使用两个独立的存储器模块,分别存储指令和数据,每个存储模块都不允许指令和数据并存; (2)使用独立的两条总线,分别作为CPU与每个存储器之间的专用通信路径,而这两条总线之间毫无关联。 后来,又提出了改进的哈佛结构(混合结构),其结构特点为: (1)使用两个独立的存储器模块,分别存储指令和数据,每个存储模块都不允许指令和数据并存; (2)具有一条独立的地址总线和一条独立的数据总线,利用公用地址总线访问两个存储模块(程序存储模块和数据存储模块),公用数据总线则被用来完成程序存储模块或数据存储模块与CPU之间的数据传输; (3)两条总线由程序存储器和数据存储器分时共用。
然后外壳执行一 系列指令来加载可执行的文件,将hello目标文件中的代码和数据从磁盘复制到主存。 数据包括最终会被输出的字符串
利用直接存储器存取(DMA,将在第6章讨论)的技术,数据可以不通过处理器而直接从 磁盘到达主存
一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的 main程序中的机器语言指令。这些指令将字符串中的字节从主存复制到 寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。
强调文本 强调文本
加粗文本 加粗文本
标记文本
删除文本
引用文本
H2O is是液体。
210 运算结果是 1024.
高速缓存
根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同 类的低速设备。例如,一个典型系统上的磁盘驱动器可能比主存大1000倍,但是对处理器而言, 从磁盘驱动器上读取一个字的时间开销要比从主存中读取的开销大1000万倍。
针对这种处理器与主存之间的差异,系统设计者采用了更小、更快的存储设备,即高速缓存存储器(简称高速缓存),作为暂时的集结区域,用来存放处理器近期可能会需要的信息使得处理器读取信息时访问速度加快。
高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的。比较新的处理能力更强大的系统甚至有三级高速缓存:LI、L2和L3。系统可以获得一个很大的存储器,同时访问速度也很快,原因是 利用了高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓 存里存放可能经常访问的数据的方法,大部分的存储器操作都能在快速的高速缓存中完成。
存储层次
存储器层次结构的主要思想是一层上的存储器作为低一层存储器的高速缓存。
操作系统管理硬件
当外壳加载和运行程序,以及程序输岀自己的消息时,外壳和hello程序都没有直接访问键盘、显示器、磁盘或者主存。取而代之的是,它们依靠操作系统提供的服务 我们可以把操作系统看成是应用程序和硬件之间插入的一层软件,所有应用程序对硬件的操作尝 试都必须通过操作系统。
操作系统有两个基本功能:
1)防止硬件被失控的应用程序滥用。
2)向应用程序提供简单一致的机制来控制复杂而又 通常大相径庭的低级硬件设备。
操作系统通过几个基本的抽象概念(进程、虚拟存储器和文件)来实现这两个功能。文件是对I/O设备的抽象表示,虚拟存储器是对主存和磁盘I/O设备的抽象表示,进程则是对处理器、主存和I/O设备的抽 象表示。
进程
- 程序在现代系统上运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行,看上去只有这个程序在使用处理器、主存和I/O设备。处理器看上去就像在不
间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统存储器中唯一的对象。这些假象是通过进程的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程, 而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的CPU个数的。传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执行多个程序。无论是在单核 还是多核系统中,一个CPU看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,它包括许多信 息,例如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能 执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上 下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从上次停止的地方开始。图1-12展示了示例hello程序运行场景的基本理念。
示例场景中有两个并发的进程:外壳进程和hello进程。起初,只有外壳进程在运行,即等待命令行上的输入。当我们让它运行hello程序时,外壳通过调用一个专门的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存外壳进程的上下 文,创建一个新的hello进程及其上下文,然后将控制权传递给新的hello进程。hello进程终止后,操作系统恢复外壳进程的上下文,并将控制权传回给它,外壳进程将继续等待一个命令行输入。
线程
尽管通常我们认为一个进程只有单一的控制流,但是在现代系统中,一个进程实际上可以由 多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。由于网络服务器对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多 进程之间更容易共享数据,也因为线程一般来说都比进程更高效。当有多处理器可用的时候,多线程也是一种使程序可以更快运行的方法——并发的基本概念,以及如何写线程化的程序。
虚拟存储器
每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。我们是从最低的地址开始,逐步向上介绍。
- 程序代码和数据。对于所有的进程来说,代码是从同一固定地址开始,紧接着的是和C全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的,在示例中就是可执行文件hello。第7章我们研究链接和加载,你将会学习到更多有关地址空间的内容。
- •堆。代码和数据区后紧随着的是运行时堆。代码和数据区是在进程一开始运行时就被规定 了大小,与此不同,当调用如malloc和free这样的C标准库函数时,堆可以在运行时动态地扩展和收缩。第9章学习管理虚拟存储器时,我们将更详细地研究堆。
- 共享库。大约在地址空间旳中间部分是一块用来存放像C标准库和数学库这样共享库的代码和数据的区域。共享库的概念非常强大,也相当难懂。第7章介绍动态链接时,我们将 学习共享库是如何工作的。
- 栈。位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别是每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。在第3章,你将学习编译器是如何使用栈的。
- •内核虚拟存储器。内核总是驻留在内存中,是操作系统的一部分。地址空间顶部的区域是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。
虚拟存储器的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每个地址的硬件翻译。其基本思想是把一个进程虚拟存储器的内容存储在磁盘上,然后用主存作为磁 盘的高速缓存。第9章将解释虚拟存储器如何工作,以及它为什么对现代系统的运行如此重要。
文件
文件就是字节序列,仅此而已。每个I/O设备,包括磁盘、键盘、显示器,甚至网络,都可以视为文件。系统中的所有输入输出都是逋过使用一小组称为Unix一I/O的系统函数调用读写文件来实现的。
文件这个简单而精致的概念其内涵是非常丰富的,因为它向应用程序提供了一个统一的视 角,来看待系统中可能含有的所有各式各样的I/O设备。例如,处理磁盘文件内容的应用程序员非常幸福,因为他们无需了解具体的磁盘技术。进一步说,同一个程序可以在使用不同磁盘技术 的不同系统上运行。第10章将介绍Unix I/O。
系统之间利用网络进行通信
系统漫游至此,我们一直是把系统视为一个孤立的硬件和软件的集合体。实际上,现代系统经常通过网络和其他系统连接到一起。从一个单独的系统来看,网络可视为一个I/O设备,如图1.14所示。当系统从主存将一串字节复制到网络适配器时,数据流经过网络到达另一台机器, 而不是其他地方,例如本地磁盘驱动器。相似地,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。
随着Internet这样的全球网络的出现,将一台主机的信息复制到另外一台主机已经成为计算 机系统最重要的用途之一。例如,电子邮件、即时通信、万维网、FTP和telnet这样的应用都是基于网络复制信息的功能。
继续讨论我们的hello示例,我们可以使用熟悉的telnet应用在一个远程主机上运行 hello程序。假设用本地主机上的telnet客户端连接远程主机上的telnet服务器。在我们登录到远程主机并运行外壳后,远端的外壳就在等待接收输入命令。从这点开始,远程运行hello。程序包括(如图1.15所示)的五个基本步骤。
当我们在telnet客户端键入"hello"字符串并敲下回车键后,客户端软件就会将这个字符串发送到telnet的服务器。telnet服务器从网络上接收到这个字符串后,会把它传递给远端外壳程序。接下来,远端外壳运行hello程序,并将输出行返回给telnet服务器。最后,telnet服务 器通过网络把输出串转发给telnet客户端,客户端就将输出串输出到我们的本地终端上。
这种客户端和服务器之间交互的类型在所有的网络应用中是非常典型的。在第11章,你将学到如何构造网络应用程序,并利用这些知识创建一个简单的Web服务器。
重要主题
数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算机做得更多,另一个是我们想要计算机运行得更快。当处理器同时能够做更多事情时,这两个因素都会改进。我们用的术语并发(concurrency)是一个通用的概念,指一个同时具有多个活动的系统; 而术语并行(parallelism)指的是用并发使一个系统运行得更快。并行可以在计算机系统的多个 抽象层次上运用。在此,我们按照系统层次结构中由高到低的顺序重点强调三个层次。
- 线程级并发
构建进程这个抽象,我们能够设计出同时执行多个程序的系统,这就导致了并发。使用线程,我们甚至能够在一个进程中执行多个控制流。从20世纪60年代初期出现时间共享以来,计算机系统中就开始有了对并发执行的支持。传统意义上,这种并发执行只是模拟出来的,是通过使一台计算机在它正在执行的进程间快速切换的方式实现的,就好像一个杂技演员保持多个球在空中飞舞。这种并发形式允许多个用户同时与系统交互,例如,当许多人想要从一个Web服务器获取页面时。它还允许一个用户同时从事多个任务,例如,在一个窗口中开启Web浏览器, 在另一窗口中运行字处理器,同时又播放音乐。在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器来完成的。这种配置称为单处理器系统。
当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到了一个多处理器系统。 其实从20世纪80年代开始,在大规模的计算中就 采用了这种系统,但是直到最近,随着多核处理器和超线程(hyperthreading)的出现,这种系统才变得常见。图1.16列出了这些不同处理器类型的分类。
多核处理器是将多个CPU (称为“核”)集成到一个集成电路芯片上。图1.17描述的是Intel Corei7处理器的组织结构,其中微处理器芯片有4 个CPU核,每个核都有自己的L1和L2高速缓存, 但是它们共享更高层次的高速缓存,以及到主存的接口。工业界的专家预言他们能够将几十个、 最终会是上百个核做到一个芯片上。
超线程,有时称为同时多线程(simultaneous multi-threading),是一项允许一个CPU执行多个控制流的技术。它涉及CPU某些硬件有多个备份,比如程序计数器和寄存器文件;而其他的硬件部分只有一份,比如执行浮点算术运算的单元。常规的处理器需要大约20000个时钟周期 做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得CPU能够更好地利用它的处理资源。例如,假设一个线程必须等到某些数据被装载到高速缓存中,那CPU就可以继续去执行另一个线程。举例来说,Intel Core i7处理器可以让一个核执行两个线程,所以一个4核的系统实际上可以并行地执行8个线程。
多处理器的使用可以从两个方面提高系统性能。
- 首先,它减少了在执行多个任务时模拟并发的需要。正如前面提到的,即使是只有一个用户使用的个人计算机也需要并发地执行多个活动。
- 其次,它可以使应用程序运行得更快。当然,这必须要求程序是以多线程方式来书写的,这些线程可以并行地高效执行。因此,虽然并发原理的形成和研究已经超过50年的时间了,但是直到多核和超线程系统的出现才极大地激发了人们的一种愿望,即找到书写应用程序的方法利用硬件开发线程级并行性。第12章将更深入地探讨并发,以及使用并发来提供处理器资源的共享,使得程序的执行允许有更多的并行。
- 指令级并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。早期的微 处理器,如1978年的Intel 8086,需要多个(通常是3〜10个)时钟周期来执行一条指令。比较先进的处理器可以保持每个时钟周期2〜4条指令的执行速率。其实每条指令从开始到结束需要长得多的时间,大约20个或者更多的周期,但是处理器使用了非常多的聪明技巧来同时处理多达100条的指令。在第4章,我们将研究流水线(pipelining)的使用。在流水线中,将执行 一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行 一个步骤。这些阶段可以并行地操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近于一个时钟周期一条指令的执行速率。
如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为超标量(superscalar) 处理器。大多数现代处理器都支持超标量操作。第5章,将介绍超标量处理器的高级模型。应用程序员可以用这个模型来理解他们程序的性能。然后,他们就能写出拥有更高程度的指令级并行性的程序代码,因而也运行得更快。 - 单指令、多数据并行
在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操 作,这种方式称为单指令、多数据,即SIMD并行。例如,较新的Intel和AMD处理器都具有并行地对4对单精度浮点数(C数据类型float)做加法的指令。
提供这些SIMD指令多是为了提高处理影像、声音和视频数据应用的执行速度。虽然有些编 译器试图从C程序中自动抽取SIMD并行性,但是更可靠的方法是使用编译器支持的特殊向量数据类型来写程序,例如GCC就支持向量数据类型。作为对比较通用的程序优化讲述的补充, 在网络旁注OPT:SIMD中描述了这种编程方式。
链接: link.
计算机系统中抽象的重要性
抽象的使用是计算机科学中最为重要的概念之一。例如,为一组函数规定一个简单的应用程序接口(API)就是一个很好的编程习惯,程序员无需了解它内部的工作便可以使用这些 代码。不同的编程语言提供不同形式和等级的抽象支持,例如Java类的声明和C语言的函数原型。
我们已经介绍了计算机系统中使用的几个抽象,如图1.18所示。在处理器里,指令集结构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像它是运行在一个一 次只执行一条指令的处理器上。底层的硬件比抽象描述的要复杂精细得多,它并行地执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。
在学习操作系统时,我们介绍了三个抽象:文件是对I/O的抽象,虚拟存储器是对程序存储器的抽象,而进程是对一个正在运行的程序的抽象。
我们再增加一个新的抽象:虚拟机,它提供对整个计算机(包括操作系统、处理器和程序的抽象。虚拟机的思想是IBM在20世纪60年代提出来的,但是最近才显示出其管理计算机方式上的优势,因为一些计算机必须能够运行为不同操作系统(例如,Microsoft Windows. MacOS Linux)或同一操作系统的不同版本而设计的程序。
在本书后续的章节中,我们会具体介绍这些抽象。
小结
计算机系统是由硬件和系统软件组成的,它们共同协作以运行应用程序。计算机内部的信息 被表示为一组组的位,它们依据上下文有不同的解释方式。程序被其他程序翻译成不同的形式, 开始时是ASCII文本,然后被编译器和链接器翻译成二进制可执行文件。
处理器读取并解释存放在主存里的二进制指令。因为计算机把大量的时间用于存储器、I/O 设备和CPU寄存器之间复制数据,所以将系统中的存储设备划分成层次结构——CPU寄存器在 顶部,接着是多层的硬件高速缓存存储器、DRAM主存和磁盘存储器。在层次模型中,位于更高层的存储设备比低层的存储设备要更快,单位比特开销也更高。层次结构中较高层次存储设备 可以作为较低层次设备的高速缓存。通过理解和运用这种存储层次结构的知识,程序员可以优化 C程序的性能。
操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象:1)文件是对I/O 设备的抽象;2)虚拟存储器是对主存和磁盘的抽象;3)进程是对处理器、主存和I/O设备的 抽象。
最后,网络提供了计算机系统之间通信的手段。从特殊系统的角度来看,网络就是一种I/O 设备。