深入理解计算机系统第四版_《深入理解计算机系统》读后感

本书探讨了编译器的工作原理,从源文件转换到可执行文件的全过程,包括预处理、编译、汇编和链接。文章详细阐述了程序性能优化的策略,如switch与ifelse的效率对比、循环优化、指针与数组访问的效率,并介绍了如何避免缓冲区溢出等安全漏洞。同时,介绍了CPU、内存、汇编语言、条件跳转和条件赋值指令等概念,以及优化循环和函数调用的方法。文章强调了了解编译系统对于程序性能提升和错误排查的重要性。
摘要由CSDN通过智能技术生成

这一本书可谓是远负盛名,在看这本书之前,我就已经见过无数个关于这本书的赞誉,这加深了我对它的兴趣。

于是我从第一章开始看这本书。

第一章从世界上绝大多数人开始学习编程的第一个程序hellokitty开始,介绍了hellokitty.c是以字节序列的方式存储在文件中的,像它这样只由ASCII码构成的叫文本文件,其他的称为二进制文件。而同一串数据,它可能表示的是一个整数,一个浮点数,字符串又或者是机器指令,这个是由读到它的时候的上下文决定的。

接着,介绍的是由源文件转化成目标文件的过程。hellokitty.c经过预处理器的处理,把#开头的命令都修改一遍,比如#define,这个时候就会用define的内容替换原文,又或者是#include,相应的头文件就会直接插入程序文本中。预处理后就开始编译了,编译完就可以得到一个汇编程序。汇编器将汇编程序翻译成机器语言指令,并打包成一个叫可重定位目标程序的格式,将结果保存在一个新的二进制文件中,然后将该文件与该文件调用的printf函数所对应的已预编译的目标文件进行合并,而链接器就是处理这种合并的。最后得到一个可执行的目标文件,可以被加载到内存中,由系统执行。

接下来它告诉我们的是,了解编译系统如何工作是很有益处的,比如优化程序性能,理解链接时出现的错误,避免安全漏洞。

而其中作者问了我们几个关于优化程序性能的问题:

1,switch是否总是比一系列的ifelse更高效?

2,While循环是否比for循环更有效?

3,指针引用是否比数组索引更有效?

4,为什么将循环变量的结果放到一个本地变量中会比将其放在一个通过引用传递过来的参数运行起来会快很多?

刚看第一章的时候,对于这几个问题,我想知道答案,但却又无从知道答案,虽然看的时候的确有些憋屈,但不得不说作者的这几个问题的确加深了我的兴趣。现在看完了第三章和第五章,我感觉我对于这几个问题也有了一定的理解了。

对于问题1,switch在某些情况下采用的是一种跳转表的高效的数据结构,不管有多少个标号,它进行跳转的时间都是一样的,所以采用跳转表时运行速度在渐进上会比一系列的ifelse快太多太多了。但对于标号的值跨度非常大,且比较稀疏,数量比较少的时候,我们就应该好好考虑要不要用跳转表了。由于跳转表实际上是一个数组,如果标号的值跨度大且比较稀疏,数量比较少的话,这个时候跳转表就会有大量冗余的分支,也是一种浪费吧。

对于问题2,在看这本书之前,我就觉得不管是while,do while还是for,他们的实现方式应该是相似或者一样的才对。看完之后明白其实他们的实现方式大致上是相同的,都是通过条件跳转来实现的,而其中dowhile和while,for它们两个的结构有点不同。所以while循环并不会比for循环更高效。

对于问题3,不管是指针引用,还是数组索引,都是通过间接访问内存来实现的,所以它们是一样的。

对于问题4,将循环变量的结果放到一个本地变量中会比将其放在一个通过引用传递过来的参数运行起来会快很多,这个的确是一个令人困惑的问题,我在看第一章的时候也是百思不得其解,这两者有什么区别吗?当我看到了优化程序性能的部分,我才明白,重复地读取与写入内存会造成极大的资源浪费,这也是优化程序的一个方向。

对于避免安全漏洞这方面,缓冲区溢出错误是一个经常能够出现的问题。如何去避免这一种错误也的确是我们需要了解的内容。比如我们平常用到的gets函数,由于它不检查字符串长度,读入过长的字符串的时候就会导致缓冲区溢出,便会导致程序运行错误。而更有甚者,不怀好意的人会利用缓冲区溢出漏洞进行缓冲区溢出攻击,让程序运行他精心设计好的一段代码,从而威胁到我们的安全,可以通过3点来最小化攻击:

1,栈随机化,使程序每次运行的栈地址都不一样,但攻击者可以通过在实际攻击代码前插入很长一段nop来猜地址。

2,栈破坏检测,在栈状态与局部缓冲区间插入金丝雀值来防止栈溢出攻击。

3,限制可执行代码区域,限制哪部分内存可存储可执行代码。

在告诉我们了解编译系统的好处后,作者又简单地给我们介绍了一下一些计算机硬件,比如cpu,主存,I/O设备,总线这些。当一个程序运行的时候,将数据从磁盘复制到主存,然后再从主存复制到处理器。我们都知道磁盘,主存,处理器的运行速度是一个比一个更快的,而在程序运行的过程中,在传输数据上就花费了很多的时间。为了加快运行速度,系统设计者们采用了一种更小更快的存储设备,称为高速缓存存储器(简称Cache或者高速缓存)。这些存储器组成了一个存储器层次结构,由上而下分别是寄存器,L1高速缓存,L2高速缓存,L3高速缓存,主存,本地磁盘,远程存储(分布式文件系统,web服务器)。当然了,进程,线程,虚拟内存,文件,网络这些概念也给我们介绍了一下。从另一个角度来看,网络也是一种I/O设备。

在第三章中,我们将要接触的是汇编语言。

常用的寻址格式为M[Imm + R[rb] + R[ri] * s],其中s只能为1,2,4,8。加载有效地址(load effective address),leaq命令是我们常用的计算地址的命令,比如leaq 7(%rdx, %rdx, 4), %rax这样的一个汇编指令,计算的就是rax = rdx + 4 * rdx + 7; 尽管它原本只是一个计算地址的指令,实际上它更多应用于平常的加法或者乘法计算,比如计算t = x + 4 * y + 12 * z; 可以用三条汇编指令来完成,分别是

// x in %rdi, y in %rsi, z in %rdx

leaq(%rdi, %rsi, 4), %rax
leaq(%rdx, %rdx, 2), %rdx
leaq(%rax, %rdx, 4), %rax

Leaq这条命令能够有效减少代码的长度,而且代码更为简洁。

有必要提一下的是,movzXX(其中XX代表要拓展的数据大小和拓展到的数据大小,比如movzbw, 将做了零拓展的字节传送到字),与之对应的是movsXX(其中XX代表要拓展的数据大小和拓展到的数据大小, 比如movsbw, 将做了符号拓展的字节传送到字)。其中有一条特别的指令,cltq指令,只用于将%eax符号拓展到%rax。要注意的是,并不存在一条明确的指令将4字节零拓展到8字节。但是我们可以用movl指令来实现,因为该指令会生成4字节的值并将高4字节置为0,而不管是movb还是movw,都只是改变对应的低位字节,并不会改变高位字节。

移位指令也有些特别的地方。移位量可以是一个立即数,也可以存放于单字节寄存器%cl中。移位操作对w位长的数据值进行操作时,移位量是由%cl寄存器的低m位决定的,这里2^m = w,高位会被忽略,其实就是最多只能移w -1位。左移指令和右移指令都分别有两个,SAL和SHL,SAR(Shift Arithmetic Right)和SHR。目的操作数可以是寄存器或者内存地址。

对于乘法和除法操作,也要稍微的看看其中的处理过程。乘法指令mul或者imul有两种不同的形式,第一种是一个单操作数的指令,它要求其中一个操作数必须在%rax中,高8字节存放于%rdx,低8字节存放于%rax,而第二种是一个双操作数的指令,mul A,B, 即B = B * A。乘法也有一条特别的指令,叫clto, 它将%rax符号拓展为%rdx %rax。而除法指令是单操作数指令。将寄存器%rdx与%rax的128位数作为被除数,而除数作为指令的操作数给出,商存储在%rax中,余数存储在%rdx中。对于大多数64位除法,被除数是64位值。 存储在寄存器%rax中。%rdx的位应该设置为全零(无符号算术)或%rax的符号位(带符号算术)。后一种操作可以使用指令cqto执行。这条指令不需要操作数,它隐式读取%rax中的符号位并复制它到%rdx的所有位上。而无符号除法divq指令,通常寄存器%rdx会事先设置为0。

接下来就到了根据各种条件可以控制程序的跳转执行,而不是只能顺序执行的部分了。

首先要了解以下几个条件码寄存器:

CF:进位标志(用于检查无符号数的溢出)
ZF:零标志(最近的操作结果为0)
SF:符号标志(最近的操作结果为负数)
OF:溢出标志(最近的操作导致补码溢出,正溢出或者负溢出)。

有两条不改变操作数数值,只改变条件码的指令,分别是cmp和test。cmp 跟sub的行为是一样的,而test跟and的行为是一样的。

Set系列指令可以根据条件码来设置一个低位单字节寄存器元素,或者说一个字节的内存位置,根据条件的true或false设置为1或0。

Jmp系列指令则是根据条件码的true or false来决定是否要跳转到标号的位置。通过jmp指令,我们就可以实现ifelse语句了。在一些情况中,ifelse语句也不一定要通过jmp指令来实现的,还可以通过条件赋值指令来实现。条件赋值指令也同样是一个根据条件码的true or false来决定是否要赋值的指令。它长这样的:cmovXXX(XXX为条件)。

我们都知道,当一个程序遇到jmp指令时,它会投机地选择一个它认为最有可能发生的分支来执行,以发挥最大地效率。在一些情况中,比如for循环i++,判断条件是i<n,这个时候的预测就非常有效了,它只会预测失败一次。但不幸的是,这种方法不总是奏效。在另一些情况中,判断条件并不是那么好预测,而且往往是随机的。这个时候由于预测失败而受到分支预测错误处罚的开销就非常大了。这时候条件赋值指令的优势就凸显出来了。它并不依赖于数据,无需让处理器预测测试结果,更容易让处理器保持流水线是满的,平均运行时间也就快得多。当然了,它不总是完美的。有时候我们可能会对指针是否非空进行判断,这个时候如果指针为空但仍然去访问它就是非法操作,就是我们平时间接引用空指针的错误。

此外,不仅仅是它有可能会导致非法操作,而且它也不总是会比条件控制转移(jmp)指令更有效率。如果相对应的条件不满足的话,该分支的工作就都白费了。所以必须衡量好条件赋值与分支预测错误所造成的处罚之间的性能差距,再从中决定是否要用条件赋值指令代替条件控制转移(jmp)指令。

Dowhile语句的实现实际上就是goto loop的过程,而while语句有两种实现方法,一种是go-to-middle,直接跳转到循环结尾的判断条件处,另一种是guarded-do,如果初始条件不成立就跳过循环,如果满足则与dowhile一样。当使用更高级别的优化时,GCC会采用后面那种策略。它能够判断当前满足进入循环的判断条件,直接转为dowhile,简化代码。而for循环变成while语句也非常简单,只需要在相应的地方增加赋值语句与更新循环变量的语句就可以了。

调用函数的时候,传递整型参数最多用寄存器传递6个。

顺序为:%rdi,%rsi,%rdx,%rcx,%r8,%r9。其余的需要通过栈来传递。函数中的局部变量和寄存器需要通过开辟栈空间来存储。

数组的访问是基址+下标*sizeof(数据类型)。

数据对齐也能加快对数据的访问。

在第五章优化程序性能中,作者先用了一些例子来说明编译器优化代码的能力会受到程序员写的代码的限制。这是由于:对于两个指针是否指向同一个内存地址而会导致不同结果

这种情况,编译器并不会将代码优化得更高效来避免不安全的优化。接着从一个简单的例子开始,逐步优化。

1,将strlen移出循环外。

2,减少函数调用,直接访问数组元素。

3,减少不必要的内存访问,设置临时变量存储运算结果。

4,2*1循环展开,即i++变成i+=2,减少循环带来的花费。

5,2*2循环展开,即增加一个临时变量同时进行运算,缩短关键路径长度。

6,2*1a循环展开,根据数据运算的结合性,进行结合变换,缩短关键路径长度。

7,使用向量操作。

8,循环展开程度过大并不会改善CPE(cycle per element),反而有可能降低,因为可能会导致寄存器溢出,从而使变量需要保存在栈(内存)中。

9,使用条件赋值。

10,了解加载与存储的性能,读写相关会导致程序运行速度减慢,增长关键路径长度。要尽量减少这种情况的发生,获得更高的并行性。

11,Amdahl定律告诉我们在消除一个瓶颈后,要关注程序的其他部分来获得更高的速度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值