《深入理解计算机系统》小结

Administrator 226 2023-03-19

汇总整理每一章最后的小结

第 1 章 计算机系统漫游

计算机系统是由硬件和软件系统组成的,它们共同协作以运行应用程序。计算机内部的信息被表示为一组组的位,它们依据上下文有不同的解释方式。程序被其他程序翻译成不同的形式,开始时是ASCII文本,然后被编译器和链接器翻译成二进制可执行文件。

处理器读取并解释存放在主存里的二进制指令。因为计算机花费了大量的时间在内存、I/O设备和CPU寄存器之间复制数据,所以将系统中的存储设备划分成层次结构——CPU寄存器在顶部,接着是多层的硬件高速缓存存储器、DRAM主存和磁盘存储器。在层次模型中,位于更高层的存储设备比低层的存储设备要更快,单位比特造价也更高。层次结构中较高层次的存储设备可以作为较低层次设备的高速缓存。通过理解和运用这种存储层次结构的知识,程序员可以优化C程序的性能。

操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象:1)文件是对I/O设备的抽象;2)虚拟内存是对主存和磁盘的抽象;3)进程是处理器、主存和I/O设备的抽象。

最后,网络提供了计算机系统之间的通信的手段。从特殊系统的角度来看,网络就是一种I/O设备。

第 2 章 信息的表示和处理

计算机将信息编码为位(比特),通常组织成字节序列。有不同的编码方式用来表示整数、实数和字符串。不同的计算机模型在编码数字和多字节数据中的字节顺序时使用不同的约定。

C语言的设计可以包容多种不同字长和数字编码的实现。64位字长的机器逐渐普及,并正在取代统治市场达30多年的32位机器。由于64位机器也可以运行32位机器编译的程序,我们的重点就放在区分32位和64位程序,而不是机器本身。64位程序的优势是可以突破32位程序具有的4GB地址限制。

大多数机器对整数使用补码编码,而对浮点数使用 IEEE标准754编码。在位级上理解这些编码,并且理解算数运算的数学特性,对于想使用编写的程序能在全部数值范围上正确运算的程序员来说,是很重要的。

在相同长度的无符号和有符号之间进行强制类型转换时,大多数C语言实现遵循的原始是低层的位模式不变。在补码机器上,对于一个ww位的值,这种行为是由函数T2UwT2U_wU2TwU2T_w来描述的。C语言隐式的强制类型转换会出现许多程序员无法预计的结果,常常导致程序错误。

由于编码的长度有限,与传统整数和实数运算相比,计算机的运算具有非常不同的属性。当超出表示范围时,有限长度能够引起数值溢出。当浮点数非常接近于0.0,从而转换成零时,也会下溢。

和大多数其他程序语言一样,C语言实现的有限整数运算和真实的整数运算相比,有一些特殊的属性,例如:由于溢出,表达式xxx*x能够得出负数。但是无符号数和补码的运算都满足整数运算的许多其他属性,包括结合律、分配律和交换律。这就允许编译器做很多的优化。例如:用(x<<3)x(x<<3)-x取代表达式7x7*x时,我们就利用了结合律、分配律和交换律的属性,还利用了位移和乘以2的幂之间的关系。

我们已经看到了几种使用位级运算和算术运算组合的聪明方法。例如,使用补码运算, x+1~x+1等价与x-x。另一个例子,假设我们想要一个形如[0,,0,1,,1][0,…,0,1,…,1]的位模式,由wkw-k个0后面紧跟kk个1组成。这些位模式有助于掩码运算。这种模式能够通过C表达式(1<<k)1(1<<k)-1生成,利用的是这样一个属性,即我们想要的位模式的数值位2k12^k-1。例如,表达式(1<<8)1(1<<8)-1将产生位模式0xFF。

浮点数表示将通过数字编码位x2yx*2^y的形式来近似地表示实数。最常见的浮点表示方法是由IEEE标准754定义的。它提供了几种不同的精度,最常见的是单精度(32位)和双精度(64位)。IEEE浮点也能够表示特殊值++∞-∞NaNNaN

必须非常小心地使用浮点运算,因为浮点运算只有有限的范围和精度,而且不遵守普遍的算术属性,比如结合性。

第 3 章 程序的机器级表示

在本章中,我们窥视了C语言提供的抽象层下面的东西,以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,我们了解了编译器和它的优化能力,以及机器、数据类型和指令集。在第5章,我们会看到,当编写能有效映射到机器上的程序时,了解编译器的特性会有所帮助。我们还更完整地了解了程序如何将数据存储在不同的内存区域中。在第12章会看到许多这样的例子,应用程序员需要知道一个程序变量是在运行时栈中,是在某个动态分配的数据结构中,还是全局程序数据的一部分。理解程序如何映射到机器上,会让理解这些存储类型之间的区别容易一些。

机器级程序和它们的汇编代码表示,与C程序的差别很大。各种数据类型之间的差别很小。程序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,对程序员来说是直接可见的。本书仅提供了低级操作来支持数据处理和程序控制。编译器必须使用多条指令来产生和操作各种数据结构,以及实现像条件、循环和过程这样的控制结构。我们讲述了C语言和如何编译它的许多不同方面。我们看到C语言中缺乏边界检查,使得许多程序容易出现缓冲区溢出。虽然最近的运行时系统提供了安全保护,而且编译器帮助使得程序更安全,但是这已经使许多系统容易受到恶意入侵者的攻击。

我们只分析了C到x86-64的映射,但是大多数内容对其他语言和机器组合来说也是类似的。例如,编译C与编译C就非常相似。实际上,C的早期实现就只是简单地执行了从C到C的源到源的转换,并对结果运行C编译器,产生目标代码。C的对象用结构来表示,类似于C的struct。C++的方法是用指向实现方法的代码的指针来表示的。相比而言,Java的实现方法完全不同。Java的目标代码是一种特殊的二进制表示,成为Java字节代码。这种代码可以看成是虚拟机的机器级程序。正如它的名字暗示的那样,这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的行为。另外,有一种成为及时编译(just-in-time compilation)的方法,动态地将字节代码序列翻译成机器指令。当代码要执行多次时(例如在循环中),这种方法执行起来更快。用字节代码作为程序的低级表示,优点是相同的代码可以在许多不同的机器上执行,而在本章谈到的机器代码只能在x86-64机器上运行。

第 4 章 处理器体系结构

第 5 章 优化程序性能

虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,但是应用程序员有很多方法来协助编译器完成这项任务。没有任何编译器能用一个好的算法或数据结构代替低效的算法或者数据结构,因此程序设计的这些方面仍然应该是程序员主要关心的。我们还看到妨碍优化的因素,例如内存别名使用和过程调用,严重限制了编译器执行大量优化的能力。同样,程序员必须对消除这些妨碍优化的因素负主要的责任。这些应该被看做好的变成习惯的一部分,因为它们可以用来消除不必要的工作。

基本级别之外调整性能需要一些对处理器微体系结构的理解,描述处理器用来实现它的指令集体系结构的底层机制。对于乱序处理器的情况,只需要知道一些关于操作、容量、延迟和功能单元发射时间的信息,就能够基本地预测程序的性能了。

我们研究了一系列基础,包括循环展开、创建多个累积变量和重新结合,它们可以利用线代处理器提供的指令级并行。随着对优化的深入,研究产生的汇编代码以及试着理解机器如何执行计算变得重要起来。确认由程序中的数据相关决定的关键路径,尤其是循环的不同迭代之间的数据关系,会收获良多。我们还可以根据必须要计算的操作数量以及执行这些操作的功能单元的数量和发射时间,量化一次计算吞吐量界限。

包含条件分支或与内存系统复杂交互的程序,比我们最开始考虑的简单循环程序,更难以分析和优化。基本策略是使分支更容易预测,或者使它们很容易用条件数据传送来实现。我们还必须注意存储和加载操作。将数值保存在局部变量中,使得他们可以放在寄存器中,这会很有帮助。

当处理大型程序时,将注意力集中在最耗时的部分变得很重要。代码剖析程序和相关的工具能帮助我们系统地评价和改进程序性能。我们描述了GPROF,一个标准Unix剖析工具。还有更加复杂完善的剖析程序可用,例如Intel的VTUNE程序开发系统,还有Linux系统基本上都有的VALGRIND。这些工具可以在过程级分解执行时间,估计程序每个*基本块(basic block)*的性能。(基本块是内部没有控制转移的指令序列,因此基本块总是整个被执行的。)

第 6 章 存储器层次结构

基本存储技术包括随机存储器(RAM)、非易失性存储器(ROM)和磁盘。RAM有两种基本类型。静态RAM(SRAM)快一些,但是也贵一些,它即可以用作CPU芯片的高速缓存,也可以用作芯片外的高速缓存。动态RAM(DRAM)慢一些,也便宜一些,用作主存和图形帧缓冲器。即使是在关电的时候,ROM也能保持它们的信息,可以用来存储固件。旋转磁盘是机械的非易失性存储设备,以每个位很低的成本保存大量的数据,但是其访问时间比DRAM长的多。固态硬盘(SSD)基于非易失性的闪存,对某些应用来说,越来越成为旋转磁盘的具有吸引力的替代产品。

一般而言,较快的存储技术每个位会更贵,而且容量更小。这些技术的价格和性能属性正在以显著不同的速度变化着。特别地,DRAM和磁盘访问时间远远大于CPU周期时间。系统通过将存储器组织成存储设备的层次结构来弥补这些差异,在这个层次结构中,较小、较快的设备在顶部,较大、较慢的设备在底部。因为编写良好的程序有好的局部性,大多数数据都可以从较高层得到服务,结果就是存储系统能以较高层的速度运行,但却有较低层的成本和容量。

程序员可以通过编写有良好空间和事件局部性的程序来显著地改进程序的运行时间。利用基于SRAM的高速缓存存储器特别重要。主要从高速缓存存取数据的程序能比主要从内存取数据的程序运行得快得多。

第 7 章 链接

链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有3中不同的形式:可重定位的、可执行和共享的。可重定位的目标文件由静态连接器合并成一个可执行的目标文件,它可以加载到内存中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlpen库的函数时。

注:此节后面的翻译有点别扭,需要结合英文版修改

链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。

静态链接器是由像GCC这样的编译驱动程序调用的。他们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引入微妙的错误。

多个目标文件可以呗连接到一个单独的静态库中。链接器用库来解析其他目标模板中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。

加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的为解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。

被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。

第 8 章 异常控制流

异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。

在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。

有四种不同类型的异常:中断、故障、终止和陷阱。当一个外部I/O设备(例如定时器芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,(对于任意指令)中断会异步的发生。控制返回到故障指令后面的那条指令。一条指令的执行可能导致故障和终止同时发生。故障处理程序会重新启动故障指令,而终止处理程序从不将空只返回给被中断的流。最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的入口点的系统调用的函数调用。

在操作系统层面,内核用ECF提供进程的基本概念。进程提供应用两个重要的抽象:1)逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器;2)私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。

在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统的不同而不同。然而,在与Posix兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义。

最后,在应用层,C程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。

第 9 章 虚拟内存

注:此节部分翻译有点别扭,需要结合英文版修改

虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址空间的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。

虚拟内存提供三个重要的功能。第一,它在主存中自动缓存最近使用的、存放磁盘上的、虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟内存简化了内存管理,进而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而简化了内存保护。

地址翻译的过程必须和系统中所有的硬件缓存的操作集成在一起。大多数页表条目位于L1高速缓存中,但是一个称为TLB的页表条目的片上高速缓存,通常会消除访问在L1上的页表条目的开销。

现代系统通过将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,这个过程称为内存映射。内存映射为共享数据、创建新的进程以及记载程序提供了一种高效的机制。应用可以使用mmap函数来手工地创建和删除虚拟地址空间的区域。然而,大多数程序依赖动态内存分配器,例如malloc,它管理虚拟地址空间区域内一个称为堆的区域。动态内存分配器是一个感觉像系统级程序的应用级程序,它直接操作内存,而无需类型系统的很多帮助。分配器有两种类型。显示分配器要求应用显示地释放它们的内存块。隐式分配器(垃圾收集器)自动释放任何未使用的和不可达的块。

对于C程序员来说,管理和使用虚拟内存是一件困难和容易出错的任务。常见的错误示例包括:间接引用坏指针,读取未初始化的内存,允许栈缓冲区溢出,假设指针和它们指向的对象大小相同,引用指针而不是它所指向的对象,误解指针运算符,引用不存在的变量,以及引起内存泄露。

第 10 章 系统级 I/O

Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行 I/O 重定向。Linux的读和写操作会出现不足值,应用程序必须能正确地预计和处理这种情况。应用程序不应直接调用Unix I/O函数,而应该使用RIO包,RIO包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。

Linux内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向v-node表中的表项。每个进程都有它自己单独的描述符表项,而所有的进程共享同一个打开文件表和v-node表。理解这些结构的一般组成就能使我们清楚地理解文件共享和I/O重定向。

标准I/O库是基于Unix I/O实现的,并提供了一组强大的高级I/O例程。对于大多数应用程序而言,标准I/O更简单,是优于Unix I/O的选择。然而,因为对标准I/O和网络文件的一些相互不兼容的限制,Unix I/O比之标准I/O更该适用于网络应用程序。

第 11 章 网络编程

每个网络应用都是基于客户端-服务器模型的。根据这个模型,一个应用是由一个服务器和一个或多个客户端组成的。服务器管理资源,以某种方式操作资源,为它的客户提供服务。客户端-服务器模型中的基本操作是客户端-服务器事务,它是由客户端请求和跟随其后的服务器响应组成的。

客户端和服务器通过因特尔这个全球网络来通信。从程序员的观点来看,我们可以把因特网看成是一个全球范围的主机集合,具有以下几个属性:1)每个因特网主机都有一个唯一的32位名字,成为它的IP地址;2)IP地址的集合被映射为一个因特网域名的集合;3)不同因特网主机上的进程能够通过连接互相通信。

客户端和服务器通过使用套接字接口建立连接。一个套接字是一个连接的端点,连接以文件描述符的形式提供给应用程序。套接字接口提供了打开和关闭套接字描述符的函数。客户端和服务器通过读写这些描述符来实现彼此间的通信。

Web服务器使用HTTP协议和它们的客户端(例如浏览器)彼此通信。浏览器向服务器请求静态或者动态的内容。对静态内容的请求是通过从服务器磁盘去的文件并把它返回给客户端来服务的。对动态内容的请求是通过在服务器上的一个子进程的上下文中运行一个程序并将它的输出返回给客户端来服务的。CGI标准提供了一组规则,来管理客户端如何将程序参数传递给服务器,服务器如何将这些参数以及其他功能传递给子进程,以及子进程如何将它的输出发送回客户端。只用几百行C代码就能实现一个简单但是有功效的Web服务器,它即可以提供静态内容,也可以提供动态内容。

第 12 章 并发编程

一个并发程序是由在时间上重叠的一组逻辑流组成的。在这一章中,我们学习了三种不同的构建并发程序的机制:进程、I/O多路复用和线程。我们以一个并发网络服务器作为贯穿全章的应用程序。

进程是由内核自动调度的,而且因为他们有各自独立的虚拟地址空间,所以要实现共享数据,必须要有显式的IPC机制。事件驱动程序创建他们自己的并发逻辑流,这些逻辑流被模型化为状态机,用I/O多路复用来显式地调度这些流。通基于进程的流一样,线程也是由内核自动调度的。同基于I/O多路复用的流一样,线程是运行在一个单一进程的上下文中的,因此可以快速而方便地共享数据。

无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的P和V操作就是为了帮助解决这个问题、信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者-消费者程序中有限缓冲区和读者-写者中的共享对象这样的资源访问进行调度。一个并发预线程化的echo服务器提供了信号量使用场景的很好的例子。

并发也引入了其他一些困难的问题。被线程调度的函数必须具有一种成为线程安全的属性。我们定义了四类线程不安全的函数,以及一些将它们变为线程安全的建议。可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数通常比不可重入函数更为有效,因为他们不需要任何同步原语。竞争和死锁是并发程序中出现的另一些困难的问题。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。