前言
《深入理解计算机系统》值得每位程序员一读,看完之后将会对整个计算机体系有一个直观的认识。
第一章计算机系统漫游
- 只有ascii字符构成的文件称为文本文件,所有其它文件都称为二进制文件。
- c语言是古怪的,有缺陷的,但同时也是一个巨大的成功,为什么会成功呢
- c语言与unix操作系统关系密切
- c语言小而简单
- c语言是为实践目的设计的
有一些重要的原因促使程序员必须知道编译系统是如何工作的
- 优化程序性能
- 理解链接时出现的错误
避免安全漏洞
shell是一个命令行解释器,它提出一个提示符,等待输入一个命令行,然后执行这个命令。如果该命令行的第一个单词不是一个内置的shell命令,那么shell就会假设这是一个可执行文件的名字,它将加载并运行这个文件。
贯穿整个系统的是一组电子管道,称作总线。
io设备是系统与外部世界的联系通道。
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
处理器,是解释或执行存储在主存中指令的引擎。利用直接存储器存取,数据可以不通过处理器而直接从磁盘到达主存。
- 通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成
每个计算机系统中的存储设备都被组织成了一个存储器层次结构。
操作系统有两个基本功能
防止硬件被失控的应用程序滥用,向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过基本的抽象概念(进程,虚拟内存和文件)来实现这两个功能。
- 文件是对i/o设备的抽象表示,虚拟内存是对主存和磁盘i/o设备的抽象表示,进程则是对处理器,主存和i/o设备的抽象表示。
进程是对操作系统对一个正在运行的程序的一种抽象。
进程并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。操作系统实现这种交错执行的机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文。
当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文,恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从它上次停止的地方开始。
虽然我们对系统的一个主要部分做出了重大改进,但是获得的系统加速却明显小于这部分的加速比。这就是amdahl定律的主要观点——想要显著加速整个系统。必须提升全系统中相当大的部分的速度。
第二章信息的表示处理
每台计算机都有一个字长,指明指针数据的标称大小。因为虚拟地址是以这样的一个字来编码的。所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。
32位字长限制虚拟地址空间为4GB
最低有效字节在最前面的方式,称为小端法,最高有效字节在最前面的方式,称为大端法。
逻辑右移是在左端补k个0,而算术右移是在左端补k个最高有效位的值
java中,正常的右移运算符>>被定义为执行算术右移,特别的运算符>>>被指定为执行逻辑右移
几乎所有的编译器/机器组合都对有符号数使用算术右移,且许多程序员也都假设机器会使用这种右移,另一方面,对于无符号数,右移必须是逻辑的。
整型数据类型中,唯一一个与机器相关的取值范围是大小指示符long的
取值范围不是对称的,负数的范围正数的范围大1。
c和c++都支持有符号和无符号数,java只支持有符号数。
c语言标准并没有要求用补码形式来表示有符号整数,但是几乎所有的机器都是这么做的。
有符号数还可以通过反码和原码的方式表示。
要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加零,这种运算称为零扩展。
要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展,在表示中添加最高有效位的值。例如sx=-12345 cf c7,扩展后x=-12345 ff ff cf c7
算术运算溢出,是指完整的整数结果不能放到数据类型的字长限制中去。
整数乘法指定相当慢,因此,编译器使用了一项重要的优化,试着用移位和加法运算的组合来代替乘以常数因子的乘法。
整数除法比整数乘法更慢。
ieee浮点标准用V=(-1)^s×M×2^E
符号(sign)s决定这数是负数(s=1)还是正数(s=0),而对于数值0的符号位解释作为特殊情况处理。尾数(signficand)M是一个二进制小数,它的范围是1~2-ξ或者是0~1-ξ
阶码(exponent)E的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)
将浮点数的位表示划分为三个字段
一个单独的符号位,直接编码符号s
k位的阶段字段exp=ek-1e0编码阶码E
n位小数字段frac=fn-1…f0编码尾数M,但是编码出来的值也依赖于阶码字段的值是否等于0在单精度浮点格式(c语言中的float)中,s,exp和frac字段分别为1位,8位和23位,……分别为1位,11位和52位
阶码的值决定了这个数是格式化的,非格式化的或特殊值。
ieee浮点格式定义了四种不同的舍入方式。向偶数舍入,向零舍入,向下舍入,向上舍入。
为什么有向偶数舍入呢?计算平均值的情况。如果总是把两个可表示值中间的数字向下舍入,那么舍入后的一组数的平均值将比这些数本身的平均值略低一些。向偶数舍入的大多数现实情况中避免了这种统计偏差。
从float或者double转换成int,值将会向零舍入。
第三章 程序的机器级表示
相对于c代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作代替慢速操作。
超线程,可以在一个处理器上同时运行两个程序。2004年,pentium 4E开始支持。
c语言中的聚合类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号,不区分各种类型的指针,甚至于不区分指针和整数。
程序内存包含程序可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块。
一条机器指令只执行一个非常基本的操作。
反汇编目标文件objdump -d output.o
+x86-64的指令长度从1到15个字节不等。常用以及操作数较少的指令所需字节数少,而不太常用或操作数较多的指令所需字节数较多?
+反汇编器只是基于机器代码文件中的字节序列来确定汇编代码,它不需要访问该程序的源代码或汇编代码。
指令结尾的“q”是大小指示符,用来表明操作数的大小。例如,callq表明其操作数为4字。
1+ 6位为一字(word)。b一字节 1字节
- w一字 2字节
- l一双字 或双精度4字节或8字节(整型操作和浮点数操作指令和寄存器不同,不会产生歧义。)
- q一四字 8字节
s一单精度 4字节
指令的操作数分为三种类型。
第一种是立即数。用来表示常数值。在att格式的汇编代码中,立即数的书写方式是“$”后面跟一个用标准c表示法表示的整数。比如$0x1C
第二种类型是寄存器。表示寄存器的内容。
第三种是内存引用,它根据计算出来的地址访问某个内存位置。而寻址方式有多种。最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。
访问寄存器比访问内存要快的多。
栈向下增长,栈顶元素是所有栈中元素地址中最低的。
机器代码对于有符号和无符号两种情况都是用一样的指令,这是因为许多算术运算对无符号和补码算术都有一样的位级行为。有些情况需要使用不同的指令来处理有符号和无符号操作,例如,使用不同版本的右移,除法,和乘法指令。
- 跳转指令有几种不同的编码,但是常用的都是PC相对的。
实现条件操作的传统控制方法是通过使用控制的条件转移,当条件满足时,程序沿着执行路径执行,而当条件不满足时,就右另一条路径。这种机制简单而通用,但是在现代处理器上,它可能会非常低效。
一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足,从中选择选取一个。
条件传送指令更符合现代处理器的性能特性。
处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行,只要它的猜测还比较可靠,指令流水线充满着指令。另外一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确的位置处起始的指令去填充流水线。这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。
只有当两个表达式都很容易计算时,它才会使用条件传送。
理解产生的汇编代码与原始源代码之间的关系,环境是找到程序值和寄存器之间的映射关系。
switch开关语句可以根据一个整数索引值进行多重分值。它通过使用跳转表这种数据结构使得实现更加高效。跳转表示一个数组,表项i示一个代码段的地址,这个代码段实现党开关索引值等于i时程序应该采取的动作。与使用if else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。
c语言的struct声明创建一个数据类型,将可能不同的类型的对象聚合到一个对象中,用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成都存放在内存中的一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。
结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。
无论数据是否对齐,x86-64硬件都能正确工作,不过,intel还是建议要对齐数据以提高内存系统的性能。
对齐原则是任何k字节的基本对象的地址必须是k的倍数。
许多计算机对基本数据类型的合法地址做了一些限制,要求某种类型对象的地址必须是某个值通常是2,4,或8()得倍数。
对于大多数x86-64指令来说,保持数据对其能够提高效率,但它不会影响程序的行为
将指针从一种类型强制转换成另一种类型,只改变他的类型,而不改变它的值
函数指针的值是改函数机器代码表示中第一条指令的地址
*操作符用于间接引用指针
蠕虫和病毒都试图在计算机中传播它们自己的代码段。蠕虫可以自己运行,并且能够将自己的等效副本传播到其它机器。病毒能够将自己添加到包括操作系统在内的其它程序中,但它不能独立运行?
缓冲区一个更加致命的使用就是让程序执行它本来不愿意执行的函数。
对抗缓冲区溢出攻击
1.栈随机化,地址空间布局随机化,198页,使得栈的位置在程序每次运行时都有变化
栈破坏检测,插入金丝雀值
限制可执行代码区域
变长数组意味着在编译时无法确定栈帧的大小
c对于数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。
- 把浮点数转换成整数时,指令会进行截断,把值向0进行舍入,这是c和大多数其他编程语言的要求。
第5章
程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望任务。这包括消除不必要的函数调用,条件测试和内存引用。
了解处理器的运作,我们就可以进行程序优化的第二步,利用处理器提供的指令级并行能力,同时执行多条指令。
gcc优化级别越高,使用的优化量也更大,这样做可以进一步提高程序的性能,但是也能增加程序的规模,也可能使标准的调试工具更难对程序调试。
我们会发现可以写出的c代码,即使用-o1选项编译得到的性能,也比用可能的最高优化等级编译一个原始的版本得到的性能好。
简单的使用命令行选项-O1,就会进行一些基本的优化。
边界检查降低了程序出错的机会,但是它也会减缓程序的执行。
对于会改变在哪里调用函数或调用多少次的变换,编译器通常会非常小心。他们不能可靠地发现一个函数是否会有副作用,因而假设函数会有副作用。
消除循环的低效率。
优化类型,代码移动。
这类优化包括识别要多次执行(例如在循环里)但是计算结果不会改变的计算。因而可以将计算代码移动到代码前面不会被多次求值的部分。(strlen)减少过程调用
- 消除不必要的内存引用
把结果累积在临时变量中。临时变量可能会保存在寄存器中
在实际的处理器中,是同时对多条指令求值的,这个现象称为指令级并行。
第六章
在程序中利用局部性
第七章
目标文件有三种格式:
- 可重定位目标文件
- 可执行目标文件
共享目标文件
每个可重定位目标文件在.symtab中都有用一张符号表(不需要-g编译选项,除非strip掉才会没有符号表)
.debug -g选项编译时才会有这张表
.line -g选项编译时会有有行号和.text节中机器指令之间的映射。
.symtab中的符号表不包含对应于本地费静态程序变量的任何符号
函数和已经初始化的全局变量是强符号,未初始化的全局变量是弱符号。
Linux链接器使用下面的规则来处理多重定义的符号名:
规则1;不允许有多个同名的强符号。
规则2:如果一个强符号和多个弱符号同名,那么选择强符号
规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
静态库,在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。生成静态库 ar rcs lib.a .o
gcc -static参数告诉编译器驱动程序,链接器应该构造一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无需更进一步的链接。在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件,但是,如果一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接就会失败。
关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的,那么这些库就可以任务顺序放置在命令行的结尾处。
3.3 重定位
重定位由两步组成,重定位节和符号定位,重定位节中的符号引用
第八章异常控制流
异常的类别
- 中断 来自io设备的信号
- 陷阱 有意的异常
- 故障 潜在可恢复的错误
终止 不可恢复的错误
每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量。
所有到linux系统调用的参数都是通过通用寄存器而不是栈传递的。
进程会因为三种原因终止
- 收到一个信号,该信号的默认行为是终止
- 从主程序返回
- 调用exit函数
第九章 虚拟内存
bss内存位置总是被加载器初始化为0,但堆中的数据不是这样的,需要自己将其初始化
虚拟页面包括未分配的,缓存的,未缓存的
- DRAM缓存不命中称为缺页
- 如果工作集的大小超出了物理内存的大小,那么将会产生抖动
- 虚拟内存大大简化了内存管理,并提供了一种自然的保护内存的方法
- vm简化了链接,加载,和共享
- 分配的目标,1最大化吞吐率,最大化内存利用率
- 内部碎片:已分配块大小和它们的有效载荷大小之差的和。内部碎片的数量只取决于以前请求的模式和分配器的实现方式
- 外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲快足够大来处理这个请求时发生的。
第十章 系统级IO
无缓冲的输入输出函数。对于从网络读写二进制数据尤其有用
带缓冲的输入函数,从文件中读取文本行和二进制数据。
- 首次适配,从头开始搜索,找到第一个合适的空闲块
第十二章 并发编程
1.基于进程的并发编程
2.基于I/O多路复用的并发编程
3.基于线程的并发编程
终止线程
1.顶层线程例程返回时,线程会隐式的终止
2.调用pthread_exit函数,线程会显式的终止
3.某个对等线程调用Linux的exit函数
4.pthread_cancel
- 一个分离线程是不能被其他线程回收或插死的。它的内存在它终止时由系统自动释放。