栈:在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的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 基址指针,是保存调用者函数的地址,总是指向函数栈栈底,ESP被调函数的指针,总是指向函数栈栈顶。
首 先,将调用者函数的EBP入栈(pushebp),然后将调用者函数的栈顶指针ESP赋值给被调函数的EBP(作为被调函数的栈底,movebp,esp),此时,EBP寄存器处于一个非常重要的位置,该寄存器中存放着一个地址(原EBP入栈后的栈顶),以该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数的局部变量值,而该地址处又存放着上一层函数调用时的EBP值;
一般规律,SS:[ebp+4]处为被调函数的返回地址,SS:[EBP+8]处为传递给被调函数的第一个参数(最后一个入栈的参数,此处假设其占用4字节内存)的值,SS:[EBP-4]处为被调函数中的第一个局部变量,SS:[EBP]处为上一层EBP值;由于EBP中的地址处总是"上一层函数调用时的EBP值",而在每一层函数调用中,都能通过当时的EBP值"向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值";
如此递归,就形成了函数调用栈;
Eg函数内局部变量布局示例:
[plain]view plaincopy#include<stdio.h>#include<string.h>structC{inta;intb;intc;};inttest2(intx,inty,intz){printf("hello,test2\n");return0;}inttest(intx,inty,intz){inta=1;intb=2;intc=3;structCst;printf("addrx=%u\n",(unsignedint)(&x));printf("addry=%u\n",(unsignedint)(&y));printf("addrz=%u\n",(unsignedint)(&z));printf("addra=%u\n",(unsignedint)(&a));printf("addrb=%u\n",(unsignedint)(&b));printf("addrc=%u\n",(unsignedint)(&c));printf("addrst=%u\n",(unsignedint)(&st));printf("addrst.a=%u\n",(unsignedint)(&st.a));printf("addrst.b=%u\n",(unsignedint)(&st.b));printf("addrst.c=%u\n",(unsignedint)(&st.c));return0;}intmain(intargc,char**argv){intx=1;inty=2;intz=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);return0;}
打印输出如下:[plain]view plaincopyaddrx=3220024704addry=3220024708addrz=3220024712addra=3220024684addrb=3220024680addrc=3220024676addrst=3220024664addrst.a=3220024664addrst.b=3220024668addrst.c=3220024672x=1;y=2;z=3;x=0;y=0;z=3;局部变量在栈中布局示意图:
该图中的局部变量都是在该示例中定义的:
这个图片中反映的是一个典型的函数调用栈的内存布局;
访问函数的局部变量和访问函数参数的区别:
局部变量总是通过将ebp减去偏移量来访问,函数参数总是通过将ebp加上偏移量来访问。对于32位变量而言,第一个局部变量位于ebp-4,第二个位于ebp-8,以此类推,32位局部变量在栈中形成一个逆序数组;第一个函数参数位于ebp+8,第二个位于ebp+12,以此类推,32位函数参数在栈中形成一个正序数组。
Eg、研究函数调用过程:
[plain]view plaincopy#include<stdio.h>intbar(intc,intd){inte=c+d;returne;}intfoo(inta,intb){returnbar(a,b);}intmain(intargc,intargv){foo(2,3);return0;}
上面是一个很简单的函数调用过程,整个程序的执行过程是main
调用foo
,foo
调用bar
。
//查看反汇编文件(要查看编译后的汇编代码,其实还有一种办法是gcc -S text_stack.c
,这样只生成汇编代码
,而不生成二进制的目标文件。)text_stack
.s
[plain]view plaincopyroot@wangye:/home/wangye#gcctext_stack.c-groot@wangye:/home/wangye#objdump-dSa.out
反汇编结果很长,下面只列出我们关心的部分。[plain]view plaincopy08048394<bar>:#include<stdio.h>intbar(intc,intd){8048394:55push%ebp8048395:89e5mov%esp,%ebp8048397:83ec10sub$0x10,%espinte=c+d;804839a:8b450cmov0xc(%ebp),%eax804839d:8b5508mov0x8(%ebp),%edx80483a0:8d0402lea(%edx,%eax,1),%eax80483a3:8945fcmov%eax,-0x4(%ebp)returne;80483a6:8b45fcmov-0x4(%ebp),%eax}80483a9:c9leave80483aa:c3ret080483ab<foo>:intfoo(inta,intb){80483ab:55push%ebp80483ac:89e5mov%esp,%ebp80483ae:83ec08sub$0x8,%espreturnbar(a,b);80483b1:8b450cmov0xc(%ebp),%eax80483b4:89442404mov%eax,0x4(%esp)80483b8:8b4508mov0x8(%ebp),%eax80483bb:890424mov%eax,(%esp)80483be:e8d1ffffffcall8048394<bar>}80483c3:c9leave80483c4:c3ret080483c5<main>:intmain(intargc,intargv){80483c5:55push%ebp80483c6:89e5mov%esp,%ebp80483c8:83ec08sub$0x8,%espfoo(2,3);80483cb:c7442404030000movl$0x3,0x4(%esp)80483d2:0080483d3:c7042402000000movl$0x2,(%esp)80483da:e8ccffffffcall80483ab<foo>return0;80483df:b800000000mov$0x0,%eax}
//我们用gdb
跟踪程序的执行,直到bar
函数中的int e = c + d;
语句执行完毕准备返回时,这时在gdb
中打印函数栈帧。
[plain]view plaincopywangye@wangye:~$gdbtext_stackGNUgdb(GDB)7.0.1-debianCopyright(C)FreeSoftwareFoundation,Inc.LicenseGPLv3+:GNUGPLversion3orlater</licenses/gpl.html>Thisisfreesoftware:youarefreetochangeandredistributeit.ThereisNOWARRANTY,totheextentpermittedbylaw.Type"showcopying"and"showwarranty"fordetails.ThisGDBwasconfiguredas"i486-linux-gnu".Forbugreportinginstructions,pleasesee:</software/gdb/bugs/>...Readingsymbolsfrom/home/wangye/text_stack...done.(gdb)startTemporarybreakpoint1at0x80483cb:filetext_stack.c,line16.Startingprogram:/home/wangye/text_stackTemporarybreakpoint1,main(argc=1,argv=-1073744732)attext_stack.c:1616foo(2,3);(gdb)sfoo(a=2,b=3)attext_stack.c:1111returnbar(a,b);(gdb)sbar(c=2,d=3)attext_stack.c:55inte=c+d;(gdb)disassembleDumpofassemblercodeforfunctionbar:0x08048394<bar+0>:push%ebp0x08048395<bar+1>:mov%esp,%ebp0x08048397<bar+3>:sub$0x10,%esp0x0804839a<bar+6>:mov0xc(%ebp),%eax0x0804839d<bar+9>:mov0x8(%ebp),%edx0x080483a0<bar+12>:lea(%edx,%eax,1),%eax0x080483a3<bar+15>:mov%eax,-0x4(%ebp)0x080483a6<bar+18>:mov-0x4(%ebp),%eax0x080483a9<bar+21>:leave0x080483aa<bar+22>:retEndofassemblerdump.(gdb)si0x0804839d5inte=c+d;(gdb)si0x080483a05inte=c+d;(gdb)si0x080483a35inte=c+d;(gdb)si6returne;(gdb)si7}(gdb)bt#0bar(c=2,d=3)attext_stack.c:7#10x080483c3infoo(a=2,b=3)attext_stack.c:11#20x080483dfinmain(argc=1,argv=-1073744732)attext_stack.c:16(gdb)inforerecordregisters(gdb)inforegieax0x55ecx0x4c2f5d431278172483edx0x22ebx0xb7fcaff4-1208176652esp0xbffff3c80xbffff3c8ebp0xbffff3d80xbffff3d8esi0x00edi0x00eip0x80483a90x80483a9<bar+21>eflags0x282[SFIF]cs0x73115ss0x7b123ds0x7b123es0x7b123fs0x00gs0x3351(gdb)inforegieax0x55ecx0x4c2f5d431278172483edx0x22ebx0xb7fcaff4-1208176652esp0xbffff3c80xbffff3c8ebp0xbffff3d80xbffff3d8esi0x00edi0x00eip0x80483a90x80483a9<bar+21>eflags0x282[SFIF]cs0x73115ss0x7b123ds0x7b123es0x7b123fs0x00gs0x3351(gdb)x/20$esp0xbffff3c8:-1073744904134513689-120817586850xbffff3d8:-107374494513603230xbffff3e8:-1073744904134513631230xbffff3f8:-1073744776-12094062981-10737447320xbffff408:-1073744724-1208084392-1073744800-1
这里我们又用了几个新的gdb命令,简单解释一下:info registers
可以显示所有寄存器的当前值。在gdb
中表示寄存器名时前面要加个$
,例如p $esp
可以打印esp
寄存器的值,在上例中esp
寄存器的值是0xbffff3c8,所以x/20 $esp
命令查看内存中从0xbffff3c8 地址开始的20个32位数。在执行程序时,操作系统为进程分配一块栈空间来保存函数栈帧,esp
寄存器总是指向栈顶,在x86平台上这个栈是从高地址向低地址增长的,我们知道每次调用一个函数都要分配一个栈帧来保存参数和局部变量,现在我们详细分析这些数据在栈空间的布局,根据gdb
的输出结果图示如下:
图中每个小方格表示4个字节的内存单元,例如b: 3
这个小方格占的内存地址是0xbffff3f4~0xbffff3f7,把地址写在每个小方格的下边界线上,是为了强调该地址是内存单元的起始地址。我们从main
函数的这里开始看起:
[plain]view plaincopyfoo(2,3);80483cb:c7442404030000movl$0x3,0x4(%esp)80483d2:0080483d3:c7042402000000movl$0x2,(%esp)80483da:e8ccffffffcall80483ab<foo>return0;80483df:b800000000mov$0x0,%eax
要调用函数foo
先要把参数准备好,第二个参数保存在esp+4
指向的内存位置,第一个参数保存在esp
指向的内存位置,可见参数是从右向左依次压栈的。然后执行call
指令,这个指令有两个作用:
foo
函数调用完之后要返回到call
的下一条指令继续执行,所以把call
的下一条指令的地址134513631压栈,同时把esp
的值减4,esp
的值现在是0xbffff3ec。
修改程序计数器eip
,跳转到foo
函数的开头执行。
现在看foo
函数的汇编代码:
[plain]view plaincopy080483ab<foo>:intfoo(inta,intb){80483ab:55push%ebp80483ac:89e5mov%esp,%ebp80483ae:83ec08sub$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
函数:
[plain]view plaincopyreturnbar(a,b);80483b1:8b450cmov0xc(%ebp),%eax80483b4:89442404mov%eax,0x4(%esp)80483b8:8b4508mov0x8(%ebp),%eax80483bb:890424mov%eax,(%esp)80483be:e8d1ffffffcall8048394<bar>}80483c3:c9leave80483c4:c3ret
现在看bar
函数的指令:
[plain]view plaincopyintbar(intc,intd){8048394:55push%ebp8048395:89e5mov%esp,%ebp8048397:83ec10sub$0x10,%espinte=c+d;804839a:8b450cmov0xc(%ebp),%eax804839d:8b5508mov0x8(%ebp),%edx80483a0:8d0402lea(%edx,%eax,1),%eax80483a3:8945fcmov%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
函数的返回指令:
[plain]view plaincopyreturne;80483a6:8b45fcmov-0x4(%ebp),%eax}80483a9:c9leave80483aa:c3ret
bar
函数有一个int
型的返回值,这个返回值是通过eax
寄存器传递的,所以首先把e
的值读到eax
寄存器中。然后执行leave
指令,这个指令是函数开头的push %ebp
和mov %esp,%ebp
的逆操作:
把ebp
的值赋给esp
,现在esp
的值是0xbffff3d8。
现在esp
所指向的栈顶保存着foo
函数栈帧的ebp
,把这个值恢复给ebp
,同时esp
增加4,esp
的值变成0xbffff3dc。
最后是ret
指令,它是call
指令的逆操作:
现在esp
所指向的栈顶保存着返回地址,把这个值恢复给eip
,同时esp
增加4,esp
的值变成0xbffff3e0。
修改了程序计数器eip
,因此跳转到返回地址0x80483c2继续执行。
地址0x80483c2处是foo
函数的返回指令:
[plain]view plaincopy80483c3:c9leave80483c4:c3ret
重复同样的过程,又返回到了main
函数。注意函数调用和返回过程中的这些规则:
参数压栈传递,并且是从右向左依次压栈。
ebp
总是指向当前栈帧的栈底。
返回值通过eax
寄存器传递。
这些规则并不是体系结构所强加的,ebp
寄存器并不是必须这么用,函数的参数和返回值也不是必须这么传,只是操作系统和编译器选择了以这样的方式实现C代码中的函数调用,这称为CallingConvention,Calling Convention是操作系统二进制接口规范(ABI,Application BinaryInterface)的一部分。