计算机系统是由硬件和系统软件组成的,它们共同协作以运行应用程序。计算机内部的信息被表示为一组组的位,它们依据不同的上下文又有不同的解释方式。程序被其他程序翻译成不同的形式,开始时是 ASCII 文本,然后被编译器和链接器翻译成二进制可执行文件。
处理器读取并解释存放在主存里的二进制指令。因为计算机花费了大最的时间在存储器、 I/O 设备和 CPU 寄存器之间拷贝数据,所以系统中的存储设备就被按层次排列,CPU 寄存器在顶部,接着是多层的硬件高速缓存存储器、 DRAM 主存储器和磁盘存储器。在层次模型中位于更高层的存储设备比低层的存储设备要快,单位比特造价也更高。程序员通过理解和运用这种存储层次结构的知识,可以优化他们 C 程序的性能。
操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象概念:文件是对 I/O 设备的抽象概念;虚拟存储器是对主存和磁盘的抽象概念;进程是处理器、主存和 I/O 设备的抽象概念。最后,网络提供了计算机系统之间通信的手段。从某个系统的角度来看,网络就是一种 I/O 设备。
计算机将信息编码为位(比特),通常组织成字节序列。有不同的编码方式用来表不整数、实数和字符串。不同的计算机模型在编码数字和多字节数据中的字节顺序上使用不同的约定。
C 语言被设计成包容多种不同字长和数字编码的实现。虽然高端机器逐渐开始使用 64 位字长,但是目前大多数机器仍使用 32 位字长。大多数机器对整数使用二进制补码编码,而对浮点数使用 IEEE 编码。在位级上理解这些编码,并且理解算术运算的数学特性,对于编写能在全部数值范围上正确运算的程序来说,是很重要的。
C 语言的标准规定在无符号和有符号整数之间进行强制类型转换时,基本的位模式不应该改变。在二进制补码机器上,对于一个 w 位的值,这种行为是由函数 T2U 和 U2T 来描述的。 C 语言隐式的强制类型转换会得到许多程序员无法预计的结果,常常导致程序错误。
由于编码的长度有限,计算机运算与传统整数和实数运算相比,具有非常不同的属性。当超出表示范围时,有限长度能够引起数值溢出。当浮点数非常接近于 0.0 ,从而转换成零时,浮点数也会下溢。
和大多数其他程序语言一样, C 语言实现的有限整数运算和真实的整数运算相比有一些特殊的属性。例如,由于溢出,表达式 x * x 能够得出负数。但是,无符号数和二进制补码的运算都满足环的属性。这就允许编译器做很多的优化。例如,用 (x<< 3) - x,取代表达式 7 * x 时,我们就利用了结合性、交换性和分配性,还利用了移位和乘以 2 的幂之间的关系。
我们己经看到了几种使用位级运算和算术运算组合的聪明方法。例如,我们看到,使用二进制补码运算,~x+1 是等价于 -x 的。另外一个例子,假设我们想要一个形如 [ 0, … ,0,1,… ,1 ] 的位模式,由 w-k 个 0 后面紧跟着 k 个 1 组成。这些位模式对于掩码运算是很有用的。这种模式能够通过 C 表达式 ( 1< < k) - 1 生成,利用的是这样一个属性,即我们想要的位模式的数值为 2^k-1。例如,表达式 (1<<8) - 1 将产生位模式 0xFF。
浮点表示通过将数字编码为 x*2^y 的形式来近似地表示实数。最常见的浮点表示方式是由 IEEE 标准 754 定义的。它提供了几种不同的精度,最常见的是单精度( 32 位)和双精度( 64 位)。 IEEE 浮点也能够表示特殊值 无穷 和 NaN。
必须非常小心地使用浮点运算,因为浮点运算的范围和精度有限,而且浮点运算并不遵守普遍的算术属性,比如结合性。
在本章中,我们窥视了高级语言提供的抽象层下面的东西,以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,我们了解了编译器和它的优化能力,以及机器代码、它的数据类型和它的指令集。在第 5 章中,我们会看到,当编写能有效映射到机器上的程序时,了解编译器的特性会有所帮助。我们还看了一些高级语言抽象隐藏有关程序操作重要细节的例子。例如,浮点代码的行为可能依赖于值是保存在寄存器中,还是在存储器中。在第 13 章中,我们会看到许多这样的例子,我们需要知道一个程序变且是在运行时找中,是在某个动态分配的数据结构中,还是在某个全局存储位置中。理解程序是如何映射到机器上的,会让理解这些存储之间的区别容易一些。
汇编语言与 C 代码差别很大。在汇编语言程序中,各种数据类型之间的差别很小。程序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈.对程序员来说是直接可见的。仅提供了低级操作来支持数据处理和程序控制。编译器必须用多条指令来产生和操作各种数据结构。来实现像条件、循环和过程这样的控制结构。我们讲述了 C 和如何编译 C 的许多不同方面。我们看到 C 中缺乏边界检查,使得许多程序容易出现缓冲区滋出,而这己经使许多系统容易受到入侵者的恶意攻击。
我们只分析了 C 到 IA32 的映射,但是我们讲的大多数内容对其他语言和机器组合来说也是类似的。例如,编译 C++ 与编译 C 就非常相似。实际上 C++ 的早期实现就只是简单地执行了从 C++ 到 C 的源到源的转换,并对结果运行 C 编译器,产生目标代码。C++ 的对象用结构来表示,类似于 C 的 struct。C++ 的方法是用指向实现方法的代码的指针来表示的。相比而言, Java 的实现方式完全不同。Java 的日标代码是一种特殊的二进制表示,称为 Java 字节代码。这种代妈可以看成是虚拟机的机器级程序。正如它的名字暗示的那样,这种机器井不是直接用硬件实现的。相反,软件解释器处理字节代码,模拟虚拟机的行为。这种方法的优点是相同的 Java 字节代码可以在许多不同的机器上执行,而我们在本章谈到的机器代码只能在 IA32 上运行。
我们已经看到,指令集体系结构(即 ISA )在处理器行为(就指令集合及其编码而言)和如何实现处理器之间提供了一层抽象。lSA 提供了程序执行的一种顺序说明,也就是一条指令执行完了,下一条指令才会开始。
基本 IA32 指令集,并且大大简化其数据类型、地址模式和指令编码,我们定义出了 Y86 指令集。得到的 ISA 既有 RISC 指令集的属性,也有 CISC 指令集的属性。然后,我们将不同指令组织放到五个阶段中处理,在此,根据被执行的指令的不同,每个阶段中的操作也不相同。从此,我们构造了 SEQ 处理器,其中每个时钟周期推进一条指令通过每个阶段。通过重新排列各个阶段,我们创建了 SEQ+ 设计.其中第一个阶段选择程序计数器的值,它被用来取出当前指令。
流水线化通过让不同的阶段并行操作,改进了系统的吞吐最性能。在任意一个给定的时刻,多条指令被处理。在引入这种并行性的过程中,我们必须非常小心,以提供与程序的顺序执行相同的用户可见的、程序级行为。我们通过往 SEQ+中添加流水线寄存器,并重新安排周期来创建 PIPE-流水线,介绍了流水线化。然后,我们添加了转发逻辑,加速了将结果从一条指令发送到另一条指令,从而提高了流水线的性能。有几种特殊情况需要额外的流水线控制逻辑来暂停或取消一些流水线阶段。
在本章中,我们学习了有关处理器设计的几个重要经验:
1)管理复杂性是首要问题。我们想要优化使用硬件资源,在最小的成本下获得最大的性能。为了实现这个目的,我们创建了一个非常简单而一致的框架,来处理所有不同的指令类型。有了这个框架,我们就能够在处理不同指令类型的逻辑中间共享硬件单元。
2)我们不需要直接实现 ISA。ISA 的直接实现意味着一个顺序的设计。为了获得更高的性能,我们想运用硬件能力以同时执行许多操作,这就导致要使用流水线化的设计。通过仔细的设计和分析,我们能够处理各种流水线冒险,因此运行一个程序的整体效果,同用 ISA 模型获得的效果完全一致。
3)硬件设计人员必须非常谨慎小心。一旦芯片被制造出来,就几乎不可能改正任何错误了。一开始就使设计正确是非常重要的。意思就是,仔细地分析各种指令类型和组合情况,甚至于那些看上去没有意义的悄况,例如弹出栈指针.必须用系统的模拟测试程序彻底地测试设计。在开发 PIPE 的控制逻辑中,我们的设计有个细微的错误,只有通过对控制组合的仔细而系统的分析才能发现。
虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,但是应用程序员有很多方法来协助编译器完成这项任务。没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构。因此程序设计的这些方面仍然应该是程序员主要关心的。我们还看到妨碍优化的因素。例如存储器别名和过程调用,严重限制了编译器执行大量优化的能力。同样,程序员必须对消除这些妨碍优化的因素负主要的责任。
除此之外,我们还研究了一系列技术,包括循环展开、迭代分割以及指针运算。随着我们对优化的深入,研究汇编代码以及试着理解机器是如何执行计算的变得重要起来。对于现代、乱序处理器上的执行,分析程序是如何在有无限处理资源但是功能单元的执行时间和发射时间与目标处理器相符的机器上执行的,收获良多。为了精练这个分析,我们还应该考虑诸如功能单元数量和类型这样的资源约束。
包含条件分支或与存储器系统复杂交互的程序,比我们首先考虑的简单循环程序,更加难以分析和优化。基本策略是使循环更容易预测,井试着减少存储和加载操作之间的相互影响。
当处理大型程序时,将我们的注意力集中在最耗时的部分变得很重要。代码剖析程序和相关的工具能帮助我们系统地评价和改进程序性能。我们描述了 GPROF ,一个标准的 Unix 剖析工其。也还有更加复杂完善的剖析程序可用,例如 Intel 的 VTUNE 程序开发系统。这些工具可以在过程级分解执行时间,测量程序每个基本块(basic block)的性能。基本块是没有条件操作的指令序列。
Amdahl 定律提供了对通过只改进系统一部分所获得的性能收益的一个简单但是很有力的看法。收益既依赖于我们对这个部分的提高程度,也依赖于这个部分原来在整个时间中所占的比例。
基本存储技术包括 RAM(随机存储器)、 ROM(非易失性存储器)和磁盘。RAM 有两种基本类型。SRAM (静态 RAM )快一些,但是也贵一些。它既可以用做 CPU 芯片上的高速缓存,也可以用做芯片外的高速缓存。动态 RAM ( DRAM )慢一点,也便宜一些,用做主存和图形帧缓冲区 。非易失性存储器,也称为只读存储器(ROM) ,即使是在关电的时候,也能保持它们的信息,它们用来存储固件( firmware )。磁盘是非易失性存储设备,以每个位很低的成本保存大量的数据。代价是较长的访问时间。
一般而言,较快的存储技术每个位会更贵,而且容最较小。这些技术的价格和性能属性正在动态地以不同的速度变化着。特别地,DRAM 和磁盘访问时间滞后于 CPU 周期时间。系统通过将存储器组织成存储设备的层次结构来弥补这些差异,在这个层次结构中,较小、较快的设备在顶部,较大、较慢的设备在底部。因为编写良好的程序有好的局部性,大多数数据都可以从较高层得到服务,结果就是存储系统能以较高层的速度运行,但却有较低层的成本和容量。
程序员可以通过编写有良好空间和时间局部性的程序来动态地改进程序的运行时间。利用基于 SRAM 的高速缓存存储器特别重要,主要从 L1 高速缓存取数据的程序能比主要从存储器取数据的程序运行得快过一个数量级。
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有三种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器组合成一个可执行的目标文件,它可以加载到存储器中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用 dlopen 库的函数时。
链接器的两个主要任务是符号解析和重定位。符号解析将目标文件中的每个全局符号都绑定到一个惟一的定义,而重定位确定每个符号的最终存储器地址,并修改对那些目标的引用。
静态链接器是由像 GCC 这样的编译器调用的。它们将多个可重定位目标文件组合成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多处定义的规则可能在用户程序中引入的微妙错误。
多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。
加载器将可执行文件的内容映射到存储器,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有未解析的到定义在共享库中的程序和数据的引用。在加载时,加载器将部分链接的可执行文件映射到存储器,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序还可以在运行时使用动态链接器。
异常控制流发生在计算机系统的各个层次。在硬件层,异常是由处理器中的事件触发的控制流中的突变,控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
有四种不同类型的异常:中断、故障、终止和陷阱。当一个外部的 I/O 设备,例如定时器芯片或者一个磁盘控制器,设置了处理器芯片上的中断管脚时,(对于任惫指令)中断会异步地发生。控制返回到中断指令的下一条指令。执行一条指令可能导致故障和终止的发生。故障处理程序会重新开始故障指令,而终止处理程序从不将控制返回给被中断的流。最后,陷阱就像是用来实现系统调用的函数调用,系统调用提供给应用到操作系统代码的受控入口点。
在操作系统层,内核提供关于一个进程的基础性概念。一个进程提供给应用两个重要的抽象: 1)逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器;2)私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。
在操作系统和应用之间的接口处,应用可以创建子进程,等待它们的子进程暂停或者终止,运行新的程序,并捕捉来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同。然而,在与 Posix 兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义。
最后,在应用层,C 程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。
虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。
虚拟内存提供三个重要的功能。第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘拷贝到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟内存简化了存储器管理,进而又简化了链接、在进程间共享数据、进程的存储器分配,以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而了简化了内存保护。
地址翻译的过程必须和系统中任意硬件缓存的操作集成在一起。大多数页表条目位于 L1 高速缓存下,但是一个称为 TLB 的页表条目在芯片上的高速缓存,通常会消除访问在 L1 上的页表条目的开销。
现代系统通过将虚拟内存组块(chunk)和磁盘上的文件组块关联起来,来初始化虚拟内存组块,这个过程称为内存映射。内存映射为共享数据、创建新的进程以及加载程序.提供了一种高效的机制。应用可以使用 mmap 函数来手工地创建和删除虚拟地址空间的区域。然而,大多数程序依较于动态内存分配器,例如 malloc,它管理虚拟地址空间区域内一个称为堆的区域。动态内存分配器是一个有系统级感觉的应用级程序,它直接操作内存,而无需类型系统的很多帮助。分配器有两种类型:显式分配器要求应用显式地释放它们的存储器块:隐式分配器(垃圾收集器)自动释放任何无用的和不可达的块。
对于 C 程序员来说,管理和便用虚拟内存是一件困难和容易出错的任务。常见的错误示例包括:间接引用坏指针,读取未初始化的内存,允许栈缓冲区滋出,假设指针和它们指向的对象大小相同,引用指针而不是它所指向的对象,误解指针运算,引用不存在的变量,以及引起内存泄漏。
Unix 提供了少量的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行 UO 重定向。 Unix 的读和写操作会出现不足值( short counts ) ,应用程序必须能正确地预计和处理这种情况。应用程序不直接调用 Unix I/O 函数,而应该使用 Rio 包,Rio 包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。
Unix 内核使用三种相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项。而打开文件表中的表项又指向 v-node 表中的表项。每个进程都有它自己单独的描述符表,而所有的进程共享同一打开文件表和 v-node 表。理解这些结构的一般构成就能使我们清楚地理解文件共享和 UO 重定向。
标准 I/O 库是基于 Unix I/O 实现的,并提供了一组强大的高级 I/O 例程。对于大多数应用程序而言,标准 I/O 更简单,是优于 Unix I/O 的选择。然而,因为对标准 I/O 和网络文件的一些相互不兼容的限制,Unix I/O 比之标准 I/O 更该适用于网络应用程序。
每个网络应用都是基于客户端-服务器模型的。根据这个模型,一个应用是由一个服务器和一个或多个客户端组成的。服务器管理资源,以某种方式操作资源,为它的客户端提供服务。客户端-服务器模型中的基本操作是客户端-服务器事务,它是由客户端请求和跟随的服务器响应组成的。
客户端和服务器通过因特网这个全球网络来通信。从一个程序员的观点来看,我们可以把因特网看成是一个全球范围的主机集合,具有以下几个属性:每个因特网都有一个惟一的 32 位名字,称为它的 IP 地址;IP 地址的集合映射为一个因特网域名的集合;不同因特网主机上为进程能够通过连接互相通信。
客户端和服务器通过使用套接字接口建立连接。套接字是连接的端点,对应用程序来说,连接是以文件描述符的形式出现的。套接字接口提供了打开和关闭套接字描述符的函数。客户端和服务器通过读写这些描述符来实现彼此间的通信。
Web 服务器使用 HTTP 协议和它们的客户端(例如浏览器)彼此通信。浏览器向服务器请求静态或者动态的内容。对静态内容的请求是通过从服务器磁盘取得文件并把它返回给客户端来服务的。对动态内容的请求是通过在服务器上一个子进程的上下文中运行一个程序井将它的输出返回给客户端来服务的。 CGI 标准提供一组规则,来管理客户端如何将程序参数传递给服务器,服务器如何将这些参数以及其他信息传递给子进程,以及子进程如何将它的输出发送回客户端。
只用几百行 C 代码就能实现一个简单但是有功效的 Web 服务器,它既可以提供静态内容,也可以提供动态内容。
一个并发程序是由在时间上重叠的一组逻辑流组成的。在这一章中,我们学习了三种不同的构建并发程序的机制:进程、 I/O 多路复用和线程,我们以一个并发网络服务器作为贯穿全章的应用程序。
进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数据,它们需要显式的 IPC 机制。事件驱动程序创建它们自己的并发逻辑流,这些逻辑流被模型化为状态机,用的多路复用来显式地调度这些流。因为程序运行在一个单一进程中,所以在流之间共享数据速度很快而且很容易。线程是这些方法的综合。同基于进程的流一样,线程是由内核自动调度的。同基于如多路复用的流一样,线程是运行在一个单一进程的上下文中的,因此可以快速而方便地共享数据。
无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的 P 和 V 操作就是为了帮助解决这个问题。信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者-消费者程序中共享缓冲区这样的资源访问进行调度。一个并发预线程化的 echo 服务器提供了这两种信号量使用场景的很好的例子。
并发性也引入了其他一些困难的问题。被线程调用的函数必须其有一种称为线程安全的属性。我们定义了四类线程不安全函数,以及一些将它们变为线程安全的建议。可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数通常比不可重入函数更为有效,因为它们不需要任何同步原语。竞争和死锁是并发程序中出现的另一些困难的问题。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。