计算机系统是由硬件和系统软件组成的,它们共同工作来运行应用程序。虽然系统的具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有相似的硬件和软件组件,它们又执行着相似的功能。
一、信息就是位+上下文
我们通过追踪 hello 程序的生命周期来打开认识系统的大门。
- hello 程序:
#include
int main()
{
printf("Hello, world!n");
return 0;
}
- hello 程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创建并保存的文本文件,文件名是 hello.c。
- 源程序实际上就是一个由值 0 和 1 组成的位(又称为比特)序列,8 个位被组织成一组,称为字节。
- 每个字节表示程序中的某些文本字符。
- 下图给出了 hello.c 程序的 ASCII 码表示:
- hello.c 程序是以字节序列的方式储存在文件中的。
- 每个字节都有一个整数值,对应于某些字符。例如,第一个字节的整数值是 35,它对应的就是字符 "#"。第二个字节的整数值为 105,它对应的字符是 "i",依此类推。
- 注意:每个文本行都是以一个看不见的换行符 "n",来结束的,它所对应的整数值为10。
- 像 hello.c 这样只由 ASCII 字符构成的文件称为文本文件,所有其他文件都称为二进制文件。
- hello.c 的表示方法说明了一个基本思想:系统中所有的信息——包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的,区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。
- 比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。
二、程序翻译
hello 程序的生命周期是从一个高级 C 语言程序开始的,这种形式能够被人读懂。
- 然而,为了在系统上运行 hello.c 程序,每条 C 语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。
- 在 Unix 系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:
# 1.确认是否有 C 语言的编译器。
[root@VM-8-11-centos c]# gcc --version
gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44)
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
[root@VM-8-11-centos c]#
# 2.hello 程序内容。
[root@VM-8-11-centos c]# cat hello.c
#include
int main()
{
printf("Hello, world!n");
return 0;
}
[root@VM-8-11-centos c]#
# 3.使用 GCC 编译器编译名为 "hello.c" 的源文件,并生成一个名为 "hello" 的可执行文件。
[root@VM-8-11-centos c]# gcc -o hello hello.c
[root@VM-8-11-centos c]#
# 4.列出生成的可执行文件。
[root@VM-8-11-centos c]# ls
hello hello.c
[root@VM-8-11-centos c]#
- 如下图所示,这个翻译过程可分为四个阶段完成:
main
// 以下每条语句都以一种文本格式描述了一条低级机器语言指令。
subq $8,%rsp
movl $.LC0,$edi
call puts
movl $0,$eax
addq $8,%rsp
ret
三、了解编译系统的益处
- 优化程序性能:
- 一个
switch
语句是否总是比一系列的if-else
语句高效得多? - 一个函数调用的开销有多大?
while
循环比for
循环更有效吗?- 指针引用比数组索引更有效吗?
- 为什么将循环求和的结果放到一个本地变量中,会比将其放到一个通过引用传递过来的参数中,运行起来快很多呢?
- 为什么我们只是简单地重新排列一下算术表达式中的括号就能让函数运行得更快?
- 一个
- 理解链接时出现的错误:
- 链接器报告说它无法解析一个引用,这是什么意思?
- 静态变量和全局变量的区别是什么?
- 如果你在不同的 C 文件中定义了名字相同的两个全局变量会发生什么?
- 静态库和动态库的区别是什么?
- 我们在命令行上排列库的顺序有什么影响?
- 最严重的是,为什么有些链接错误直到运行时才会出现?
- 避免安全漏洞:
- 缓冲区溢出错误是造成大多数网络和 Internet 服务器上安全漏洞的主要原因。
- 存在这些错误是因为很少有程序员能够理解需要限制从不受信任的源接收数据的数量和格式。
- 学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式会引起的后果。
四、处理器解释指令
此刻, hello.c 源程序已经被编译系统翻译成了可执行目标文件 hello,并被存放在磁盘上。
- 要想在 Unix 系统上运行该可执行文件,我们将它的文件名输入到称为 shell 的应用程序中:
# 1.执行程序。(hello 程序在屏幕上输出它的消息,然后终止。)
[root@VM-8-11-centos c]# ./hello
Hello, world!
[root@VM-8-11-centos c]#
4.1 系统的硬件组成
为了理解运行 hello 程序时发生了什么,我们需要了解一个典型系统的硬件组织。
- 一个典型系统的硬件组成:
-
总线:
- 贯穿整个系统的是一组电子管道,称作总线,它携带信息字节并负责在各个部件间传递。
- 通常总线被设计成传送定长的字节块,也就是字(word)。
- 字中的字节数(即字长)是一个基本的系统参数,各个系统中都不尽相同。
- 现在的大多数机器字长要么是 4 个字节(32 位),要么是 8 个字节(64 位)。
-
I/O 设备:
- I/O(输入/输出)设备是系统与外部世界的联系通道。
- 上图包括四个 I/O 设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序的磁盘驱动器。
- 最开始,可执行程序 hello 就存放在磁盘上。
- 每个 I/O 设备都通过一个控制器或适配器与 I/O 总线相连。
- 控制器和适配器之间的区别主要在于它们的封装方式。
- 控制器是 I/O 设备本身或者系统的主印制电路板(通常称作主板)上的芯片组。
- 而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是在 I/O 总线和 I/O 设备之间传递信息。
-
主存:
- 主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据,
- 从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。
- 从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(数组索引),这些地址是从零开始的。
- 一般来说,组成程序的每条机器指令都由不同数量的字节构成。与 C 程序变量相对应的数据项的大小是根据类型变化的。
- 比如,在运行 Linux 的 x86-64 机器上,
short
类型的数据需要 2 个字节,int
和float
类型需要 4 个字节,而long
和double
类型需要 8 个字节。
-
处理器:
- 中央处理单元(CPU),简称处理器,是解释(或执行)存储在主存中指令的引擎。
- 处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。
- 在任何时刻, PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)。
- 从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。
- 处理器看上去是按照一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的。
- 在这个模型中,指令按照严格的顺序执行,而执行一条指令包含执行一系列的步骤。
- 处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新 PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻。
-
下面是一些简单操作的例子,CPU 在指令的要求下可能会执行这些操作:
- 加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来的内容。
- 存储:从寄存器复制一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容。
- 操作:把两个寄存器的内容复制到 ALU, ALU 对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
- 跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC 中原来的值。
4.2 运行 hello 程序
现在开始介绍当运行示例程序时到底发生了些什么。
五、至关重要的高速缓存
上面的示例揭示了一个重要的问题,即系统花费了大量的时间把信息从一个地方挪到另一个地方。
- 根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备。
- 针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器( cache memory,简称为 cache 或 高速缓存 )。作为暂时的集结区域,存放处理器近期可能会需要的信息。
- L1 和 L2 高速缓存是用一种叫做静态随机访问存储器( SRAM )的硬件技术实现的。
- 比较新的、处理能力更强大的系统甚至有三级高速缓存:L1、L2 和 L3。
- 系统可以获得一个很大的存储器,同时访问速度也很快,原因是利用了高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。
六、存储设备的层次结构
实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构。
- 在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。
- 寄存器文件在层次结构中位于最顶部,也就是第 0 级或记为 L0,这里我们展示的是三层高速缓存L1 到 L3,占据存储器层次结构的第 1 层到第 3 层。主存在第 4 层,以此类推:
- 存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。
- 在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上的数据的高速缓存。
七、操作系统管理硬件
所有应用程序对硬件的操作尝试都必须通过操作系统。
- 操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用;
- 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。
- 操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能。
- 计算机系统的分层视图:
- 操作系统提供的抽象表示:
- 文件是对 I/O 设备的抽象表示,虚拟内存是对主存和磁盘 I/O 设备的抽象表示,进程则是对处理器、主存和 I/O 设备的抽象表示。
7.1 进程
进程是操作系统对一个正在运行的程序的一种抽象。
- 在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。
- 而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的 CPU 个数的。
- 传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执行多个程序。
- 无论是在单核还是多核系统中,一个 CPU 看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。
- 进程的上下文切换:
-
为了简化讨论,我们只考虑包含一个 CPU 的单处理器系统的情况:
- 操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,包括许多信息,比如 PC 和寄存器文件的当前值,以及主存的内容。
- 在任何一个时刻,单处理器系统都只能执行一个进程的代码。
- 当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从它上次停止的地方开始。
-
从一个进程到另一个进程的转换是由操作系统内核( kernel )管理的。
-
内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,比如读写文件,它就执行一条特殊的系统调用( system call )指令,将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。
-
注意:内核不是一个独立的进程。相反,它是系统管理全部进程所用代码和数据结构的集合。
7.2 线程
尽管通常我们认为一个进程只有单一的控制流,但是在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。
- 由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。
- 当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法。
7.3 虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。
- 虚拟地址空间(其他Unix系统的设计也与此类似),在Linux中,地址空间最上面的区域是保留给操作系统中的代码和数据的,这对所有进程来说都是一样。
- 地址空间的底部区域存放用户进程定义的代码和数据。
- 请注意,图中进程的虚拟空间地址是从下往上增大的:
- 每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。
- 程序代码和数据:对所有的进程来说,代码是从同一固定地址开始,紧接着的是和 C 全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的。
- 堆:代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像 malloc() 和 free() 这样的 C 标准库函数时,堆可以在运行时动态地扩展和收缩。
- 共享库:大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的区域,共享库的概念非常强大,也相当难懂。
- 栈:位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别地,每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。
- 内核虚拟内存:地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些操作。虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每个地址的硬件翻译。基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。
7.4 文件
文件就是字节序列,仅此而已。
- 每个 I/O 设备,包括磁盘、键盘、显示器,甚至网络,都可以看成是文件。
- 系统中的所有输入输出都是通过使用一小组称为 Unix I/O 的系统函数调用读写文件来实现的。
- 文件这个简单而精致的概念是非常强大的,因为它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的 I/O 设备。
八、系统之间利用网络通信
随着 Internet 这样的全球网络的出现,从一台主机复制信息到另外一台主机已经成为计算机系统最重要的用途之一。比如,像电子邮件、即时通信、万维网、FTP 和
telnet
这样的应用都是基于网络复制信息的功能。
- 回到 hello 示例,我们可以使用熟悉的
telnet
应用在一个远程主机上运行 hello 程序。 - 假设用本地主机上的
telnet
客户端连接远程主机上的telnet
服务器。在我们登录到远程主机并运行 shell 后,远端的 shell 就在等待接收输入命令。此后在远端运行 hello 程序。 telnet
服务器向客户端发送字符串 "hello worldn"(包含以下五个基本步骤):
- 这种客户端和服务器之间交互的类型在所有的网络应用中是非常典型的。
九、重要概念补充
计算机系统是硬件和系统软件互相交织的集合体,它们必须共同协作以达到运行应用程序的最终目的。
9.1 并发和并行
数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算机做得更多,另一个是我们想要计算机运行得更快。当处理器能够同时做更多的事情时,这两个因素都会改进。
- 并发( concurrency ):是一个通用的概念,指一个同时具有多个活动的系统。
- 并行( parallelism ):指的是用并发来使一个系统运行得更快,并行可以在计算机系统的多个抽象层次上运用。
- 在此,我们按照系统层次结构中由高到低的顺序重点强调三个层次。
- 线程级并发。
- 指令级并行。
- 单指令、多数据并行。
-
构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了并发。
-
这种并发形式允许多个用户同时与系统交互,例如,在一个窗口中开启 Web 浏览器,在另一窗口中运行字处理器,同时又播放音乐。在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器完成的。这种配置称为单处理器系统。
-
当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到了一个多处理器系统。
-
多核处理器是将多个 CPU (称为“核”)集成到一个集成电路芯片上(下图为多核处理器的组织结构):
- 超线程:有时称为同时多线程( simultaneous multi - threading ),是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,比如程序计数器和寄存器文件,而其他的硬件部分只有一份,比如执行浮点算术运算的单元。
- 常规的处理器需要大约 20 000 个时钟周期做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得 CPU 能够更好地利用它的处理资源。
- 比如,假设一个线程必须等到某些数据被装载到高速缓存中,那 CPU 就可以继续去执行另一个线程。举例来说, Intel Core i 7 处理器可以让每个核执行两个线程,所以一个 4 核的系统实际上可以并行地执行 8 个线程。
- 多处理器的使用可以从两方面提高系统性能。
- 首先,它减少了在执行多个任务时模拟并发的需要。正如前面提到的,即使是只有一个用户使用的个人计算机也需要并发地执行多个活动。
- 其次,它可以使应用程序运行得更快,当然,这必须要求程序是以多线程方式来书写的,这些线程可以并行地高效执行。
- 在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。
- 在流水线中,将执行一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行一个步骤。这些阶段可以并行地操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近于一个时钟周期一条指令的执行速率。
- 如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为超标量( super-scalar )处理器。大多数现代处理器都支持超标量操作。
- 在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即 SIMD 并行。例如,较新几代的 Intel 和 AMD 处理器都具有并行地对 8 对单精度浮点数( C 数据类型
float
)做加法的指令。 - 提供这些 SIMD 指令多是为了提高处理影像、声音和视频数据应用的执行速度。虽然有些编译器会试图从 C 程序中自动抽取 SIMD 并行性,但是更可靠的方法是用编译器支持的特殊的向量数据类型来写程序,比如 GCC 就支持向量数据类型。
9.2 计算机系统中抽象的重要性
抽象的使用是计算机科学中最为重要的概念之一。
-
例如,为一组函数规定一个简单的应用程序接口( API )就是一个很好的编程习惯,使用者无须了解它内部的工作便可以使用这些代码。不同的编程语言提供不同形式和等级的抽象支持,例如 Java 类的声明和 C 语言的函数原型。
-
计算机系统中的一个重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性:
- 文件是对 I/O 设备的抽象,虚拟内存是对程序存储器的抽象,而进程是对一个正在运行的程序的抽象。
- 我们再增加一个新的抽象:虚拟机,它提供对整个计算机的抽象,包括操作系统、处理器和程序。虚拟机的思想是 IBM 在20世纪60年代提出来的,但是最近才显示出其管理计算机方式上的优势,因为一些计算机必须能够运行为不同的操作系统(例如, Mierosoft Windows , MacOS 和 Linux )或同一操作系统的不同版本设计的程序。