CS:APP各章小结 (2017-08-28 15:51:44)

第1章 计算机系统漫游

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

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

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

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

第一部分 程序结构和执行

我们对计算机系统的探索是从学习计算机本身开始的,它由处理器和存储器子系统组成。在核心部分,我们需要方法来表示基本数据类型,比如整数和实数运算的近似值。然后,我们考虑机器级指令如何操作这样的数据,以及编译器又如何将C程序翻译成这样的指令。接下来,研究几种实现处理器的方法,帮助我们更好地了解硬件资源如何被用来执行指令。一旦理解了编译器和机器级代码,我们就能了解如何编写C程序以及编译它们来最大化程序的性能。本部分以存储器子系统的设计来作为结束,这是现代计算机系统最复杂的部分之一。

本书的这一部分将领着你深入了解如何表示和执行应用程序。你将学会一些技巧,来帮助你写出安全、可靠且充分利用计算资源的程序。

第2章 信息的表示和处理

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

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

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

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

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

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

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

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

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

第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章 处理器体系结构

我们已经看到,指令集体系结构,即ISA,在处理器行为(就指令集合及其编码而言)和如何实现处理器之间提供了一层抽象。ISA提供了程序执行的一种顺序说明,也就是一条指令执行完了,下一条指令才会开始。

从IA32指令开始,大大简化数据类型、地址模式和指令编码,我们定义了Y86-64指令集,得到的ISA既有RISC指令集的属性,也有CISC指令集的属性。然后,将不同指令组织放到五个阶段中处理,在此,根据被执行的指令的不同,每个阶段中的操作也不相同。据此,我们构造了SEQ处理器,其中每个时钟周期执行一条指令它会通过所有五个阶段。

流水线化通过让不同的阶段并行操作,改进了系统的吞吐量性能。在任意一个给定的时刻,多条指令被不同的阶段处理。在引入这种并行性的过程中,我们必须非常小心,以提供与程序的顺序执行相同的程序级行为。通过重新调整SEQ各个部分的顺序,引入流水线,我们得到SEQ+,接着添加流水线寄存器,创建出PIPE—流水线。然后,添加了转发逻辑,加速了将结果从一条指令发送到另一条指令,从而提高了流水线的性能。有几种特殊情况需要额外的流水线控制逻辑来暂停或取消一些流水线阶段。

我们的设计中包括了一些基本的异常处理机制,在此,保证只有到异常指令之前的指令会影响程序员可见的状态。实现完整的异常处理远比此更具挑战性。在采用了更深流水线和更多并行性的系统中,要想正确处理异常就更加复杂了。

在本章中,我们学习了有关处理器设计的几个重要经验:

l 管理复杂性是首要问题。想要优化使用硬件资源,在最小的成本下获得最大的性能。为了实现这个目的,我们创建了一个非常简单而一致的框架,来处理所有不同指令类型。有了这个框架,我们就能够在处理不同指令类型的逻辑中共享硬件单元。

l 我们不需要直接实现ISA。ISA的直接实现意味着一个顺序的设计。为了获得更高的性能,我们想运用硬件能力以同时执行许多操作,这就导致要使用流水线化的设计。通过仔细的设计和分析,我们能够处理各种流水线冒险,因此运行一个程序的整体效果,同用ISA模型获得的效果完全一致。

l 硬件设计人员必须非常谨慎小心。一旦芯片被制造出来,就几乎不可能改正任何错误了。一开始就使设计正确是非常重要的。这就意味着要仔细地分析各种指令类型和组合,甚至于那些看上去没有意义的情况,例如弹出值到栈指针。必须用系统的模拟测试程序彻底地测试设计。在开发PIPE的控制逻辑中,我们的设计有个细微的错误,只有通过对控制组合的仔细而系统的分析才能发现。

第5章 优化程序性能

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

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

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

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

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

第6章 存储器层次结构

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

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

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

第二部分 在系统上运行程序

继续我们对计算机系统的探索,进一步来看看构建和运行应用程序的系统软件。链接器把程序的各个部分联合成一个文件,处理器可以将这个文件加载到内存,并且执行它。现代操作系统与硬件合作,为每个程序提供一种幻像,好像这个程序是在独占地使用处理器和主存,而实际上,在任何时刻,系统上都有多个程序在运行。

在本书的第一部分,你很好地理解了程序和硬件之间的交互关系。本书的第二部分将拓宽你对系统的了解,使你牢固地掌握程序和操作系统之间的交互关系。你将学习到如何使用操作系统提供的服务来构建系统级程序,例如Unix shell和动态内存分配包。

第7章 链接

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

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

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

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

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

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

第8章 异常控制流

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

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

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

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

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

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

第9章 虚拟内存

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

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

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

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

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

第三部分 程序间的交互和通信

我们学习计算机系统到现在,一直假设程序是独立运行的,只包含最小限度的输入和输出。然而,在现实世界里,应用程序利用操作系统提供的服务来与I/O设备及其他程序通信。

本书的这一部分将使你了解Unix操作系统提供的基本I/O服务,以及如何用这些服务来构造应用程序,例如Web客户端和服务器,它们是通过Internet彼此通信的。你将学习编写诸如Web服务器这样的可以同时为多个客户端提供服务的并发程序。编写并发应用程序还能使程序在现代多核处理器上执行得更快。当学完了这个部分,你将逐渐变成一个很牛的程序员,对计算机系统以及它们对程序的影响有很成熟的理解。

第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服务器提供了信号量使用场景的很好的例子。

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

发布了70 篇原创文章 · 获赞 3 · 访问量 4667

猜你喜欢

转载自blog.csdn.net/qq_37150711/article/details/103984587