(环境是vc6Debug方式下)
#include<stdio.h>
void test()
{
int t;
scanf("%d",&t);
在这里加入代码
}
main()
{
int m;
test();
printf("m=%d",m);
}
要在test函数中加入代码,影响main函数里面的变量,很明显我们要加的代码是一个赋值语句,语句的右值是我们输入的t的值,而左值,则是test函数里t变量的地址加上main函数里m变量的地址相对于t变量的偏移的值。
那么问题的实质,就转变为变量m的地址相对于变量t地址的偏移了。
这个偏移如何求?
基础知识:
让我们跳出这个题,看看我们手头掌握的知识。
函数调用需要用到堆栈,当程序调用函数时,C将函数调用后面的指令地址(称为返回地址)压入堆栈。然后,C将函数的参数从右至左依次压入堆栈。最后,如果函数声明了局部变量,C将堆栈空间分配给函数以存储变量的值。
当函数结束的时候,C释放存储局部变量和参数的堆栈空间。然后,C根据返回地址判断下一步要执行的指令。C从堆栈中移走返回值并将地址放入IP寄存器中。
函数调用的堆栈示意图如下图所示:

需要说明的是,什么是堆栈呢?我们都知道,变量、常量在内存中的分配大致分成四个区域,分别是堆栈、堆、常数存储区以及全局、静态存储区。分配在堆栈中的变量具有的最主要的特点就是当这个变量不再需要的时候,编译器会自动回收,函数内的局部变量的存储单元都可以在栈上分配。
实际上,堆栈是系统内存中的一块区域,这个区域在操作系统初始化时得到分配。
探究本题:
由堆栈的知识,我们知道了,test函数里的t,以及main函数里的m都是分配在堆栈中的,因此他们之间的地址偏移量,也就取决于函数调用时堆栈是如何操作的。
我们通过汇编代码来仔细分析下堆栈的操作方式,调试坏境是VC6.0,在windows操作系统中,堆栈的生长方向是由高到低的。
32:
33:
34: main()
35: {
00401070 push ebp
00401071 mov ebp,esp ①
00401073 sub esp,44h ②
00401076 push ebx
00401077 push esi
00401078 push edi
00401079 lea edi,[ebp-44h] ③
0040107C mov ecx,11h
00401081 mov eax,0CCCCCCCCh
00401086 rep stos dword ptr [edi] ④
36: int m;
37: test();
00401088 call @ILT+0(test) (00401005) ⑤
38: printf("m=%d",m);
0040108D mov eax,dword ptr [ebp-4]
00401090 push eax
00401091 push offset string "m=%d" (0042201c)
00401096 call printf (00401160)
0040109B add esp,8 ⑥
39: }
0040109E pop edi
0040109F pop esi
004010A0 pop ebx
004010A1 add esp,44h
004010A4 cmp ebp,esp
004010A6 call __chkesp (00401120)
004010AB mov esp,ebp
004010AD pop ebp
004010AE ret ⑦
--- No source file ----
下图是到步骤⑤时的堆栈情况,实际上函数main()和test()一样,也可以看做是函数调用的情况,只不过函数main是由mainCRTStartup这个默认函数调用的,而函数test是由函数main调用的,两者在调用上的汇编代码都是一样的。

好了,堆栈的全貌我们看到了,现在就要真正的开工了。我们结合汇编代码来看看到底函数调用时,堆栈是如何操作的。
当一切都还没有发生却即将发生的时候,是一个令人鸡冻的时刻,是时候该向过去告别了,因为我们即将进入一个新的函数,一段新的旅程。但是别急,磨刀不误砍柴功,有些事情必须先做准备,那么就从这里开始准备吧。
此时,栈顶esp的值是0x12ff84,这个地址上存的值是0x4012c9,我们再去看看0x4012c9位置的指令。很好,在这个指令上一行我们看到了一条醒目的指令:call @ILT+5(main),一切都明白了,0x4012c9就是函数的返回地址。旅行虽然令人兴奋,但是我们必须把出口的地址记下来,毕竟,旅行只是生活的调节,我们终将还得继续自己的生活。
① push ebp
mov ebp, esp
这段代码很容易,就是把栈底寄存器ebp入栈。ebp主要用于给出堆栈中数据区基址的偏移,从而方便的实现直接存取堆栈中的数据。
mov ebp, esp 就是把当前esp的值赋给ebp。实际上,就是给ebp重新赋了值,该值就是这个main()函数在堆栈中的基址,以后main()函数中分配的局部变量等的地址都会通过ebp算出偏移,来进行读写运算。
我们看到0x12ff80地址上存的是0x12ffc0,这是调用main()函数的函数在堆栈中的基址,我们在这里把这个值保存下来,以便退出main()函数时能够恢复ebp。
② sub esp, 44h
在存储了ebp,并且重新对ebp赋值了后,代码将esp减去44h,这个区域是预留给函数的局部变量的。在该函数中定义的局部变量,将依次序在这块区域中得到分配。
有人说了,万一这个44h的区域不够怎么办,实际上这个44h是可以变化的,编译器会根据实际情况调整这个数值。我测试了一下,如果定义了1个变量,就会分配44h的空间,定义了2个变量,就会分配48h的空间……依次类推。
③ push ebx
push esi
push edi
lea edi,[ebp-44h]
分配了44h空间后,ebx, esi, edi的值将依次入栈。为什么要先分配44h的空间,再push这三个寄存器,而不是一口气把ebp, ebx, esi, edi这四个东东全部都压进去,再分配局部变量的空间呢。
我想是因为这个空间是分配给局部变量的,而所有局部变量的地址都是通过ebp加上偏移量算出来的,因此让这个空间和ebp紧挨能够提高运算的效率。
ebx寄存器是基地址寄存器,是四个数据寄存器中唯一可作为存储器指针使用的寄存器。esi寄存器是源变址寄存器,edi则是目的变址寄存器。这两个寄存器的典型用法就是进行字符串操作时,esi作为源指针,edi作为目的指针。
lea edi, [ebp-44h] 这一句使得edi的值由0x00000000变成了(0x12ff80 – 44h = )0x12ff3c,这个地址是44h的最后一个地址,至于为什么要赋成这个值,我们马上就会知道了。
④ mov ecx,11h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
我们在②中分配的空间似乎还没有初始化,现在我们就着手来对这段区域进行初始化。在③中已经把edi的值赋给了这块空间的一端,而ecx这个计数寄存器里存储的是循环次数,一共要循环11h次,哦,这个11h就是44h/4得到的。很显然,eax里存储的是初值罗,这段话的意思就是,从0x12ff3c这个地址开始,以后的11h个字的值初始化为0xcccccccc。
程序走到这里,基本上函数调用的步骤已经全部完成了,我们总结一下,看看堆栈里到底发生了什么。
压入了4个寄存器的值,分别是ebp,ebx,esi,edi的值。分配了一个大小为44h的空间,这个空间的大小根据函数的具体情况,由编译器自行决定。对这个大小为44h的空间的每一个字初始化为0xcccccccc。
OK,我们来算算到目前为止,我们用了堆栈多少空间:0x12ff84 – 0x12ff30 = 54h。
⑤ call @ILT+0(test) (00401005)
做完函数调用的工作,就要开始真正的函数本题的工作了。我们看到int m;这一句C语言并没有转化成任何的汇编语言,实际上,这只是一个声明的语句,告诉编译器这里有一个变量,叫做m。那么,什么时候分配这个m的空间呢,我们继续往下看,就会看到一句mov eax,dword ptr [ebp-4],对头,就在这里,地址是ebp-4,正是44h的最开始的地址。
test()这句话汇编为call @ILT+0(test) (00401005),那么我们又要开始一段新的旅程了。
OK, 我们现在来到了test函数,也离我们需要解答的地方越来越近了。其实我们走到了这里,聪明的你,是不是已经知道了答案了呢?
还是和之前一样,我们从汇编看起。
2: void test()
3: {
0040DA10 push ebp
0040DA11 mov ebp,esp ⑴
0040DA13 sub esp,44h ⑵
0040DA16 push ebx
0040DA17 push esi
0040DA18 push edi
0040DA19 lea edi,[ebp-44h] ⑶
0040DA1C mov ecx,11h
0040DA21 mov eax,0CCCCCCCCh
0040DA26 rep stos dword ptr [edi] ⑷
4: int t;
5: scanf("%d",&t);
0040DA28 lea eax,[ebp-4]
0040DA2B push eax
0040DA2C push offset string "%d" (00422fd8)
0040DA31 call scanf (004010c0)
0040DA36 add esp,8
?
7: } ⑸
0040DA3F pop edi
0040DA40 pop esi
0040DA41 pop ebx
0040DA42 add esp,44h
0040DA45 cmp ebp,esp
0040DA47 call __chkesp (00401120)
0040DA4C mov esp,ebp
0040DA4E pop ebp
0040DA4F ret
--- No source file ---
堆栈如下图所示:

函数的调用和main()函数一样,而如前所述,局部变量t分配到44h空间的第一个字,也就是0x12ff24这个地址。而局部变量m则分配到main()函数44h空间的第一个字,也就是0x12ff7c中。
OK,我们至此找到了变量t和变量m的偏移,0x12ff7c – 0x12ff24 = 58h,因此我们问题的答案就显而易见了:
*((&t) + 0x58 / 4) = t;
发散思维:
题目已经解决了,但是我们注意到了,test()函数没有参数,显然,我们也很想知道,如果这个函数有参数,堆栈将如何工作。
那么,把题目里的程序改一下子吧:
#include<stdio.h>
void test(int x, char *pStr, double y)
{
int t;
scanf("%d",&t);
*((&t) + 0x58/4) = t;
}
void main()
{
int m;
test(1, "Hello World!", 3.14);
printf("m=%d",m);
}
给test()函数加上了3个参数,分别是int、char *以及double型的,编译环境依然是Windows操作系统,VC++6.0。
我们来看看关键部分的汇编代码:
14: test(1, "Hello World!", 3.14);
00401088 push 40091EB8h
0040108D push 51EB851Fh ①
00401092 push offset string "Hello World!" (00426028) ②
00401097 push 1 ③
00401099 call @ILT+0(test) (00401005) ④
0040109E add esp,10h ⑤
涉及到这一段代码的堆栈如下图:

①②③分别把三个参数压入栈中,顺序是从右向左。
先压入函数的参数,再压入返回地址,与基础知识里讲的正好相反,可见压入的顺序是与编译器有关的。
总结:
在windows操作系统,VC Debug环境下:
1, 函数的局部变量是分配在栈中的,这个区域是将ebp压入栈后分配的一块区域。这也是为什么分配在栈上的局部变量在函数结束后会自动收回的原因,因为函数结束后会退栈,因此分配给该函数局部变量的那块区域也就退栈掉了,自然也就自动收回了。
2, 函数调用时,会自动将ebp, ebx, esi和edi压入栈,函数的局部变量是通过ebp加上偏移来进行访问的。
3, 函数的调用是先将参数由右向左压入栈,再压入下一条指令的地址作为返回地址。
|