csapp_study_1 —— 计算机系统漫游

全书概述

  1. 第一章 :计算机系统漫游,用一个简单的helloworld程序从开始到结束的一生来介绍计算机系统的主要概念。
  2. 第二到六章:程序结构和执行
  3. 第七到九章:应用程序在计算机系统上运行的相关知识
  4. 第九章到最后:程序间的交互和通信

计算机系统漫游

漫游(一)

Hello 程序

1
2
3
4
5
6
#include <stdio.h>
int main()
{
printf("hello,world\n");
return 0;
}

保存得到一个后缀为.c的文件 —— hello.c

编译系统

1
linux> gcc -o hello hello.c

通过编译器gcc对hello.c进行处理,生成可执行文件 —— hello, 此过程虽然是通过一条指令实现的, 但是整个编译过程却是十分复杂的, 大致可以分为四个阶段, 分别为预处理、编译、汇编以及链接

预处理

预处理器会读取hello.c文件中头文件的内容,如

1
#include <stdio.h>

并将其中的内容直接插入到源程序中,结果就得到了另一个c程序,这个经过预处理器处理后得到的文件通常以.i结尾

编译

编译器将hello.i文件翻译为hello.s文件,此过程被称为编译,其中编译这一阶段包括词法分析、语法分析、语义分析、中间代码生成以及优化等等一系列的中间操作。

汇编

汇编器根据指令集将汇编程序hello.s翻译成机器指令,并且把这一系列的机器指令按照固定的规则进行打包,得到可重定位目标文件 —— hello.o

此时hello.o虽然是一个二进制文件,但是还不能执行,还要经历最后一个阶段 —— 链接

链接

在hello这个程序中,我们调用了printf函数,这个函数是标准C库中的一个函数,每一个C语言的编译器都会提供,通俗点讲,就是当你要调用printf这个函数时,编译器就知道你要在屏幕上打印输出内容,它会将这行代码翻译成计算机可以理解的指令,这个printf函数在名为printf.o的文件中,它是一个提前编译好的目标文件,链接器(ld)负责把hello.o和printf.o进行合并,当然这个合并需要遵守一些规则,正是因为连接器要对hello.o和printf.o进行调整,所以hello.o才会被称为可重定位目标文件,最终经过链接阶段可以得到可执行目标文件 —— hello,此时得到的hello就可以被加载到内存中执行了

上述就是编译系统的一个简单概述

为什么要理解编译系统如何工作

  1. 理解编译系统可以优化程序的性能,现代编译器是非常成熟的工具,通常可以生成很好的代码,我们没有必要为了写出高效的代码而去研究编译器的内部是如何工作的,但是还是需要对机器执行的代码有一个基本的了解,这样我们就知道编译器把不同的C代码转换成的机器代码是什么
  2. 理解编译系统可以帮助我们理解链接过程中出现的错误,如果所有的程序都像helloworld一样简单那的确没有必要去理解编译系统,但当你开始构造大型程序时,往往涉及各种函数库的调用,根据以往的经验,一些奇奇怪怪的错误往往都是与链接器有关的,例如:静态变量和全局变量的区别是什么?静态库和动态库的区别是什么?更严重的是,还有一些错误直到程序运行的时候才会出现
  3. 避免安全漏洞,多年以来,缓冲区溢出是导致互联网安全漏洞的主要原因,如何避免写出的代码存在安全漏洞,第一步就是要理解数据和控制信息在程序栈上是如何存储的,了解不严谨不规范的书写方式会引起什么样的后果,在第三章中,会讲述堆栈的原理和缓冲区溢出错误,以及如何利用操作系统、编译器来降低攻击的风险

漫游(二)

通过shell运行hello程序

通过上一小节的内容,我们已经将生成的可执行目标文件放在系统的磁盘上

为了在linux系统上运行可执行程序,第一步我们需要打开一个shell程序,然后在shell程序中输入相应可执行文件的文件名

1
linux> ./hello

不了解linux系统的同学可能对shell比较陌生,这里做一个简单的介绍,shell是一个命令解释程序,它输出一个提示符 > 来等待一个命令行的输入,然后执行这个命令,如果该命令行的第一个单词不是内置的shell命令,那么shell就会假设这是一个可执行文件的名字 ,对这个文件进行加载并运行。在第八章的课程中,我们将通过一个实验来讲述如何实现一个简单的shell程序

接下来我们来看一下hello程序运行时,系统发生了什么,不过在此之前我们先来看一下计算机系统的硬件组成

计算机系统的硬件组成

中央处理单元(CPU),也称处理器

实际上CPU的内部结构是非常复杂的,此处为了介绍hello程序的运行,我们采用简化的框图来大概示意一下整个CPU的结构

程序计数器(Program Count,PC)

PC实质上是一个大小为一个字的存储区域(对于32位的机器,一个字是4字节,对于64位的机器,一个字是8个字节),说白了PC就是一个4字节或者8字节的存储空间,里面存放的是某一条指令的地址,从处理器上电的一瞬间到断电的那一瞬间,处理器就在不断地执行PC指向的指令,然后更新PC,使其指向下一条要执行的指令(注意:这个下一条指令与刚刚执行的指令不一定是相邻的)

寄存器文件(Register file)

他就是CPU内部的一个存储设备,寄存器文件是由一些单字长的寄存器构成的,每个寄存器都有自己唯一的名字,通俗点讲,寄存器可以理解为一个临时存放数据的空间

算术逻辑单元(ALU)

ALU是算术逻辑单元的简称,是能实现多组算术运算和逻辑运算的组合逻辑电路,是中央处理器(CPU)的执行单元,也是所有中央处理器的核心组成部分。ALU的输入是要进行操作的数据(称为操作数)以及来自控制单元的指令代码,用来指示进行哪种运算。它的输出即为运算结果。

主存(也称为内存)

处理器在执行程序时,内存主要存放程序的指令以及数据,从物理上讲,内存是由随机动态存储器芯片组成,从逻辑上讲,内存可以看成一个从零开始的大数组,每个字节都有相应的地址,关于内存更多的内容,在第六章中有详细的讲解

总线

内存和处理器之间通过总线来进行数据传递,实际上总线贯穿了整个计算机系统它负责将信息从一个部件传递到另外一个部件,通常总线被设计成传送固定长度的字节快,也就是字(word),这个字到底是多少个字节,各个系统中是不一样的

各种输入输出设备

例如,键盘、鼠标、显示器以及磁盘等等,每一个输入输出设备都通过一个控制器或者适配器与IO总线相连控制器与适配器主要区别在于它们的封装方式,无论是控制器还是适配器,它们的功能都是在IO设备与IO总线之间传递数据,更多IO的知识,将在第6章和第10章进行详细的讲解

hello程序的执行

首先,我们通过键盘输入 “./hello” 的字符串,shell程序会将输入的字符逐一读入寄存器,处理器会把hello这个字符串放入内存中,整个流程如下图所示

当我们完成输入按下回车键时,shell程序就知道我们已经完成了命令的输入,然后执行一系列的指令来加载可执行文件hello,这些指令将hello中的数据和代码从磁盘复制到内存,数据就是我们要显示输出的 “hello,world\n” ,这个复制过程将利用DMA(Direct Memory Access)技术,数据可以不经过处理器,从磁盘直接到达内存

当可执行文件hello中的代码和数据被加载到内存中,处理器就开始执行main函数中的代码,CPU会将 “hello,world\n” 这个字符串从内存复制到寄存器文件,然后再从寄存器文件复制到显示设备,最终hello, world显示在屏幕上

从hello程序的执行过程我们可以看出,系统就算执行如此简单的程序,数据信息仍旧需要在磁盘、内存、处理器以及IO设备之间进行搬运,数据从一个地方搬运到另一个地方需要花费时间,系统设计人员的一个主要任务就是缩短信息搬运所花费的时间

通常情况下大容量的存储设备的存取速度要比小容量的慢,运行速度更快的设备的价格比低速设备更贵

计算机系统的信息存储层次结构

从这个层次结构来看,从上到下,设备的访问速度越来越慢,容量越来越大,每字节的造价也越来越便宜,这个存储层次的主要思想,就是上一层存储设备是下一层存储设备的高速缓存,关于缓存的更多内容将在第六章会有更加详细的讲解

漫游(三)

上一节介绍了hello程序运行的细节,无论是shell程序还是hello程序,它们都没有直接访问键盘、显示器、磁盘这些硬件设备,真正操控硬件的是操作系统,我们可以把操作系统看成是应用程序和硬件之间的中间层,所有应用程序对硬件的操作必须通过操作系统来完成,这样设计的目的主要有两个:

  • 防止硬件被失控的应用程序滥用
  • 操作系统提供统一的机制来控制这些复杂的底层硬件

几种抽象

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

进程

下面我们借助hello程序运行的场景来解释一下进程,假设示例场景中只有两个并发的进程,shell进程和hello进程,最开始的时候,只有shell进程在运行,即shell在等待命令行的输入

当我们通过shell进程加载hello进程时,shell进程通过系统调用来执行我们的请求,系统调用会将控制权从shell进程传递给操作系统,操作系统保存shell进程的上下文,然后创建一个新的进程及其上下文,然后将控制权交给hello进程

hello进程执行完后,操作系统就会恢复shell进程的上下文,并将控制权交给shell进程,之后shell进程继续等待下一个命令的输入

上下文

刚刚我们提到了一个名词,上下文(context)。这里稍微解释一下,操作系统会跟踪进程运行中所需要的所有状态信息,这种状态称为上下文,例如当前PC和寄存器的值,以及内存中的内容等等

在第八章,我们将详细讲述关于进程的知识,现代操作系统中,一个进程实际上由多个线程组成,每个线程都运行在进程的上下文中,共享代码和数据,由于网络服务器对并行处理的需求,线程成为越来越重要的编程模型

在第12章中,我们会讲述如何编写多线程程序

内存的抽象 —— 虚拟内存

虚拟内存位每个进程提供了一个假象,就是每个进程都在独自占用整个内存空间,每个进程看到的内存都是一样的,我们称之为虚拟地址空间,如下图,就是linux系统中进程的虚拟地址空间

从下往上看,地址是增大的,下面是0地址

接下来我们自下而上的介绍一下虚拟地址空间的分布

存放程序代码和数据的区域

此区域的内容是从可执行目标文件中加载而来的,例如我们多次提到的hello程序,对于所有进程来讲,代码都是从固定的地址开始的,至于这个读写数据区域放的是什么数据呢?例如,在C语言中,全局变量就是存放在这个区域,关于这个区域更多详细的内容,我们会在第七章有所介绍

堆(heap)

顺着地址增大的方向,继续往上看就是堆(heap),malloc函数申请到的内存空间就在这个堆中,程序的代码和数据区在程序一开始的时候就被指定了大小,但是堆可以在运行时动态地扩展和收缩,在第九章我们研究虚拟内存的时候,会详细地介绍堆

共享库的存放区域

这个区域主要存放像C语言的标准库和数学库这种共享的代码和数据,例如hello程序中的printf函数就是存放在这里,在第七章介绍链接时,会详细介绍共享库是如何工作的

用户栈

我们在写程序的时候都用过函数调用,实际上函数调用的本质就是压栈,每一次当程序进行函数调用的时候,栈就会增长,函数执行完毕返回时,栈就会收缩。需要注意的是,栈的增长方向是从高地址到低地址,在第三章,我们会详细介绍编译器如何使用栈来实现函数调用

为内核保留的区域

应用程序代码不能读写这个区域的数据,也不能直接调用内核中定义的函数,也就是说这个区域对应用程序是不可见的,关于更多虚拟内存的知识,我们将会在第九章详细地讲述

linux系统的思想 —— 一切皆文件

linux系统的哲学思想是:一切皆为文件。所有的IO设备,包括键盘、磁盘、显示器甚至是网络,都可以看成文件,系统中所有的输入输出都可以通过读写文件来完成。虽然文件的概念非常简单,但却非常强大,例如:当程序员需要处理读写磁盘上的文件时,他们不需要了解具体的磁盘技术,同一个程序可以在不同磁盘技术上的不同系统上运行,我们将在第十章详细地讲述Unix IO

漫游(四)

系统漫游至此,我们一直将计算机系统作为一个孤立的硬件与软件的集合体,从一个系统来看,网络也可视为一个IO设备,随着互联网的发展,从一台计算机发送消息到另一台计算机已经成为非常普遍的应用,深入理解计算机系统的原书中讲述了如何使用本地计算机上的telnet客户端连接远程主机上的telnet服务器,由于telnet的安全性问题,目前ssh的连接方式更加普遍,接下来,我们讲述如何通过网络在远程主机上运行hello程序

数据交换

当我们在ssh的客户端中输入hello字符串并且敲下回车之后,客户端的软件就会通过网络将字符串发送到ssh服务端,ssh服务端从网络端接收到这个字符串以后,会将这个字符串传递给远程主机上的shell程序,然后shell程序负责hello程序的加载,运行结果返回给ssh的服务端,最后ssh的服务端通过网络将程序的运行结果发送给ssh的客户端,ssh客户端在屏幕上显示运行结果

这类客户端与服务端之间交互的应用是非常普遍的,在第十一章中,我们将会介绍如何创建一个简单的web服务器

阿姆达尔定律

为了定量的看一下系统的加速比,我们首先介绍一下阿姆达尔定律

$$ S = T_{old} / T_{new} = \frac{1}{(1 - \alpha) + \alpha / k} $$

这个定律的主要思想是,当我们对系统的某一部分进行加速时,被加速部分的重要性和加速程度是影响整体系统性能的关键因素。在加速前,假设一个应用程序的执行所需要的全部时间用 $T_{old}$ 来表示,为了方便描述,我们可以笼统的将这个程序分为两部分,一部分是不可加速的另外一部分是可加速的,其中可以加速的部分执行花费的时间为 $\alpha \cdot T_{old}$ ,所以不可加速部分的执行时间为 $T_{old}-\alpha\cdot T_{old}$

程序经过优化后,可加速部分性能提升比例为 $k$ ,那么经过加速后,这个可加速部分所花费的时间就是 $(\alpha \cdot T_{old})/k$ ,因此我们可以计算出程序经过加速后所花费的时间,这里用 $T_{new}$ 来表示

$$ T_{new} = (1 - \alpha) \cdot T_{old} + (\alpha\cdot T_{old}) / k = T_{old} \cdot [(1 - \alpha) + \alpha / k] $$

此时,我们可以得到加速比 $S = T_{old} / T_{new}$

$$ S = T_{old} / T_{new} = \frac{1}{(1 - \alpha) + \alpha / k} $$

当 $\alpha = 0.6$ 时,加速因子 $k = 3$ ,那么我们获得的加速比就可以根据上述的公式计算出来 $S = 1.67$ 倍

如果我们考虑一个极限情况, $k \to \infty$ ,也就是我们可以把这60%的部分加速到几乎不花时间的程度,此时的加速比可以简化为

$$ S = \frac{1}{1 - \alpha} $$

那么整个系统获得的净加速比也只有

$$ S = \frac{1}{1 - \alpha} = \frac{1}{1 - 0.6} = 2.5 $$

因此在计算机的世界里,如果我们需要把系统的性能提高2倍或者更多,只有通过优化大部分的组件才能实现

如何获得更高的计算能力

可以通过以下三种途径

  1. 线程级并发
  2. 指令级并发
  3. 单指令多数据并行

线程级并发

处理器芯片具有四个CPU核心,每个CPU核心都有自己的L1cache和L2cache,四个CPU核心共享L3cache,这四个CPU核心集成在一颗芯片上,对于许多高性能的服务器芯片,单颗芯片集成的CPU数量高达几十个,甚至上百个,通过增加CPU的核心数,可以提高系统的性能

除此之外,还有一个技术就是超线程(hyperthreading),也称同时多线程,如果每个CPU核心可以同时执行两个线程,那么四个核心就可以并行地执行8个线程,那么单个CPU核心是如何实现超线程的呢?在CPU内部,像程序计数器和寄存器文件这样的硬件部件有多个备份,而像浮点运算部件这样的硬件还是只有一份,常规单线程处理器在做线程切换时,大概需要20000个时钟周期,而超线程处理器可以在单周期的基础上决定执行哪一个线程,这样一来CPU可以更好地利用它来处理资源,当一个线程因为读取数据进入等待状态时,CPU可以去执行另外一个线程,其中线程之间的切换只需要极少的时间代价,在第12章,我们会深入探讨一下并发的相关知识

指令级并发

现代处理器可以同时执行多条指令的属性称为指令级并行,每条指令从开始到结束大概需要20个时钟周期或者更多,但是处理器采用了非常多的技巧可以同时处理多达100条指令,因此近几年的处理器可以保持每个周期2~4条指令的执行效率,在第四章中,我们将详细介绍流水线技术

单指令多数据

现代处理器拥有特殊的硬件部件,允许一条指令产生多个并行的操作,这种方式称为单指令多数据(Single Instruction Multiple Data),SIMD的指令多是为了提高处理视频以及声音这类数据的执行速度,比较新的Intel以及AMD的处理速度都是支持SIMD指令加速的

计算机系统中的抽象

在文章的开始,我们介绍了三个抽象,文件、虚拟内存以及进程,这里我们再增加一个,虚拟机是对整个计算机系统的抽象,包括操作系统、处理器以及程序

后续的内容我们会具体介绍这些抽象,第一章计算机系统漫游至此。