在程序设计语言里面,循环是三种语言流程之一(顺序,分支,循环),这其中循环又是编程这件事中最具魅力的,它发挥了人在思维和计算机在计算方面的双方优势,体现了程序员的技巧和智慧,也体现了代码的简洁,优雅和优美。循环中最常用的应该是 for 循环,其他几种例如while,do while又基本上可以等效写成 for 循环。同时 for 循环又可以被等效改写为递归函数。本文首先通过VC创建一个含有for循环的简单函数的工程,然后用IDA工具分析其汇编代码。
for循环主要由以下形式组成,例如:
for( i = 0; i < imax; i++) { ... }
可以将其看做有四个基本部分构成,即初始化语句( i = 0 ), 条件语句(i < imax), 主体后续语句 (i++), 和供循环重复执行的主体({。。。})。首先用VC创建一个Win32 Console 程序,我们输入一个含有for循环的简单函数,代码如下:
#include < string .h >
int getstring( char * s)
{
int length = strlen(s);
int i;
for (i = 0 ; i < length; i ++ )
{
// 取余(%)优先级比加减(+-)高!
s[i] = (s[i] - ' a ' + 1 ) % 26 + ' a ' ;
}
return length;
}
int main( int argc, char * argv[])
{
char s[] = " abcdefg\0 " ;
int result = getstring(s);
printf( " %s\n " , s);
return 0 ;
}
简要介绍以下上面的代码,函数 getstring 用于对输入的字符串(假设输入的字符串全部有小写字母a-z组成),把字符串中每个字符改为其下一个英文字符(例如a改为b,b改为c,...,z改为a)。因此上面的程序我们在main里面初始化一个字符串为“abcdefg”,输出“bcdefgh”。
下面我们用IDA查看其反汇编代码(win32 debug):在汇编代码中,getstring 函数和 main 函数和我们的C++代码中出现的顺序相同,可以看到:
getstring 函数从 00401020H ~ 00401095H (代码量: 118 bytes,按 16bytes 对齐后实际占据 160bytes);
main 函数从 004010C0H ~ 00401124H (代码量:101 bytes, 对齐后实际占据 128 bytes);
在函数和函数之间,用0xCC进行填充,以使函数开始地址都位于16bytes整数倍处;
我们先简要看下 main 函数,很显然,字符串 s 是在 main函数内 的栈上空间:汇编代码如下:
![](https://i-blog.csdnimg.cn/blog_migrate/8f900a89c6347c561fdf2122f13be562.gif)
![ExpandedBlockStart.gif](https://i-blog.csdnimg.cn/blog_migrate/961ddebeb323a10fe0623af514929fc1.gif)
. text: 004010C0
. text: 004010C0 var_50 = dword ptr -50h
. text: 004010C0 var_10 = dword ptr -10h
. text: 004010C0 var_C = dword ptr -0Ch
. text: 004010C0 var_8 = dword ptr - 8
. text: 004010C0 var_4 = byte ptr - 4
. text: 004010C0
. text: 004010C0 push ebp
. text: 004010C1 mov ebp, esp
; main 在栈上申请了 80 bytes 临时空间
. text: 004010C3 sub esp, 50h ; main 在栈上申请了 80 bytes 临时空间
. text: 004010C6 push ebx ; 保存寄存器当前值(esp继续减小,存储于 80bytes 相邻的低地址处)
. text: 004010C7 push esi
. text: 004010C8 push edi
; 把 80bytes 临时空间初始化成全部用 0xCC 填充
. text: 004010C9 lea edi, [ebp+var_50]
. text: 004010CC mov ecx, 14h
. text: 004010D1 mov eax, 0CCCCCCCCh
. text: 004010D6 rep stosd ; stosd:串存储
; 把.rdata中的数据放到char s[],s: ebp + var_C
; eax <- "abcd"
. text: 004010D8 mov eax, ds: ??_C@_08PIFP@abcdefg?$AA?$AA@
. text: 004010DD mov [ebp+var_C], eax
; ecx <- "efg0"
. text: 004010E0 mov ecx, ds: dword_422024
. text: 004010E6 mov [ebp+var_8], ecx
. text: 004010E9 mov dl, ds: byte_422028
. text: 004010EF mov [ebp+var_4], dl
; 调用 getstring(char* s);
. text: 004010F2 lea eax, [ebp+var_C]
. text: 004010F5 push eax
. text: 004010F6 call j_getstring
. text: 004010FB add esp, 4 //调用方复原堆栈。
. text: 004010FE mov [ebp+var_10], eax ; result = getstring(s);
; 调用printf("%s", s);
. text: 00401101 lea ecx, [ebp+var_C]
. text: 00401104 push ecx
. text: 00401105 push offset ??_C@_03HHKO@?$CFs?6?$AA@
. text: 0040110A call printf
. text: 0040110F add esp, 8 ; 复原堆栈
. text: 00401112 xor eax, eax
. text: 00401114 pop edi
. text: 00401115 pop esi
. text: 00401116 pop ebx ;复原寄存器内容(导致esp增加)
释放栈上申请的80 bytes 临时空间
. text: 00401117 add esp, 50h
; 检查栈是否被破坏?
. text: 0040111A cmp ebp, esp
. text: 0040111C call __chkesp
. text: 00401121 mov esp, ebp
. text: 00401123 pop ebp
. text: 00401124 retn
. text: 00401124 main endp
main函数并不是重点,不过我们还是可以看出一些基本的东西,比如函数是如何利用栈的。从上面的代码可以看出,进入函数的第一件事是把esp复制给ebp,这相当于对刚进入函数时刻的栈做了一个“快照”(由于esp是始终处于动态变化的,所以需要缓存进入函数时刻的栈顶地址)。此后对参数的访问都通过“快照” ebp 完成,即( ebp + 参数偏移量)去访问参数, 栈由调用方负责复原(复原 esp 的值,因为下一条指令地址和参数等信息入栈(push)时esp会减小),这属于调用约定的范畴。
然后函数一次性“申请”到足够函数内的临时变量使用的栈上空间(在main函数里是 80bytes),并把他们初始化全部用0xCC填充。此后的函数内部声明的那些临时变量就都处于在这一块栈上的空间之内。在函数返回之前,这一块空间将被函数“释放”(【备注】请注意这里的申请和释放指的是对 esp 即栈顶地址的加减操作,减对应于申请,加对应释放,请注意和堆上的内存管理并不是相同概念)。例如我们在main函数中声明的 char s[] 就位于这80bytes之内,请注意汇编代码中是如何初始化s数组的,由于我设置的是char s[] = "abcdefg\0", 字符串"abcdefg\0"的内容存储在.rdata节中,由于它正好是8个字符,所以就可以认为是相当于两个DWORD数据,所以在汇编代码里是用了两条 mov 指令完成对 s数组 的初始化的。
在函数即将返回之前,再次将当前栈的状态(esp)应该和刚进入函数时的“快照”(ebp)进行比较,查看两者是否相符,否则栈可能在函数执行期间遭到了破坏,或者调用方和函数之间没有遵循同样的调用约定。我们使用 IDA 在调试时对esp或者ebp进行修改,使两者不等,则进入 __chkesp 函数以后,我发现会弹出下图所示的一个对话框,观察对话框上输出的信息大意是,“ESP的值在函数调用期间没有被正确的存储,这通常是由于使用和函数不同的调用约定声明的函数指针去调用函数导致的。”在我的印象中 chkesp 好像是在某个VisualStdio版本之后才开始加入并成为了默认打开的选项,已使得应用程序更加安全,好像在IDE中有一个编译开关可以关闭编译器生成检查堆栈的代码,我们可以关闭这个开关使生成的应用程序更加精简,运行更加高效,但毫无疑问有可能降低其安全性。关于它的后续我们就不继续分析了。
假如用16进制编辑器把释放临时变量空间的汇编代码(add esp, **h) 全部填充为NOP指令(0x90),运行时也会弹出上面的对话框,如果我们让程序接着运行,就会弹出另一个XP中常见的对话框:...exe遇到问题需要关闭,然后我们点击查看它发送的错误报告的技术信息,就会看到下面的对话框,在这里我查看了里面的Address,是触发中断的汇编代码(int 3),然后是CPUID(通过cpuid指令得到),后面是一些模块,栈,内存信息。由于我们是在运行时有意破坏了esp校验的条件,所以这些数据发给MS公司显然也是不太可能有什么结果的。
下面我们再看以下getstring的汇编代码,这里含有一个基本的for循环:
![](https://i-blog.csdnimg.cn/blog_migrate/8f900a89c6347c561fdf2122f13be562.gif)
![ExpandedBlockStart.gif](https://i-blog.csdnimg.cn/blog_migrate/961ddebeb323a10fe0623af514929fc1.gif)
. text: 00401020
. text: 00401020 var_48 = dword ptr -48h
. text: 00401020 var_8 = dword ptr - 8
. text: 00401020 var_4 = dword ptr - 4
. text: 00401020 arg_0 = dword ptr 8
. text: 00401020
. text: 00401020 push ebp
. text: 00401021 mov ebp, esp ; ebp = esp;
. text: 00401023 sub esp, 48h ; 在栈上申请72bytes(供临时变量使用)
. text: 00401026 push ebx
. text: 00401027 push esi
. text: 00401028 push edi ; 保存寄存器数据
. text: 00401029 lea edi, [ebp+var_48]
. text: 0040102C mov ecx, 12h
. text: 00401031 mov eax, 0CCCCCCCCh
. text: 00401036 rep stosd
; 调用strlen(s);
. text: 00401038 mov eax, [ebp+arg_0] ; 取出参数s的地址(在ebp下面)
. text: 0040103B push eax ; 参数入栈
. text: 0040103C call strlen
. text: 00401041 add esp, 4 ; 调用方复原 栈指针
. text: 00401044 mov [ebp+var_4], eax ; length = strlen(s);
; 从这里开始进入for循环,下面是初始化部分(i = 0)
. text: 00401047 mov [ebp+var_8], 0 ; i = 0;
. text: 0040104E jmp short loc_401059 ; 跳转到条件判断部分
; 这里是后续语句(i++;)
. text: 00401050
. text: 00401050 loc_401050: ; CODE XREF: getstring+60j
. text: 00401050 mov ecx, [ebp+var_8]
. text: 00401053 add ecx, 1
. text: 00401056 mov [ebp+var_8], ecx ; i = i + 1;
; 这里是条件判断语句 (i < length? )
; jge: 在 >= 时跳转到指定地址;
. text: 00401059
. text: 00401059 loc_401059: ; CODE XREF: getstring+2Ej
. text: 00401059 mov edx, [ebp+var_8]
. text: 0040105C cmp edx, [ebp+var_4]
. text: 0040105F jge short loc_401082 ; i<length?
; 以下是循环主题部分 {... }
. text: 00401061 mov eax, [ebp+arg_0] ; eax = s;
. text: 00401064 add eax, [ebp+var_8] ; eax = s + i;
. text: 00401067 movsx eax, byte ptr [eax] ; eax = s[i];
. text: 0040106A sub eax, 60h
; eax = s[i] - 'a' + 1; ('a' = 0x61)
. text: 0040106D cdq
; CDQ: Convert Double to Quad (386+)
; 把edx扩展为eax的高位,也就是说变为64位。
. text: 0040106E mov ecx, 1Ah ; ecx = 26;
. text: 00401073 idiv ecx
; idiv: 有符号除法, eax为结果,edx为余数;
. text: 00401075 add edx, 61h ; edx += 'a';
. text: 00401078 mov eax, [ebp+arg_0]
. text: 0040107B add eax, [ebp+var_8]
. text: 0040107E mov [eax], dl ; s[i] = edx low
. text: 00401080 jmp short loc_401050 ; 跳转到(i++)处
; 以下开始循环体之后的后续代码
. text: 00401082
. text: 00401082 loc_401082: ; CODE XREF: getstring+3Fj
. text: 00401082 mov eax, [ebp+var_4] ; return length;
. text: 00401085 pop edi
. text: 00401086 pop esi
. text: 00401087 pop ebx ; 恢复寄存器数据
. text: 00401088 add esp, 48h ; 释放栈上申请的临时空间
. text: 0040108B cmp ebp, esp ; 检查栈
. text: 0040108D call __chkesp
. text: 00401092 mov esp, ebp
. text: 00401094 pop ebp
. text: 00401095 retn
. text: 00401095 getstring endp
在上面的代码中,取参数s的地址是 [ebp + 8]? 这是为什么呢,可以看调用函数的过程:
.text:004010F2 lea eax, [ebp+var_C]
.text:004010F5 push eax // esp 就是 s 的地址(位于栈上)
.text:004010F6 call j_getstring //注意 call 指令会把下一条指令地址( 004010FB )入栈, esp 增加4;
.text:004010FB add esp, 4 //调用方复原堆栈。
call指令然后跳转到 getstring 函数:
.text:00401020 getstring proc near
.text:00401020 arg_0 = dword ptr 8
.text:00401020
.text:00401020 push ebp //为了保存ebp的值,esp再次增加4;
.text:00401021 mov ebp, esp //因此这时的 ebp / esp 距离参数的距离就是8 bytes;
ebp入栈后,栈内的数据如下:
---------------------------------------------------------------------------------
esp---> | ebp的原值 (4bytes)
| 函数返回时的跳转地址(call j_getstring 的下一条指令地址) (4 bytes)
| 参数地址(第一个/最左侧参数),即 char s[] 的地址(位于栈上);
因此函数的参数距离ebp的距离是 8 bytes,如果是按照从右到左的顺序入栈参数,则越靠左侧的参数距离栈顶越近(深度越浅)。以上分析内容在IDA的运行时截图如下所示:
在这里我们再总结以下VC编译器为一个函数生成的典型汇编代码:(注:这里的调用约定内容是:参数从右到左入栈,调用方负责参数出栈。)
(1)push ebp; ebp入栈,保存ebp的原值;
(2)mov ebp,esp; ebp指向当前栈顶;(此后ebp用作访问参数和函数临时变量的依据)
(3)sub esp,**h; 为函数内的临时变量在栈上申请空间。(大于等于临时变量的精确需求,以32bits内存对齐)
(4)编译器产生的开场白(prolog)。 (注:如果使用MS的关键字naked可以自己编写这部分汇编代码。)
包括:入栈保护寄存器的值,ESI, EDI, EBX, EBP (假如在函数里面用到了他们);“初始化”临时变量分配的栈上空间;(备注:用0xCC填充)
(5)函数主体;(返回值被放到eax)
(6)编译器产生的收场白(epilog)。(注:同上,可以用naked关键字自己来编写这部分汇编代码)
包括:复原被保护的寄存器;
(7)add esp, **h; 释放为临时变量在栈上申请的空间。
(8)cmp ebp, esp
call __chkesp
mov esp, ebp 检查栈指针
(9)pop ebp 恢复ebp的值
(10) retn 返回。
【备注】注意,这里讲的初始化有两种级别,一种是编译器级别(用0xcc填充栈上的临时变量空间),这一步骤对程序员透明;另一种是程序员编码时对临时变量的初始化,这是在高级语言编码级别的。
所有没经过程序员初始化的函数临时变量的数据都是0xCC;这样可以很容易辨认出变量是否没有经过编码级别初始化,例如没有程序员初始化的数据,字符串都是这样的特殊值,对于程序员由于粗心而忘记初始化的情况很容易发现。如果编译器没有做这一个动作,则临时变量的值都是随机数据,它们可能是由过去的“函数”使用后留下的痕迹,我们很难辨别它们是的确被程序员有意初始化了还是被遗忘了(从来就没有被赋予过初值),所以编译器的这个初始化动作尽管并非程序运行所必须,但却是我们排错调试所必要的。
-------------------------------------------------------------------
【补充说明】hoodlum1980 于2011-8-27
我在这里提到的“初始化过程”(对函数内申请的栈上空间用 0xCC 填充)针对的是 VC 在生成 Debug 版本时,编译器会产生这些代码。因此 Debug 版本中在程序中未初始化的栈上数据、缓冲区将被体现为这些值。有一种说法是因为 0xCC 是 int3 指令,这样一旦 PC 意外跳转到栈上,调试时就可以触发编译器中断。这种说法有一定道理,但 PC 从代码段跳转到进程的栈上空间的可能性几乎没有,且各个 section 上都有段特性(相当于权限和性质)的定义。所以这种说法我还从未见到在现实中被验证过。当然,凡事不排除一个意外,至于为什么VC采用 0xCC 来填充栈上的空间的这个问题?可能需要开发编译器的更内部的人士来解答。
-------------------------------------------------------------------
其中栈内的数据分布如下:
---------------------------------------------------------
栈顶esp---> 临时变量空间(保证内存对齐)
受保护的寄存器值
ebp-------> ebp的旧值
函数返回时的跳转地址
参数(从上到下的顺序:左侧参数,右侧参数)
----------------------------------------------------------
这里我们可以看到 for 循环中包含的四个基本部分,在汇编代码中按下面的顺序排列:
1. 初始化语句(i=0); 然后跳转到 3;
2. 后续工作语句(i++);
3. 条件语句(i<imax ? );条件不满足时跳转到5;
4. 循环体内部主体({...});然后跳转到2;
5. 继续向下执行后面的代码...
其中的2,3,5的起始地址在汇编代码中有标签以供跳转,即一个for循环的汇编代码主要由四部分组成,同时产生三个供跳转的地址标签。
在循环体控制中,还有两个主要的控制流程的高级语言代码是continue和break;这两者对循环的影响是很多人都非常清楚的,但是这两者对后续语句(i++)的影响恐怕就不是每个人都能分辨的清楚了。例如假如有如下的考题,请写出他们的输出,你是否能够搞得清楚?
int result;
for ( i = 0 , result = 0 ; i < 10 ; i ++ , result ++ )
{
if ( i == 5 ) continue ;
}
printf( " %d " , result);
// 题目B:
int result;
for ( i = 0 , result = 0 ; i < 10 ; i ++ , result ++ )
{
if ( i == 5 ) break ;
}
printf( " %d " , result);
实际上continue将跳转到2, break将跳转到5;请注意这两者的区别,continue是对continue之后的循环主体进行跳过(仅仅是循环主体不能够完整,其他三个部分的执行是完整的。),所有的 后续语句(i++)则都会完整执行;而 break 是直接离开循环体 (属于本次循环后续的 i++ 则不会被执行)。因此弄明白这一点同样是非常重要的。
下面我们给出一个for循环的示意图作为结束,从下图可以看出2,3,4组成一个回环(loop),此回环唯一的出入口位于3(条件语句),如果条件语句永远为TRUE,则无休止的在回环中运行,永远跳不出,也就是所谓的死循环。
--The end; by hoodlum1980; on 2010.07.31;
【题外话】strlen在VC中是用汇编语言实现的(可以在VC中查到它的汇编代码),非常有趣,它的高效之处在于不是逐个char去比较,而是以4个char为一组(DWORD)进行判断。第一次看恐怕很难看懂strlen的汇编代码,可以参考下面的文章: