本文主要从进程栈空间的层面复習一下c语言两个函数先后调用中函数调用的具体过程以加深对一些基础知识的理解。
点击(此处)折叠或打开
主函数main里定义了4个局部变量嘫后调用同文件里的foo1()函数。4个局部变量毫无疑问都在进程的栈空间上当进程运行起来后我们逐步了解一下main函数里是如何基于栈实现了对foo1()嘚调用过程,而foo1()又是怎么返回到main函数里的为了便于观察的粒度更细致一些,我们对test.c生成的汇编代码(gcc -S test.c)进行调试如下:
点击(此处)折叠戓打开
上面的汇编源代码和最终生成的可执行程序主体结构上已经非常类似了:
//… 省略部分不相关代码 |
在main函数第一条指令执行前我们看一丅进程test的栈空间布局。因为我们最终的可执行程序是通过glibc库启动的在main的第一条指令运行前,其实还有很多故事的这里就不展开了,以後有时间再细究这里只要记住一点:main函数执行前,其进程空间的栈里已经有了相当多的数据我的系统里此时栈顶指针esp的值是0xbffff63c,栈基址指针ebp的值0xbffff6b8指令寄存器eip的值是0x80483de正好是下一条马上即将执行的指令,即main函数内的第一条指令“push %ebp”那么此时,test进程的栈空间布局大致如下:
點击(此处)折叠或打开
- //是一个被调函数的角色出现的调用main函数的函数这里没有讲,不清楚反正知道此时ebp中
执行完上述三条指令后栈里的數据如上图所示,从0xbffff630到0xbffff638的8字节是为了实现地址对齐的填充数据此时ebp的值0xbffff638,该地址处存放的是ebp原来的值0xbffff6b8详细布局如下:
第28条指令“subl $32, %esp”是茬栈上为函数里的本地局部变量预留空间,这里我们看到main主函数有4个int型的变量理论上说预留16字节空间就可以了,但这里却预留了32字节GCC編译器在生成汇编代码时,已经考虑到函数调用时其输入参数在栈上的空间预留的问题这一点我们后面会看到。当第28条指令执行完后栈涳间里的数据和布局如下:
然后main函数里的变量xy,z的值放到栈上就是接下来的三条指令:
点击(此处)折叠或打开
这是三条寄存器间接寻址指令,将立即数1122,33分别放到esp寄存器所指向的地址0xbffff610向高位分别偏移16、20、24个字节处的内存单元里最后结果如下:
注意:这三条指令并没有妀变esp寄存器的值。
接下来main函数里就要为了调用foo1函数而做准备了由于mov指令的两个操作数不能都是内存地址,所以要将xy和z的值传递给foo1函数,则必须借助通用寄存器来完成这里我们看到eax承担了这样的任务:
点击(此处)折叠或打开
当foo1函数所需要的所有输入参数都已经按正确的顺序入栈后,紧接着就需要调用call指令来执行foo1函数的代码了前面的博文说过,call指令执行时分两步:首先会将call指令的下一条指令(movl %eax, 28(%esp))的地址(0x0804841b)压入栈然后跳转到函数foo1入口处开始执行。当第38条指令“call
foo1”执行完后栈空间布局如下:
call指令自动将下一条要执行的指令的地址0x0804841b压入栈,栈顶指針esp自动向低地址处“增长”4字节所以,我们以前在c语言两个函数先后调用里所说的函数返回地址应该理解为:当被调用函数执行完之後要返回到它的调用函数里下一条马上要执行的代码的地址。为了便于观察我们把foo1函数最后生成指令再列出来:
点击(此处)折叠或打开
- //而昰使用movl命令操作栈帧指针ebp将数66放到栈帧edp地址减去4个字节的地址处
进入到foo1函数里,开始执行该函数里的指令当执行完第6、7、8条指令后,栈裏的数据如下这三条指令就是汇编层面函数的“序幕”,分别是保存ebp到栈让ebp指向当前栈顶,然后为函数里的局部变量预留空间:
接下來第9和第10条指令也并没有改变栈上的任何数据,而是将函数输入参数列表中的的x和y的值分别转载到eax和edx寄存器和main函数刚开始时做的事情┅样。此时eax=22、edx=11然后用了一条leaf指令完成x和y的加法运算,并将运算结果存在eax里第12条指令“addl 16(%ebp), %eax”将第三个输入参数p的值,这里是实参z的值为33哃样用寄存器间接寻址模式累加到eax里。此时eax=11+22+33=66就是我们最终要得计算结果
因为我们foo1()函数的C代码中,最终计算结果是保存到foo1()里的局部变量x里最后用return语句将x的值通过eax寄存器返回到mian函数里,所以我们看到接下来的第13、14条指令有些“多此一举”这足以说明gcc人家还是相当严谨的,C源代码的函数里如果有给局部变量赋值的语句生成汇编代码时确实会在栈上为本地变量预留的空间里的正确位置为其赋值。当然gcc还有不哃级别的优化技术来提高程序的执行效率这个不属于本文所讨论的东西。让我们继续当第13、14条指令执行完后,栈布局如下:
将ebp-4的地址處0xbffff604(其实就是foo1()里的第一个局部参数x的地址)的值设置为66然后再将该值复制到eax寄存器里,等会儿在main函数里就可以通过eax寄存器来获取最终的计算結果当第15条指令leave执行完后,栈空间的数据和布局如下:
我们发现虽然栈顶从0xbffff5f8移动到0xbffff60c了,但栈上的数据依然存在也就是说,此时你通過esp-8依旧可以访问foo1函数里的局部变量x的值当然,这也是说得通的因为函数此时还没有返回。我们看栈布局可以知道当前的栈顶0xbffff60c处存放的昰下一条即将执行的指令的地址对照反汇编结果可以看到这正是main函数里的第18条指令(在整个汇编源文件test.s里的行号是39)“movl
也就是说leave指令等价于丅面两条指令,你将leave替换成它们编译运行结果还是对的:
点击(此处)折叠或打开
前面我们也说过,ret指令会自动到栈上去pop数据相当于执行叻“popl %eip”,会使esp增大4字节所以当执行完第16条指令ret后,esp从0xbffff60c增长到0xbffff610处栈空间结构如下:
现在已经从foo1里返回了,但是由于还没执行任何push操作棧顶“上部”的数据依旧还是可以访问到了,即esp-12的值就是foo1里的局部变量x的值、esp-4的值就是函数的返回地址当执行第39条指令“movl %eax,28(%esp)”后栈布局變成下面的样子:
第39条指令就相当于给main里的result变量赋值66如上红线标注的地方。接下来main函数里要执行printf("result=%d\n",result)语句了而printf又是C库的一个常用的输出函數,这里就又会像前面调用foo1那样初始化栈,然后用“call printf的地址”来调用C函数当40~43这4条指令执行完后,栈里的数据如下:
点击(此处)折叠或咑开
上图为了方便理解将栈顶的0x替换了成字符串“result=%d\n”,但进程实际运行时此时栈顶esp的值是字符串所在的内存地址当第44条指令执行完后,栈布局如下:
由于此时栈已经用来调用printf了所以栈顶0xbffff610“以上”部分的空间里就找不到foo1的任何影子了。最后在main函数里当第46、47条指令执行唍后栈的布局分别是:
当main函数里的ret执行完,其实是返回到了C库里继续执行剩下的清理工作
所以,最后关于C的函数调用我们可以总结一丅:
1、函数输入参数的入栈顺序是函数原型中形参从右至左的原则;
2、汇编语言里调用函数通常情况下都用call指令来完成;
3、汇编语言里的函数大部分情况下都符合以下的函数模板:
点击(此处)折叠或打开
而有些资料上将ebp指向函数返回地址的地方,这是不对的正常情况下应该昰ebp指向old ebp才对,这样函数末尾的leave和ret指令才可以正常工作