《深入理解计算机系统-程序结构》读书笔记
计算机系统是由硬件和系统软件组成的,他们共同工作来运行应用程序。在《深入理解计算机系统》一书中将会学到很多实践的技巧。例如:了解编译器是如何实现过程调用的、避免缓冲区溢出错误带来的安全漏洞、理解并发带来的希望和陷阱等。
1.1 信息就是位+上下文
-
源程序的组成:
-
源程序是由值0和1组成的位序列,8个位被组织成一组,称为字节。
-
每个字节表示程序中的某些文本字符。
-
-
文本文件:
-
大部分系统都使用ASCII标准来表示文本字符,只由ASCII字符构成的文件称为文本文件。
-
所有其他文件都称为二进制文件。
-
1.2 程序被其他程序翻译成不同格式
-
目标程序:
-
目标程序也称为可执行目标文件,目标文件是由编译器驱动程序将源程序转化得到的。
-
-
翻译过程分为4个阶段
-
预处理阶段:将源程序修改位另一种能够编译的程序文件。
-
编译阶段:将预处理的源程序翻译成汇编语言程序。
-
汇编阶段:将汇编语言程序翻译成机器语言指令。
-
链接阶段:把标准库的目标程序和源程序合并,得到可以被加载到内存中,系统执行的目标文件。
-
-
了解编译系统如何工作是大有益处的
-
优化程序性能:一个函数调用的开销有多大?本地变量和引用变量哪个更快?简单的排列括号函数就运行的快很多是什么原因?等等
-
理解链接时出现的错误:连接器无法解析引用是什么意思?静态变量和全局变量的区别是什么?为什么有些链接直到运行时才出现错误?
-
避免安全漏洞:缓冲区溢出错误、理解数据和控制信息存储在程序栈中的方式会引起的后果。
-
1.3 处理器读并解释储存在内存中的指令
-
源程序解释成目标文件后,通过shell命令行解释器执行
-
系统的硬件组成
-
总线:贯穿整个系统的一组电子管道。它携带信息字节并负责向各个部件间传递。通常被设计成传送定长的字节块,4个字节是32位,8个字节是64位。
-
I/O设备:系统与外部世界联系的通道。例如:键盘、鼠标、显示器、磁盘。
-
主存:临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。由一组动态随机存取器芯片组成的。
-
处理器:中央处理单元,是解释存储在主存中指令的引擎。
-
-
运行程序
-
键盘输入的命令读入寄存器,再放到内存。
-
根据输入的指令从磁盘复制目标文件到主存。
-
处理器执行目标程序,然后从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。
-
1.4 存储设备层次结构
-
指令执行过程:
-
机器指令会从磁盘复制到主存,当执行时从主存复制到处理器。
-
-
高速缓存存储器:
-
存放处理器近期可能会需要的信息
-
分为L1、L2、L3,L1容量最小访问速度最快,L2容量打访问较L1慢。
-
-
存储设备结构层次
-
L0: 寄存器
-
L1->L3:高速缓存(SRAM)
-
L4:主存(DRAM)
-
L5: 本地二级存储(本地磁盘)
-
L6: 远程二级存储(分布式文件系统,Web服务器)
-
1.5 进程
-
进程
-
是操作系统对一个正在运行的程序的一种抽象。
-
并发运行说的是一个进程和另一个进程交错执行。
-
-
线程
-
每个线程都运行在进程的上下文中,共享同样的代码和全局数据。
-
-
虚拟内存
-
虚拟内存是一个抽象概念。
-
它为每个进程提供一个假象,即每个进程都独占主存,每个进程看到的内存都是一致的。
-
1.6 并发和并行
-
线程级并发:多任务之间切换执行。
-
指令级并发:同时执行多条指令。
-
单指令、多数据并行:一条指令可以产生多个可并行执行的操作。
1.7 小结
程序开始是ASCII文本,然后被编译器翻译成二进制可执行文件。处理器读取并解释存放在主存里的二进制指令。
2.信息的表示和处理
2.1 数字运算
-
三种数字表示
-
无符号编码基于二进制表示法,表示大于或者等于零的数字。
-
补码编码表示有符号整数的最常见方式,有符号整数可以为正或者为负的数字。
-
浮点数编码是表示实数的科学记数法的以2为基数的版本。
-
-
整数运算:整数是有限数量的位,当太大时就会溢出。只能表示相对较小的数值范围,但是很精确。
-
浮点数运算:可以表示一个较大的数值范围,但是这种表示只是近似。
2.2 信息存储
-
虚拟内存和虚拟地址:
-
机器级程序将内存视为一个非常大的字节数组,称为虚拟内存。
-
内存的每个字节都由一个数字表示,称为地址。所有可能的地址集合就称为虚拟地址空间。
-
虚拟地址空间是一个概念性映像。实际上是动态随机访问存储器、内存、磁盘存储器、特殊硬件和操作系统软件结合起来提供的看上去统一的字节数组。
-
-
十六进制表示法:十六进制由0~9以及A-F表示。
-
字数据大小:
-
每台计算机都有一个字长,指明指针的标称大小。
-
因为虚拟地址是以这样一个字来编码,所以字长决定虚拟地址空间的大小。
-
32位字长位4GB,64位为16EB,大多数64为机器也可以运行32位机器编译的程序。
-
-
寻址和字节顺序
-
对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及在内存中如何排列这些字节。
-
小端法:最低有效字节在前面,最高有效字节在后面。
-
大端法:最高有效字节在后面,最低有效字节在前面。
-
-
表示字符串
-
C语言的字符串被编码为一个null字符结尾的字符数组,每个字符都由某个标准编码来表示,最常见的是ASCII字符码。
-
文本数据比二进制数据具有更强的平台独立性。
-
-
表示代码
-
不同的机器类型使用不同的且不兼容的指令和编码方式。
-
完全一样的进程,在不同的操作系统上也会有不同的编码规则,因此二进制代码是不兼容的。
-
-
布尔代数简介
-
二进制值是计算机编码、存储和操作消息的核心。
-
布尔代数是在二元集合{0,1}基础上的定义。
-
2.3 整数表示
-
整型数据类型
-
C语言支持多种整型数据类型,表示有限范围的整数。
-
负数位比整数范围大1,int为21亿,无符号为42亿。
-
无符号数的编码范围2的i次幂。
-
补码编码是双射,因此需要除以2.
-
-
无符号和有符号的转换
-
强制转换只会保持位值不变,但是解释这些位的方式发生变化,最终解释的结果会不一致。
-
2.4 整数运算
-
超出计算机运算范围时,两个正数相加会得到一个负数,x<y和x-y<0产生不同的结果。
-
无符号的相加
-
算术运算溢出:是指完整的整数结果不能放到数据类型的字长限制中去。
-
-
大多数机器,整数乘法指令需要10个以上的时钟周期。除法需要30个以上的时钟周期。因为是使用逻辑位移和算术位移达到目的。
2.5 浮点数
-
浮点数的表示
-
小数点左边是10的正幂,小数点右边是10的负幂。0
-
-
IEEE浮点表示
-
符号:决定数字是负数还是正数。
-
尾数:二进制小数。
-
阶码:对浮点数加权。
-
-
舍入
-
表示方法限制了浮点数的范围和精度,所以浮点数运算只能近似地表示实数运算。
-
舍入的方法:向偶舍入(四舍五入)、向零舍入、向上舍入、向下舍入。
-
-
浮点数运算
-
浮点数运算的结果都是经过舍入的近似值,因此(3.14+le10)-le10会得到0.0,而3.14+(le10-le10)得到3.14。
-
-
C语言的浮点数:float单精度浮点数和double双精度浮点数。
2.6小结
-
计算机将信息编码为位(比特),通常组织成字节序列。不同的编码方式表示整数、实数和字符串。不同的计算机模型在编码数字和多字节数据中字节顺序使用不同的约定。
-
64位字长机器逐渐取代32位机器。64位机器的虚拟地址空间是16EB,而32位的虚拟地址空间是4GB。
-
大多数机器对整数使用补码编码,对浮点数使用IEEE标准754编码。
-
必须小心使用浮点数运算,因为浮点数运算只有有限范围和精度,并且不遵守普遍的算术属性,例如结合性。
3.程序的机器级表示
3.1 程序编码
-
命令GCC指的是GCC编译器,是Linux上默认的编译器。
-
GCC命令将源代码转化成可执行代码的过程:
-
首先,C预处理器扩展源代码,插入include命令指定的文件,扩展所有define声明指定的宏。
-
然后,编译器产生两个源文件的汇编代码。
-
接下来,汇编器会将汇编代码转化成二进制目标代码文件。
-
最后,链接器将两个目标代码文件与实现库函数的代码合并,并产生最终的可执行代码文件。
-
-
机器级代码
-
机器级编程的两种抽象:指令集体系结构或指令集架构、机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
-
-
x86-64的机器代码
-
程序计数器:给出将要执行的下一条指令在内存中的地址。
-
整数寄存器文件:包含16个命令的文职,分别存储64位的值。
-
条件码寄存器:保存着最近执行的算术或逻辑指令的状态信息。
-
一组向量寄存器:可以存放一个或多个整数、浮点数值。
-
3.2 控制条件
-
机器代码实现有条件行为的机制:测试数据值。
-
条件码:
-
除了整数寄存器,CPU还维护着一组单个位的条件码寄存器。
-
CF:进位标志,最近操作使最高位产生了进位,可用来检查无符号操作的溢出。
-
ZF:零标志,最近的操作得出的结果为0。
-
SF:符号标志,最近的从左得到的结果为负数。
-
OF:溢出标志,最近操作导致一个补码溢出-正溢出或者负溢出。
-
-
访问条件码:,条件码通常不直接读取,常用的使用三种方法:
-
根据条件码的某种组合,将一个字节设置为0或者1。
-
可以条件跳转到程序的某个部分。
-
可以有条件的传送数据。
-
-
跳转指令
-
正常情况下指令使按照顺序一条一条的执行。而跳转指令可以让执行切换到程序中一个全新的位置。
-
直接跳转:跳转目标作为指令的一部分编码,跳转到给出的标号位置。
-
间接跳转:跳转目标使从寄存器或内存位置中读出的。
-
-
用条件控制来实现条件分支
-
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
-
实现条件操作的传统方式是通过使用控制的条件转移。
-
条件控制转移代码需要预测逻辑来使得指令流水线上充满指令大概需要8-27个周期,而使用条件传送的代码所需的时间都是大约8个时钟周期。
-
条件数据传送提供了一种条件控制转移来实现条件操作的代替策略,但是它只能用于非常受限制的情况。
-
3.2 循环
-
汇编中没有类似while和for的循环,但可以用条件测试和跳转组合来实现循环的效果。
-
do-while循环结构:
-
每次循环都会执行循环体里的语句,然后执行测试表达式。如果测试为真,就回去再执行一次循环。
-
-
while循环
-
在执行循环体之前,会对表达式进行测试。
-
有两种翻译成机器代码的方法:
-
第一种翻译方法:执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。
-
第二中翻译方法:称之为guarded-do,首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while循环。
-
-
-
for循环
-
先转换为while循环,再进行guarded-do变换,转换成do-while形式。
-
-
switch语句
-
switch能根据整数索引值进行多重分支,它是一个跳转表,这种数据结构能使得实现更加高效。
-
switch跟一组很长的if-else相比,使用switch的优点是执行开关语句的时间与开关情况的数量无关。GCC会根据开关的数量和稀疏程度来翻译开关。
-
执行switch语句的关键步骤3是通过跳转表来访问代码位置。
-
3.3 过程
-
过程是软件中的一种抽象
-
良好的过程设计需英寸某个行为的具体实现,同时有提供清晰简洁的接口定义,说明要计算哪些值,过程会对程序状态产生什么样的影响。
-
传递控制:进入过程时程序计数器记录起始地址,返回时需调用过程后面的地址。
-
传递数据:过程接收一个或者多个参数,处理完成后返回一个值。
-
分配和释放内存:开始时,为局部变量分配空间,而返回前又必须释放这些存储空间。
-
-
运行时栈
-
C语言的过程调用,使用的机制是栈数据结构后进先出的内存管理原则。
-
当X96-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧。
-
-
转移控制
-
将控制从函数P转移到函数Q只需要简单地把程序计数器设置为Q的代码的起始位置。当从Q返回时,需要继续P的执行的代码位置。在X86-64机器中,这个信息时用指令call Q调用过程Q来记录的。因此使用栈。
-
-
数据传递
-
X86-64中大部分过程间的数据传送是通过寄存器实现的。
-
可以通过寄存器最多传递6个整型参数,如果超过6个就要通过栈来传递。
-
-
栈上的局部存储
-
大多数过程示例不需要超出寄存器大小的本地存储区域,但是以下情况的局部数据必须存放在内存中
-
寄存器不足够存放所有的本地数据
-
对一个局部变量使用地址运算符&,因此必须能够为它产生一个地址
-
某些局部变量是数组或结构。
-
-
-
寄存器中的局部存储空间
-
寄存器组是唯一被所有过程都共享的资源。
-
为了避免被调用者覆盖调用者后面会使用的寄存器值,x86-64采用了统一的寄存器。
-
-
递归过程
-
递归调用一个函数本身和调用其他函数是一样的,栈提供一种机制,每次函数调用都有它的私有状态信息存储空间。
-
3.4 数组分配和访问
-
C语言中的数组是一种将表里数据聚集成更大数据类型的方式,数组下标都会被翻译成地址计算。因此,优化编译器非常善于简化数组索引所使用的地址计算。
-
基本原则
-
对于数据类T和整型常数N,声明如下:TA[N],起始位置表示为Xa,在内存中分配L.N字节的连续区域,数组可以用0~N-1的整数索引来访问该数组元素。
-
-
指针运算
-
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型大小进行缩进。
-
-
嵌套元素数组
-
访问多维数组的元素,编译器会以数组起始为基地址,偏移量为索引,产生计算期望的元素偏移量,然后使用某种MOV指令。
-
-
定长数组:C语言编译器能够优化定长多维数组上的操作代码。
-
变长数组:历史上C语言只支持定长数组。ISO C99引入了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。