前言

此为深入理解计算机系统(csapp)第一章的笔记,也算是一个开始了。

Hello程序

#include <studio.h>

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

基本思想

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

要点

  • 源程序实际上就是一个由0和1组成的位(或称为比特:bit)序列,8个位为一组,称为字节(byte)。

  • 大部分的现代计算机系统都使用ASCII标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值来表示每个字符

  • hello.c程序是以字节序列的方式存储在文件中。每个字节都有一个整数值,对应于某些字符(注意:每个文本行都是以一个看不见的换行符“/n”来结束的)

  • 只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件

  • 编译系统(compilation system):编译的四个阶段

    gcc -o hello hello.c
    

    hello.c(文本) —> 预处理器[cpp] —> hello.i(文本) —> 编译器[ccl] —> hello.s(文本) —> 汇编器[as] —> hello.o(二进制) —> 链接器[ld] —> hello(二进制)

    • 预处理:预处理器处理#开头的命令,修改原始的C程序
    • 编译:编译器将hello.i翻译为hello.s,即将源文件翻译为汇编语言。汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。例如,C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言
    • 汇编:汇编器将hello.s翻译为机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocation object program)的格式,并将结果保存在目标文件hello.o中。hello.o是一个二进制文件(即将汇编内容翻译为机器语言指令,并将这些指令打包成一种叫做可重定位目标程序的格式
    • 链接:链接器用于处理合并。比如在hello程序中调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到hello.o程序中。链接器就负责处理这种合并。最终得到hello文件,它是一个可执行目标文件(或简称为可执行文件),可以被加载到内存中,由系统执行
  • shell是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。如果该命令行的单词不是一个内置的命令,那么shell就会u假设这是一个可执行文件的名字,它将加载并运行这个文件。

系统的硬件组成

  • CPU:中央处理单元;
  • ALU:算术/逻辑单元;
  • PC:程序计数器;
  • USB:通用串行总线

hardware-composition

总线

贯穿整个系统的是一组电子管道,称做总线,它携带信息字节并负责在各个部件间传递。硬件之间进行信息交流需要有一个统一的标准,也就是二进制信息传递规则,为了高效考虑,通常总线被设计成传送定长的字节块,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,在各个系统中的情况都不尽相同。现在的大多数机器字长要么是4个字节(32位),要么是8个字节(64位)。

I/O设备

输入 / 输出(I/O)设备是系统与外部世界的联系通道。每个 I/O 设备都通过一个控制器或适配器与 I/O 总线相连。控制器与适配器之间的区别主要在于它们的封装方式。控制器是 I/O 设备本身或者系统的主印制电路板(通常称为主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是在 I/O 总线和 I/O 设备之间传递信息。

主存

主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(即数组索引),这些地址是从零开始的。一般来说,组成程序的每条机器指令都是由不同数量的字节构成。与C程序变量相对应的数据项(程序处理的数据)的大小是根据类型(short、int、float…)变化的。

处理器

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

术业有专攻,计算机的世界里,你做你的,它做它的,大家各司其职。 如果想要 hello 可执行文件(存储在主存上)运行起来,就要调用程序计数器执行整个程序的每一条指令,并有专门的硬件来处理数值计算逻辑操作,并且在数值处理过程中会产生临时的数据,这就需要有个地方寄存,等待所有程序执行结束之后,再存到主存上。

从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。处理器看上去是按照一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的。在这个模型中,指令按照严格的顺序执行,而执行一条指令包含一系列的步骤:

处理器从程序计数器(PC)指向的存储器处读取指令,解释指令中的位(bit),执行该指令指示的简单操作,然后更新 PC,使其指向下一条指令,而这条指令并不一定与存储器中刚刚执行的指令相邻。


操作是围绕着**主存、寄存器文件(register file)和算术 / 逻辑单元(ALU)进行的。**寄存器文件是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字。ALU 计算新的数据和地址值。下面是一些简单操作的例子,CPU 在指令的要求下可能会执行以下操作 :

  • 加载 :把一个字节或者一个字从主存复制到寄存器,以覆盖寄存器原来的内容。
  • 存储 :把一个字节或者一个字从寄存器复制到主存的某个位置,以覆盖这个位置上原来的内容。
  • 操作 :把两个寄存器的内容复制到 ALU,ALU 对这两个字做算术操作,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
  • 跳转 :从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖 PC 中原来的值。

💡 关于处理器的指令集架构和微体系结构

处理器看上去是它的指令集架构的简单实现,但是实际上现代处理器使用了非常复杂的机制来加速程序的> 执行。因此,我们将处理器的指令集架构和处理器的微体系架构区分开来

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

存储设备

高速缓存局部性原理:即程序具有访问局部区域里的数据和代码的趋势。

在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如图 1-9 所示。在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶部,也就是第 0 级或记为 L0。这里我们展示的是三层高速缓存 L1 到 L3,占据存储器层次结构的第 1 层到第 3 层。主存在第 4 层,以此类推。

Memory-Hierarchy-Diagram

存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。因此,寄存器文件就是 L1 的高速缓存,L1 是 L2 的高速缓存,L2 是 L3 的高速缓存,L3 是主存的高速缓存,而主存又是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上的数据的高速缓存。 正如可以运用不同的高速缓存的知识来提高程序性能一样,程序员同样可以利用对整个存储器层次结构的理解来提高程序性能。

操作系统

我们可以把操作系统看作是应用程序与硬件之间插入的一层软件,所有应用程序对硬件的操作尝试都必须通过操作系统

Operating-System-Abstract-Diagram

操作系统的两个基本功能

  1. 防止硬件被失控的应用程序滥用
  2. 向应用程序提供简单一致的机制来控制复杂而有通常不相同的低级硬件设备

操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能

process-abstraction-graph

  • 文件是对I/O设备的抽象表示
  • 虚拟内存是对主存和磁盘I/O的抽象表示
  • 进程则是对处理器、主存和I/O设备的抽象表示

进程

进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另一进程的指令是交错执行的(进程级并发)。在大多数系统中,需要运行的进程数是多与可以运行它们的CPU个数的。传统系统(单核系统)在一个时刻只能执行一个程序,而多核处理器同时能够执行多个程序。无论是在单核还是多核系统中,一个CPU看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换实现的。操作系统实现这种交错执行的机制称为上下文切换。(进程是计算机科学中最重要和最成功的概念之一)

上下文(进程上下文)

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

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

Context-switching-use-case-diagram

线程

在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。

虚拟内存

虚拟内存是一个抽象概念,它为每个进程都提供一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。

Virtual-memory-structure-diagram

在 Linux 中,地址空间最上面的区域是保留给操作系统中的代码和数据的,这对所有进程来说都是一样。地址空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。

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

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

虚拟内存的运作需要硬件和操作系统软件之间精密复杂的交互,包括对处理器生成的每个地址的硬件翻译。基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。

文件

文件就是字节序列,仅此而已。每个I/O设备,包括磁盘、键盘、显示器,甚至网络,都可以看成是文件。系统中的所有输入输出都是通过使用一小组称为 Unix I/O 的系统函数调用读写文件来实现的。它(文件)向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的I/O设备。

Amdahl定律

对提升系统某一部分性能所带来的效果做出了简单却有效的观察。这个观察被称为Amdahl定律(Amdahls law)。该定律的主要思想是,当我们对系统的某个部分加速时,其对系统整体性能的影响取决于该部分的重要性和加速程度。若系统执行某应用程序所需要时间为Told。假设系统某部分所需执行时间与该时间的比例为a,而该部分性能提升的比例为k。即该部分初始所需时间为aTold,现在所需时间为aTold/k 。因此,总的执行时间为:

Tnew=(1-a)Told + (aTold)/k = Told[(1-a) + a/k]

由此,可以计算加速比S=Told/Tnew

S=1/((1-a) + a/k)

💡 主要观点:要想显著加速整个系统,必须提升全系统中相当大的部分的速度。

性能提升最好的表示防范就是用比例的形式Told/Tnew,其中,Told为原始系统所需时间,Tnew为修改后的系统所需时间。如果有所改进,则比值应大于1。我们用后缀“X”来表示比例,因此,“2.2X”读作“2.2倍”

并发与并行

我们用的术语并发(concurrency)是一个通用的概念,指一个同时具有多个活动的系统;而术语并行(parallelism)指的是用并发来使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用。在此,我们按照系统层次结构中由高到低的顺序重点强调三个层次。

线程级并发

构建在进程这个抽象之上,我们能够设计出同时有多个程序执行的系统,这就导致了并发。使用线程,我们甚至能够在一个进程中执行多个控制流。自 20 世纪 60 年代初期出现时间共享以来,计算机系统中就开始有了对并发执行的支持。传统意义上,这种并发执行只是模拟出来的,是通过使一台计算机在它正在执行的进程间快速切换来实现的,就好像一个杂耍艺人保持多个球在空中飞舞一样。在以前,即使处理器必须在多个任务间切换,大多数实际的计算也都是由一个处理器来完成的。这种配置称为单处理器系统。

Processor-System-Class-Diagram

多核处理器是将多个 CPU(称为“核”)集成到一个集成电路芯片上。图 1-17 描述的是一个典型多核处理器的组织结构,其中微处理器芯片有 4 个 CPU 核,每个核都有自己的 L1 和 L2 高速缓存,其中的 L1 高速缓存分为两个部分——一个保存最近取到的指令,另一个存放数据。这些核共享更高层次的高速缓存,以及到主存的接口。

Schematic-diagram-of-multi-core-processor-structure

超线程,有时称为同时多线程(simultaneous multi-threading),是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,比如程序计数器和寄存器文件,而其他的硬件部分只有一份,比如执行浮点算术运算的单元。常规的处理器需要大约 20000 个时钟周期做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得 CPU 能够更好地利用它的处理资源。比如,假设一个线程必须等到某些数据被装载到高速缓存中,那 CPU 就可以继续去执行另一个线程(时间空档切换)。举例来说,Intel Core i7 处理器可以让每个核执行两个线程,所以一个 4 核的系统实际上可以并行地执行 8 个线程。

指令级并行

在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。早期的微处理器,如 1978 年的 Intel 8086,需要多个(通常是 3~10 个)时钟周期来执行一条指令。最近的处理器可以保持每个时钟周期 2~4 条指令的执行速率。其实每条指令从开始到结束需要长得多的时间,大约 20 个或者更多周期,但是处理器使用了非常多的聪明技巧来同时处理多达 100 条指令。在第 4 章中,我们会研究流水线(pipelining)的使用。在流水线中,将执行一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行一个步骤。这些阶段可以并行地操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近于一个时钟周期一条指令的执行速率。

如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为超标量(super-scalar)处理器。大多数现代处理器都支持超标量操作。第 5 章中,我们将描述超标量处理器的高级模型。应用程序员可以用这个模型来理解程序的性能。然后,他们就能写出拥有更高程度的指令级并行性的程序代码,因而也运行得更快。

单指令、多数据并行

在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即 SIMD 并行。例如,较新几代的 Intel 和 AMD 处理器都具有并行地对 8 对单精度浮点数(C 数据类型 float)做加法的指令。

关于抽象

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

此图表示计算机系统提供的一些抽象。计算机系统中的一个重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性

Virtual-Machine-Abstract-Diagram

操作系统的三个抽象

  • 文件:是对 I/O 设备的抽象
  • 虚拟内存:是对主存和磁盘的抽象
  • 进程:是对处理器、主存和 I/O 设备的抽象

参考