13.1 x86

13.1.1 无优化的 MSVC

让我们编译一下:

  1. _eos$ = -4 ; size = 4
  2. _str$ = 8 ; size = 4
  3. _strlen PROC
  4. push ebp
  5. mov ebp, esp
  6. push ecx
  7. mov eax, DWORD PTR _str$[ebp] ; place pointer to string from str
  8. mov DWORD PTR _eos$[ebp], eax ; place it to local varuable eos
  9. $LN2@strlen_:
  10. mov ecx, DWORD PTR _eos$[ebp] ; ECX=eos
  11. ; take 8-bit byte from address in ECX and place it as 32-bit value to EDX with sign extension
  12. movsx edx, BYTE PTR [ecx]
  13. mov eax, DWORD PTR _eos$[ebp] ; EAX=eos
  14. add eax, 1 ; increment EAX
  15. mov DWORD PTR _eos$[ebp], eax ; place EAX back to eos
  16. test edx, edx ; EDX is zero?
  17. je SHORT $LN1@strlen_ ; yes, then finish loop
  18. jmp SHORT $LN2@strlen_ ; continue loop
  19. $LN1@strlen_:
  20. ; here we calculate the difference between two pointers
  21. mov eax, DWORD PTR _eos$[ebp]
  22. sub eax, DWORD PTR _str$[ebp]
  23. sub eax, 1 ; subtract 1 and return result
  24. mov esp, ebp
  25. pop ebp
  26. ret 0
  27. _strlen_ ENDP

我们看到了两个新的指令:MOVSX(见13.1.1节)和TEST。

关于第一个:MOVSX用来从内存中取出字节然后把它放到一个32位寄存器中。MOVSX意味着MOV with Sign-Extent(带符号扩展的MOV操作)。MOVSX操作下,如果复制源是负数,从第8到第31的位将被设为1,否则将被设为0。

现在解释一下为什么要这么做。

C/C++标准将char(译注:1字节)类型定义为有符号的。如果我们有2个值,一个是char,另一个是int(int也是有符号的),而且它的初值是-2(被编码为0xFE),我们将这个值拷贝到int(译注:一般是4字节)中时,int的值将是0x000000FE,这时,int的值将是254而不是-2。因为在有符号数中,-2被编码为0xFFFFFFFE。 所以,如果我们需要将0xFE从char类型转换为int类型,那么,我们就需要识别它的符号并扩展它。这就是MOVSX所做的事情。

请参见章节“有符号数表示方法”。(35章)

我不太确定编译器是否需要将char变量存储在EDX中,它可以使用其中8位(我的意思是DL部分)。显然,编译器的寄存器分配器就是这么工作的。

然后我们可以看到TEST EDX, EDX。关于TEST指令,你可以阅读一下位这一节(17章)。但是现在我想说的是,这个TEST指令只是检查EDX的值是否等于0。

13.1.2 无优化的 GCC

让我们在GCC 4.4.1下测试:

  1. public strlen
  2. strlen proc near
  3. eos = dword ptr -4
  4. arg_0 = dword ptr 8
  5. push ebp
  6. mov ebp, esp
  7. sub esp, 10h
  8. mov eax, [ebp+arg_0]
  9. mov [ebp+eos], eax
  10. loc_80483F0:
  11. mov eax, [ebp+eos]
  12. movzx eax, byte ptr [eax]
  13. test al, al
  14. setnz al
  15. add [ebp+eos], 1
  16. test al, al
  17. jnz short loc_80483F0
  18. mov edx, [ebp+eos]
  19. mov eax, [ebp+arg_0]
  20. mov ecx, edx
  21. sub ecx, eax
  22. mov eax, ecx
  23. sub eax, 1
  24. leave
  25. retn
  26. strlen endp

可以看到它的结果和MSVC几乎相同,但是这儿我们可以看到它用MOVZX代替了MOVSX。 MOVZX代表着MOV with Zero-Extend(0位扩展MOV)。这个指令将8位或者16位的值拷贝到32位寄存器,然后将剩余位设置为0。事实上,这个指令比较方便的原因是它将两条指令组合到了一起:xor eax,eax / mov al, […]。

另一方面来说,显然这里编译器可以产生如下代码: mov al, byte ptr [eax] / test al, al,这几乎是一样的,但是,EAX高位将还是会有随机的数值存在。 但是我们想一想就知道了,这正是编译器的劣势所在——它不能产生更多能让人容易理解的代码。严格的说, 事实上编译器也并没有义务为人类产生易于理解的代码。

还有一个新指令,SETNZ。这里,如果AL包含非0, test al, al将设置ZF标记位为0。 但是SETNZ中,如果ZF == 0(NZ的意思是非零,Not Zero),AL将设置为1。用自然语言描述一下,如果AL非0,我们就跳转到loc_80483F0。编译器生成了少量的冗余代码,不过不要忘了我们已经把优化给关了。

13.1.3 优化后的 MSVC

让我们在MSVC 2012下编译,打开优化选项/Ox:

清单13.1: MSVC 2010 /Ox /Ob0

  1. _str$ = 8 ; size = 4
  2. _strlen PROC
  3. mov edx, DWORD PTR _str$[esp-4] ; EDX -> 指向字符的指针
  4. mov eax, edx ; 移动到 EAX
  5. $LL2@strlen:
  6. mov cl, BYTE PTR [eax] ; CL = *EAX
  7. inc eax ; EAX++
  8. test cl, cl ; CL==0?
  9. jne SHORT $LL2@strlen ; 否,继续循环
  10. sub eax, edx ; 计算指针差异
  11. dec eax ; 递减 EAX
  12. ret 0
  13. _strlen ENDP

现在看起来就更简单点了。但是没有必要去说编译器能在这么小的函数里面,如此有效率的使用如此少的本地变量,特殊情况而已。

INC / DEC是递增 / 递减指令,或者换句话说,给变量加一或者减一。

13.1.4 优化后的 MSVC + OllyDbg

我们可以在OllyDbg中试试这个(优化过的)例子。这儿有一个简单的最初的初始化:图13.1。 我们可以看到OllyDbg

找到了一个循环,然后为了方便观看,OllyDbg把它们环绕在一个方格区域中了。在EAX上右键点击,我们可以选择“Follow in Dump”,然后内存窗口的位置将会跳转到对应位置。我们可以在内存中看到这里有一个“hello!”的字符串。 在它之后至少有一个0字节,然后就是随机的数据。 如果OllyDbg发现了一个寄存器是一个指向字符串的指针,那么它会显示这个字符串。

让我们按下F8(步过)多次,我们可以看到当前地址的游标将在循环体中回到开始的地方:图13.2。我们可以看到EAX现在包含有字符串的第二个字符。

我们继续按F8,然后执行完整个循环:图13.3。我们可以看到EAX现在包含空字符()的地址,也就是字符串的末尾。同时,EDX并没有改变,所以它还是指向字符串的最开始的地方。现在它就可以计算这两个寄存器的差值了。

然后SUB指令会被执行:图13.4。 差值保存在EAX中,为7。 但是,字符串“hello!”的长度是6,这儿7是因为包含了末尾的。但是strlen()函数必须返回非0部分字符串的长度,所以在最后还是要给EAX减去1,然后将它作为返回值返回,退出函数。

13.1 x86 - 图1

图13.1: 第一次循环迭代起始位置

13.1 x86 - 图2

图13.2:第二次循环迭代开始位置

13.1 x86 - 图3

图13.3: 现在要计算二者的差了

13.1 x86 - 图4

图13.4: EAX需要减一

13.1.5 优化过的GCC

让我们打开GCC 4.4.1的编译优化选项(-O3):

  1. public strlen
  2. strlen proc near
  3. arg_0 = dword ptr 8
  4. push ebp
  5. mov ebp, esp
  6. mov ecx, [ebp+arg_0]
  7. mov eax, ecx
  8. loc_8048418:
  9. movzx edx, byte ptr [eax]
  10. add eax, 1
  11. test dl, dl
  12. jnz short loc_8048418
  13. not ecx
  14. add eax, ecx
  15. pop ebp
  16. retn
  17. strlen endp

这儿GCC和MSVC的表现方式几乎一样,除了MOVZX的表达方式。

但是,这里的MOVZX可能被替换为mov dl, byte ptr [eax]。

可能是因为对GCC编译器来说,生成此种代码会让它更容易记住整个寄存器已经分配给char变量了,然后因此它就可以确认高位在任何时候都不会有任何干扰数据的存在了。

之后,我们可以看到新的操作符NOT。这个操作符把操作数的所有位全部取反。可以说,它和XOR ECX, 0fffffffh效果是一样的。NOT和接下来的ADD指令计算差值然后将结果减一。在最开始的ECX出存储了str的指针,翻转之后会将它的值减一。

请参考“有符号数的表达方式”。(第35章)

换句话说,在函数最后,也就是循环体后面其实是做了这样一个操作:

  1. ecx=str;
  2. eax=eos;
  3. ecx=(-ecx)-1;
  4. eax=eax+ecx
  5. return eax

这样做其实几乎相等于:

  1. ecx=str;
  2. eax=eos;
  3. eax=eax-ecx;
  4. eax=eax-1;
  5. return eax

为什么GCC会认为它更棒呢?我不能确定,但是我确定上下两种方式都应该有相同的效率。