前言
今天来读一下《深入理解计算机系统》,在这篇文章中,笔者将这本书的内容做一些大概的总结与归纳,如果你希望学习:
- 如何避免由计算机表示数字的方式导致的奇怪的数字错误
- 怎样通过一些小聪明小窍门来优化你的C代码,以充分利用现代处理器和存储器系统的设计
- 编译器是如何实现过程调用的
- 如何避免缓冲区溢出错误带来的安全漏洞
- 如何识别和避免链接时那些令人讨厌的错误
- 如何编写自己的Unix shell,自己的动态存储分配包,甚至是自己的Web服务器
- 了解并发带来的希望与陷阱
强烈推荐去看一下原书。本文只能是对书本中笔者认为重要或者难以理解与记忆的地方做总结与推导,甚至部分内容加入了笔者自己的理解,理解不一定正确,仅供大家参考。
最后,再一次推荐大家去看一遍原书,我们要直接对原著围观,尽可能不围观他人的围观,这中间难免有信息的减损甚至误导。
我们将通过跟踪hello程序的生命周期,来开始对系统的学习。
|
|
当然,首先我们要对要出现的关键概念,专业术语与组成部分做一下介绍。
信息就是位+上下文
hello程序的生命周期是从一个源程序(源文件)开始的,源程序实际上就是一个值由0和1组成的位序列。大部分的现代系统都使用ASCII标准来表示文本。像hello.c这种只由ASCII字符构成的文件称为文本文件,其它的则称之为二进制文件,如:如经过汇编器后将汇编语言(文本)翻译成机器语言(二进制)后的可重定位目标程序。
编译系统
GNU
GNU(GNU’s Not Unix),是1984年由Richard Stallman发起的免税慈善项目,旨在开发一个完整的类Unix的系统,其源代码可不受限制地传播与修改。GNU已经开发出了一个包含出Unix所有的主要部件,出了内核(由Linux项目独立发展)。GCC便是其中一个有用的工具之一。
GCC编译器可以编译包括C语言,C++,Objective-C,Python等多种语言。到现在也是Linux下非常重要的C语言编译工具。
编译过程
在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:
|
|
这个编译系统由四个阶段组成:预处理器、编译器、汇编器、链接器。
- 预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如hello程序中的
#include<stdio.h>
会告诉预处理器读取系统头文件stdio.h的内容,并直接插入到程序文本中,结果就得到了另一个C程序,通常以.i
作为文件扩展名 - 编译阶段:编译器(cc1)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。汇编语言程序中的每条语句都以一种标准的文本格式确切地描述了一条低级机器语言指令。
- 汇编阶段:汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o 中。hello.o 文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。 ==从此时开始,文件内容格式便从ASCII字符文本转换成二进制==
- 链接阶段:hello 程序调用了 printf 函数,它是每个 C 编译器都会提供的标准 C 库中的一个函数。printf 函数存在于一个名为 printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中。链接器(ld)就负责处理这种合并。结果就得到 hello 文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
处理器读并解释存储在存储器中的指令
了解系统的硬件组成
- 总线:贯穿整个系统的是一组电子管道,称做总线,它携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节块,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,在各个系统中的情况都不尽相同。现在的大多数机器字长有的是 4 个字节(32 位),有的是 8 个字节(64 位)。
- I/O设备:输入 / 输出(I/O)设备是系统与外部世界的联系通道。
- 主存:主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(即数组索引),这些地址是从零开始的。
- 处理器:中央处理单元(CPU),简称处理器,是解释(或执行)存储在主存中指令的引擎。处理器的核心是一个字长的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC 都指向主存中的某条机器语言指令(即含有该条指令的地址)。
运行hello程序
- 初始时,外壳程序(shell)执行它的指令,等待我们输入一个命令。当我们在键盘上输入字符串“./hello”后,外壳程序将字符逐一读入寄存器,再把它存放到存储器中,如下图所示。
- 当我们在键盘上敲回车键时,外壳程序就知道我们已经结束了命令的输入。然后外壳执行一系列指令来加载可执行的 hello 文件,将 hello 目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串“hello, world\n”。利用直接存储器存取(DMA)的技术,数据可以不通过处理器而直接从磁盘到达主存。这个步骤如下图所示。
- 一旦目标文件 hello 中的代码和数据被加载到主存,处理器就开始执行 hello 程序的main 程序中的机器语言指令。这些指令将“hello, world\n”字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。这个步骤如下图 所示。
从这张图中可以看到,当CPU的工作需要使用到某些资源或数据时,从主存调取往往需要费一番周折,从主存出发,经过存储器总线,系统总线,总线接口,才堪堪到达寄存器,而这段时间CPU是无法进行下一步工作的,CPU的算力被白白浪费了。
高速缓存
高速缓存就是CPU
与主存
(我们经常口头说的内存)之间的一个缓冲区,因为主存的读写速度要比CPU的运行速度低几个数量级,因此,每当CPU开始需要从主存中读写数据时,都需要停下来等待主存完成读写,这样CPU的算力被白白浪费了。因此高速缓存诞生了,它的存储空间没有主存大,但是运行速度比主存快,它的运行速度没有CPU内的寄存器快,但是存储空间比寄存器大,如下所示:
存储空间 | 读写速度 | |
---|---|---|
寄存器 | 小 | 快 |
高速缓存 | 中 | 中 |
主存 | 大 | 慢 |
从数学的角度来讲,在寄存器与主存中间插入了以及高速缓存,使读写速度的曲线更为平稳光滑,虽然高速缓存的速度依然没有寄存器快,但是介于成本(毕竟运行速度与存储空间在当下的物理材料与技术特性中依然属于不可兼得的)与运行效率的考量,高速缓存是最具性价比的。
首先了解什么叫局部性原理
局部性原理的概念:
局部性原理是计算机科学中的一个重要概念,它描述了一个现象:==在一段时间内,程序倾向于仅使用一部分代码或数据。==这种倾向性分为两类:时间局部性和空间局部性。时间局部性指的是如果某个数据项被访问,那么它不久后可能会被再次访问。空间局部性则是指如果访问了某个存储单元,那么其附近的存储单元也很可能不久后会被访问
利用局部性原理,可能经常访问的数据提前调入高速缓存中,如此CPU在访问下一次访问时,大概率可以直接从高速缓存中进行访问,这样运行速度将大大提升。
在编程实践中,理解局部性原理可以帮助开发者优化代码性能。例如,在处理数组或循环结构时,考虑数据的存储和访问模式可以显著影响程序的执行效率。数组通常在内存中顺序存储,因此按行访问数组元素通常比按列访问更高效,因为这符合空间局部性原理。
存储设备的层次模型
这个模型比较熟悉了
操作系统管理硬件
应用程序并不直接接触硬件,操作系统介于软件与硬件之间。
操作系统为程序与硬件之间提供了一个虚拟接口,操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂又大相径庭的硬件设备
操作系统通过将复杂的硬件接口抽象成几个基本概念(进程,虚拟存储器,文件)供应用程序使用,以此来完成上面两个功能。
操作系统将I/O设备抽象成文件,将主存+I/O设备抽象成虚拟存储器,将处理器+主存+I/O设备抽象成进程。
文件
文件就是字节序列,每一个I/O设备,磁盘,键盘,甚至是网络,都可以看作是一个文件,I/O设备的输入输出,其实就可以看作是对文件的读写操作。这样对程序员来说是方便的,你无需了解磁盘的各种技术,就可以使用磁盘的数据,因此,同一个程序可以在使用了不同磁盘技术的不同系统上运行。
虚拟存储器
虚拟存储器是一个抽象概念,它为每一个进程提供了一种抽象,让进程以为自己独占内存,让每一个进程看到的是一致的内存,这对程序员是方便的,假设如果没有虚拟内存储器技术,程序员在编写程序时,需要提前对内存进行操作,因为你不知道你现在使用的内存地址是否被其它进程占用了。现在,对内存的操作交给了虚拟存储器,程序员只需编写相对地址,由操作系统自动分配实际的内存地址。以下是Linux进程的虚拟地址空间
- 程序代码和数据:对于所有进程来说,代码是从同一固定地址开始的,然后是全局变量与相对的数据位置。代码和数据是按照可执行目标文件的内容开始初始化的,当然还用这里还有链接与加载的事。
- 堆:下一块是运行时堆,与代码与数据区不同,代码与数据区在进程一开始时就确定好了大小,而堆的空间会随着程序调用
malloc
或free
等操作,而增大或缩小空间。 - 共享库:大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样共享库的代码和数据的区域。
- 栈:位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。特别是每次我们调用一个函数时,栈就会增长 ;从一个函数返回时,栈就会收缩。
- 内核虚拟存储器:内核总是驻留在内存中,是操作系统的一部分。地址空间顶部的区域是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。
进程
当程序在操作系统上运行时,操作系统会提供一个假象,即系统上好像只有这一个程序在运行,它可以调用处理器,主存和I/O设备。
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。在系统中,正在运行的进程往往是比CPU的个数还要多的,因此,无论是单核处理器还是多核处理器,都具备并发运行的能力。而并发运行是通过处理器在进程之间切换来实现的,操作系统实现这种交错机制称之为“上下文切换”。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,它包括许多信息,例如 PC 和寄存器文件的当前值,以及主存的内容。
当操作系统决定将处理器的控制权交给另外一个进程时,就会进行上下文切换,保存当前进程的上下文,恢复新进程的上下文,将处理器控制权交给新进程。
值得注意的是,在Linux中,上下文切换的读写数据在内核空间发生,“内核空间”与“用户空间”是Linux类系统的重要概念。
小结
计算机系统是由硬件和系统软件组成的,它们共同协作以运行应用程序。计算机内部的信息被表示为一组组的位,它们依据上下文有不同的解释方式。程序被其他程序翻译成不同的形式,开始时是 ASCII 文本,然后被编译器和链接器翻译成二进制可执行文件。
处理器读取并解释存放在主存里的二进制指令。因为计算机把大量的时间用于存储器、I/O设备和 CPU 寄存器之间复制数据,所以将系统中的存储设备划分成层次结构—CPU 寄存器在顶部,接着是多层的硬件高速缓存存储器、DRAM 主存和磁盘存储器。在层次模型中,位于更高层的存储设备比低层的存储设备要更快,单位比特开销也更高。层次结构中较高层次存储设备可以作为较低层次设备的高速缓存。通过理解和运用这种存储层次结构的知识,程序员可以优化C 程序的性能。
操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象 :1)文件是对 I/O设备的抽象 ;2)虚拟存储器是对主存和磁盘的抽象 ;3)进程是对处理器、主存和 I/O 设备的抽象。