栈:在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
当发生函数调用的时候,栈空间中存放的数据是这样的:
1、调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
2、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
3、在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);
4、在被调函数中,从ebp的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;
所以,发生函数调用时,入栈的顺序为:
参数N
参数N-1
参数N-2
.....
参数3
参数2
参数1
函数返回地址
上一层调用函数的EBP/BP
局部变量1
局部变量2
....
局部变量N
函数调用栈如下图所示:
解释:
首
先,将调用者函数的EBP入栈(push ebp),然后将调用者函数的栈顶指针ESP赋值给被调函数的EBP(作为被调函数的栈底,mov
ebp,esp),此时,EBP寄存器处于一个非常重要的位置,该寄存器中存放着一个地址(原EBP入栈后的栈顶),以该地址为基准,向上(栈底方向)能
获取返回地址、参数值,向下(栈顶方向)能获取函数的局部变量值,而该地址处又存放着上一层函数调用时的EBP值;
一般而言,SS:
[ebp+4]处为被调函数的返回地址,SS:[EBP+8]处为传递给被调函数的第一个参数(最后一个入栈的参数,此处假设其占用4字节内存)的
值,SS:[EBP-4]处为被调函数中的第一个局部变量,SS:[EBP]处为上一层EBP值;由于EBP中的地址处总是"上一层函数调用时的EBP
值",而在每一层函数调用中,都能通过当时的EBP值"向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值";
如此递归,就形成了函数调用栈;
函数内局部变量布局示例:
#include
#include
struct C
{
int a;
int b;
int c;
};
int test2(int x, int y, int z)
{
printf("hello,test2\n");
return 0;
}
int test(int x, int y, int z)
{
int a = 1;
int b = 2;
int c = 3;
struct C st;
printf("addr x = %u\n",(unsigned int)(&x));
printf("addr y = %u\n",(unsigned int)(&y));
printf("addr z = %u\n",(unsigned int)(&z));
printf("addr a = %u\n",(unsigned int)(&a));
printf("addr b = %u\n",(unsigned int)(&b));
printf("addr c = %u\n",(unsigned int)(&c));
printf("addr st = %u\n",(unsigned int)(&st));
printf("addr st.a = %u\n",(unsigned int)(&st.a));
printf("addr st.b = %u\n",(unsigned int)(&st.b));
printf("addr st.c = %u\n",(unsigned int)(&st.c));
return 0;
}
int main(int argc, char** argv)
{
int x = 1;
int y = 2;
int z = 3;
test(x,y,z);
printf("x = %d; y = %d; z = %d;\n", x,y,z);
memset(&y, 0, 8);
printf("x = %d; y = %d; z = %d;\n", x,y,z);
return 0;
}
打印输出如下:
addr x = 4288282272
addr y = 4288282276
addr z = 4288282280
addr a = 4288282260
addr b = 4288282256
addr c = 4288282252
addr st = 4288282240
addr st.a = 4288282240
addr st.b = 4288282244
addr st.c = 4288282248
a = 1; b = 2; c = 3;
a = 0; b = 0; c = 3;
示例效果图:
该图中的局部变量都是在该示例中定义的;
这个图片中反映的是一个典型的函数调用栈的内存布局;
访问函数的局部变量和访问函数参数的区别:
局部变量总是通过将ebp减去偏移量来访问,函数参数总是通过将ebp加上偏移量来访问。对于32位变量而言,第一个局部变量位于ebp-4,第二个位于ebp-8,以此类推,32位局部变量在栈中形成一个逆序数组;第一个函数参数位于ebp+8,第二个位于ebp+12,以此类推,32位函数参数在栈中形成一个正序数组。
汇编代码示例(1):
////////////////////////////////////////////////////////////////////
比如 我们有这样一个C函数
#include
long test(int a,int b)
{
a = a + 1;
b = b + 100;
return a + b;
}
void main()
{
printf("%d",test(1000,2000));
}
写成32位汇编就是这样
;//////////////////////////////////////////////////////////////////////////////////////////////////////
.386
.model flat,stdcall ;这里我们用stdcall 就是函数参数 压栈的时候从最后一个开始压,和被调用函数负责清栈
option casemap:none ;区分大小写
includelib msvcrt.lib ;这里是引入类库 相当于 #include了
printf PROTO C:DWORD,:VARARG ;这个就是声明一下我们要用的函数头,到时候 汇编程序会自动到msvcrt.lib里面找的了
;:VARARG 表后面的参数不确定 因为C就是这样的printf(const char *, ...);
;这样的函数要注意 不是被调用函数负责清栈 因为它本身不知道有多少个参数
;而是有调用者负责清栈 下面会详细说明
.data
szTextFmt BYTE '%d',0 ;这个是用来类型转换的,跟C的一样,字符用字节类型
a dword 1000 ;假设
b dword 2000 ;处理数值都用双字 没有int 跟long 的区别
;/////////////////////////////////////////////////////////////////////////////////////////
.code
_test proc ;A:DWORD,B:DWORD
push ebp
mov ebp,esp
mov eax,dword ptr ss:[ebp+8]
add eax,1
mov edx,dword ptr ss:[ebp+0Ch]
add edx,100
add eax,edx
pop ebp
retn 8
_test endp
_main proc
push dword ptr ds:b ;反汇编我们看到的b就不是b了而是一个[*****]数字 dword ptr 就是我们在ds(数据段)把[*****]
;开始的一个双字长数值取出来
push dword ptr ds:a ;跟她对应的还有 byte ptr ****就是取一个字节出来 比如这样 mov al,byte ptr ds:szTextFmt
;就把 % 取出来 而不包括 d
call _test
push eax ;假设push eax的地址是×××××
push offset szTextFmt
call printf
add esp,8
ret
_main endp
end _main
汇编代码示例(2):
研究函数的调用过程
int bar(int c, int d)
{
int e = c + d;
return e;
}
int foo(int a, int b)
{
return bar(a, b);
}
int main(void)
{
foo(2, 3);
return 0;
}
如果在编译时加上-g选项(在讲过-g选项),那么用objdump反汇编时可以把C代码和汇编代码穿插起来显示,这样C代码和汇编代码的对应关系看得更清楚。反汇编的结果很长,以下只列出我们关心的部分。
$ gcc main.c -g
$ objdump -dS a.out
...
08048394 :
int bar(int c, int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e = c + d;
804839a: 8b 55 0c mov 0xc(%ebp),%edx
804839d: 8b 45 08 mov 0x8(%ebp),%eax
80483a0: 01 d0 add %edx,%eax
80483a2: 89 45 fc mov %eax,-0x4(%ebp)
return e;
80483a5: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a8: c9 leave
80483a9: c3 ret
080483aa :
int foo(int a, int b)
{
80483aa: 55 push %ebp
80483ab: 89 e5 mov %esp,%ebp
80483ad: 83 ec 08 sub $0x8,%esp
return bar(a, b);
80483b0: 8b 45 0c mov 0xc(%ebp),%eax
80483b3: 89 44 24 04 mov %eax,0x4(%esp)
80483b7: 8b 45 08 mov 0x8(%ebp),%eax
80483ba: 89 04 24 mov %eax,(%esp)
80483bd: e8 d2 ff ff ff call 8048394
}
80483c2: c9 leave
80483c3: c3 ret
080483c4 :
int main(void)
{
80483c4: 8d 4c 24 04 lea 0x4(%esp),%ecx
80483c8: 83 e4 f0 and $0xfffffff0,%esp
80483cb: ff 71 fc pushl -0x4(%ecx)
80483ce: 55 push %ebp
80483cf: 89 e5 mov %esp,%ebp
80483d1: 51 push %ecx
80483d2: 83 ec 08 sub $0x8,%esp
foo(2, 3);
80483d5: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483dc: 00
80483dd: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483e4: e8 c1 ff ff ff call 80483aa
return 0;
80483e9: b8 00 00 00 00 mov $0x0,%eax
}
80483ee: 83 c4 08 add $0x8,%esp
80483f1: 59 pop %ecx
80483f2: 5d pop %ebp
80483f3: 8d 61 fc lea -0x4(%ecx),%esp
80483f6: c3 ret
...
要查看编译后的汇编代码,其实还有一种办法是gcc -S main.c,这样只生成汇编代码main.s,而不生成二进制的目标文件。
整个程序的执行过程是main调用foo,foo调用bar,我们用gdb跟踪程序的执行,直到bar函数中的int e = c + d;语句执行完毕准备返回时,这时在gdb中打印函数栈帧。
(gdb) start
...
main () at main.c:14
14 foo(2, 3);
(gdb) s
foo (a=2, b=3) at main.c:9
9 return bar(a, b);
(gdb) s
bar (c=2, d=3) at main.c:3
3 int e = c + d;
(gdb) disassemble
Dump of assembler code for function bar:
0x08048394 : push %ebp
0x08048395 : mov %esp,%ebp
0x08048397 : sub $0x10,%esp
0x0804839a : mov 0xc(%ebp),%edx
0x0804839d : mov 0x8(%ebp),%eax
0x080483a0 : add %edx,%eax
0x080483a2 : mov %eax,-0x4(%ebp)
0x080483a5 : mov -0x4(%ebp),%eax
0x080483a8 : leave
0x080483a9 : ret
End of assembler dump.
(gdb) si
0x0804839d 3 int e = c + d;
(gdb) si
0x080483a0 3 int e = c + d;
(gdb) si
0x080483a2 3 int e = c + d;
(gdb) si
4 return e;
(gdb) si
5 }
(gdb) bt
#0 bar (c=2, d=3) at main.c:5
#1 0x080483c2 in foo (a=2, b=3) at main.c:9
#2 0x080483e9 in main () at main.c:14
(gdb) info registers
eax 0x5 5
ecx 0xbff1c440 -1074674624
edx 0x3 3
ebx 0xb7fe6ff4 -1208061964
esp 0xbff1c3f4 0xbff1c3f4
ebp 0xbff1c404 0xbff1c404
esi 0x8048410 134513680
edi 0x80482e0 134513376
eip 0x80483a8 0x80483a8
eflags 0x200206 [ PF IF ID ]
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
fs 0x0 0
gs 0x33 51
(gdb) x/20 $esp
0xbff1c3f4: 0x00000000 0xbff1c6f7 0xb7efbdae 0x00000005
0xbff1c404: 0xbff1c414 0x080483c2 0x00000002 0x00000003
0xbff1c414: 0xbff1c428 0x080483e9 0x00000002 0x00000003
0xbff1c424: 0xbff1c440 0xbff1c498 0xb7ea3685 0x08048410
0xbff1c434: 0x080482e0 0xbff1c498 0xb7ea3685 0x00000001
(gdb)
这里又用到几个新的gdb命令。disassemble可以反汇编当前函数或者指定的函数,单独用disassemble命令是反汇编当前函数,如果disassemble命令后面跟函数名或地址则反汇编指定的函数。以前我们讲过step命令可以一行代码一行代码地单步调试,而这里用到的si命令可以一条指令一条指令地单步调试。info registers可以显示所有寄存器的当前值。在gdb中表示寄存器名时前面要加个$,例如p $esp可以打印esp寄存器的值,在上例中esp寄存器的值是0xbff1c3f4,所以x/20 $esp命令查看内存中从0xbff1c3f4地址开始的20个32位数。在执行程序时,操作系统为进程分配一块栈空间来保存函数栈帧,esp寄存器总是指向栈顶,在x86平台上这个栈是从高地址向低地址增长的,我们知道每次调用一个函数都要分配一个栈帧来保存参数和局部变量,现在我们详细分析这些数据在栈空间的布局,根据gdb的输出结果图示如下[]:
图中每个小方格表示4个字节的内存单元,例如b: 3这个小方格占的内存地址是0xbff1c420~0xbff1c423,我把地址写在每个小方格的下边界线上,是为了强调该地址是内存单元的起始地址。我们从main函数的这里开始看起:
foo(2, 3);
80483d5: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483dc: 00
80483dd: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483e4: e8 c1 ff ff ff call 80483aa
return 0;
80483e9: b8 00 00 00 00 mov $0x0,%eax
要调用函数foo先要把参数准备好,第二个参数保存在esp+4指向的内存位置,第一个参数保存在esp指向的内存位置,可见参数是从右向左依次压栈的。然后执行call指令,这个指令有两个作用:
-
foo函数调用完之后要返回到call的下一条指令继续执行,所以把call的下一条指令的地址0x80483e9压栈,同时把esp的值减4,esp的值现在是0xbff1c418。
-
修改程序计数器eip,跳转到foo函数的开头执行。
现在看foo函数的汇编代码:
int foo(int a, int b)
{
80483aa: 55 push %ebp
80483ab: 89 e5 mov %esp,%ebp
80483ad: 83 ec 08 sub $0x8,%esp
push %ebp指令把ebp寄存器的值压栈,同时把esp的值减4。esp的值现在是0xbff1c414,下一条指令把这个值传送给ebp寄存器。这两条指令合起来是把原来ebp的值保存在栈上,然后又给ebp赋了新值。在每个函数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问,例如foo函数的参数a和b分别通过ebp+8和ebp+12来访问。所以下面的指令把参数a和b再次压栈,为调用bar函数做准备,然后把返回地址压栈,调用bar函数:
return bar(a, b);
80483b0: 8b 45 0c mov 0xc(%ebp),%eax
80483b3: 89 44 24 04 mov %eax,0x4(%esp)
80483b7: 8b 45 08 mov 0x8(%ebp),%eax
80483ba: 89 04 24 mov %eax,(%esp)
80483bd: e8 d2 ff ff ff call 8048394
现在看bar函数的指令:
int bar(int c, int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e = c + d;
804839a: 8b 55 0c mov 0xc(%ebp),%edx
804839d: 8b 45 08 mov 0x8(%ebp),%eax
80483a0: 01 d0 add %edx,%eax
80483a2: 89 45 fc mov %eax,-0x4(%ebp)
这次又把foo函数的ebp压栈保存,然后给ebp赋了新值,指向bar函数栈帧的栈底,通过ebp+8和ebp+12分别可以访问参数c和d。bar函数还有一个局部变量e,可以通过ebp-4来访问。所以后面几条指令的意思是把参数c和d取出来存在寄存器中做加法,计算结果保存在eax寄存器中,再把eax寄存器存回局部变量e的内存单元。
在gdb中可以用bt命令和frame命令查看每层栈帧上的参数和局部变量,现在可以解释它的工作原理了:如果我当前在bar函数中,我可以通过ebp找到bar函数的参数和局部变量,也可以找到foo函数的ebp保存在栈上的值,有了foo函数的ebp,又可以找到它的参数和局部变量,也可以找到main函数的ebp保存在栈上的值,因此各层函数栈帧通过保存在栈上的ebp的值串起来了。
现在看bar函数的返回指令:
return e;
80483a5: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a8: c9 leave
80483a9: c3 ret
bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读到eax寄存器中。然后执行leave指令,这个指令是函数开头的push %ebp和mov %esp,%ebp的逆操作:
-
把ebp的值赋给esp,现在esp的值是0xbff1c404。
-
现在esp所指向的栈顶保存着foo函数栈帧的ebp,把这个值恢复给ebp,同时esp增加4,esp的值变成0xbff1c408。
最后是ret指令,它是call指令的逆操作:
-
现在esp所指向的栈顶保存着返回地址,把这个值恢复给eip,同时esp增加4,esp的值变成0xbff1c40c。
-
修改了程序计数器eip,因此跳转到返回地址0x80483c2继续执行。
地址0x80483c2处是foo函数的返回指令:
80483c2: c9 leave
80483c3: c3 ret
重复同样的过程,又返回到了main函数。注意函数调用和返回过程中的这些规则:
-
参数压栈传递,并且是从右向左依次压栈。
-
ebp总是指向当前栈帧的栈底。
-
返回值通过eax寄存器传递。
这些规则并不是体系结构所强加的,ebp寄
存器并不是必须这么用,函数的参数和返回值也不是必须这么传,只是操作系统和编译器选择了以这样的方式实现C代码中的函数调用,这称为Calling
Convention,Calling Convention是操作系统二进制接口规范(ABI,Application Binary
Interface)的一部分。
阅读(846) | 评论(0) | 转发(0) |