《深入理解计算机系统》读书笔记&要点总结by 浅墨

§第一章 计算机系统漫游

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

2、区分不同数据对象的唯一方法是我们读到这些数据时候的上下文。

3、汇编为不同的高级语言的编译器提供了通用的输出语言。

4、从物理上来说,主存是由一组动态随机存取存储器(DRAM)组成的。从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(即数组索引),这些地址是从0开始的。

5、利用直接储存器存取(DMA),数据可以不通过处理器而直接从磁盘到达主存。

6、对处理器而言,从磁盘驱动器上读取一个字的开销要比从主存中读取的开销大100万倍。

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

8、操作系统有两个基本的功能:1)防止硬件被失控的应用程序滥用 2)向应用程序提供简单一致的机制来控制复杂而通常大相径庭的低级硬件设备。操作系统通过几个基本的抽象概念(进程、虚拟存储器和文件)来实现这两个功能。

9、进程是对操作系统正在运行的程序的一种抽象。

10、操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,包括了许多信息,例如PC和寄存器文件的当前值,以及主存的内容。

11、实现进程这个抽象的概念需要底层硬件和操作系统软件之间的紧密配合。

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

13、文件是对I/O的抽象,虚拟存储器是对程序存储器的抽象,进程是对一个正在运行的程序的抽象。

第一部分 程序的结构和执行

§第二章 信息的表示和处理

1、二值信号能更容易的被表示存储和运输,对二值信号进行存储和执行计算的电子电路非常简单和可靠,制造商能够在一个单独的硅片上集成数百万甚至数十亿个这样的电路。

2、无符号(unsigned)编码基于传统的二进制表示法,表示大于0或者等于0的数字;补码(two’ s s-complement)是表示有符号整数的最常见的方式;浮点数(floating-point)编码是表示实数的科学计数法的以二为基数的版本。

3、整数的表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;浮点数虽然可以编码一个较大的数值范围,但这种表示是近似的。由于表示的精度有限,浮点运算是不可结合的。

4、机器级程序将储存器视为一个非常大的字节数组,称为虚拟存储器(virtual memory)。存储器的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能的地址集合称为虚拟地址空间(virtual address space)。顾名思义,这个虚拟地址空间只是一个展现给机器级程序的概念性映像,实际的实现是将随机访问存储器(RAM),磁盘存储器,特殊硬件和操作系统软件结合起来,为程序提供一个看上去统一的字节数组。

5、C编译器把每个指针和类型信息联系起来,这样就可以根据指针值的类型,生成不同的机器级代码来访问存储在指针所指向位置处的值。尽管C编译器维护着这个类型信息,但是它生成的实际机器级程序并不包含关于数据类型的信息。每个程序对象可以简单地视为一个字节块,程序本身就是一个字符序列。

6、每台计算机都有一个字长(word size),指明整数和指针数据的标称大小(nominal size)。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为w的机器而言,虚拟地址的尺寸范围为0~2w-1,程序最多访问2w个字节。

7、在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中的最小地址。

8、最低有效字节在最前面的方式,称为小端法(little endian)。大多数Intel兼容机都采用这种模式;最高有效字节在最前面的方式,称为大端法(big endian)。大多数IBM和Sun Microsystems的机器采用这种模式。这些规则并没有严格按照企业界限来划分。许多比较新的微处理器采用双端法(bi-endian),可以配制作为小端或者大端运行。

9、在使用ASCII码作为字符码的任何系统上都将得到相同的结果,与字节顺序和字大小无关。因为,文本数据比二进制数据具有更强的平台独立性。

10、计算机系统的一个基本概念就是从机器的角度看,程序仅仅是字节序列。机器没有任何关于初始源程序的任何信息,除了可能有些用来帮助调试的辅助表而已。

11、C语言标准并没有明确定义应该使用哪种类型的右移。对于无符号数据(也就是以限定词unsigned声明的整形对象),右移必须是逻辑的。而对于有符号数据,算数的和逻辑的都是可以的。不幸的是,这就意味着任何一种假设或者另一种右移的假设都潜在着可移植问题。然而,实际上,几乎所有的编译器/机器组合都对有符号数采用算术右移,且许多程序员都假设机器会采取这种右移。另一方面,Java对于如何进行右移有着明确的定义。表达式x»k会将x算术右移k个位置,而x»>k会对x做逻辑右移。

12、在许多机器上,当移动一个w位的值时,移位指令只考虑位移量的低log2w位,因此实际上位移量就是通过k mod w来计算的。不过这种行为对于C程序来说是没有保证的,所以移位数量应该保证小于字长。

13、在C表达式中搞错优先级是很常见的事情,所以当你拿不准的时候,最好加上括号。

14、C和C++都支持有符号数和无符号数,而Java只支持有符号数。

15、C语言标准并没有要求有符号数采用补码的形式存储,但几乎所有的机器都是这么做的。

16、C库中的头文件定义了一组常量,来限定编译器运行的这台机器的不同整型的取值范围。

17、强制类型转换的结果保持位值不变,只是改变了解释这些位的方式(仅指有符号数和无符号数)。

18、由于C语言对同时包含有符号数和无符号数的表达式进行计算时,会隐式的将有符号数强制类型转换为无符号数,并假设这两个数都是非负的,并执行这个运算。这种方法对于标准的算术运算来说并无多大差异,但是对于像<和>这样的关系运算符来说,将导致非直观的结果。

19、IA32非同一般的属性是,浮点寄存器使用一种特殊的80位的扩展精度格式。与存储器中保存值所使用的 普通32位单精度和64位双精度格式相比,它提供了更大的表示范围和更高的精度。

20、目前大多数机器仍使用32位字长。大多数机器对整数使用补码编码,而对浮点数使用IEEE浮点编码。

21、由于编码的长度有限,与传统整数和实数运算相比,计算机运算具有完全不同的属性。当超出表示范围时,有限长度能够引起数值溢出。当浮点数非常接近于0.0,从而转换成0时,也会下溢。

22、浮点表示通过将数字编码为x×2y的形式来近似的表示实数。最常见的浮点表示方法是由IEEE标准754定义的。它提供了几种不同的精度,最常见的是单精度(32位)和双精度(64位)。IEEE浮点也能表示特殊值+,-,和NaN。必须十分小心的使用浮点运算,因为浮点运算只有有限的范围和精度,而且不遵守普遍的算术属性,比如结合性。

  • 关于IEEE标准754,另行总结。

§第三章 程序的机器级表示

1、GCC以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,从而根据汇编代码生成可执行的机器代码。

2、现代编译器的优化产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样的高效和简洁。用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。

3、程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求程序员能直接使用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。

4、对于机器级编程来说,其中两种抽象尤为重要。第一种是机器级程序的格式和行为,定义为指令集体系结构(Instruction set architecture,ISA)它定义了处理器状态,指令的格式,以及每条指令对状态的改变。大多数ISA,包括IA32和x86_64,将程序的行为描述成好像每条指令按顺序执行的,一条信息结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致。第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去像是一个非常大的字节数组。储存器系统的实现实际上是将多个硬件存储器和操作系统软件组合起来的。

5、虽然C语言提供了一种模型,可以在存储器中声明和分配各种数据类型的对象,但是机器代码只是简单的把存储器看成一个很大的、按字节寻址的数组。C语言中的聚合数据类型,例如数组和结构,在机器代码中用连续的一组字节来表示。即使是标量数据类型,汇编代码也不区分有符号数或无符号整数,不区分各种类型的指针,甚至不区分指针和整数。

6、虽然IA32的32位地址可以寻址4GB的地址范围,但是通常一个程序只会访问几兆字节。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器储存器(processor memory)中的物理地址。

7、由于是从16位体系结构扩展成32位的,Intel用术语“字”(word)表示16位数据类型。因此,称32位数为“双字”(double words),称64位数为“四字”(quad words)。

8、浮点数有三种格式:单精度(4字节)值,对应于C语言中的float类型;双精度(8字节)值,对应C语言中的double类型;扩展精度(10字节)值,GCC用数据类型long double来表示扩展精度的浮点值。为了提高存储器系统性能,它将这样的浮点数存储为12字节数。

9、处理器使用流水线(pipelining)来获取高性能。在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分。这种方法通过重叠连续指令的步骤来获取高性能。当机器遇到条件跳转时,它常常还不能确定是否要进行跳转。处理器采用非常精密的分支预测逻辑试图猜测每条指令是否会执行。只要它的猜测还比较可靠,指令流水线中就充满了指令。另一方面,错误预测一个跳转要求处理器丢掉它为该跳转指令后所有指令已经做的工作,然后再开始从正确位置处起始的指令去填充流水线。正如我们看到的,这样的一个错误预测会招致很严重的惩罚。大约20~40个时钟周期的浪费,导致程序性能的严重下降。

10、一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。大多数机器,包括IA32,只提供转移控制到过程和从过程中转移出控制这种简单的指令。数据传递、局部变量的分配和释放通过操纵栈来实现。

11、程序寄存器组是唯一能被所有过程共享的资源。虽然在给定时刻只能有一个过程是活动的,但是我们必须保证当一个过程调用另一个过程的时候,被调用者不会覆盖某些调用者稍后会使用到的寄存器的值。为此,IA32采用了一组统一的寄存器使用惯例,所有的过程都必须遵守,包括程序库中的过程。

根据惯例,寄存器%eax,%edx和%ecx是调用者保存寄存器。当过程P调用过程Q的时候,Q就可以覆盖掉这些寄存器,而不会破坏P所需要的数据。另一方面,寄存器%ebx,%esi,%edi被划分为调用者保存寄存器。这意味着Q必须在覆盖这些寄存器的值之前先将其保存到栈里,并在返回前恢复它们。此外,根据惯例,被调用者要保存%ebp,%esp。

12、GCC坚持了一个x86编程的方针,也就是一个函数使用的所有的栈空间必须是16字节的整数倍。包括保存%ebp的4个字节和返回值的4个字节。采用这个规则是为了保证访问数据的严格对齐(alignment)。

13、编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些字节偏移作为存储器引用指令中的位移,从而产生对结构元素的引用。

14、许多计算机系统对基本数据类型合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。这种对齐限制简化了形成处理器和存储器系统之间的硬件设计。无论数据是否对其,IA32硬件都能正常工作。不过,Intel还是建议要对数据进行对齐以提高存储器系统的性能。

15、对于大多数IA32指令来说,保持数据对齐能够提高效率,但是它不会影响程序的行为。另一方面,如果程序未对齐,有些实现多媒体操作的SEE指令就无法正确的工作。这些指令要求对16字节数据块进行操作,在SEE单元和存储器之间传送数据的指令要求存储器地址必须是16的倍数。任何试图以不满足对齐要求的地址来访问存储器都会导致异常(exception),默认的行为是程序终止。因此IA32的一个惯例是,确保每个栈帧的长度都是16字节的整数倍。编译器就可以在栈帧中以每个块的存储都是16字节对齐的方式来分配存储器。

16、Microsoft Windows对齐的要求更严格——任何K字节基本对象的地址都必须是K的倍数,K=2,4或者8。特别地,它要求一个double或者long long类型的数据的地址应该是8的倍数。这种要求提高了存储器性能,而代价是浪费了一些空间。Linux的惯例是8字节数据在4字节边界上对齐,这可能对i386很好,因为过去存储器十分缺乏,而存储器接口只有4字节宽。对于现代处理器来说,Microsoft的对齐策略就是更好的选择。在Windows和Linux上,数据类型long double都有4字节对齐的要求,为此GCC产生的IA32代码分配12个字节(虽然实际的数据类型只需要10个字节)。

  • 缓冲区溢出的部分内容在以前博文已经有所涉及,不再总结。由于时间关系,64位汇编的特性暂不总结。

§第五章 优化程序性能

1、编写高效程序需要几类活动:第一,我们必须选择一组合适的算法和数据结构;第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。对于第二点,理解编译器的优化能力和局限性是很重要的。编写程序的方式中看似只是一点点的变动,都会引起编译器优化方式上很大的变化;第三项技术针对处理运算量特别大的计算,讲一个任务分为多个部分,这些部分可以在多核和多处理器的某种组合上并行的计算。

2、尽管做了广泛的变化,但还是要维护代码一定程度的简洁和可读性。

3、程序员必须编写容易优化的代码,以帮助编译器。主要包括:消除循环的低效率,减少过程调用和消除不必要的存储器引用。

4、在实际的处理器中,是同时对多条指令求值,这个现象叫做指令级并行。特别地,当一系列操作必须严格按照顺序执行时,就会遇到延迟界限(latency bound),因为下一条指令开始之间,这条指令必须结束。当代码中的数据相关限制了处理器利用指令级并行的能力时,延迟界限会限制程序性能。吞吐量界限(throughput bound)刻画了处理器单元的原始计算能力。这个界限是程序性能的终极限制。

5、没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构,因此程序设计的这些方面仍然是程序员主要关心的。

§第六章 存储器层次结构

1、在简单模型中,存储器系统是一个线性的字节数组,而CPU能够在一个常数时间内访问每个存储器位置。实际上,存储器系统(memory system)是一个具有不同容量、成本和访问时间的存储器层次结构。CPU寄存器保存着最常用的数据。靠近CPU的小的、快速的高速缓冲存储器(cache memory)作为一部分存储在相对慢速的主存储器(main memory)中的数据和指令的缓冲区域。主存暂时存放存储在容量较大、慢速磁盘上的数据,而这些磁盘又常常作为存储在通过网络连接的其它机器的磁盘上的数据的缓冲地带。

2、如果程序所需的数据存储在CPU寄存器中,那么在指令的执行期间,在0个周期内就能访问到它们。如果在高速缓冲存储器内,需要1~30个周期。如果存储在主存中,需要50~200个周期。而如果在磁盘上,则需要大约几千万个周期。

3.存储器层次结构围绕着计算机程序的一个称为局部性(locality)的基本属性。具有良好局部性的程序倾向于一次又一次的访问相同的数据项集合,或是倾向于访问邻近的数据项集合。局部性通常有两种不同的形式:时间局部性(temporal locality)和空间局部性(spatial locality)。

4、由于历史原因,虽然ROM中有的类型既可以读又可以写,但是整体上还是叫做只读存取器(Read-Only Memory,ROM),存储在ROM中的 程序常常被称为固件(firmware)。

5、理解存储器层次结构本质的程序员能够利用这些知识编写出更有效的程序,无论具体的存储器系统是怎样实现的。特别地,我们推荐以下技术:1)将注意力集中在内循环上,大部分计算和存储器访问都发生在这里。2)通过按照数据对象存储在存储器中的顺序、以步长为1来读取数据,从而使程序的空间局部性最大。3)一旦程序中读入了一个数据对象,就尽可能多的使用它,从而使程序中的时间局部性最大

§第七章 链接

引言:现代操作系统与硬件合作,为每个程序提供一种幻象,好像这个程序是在独占的使用处理器和主存,而实际上,在任何时刻,系统上都有多个程序在运行。

1、链接(linking)是将各种代码和数据部分收集起来并组合成一个单一文件的过程(感觉该句描述欠妥,应该是针对目标文件而非代码文件),这个文件可以被加载(或被拷贝)到存储器执行。链接可以执行于编译时(compile time),也就是在源代码被翻译为机器代码时;也可以运行于加载时(load time),也就是程序被加载器(loader)加载到存储器并执行时;甚至执行与运行时(run time),由应用程序执行。

2、链接器对目标机器知之甚少,产生目标文件的编译器和汇编器已经完成了大部分工作了。

3、当编译器遇到一个不是在当前模块中被定义的符号(变量或函数名)时,它就会假设该符号是在其它某个模块中被定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输出一条错误信息并终止。

4、所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,称为静态库(static library),它可以用做链接器的输入。

5、共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行时,可以加载到任意的 存储器地址,并和一个在存储器中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序执行的。

6、每个Unix程序都有一个运行时存储器映像,在32位Linux系统中,代码段总是从地址0x08048000处开始。数据段是在接下来的一个4KB对齐的地址处。运行时堆在读/写段之后接下来的第一个4KB对齐的地址处,并通过调用malloc库向上增长。用户栈总是从最大的合法用户地址开始,向下增长的(向低存储器地址方向增长)。从栈的上部开始的段是为操作系统驻留存储器部分(也就是内核)的代码和数据保留的。

§第八章 异常控制流

1、现代操作系统通过使控制流发生突变对系统状态的变化做出响应。一般而言,我们把这些突变称为异常控制流(Exceptional Control Flow,ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文转换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

2、异常(exception)是异常控制流的一种形式,它一部分是由硬件实现的,一部分是由操作系统实现的。异常就是控制流中的突变,用来响应处理器状态中的某些变化。

3、在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表(即16位下的中断向量表和32位下的中断描述符表),进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler))。

4、系统中把每种可能发生的异常都分配了一个唯一的非负整数的异常号(exception number)。异常号是到异常表的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。(x86叫中断描述符表寄存器IDT(Interrupt Descriptor Table))。

5、异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。

6.中断是异步产生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序通常称为中断处理程序(interrupt handler)。I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,以触发中断,这个异常号标识了引起中断的设备。

7、陷阱是有意的异常,实质性一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途就是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。

8、故障是由错误引起的,它可能被故障处理程序修正。当故障发生时,处理器将控制转移到故障处理程序。如果处理程序能够修正这个错误情况,它就会将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核的abort例程,abort例程会终止引起故障的应用程序。

9、终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,例如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。

10、为了使描述更具体,让我们来看看为IA32系统定义的一些异常。有高达256种不同的异常类型。0~31的号码是由Intel架构师定义的异常,因此对任何IA32的系统都是一样的。32~255的号码对应的是操作系统中定义的中断和陷阱。

11、每个Linux系统调用都有一个唯一的整数号(系统调用号),对应于一个到内核中跳转表的偏移量。历史上系统调用是通过异常128(0x80)提供的。

12、C程序用syscall函数可以直接调用任何系统调用。然而,实际中几乎没有必要这么做。对于大多数系统调用,标准C库提供了一组方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用号陷入内核,然后将系统调用的返回状态传递给调用程序。

13、所有的Linux系统调用的参数都是通过寄存器而不是栈来传递数据的。按照惯例,%eax寄存器保存系统调用号,寄存器%ebx、%ecx、%edx、%esi、%edi和%ebp最多包含任意6个参数。栈指针%esp不能使用,因为当进入内核,模式时,内核会覆盖它。

14、进程是计算机科学中最深刻最成功的概念之一。进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都是运行在某个进程的上下文(context)中的。上下文是由程序正常运行所需的状态组成的。这个状态包括存放在存储器中的程序代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开的文件描述符的集合。

15、内核为每个程序维持一个进程上下文。上下文就是内核重新启动一个被抢占的进程所需要的状态。它由一些对象的值组成,这些对象包括通用的目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描绘地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

Published 31 May 2014