0%

【书摘】深入理解计算机系统(第三版)

本文是《深入理解计算机系统(中文第三版)》上市后的重读笔记。


更新历史

  • 2017.02.21: 完成初稿
  • 2016.12.20: 开始更新

从程序员的角度来学习计算机系统是如何工作的会非常有趣,主要是因为你可以主动地来做这件事情。无论何时你学到一些新的东西,都可以马上试验并且直接看到运行结果。事实上,我们相信学习系统的唯一方法就是做(do)系统,即再真正的系统上解决具体的问题,或是编写和运行程序。

本书起源于1998年秋季,作者在 CMU 开设的编号为15-213的介绍性课程:计算机系统导论(Introduction to Computer Systems, ICS),是大多数高级系统课程的先行必修课。宗旨是用一种不同的方式向学生介绍计算机,只讨论那些影响用户级 C 语言程序的性能、正确性或实用性的主题。

计算机系统漫游

计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。虽然系统的具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有相似的硬件核软件组件,它们又执行着相似的功能。一些程序员希望深入了解这些组件是如何工作的以及这些组件是如何影响程序的正确性和性能的,以此来提高自身的技能。本书便是为这些读者而写的。

源程序实际上就是一个由值 0 和 1 组成的位(又称为比特)序列,8 个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。

大部分的现代计算机系统都使用 ASCII 标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值来表示每个字符。

只由 ASCII 字符构成的文件称为文本文件,所有其他文件都称为二进制文件

每条 C 语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标文件的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。在 Unix 系统上,从源文件到目标文件的转化是由编译器驱动程序完成的。

系统不仅仅只是硬件。系统是硬件和系统软件互相交织的集合体,它们必须共同协作以达到运行应用程序的最终目的。

数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算机做得更多,另一个是我们想要计算机运行得更快。当处理器能够同时做更多的事情时,这两个因素都会改进。我们用术语并发(concurrentcy)是一个通用的概念,指一个同时具有多个活动的系统;而术语并行(parallelism)指的是用并发来使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用。

重点强调的三个层次:线程级并发、指令级并行、单指令/多数据并行。

抽象的使用是计算机科学中最为重要的概念之一。在处理器里,指令集架构提供了对实际处理器硬件的抽象。

在学习操作系统时,我们介绍了三个抽象:文件是对 I/O 设备的抽象,虚拟内存是对程序存储器对的抽象,而进程是对一个正在运行的程序的抽象。

小节

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

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

操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象

  1. 文件是对 I/O 设备的抽象
  2. 虚拟内存是对主存和磁盘的抽象
  3. 进程是处理器、主存和 I/O 设备的抽象

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

信息的表示和处理

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

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

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

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

程序的机器级表示

计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。

在本章中,我们窥视了 C 语言提供的抽象层下面的东西,以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,我们了解了编译器和它的优化能力,以及机器、数据类型和指令集。

机器级程序和它们的汇编代码表示,与 C 程序的差别很大。各种数据类型之间的差别很小。程序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时的栈,对程序员来说是直接可见的。编译器必须使用多条指令来产生和操作各种数据结构,以及实现像条件、循环和过过程这样的控制结构。

处理器体系结构

一个处理器支持的指令和指令的字节级编码称为它的指令集体系结构(Instruction-Set Architecture, ISA)。不同的处理器『家族』都有不同的 ISA。

流水线化通过让不同的阶段并行操作,改进了系统的吞吐量性能。在任意一个给定的时刻,多条指令被不同的阶段处理。

有关处理器设计的几个重要经验:

  • 管理复杂性是首要问题。想要优化使用硬件资源,在最小的成本下获得最大的性能。为此,我们创建了一个非常简单而一致的框架,来处理所有不同的指令类型
  • 我们不需要直接实现 ISA
  • 硬件设计人员必须非常谨慎小心

优化程序性能

写程序最主要的目标就是使它在所有可能的情况下都正确工作。一个运行得很快但是给出错误结果的程序没有任何用处。程序员必须写出清晰简洁的代码,这样做不仅是为了自己能够看懂代码,也是为了在检查代码和今后需要修改代码时,其他人能够读懂和理解代码。

程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务。这包括消除不必要的函数调用、条件测试和内存引用。这些优化不依赖于目标机器的任何具体属性。

存储器层次结构

存储器层次结构是可行的,这是因为与下一个更低层次的存储设备相比来说,一个编写良好的程序倾向于更频繁地访问某一个层次上的存储设备。所以下一层的存储设备可以更慢速一点,也因此可以更大,每个比特位更便宜。整体效果是一个大的存储器池,其成本与层次结构底层最便宜的存储设备相当,但是却以接近于层次结构顶部存储设备的高速率向程序提供数据。

链接

  • 理解链接器将帮助你构造大型程序
  • 理解链接器将帮助你避免一些危险的编程错误
  • 理解链接将帮助你理解语言的作用域规则
  • 理解链接将帮助你理解其他重要的系统概念(加载和运行程序、虚拟内存、分页、内存映射)
  • 理解链接将使你能够利用共享库

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

异常控制流

现代系统通过使控制流发生突变来对意外情况做出反应,称为异常控制流(Exceptional Control Flow, ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会出发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个金城可以发送信号到另一个进程,而接受者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

  • 理解 ECF 将帮助你理解重要的系统概念(I/O、进程、虚拟内存)
  • 理解 ECF 将帮助你理解应用程序是如何与操作系统交互的(trap / system call)
  • 理解 ECF 将帮助你理解并发
  • 理解 ECF 将帮助你理解软件异常如何工作

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

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

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

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

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

虚拟内存

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

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

系统级 I/O

Linux 提供了少量基于 Unix I/O 模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行 I/O 重定向。

Linux 内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向 v-node 表中的表项。每个进程都有它自己单独的描述符表,而所有的进程共享同一个打开文件表和 v-node 表。

网络编程

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

并发编程

使用应用机并发的应用程序称为并发程序(concurrent program)。现代操作系统提供了三种基本的构造并发程序的方法:

  • 进程。用这个方法,每个逻辑控制流都是一个进程,→内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信(interprocess communication, IPC)机制
  • I/O 多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度他们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间
  • 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合里,像进程流一样由内核进行调度,而像 I/O 多路复用流一样共享同一个虚拟地址空间

无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的 P 和 V 操作就是为了帮助解决这个问题。

并发也引入了其他一些困难的问题。被线程调用的函数必须具有一种称为线程安全的属性。