文章目录
第一章 计算机系统漫游
计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。虽然系统的具体实现随着时间不断变化,但是系统内在的概念并没有改变。所有计算机都由相似的硬件和软件组成,它们又执行着相似的功能。一些程序员希望了解这些组件是如何工作的,以及,这些组件是如何影响程序的正确性和性能的,以此来提高自身的技能。本书便是为这些读者而写的。
现在就要开始一段有趣的漫游经历了。如果你全力投入 学习本书中的概念,完全理解底层计算机系统以及它对应用程序的影响,那么你会成为步上为数不多的“大牛”的道路。
你将会学习一些实践技巧,比如,如何避免由计算机表示数字的方式,引起的奇怪的数字错误。你将会学习怎样通过一些小窍门来优化你编写的 C 代码,以充分利用现代处理器和存储器系统的设计。你将了解编译器是如何实现过程调用的,以及如何利用这些知识来避免缓冲区溢出错误,带来的安全漏洞,这些弱点给网络和英特网软件带来了巨大的麻烦。你讲学会如何识别和避免链接时那些令人讨厌的错误,它们困扰着普通的程序员。你将学会如何编写自己的Unix shell、自己的动态存储分配包,甚至自己的Web服务器。你会认识并发带来的希望和陷阱,这个主题随着单个芯片上集成了多个处理器核,变得越来越重要。
在 Kernighan 和 Ritchie 的关于 C 语言编程的经典教材【61】中,他们通过图1-1所示的 hello 程序来向读者介绍 C。尽管 hello 程序非常简单,但是为了让它实现运行,系统的每个主要 组成部分 都需要协调工作。从某种意义上来说,本书的目的,就是要帮助你了解当你在系统上执行 hello 程序时,系统发生了什么,以及 为什么会这样。
我们通过跟踪 hello 程序的生命周期,来开始对系统的学习。从它被程序员创建开始,到在系统上运行,输出简单的消息,然后终止。我们将 沿着这个程序的生命周期,简要的介绍一些逐步出现的关键概念、专业术语和组成部分。后面的章节将围绕这些内容展开。
1.1 信息 就是 位 + 上下文
hello 程序的生命周期是从一个 源程序
(或者说是 源文件
)开始的,即程序员通过编辑器创建并保存的文本文件,文件名是 hello.c。源程序实际上就是一个由值 0 和 1 组成的位(又称为 比特),8个位被组织成一组,称为 字节。每个字节表示程序中的某些文本字符。
大部分的现代计算机系统都是用 ASCII 标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值来表示每个字符。比如,图 1-2 中给出了 hello.c 程序的 ASCII 表示。
hello.c 程序是以 字节序列的方式,储存在文件中的。每个字节都有一个整数值, 对应与某些字符。例如,第一个字节的整数值是 35,它对应的字符就是 “#”。第二个字节的整数值为 105,它对应的字符是 “i”,以此类推。注意,每个文本行,都是以 一个看不见的换行符 \n
来结束的。像 hello.c 这样只由 ASCII 字符构成的文件称为 文本文件,所有其他文件称为 二进制文件。
hello.c 的表示方法,说明了一个思想:系统中的所有信息,包括 磁盘文件、内存中的程序、内存中存放的用户数据 以及 网络上传送的数据,都是由 一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示 一个整数、浮点数、字符串 或者 机器指令。
作为程序员,我们需要了解数字的机器表示方式,因为它们与实际的整数和实数是不同的。它们是对真值的有限近似值,有时会有意想不到的行为表现。这方面的基本原理将在第 2 章中详细描述。
1.2 程序被其他程序翻译成不同的格式
hello 程序的生命周期是从一个高级 C 语言程序开始的,因为这种形式能够被人读懂。然而 为了能在系统上运行 hello.c 程序,每条 C 语言都必须被其他程序转化为一系列的低级机器语言指令
。然后这些指令按照一种称为 可执行目标程序
的格式打好包,并以二进制磁盘文件的形式,存放起来。目标程序也称为 可执行目标文件。
在 Unix 系统上,从 源文件
变成 目标文件
的转化,是由 编译器驱动程序
完成的:
linux> gcc -o hello hello.c
在这里,GCC编译器驱动程序读取 源程序文件 hello.c,并把它翻译成一个可执行的目标文件 hello。这个翻译过程可分为四个阶段完成,如图 1-3 所示。执行这四个阶段(预处理器、编译器、汇编器、链接器)的程序,一起构成了编译系统(compilation system)。
- 预处理阶段。 预处理器(cpp)根据字符 # 开头的命令,修改原始的 C 程序,比如 hello.c 中的第 1 行的 #include<stdio.h>命令,告诉 预处理器 读取系统头文件 stdio.h 的内容,并把它直接插入程序文本中。结果就得到了另一个 C 程序,通常是以 .i 作为文件扩展名。
- 编译阶段。 编译器(ccl)将文本文件 hello.i 翻译成 文本文件 hello.s,它包含一个汇编语言程序。该程序包含 函数main的定义,如下所示:
定义中 2~7 行的每条语句都以一种文本格式描述了一条 低级机器语言指令。汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。例如,C 编译器 和 Fortran 编译器 产生的输出文件,用的都是一样的汇编语言。 - 汇编阶段。 接下来,汇编器(as)将 hello.s 翻译成 机器语言指令,把这些指令打包成一种叫做 可重定位目标程序(relocatable object program)的格式,并把结果保存在目标文件的 hello.o 中。hello.o 文件是一个二进制文件,它包含的 17 个字节是函数main的指令编码。如果我们在文本编辑器中打开 hello.o 文件,将看到一堆乱码。
- 链接阶段。 请注意,hello 程序调用了 printf 函数,它是每个 C 编译器都提供的标准 C 库中的一个函数。printf 函数存在于 一个名为 printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中。链接器(ld)就负责处理这种合并。结果就得到 hello 文件,它是一个可执行目标文件(或者简称为 可执行目标文件),可以被加载到内存中,由系统执行。
1.3 了解编译系统如何工作是大有益处的
对于像 hello.c 这样简单的程序,我们可以依靠 编译系统
生成有效的机器代码。但是,有一些重要的原因促使程序员必须知道编译系统是如何工作的
。
5. 优化程序性能。 现代编译器都是成熟的工具,通常可以生成很好的代码。作为程序员,我们无需为了写出高效代码二区了解编译器的内部工作。但是,为了在 C 程序中做出好的编码选择,我们需要了解一些机器代码 以及 编译器将不同的 C 语句转化为机器代码的方式。比如,一个 switch 语句是否总是比一系列的 if-else 高效的多?一个函数调用的开销有多大?while 循环比 for 循环更有效吗?指针引用比数组引用更有效吗?为什么将循环求和的结果放到一个本地变量中,会比 将其放到一个 通过引用传递过来的参数 中,运行起来快很多呢?为什么我们只是简单的重新排列一下算数表达式中的括号就能让函数运行得更快呢?
在第 3 章中,我们将介绍 x86-64
,最近几代 Linux、Macintosh 和 Windows 计算机的计算语言。我们将讲述编译器是如何把不同的 C 语言结构翻译成这种 机器语言的。在第 5 章中,你将学习如何通过简单转换 C 语言,帮助编译器更好的完成工作,从而调整 C 程序的性能。在第 6 章中,你将学习存储器系统的层次结构特征
, C 语言编译器如何将数组存放在内存中,以及 C 程序又是如何利用这些知识从而更高效的运行。
6. 理解链接时出现的错误。 根据我们的经验,一些最令人困扰的程序错误,往往都与链接器操作有关
。尤其是,当你试图构建大型的软件系统时。比如,链接器报告说,它无法解析 一个引用,这是什么意思?静态变量和全局变量的区别是什么?如果你在两个不同的 C 文件中定义了相同的两个全局变量,会发生什么?静态库和动态库的区别是什么?我们在命令行上排列库的顺序有什么影响?最严重的是,为什么有些错误,直到链接时,才会出现?在第 7 章
中,你将会得到这些答案
。
7. 避免安全漏洞。 多年来,缓冲区溢出
是造成大多数网络和 Internet 服务器上安全漏洞的主要原因。存在这些错误是因为很少有程序员能够理解需要限制从不受信任的源接收数据的数量和格式
。学习安全编程的第一步就是,理解数据和控制信息存储在程序栈上的方式会引起的后果。作为学习汇编语言的一部分,我们将在第 3 章
中描述 堆栈原理
和缓冲区溢出错误
。我们还将学习 程序员、编译器和操作系统 可以用来降低攻击威胁的方法。
1.4 处理器读并解释储存在内存中的指令
此刻,hello.c 程序已经被编译系统翻译成了 可执行目标文件 hello,并把它放在磁盘上。要想在 Unix 系统上运行该可执行文件,我们将它的文件名输入到称为 shell 的应用程序中:
linux> ./hello
hello, world
linux>
shell 是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。如果 该命令行的第一个单词不是一个内置的 shell 命令,那么 shell 就会假设这是一个可执行文件的名字,它将加载并运行这个文件。所以在此例中, shell 将加载并运行 hello 程序,然后等待程序终止。hello 程序在屏幕上输出它的消息,然后终止。shell 随后输出一个提示符,等待下一个输入的命令行。
1.4.1. 系统的硬件组成
为了理解并运行 hello 程序时发生了什么,我们需要了解一个典型的硬件组织,如果 1-4 所示。这张图是近期 Intel 系统产品族的模型,但是所有其他系统也有相同的外观和特性。现在不用担心这张图很复杂,我们将在本书分阶段对其进行详细的介绍。
1. 总线
贯穿真个系统的是一组电子管道,称作 总线
,它携带字节信息并负责在各个部件间传递。通常总线被设计成传送定长的字节块,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,各个系统都不尽相同。现在的大多数机器字长要么是 4 个字节(32位),要么是 8 个字节(64)位。本书中,我们不对字节做任何固定的假设。相反,我们需要明确定义的上下文中具体说明一个 “字” 是多大。
2. I/O设备
I/O(输入/输出)设备,是 系统 与 外部世界 的联系通道。
我们的示例系统包括 4 个 I/O 设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用户长期存储数据和程序的磁盘驱动器(简单的说,就是 磁盘)。最开始,可执行程序就是放在磁盘上。
每个 I/O 设备都通过一个控制器或适配器与 I/O 总线相连。控制器和适配器之间的区别主要在于,它们的封装方式。控制器是 I/O 设备本身或者系统的主印刷电路板(通常称作 主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能,都是在 I/O 设备与 I/O 总线 之间传递信息。
第 6 章会更加详细的说明磁盘之类的 I/O 设备是如何工作的。在第 10 章中,你将学习如何在应用程序中利用 Unix I/O 接口访问设备,不过这些技术对于其他设备也是通用的。
3. 主存
主存 是一个 临时存储设备。
在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主从 是由一组动态随机存取存储器(DRAM)
芯片组成的。从逻辑上来说,存储器是一个线性的字节数组。每个字节都有其唯一的地址(数组索引),这些地址是从 0 开始的。一般来说,组成程序的每条机器指令都由不同数量的字节构成。与 C 程序变量相对应的数据项的大小是根据类型变化的。比如,在运行 Linux 的 x86-64 机器上,short 类型的数据需要 2 个字节,int 和 float 需要 4 个字节,而 long 和 double 需要 8 个字节。
第 6 章将具体介绍存储器技术。比如,DRAM 芯片是如何工作的,它们又是如何组合起来,构成主存的。
4. 处理器
中央处理单元(CPU),简称 处理器,
是 解释或执行
存储在 主存
中指令的引擎。处理器的核心
是一个大小为一字节的存储设备(或寄存器),称为 程序计数器(PC)。
在任何时刻,PC 都指向主存的某条机器语言指令(即含有该条指令的地址)。
从系统通电开始,到系统断电,处理器一直在不断的执行程序计数器执行的指令,再更新程序计数器,使其指向下一条指令。处理器看上去是按照一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的。在这个模型中,指令按照严格的顺序执行,而执行一条指令,包括一系列的步骤。处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新 PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻。
这样的简单操作并不多,它们围绕着 主存、寄存器文件(register file)和 算数/逻辑单元(ALU)进行。寄存器文件是一个很小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字。ALU 计算新的数据和地址值。下面是一些简单操作的例子,CPU 在指令的要求下,可能会执行这些操作:
- 加载 从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来的内存。
- 存储 从寄存器复制一个字节或者一个字到主存的某个位置,以覆盖整个位置上原来的内容。
- 操作 把两个寄存器的内容复制到 ALU,ALU 对这两个字节做算数运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
- 跳转 从指令本身抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖 PC 中原来的值。
处理器看上去是它的指令架构的简单实现,但是实际上,现代处理器 使用了非常复杂的机制来加速程序的执行。因此,我们将处理器的指令集架构和处理器的微体系结构区分开来:指令集架构描述的是每条机器代码指令的效果;而微体系结构描述的是处理器实际上是如何实现的。在第 3 章研究机器代码时,我们考虑的是机器的指令集架构所提用的抽象性。第 4 章更详细的介绍处理器实际上是如何实现的。第 5 章用一个模型说明现代处理器是如何工作的,从而预测和优化机器语言执行的性能。
1.4.2 运行 hello 程序
前面简单描述了系统的硬件和操作,现在开始介绍当我们运行实例代码时到底发生了些什么。在这里必须省略很多细节,稍后会做补充,但是现在我们很满意于整体上的描述。
初始时,shell 程序执行它的指令,等待我们输入一个命令。当我们在键盘上输入字符串 “./hello” 后,shell 程序将字符逐一读入寄存器,再把它存放到内存中,如图 1-5 所示。
当我们在键盘上敲回车时,shell 程序就知道我们已经结束了命令的输入。然后 shell 执行一系列指令来加载可执行的 hello 文件,这些指令将 hello 目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串 “hello, world\n”。
利用 直接存储器存取技术,数据可以不通过处理器而直接从磁盘到达主存。这个步骤如图 1-6 所示。
一旦目标文件 hello 中的代码和数据被加载到主存,处理器就开始执行 hello 程序中的 main 程序中的机器语言指令。这些指令将 “hello,world\n”字符串中的字节从主存复制到寄存器文件,再从寄存器文件复制到显示设备,最终显示在屏幕上。这个步骤如图 1-7 所示。
1.5 高速缓存至关重要
这个简单的示例揭示了一个重要的问题,即系统话费了大量的时间把信息从一个地方挪到了另一个地方。hello 程序的机器指令最初是存放在磁盘上,当程序加载时,它们被复制到主存。当处理器运行程序时,指令又从主存复制到处理器。相似的,字符串 “hello,world\n” 开始是在磁盘上,然后被复制到主存,最后从主存复制到显示设备。从程序员的角度看,这些复制就是开销,减慢了程序“真正”的工作。因此,系统设计者的一个主要目标就是使这些复制尽可能快的完成。
根据机械原理,较大的存储设备要比较小的设备,运行的慢,而快速设备的造价远高于同类的低速设备。比如说,一个典型系统上的磁盘驱动可能比主存大 1000 倍,但是对于处理器而言,从磁盘驱动器上读取一个字的时间开销可能要比从主存中读取的开销大 1000 万倍。
类似的,一个典型的寄存器文件只存储几百字节的信息,而主存里可存放几十亿字节。然而,处理器从寄存器文件中读取数据要比从主存中读取 几乎要快 100 倍。更麻烦的是,随着这些年半导体技术的进步,这种处理器与主存之间的差距还在增大。加快处理器的运行要比速度要比加快主存的运行速度要容易和便宜的多。
针对这种处理器
与主存
之间的差异,系统设计者采用了更小更快的存储设备,称为 高速缓存存储器(cache memory,简称为 cache 或 高速缓存),
作为暂时的集结区域,存放处理器近期可能会处理的信息。图 1-8 展示了一个典型系统中的高速缓存处理器。位于处理器芯片上的 L1 高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。一个容量为数十万到数百万字节的更大的 L2 高速缓存通过一条特殊的总线连接到处理器。进程访问 L2 高速缓存的时间要比访问 L1 高速缓存的时间长 5 倍,但是这仍然比访问主存的时间快 5 ~ 10 倍。L1 和 L2 高速缓存是用一种叫做 静态随机访问存储器 (SRAM)的硬件技术实现的。比较新的、处理能力更强大的系统甚至有三级告诉缓存:L1、L2 和 L3。系统可以获得很大的一个存储器,同时访问速度也很快,原因是利用了高速缓存的局部性原理,即 程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。
本书得出的重要结论之一就是:意识到 高速缓存存储器
存在的应用程序员能够利用高速缓存 将程序的性能提供一个数量级。
你将在第 6 章里学习这些重要的设备以及如何利用它们。
1.6 储存设备形成层次结构
在处理器和一个较大较慢的设备(例如 主存)之间插入一个更小更快的存储设备(例如 高速缓存)的想法,已经成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被组织成立一个 存储器层次结构
,如果 1-9 所示l。在这个层次结构中,从上之下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结构中处于最顶部,也就是第 0 级 或 L0。这里我们展示的是三层高速缓存 L1 到 L3。占据层次结构的 第1 层 到 第 3 层,主存在第 4 层,以此类推。
存储器层次结构的思想是 上一层的存储器作为第一层存储器的高速缓存。因此,寄存器文件就是 L1 的高速缓存,L1 就是 L2 的高速缓存, L2 就是 L3 的高速缓存, L3 就是 主存的高速缓存, 主存就是磁盘的的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上的数据的高速缓存。
正如可以运用不同的高速缓存的只是来提高程序性能一样,程序员同样可以利用对整个存储器层次结构的理解来提高程序性能。在第 6 章将更详细的讨论这个问题。
1.7 操作系统管理硬件
让我们回到 hello 程序的例子。当 shell 加载和运行 hello 程序时,以及 hello 程序输出自己的消息时,shell 和 hello 程序都没有直接访问键盘、显示器、磁盘或者主存。取而代之的是,它们利用操作系统提供的服务。我们可以把 操作系统
看作是 硬件和应用程序之间插入的一层软件
,如图 1-10 所示。所有应用程序对硬件的操作尝试都必须通过操作系统。
操作系统有两个基本功能:(1)防止硬件被失控的应用程序滥用。(2)向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过几个基本的抽象概念(进程
、虚拟内存
、文件
)来实现这两个功能。如图 1-11 所示,文件是对 I/O 设备的抽象表示,虚拟内存是对主存和磁盘 I/O 设备的抽象表示,进程则是对处理器、主存和 I/O 设备的抽象表示。我们将依次讨论每种抽象表示。
1.7.1 进程
像 hello 这样的程序在现代系统上运行,操作系统会提供一种假象,就好像系统上只有这个程序在运行。系统看上去是在独占处理器、主存和I/O设备。系统看上去就像在不间断的一条一条的执行程序中的指令,即 该程序上的代码和数据是系统上唯一的对象。这些假象是通过 进程
的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占的使用硬件。而 并发运行
,则是说 一个进程的指令和另一个进程的指令是 交错执行
的。在大多数系统中,需要运行的进程数是 多于 可以运行它们 的 CPU 个数的。传统系统在一个时刻只能运行一个时刻,而先进的多核处理器同时能够执行多个程序。无论是在单核还是多核系统中,一个 CPU 看上去都像是 并发的在执行多个进程,这是通过处理器在进程间切换实现的。操作系统实现这种交错执行的机制,叫做 上下文切换
。为了简化讨论,我们只考虑 包含一个 CPU 的单处理器的情况,我们会在 1.9.2 节中讨论多处理器系统。
操作系统保持 跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,包括许多信息,比如,PC 和 寄存器文件的当前值,以及 主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就回进行上下文切换,即 保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递给新进程。新进程就会从它上次停止的地方开始。 图 1-12 展示了示例 hello 程序运行场景的基本概念。
示例场景中有两个进程:shell 进程 和 hello 进程。 最开始,只有 shell 进程在运行,即 等待命令行数上的输入。当我们让它运行 hello 程序时, shell 通过调用一个专门的函数,即 系统调用,来执行我们的请求,系统调用会将控制权转移给操作系统,操作系统保存 shell 进程的上下文,创建一个新的 hello 进程及上下文,然后将控制权传递给新的 hello 进程。hello 进程终止后,操作系统恢复 shell 进程的上下文,并将控制权传回给它,shell 进程会继续等待下一条命令行输入。
如图 1-12 所示,从一个进程到另一个进程的转换
,是由 操作系统内核(kernel)
管理的。内核 是操作系统代码常驻主存的部分。当应用程序 需要 操作系统 的 某些操作时,比如 读写文件,它就执行一条特殊的系统调用(system call)指令,将控制权传递给内核。然后内核执行被请求的操作,并返回应用程序。注意,内核不是一个独立的进程
。相反,它是 系统管理全部进程所用代码和数据结构的集合。
实现进程这个抽象概念,需要低级硬件和操作系统软件之间的紧密合作。我们将在第 8 章中揭示这项工作的原理,以及应用程序是如何创建和控制它们的进程的。
1.7.2 线程
通常我们认为,一个进程只有一个单一的控制流,但是在现代系统中,一个进程
实际上可以由 多个称为线程
的执行单元 组成
,每个 线程
都运行在 进程的上下文中
,并 共享同样的代码和全局数据
。由于网络服务器中对并行处理的要求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为一般来说,线程都比进程更高效。当有多处理器可用的时候,多线程也是一种使得程序可以运行的更快的方法,我们将在 1.9.2 中讨论这个问题。在第 12 章中,你将学习并发的概念,包括如何写线程化的程序。
1.7. 3 虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即 每个进程都在独占的使用主存。每个进程看到的内存都是一致的,称为 虚拟地址空间。图 1-13 所示的是 Linux 进程的虚拟地址空间(其他 Unix 系统的设计也与此类似)。在 Linux 中,地址空间最上面的区域是 保留给操作系统中的代码和数据的。这对所有进程来说都是一样。地址空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。
每个进程看到的虚拟地址空间由大量准确定义的取构成,每个区都有专门的功能。在本章的后续章节你将学习更多有关这些区的知识,但是先简单了解一下每一个区是非常有益的。我们从最低的地址开始,逐步向上介绍。
- 程序代码和数据
对所有的进程来说,代码是从同一固定地址开始
,紧接着的是和 C 全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的,在示例中就是可执行文件 hello。 在第 7 章我们研究链接和加载时,你会学习更多有关地址空间的内容。 - 堆
代码和数据区后紧跟着的是运行时堆
。代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像 malloc 和 free 这样的 C 标准库函数时,堆
可以在运行时动态的扩展和收缩
。在第 9 章学习管理虚拟内存时,我们将更详细的研究堆。 - 共享库 大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库 这样的共享库的代码和数据的区域。共享库的概念非常强大,也非常难懂。在第 7 章介绍动态链接时,将学习共享库是如何工作的。
- 栈 位于 用户虚拟地址空间顶部的是用户栈,编译器用它实现函数调用。和堆一样,用户栈在程序执行期间可以动态的扩展和收缩。特别的,每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。在第 3 章中将学习编译器是如何使用栈的。
- 内核虚拟内存
地址空间顶部的区域是为内核留的
。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些操作。
虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每个地址的硬件翻译。基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。在第 9 章将解释它如何工作,以及为什么对现代系统的运行如此重要。
1.7. 4 文件
文件就是字节序列,仅此而已。每个 I/O 设备,包括磁盘、键盘、显示器,甚至网络,都可以看作是 文件。系统中的所有输入输出都是通过使用一小组称为 Unix I/O 的系统函数调用读写文件来实现的。
文件这个简单而又精致的概念,是十分强大的。因为,它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的 I/O 设备。例如,处理磁盘文件内容的应用程序员可以非常幸福,因为他们无需了解具体的磁盘技术。进一步说,同一个程序可以在使用不同磁盘技术的不同系统上运行,你将在第 10 章中学习 Unix I/O 。
1.8 系统之间利用网络通信
系统漫游至此,我们一直是把 系统 视作是一个孤立的硬件和软件的集合体。实际上,现代操作系统
经常通过 网络
和 其他系统连接在一起
。从一个单独的系统来看,网络可视作一个 I/O 设备,如图 1-14 所示。当一个系统从主存复制一串字节到网络适配器时,数据流经过网络到达另一台机器,而不是,比如说,到达本地磁盘驱动器。相似的,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。
随着 Interner 这样的全球网络的出现,从一台主机复制到另外一台主机已经成为计算机系统最重要的用途之一。比如,像电子邮件、即时通信、万维网、FTP 和 telnet 这样的应用都是基于网络复制信息的功能。
回到熟悉的 hello 示例,我们可以使用熟悉的 Telnet 应用在一个远程上运行 hello 程序。假设,用本地主机上的 Telnet 客户端连接 远程主机上的 Telnet 服务器。在我们登录远程主机运行 shell 后,远端的 shell 就在等待接受输入命令。此后在远端运行 shell 程序,包括如图 1-15 所示的 5 个基本步骤。
当我们在 Telnet 客户端键盘入 “hello” 字符串并敲下回车键后,客户端软件就会将这个字符串发送到 telnet 的服务器。telnet 服务器从网络上接受到这个字符串后,会把它传递给远端 shell 程序。接下来,远端 shell 运行 hello 程序,并将输出行返回给 telnet 服务器。最后, telnet 服务器通过网络把输出串转发给 telnet 客户端,客户端就将输出串输出到我们的本地终端上。
这种客户端和服务器之间交互的类型,在所有的网络应用中是非常典型的。在第 11 章中,你将学习如何构造网络应用程序,并利用这些知识创建一个简单的 Web 服务器。
1.9 重要主题
在此,小结一下我们系统式的系统漫游。这次得出一个很重要的观点,那就是 系统不仅仅只有硬件
。系统是硬件和系统软件的集合体
,他们必须共同合作,以达到运行应用程序的最终目的。本书的余下内容会讲述 硬件和软件 的详细内容,通过了解这些详细内容,你可以写出更便捷、更安全和更可靠的程序。
作为本章的结束,我们在此 强调几个贯穿计算机系统所有方面的重要概念
。我们会在本书的多处,讨论这些概念的重要性。
1.9.1 Amdahl 定律
Gene Amdahl,计算领域的早期先锋之一,对提升系统某一部分性能所带来的效果做出了简单却有见地的观察。这个观察被称为 Amdahl
(Amdahl’s law)定律
。该定律的 主要思想
是,当我们对某个系统的主要部分加速时,其对系统整体性能的影响取决于该部分的重要性和加速程度
。当系统执行某应用程序需要时间为 Told,现在所需时间为 (αTold)/ k 。因此,总的执行时间为
举个例子,考虑这样一种情况,系统的某个部分初始耗时比例为 60%(α = 0.6),其加速比例因子为 3(k = 3)。则我们可以获得加速比例为 1 / [0.4 + 0.6 / 3] = 1.67 倍。虽然我们对系统的一个主要部分做出了重大改进,但是获得系统加速比却明显小于这部分的加速比。这就是 Amdahl 定律的主要观点,要想显著加速整个系统,必须提升全系统中相当大的部分的速度。
练习题 1.1 假设你是一个卡车司机,要将土豆从爱达荷州的 Boise 运送到明尼苏达州 Minneapolis,全程 2500 公里。在限速范围内,你估计平均速度为 100公里 / 小时,整个形成需要 25 个小时。
A. 你听到新闻说蒙大拿州刚刚取消了限速,这使得行程中有 1500 公里卡车的速度可以为 150 公里 / 小时,那么这对整个行程的加速比是多少?
B. 你可以在 www.fasttrucks.com 网站上为自己的卡车买个新的涡轮增压器。网站现货供应各种型号,不过速度越快,价格越高。如果想要让整个行程的加速比为 1.67X,那么你必须以多快的速度通过蒙大拿州?
练习题 1.2 公司的市场部向你的客户承诺,下一个版本的软件性能将提升 2X。这项任务被分配给你。你已经确认只有 80% 的系统能被改进,那么,这部分需要被改进多久(即 k 取多少) ,才能达到整体性能目标?
Amdahl 定律有一个有趣的特殊情况是考虑 k 趋向 ∞ 时的效果。这就意味着,我们可以取系统的某一部分将其加速到一个点,在这个点上,这部分花费的时间可以忽略不计。于是我们可以得到
举个例子,如果 60% 的系统,能够做到加速到不花时间的程度,我们将获得的净加速比仍然只有 1 / 0.4 = 2.5X。
Amdahl 定律描述了 改善任何过程的一般原则。除了可以用在加速计算机系统方面之外,它还可以用在公司试图降低刀片制造成本,或学生想要提高自己的绩点平均值等方面。也许它在计算机世界里是最有意义的,在这里,我们常常把性能提升 2 倍或更高的比例因子。这么高的比例因子只有通过优化系统的大部分组件才能获得。
1.9.2 并发和并行
数字计算机的整个历史中,有两个需求是驱动进步的持续动力
,一个是我们想要计算机做的更多
,另一个是因为我们想要让计算机运行的更快
。当处理器能够同时做更多的事情时,这两个因素都会改进。我们用的术语 并发
(concurrency)是一个通用的概念,指 一个同时具有多个活动的系统;而术语 并行
(parallelism)指的是用 并发 来使一个系统运行的更快。并行可以在计算机系统的多个抽象层次上运用。在此,我们按照系统层次结构中由高到低不顺序重点强调三个层次。
- 1 线程级并发
构建在进程这个抽象之上,我们能够设计出同时多个程序执行的系统,这就导致了 并发
。使用线程,我们甚至能在一个进程中执行多个控制流。自 20 世纪 60 年代初期出现时间共享以来,计算机系统就开始有了对并发执行的支持。传统意义上,这种并发执行只是 模拟 出来的,是通过使一台计算机在它正在执行的进程间快速切换来实现的,就好像一个杂耍艺人保持多个球在空中飞舞一样。这种并发形式允许多个用户同时与系统交互,例如,当许多人想要从一个 Web 服务器获取页面时,它还允许一个用户同时从事多个任务,例如,在一个窗口中开启 Web 浏览器,在另一个窗口运行字处理器,同时又播放音乐。在以前,即使处理器必须必须在多个任务间切换,大多数实际的计算机也都是由一个处理器来完成的。这种配置称为单处理器系统
。
当构建一个由 单处理器系统内核 的 多处理器 组成的系统时,我们就得到了一个 多处理器系统。其实从 20 世纪 80 年代开始,在大规模的计算中就有了这种系统,但是知道最近,随着多核处理器
和超线程
(hyperthreaing)的出现,这种系统才变得常见。图 1-16 给出了这些不同处理器类型的分类。
多核处理器是将多个 CPU (称为 “核”)集成到一个集成电路芯上。图 1-17 描述的是一个典型的多核处理器的组织结构,其中微处理器芯片有 4 个 CPU 核,每个核 都有自己的 L1 和 L2 高速缓存,其中的 L1 高速缓存分为两个部分,一个保存最近取到的指令,另一个存放数据。这些核共享更高层次的高速缓存,以及到主存的接口。工业界的专家预言他们能够将几十个、最终会是上百个核,做到一个芯片上。
超线程,有时称为同时多线程,是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,比如程序计算器和寄存器文件,而其他的硬件部分只有一份,比如执行浮点算术运算的单元。常规的处理器需要大约 20,000 个始终周期做不同线程间的切换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得 CPU 能够更好的利用它的处理资源。比如,假设一个线程必须等到某些数据被装载到高速缓存中,那 CPU 就可以继续去执行另一个线程。举例来说, Intel Core i7 处理器可以让每个核执行两个线程,所以 一个 4 核 的系统实际上,可以并行的处理 8 个线程。
多处理器
的使用可以从两个方面
提升系统心性能。首先,它减少了在执行多个任务时模拟并发的需要
。正如前面提到的,即使 是 只有一个用户使用的个人计算机也需要并发的执行多个活动。其实,它可以使程序运行的更快
,当然,这要求程序必须是以多线程的形式来书写
的,这些线程可以并发高效的执行。因此,虽然,并发原理的形成和研究已经超过了 50 年的时间了,但是多核和超线程系统的出现才极大的激发了一种愿望,即 找到书写应用程序的方法利用硬件开发线程级并发性。第 12 章会更深入的讨论并发,以及使用并发来提供处理器资源的共享,使程序的执行允许有更多的并行。 - 2 指令集并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令集并行
。早期的微处理器,如 1978 年的 Intel 8086 ,需要多个(通常是 3 ~ 10 个)时钟周期来执行一条指令。最近的处理器可以保持每个时钟周期 2 ~ 4 条指令的执行效率。其实每条指令从开始到结束需要长的多的时间,大约 20 个或者更多周期,但是处理器使用了非常多的技巧来同时处理多大达100 条指令。在第 4 章中,我们研究流水线
(pipelining) 的使用。在流水线中,将只执行一条流水线所需要的活动划分成不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行一个步骤。这些步骤可以并行的操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近于一个时钟周期一条指令的执行效率。
如果处理器可以达到比一个周期一条指令更快的执行效率,就称之为超标量
(super-scalar) 处理器。多达数现代处理器都支持 超标量操作。在第 5 章中,我们将描述超标量处理器的高级模型。应用程序员可以用这个模型来理解程序的性能。然后,他们就写出能拥有更高程度的指令级并行性的程序代码,因而也运行的更快。 - 3 单指令、多数据并行
在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生可以多个并行执行的操作,这种方式称为单指令、多数据,即 SIMD 并行
。例如,较新几代的 Intel 和 AMD 处理器都具有并行的对 8 对单精度浮点数(C 数据类型 float)做加法的指令。
提供这些SIMD
指令,是为了提高高影像处理、声音和视频数据应用的执行速度
。虽然有些编译器会试图从 C 程序中自动抽取 SIMD 并行性,但是更可靠的方法是用编译器支持的特殊的向量数据类型来写程序,比如 GCC 就支持向量数据类型。作为对第 5 章中比较通用的程序优化描述的补充,我们在网络旁注 OPT:SIMD 中描述了这种编程方式。
1.9.3 计算机系统中抽象的重要性
抽象的使用是计算机系统中最为重要的概念之一。例如,为一组函数规定一个简单的应用程序接口(API)就是一个很好的编程习惯,程序员无需了解它内部的工作便可以使用这些代码。不同的编程语言提供不同形式和等级的抽象支持,例如 Java 类的声明和 C 语言的函数原型。
我们已经介绍了计算机系统中使用的几个抽象,如图 1- 18所示。在处理器里,指令集架构 提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现的就好像运行在一个一次只执行一条指令的处理器上。底层的硬件远比抽象描述的要复杂精细,它并行的执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。
在学习操作系统时,我们学习了三个抽象概念:文件是对 I/O设备的抽象,虚拟内存是对程序存储器的抽象,而进程是对一个正在运行的程序的的抽象。我们再增加一个新的抽象:虚拟机
,它提供对 整个计算机的抽象
,包括 操作系统
、处理器
和 程序
。虚拟机的思维是 20 世纪 60 年代提出来的,但是最近才显示出其管理计算机方式上的优势,因为一些计算机必须能够能够运行 为不同的操作系统(例如,Microsoft Windows、MacOS 和 Linux)或同一系统的不同版本 设计的的程序。
在本章后续的章节中,我们会具体介绍这些抽象。
1.10 小结
计算机系统是由软件和硬件组成的,它们共同协作以运行应用程序。计算机内部的信息,被表示为一组组的位,它们根据上下文有不同的翻译方式。程序被其他程序翻译成不同的形式,开始时是 ASCII 文本, 然后被编译器和链接器翻译成二进制可执行文件。
处理器读取并解释存放在主存里的二进制指令。因为计算机花费了大量的时间在 内存、I/O 设备 和 CPU 寄存器之间复制数据,所以将系统中的存储设备划分成 层次结构,CPU 寄存器在顶部,接着是多层的硬件高速缓存存储器、DRAM 主存 和 磁盘存储器。在层次模型中。在层次模型中,位于更高层的存储设备比低层的存储设备要快,单位比特造价也更高。层次结构中较高层次的存储设备可以作为较低层次设备的高速缓存。通过理解和运用这种存储层次结构的知识,程序员可以优化 C 程序的性能。
操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象:(1)文件是对 I/O 设备的抽象(2)虚拟内存是对主存和磁盘的抽象(3)进程是处理器、主存 和 I/O 设备的抽象。
最后,网络提供了计算机系统之间通信的手段。从特殊系统的角度来看,网络就是一种 I/O 设备。
参考文献说明
Ritchie 写了关于早期 C 和 Unix 的有趣的第一手资料 [91,92]。Ritchie 和 Thompson 提供了最早出版的 Unix 资料 [93]。 Silberschatz、Galvin 和 Gagne [102] 提供了关于 Unix 不同版本的详尽历史。GNU( www.gnu. org) 和 Linux( www. linux. org) 的网站上有大量的当前信息和历史资料。Posix 标准可以在线获得 ( www. unix. org)。
练习题答案
1.1 该公式说明 Amdahl 定律 不仅仅适用于计算机系统。
A. 根据公式 1.1,有 α = 0.6, k = 1.5。更直接的说,在蒙大拿行驶的 1500 公里需要 10 个小时,而其他行程也需要 10 个小时。则加速比为 25 / (10 + 10) = 1.25X。
B. 根据公式 1.1,有 α = 0.6,要求 S = 1.67,则可算出 k。更直接的说,要使行程加速达到 1,.67X,我们必须把全程时间减少到 15 个小时。蒙大拿以外仍要求 10 小时,因此,通过蒙大拿的时间就为 5 个小时。这就要求行驶速度为 300 公里 / 小时,对卡车这个速度太快了!
2. 理解 Amdahl 定律最好的办法就是解决一些实例。本题要求你从特殊的角度来看公式 1.1。
本题是公式的简单应用。已知 S = 2, α = 0.8,则计算 k :