《Computer System: A Programmer’s Perspective》

        “CS: APP”,中文名:《深入理解计算机系统》。第一次听说这本书的的时候,看见“系统”二字,我下意识的以为这是本讲操作系统的书籍。当然这种想法是不对的,事实上这本书是一个关于计算机科学的“大杂烩”,内容主要包括信息的表示和处理、程序的机器级表示、处理器体系结构、优化程序性能、存储器层次结构、链接、异常控制流、虚拟存储器、系统级I/O、网络编程、并发编程等。这本书的目的也并不是想教人们实现计算机的某个部件或软件,而是以一个程序员的角度,告诉阅读者如何利用计算机系统的知识写出更好的程序。

计算机系统漫游

        通过本章的学习,我们将初步了解计算机系统工作的流程。

1.关于编译系统

        在Linux系统上,我们通常通过gcc -o 命令来让编译器工作,然而这一条小小的命令,其背后的工作却十分复杂。主要分为以下四个部分。

  • 预处理阶段

        相信写过C语言的同学对头文件都不会陌生,例如 “#include< stdio.h >”,对这样的头文件的处理,就是预处理工作的一部分。事实上,在预处理阶段, 预处理器(cpp) 会根据以 # 开头的代码,来修改原始程序。对于上面的头文件,预处理器会自动读入该头文件的内容,并将其中的内容插入到源程序中。只有头文件的内容被放进源程序之后,源程序才可以调用头文件内的库函数。假设我们有一个C语言源程序hello.c,当预处理的过程结束之后,hello.c 文件会变成 hello.i 文件,这样,预处理就完成了。

  • 编译阶段

        经过预处理,生成了一个 hello.i 文件,但这个时候这个程序还并不能直接运行,编译器(ccl) 将文本文件 hello.i 翻译成文本文件 hello.s ,编译的工作十分复杂精细,其中包含词法分析、语法分析、语义分析、中间代码生成以及优化等等一系列的中间操作。这个过程结束之后,我们就得到了一个 hello.s 的文件,这是个汇编文件。

  • 汇编阶段

        在这一阶段,汇编器(as) 会根据指令集将汇编程序 hello.s 翻译成机器指令,机器指令就是二进制文件了,这个时候的程序已经足够的 “底层” 了。之后汇编器会把这一系列的机器指令按照固定的规则进行打包,得到 可重定位目标文件(relocatable object program) hello.o 。虽然此时 hello.o 已经是一个二进制的文件了,但是还不能直接执行,还要经历最后一个阶段:链接。

  • 链接阶段

        我们上文中说到,只有引入了头文件,才能使用该头文件内的库函数。例如当我们调用printf函数时,它实际上存储在名为 printf.o 的文件中。链接器 (ld) 就负责把 hello.o 和 printf.o 按照一定规则进行合并。也正是因为链接器要对 hello.o 和 printf.o 的进行调整,所以 hello.o 才会被称之为可重定位目标文件。最终经过链接阶段可以得到可执行目标文件hello。

        

        (编译流程图)

我们为什么要去了解编译器的工作原理呢?

        相信很多朋友都有这样的疑惑,现代编译器技术已经足够的成熟,我们也只需要一行命令或者在IDE上点击一下编译按钮,我们就能得到一个可执行文件了。虽然我们不需要对编译器的细节有十分细致的掌握,但我们还是需要对机器执行的代码有一个基本的了解,这样我们就知道编译器把不同的 C 代码转换成的机器代码是什么。于是我们才能知道,为什么switch往往比多层if else的效率高?(if else执行时候是顺序执行的,会一层一层遍历,对于switch语句,编译器会建立一个map,根据传入的值直接定位相关语句。所以switch相当于时间换空间)。

        而在大型项目的开发过程中,由于涉及到大量各种各样函数库的调用,根据以往的经验,一些奇奇怪怪的错误往往都是与链接器有关的。例如静态变量和全局变量的区别是什么?静态库和动态库的区别是什么?一些链接错误甚至在程序运行的时候才会出现。搞不清楚这些问题,工作中就会出错。

        除此之外,为了避免缓冲区溢出(buffer overflow)的问题。我们第一步就是要理解数据和控制信息在程序栈上是如何存储的,了解不严谨不规范的书写方式会引起什么样的后果。

        所以毫无疑问,了解编译器的基本原理是很重要的。可以帮助我们优化程序性能,理解链接时出现的错误,以及避免一些安全漏洞。

2.系统的硬件组成

        编译系统将程序文件处理成二进制文件(机器码),接下来就要让计算机来执行这个文件了,那么计算机执行程序文件的过程中发生了什么呢?想要了解这个,我们得先了解一下计算机的硬件组织。硬件组成主要有以下四部分:

  • 总线

        总线是贯穿整个系统的一组子管道。它携带信息并负责咋各个部件间传递,包括内存和处理器之间的信息传递。 通常总线被设计成传送固定长度的字节块,也就是字(word),至于这个字到底是多少个字节,各个系统中是不一样的,32 位的机器,一个字长是 4 个字节;而 64 位的机器,一个字长是 8 个字节。

  • I/O设备

        I/O设备,即输入输出设备。相较于总线这种内部设备,输入输出设备我们就很熟悉了。比较典型的输入设备有:键盘、鼠标等;典型的输出设备有:显示器、音响等;I/O设备是系统与外部世界的联系通道。每个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中还有 寄存器(register)算术逻辑单元(ALU) 两个核心部件。CPU的工作需要三者配合,一个常见的处理过程是这样的。

1.加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来的内容,作为接下来的操作数据。

2.操作:把两个寄存器的内容复制到ALU,ALU对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。

3.存储:从寄存器复制一个字节或者一个字到主存的某个位置,以覆盖这个位置上原本的数据

4.跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的值。

        

         (计算机系统硬件组成图)

3.重要的高速缓存

        假设我们执行一个 ”hello, world\n“ 的C语言程序,hello程序中的代码和数据会从磁盘复制到主存。数据包括最终会被输出的字符串“hello, world”。利用直接存储器存取(DMA)技术,数据可以不通过处理器而直接从磁盘到达主存。一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的main程序中的机器语言指令。这些指令将“hello, world\n”字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。很明显这个过程存在着一定的问题,即系统花费了大量的时间把信息从一个地方挪到另一个地方。hello程序的机器指令最初是存放在磁盘上,当程序加载时,它们被复制到主存;当处理器运行程序时,指令又从主存复制到处理器。同样,对于字符串”hello, world\n“也是如此的过程。这样的过程显然减慢了系统的运行时间。

        根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高
于同类的低速设备。比如说,一个典型系统上的磁盘驱动器可能比主存大1000倍,但是对处理器而言,从磁盘驱动器上读取一个字的时间开销要比从主存中读取的开销大1000万倍。

        所以说,设计一个计算机系统时,往往就是在做成本与性能的权衡,既要设计的达到不错的性能,还要降低造价,在这样的博弈下,系统设计者采用了一个较快较小的存储设备作为暂时的集结区域,通常称之为 高速缓存存储器(cache memory), 这块区域通常用来存放处理器近期需要用到的信息。

        

        cache memory的思想就是在处理器和较大较慢的设备中间插入一个较小较快的设备,从而提升存取的效率,而这种思想在计算机系统的设计上已经成为一种普遍的观念了。几乎每个计算机系统中的存储设备都被设计成一个存储器层次结构

        

        整体结构由上至下,速度逐渐变慢,容量逐渐变大,造价逐渐降低。

4.操作系统来管理硬件

        要知道,无论是hello程序,还是别的应用程序,它们在运行时候,都不直接调用硬件资源。为啥不让他们直接用呢?原因很简单,大家都想自己先用,就会一团糟。。。因为硬件设备就那么几块。所以我们把硬件交给硬件,哪个程序想用就跟操作系统申请一下,然后操作系统根据规则来分配资源。这样就有效避免了硬件资源被滥用,也提供了一套清晰的规则控制杂乱的底层硬件。

        为了实现上述功能,操作系统引入了一些抽象的概念。例如:文件是对 I/O 设备的抽象;虚拟内存是对内存和磁盘 I/O 的抽象;进程是对处理器、内存以及 I/O 设备的抽象。

  • 进程

        进程是计算机系统中极其重要的概念之一,进程这一概念,帮助操作系统提供一种假象给程序,就好像系统上只有这个程序在运行。程序看上去是独占地使用理器、主存和I/O设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对象。进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的 CPU个数的。传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执行多个程序。无论是在单核还是多核系统中,一个CPU看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。

  • 虚拟内存

        操作系统也为每个进程提供了一个假象,就是每个进程都在独自占用整个内存空间,每个进程看到的内存都是一样的,我们称之为虚拟地址空间。

  • 文件

        对于Linux系统来说,有一个看似很”极端“的思想,即:万物皆文件。

        所有的 IO 设备,包括键盘,磁盘,显示器,甚至网络,这些都可以看成文件,系统中所有的输入和输出都可以通过读写文件来完成。文件这个简单而精致的概念是非常强大的,因为它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的I/O设备。例如,处理磁盘文件内容的应用程序员可以非常幸福,因为他们无须了解具体的磁盘技术。进一步说,同一个程序可以在使用不同磁盘技术的不同系统上运行。

5.系统间的网络通信

        我们至此,一直在讲独立的计算机系统,然而在现实世界,一台孤立的计算机往往无法发挥很大的作用,所以这里,我们引入网络这一概念。

        从一个单独的系统来看,网络可视为一个I/O设备。当系统从主存复制一串字节到网络适配器时,数据流经过网络到达另一台机器,而不是比如说到达本地磁盘驱动器。相似地,系统可以读取从其他机器发送来的数据,并把数据复制到自己的主存。

        

        这里我们通过talent的应用来简单说一下网络的工作流程

        当我们在telnet 客户端键人“hello” 字符串并敲下回车键后,客户端软件就会将这个字符串发送到telnet的服务器。telnet服务器从网络上接收到这个字符串后,会把它传递给远端shell程序。接下来,远端shell运行hello程序,并将输出行返回给telnet服务器。最后,telnet服务器通过网络把输出串转发给telnet 客户端,客户端就将输出串输出到我们的本地终端上。

6.重要主题

  • 阿姆达尔定律(Amdahl’s Law)