第 1 章 计算机系统漫游
文章目录
1.1 信息就是位+上下文
//hello.c
#include <stdio.h>
int main()
{
printf("hello,world\n");
return 0;
}
上述程序的生命周期是从一个程序(或者说源文件)开始的,即程序员通过编辑器创建并保存的文本文件。源程序实际上就是一个由 0 0 0 和 1 1 1 组成的位序列, 8 8 8 个位作为一组,称为字节,每个字节表示程序中的一个字符。上述位又可称作比特。
hello.c
程序是以字节序列的方式存储在文件中的,每个字节都有一个整数值(即ASCII码值),对应于某些字符。例如,程序中第一个字节对应字符位 #
,ASCII码值为
35
35
35。注意,每一个文本行都以一个看不见的换行符 \n
结束。
像 hello.c
这样由字符构成的文件称为文本文件 ,所有其他文件都称为二进制文件。该程序启发我们:系统中的所有信息都是由一串比特序列表示的,区分不同数据的唯一方法就是读到这些数据时的上下文。比如,在不同的上下文中,一串相同的比特序列可能表示一个浮点数、整数、字符串或机器指令等。
1.2 程序被其他程序翻译成不同格式
为了能在系统上运行 hello.c
程序,每条C语言语句都必须被其他程序转化为一系列的低级机器语言指令,然后这些指令按照一种称为可执行目标程序(又称可执行目标文件)的格式打包,并以二进制磁盘文件的形式存起来。
在 Unix 系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:
linux> gcc -o hello hello.c
上述代码中,GCC编译器驱动程序读取源文件 hello.c
,并把它翻译成一个可执行目标文件 hello
。这个翻译过程可以分为四个阶段,如下图所示。执行这四个阶段的程序(预处理器、编译器、汇编器、链接器)一起构成了编译系统。
-
预处理阶段
预处理器根据以字符
#
开头的命令修改原始C程序。比如hello.c
中的#include <stdio.h>
命令告诉预处理器读取系统头文件stdio.h
的内容,并把它直接插入程序文本中,得到另一个C程序 ,通常以.i
作为文件扩展名。 -
编译阶段
编译器将预处理后得到的
hello.i
翻译成hello.s
,其包含一个汇编语言程序。该程序包含函数main
的定义,如下所示:main: subq $8, %rsp movl $.LCO, %edi call puts movl $0, %eax addq $8, %rsp ret
汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。
-
汇编阶段
接下来,汇编器将文本文件
hello.s
翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o
中。hello.o
是一个二进制文件,包含main
函数的指令编码。 -
链接阶段
hello
程序调用了printf
函数,其为标准库函数,保存在一个名为printf.o
的单独预编译好了的文件中,链接器可以将其合并到hello.o
中。链接完成后得到hello
文件,它是一个可执行目标文件,可以被加载到内存中由系统执行。
1.4 处理器读取并解释储存在内存中的指令
1.4.1 系统的硬件组成
-
总线
总线是贯穿整个系统的一组电子通道,其能携带信息并在各个部件间传递。通常总线被设计成传输定长的字节块,也叫做字。大多数机器的字长要么为 4 4 4 个字节( 32 32 32 位),要么为 8 8 8 个字节( 64 64 64 位)。
-
I/O 设备
I/O 设备是系统与外部世界的联系通道。上述示例系统包括四个 I/O 设备:鼠标、键盘(输入),显示器(输出),磁盘。最开始,可执行程序
hello
就放在磁盘上。每个 I/O 设备都通过一个控制器或适配器与 I/O 总线相连。 -
主存
主存是一个临时存储设备,在处理器执行程序时,用于存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的,从逻辑上来说,存储器是一个线性字节数组,每个字节有唯一的地址,且地址是从 0 0 0 开始的。
-
处理器
中央处理单元(CPU),简称处理器,是执行存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)。从系统通电到断电,处理器一直在不断地执行 PC 指向的指令,再更新 PC,使其指向下一条指令。
这些指令围绕着主存、寄存器文件和算术/逻辑单元(ALU)进行。寄存器文件是一个小的存储设备,由一些单个字长( 4 4 4 或 8 8 8 个字节)的的寄存器组成,每个寄存器有唯一的名字。ALU 负责计算新的数据和地址值。
1.5 高速缓存至关重要
考虑 hello
程序运行的过程:hello
程序的机器指令最初存放在磁盘上,当程序加载时,它们被复制到主存;当处理器运行程序时,指令又从主存复制到处理器。相似地,数据串 "hello, world\n"
最开始在磁盘上,然后被复制到主存,最后从主存复制到显示设备。这些复制过程减缓了程序运行速度。
根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价高于低速设备。针对处理器与主存间运行速度与储存空间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器,作为暂时的集结区域,存放处理器近期可能会需要的信息,以大大提高程序运行速度。
1.6 存储设备形成层次结构
在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存),这种想法已经成为一种普遍的观念。实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如下图所示。
上述层次采用了三层高速缓存结构,从上到下设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。
1.7 操作系统管理硬件
回到 hello
程序的例子。通过 shell
调用 hello
程序时,shell
和 hello
程序都没有直接访问键盘、显示器、磁盘或者主存,而是依靠操作系统提供的服务。可以把操作系统看成是应用程序和硬件之间插入的一层软件,所有应用程序对硬件的操作尝试都必须经过操作系统。
操作系统有两个基本功能:( 1 1 1)防止硬件被失控的应用程序滥用;( 2 2 2)防止硬件被失控的应用程序滥用。操作系统通过以下几个概念来实现这两个功能。
1.7.1 进程
像 hello
这样的程序在现代系统上运行时,操作系统会提供一种假象:好像系统上只有这一个程序在运行,程序像是独占地使用处理器、主存和 I/O 设备。这些假象是通过进程的概念来实现的。
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数可以多于 CPU 个数,一个 CPU 看上去都像是在并发地执行多个进程,这是通过 CPU 在进程间切换来实现的。操作系统的这种机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态就是上下文,包括如 PC 和寄存器文件的当前值、主存的内容等。在任何一个时刻,单处理器只能执行一个进程的代码,当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程,新进程会从上次它停止的地方开始继续运行。
下图展示了 hello
程序运行时的场景。该场景包括两个进程:shell
进程和 hello
进程。最开始只有 shell
进程运行,即等待命令行输入。 当我们输入 ./hello
运行 hello
程序时, shell
通过调用系统调用,来执行这一过程。系统调用会将控制权传递给操作系统,操作系统保存 shell
进程的上下文,创建一个新的 hello
进程及其上下文,然后将控制权传给新的 hello
进程。hello
进程结束后,操作系统恢复 shell
进程上下文并转移给它控制权,shell
进程等待下一命令行输入。
上图还展示了从一个进程到另一个进程的转换是由操作系统内核管理的。当应用程序需要操作系统的某些操作时,它就执行一条特殊的系统调用指令,将控制权传递给内核,然后内核执行相应操作并返回控制权。需要注意的是,内核不是一个独立的进程,而是系统管理全部进程的代码和数据结构的集合。
1.7.2 线程
通常我们认为一个进程只有单一的控制流,但现代系统中一个进程实际可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,共享同样的代码和全局数据。多线程是一种使得程序运行得更快的方法。
1.7.3 虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象:每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。下图所示为 Linux 进程的虚拟地址空间。在 Linux 中,地址空间最上面的区域是保留给操作系统中的代码和数据的,这对所有进程都是一样。地址空间底部区域存放用户定义的代码和数据,图中地址从下往上是增大的。
下面分块介绍各区。
-
程序代码和数据
对所有进程来说,代码是从同一固定地址开始,紧接着和 C 全局变量相对应的数据位置。代码和数据区是按照可执行文件的内容初始化的,例如从
hello
文件。 -
堆
代码和数据区后是运行时堆。代码和数据区在进程一开始运行时大小已经确定,而堆可以在调用如
malloc
和free
时动态地扩展和收缩。 -
共享库
大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的区域。
-
栈
位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。栈和堆一样可以动态扩展收缩(调用函数时栈扩展,函数返回 时栈收缩)。
-
内核虚拟内存
地址空间顶部的区域是为内核保留的。不允许程序读写这个区域或者直接调用内核代码定义的函数,程序必须调用内核来执行这些操作。
1.7.4 文件
文件就是字节序列,仅此而已。每个 I/O 设备,包括磁盘、键盘、显示器,甚至是网络,都可以看成是文件。
1.8 系统之间利用网络通信
现代设备经常通过网络和其它系统连接到一起。从一个单独的系统来看,网络可视为一个 I/O 设备。当系统从主存复制一串字节到网络适配器时,数据流经过网络到达另一台机器;相似地,系统可以读取从其他机器发来的数据,并把数据复制到自己的主存。
下图是利用 telnet 在远程主机运行 hello
程序的示例。
1.9 几个重要概念
1.9.1 Amdahl 定律(阿姆达尔定律)
内容:当我们对系统的某个部分加速时,其对系统整体性能的影响取决于该部分的重要性和加速程度。
设系统执行某应用程序需要时间 T o l d T_{old} Told,执行程序某部分时间占全部的比例为 α \alpha α ,而该部分性能提升比例为 k k k。则总的运行时间应为
T n e w = ( 1 − α ) T o l d + ( α T o l d ) / k = T o l d [ ( 1 − α ) + α / k ] T_{new}=(1-\alpha)T_{old}+(\alpha T_{old})/k=T_{old}[(1-\alpha)+\alpha/k] Tnew=(1−α)Told+(αTold)/k=Told[(1−α)+α/k],由此可得加速比 S = T o l d / T n e w = 1 ( 1 − α ) + α / k S=T_{old}/T_{new}=\frac{1}{(1-\alpha)+\alpha/k} S=Told/Tnew=(1−α)+α/k1
1.9.2 并发和并行
并发是指一个同时具有多个活动的系统;并行是指用并发使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用,在此强调以下几个层次。
-
线程级并发
构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了并发。使用线程,我们甚至可以在一个进程内执行多个控制流。传统意义上,这种并发只是模拟出来的(通过在不同进程间快速切换实现),就好比杂技演员同时保持多个球在空中废物一样。在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器来完成的,这种配置称为单处理器系统。
随着多核处理器和超线程的出现,多处理器系统变得更加常见。多处理器系统是一个由单个操作系统控制内核控制多个 CPU 组成的系统。多核处理器是将多个 CPU 集成到一个集成电路芯片上。如下图所示:
超线程,有时称为同时多线程,是一项允许一个 CPU 执行多个控制流(线程)的技术。比如,假设一个线程必须等到某些数据被装载道高速缓存中,那 CPU 就可以继续去执行另一个线程。
-
指令级并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。
-
单指令、多数据并行
在最低层次上,许多现代处理器允许一条指令产生多个可以并发执行的操作,这种方式称为单指令、多数据并行,即 SIMD 并行。例如,较新的 Intel 并行地同时计算 8 8 8 对单精度浮点数加法。
1.9.3 计算机系统中抽象的重要性
抽象是计算机科学中重要的概念之一。例如为一组函数规定一个简单的 API(应用程序接口)就是一个很好的编程习惯,程序员无需了解函数的内部工作原理就可以使用这些代码。下面总结几个抽象。
在处理器里,指令集架构提供了对实际处理器的抽象。使用这个抽象,机器代码程序表现得就好像运行在一个一次只执行一条指令的处理器上。底层的硬件远比抽象描述的复杂精细,它并发地执行执行多条指令,但又总是与那个简单有序的模型保持一致。
在学习操作系统时,文件是对 I/O 设备的抽象,虚拟内存是对程序存储器的抽象,进程是对一个正在运行的程序的抽象。
最后,虚拟机是对整个计算机的抽象,包括操作系统、处理器和程序。