首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

本附录讲解了在汇编语言中实现算术运算的一些基本知识,并展示一些基本的算术指令序列以及逆向过程中它们会以什么样的形式出现。控制流、数据管理一样,算术运算也是程序必不可少的组成部分。在逆向工程中,某些算术运算指令序列是很容易解读的,但是在其他一些情况下,算术运算指令序列经过了编译器优化处理后,读起来难度就会大一些。

本附录先介绍了基本的IA-32算术标志位,然后又展示了IA-32编译器生成的IA-32汇编语言代码中各种常见的算术运算序列。

B.1  算术标志位

为了详细理解汇编语言中是如何实现算术和逻辑运算的,你必须全面地理解各个标志位以及它们的使用方法。几乎指令集中所有的算术指令都要用到标志位,因此,要真正理解汇编语言中算术指令序列的含义,你必须理解每一个标志位的含义以及算术指令是怎样使用它们的。

IA-32处理器中的标志位都被集中存放在EFLAGS存储器中,它是一个由处理器管理的32位寄存器,程序代码很少会直接访问它。EFLAGS存储器中的大多数标志位是系统标

志位,系统标志位的状态确定了处理器的当前状态。除了这些系统标志位外,还有8个状态标志位,这8个标志位代表了处理器的当前状态,状态标志位的取值与最近一次执行的算术运算的结果有关。接下来几小节中我们将介绍IA-32处理器中最重要的状态标志位。

B.1.1  溢出标志位(CF与OF)

进位标志位(CF,carry flag)和溢出标志位(OF,overflow flag)对于汇编语言中的算术指令和逻辑指令是非常重要的两个标志位。它们的功能以及它们之间的区别并不十分明显,因此我们这里简单地介绍一下。

CF和OF都是溢出指示符,这就是说它们两都可以用于通知程序算术运算的结果太大了以至于无法把它全部表示在目标操作数(destination operand)中。这两个标志位的区别与程序所处理的数据类型有关。

与大多数高级语言不同,汇编语言程序不会显式地指明当前所处理数据的类型细节。一些算术指令如ADD指令(加法)和SUB指令(减法)也不去管它们处理的操作数到底是有符号数还是无符号数,因为这对它们来说不重要—运算的二进制结果是一样的。而其他一些指令,如MUL指令(乘法)和DIV指令(除法)就有无符号数版本和有符号数版本两种——因为不同的数据类型对于乘法和除法来说会产生不同的二进制输出。

有符号数表示和无符号数表示总是与一个问题有关,这个问题就是溢出。因为有符号整数要比同样长度的无符号整数少一位(因为这个特殊的位被用来存放符号),所以有符号数和无符号数的溢出触发条件是不一样的。这就是我们需要CF和OF两个溢出标志位的原因。处理器并没有为算术指令提供独立的有符号和无符号两个版本,而是简单地通过用两个溢出标志位报告溢出就把问题合理地解决了:这两个标志位一个用于有符号操作数,一个用于无符号操作数。加法或者减法运算可以用同一个版本的指令对有符号操作数和无符号操作数进行运算,并对两个标志位进行置位——置位后的标志位留待接下来的指令处理。

举个例子,考虑下面的算术运算代码,看看它会对这两个溢出标志位产生什么样的影响:

上面的加法指令可能会产生不同的结果,具体的结果取决于是把目的操作数当作有符号数还是无符号数对待。如果用十六进制数表示的话,结果是0x8326,即十进制数的33574——假定AX被当作一个无符号操作数。如果你把AX当作一个有符号操作数,你就会看到发生了溢出,因为任何最高位为1的有符号数是负数,所以0x8326就成了-31962了。显然,因为一个16位的有符号数能表示的最大的数是32767,把4390与29184相加显然会产生溢出,于是AX中的数就变成了一个负数。因此,上面的代码对无符号数来说没有溢出,但如果你把目的操作数看作是有符号数就溢出了。所以,前面这段代码会导致OF(表示在有符号操作数中溢出)置1,而CF(表示在无符号操作数中溢出)被清零。

零标志位(ZF)

当算术运算的结果为0时,零标志位ZF将被置1;如果结果不为0,ZF则被清零。在IA-32汇编语言代码中,在好多种情况下会使用ZF标志位,但可能最常见情况就是比较两个操作数并测试它们是否相等。比如用CMP指令将一个操作数减去另一个操作数,如果减法运算的伪结果(pseudoresult,表示此结果并不写入目的操作数中)为0就将ZF标志位置1,表明两个操作数相等。如果两个操作数不相等,ZF被清零。

符号标志位(SF)

符号标志位记录结果的最高位(不管结果是有符号数还是无符号数)。对于有符号整数,SF相当于整数的符号。符号标志位SF为1表明结果是负数,为0表示结果是正数(或者是0)。

奇偶标志位(PF)

奇偶标志位(极少使用)记录算术运算结果低8位的二进制奇偶校验(binary parity)。二进制奇偶校验就是指数中置为1的位数(number of bits set)是奇数还是偶数,它与数的奇偶性(numeric parity)完全是两回事儿。PF为1表明运算结果的低8位中1的个数是偶数,而PF为零则表明结果的低8位中1的个数是奇数。

B.2  基本的整数算术运算

这一节中我们将讨论基本的算术运算,以及IA-32机器上编译器是怎样实现它们的。我要介绍的内容包括优化的加法、减法、乘法、除法以及模运算。

需要指出的是,对于任意一款正常的编译器来说,任何涉及两个常量操作数的算术运算都会在编译阶段被全部清除并代之以它们的运算结果(你在汇编语言代码中只能看到这个运算结果)。因此,下面我们讨论的算术优化只应用于运算中至少包括一个事先不知道取值的变量的情形。

B.2.1  加法和减法

整数的加法和减法通常是用ADD和SUB指令来实现的,这两条指令可以接收几种不同类型的操作数:寄存器名,立即(硬编码)数或者内存地址。这些类型的操作数具体怎么组合取决于编译器,而且通常并不能反映出任何有关源代码的具体信息,但有一种情况很明显——如果是加上、减去一个立即操作数的话,那么它通常反映到源代码中的一个硬编码的常数(当然,在某些情况下编译器会为了其他目的将常数放到寄存器中再进行加减运算,而不是按照源代码所指示的方式处理)。要指出的是,两条指令都是将运算结果存放在左操作数(left-hand operand)中。

加、减法运算都是非常简单的运算,在现代IA-32处理器中执行效率非常高,并且通常编译器是用直接的方式实现的。在早期的IA-32处理器中,LEA指令的执行效率比ADD和SUB指令高,这导致了许多编译器都优先选用LEA指令来实现快速加法和移位操作。下面是用LEA指令执行算术运算的一个例子。

需要指出的是,尽管大多数反汇编器会在操作数前加上“DWORD PTR”,但事实上LEA指令并不能区别指针和整数。LEA不执行任何真正意义上的内存访问。

自从Pentium 4处理器问世以来,形势就开始逆转了,现在大多数编译器会在生成代码的时候使用ADD指令和SUB指令。然而,尽管处理器提供了众多的ADD和SUB指令,Intel编译器还是倾向于使用LEA指令。这可能是因为LEA指令所使用的执行单元与ADD和SUB指令所使用的执行单元是分开的,这样当主要的ALUs(算术逻辑单元)都处于忙状态时LEA还是可以执行——LEA指令在运行时提高了运行的并行性。

B2.2  乘法和除法

在开始讨论乘法和除法之前,我们先来了解一些基本知识。首先,要知道在计算机中乘法和除法都是相当复杂的运算,比加法和减法要复杂得多。IA-32处理器提供了几种不同类型的乘法、除法指令,但是它们的运算速度都相对比较慢。因此,编译器通常倾向于使用其他方式来完成乘法和除法运算。

用2的整数次方乘或除一个数是最适合于计算机的运算,因为这在二进制的整数中再好表示不过了。这就像我们可以轻松地完成乘以或除以10的整数次方的运算一样——只需要移小数点或者补零就可以了。有趣的是,计算机处理乘法和除法运算和我们的处理方法基本上是一样的。总的方法是试着将除数或乘数尽可能精确地转化成易于二进制系统表示的数值。这样,你就可以执行相对比较简单的计算了,并找出将除数或乘数的其余部分用到计算中的方法。对于IA-32处理器,等效于移小数点或者补零的操作是执行二进制移位——二进制移位可以用SHL和SHR指令完成的。SHL指令将数左移,相当于乘以2的整数次方;SHR指令将数右移,相当于除以2的整数次方。移位操作完成后,编译器通常还会用加法和减法对结果做一些必要的补偿。

乘法

当你用一个变量乘以另外一个变量的时候,通常MUL/IMUL指令是你能用的最有效的工具。不过,当乘数是一个常数时,大多数编译器决不会使用MUL/IMUL指令。比如说当一个变量乘以常数3时,编译器通常是先将变量左移1位然后加上原来的值。完成这一运算可以使用SHL指令和ADD指令,也可以只使用LEA指令,如下所示:

在更加复杂的情况下,编译器会组合使用LEA和ADD指令。例如下面这段代码——实现的是乘以32的运算:

这里实际的操作是“y=x*2*2*2*2*2”,等价于“y=x*32”。这段代码是由Intel的编译器生成的,当你看到它一定会大吃一惊。首先,就代码长度而言它很长——这段代码用了1个LEA指令和4个ADD指令,这比只用一条SHL指令来实现的代码长多了。其次,令人吃惊的是,这段代码的实际执行速度比只执行一条左移5位的SHL指令还要快,尽管我们认为SHL指令已经算是执行效率非常高的指令了。其中的原因是:LEA指令和ADD指令都是执行时间短、高吞吐量的指令。实际上,执行完这段代码可能不到三个时钟周期(虽然这取决于具体的处理器和其他环境方面的因素)。比较而言,执行一条SHL指令需要4个时钟周期,这就是为什么用它实现不如用上面的代码效率高的原因。

我们来看另一段乘法代码:

上面这段代码是用GCC编译器生成的,它先用LEA指令实现ESI乘以3,然后用SAL指令(SAL指令与SHL指令相同——它们共用同一个操作码)把结果进一步乘以4。这两次运算将操作数乘以了12。然后,代码又将结果减去该操作数。实际上,这段代码是将操作数乘以11。用数学表达式可以将这段代码表示为:“y=(x+x*2)*4-x”。

除法

对于计算机来说,除法是整数算术运算中最复杂的运算。在处理器中实现的内置除法指令有DIV指令和IDIV指令,(相对其他指令来说)它们的执行速度非常慢,执行时间超过了50个时钟周期(在NetBurst处理器最新的产品上),而加法和减法指令执行时间不到一个时钟周期。对于除数是未知数的情况,编译器只能使用DIV指令(译注:当然也包括IDIV指令)。这会影响到程序的执行性能,但对于逆向工程人员来说却是个好消息,因为这使得代码可读性强、且很直观。

如果除数是常数的话,情况就变得复杂多了。编译器可以根据除数的具体数值选用一些非常有创造性的技术来实现更为高效的除法。问题出现了——这样做会导致代码的可读性变差。下面几小节中我们将讨论倒数相乘reciprocal multiplication——一种除法优化技术。

理解倒数相乘

倒数相乘的思想是利用乘法来代替实现除法运算。在IA-32处理器上,乘法指令的运算速度比除法指令要快4到6倍,因此我们会在某些情况下尽量避免使用除法指令,而代

之以乘法指令。其思路就是将被除数乘以除数的倒数。举例来说,如果要用30除以3,你可以容易地计算出3的倒数,“1÷3”,计算的结果近似为0.3333333,再用30乘以0.3333333,你就可以得到正确的结果10。

在整数算术运算中实现倒数相乘要比这个例子复杂得多,因为你能使用的数据类型只有整型。为了解决这一问题,编译器采用了一种被称为“定点运算fixed-point arithmetic”的方法。

定点算法使得我们可以不用“浮动的”可动小数点分数和实数。有了定点运算,我们表示小数时不再用阶码(exponent component,即小数点在浮点数据类型中的位置),而是要保持小数点的位置不变。这和硬件浮点数机制截然不同。硬件浮点数机制是由硬件负责给整数部分和小数部分分配可用的位。有了这种机制,浮点数就可以表示很大范围的数——从极小的数(在0到1之间的实数)到极大的数(在小数点前有数十个0)。

要用整型数来近似地表示实数,我们要在整数中定义一个假想的小数点,用它来确定哪一部分表示的是该实数的整数值而哪一部分表示的是该实数的小数值(译注:即指小数点后面的部分)。实数的整数值就用划分后用于表示整数部分的“位”(分配的数量)按照普通的整型数的方式来表示。实数的小数部分表示数值的距离(即从当前的整数值,比方说1,到下一个整数值2的距离)的近似表示,尽可能精确地用可用的位数表示出来。不用说,这是一个近似的表示——很多实数值都不能精确地表示。举例来说,要表示.5,小数值应为0x80000000(假定是32位的小数值),要表示.1,小数值应为0x20000000。

再回到我们原来的问题上,为了用乘法来实现除32位被除数的除法运算,编译器会为乘上被除数乘上一个32位的倒数,相乘的结果是一个64位的数,其中低32位是余数(也是用一个小数值来表示的),高32位就是我们要的结果。

表B.1给出了编译器使用的几个32位倒数的例子。每个倒数都跟一个除数(通常是2的整数次方)一起使用(实质上就是右移操作,我们要尽量避免使用除法)。编译器将右移操作和倒数结合在一起使用,以实现更高精度的运算,因为当被除数很大时,倒数的精度就显得不够精确了。

表B.1  用倒数相乘实现除法的例子

源代码中的除数

32位倒数

倒数值(用小数表示)

与除数结合

3

0xAAAAAAAB

2/3

2

5

0xCCCCCCCD

4/5

4

6

0xAAAAAAAB

2/3

4

请注意,这里的每个倒数的最后一位(表B.1中第二列的十六进制数)都比其它位多1。这是因为小数值不能被精确表示,所以编译器为了获得准确的整数表示而对小数做了圆整处理(在给定位数内)。

当然,你要记住乘法运算也不是一个简单的运算。在IA-32处理器中,乘法指令的执行速度也相当地慢(尽管它比除法运算快一些)。因此,编译器只在除数不是2的整数次方时才使用倒数相乘。如果除数是2的整数次方的话,编译器只需要将操作数右移所需的位数即可。

解读倒数相乘

当你理解了倒数相乘的原理后,你就能够非常容易地在逆向过程中发现它们。下面是一段典型的倒数相乘的代码:

被除数(dividend)是变量能用倒数相乘实现除法吗?

当然还有可用于被除数是变量这种情况的优化除法算法,这类算法中的倒数是运行时计算出来的。不过,现代IA-32处理器为DIV和IDIV指令提供了一套相对高效的实现,因此,编译器在生成IA-32机器码时很少会使用倒数相乘来处理被除数是变量的情况,而是直接用DIV或IDIV指令。很有可能在运行时计算倒数以及倒数相乘花费的时间比直接使用除法指令所花费的时间还要多。

这段代码中用0xAAAAAAAB乘以ECX,0xAAAAAAAB也就等价于0.6666667(也即2/3)。然后,将得到的结果右移两位,这实际上就是除以4。把乘以2/3和除以4结合起来就相当于除以6。注意,乘法的结果是存放在EDX寄存器中,而不是EAX寄存器,这是因为MUL指令产生64位的结果——高32位存放在EDX中而低32位存放在EAX中。我们感兴趣的是高32位中的数,因为那才是定点表示的整数值。

下面是一个稍复杂一些的例子,其中加入了几个新的操作:

这段代码和前面的例子非常相似,只是对乘法运算的结果多做了一些处理。从数学的角度来看,上面的代码完成了以下计算:

y = ((x - x _ sr) ÷ 2 + x _ sr) ÷ 4

其中“x = dividend”且“sr = 1 ÷ 7”(比例)。

看看上面的公式,显然这是一个除数为7的除法。但是初看上去MUL指令后面的代码似乎是多余的。要实现除以7的运算,本来只需要乘以7的倒数即可。问题是倒数的精度是有限的。编译器为了减小乘法运算所产生的误差量,它将倒数向上圆整(upward-rounded)为与其最接近且可表示出来的数。对于较大的被除数,乘法的基类误差可能会导致错误的计算结果。要理解这个问题,你必须记住商将会被截去最后的数位(即向下圆整,rounded downward)。倒数是向上圆整的,当而商是被向下圆整(对一些被除数)。因此,编译器对倒数做了一次加法,然后又对商做了一次减法——为的就是消除结果中引入的误差。

B.2.3  模运算(Modulo)

从根本上说,模运算和除法是同样的运算,只是取了运算结果的另一部分。下面是一种用于对32位整数求模的非常常用且直观的方法:

这段代码将被除数Divisor除以100,并把结果放在EDX寄存器中。这是最简单的实现求模的方法,因为只要用IDIV指令除以两个值就可以得到模,IDIV指令是处理器的有符号的除法指令。IDIV指令一般会把除法的结果放在EAX寄存器中,而把余数放在EDX寄存器中,因此这段代码执行问候你可以直接从EDX中取得余数。要注意的是:因为IDIV指令的参数是一个32位的除数(EDI),所以它将使用一个64位的被除数EDX:EAX,这就是为什么代码中还用了一条CDQ指令的原因。CDQ只是将EAX中的值扩展成64位的值并存放在EDX:EAX中。有关CDQ指令的更多信息,请参考本附录后面的“类型转换”一节。

对逆向工程人员来说这种方法确实是个“好”方法,因为代码非常易读,但从运行时性来看这不是最佳的方法。IDIV是一条相当慢的指令——可以说是整个指令集中最慢的指令之一。这段代码是用Microsoft的编译器生成的。

一些编译器实际上使用倒数相乘的方法来确定模(参见本附录的“除法(Division)”小节)。

B.3  64位算术运算

为了实现诸如高精度的计时器、进行高精度的信号处理以及许多其他一些应用,最新的32位软件经常使用高于32位的整型数据类型。因为通用代码没有为了在高级处理器增强集(advanced processor enhancements,如SSE、SSE2和SSE3指令集)上运行而专门进行编译,所以编译器将两个32位的整数合并为一个64位的整数,并使用专门的指令序列来对它们执行算术运算。下面几小节中我们将讨论如何将最常用的算术运行运用到64位的数据类型上。

当处理超过32位的数据类型时(不使用高级的SIMD数据类型),编译器会用几个32位的整数来表示整个操作数。在这种情况下,使用不同的编译器会导致所采用的算术运算也不一样。那些支持长整型数据类型的编译器会使用内建的方法来处理这些数据类型。其他的编译器就只能把这些数据类型看成是包含了几个整数的数据结构来处理了,这就需要在程序或代码库中提供处理这些数据类型的算术运算相应的代码。

大多数现代编译器都提供了对64位数据类型的内建支持。64位的数一般使用内存中两个32位的整型数的空间来存放,而编译器通过使用专门的代码来完成与64位数据类型有关的运算。下面几小节中我们将讨论常用的算术函数是怎样处理这种数据类型的。

B.3.1  加法

64位整数的加法运算通常是将一个ADD指令和一个ADC指令(带进位的加法)组合起来实现的。ADC指令与标准的ADD指令非常相似,只是它还要把进位标志位(CF)的值加到结果中去。

两个64位操作数的低32位是用普通的ADD指令完成加法运算的,这部分运算会根据是否产生进位(译注:原文误为remainder)对CF标志位置1或清零。然后,两个操作数的高32位用ADC指令相加,这样前面的ADD结果也会加到结果中去。下面是个简单的例子:

需要注意的是,本例中的两个64位操作数都存放在寄存器中。因为每个寄存器都是32位的,所以每个操作数要使用两个寄存器。第一个操作数用ESI寄存器存放其低32位部分,EDI寄存器存放其高32位部分;第二个操作数用EAX存放其低32位部分,EDX存放其高32位部分。运算的结果就存放在EDX:EAX中。

B.3.2  减法

减法运算实际上和加法是一样的,只是CF标志位被作联系低32位部分和高32位部分的“借位”。这里先用SUB指令把两个操作数的低32位部分相减(因为这就是一个普通的减法),然后再用SBB指令把两个操作数的高32位部分相减,因为SBB指令在运算的时候包含了CF的值。

B.3.3  乘法

64位数相乘实在是既长又复杂的运算,以至于编译器都不便于将它嵌入在代码中。实际上,编译器是用一个称为allmul预定义函数(predefined function)来实现64位乘法运算。

该函数及其汇编语言源代码都放在Microsoft C run-time library(C运行时库,CRT)中。其源代码见列表B.1。

列表B.1  由Microsoft编译器生成的用于实现64位乘法的allmul函数的汇编语言代码

不幸的是,在大多数逆向工程情况下你都可能遇见这个函数而不知道它的名字(因为它的名字可能是程序中的一个内部符号)。你最好是简单看一下列表B.1中给出的源代码并试着了解一下这个函数的工作原理——这可能有助于你在以后的逆向工程中在碰到它的时候能够一眼认出来。

B.3.4  除法

64位整数的除法比64位整数的乘法还要复杂很多,编译器同样是用一个外部函数来实现这一功能。Microsoft的编译器用alldiv这个CRT函数实现64位除法。同样,我们把alldiv函数的全部源代码列出来了(见列表B.2),以便于你在逆向带有64位算术运算的程序的过程中识别这个函数。

列表B.2  由Microsoft编译器生成的用于实现64位除法的alldiv函数的汇编语言代码(待续)

列表B.2(续)

列表B.2(续)

列表B.2(续)

我们不再深入讨论alldiv函数的运作机制了,因为这只是一段静态的代码。在逆向过程中,你真正需要的就是正确地识别出这个函数,这就足够了。你只要理解了它完成什么功能就可以了,其内部的运作机制对你确实没有什么用。

B.4  类型转换

在阅读代码的底层表示时,数据类型通常是隐藏的。问题在于——尽管大多数高级语言和编译器都是数据类型敏感的(data-type-aware①),但这些信息不会反映在程序的二进制代码中。有一种情况,即在不同的数据类型之间相互转换时,你才能准确地确定出有关的数据类型。在程序执行数据类型转换时,根据具体的数据类型,编译器通常会使用一些典型的代码序列。下面几小节中我们将讨论两种最常见的类型转换:零扩展和符号扩展。

B.4.1  零扩展

当程序要增加一个无符号整型数的长度时,它通常会采用MOVZX指令。MOVZX指令把一个长度较小的操作数写入一个长度较大的操作数中去,同时进行零扩展。零扩展就

是将源操作数拷贝到一个长度较大的目标操作数中,并将高位字节(most significant bits)置为0(不管源操作数的值多大)。这通常意味着源操作数是无符号数。MOVZX指令支持8位到16位和32位以及16位到32位的零扩展转换。

B.4.2  符号扩展

当程序要将一个长度较小的有符号整数转换到一个长度较大的有符号整数中去的话,就会用到符号扩展。因为在计算机中有符号数是用补码表示的,所以操作数为负整数就必须将高位全部置1,为正整数则要全部清零。

扩展到32位

除了对有符号整数进行操作外,MOVSX指令与MOVZX指令等价。该指令可用于8位操作数到16位和到32位以及16位操作数到32位的转换。

扩展到64位

CDQ指令用于将一个存放在EAX寄存器的有符号操作数转换为一个存放在EDX:EAX中的扩展为64位的有符号整数。在很多情况下,出现该指令就表明EAX寄存器中存放的是一个有符号整型数,而且接下来的代码将把EDX:EAX当作一个64位的有符号整数来对待,其中EDX存放的是该整数的高32位,EAX存放的是该整数的低32位。类似地,如果在一条将EDX:EAX当作一个64位操作数进行处理的指令之前恰好EDX寄存器被清零了,你就知道EAX中应该是一个无符号整数(译注:这里应该是“正整数”才对)。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论