- 第一章 CPU 简介
- 第二章 Hello,world!
- 第三章 函数开始和结束
- 第四章 栈
- Chapter 5 printf() 与参数处理
- Chapter 6 scanf()
- CHAPER7 访问传递参数
- Chapter 8 一个或者多个字的返回值
- Chapter 9 指针
- Chapter 10 条件跳转
- 第 11 章 选择结构 switch()/case/default
- 第 12 章 循环结构
- 第 13 章 strlen()
- Chapter 14 Division by 9
- chapter 15 用 FPU 工作
- Chapter 16 数组
- Chapter 17 位域
- 第 18 章 结构体
- 19 章 联合体
- 第二十章 函数指针
- 第 21 章 在 32 位环境中的 64 位值
- 第二十二章 SIMD
- 23 章 64 位化
- 24 章 使用 x64 下的 SIMD 来处理浮点数
- 25 章 温度转换
- 26 章 C99 的限制
- 27 章 内联函数
- 第 28 章 得到不正确反汇编结果
- 第 29 章 花指令
- 第 30 章 16 位 Windows
- 第 31 章 类
- 三十二 ostream
- 34.2.2 MSVC
- 34.2.3 C++ 11 std::forward_list
- 34.3 std::vector
- 34.4 std::map and std::set
12.1 x86
在 x86 指令集中,有一些独特的 LOOP 指令,它们会检查 ECX 中的值,如果它不是 0 的话,它会逐渐递减 ECX 的值(减一),然后把控制流传递给 LOOP 操作符提供的标签处。也许,这个指令并不是多方便,所以,我没有看到任何现代编译器自动使用它。如果你看到哪里的代码用了这个结构,那它很有可能是程序员手写的汇编代码。
顺带一提,作为家庭作业,你可以试着解释以下为什么这个指令如此不方便。
C/C++循环操作是由 for()、while()、do/while() 命令发起的。
让我们从 for() 开始吧。
这个命令定义了循环初始值(为循环计数器设置初值),循环条件(比如,计数器是否大于一个阈值?),以及在每次迭代(增/减)时和循环体中做什么。
for (初始化; 条件; 每次迭代时执行的语句)
{
循环体;
}
所以,它生成的代码也将被考虑为 4 个部分。
让我们从一个简单的例子开始吧:
#!cpp
#include <stdio.h>
void f(int i)
{
printf ("f(%d)
", i);
};
int main()
{
int i;
for (i=2; i<10; i++)
f(i);
return 0;
};
反汇编结果如下(MSVC 2010):
清单 12.1: MSVC 2010
#!bash
_i$ = -4
_main PROC
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _i$[ebp], 2 ; loop initialization
jmp SHORT $LN3@main
$LN2@main:
mov eax, DWORD PTR _i$[ebp] ; here is what we do after each iteration:
add eax, 1 ; add 1 to i value
mov DWORD PTR _i$[ebp], eax
$LN3@main:
cmp DWORD PTR _i$[ebp], 10 ; this condition is checked *before* each iteration
jge SHORT $LN1@main ; if i is biggest or equals to 10, let’s finish loop
mov ecx, DWORD PTR _i$[ebp] ; loop body: call f(i)
push ecx
call _f
add esp, 4
jmp SHORT $LN2@main ; jump to loop begin
$LN1@main: ; loop end
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
看起来没什么特别的。
GCC 4.4.1 生成的代码也基本相同,只有一些微妙的区别。
清单 12.1: GCC 4.4.1
#!bash
main proc near ; DATA XREF: _start+17
var_20 = dword ptr -20h
var_4 = dword ptr -4
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 20h
mov [esp+20h+var_4], 2 ; i initializing
jmp short loc_8048476
loc_8048465:
mov eax, [esp+20h+var_4]
mov [esp+20h+var_20], eax
call f
add [esp+20h+var_4], 1 ; i increment
loc_8048476:
cmp [esp+20h+var_4], 9
jle short loc_8048465 ; if i<=9, continue loop
mov eax, 0
leave
retn
main endp
现在,让我们看看如果我们打开了优化开关会得到什么结果(/Ox):
清单 12.3: 优化后的 MSVC
#!bash
_main PROC
push esi
mov esi, 2
$LL3@main:
push esi
call _f
inc esi
add esp, 4
cmp esi, 10 ; 0000000aH
jl SHORT $LL3@main
xor eax, eax
pop esi
ret 0
_main ENDP
要说它做了什么,那就是:本应在栈上分配空间的变量 i 被移动到了寄存器 ESI 里面。因为我们这样一个小函数并没有这么多的本地变量,所以它才可以这么做。 这么做的话,一个重要的条件是函数 f()不能改变 ESI 的值。我们的编译器在这里倒是非常确定。假设编译器决定在 f()中使用 ESI 寄存器的话,ESI 的值将在函数的初始化阶段被压入栈保存,并且在函数的收尾阶段将其弹出(注:即还原现场,保证程序片段执行前后某个寄存器值不变)。这个操作有点像函数开头和结束时的 PUSH ESI/ POP ESI 操作对。
让我们试一试开启了最高优化的 GCC 4.4.1(-03 优化)。
清单 12.4: 优化后的 GCC 4.4.1
#!bash
main proc near
var_10 = dword ptr -10h
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 10h
mov [esp+10h+var_10], 2
call f
mov [esp+10h+var_10], 3
call f
mov [esp+10h+var_10], 4
call f
mov [esp+10h+var_10], 5
call f
mov [esp+10h+var_10], 6
call f
mov [esp+10h+var_10], 7
call f
mov [esp+10h+var_10], 8
call f
mov [esp+10h+var_10], 9
call f
xor eax, eax
leave
retn
main endp
GCC 直接把我们的循环给分解成顺序结构了。
循环分解(Loop unwinding)对这些没有太多迭代次数的循环结构来说是比较有利的,移除所有循环结构之后程序的效率会得到提升。但是,这样生成的代码明显会变得很大。
好的,现在我们把循环的最大值改为 100。GCC 现在生成如下:
清单 12.5: GCC
#!bash
public main
main proc near
var_20 = dword ptr -20h
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
push ebx
mov ebx, 2 ; i=2
sub esp, 1Ch
nop ; aligning label loc_80484D0 (loop body begin) by 16-byte border
loc_80484D0:
mov [esp+20h+var_20], ebx ; pass i as first argument to f()
add ebx, 1 ; i++
call f
cmp ebx, 64h ; i==100?
jnz short loc_80484D0 ; if not, continue
add esp, 1Ch
xor eax, eax ; return 0
pop ebx
mov esp, ebp
pop ebp
retn
main endp
这时,代码看起来非常像 MSVC 2010 开启/Ox 优化后生成的代码。除了这儿它用了 EBX 来存储变量 i。 GCC 也确信 f()函数中不会修改 EBX 的值,假如它要用到 EBX 的话,它也一样会在函数初始化和收尾时保存 EBX 和还原 EBX,就像这里 main()函数做的事情一样。
12.1.1 OllyDbg
让我们通过/Ox 和/Ob0 编译程序,然后放到 OllyDbg 里面查看以下结果。
看起来 OllyDbg 能够识别简单的循环,然后把它们放在一块,为了演示方便,大家可以看图 12.1。
通过跟踪代码(F8, 步过)我们可以看到 ESI 是如何递增的。这里的例子是 ESI = i = 6: 图 12.2。
9 是 i 的最后一个循环制,这也就是为什么 JL 在递增的最后不会触发,之后函数结束,如图 12.3。
图 12.1: OllyDbg main()开始
图 12.2: OllyDbg: 循环体刚刚递增了 i,现在 i=6
图 12.3: OllyDbg 中 ESI=10,循环终止
12.1.2 跟踪
像我们所见的一样,手动在调试器里面跟踪代码并不是一件方便的事情。这也就是我给自己写了一个跟踪程序的原因。
我在 IDA 中打开了编译后的例子,然后找到了 PUSH ESI 指令(作用:给 f()传递唯一的参数)的地址,对我的机器来说是 0x401026,然后我运行了跟踪器:
#!bash
tracer.exe -l:loops_2.exe bpx=loops_2.exe!0x00401026
BPX 的作用只是在对应地址上设置断点然后输出寄存器状态。
在 tracer.log 中我看到执行后的结果:
#!bash
PID=12884|New process loops_2.exe
(0) loops_2.exe!0x401026
EAX=0x00a328c8 EBX=0x00000000 ECX=0x6f0f4714 EDX=0x00000000
ESI=0x00000002 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=PF ZF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000003 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000004 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000005 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000006 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000007 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000008 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000009 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
PID=12884|Process loops_2.exe exited. ExitCode=0 (0x0)
我们可以看到 ESI 寄存器是如何从 2 变为 9 的。
甚至于跟踪器可以收集某个函数调用内所有寄存器的值,所以它被叫做跟踪器(a trace)。每个指令都会被它跟踪上,所有感兴趣的寄存器值都会被它提示出来,然后收集下来。 然后可以生成 IDA 能用的.idc-script。所以,在 IDA 中我知道了 main() 函数地址是 0x00401020,然后我执行了:
#!bash
tracer.exe -l:loops_2.exe bpf=loops_2.exe!0x00401020,trace:cc
bpf 的意思是在函数上设置断点。
结果是我得到了 loops_2.exe.idc 和 loops_2.exe_clear.idc 两个脚本。我加载 loops_2.idc 到 IDA 中,然后可以看到图 12.4 所示的内容。
我们可以看到 ESI 在循环体开始时从 2 变化为 9,但是在递增完之后,它的值从 9(译注:作者原文是 3,但是揣测是笔误,应为 9。)变为了 0xA(10)。我们也可以看到 main()函数结束时 EAX 被设置为了 0。
编译器也生成了 loops_2.exe.txt,包含有每个指令执行了多少次和寄存器值的一些信息:
清单 12.6: loops_2.exe.txt
#!bash
0x401020 (.text+0x20), e= 1 [PUSH ESI] ESI=1
0x401021 (.text+0x21), e= 1 [MOV ESI, 2]
0x401026 (.text+0x26), e= 8 [PUSH ESI] ESI=2..9
0x401027 (.text+0x27), e= 8 [CALL 8D1000h] tracing nested maximum level (1) reached,
skipping this CALL 8D1000h=0x8d1000
0x40102c (.text+0x2c), e= 8 [INC ESI] ESI=2..9
0x40102d (.text+0x2d), e= 8 [ADD ESP, 4] ESP=0x38fcbc
0x401030 (.text+0x30), e= 8 [CMP ESI, 0Ah] ESI=3..0xa
0x401033 (.text+0x33), e= 8 [JL 8D1026h] SF=false,true OF=false
0x401035 (.text+0x35), e= 1 [XOR EAX, EAX]
0x401037 (.text+0x37), e= 1 [POP ESI]
0x401038 (.text+0x38), e= 1 [RETN] EAX=0
生成的代码可以在此使用:
图 12.4: IDA 加载了.idc-script 之后的内容
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论