1、概要
1.1、信息就是位+上下文
计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。所有计算机系统都有相似的硬件和软件组件,它们执行着相似的功能。
从某种意义上来说,本书的目的就是要帮助你了解当你在系统上执行 hello 程序时,系统发生了什么以及为什么会这样。
// hello 程序
#include <stdio.h>
int main()
{
printf("hello, world\n");
}
hello 程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员利用编辑器创建并保存的文本文件,文件名是 hello.c。源程序实际上就是一个由值 0 和 1 组成的位(bit)序列,8 个位被组织成一组,称为字节。每个字节表示程序中某个文本字符。
hello 源程序是文本编辑器编写的一个文件,使用 HxD (免费的十六进制和磁盘数据编辑器)对源文件进行源码查看:
从上图可以看见,所有的源码字符最终都会被转为对应的数字。像 hello.c 这样只由 ASCII 字符构成的文件称为文本文件,所有其他文件都称为二进制文件。hello.c 的表示方法说明了一个基本的思想 :系统中所有的信息(包括磁盘文件、存储器中的程序、存储器中存放的用户数据以及网络上传送的数据),都是由一串位表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。
1.2、编译系统如何工作
hello.c 是肉眼可直接识别的文本文件,但作为机器来说,它只认识二进制数字(物理逻辑上对应的是:不同大小电流或高低的电压),文件均附生在操作系统之上,我们需要一套完整的流程将源文件翻译成机器可以直接看懂并执行的可执行文件,执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统(compilation system)。
- 预处理阶段: 预处理器(cpp)根据以字符 # 开头的命令,修改原始的 C 程序。比如 hello.c 中第 1 行的 #include 命令告诉预处理器读取系统头文件 stdio.h 的内容,并把它直接插入到程序文本中。结果就得到了另一个 C 程序,通常是以 .i 作为文件扩展名。
- 编译阶段: 编译器(cc1)将文本文件 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 程序中。链接器(ld)就负责处理这种合并。结果就得到 hello 文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
1.3、高速缓存
随机访问存储器( Random-Access Memory,RAM)分为两类:静态的和动态的。静态RAM(SRAM)比动态RAM(DRAM)更快,但也贵得多。SRAM用来作为高速缓存存储器。DRAM用来作为主存以及图形系统的帧缓冲区。
静态RAM
SRAM将每个位存储在一个双稳态的( bistable)存储器单元里。每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同的电压配置( configuration)或状态( state)之一。其他任何状态都是不稳定的,在不稳定状态时,电路会迅速转移到两个稳定状态的一个。
由于SRAM存储器单元的双稳态特性,只要有电,它就会永远地保持它的值。即使有干扰(例如电子噪音)来扰乱电压,当干扰消除时,电路就会恢复到稳定值。
动态RAM
DRAM将每个位存储为对一个电容的充电。DRAM存储器可以制造得非常密集。每个单元由一个电容和一个访问晶体管组成。但是,与SRAM不同,DRAM存储器单元对干扰非常敏感。当电容的电压被扰乱之后,它就永远不会恢复了。暴露在光线下会导致电容电压改变。
高速缓存存储器
高速缓存关于读的操作非常简单。首先,在高速缓存中查找所需字ww的副本。如果命中,立即返回字ww给CPU。如果不命中,从存储器层次结构中较低层中取出包含字ww的块,将这个块存储到某个高速缓存行中(可能会驱逐一个有效的行),然后返回字ww。
写的情况就要复杂一些了。假设我们要写一个已经缓存了的字ww(写命中, write hit)。在高速缓存更新了它的ww的副本之后,怎么更新ww在层次结构中紧接着低一层中的副本呢?最简单的方法,称为直写( write-through),就是立即将ww的高速缓存块写回到紧接着的低一层中。虽然简单,但是直写的缺点是每次写都会引起总线流量。另一种方法,称为写回( write-back),尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写到紧接着的低一层中。由于局部性,写回能显著地减少总线流量,但是它的缺点是增加了复杂性。高速缓存必须为每个高速缓存行维护一个额外的修改位( dirty bit),表明这个高速缓存块是否被修改过。
另一个问题是如何处理写不命中。一种方法,称为写分配( write-allocate),加载相应的低一层中的块到高速缓存中,然后更新这个高速缓存块。写分配试图利用写的空间局部性,但是缺点是每次不命中都会导致一个块从低一层传送到高速缓存。另一种方法,称为非写分配(not- write-allocate),避开高速缓存,直接把这个字写到低一层中。直写高速缓存通常是非写分配的。写回高速缓存通常是写分配的。
高速缓存既保存数据,也保存指令。只保存指令的高速缓存称为 i-cache。只保存程序数据的高速缓存称为 d-cache。既保存指令又包括数据的高速缓存称为统一的高速缓存( unified cache)。现代处理器包括独立的 i-cache和d-cache。这样做有很多原因。有两个独立的高速缓存,处理器能够同时读一个指令字和一个数据字。 i-cache通常是只读的,因此比较简单。通常会针对不同的访问模式来优化这两个高速缓存,它们可以有不同的块大小,相联度和容量。使用不同的高速缓存也确保了数据访问不会与指令访问形成冲突不命中,反过来也是一样,代价就是可能会引起容量不命中增加。
1.4、存储设备层次结构
存储技术和计算机软件的一些基本的和持久的属性:
存储技术:不同存储技术的访问时间差异很大。速度较快的技术每字节的成本要比速度较慢的技术高,而且容量较小。CPU和主存之间的速度差距在增大。
计算机软件:一个编写良好的程序倾向于展示出良好的局部性。
硬件和软件的这些基本属性互相补充得很完美。它们这种相互补充的性质使人想到一种组织存储器系统的方法,称为存储器层次结构( memory hierarchy),下图展示了一个典型的存储器层次结构。一般而言,从高层往底层走,存储设备变得更慢、更便宜和更大。在最高层(L0),是少量快速的CPU寄存器,CPU可以在一个时钟周期内访问它们。接下来是一个或多个小型到中型的基于SRAM的高速缓存存储器,可以在几个CPU时钟周期内访问它们。然后是一个大的基于DRAM的主存,可以在几十到几百个时钟周期内访问它们。接下来是慢速但是容量很大的本地磁盘。最后,有些系统甚至包括了一层附加的远程服务器上的磁盘,要通过网络来访问它们。
存储器结构中的缓存
一般而言,高速缓存( cache,读作“cash”)是一个小而快速的存储设备,它作为存储在更大、也更慢的设备中的数据对象的缓冲区域。使用高速缓存的过程称为缓存( caching,读作“ cashing")。
存储器层次结构的中心思想是,对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层都缓存来自较低一层的数据对象。
数据总是以块大小为传送单元( transfer unit)在第k层和第k+1层之间来回复制的。虽然在层次结构中任何一对相邻的层次之间块大小是固定的,但是其他的层次对之间可以有不同的块大小。如上图所示,L1和L0之间的传送通常使用的是1个字大小的块。L2和L1之间(以及L3和I2之间、I4和I3之间)的传送通常使用的是几十个字节的块。而L5和L4之间的传送用的是大小为几百或几千字节的块。一般而言,层次结构中较低层(离CPU较远)的设备的访问时间较长,因此为了补偿这些较长的访问时间,倾向于使用较大的块。
缓存命中
当程序需要第k+1层的某个数据对象d时,它首先在当前存储在第k层的一个块中查找d。如果d刚好缓存在第k层中,那么就是我们所说的缓存命中( cache hit)。
缓存不命中
另一方面,如果第k层中没有缓存数据对象d,那么就是我们所说的缓存不命中( cache miss)。当发生缓存不命中时,第k层的缓存从第k+1层缓存中取出包含d的那个块,如果第k层的缓存已经满了,可能就会覆盖现存的一个块。(缓存的替换策略:随机替换替换策略,最少被使用(LRU)替换策略)。
缓存不命中种类
区分不同种类的缓存不命中有时候是很有帮助的。如果第k层的缓存是空的,那么对任何数据对象的访问都会不命中。一个空的缓存有时被称为冷缓存( cold cache),此类不命中称为强制性不命中( compulsory miss)或冷不命中( cold miss)。冷不命中很重要,因为它们通常是短暂的事件,不会在反复访问存储器使得缓存暖身( warmed up)之后的稳定状态中出现。
缓存管理
存储器层次结构的本质是,每一层存储设备都是较低一层的缓存。在每一层上,某种形式的逻辑必须管理缓存。这里,我们的意思是指某个东西要将缓存划分成块,在不同的层之间传送块,判定是命中还是不命中,并处理它们。管理缓存的逻辑可以是硬件、软件,或是两者的结合。
2、处理器解释内存中指令
2.1、系统硬件组成
经过编译,hello.c 源程序已经被编译成了可执行目标文件 hello,并存放在磁盘上,那么如何运行呢?
为了理解运行 hello 程序时发生了什么,我们先要了解一个典型系统的硬件组织。如下图:
我们现在不需要对这张图有很深入的理解,后面会详细进行介绍。现在先简单的认识一下下面几个主要部件:
一、总线:贯穿整个系统的一组电子管道,通常被设计成用来传送定长的字节块,也就是字。字的大小与系统相关,比如在32位操作系统当中,一个字是4个字节。
二、I/O设备:输入/输出(I/O)设备是系统与外部世界联系通道,上图有4个I/O设备。作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序的磁盘。每一个I/O设备都通过一个控制器或者适配器与I/O总线相连。控制器是置于I/O设备本身的或者系统的主印刷电路板(通常称为主板)上的芯片组,而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是在 I/O 总线和 I/O 设备之间传递信息。
三、主存:它是计算机中的一个临时存储设备,在处理器执行程序的时候,用来存放程序和程序处理的数据。物理上来说,主存是由一组动态随机存取存储器(DRAM)组成的,逻辑上来说,它是一个线性的字节数组,每一个字节都有唯一的地址(即数组索引)。
四、处理器:全称中央处理器(CPU),是解释(或执行)存储在主存中指令的引擎。处理器的核心是一个字长的存储设备(或寄存器),简称程序计数器(PC),在任何时刻,它都会指向主存中的某条机器指令(即含有该条指令的地址)。从系统通电到断点,处理器一直在不断的执行程序计数器所指向指令,再更新程序计数器,使其指向下一条指令。处理器所做的操作是围绕主存、寄存器文件以及算术/逻辑单元(ALU)进行的,寄存器文件是一个小的存储设备,由一些1字长的寄存器组成,每个寄存器都有唯一的名字。ALU则计算新的数据和地址值。
CPU 在指令的要求下会做如下操作:
①、加载:把一个字节或者一个字从主存复制到寄存器,以覆盖寄存器原来的内容
②、存储:把一个字节或者一个字从寄存器复制到主存的某个位置,以覆盖这个位置上原来的内容
③、操作:把两个寄存器的内容复制到 ALU,ALU 对这两个字做算术操作,并把结果存放到一个寄存器中,以覆盖寄存器原来的内容
④、跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的内容。
2.2、运行hello程序
前面简单的介绍了系统的硬件组成和操作,那么接下来介绍我们运行程序时到底发生了什么。
想要在 Linux 系统中运行该可执行程序,我们要将它的文件名输入到称为外壳(shell)的应用程序中,外壳是一个命令行解释器,它输出一个提示符,等待你输入一个命令,然后执行这个命令。如果该命令行的第一个单词不是一个内置的外壳命令,那么外壳就会假设这是一个可执行文件的名字,它将加载并运行这个文件。
初始时,外壳程序执行它的指令,等待我们输入一个命令。当我们在键盘上输入字符串"./hello"后,外壳程序将字符逐一读入到寄存器中,再把它放入到存储器中,如下图:
当我们在键盘上敲回车键的时候,外壳程序知道我们已经结束了命令的输入。然后外壳执行一系列指令来加载可执行的 hello 文件,将 hello 目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串“Hello World\n”,一旦目标文件中的代码和数据被加载到主存,处理器就开始执行 hello 程序的 main 程序中的机器语言指令。这些指令将“Hello World\n” 字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。
3、操作系统管理硬件
程序不会直接访问磁盘、显示器等外设,而是依靠操作系统提供的服务。所有应用对硬件的操作尝试都必须通过操作系统。
操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂而大不相同的低级硬件设备
操作系统通过几个抽象概念来实现这两个功能,进程、虚拟内存和文件。文件是对 IO 设备的抽象表示,虚拟内存是对主存和磁盘 IO 设备的抽象表示,进程是对处理器、内存和 IO 设备的抽象表示。
3.1、进程
进程是操作系统对一个正在运行的程序的一种抽象。 在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。
一个 CPU 看上去像是在并发地执行多个进程,这是通过处理器在进程间切换的机制(上下文切换)来实现对。
操作系统保持跟踪进程运行所需的所有状态信息,这种状态就是上下文,包括 PC 和寄存器文件的当前值以及主存的内容。
当操作系统决定把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文,恢复新进程的上下文,然后将控制权传递到新进程。
从一个进程到另一个进程的切换是由操作系统的内核(kernel)管理的,内核是操作系统代码常驻主存的部分。它不是一个独立的进程,而是系统管理全部进程所用代码和数据结构的集合。
3.2、线程
一个进程实际上由多个线程的组成,每个线程都运行在进程的上下文中,并共享同样的代码和数据。
线程成为越来越重要的编程模型,因为多线程比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。
3.3、虚拟内存
虚拟内存为每个进程提供了一个假象,即每个进程都独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。
每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。
虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每个地址的硬件翻译。基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。
3.4、文件
文件就是字节序列,仅此而已。 每个 IO 设备,包括磁盘、键盘、显示器,甚至网络,都可以看作文件。系统中的所有输入输出都是通过使用一组称为 Unix I/O 的系统函数调用读写文件来实现的。
文件的概念简单精致,它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的 I/O设备。
在 Linux 看来,一切皆文件。
4、重要主题
4.1、Amdah1定律
Gene Amdahl,计算领域的早期先锋之一,对提升系统某一部分性能所带来的效果做出了简单却有见地的观察。这个观察被称为Amdahl定律(Amdahl’s law)。该定律的主要思想是,当我们对系统的某个部分加速时,其对系统整体性能的影响取决于该部分的重要性和加速程度。
4.2、并发和并行
我们用术语并发(concurrency)来指一个系统在同一时刻做多见事情,用术语并行(parallelism)指使用并发使系统运行得更快。并行可以在一个系统的多个抽象层次被使用。我们这里强调三个层次,我们系统抽象的高层向底层讲。
- 线程级并发
- 指令集并行
- 单指令多数据并行
4.3、系统中抽象重要性
对于操作系统,我们介绍了三个抽象:文件是I/O的抽象,虚拟内存是程序内存的抽象,进程是运行时程序的抽象。这里我们增加一个新的抽象:虚拟机,它是整个计算机的抽象,包括操作系统,处理器,程序。