计算机系统漫游

hello 程序:

#include <stdio.h>

int main() 
{
    printf("hello, world\n");
    return 0;
}

跟踪 hello 程序的生命周期:从它被程序员创建开始,到在系统上运行,输出简单的消息,然后终止。

1.1 信息就是位+上下文 1

hello 程序的生命周期是从一个源程序开始,文件名是 hello.c。源程序实际上就是一个由值 0 和 1 组成的位序列,8 个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。

ASCII:使用一个唯一的单字节大小的整数值来表示每个文本字符。

hello.c 程序是以字节序列的方式储存在文件中。

hello.c 的表示方法说明了一个基本思想:系统中所有的信息————包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。

1.2 程序被其他程序翻译成不同的格式 3

$ gcc -o hello hello.c
compilation.png
Figure 1: 编译系统
  • 预处理阶段:预处理器(cpp)根据以字符 # 开头的命令,修改原始的 C 程序。比如 hello.c 中的 #include <stdio.h> 命令告诉预处理器读取系统头文件 stdio.h 的内容,并把它插入程序文本中。结果就得到了另一个 C 程序,通常是以 .i 作为文件扩展名。
$ gcc -E hello.c -o hello.i
$ gcc -E -P hello.c -o hello.i
  • 编译阶段:编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。
$ gcc -S hello.i -o hello.s
  • 汇编阶段:汇编器(as)将 hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o 中。hello.o 文件是一个二进制文件。
$ gcc -c hello.s -o hello.o
  • 链接阶段:hello 程序调用了 printf 函数,它是 C 编译器提供的标准库中的一个函数。printf 函数存在于一个名为 printf.o 的单独的目标文件中,而这个文件必须以某种方式合并到 hello.o 程序中。链接器(ld)负责处理这种合并。结果就得到 hello 文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。
$ gcc hello.o -o hello

1.3 了解编译系统如何工作是大有益处的 4

  • 优化程序性能
  • 理解链接时出现的错误
  • 避免安全漏洞

1.4 处理器读并解释储存在内存中的指令 5

$ ./hello
hello, world

1.4.1 系统的硬件组成 5

hardware.png
Figure 2: 一个典型系统的硬件组成
  • CPU:中央处理器单元
  • ALU:算术/逻辑单元
  • PC:程序计数器
  • USB:通用串行总线
  1. 总线

贯穿整个系统的一组电子管道,称作总线,它携带信息字节并负责在各个部件间传递。
通常总线被设计成传送定长的字节块,也就是字(word)。现在的大多数机器字长(字中的字节数)要么是 4 个字节(32位),要么是 8 个字节(64位)。

  1. I/O 设备

I/O 设备是系统与外部世界的联系通道。
示例系统包括四个 I/O 设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,用于长期存储数据和程序的磁盘。
每个 I/O 设备通过一个控制器或适配器与 I/O 总线相连。控制器和适配器之间的区别主要在于它们的封装方式。控制器是 I/O 设备本身或者系统的主印制电路板(主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。它们的功能是在 I/O 总线和 I/O 设备之间传递消息。

  1. 主存

主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
从物理上来说,主存是由一组动态随机存储器(DRAM)芯片组成的。
从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(数组索引),这些地址是从零开始的。

  1. 处理器

中央处理器单元(CPU),简称处理器,是解释(或执行)存储在主存中指令的引擎。
处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻, PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)。

从系统通电开始,直到系统断电,处理器一直不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。

一个非常简单的指令执行模型:

处理器从程序计数器指向的内存处读取指令 -> 解释指令中的位 -> 执行指令指示的简单操作 -> 更新 PC,使其指向下一条指令

下一条指令并不一定和在内存中刚刚执行的指令相邻。

指令执行模型是由指令集架构决定。

寄存器文件(register file)是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字。
算术/逻辑单元(ALU)计算新的数据和地址值。

指令集架构描述的是每条机器代码指令的效果;而微体系结构描述的是处理器实际上是如何实现的。

1.4.2 运行hello程序 7

keyboardread.png
Figure 3: 从键盘上读取 hello 命令
helloload.png
Figure 4: 从磁盘加载可执行文件到主存
displaywrite.png
Figure 5: 将输出字符串从存储器写到显示器

1.5 高速缓存至关重要 9

寄存器 -> 主存 -> 磁盘

从左至右,存储器的访问速度越来越慢、容量越来越大

Latency Numbers Every Programmer Should Know

operation time
execute typical instruction 1ns
L1 cache reference 1ns
Branch mispredict 3ns
L2 cache reference 4ns
Mutex lock/unlock 17ns
Main memory reference 100ns
Disk seek 2,000,000ns ≈ 2ms

针对处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器(cache memory,简称为 cache 或高速缓存),作为暂时的集结区域,存放处理器近期可能会需要的信息。

位于处理器芯片上的 L1 高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。
一个容量为数十万到数百万字节的更大的 L2 高速缓存通过一条特殊的总线连接到处理器。
进程访问 L2 高速缓存的时间比访问 L1 高速缓存的时间长 5 倍,但是这仍然比访问主存的时间快 5 ~ 10 倍。
L1L2 高速缓存使用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的。
比较新的、处理能力更强大的系统甚至有三级缓存:L1L2L3。系统可以获得一个很大的存储器,同时访问速度也很快,原因是利用了高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。

cachebus.png
Figure 6: 高速缓存存储器

1.6 存储设备形成层次结构 9

存储器层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶部,记为 L0

memhier.png
Figure 7: 一个存储器层次结构的示例

存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。因此,寄存器文件就是 L1 的高速缓存,L1 就是 L2 的高速缓存,L2L3 的高速缓存,以此类推。

1.7 操作系统管理硬件 10

layers.png
Figure 8: 计算机系统的分层视图
abstractions-os.png
Figure 9: 操作系统提供的抽象表示

操作系统有两个基本功能:
(1)防止硬件被失控的应用程序滥用;
(2)向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。

操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能。
文件是对 I/O 设备的抽象表示,虚拟内存是对主存和磁盘 I/O 设备的抽象表示,进程是对处理器、主存和 I/O 设备的抽象表示。

1.7.1 进程 11

进程是操作系统对一个正在运行的程序的一种抽象。而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行。
并发执行多个进程,这是通过处理器在进程间切换来实现的。操作系统把实现这种交错执行的机制称为上下文切换。

操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,包括许多信息,比如 PC 和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从它上次停止的地方开始。

从一个进程到另一个进程的转换是由操作系统内核(kernel)管理的。内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,比如读写文件,它就执行一条特殊的系统调用(system call)指令,将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。注意,内核不是一个独立的进程。相反,它是系统管理全部进程所用代码和数据结构的集合。

switch.png
Figure 10: 进程的上下文切换

1.7.2 线程 12

一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。当有多处理器可用的时候,多线程也是一种是的程序可以运行得更快的方法。

1.7.3 虚拟内存 12

虚拟内存是一个抽象概念,它为每一个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。在 Linux 中,地址空间最上面的区域是保留给操作系统中的代码和数据的。地址空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。

rtimage.png
Figure 11: Linux 进程的虚拟地址空间

每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。
从最低的地址开始,逐步向上介绍。

  • 程序代码和数据:对所有的进程来说,代码是从同一固定位置开始,紧接着的是和 C 全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的,在示例中就是可执行文件 hello。
  • 堆:代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像 mallocfree 这样的 C 标准库函数时,堆可以再运行时动态地扩展和收缩。
  • 共享库:大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的区域。共享库的概念非常强大,也相当难懂。
  • 栈:位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态的扩展和收缩。特别地,每次调用一个函数,栈就会增长;从一个函数返回时,栈就会收缩。
  • 内核虚拟内存:地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些操作。

虚拟内存是对主存和磁盘 I/O 设备的抽象表示,基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。

1.7.4 文件 14

文件就是字节序列,仅此而已。每个 I/O 设备,包括磁盘、键盘、显示器,甚至网络,都可以看成是文件。系统中的所有输入输出都是通过使用一小组称为 Unix I/O 的系统函数调用读写文件来实现的。

1.8 系统之间利用网络通信 14

1.9 重要主题 16

1.9.1 Amdahl定律 16

1.9.2 并发和并行 17

数字计算机的整个历史中,有两个需求是驱动进步的持续动力:一个是我们想要计算机做的更多,另一个是我们想要计算机运行得更快。
并发(concurrency):一个同时具有多个活动的系统;
并行(parallelism):用并发来使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用。

按照系统层次结构中由高到低的顺序重点强调三个层次。

  1. 线程级并发

构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了并发。我们使用线程,甚至能够在一个进程中执行多个控制流。分时系统(time-sharing)出现之后,计算机系统就开始有了对并发执行的支持。传统意义上,这种并发执行只是模拟出来的,是通过使一台计算机在它正在执行的进程间快速切换来实现的。这种并发形式允许多个用户同时与系统交互,它还允许一个用户同时从事多个任务。在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器来完成的。这种配置称为单处理器系统。

当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到了一个多处理器系统。

processors.png
Figure 12: 不同的处理器配置分类。随着多核处理器和超线程的出现,多处理器变得普遍了
corei7caches.png
Figure 13: 多核处理器的组织结构。4 个处理器核集成在一个芯片上

超线程,有时称为同时多线程(simultaneous multi-threading),是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,比如程序计数器和寄存器文件,而其他的硬件部分只有一份,比如执行浮点算术运算的单元。常规的处理器需要大约 20000 个时钟周期做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程,这使得 CPU 能够更好的利用它的处理资源。

多处理器的使用可以从两方面提供系统性能。首先,它减少了在执行多个任务时模拟并发的需要。其次,它可以使应用程序运行得更快,当然,必须要求程序是以多线程方式书写。

  1. 指令级并行

在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。
早期的微处理器,如 1978 年的 Intel 8086,需要 3 ~ 10 个时钟周期来执行一条指令。
最近的处理器可以保持每个时钟周期 2 ~ 4 条指令的执行速率。
其实每条指令从开始到结束,需要大约 20 个或者更多时钟周期,但是处理器使用了非常多的聪明技巧来同时处理多达 100 条指令。

如果处理器可以达到比一个时钟周期一条指令更快的执行速率,就称之为超标量(super-scalar)处理器。大多数现代处理器都支持超标量操作。

  1. 单指令、多数据并行

在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即 SIMD 并行。

提供这些 SIMD 指令多是为了提高处理影像、声音和视频数据应用的执行速度。

1.9.3 计算机系统中抽象的重要性 19

抽象的使用是计算机科学中最为重要的概念之一。

在处理器里,指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序运行在一个一次只执行一条指令的指令执行模型上。底层的硬件远比抽象描述的要复杂精细,它并行的执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。

abstractions-multi.png
Figure 14: 计算机系统提供的一些抽象。计算机系统中的一个重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性。

在学习操作系统时,我们介绍了三个抽象:文件是对 I/O 设备的抽象,虚拟内存是对主存和磁盘 I/O 设备的抽象,而进程是对正在运行的程序的抽象。我们再增加一个新的抽象:虚拟机,它提供对整个计算机的抽象,包括操作系统、处理器和程序。

1.10 小结 20

posted @ 2020-04-23 15:08  clipboard  阅读(234)  评论(0编辑  收藏  举报