记录一下《C++反汇编与逆向分析技术》(二)
第四章 各种表达式的求值过程
单独的算术运算虽然可以编译通过,但是并不会生成代码。因为只进行计算而没有传递结果的运算不会对程序结果有任何影响,此时编译器将其视为无效语句,与空语句等价,不会有任何编译处理
加法
VC++ 6.0有两种常用的优化方案
- O1方案,生成文件占用空间最小
- O2方案,执行效率最快
在VC++ 6.0中,Release编译选项组的默认选项为O2选项,在Debug编译选项组中,使用的是Od+ZI选项,此选项使编译器产生的一切代码都以便于调试为最根本的前提,甚至为了便于单步调试,以及源码和目标代码块的对应阅读
1 | //C++源码说明:加法运算 |
由上方代码可得:
- 在两常量相加的情况下,编译器在编译期间就计算出两常量相加后的结果,将这个结果值作为立即数参与运算,减少了程序在运行期的计算
- 当有变量参与加法运算时,会先取出内存中的数据,放入通用寄存器中,再通过加法指令来完成计算过程得到结果,最后存回到变量所占用的内存空间中
在开启O2选项后,编译出来的汇编代码会由于效率优先的编译选项而发生很大变化,编译器会将无用代码去除,并将可合并代码进行归并处理。例如,在代码清单4-1中,“nVarOne =
nVarOne+1;”这样的代码将被删除,因为在其后又重新对变量nVarOne进行了赋值操作,而在此之前没有对变量nVarOne的任何访问,所以编译器判定此句代码是可被删除的
O2选项后Release版代码如下:
1 | ; int _cdecl main(int argc, const char **argu, const char **enup) |
- 常量传播
1 | void main(){ |
变量nVar是一个在编译期间可以计算出结果的变量。因此,在程序中所有引用到nVar的地方都会直接使用常量1来代替,于是代码等价于:
1 | void main(){ |
- 常量折叠
当计算公式中出现多个常量进行计算的情况时,且编译器可以在编译期间计算出结果时,这样源码中所有的常量计算都将被计算结果代替,如下面的代码所示:
1 | void main(){ |
在这串算式被编译后会直接转化成常量-12,即”int nVar = -12;”
在给出的加法代码中,变量nVarOne和nVarTwo的初始化值是-一个常量, VC++编译器在开启02优化方案后,会尝试使用常量替换掉变量。如果在程序的逻辑中,声明的变量没有被修改过,而且上下文中不存在针对此变量的取地址和间接访问操作,那么这个变量也就等价于常量,编译器就认为可以删除掉这个变量,直接用常量代替。使用常量的好处是可以生成立即数寻址的目标代码,常量作为立即数成为指令的一-部分,从而减少了内存的访问次数
1 | int nVarOne = 0; //常量化以后: int nVarOne = 0; nVarOne 用0代替了 |
经过转化后,直接变成”printf(“nVarOne = %d \r\n”);”
将代码修改为:
1 | int main(int argc, char* argv[]) { |
这里初始化nVarOne和nVarTwo的时候用命令行参数argc,故在编译期间无法确定,程序中的变量就不会被常量替换掉,汇编代码为
1 | ; int_ cdec1 main(int argc, const char **argu, const char **envp) |
这里还是被删除了一个变量,优化过程如下:
1 | int main(int argc, char* argv[]) { |
减法
减法运算对应于汇编指令sub,计算机是通过补码将减法转变为加法形式来完成转换,有公式
设有二进制数Y,其反码记为Y(反),假定其二进制长度为8位,有:
- Y + Y(反) = 1111 1111B
- Y + Y(反) + 1 = 0(进位丢失)
根据以上两点,可推出
- Y(反) + 1 = 0 - Y<==>Y(反) + 1 = -Y <==> Y(补} = -Y
1 | //C++源码说明:减法运算 |
Release与加法相同,就不赘述了
乘法
乘法运算对应的汇编指令有有符号imul和无符号mul两种,由于乘法指令的执行周期较长,在编译过程中,编译器会先尝试将乘法转换成加法,或使用移位等周期较短的指令。当它们都不可转换时,才会使用乘法指令
乘法转换——Debug版
1 | //C++源码说明,乘法运算 |
代码使用编译选项为Od+ZI。在这种侧重调试的编译方式下,有符号数乘以常量值,且这个常量非2的幂,会直接使用有符号乘法imul指令。当常量值为2的幂时,编译器会采用执行周期短的左移运算来代替执行周期长的乘法指令,而若乘数不等于2的幂次方,则会先拆分后用imul指令进行编译
各类型的乘法转换示例——Release版
1 | //IDA直接将参数作为局部变量使用 |
除法
- 向下取整:所谓对x向下取整,就是取得往-∞方向最接近x的整数值,换而言之也就是取得不大于x的最大整数
在标准C语言的math.h中,定义了floor函数,其作用就是向下取整,也有人称之为“地板取整”
- 向上取整:所谓对x向上取整,就是取得往+∞方向最接近x的整数值,换而言之也就是取得不小于x的最小整数
在标准C语言的math.h中有定义ceil函数,其作用就是向上取整,也有人称之为“天花板取整”
- 向0取整:所谓对x向零取整,就是取得往0方向最接近x的整数值,换而言之也就是放弃小数部分
C语言是向0取整
VC++6.0对除数为整型常量的除法会进行优化:如果除数是变量,则只能使用除法指令。如果除数为常量,就有了优化的余地。根据除数值的相关特性,编译器有对应的处理方式。
各类型除法转换——Debug版
1 | //C++源码说明:除法运算 |
这里只对除数为2的幂的情况进行了优化处理:
这里运用了推导7的结论:(x+(2^n-1)) >> n
除数为2的幂
上述代码中的0040B80C的cdq是符号扩展到高位edx,如果eax的最高位(符号位)为1,那么edx的值为0xFFFFFFFF,也就是-1,否则为0。0040B80D地址处的sub eax,edx指令执行的操作是将eax减去高位edx,实际上就是被除数为负数的情况下,由于除数为正数(+2的幂),故除法的商为负数。移位运算等价于向下取整,C语言的除法是向零取整,因此需要对商为负的情况做加1调整(见推导7),减去-1等同于加1。eax不为负则减0,等于没处理。最后sar右移完成除法。这样的设计可以避免分支结构的产生
1 | //C++源码对比,变量 / 常量(常量值为2的3次方) |
这里我要记录一下我的犯蠢时刻,计算机里负数的各项计算都是通过补码进行的,所以add eax,edx这句话就不会有问题,基础太差了呜呜
0040B854的cdq是符号扩展到高位edx,在0040B855处对edx做位与运算,当被除数为负数时,edx的值为7,在0040B858处的add eax,edx就是被除数为负数时加上2^n-1,不为负数则加0,最后sar右移完成除法
除数非2的幂
Release版中还对除数不为2的幂的情况做了优化:
1 | //IDA中的参数标识,经过优化后,省去了局部变量,直接使用参数 |
由于除法指令的周期比乘法要长出很多,所以release版的除法指令会被乘法和其他指令替代,这里出现了一个大数92492493,给出数学证明
来个例子看一下
1 | .text:00401000 _main proc near ; CODE XREF: start+AF p |
看到在地址.text: 00401004处,我们看到了mov eax, 38E38E39h,此后做了乘法和移位操作,最后直接使用edx显示。在乘法指令中,由于edx存放乘积数据的高4字节,因此直接使用edx就等价于乘积右移了32位,再算上.text:0040100B sar edx, 1,那就一共移动了33位。在地址.text: 0040100D 处,eax 得到了edx 的值,然后对eax右移了1Fh位,对应10进制也就是右移了31位,然后有个很奇怪的加法。其实这里移位的目的是得到有符号数的符号位,如果结果是正数,add edx, eax就是加0,等于什么都没干;如果结果是负数,由于其后面的代码直接使用edx作为计算结果,需要对除法的商调整加1。简单证明如下:
于是可以推出C代码:
1 | printf("%d",arg/9); |
总结:
1 | mov eax,MagicNumber |
遇到上面你的指令序列的时候,基本可以判定是除法优化后的代码,除法的原型为a/o,imul是表明的有符号计算,操作数是优前的被除数a,右移的总次数确定n的值,用公式o= 2^n/c,将MagicNumber作为c代入公式求我们的除数o的近似值,四舍五入取整,就可以恢复除法原型!
再来个例子
1 | .text:00401080 _main proc near ; CODE xREF: start+AF p |
手推:
于是,可以推出代码来:
1 | printf ("nVarTwO / 7 = %d\r\n", argc / 7); |
计算得出MagicNumber后,如果其值超出4字节整数的表达范围,编译器会对其进行调整,如上个例子中的argc/7,计算MagicNumber时,编译器选择 2^35/7,其结果超出了4字节整数的表达范围,所以编译器调整MagicNumber的取值为2^35/7-2^32
公式:a/o = [((a-a * c)/2^32)/2 + a * c/2^32]/2^(n-1)
总结:
1 | mov eax,MagicNumber |
如果遇到上面的指令序列,基本可以判定出发优化后的代码,其除法原型为a/常量o,mul代表的无符号计算,用公式o = 2^(32+n)/(2^32+c)将MagicNumber作为c值代入公式求解常数除数o,即可恢复除法原型
现在再来看之前的代码
1 | ; 92492493h疑似 2^n/o |
这段代码在.text:004010B4处的加法显得很奇怪,其实这里的代码是上面介绍的除法转乘法公式的变化。在.text:004010B2处的指令是imul esi,这是个有符号数的乘法。请注意,编译器在计算MagicNumber时是作为无符号处理的,而代入除法转换乘法的代码中又是作为有符号乘数处理的。因为有符号数的最高位不是数值,而是符号位,所以,对应的有符号乘法指令是不会让最高位参与数值计算的,这会导致乘数的数学意义和MagicNumber不一致
由于符号数和无符号数的存储问题,所以要将表达式调整一下
总结
1 | mov eax, MagicNumber (大于7fffffffh) |
当遇到以上指令序列时,基本可判定是除法优化后的代码,其除法原型为a除以常量o,imul表明是有符号计算,其操作数是优化前的被除数a,接下来统计右移的总次数以确定公式中的n值,然后使用公式o=(2^n)/c,将MagicNumber作为c值代入公式求解常量除数o,即可恢复除法原型
- 除数为负的非2的幂(MagicNumber大于0x7fffffffh)
总结
1 | mov eax, MagicNumber (大于7fffffffh) |
如果遇到以上指令序列,则基本可判定是除法优化后的代码,其除法原型为a除以常量o, imul可表明是有符号计算,其操作数是优化前的被除数a,由于MagicNumber取值大于7fffffffh,而imul和sar之间未见任何调整代码,故可认定除数为负,且MagicNumber为补码形式。接下来统计右移的总次数以确定公式中的n值,然后使用公式|o|=2^n/((2^32)-c),MagicNumber作为c值代人公式求解常量除数|o|,即可恢复除法原型
- 除数为负的非2的幂(MagicNumber小于0x7fffffffh)
总结
1 | mov eax, MagicNumber (小于等于7fffffffh) |
如果遇到以上指令序列,则基本可判定是除法优化后的代码,其除法原型为a除以常量o, imul可表明是有符号计算,其操作数是优化前的被除数a,由于MagicNumber取值小于7fffffffh,而imul和sar之间未见任何调整代码,故可认定除数为负,且MagicNumber为补码形式。接下来统计右移的总次数以确定公式中的n值,然后使用公式|o|=2^n/((2^32)-c),MagicNumber作为c值代人公式求解常量除数|o|,即可恢复除法原型
- 除法优化原则
这里我直接copy l0x1c师傅的blog了,没错,👴就是懒狗
先给一些背景:
(1)比如 7 / 2 = 3 …… 1 -7 / 2 = -3 …… -1
比较重要的是,余数的绝对值小于除数的绝对值,并且余数和被除数同正负
(2)由于C语言中除法是向0取整,也就是“截断除法”
不难发现,正数除以正数时,截断除法相当于向下取整(3.5 -> 3);而负数除以正数时,截断除法相当于向上取整( -3.5 -> -3 )
(3)除以2的k次幂通常会被优化成右移k位,这里考虑除以2时
用一个signed byte表示7,是00000111,右移一位变成00000011是3,是正确的
但是,考虑-7/2,-7是11111001,右移一位后变成11111100,这是-4,因为这是向下取整的结果,所以比正确的答案 -3少了1
代码中为了统一和效率,如果是32位的数字,会先右移31位扩展符号位。原先是正数则最高位是0,那么最后会变成32个0,也就是0,;原先是负数最高位是1,最后会变成32个1,也就是-1,暂且把这个扩展符号位后形成的数记作S,
那么,我们只需要把右移一位的结果,减去这个S,就可以得到正确的截断除法的值
7/2 = 7>>1 – (0) = 3 -7/2 == -7>>1 – (-1) = -3
( 这一点在例题代码中会再次提到 )
(4)当除以正数N,而N不是2的次幂时,编译器会生成一个magic_number(C),以使除法优化成乘法,提高效率
贴一道例题分析
![https://ctftime.org/task/5294?tdsourcetag=s_pcqq_aiomsg]
- 取模运算
1 | mov reg,被除数 |
当遇到以上指令序列时,基本可判定是取模代码,其取模原型为被除数(变量)对2^k(常量)执行取模运算,jns 可表明是有符号计算,考察“and reg,8000001F”这类去掉高位保留低位的代码,统计出一共保留了多少低位,即可得到k的值,代入求得2*的值后,可恢复取模代码原型
对于x%(2^k),有的编译器会得到以下代码:
1 | ;先求x/2^5的商 |
算术结果溢出
进位
无符号数超出存储范围叫做进位。因为没有符号位,不会破坏数据,而多出的1位数据会被进位标志位CF保存,数据产生了进位,只是进位后的1位数据1不在自身的存储空间中,而在标志位CF中。可通过查看进位标志位CF,检查数据是否进位溢出
有符号数超出存储范围叫做溢出,由于数据进位,从而破坏了有符号数的最高位——符号位。只有有符号数才有符号位,所以溢出只针对有符号数。可查看益出标志位OF,检查数据是否溢出。OF的判定规则很简单,如果参与加法计算的数值符号一致,而计算结果符号不同,则判定OF成立,其他都不成立
自增和自减
VC++ 6.0使用“++”、“–” 来实现自增和自减操作。自增和自减有两种定义,一种为自增自减运算符在语句块之后,则先执行语句块,再执行自增自减;另一种恰恰相反,自增自减运算符在语句块之前,则先执行自增和自减,再执行语句块。通常,自增和自减是被拆分成两条汇编指令语句执行的
代码如下
1 | //自增自减代码 |
关系运算和逻辑运算
- 与运算(&&)
- 或运算(||)
- 非运算(!)
各种关系对应的条件跳转指令如表
指令助记符 | 检查标记位 | 说明 |
---|---|---|
JZ | ZF == 1 | 等于0则跳转 |
JE | ZF == 1 | 相等则跳转 |
JNZ | ZF == 0 | 不等于0则跳转 |
JNE | ZF == 0 | 不相等则跳转 |
JS | SF == 1 | 符号为负则跳转 |
JNS | SF == 0 | 符号为正则跳转 |
JP/JPE | PF == 1 | “1”的个数为偶数则跳转 |
JNP/JPO | PF == 0 | “1”的个数为奇数则跳转 |
JO | OF == 1 | 溢出则跳转 |
JNO | OF == 0 | 无溢出则跳转 |
JC | CF == 1 | 进位则跳转 |
JB | CF == 1 | 小于则跳转 |
JNAE | CF == 1 | 不大于等于则跳转 |
JNC | CF == 0 | 无进位则跳转 |
JNB | CF == 0 | 不小于则跳转 |
JAE | CF == 0 | 大于则跳转 |
JBE | CF == 1或ZF == 1 | 小于等于则跳转 |
JNA | CF == 1或ZF == 1 | 不大于则跳转 |
JNBE | CF == 0或ZF == 0 | 不小于等于则跳转 |
JA | CF == 0或ZF == 0 | 大于则跳转 |
JL | SF != OF | 小于则跳转 |
JNGE | SF != OF | 不大于等于则跳转 |
JNL | SF == OF | 不小于则跳转 |
JGE | SF == OF | 不大于等于则跳转 |
JLE | ZF != OF或ZF == 1 | 小于等于则跳转 |
JNG | ZF != OF或ZF == 1 | 不大于则跳转 |
JNLE | SF == OF且ZF == 0 | 不小于等于则跳转 |
JG | SF == OF且ZF == 0 | 大于则跳转 |
表达式短路
表达式短路通过逻辑与运算和逻辑或运算使语句根据条件在执行时发生中断,从而不予执行后面的语句。是根据逻辑与和逻辑或运算的特性,如果是与运算,当运算符左边的语句块为假值时,则直接返回假值,不执行右边的语句;如果是或运算,当运算符左边的语句块为真值时,直接返回真值,不执行右边的语句块
汇编代码如下
1 | //C++源码说明:递归函数,用于计算整数累计,nNumber为累加值 |
这个递归函数的出口是逻辑式“&&”的左侧判断。逻辑运算“||”虽然与逻辑运算“&&”有些不同,但它们的构成原理相同,只需稍作修改就可以解决上方代码的问题,代码如下
1 | //c++源码说明,和上面的类似 |
条件表达式
条件表达式也称为三目运算:
表达式1 ? 表达式2 : 表达式3
这个意思是如果表达式1是真值,则执行表达式2,如果是假值,则执行表达式3
表达式1,2,3都可以套用到条件表达式中。条件表达式被套用后,其执行顺序依然是由左向右,自内向外。当表达式2与表达3都为常量时,条件表达式可以被优化:而当表达式2或表达式3中的一个为变量时,条件表达式不可以被优化,会转换成分支结构。当表达式1为-一个常量值时,编译器会在编译期间得到答案,将不会有条件表达式存在,共有四套转换方案
- 表达式1为简单比较,而表达式2和表达式3两者的差值等于1
- 表达式1为简单比较,而表达式2和表达式3两者的差值大于1
- 表达式1为复杂比较,而表达式2和表达式3两者的差值大于1
- 表达式2和表达式3有一个为变量,于是无优化
方案一具体代码
1 | //c++源码说明,条件表达式 |
方案二具体代码
1 | //c++源码说明,条件表达式 |
对于argc == 5这种等职的比较,VC++会使用减法和求补运算来判断是否为真值,如果我们的argc不为5,那么执行sub指令后的eax就不为0,neg的指令会将eax的符号位发生改变,也就是求补的运算,如果eax为0,也就是argc为5的话,那么0进行补+1,也是0,那么CF=0,我们现在假设CF=1的情况,那么执行sbb eax,eax 等同于了 eax-eax-CF 那么eax会变成0xFFFFFFFF,另一个情况就是0,使用eax与6进行与运算,如果eax数值为0xFFFFFFFF那么 想与就是6,相加4就是10,如果是0的情况那么直接就是0+4 = 4
总结
1 | 总结: |
如果表达式2大于表达式3,那么最后加的数字为一个负数。这是由表达式3减去表达式2得到的数值
方案三具体代码
1 | //C++源码说明,条件表达式 |
总结:
1 | 先调整reg为0或者为-1 |
遇到这样的代码块,需要重点考察and前的指令,以辨别真假逻辑的处理方式。对于上例中dec reg这样的指令,之前reg只能是0或者是1,因此这里的dec其实是对reg进行修正,如果原来reg为1, dec后修正为0,否则为ffffff,便于其后的and运算。这时候要根据and前的指令流程分析原来的判定在什么情况下会导致reg为0offfffff或者0,以便于还原。编译器这样做是为了避免产生分支语句。而对于顺序结构,处理器会预读下一条指令,以提高运行效率
无优化使用分支结果
如果表达式2或者表达式3中的值为未知数时候,就无法使用之前的方案去优化,编译器会那招正常的语句流程进行比较和判断,选择对应的表达式
1 | //C++源码说明:条件表达式 |
位运算
<<:左移运算,最高位左移到CF中,最低位补零
>>:右移运算,最高位不变,最低位右移到CF中
|:位或运算,在两个数的相同位上,只要有一个为1,则结果为1
&:位与运算,在两个数的相同位上,只有同时个1,则结果为1
^:异或运算,在两个数的相同位上,当两个值相同时为0,不同时为1
~:取反运算
位运算——Debug版
1 | //C++源码对应汇编,位运算(有符号数) |
无符号数位移
1 | //C++源码说明:无符号数位移 |
符号数与无符号数的差别在右移运算,有符号数对应的指令为sar,可以保留符号位;无符号数不需要符号位,所以直接使用shr将最高位补0
编译器使用的优化技巧
代码优化:是指为了达到某一种优化目的对程序代码进行变换。这样的变换有一个原则:变化前和变换后等价
就优化目的而论,代码优化一般有四个方向:
- 执行速度优化
- 内存存储空间优化
- 磁盘存储空间优化
- 编译时间优化
编译器的工作过程中可以分为几个阶段:预处理→词法分析→语法分析→语义分析→中间代码生成→目标代码生成。其中,优化的机会一般存在于中间代码生成和目标代码生成这两个阶段。尤其是在中间代码生成阶段所做的优化,这类优化不具备设备相关性,在不同的硬件环境中都能通用,因此编译器设计者广泛采用这类办法
常量折叠
x = 1 + 2
1和2都是常量,所以结果可以预见,直接生成x = 3常量传播
接上面的代码,其后代码为y = x + 3;由于上例最后生成了x = 3,其结果还是可以预见的,所以直接生成y = 6减少变量
假设一个 x = i * 2 y = j * 2 if(x>y){}这里的x和y比较等价于i和j的比较,所以如果后面没有引用x,y,那么就会直接去掉x,y,生成 if(i>j)公共表达式
那么假设 x = i * 2,y = i * 2所以 i * 2 叫做公共表达式,可以归并为一个,x = i * 2,y = x复写传播
类似于常量传播,但是目标变成了变量,示例如下:1
2
3x = a;
……
y = x + c;
如果省略号表示的代码中没有修改变量x,则可以直接用变量a代替x:
y = a + c
剪去不可达分支
如果if作用域内的代码内容永远不会被执行,因此整个if代码块没有存在的理由还有顺序语句代替分支,强度削弱,数学变换,代码外提等等
流水线优化
- 取指令:CPU从高速缓存或内存中取机器码
- 指令译码:分析指令的长度,功能和寻址方式
- 按寻址方式确定操作数:指令的操作数可以是寄存器,内存单元或者立即数,如果操作数在内存单元里,这一步就要计算出有效地址
- 取操作数:按操作数存放的位置获得数值,并存放在临时寄存器中
- 执行指令:由控制单元或者计算单元执行指令规则的操作
- 存放计算结果
来个例子~
1 | 执行指令: add eax,dword ptr ds:[ebx+40DA44] |
Intel处理器位小端序排列,数据的高位对应内存的高地址,低位对应内存的低地址
步骤:取指令,得到第一个十六进制字节:0x03,并且eip+1,译码知道这个指令是加法,但是信息不够,于是乎将0x03放入处理器的指令队列缓存中,取指令得到第二个十六进制字节:0x83,机器码放入处理器的指令队列缓存中,eip+1,译码后知道这个寄存器相对寻址方法的加法,而且参与寻址的寄存器是ebx,存放的目标是eax,后面还有4字节的偏移,指令长度确定后,机器码放入处理器的指令队列,取地址,得到第三个十六进制字节:0x44,这是指令中包含的4字节地址偏移量信息的第一个字节,放入内部暂存区,ebx保存在ALU,准备计算有效的地址,eip+1,之后依次开始取指令0xDA 0x40 0x00 放入寄存器,eip依次+1,这时候eax的值传给ALU,调度MMU,得到内存单元,传送到ALU,计算结果,最后将计算结果存回eax中
流水线
来个例子~
1 | 004010AA mov eax,92492493h |
执行这段代码,不具备流水线的处理器先读取004010AA处的二进制指令,然后开始译码等操作,这–系列工作的每–步都是需要时间的,比如取指令,内存管理单元开始工作,其他部件闲置等待,等拿到了指令才进行下一步工作。于是,为了提高效率,Intel 公司从486开始就引入了流水线的机制
引入流水线之后,在第一条流水线执行mov指令的过程中,第二条流水线就可以开始对add执行读取和译码了,Intel采用长流水线设计,把每条指令划分出很多阶段,使得每个步骤的工作内容简单,但这容易取指令错误的时候回滚操作过多,AMD厂商使用多流水线设计,回滚错误少,但电路设计复杂
注意点
指令相关性
对于顺序安排的两条指令,后一条指令的执行依赖前–条指令的硬件资源,这样的情况就是指令相关,如下面的代码所示:
add edx,esi
sar edx,2
由于以上两条代码都需要访问并设置edx,因此只能在执行完add edx,esi后才能执行sar edx, 2。这样的情况会存在寄存器的争用,影响并行效率,应尽量避免地址相关性
对于顺序安排的两条指令,前一条指令需要访问并回写到某一地址上,而后一条指令也需要访问这一-地址,这样的情况就是地址相关,如下面的代码所示:
add [00401234], esi
mov eax, [00401234]
由于第一条指令访问的是0x401234地址,那么只能第一条指令操作完后再去执行第二条语句,会影响效率,VC++的O2的release选项生成的代码会考虑流水线执行的工作方式
代码如下:
1 | 0040101F push eax |
恢复栈顶的指令add esp,8,中间有mov eax,92492493h指令,这里为流水线优化,因为后面的imul esi需要设置eax,把计算结果的低位放在eax中,那么中间换成add esp,8防止了寄存器争用,后面的这里的 add edx,esi 与 sar edx,2不能移动是因为,在后面的mov eax,edx 与 edx有关系,如果位置移动的话,我们会出现edx的一个的计算结果不正确,于是乎不能改变顺序
分支优化
配合流水线的工作模式,处理器增加了一个分支目标缓冲器,在流水线的工作模式下,如果遇见分支结构,我们就可以利用分支目标缓冲器预测并且读取指令的目标地址,分支目标缓冲器在程序运行的时候将动态记录和调整转移指令的目标地址,可以记录多个地址,进行表格化的管理,当发生转移的时候秒如果分支目标缓冲器中有记录,下一条指令在取指令阶段就会将其作为目标地址,如果我们记录地址不等于实际目标地址,就会被流水线冲刷,用一个分支,多次与邪恶失败,就会更新记录目标地址,所以我们在编写多重循环的时候,要把大循环放在内层,可以增加分支预测的准确度
1 | for(int i=0; i < 10; i++){ |
1 | for(int i=0; i < 10000; i++){ |
高速缓存优化规则
数据对齐
cache不会保存VA的二进制低位,对于Intel的32位处理器来说,如果访问的地址是4的倍数,则可以直接查询并提取之;如果不是4的倍数,则需要访问多次。因此,VC++编译器在设置变量地址时会按照4字节边界对齐数据集中
将访问次数多的数据或代码尽量安排在-起,一方面是cache在抓取命中数据时会抓取周围的其他数据;另- -方面是虚拟内存分页的问题,如果数据分散,保留到多个分页中,就会导致过多的虚拟地址转换,甚至会导致缺页中断频繁发生,这些都会影响效率减少体积
命中率高的代码段应该减少体积,尽量放入cache中,以提高效率
第四章记录完毕~