C函数调用与栈
这篇blog试图说明这么一个问题,当一个c函数被调用时,一个栈帧(stack frame)是如何被建立,又如何被消除的。这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在Linux的gcc编译器而言的。c语言的标准并没有描述实现的方式。所以,不同的编译器、不同的操作系统都可能有自己的建立栈帧的方式。
下面先看一个典型的栈帧:
图1
上面个的这个图是一个典型的栈帧,图中,栈顶在上,地址空间往下增长。
在看看这个栈对应的函数代码:
int foo(int arg1, int arg2, int arg3);
并且假设foo有两个局部的int变量(各占4个字节).
在上面的栈帧对应的场景中,main调用foo,而程序的控制仍在foo中。这里,main是调用者(caller),foo是被调用者(callee)。
ESP被foo使用来指示栈顶,EBP相当于一个“基准指针”。从main传递到foo的参数以及foo本身的局部变量都可以通过这个基准指针为参考,加上偏移量找到。
寄存器要在栈中做好备份。在被调函数真正开始执行前,有个准备工作,就是它要用到哪些寄存器,但是里面的数据可能会有用,所以他要负责把寄存器里的数据push到栈里做一个copy,并且在执行返回指令前要恢复原来寄存器里的值。
这是原blog有关寄存器备份的讲解,我觉得有问题,所以改成了上面的。
由于被调用者允许使用EAX、ECX和EDX寄存器,所以如果调用者希望保存这些寄存器的值,就必须在调用子函数之前显式地把它们保存在栈中。另一方面,如果除了上面提到的几个寄存器,被调用者还想使用别的寄存器,比如EBX、ESI和EDI,那么,被调用者就必须在栈中保存这些被额外允许使用的寄存器,并在调用返回前恢复它们。也就是说,如果被调用者只使用约定的EAX、ECX和EDX寄存器,它们由调用者负责保存并恢复,但是如果被调用者还额外的使用了其他的寄存器,则必须有被调用者自己保存并恢复这些寄存器的值。
传递给foo的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。foo中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。
小于等于4个字节的返回值会被保存到EAX寄存器中,如果大于4字节,小于8字节,那么EDX也会被用来保存返回值。如果返回值占用的控件还要大,那么调用者会向被调用者传递一个额外的参数,这个额外的参数指向将要保存返回值的地址。用c语言来说,就是函数调用:
x = foo(a, b, c);
被转化为:
foo(&x, a, b, c);
注意,这仅仅在返回值占用大于8个字节时才发生。有的编译器不用EDX保存返回值,所以当返回值大于4个字节时,就用这种转换。
当然,并不是所有的函数调用都直接赋值给一个变量,还可能是直接参与到某个表达式的计算中,如:
m = foo(a, b, c) + foo(d, e, f);
又或者作为另外的函数的参数,如:
fooo(foo(a, b, c), );
这些情况下,foo的返回值会被保存在一个临时的变量中参加后续的运算。
让我们一步步地看一下在c函数调用过程中,一个栈帧是如何建立及消除的。
函数调用前调用者的动作
在前面的那个栈帧中,调用者是main,它准备调用函数foo。在函数调用前,main正在用ESP和EBP寄存器指示它自己的栈帧。首先,main把EAX、ECX和EDX压栈。这是一个可选的步骤,只在这三个寄存器内容需要保留的时候执行此步骤。
接着,main把传递给foo的参数一一进栈,最后的参数最先进栈。例如,这里的函数调用时:
a = foo(, , );
相应的汇编指令:
push dword
push dword
push dword
最后,main用call指令调用子函数:
call foo
当call指令执行的时候,EIP指令指针寄存器的内容被压入栈中。因为EIP寄存器是指向main中的下一条指令,所以现在返回地址就在栈顶了。在call指令执行完之后,下一个执行周期将从名为foo的标记处开始,进入了被调函数。
下图展示了call指令完成后栈的内容,图中红线位置是函数调用前栈顶指针的位置,最后我们可以看到,当整个函数调用过程结束后,栈顶指针又回到了这个位置。
看到调用函数EBP的压栈操作是被调函数完成的,在调用函数中的call指令把EIP的内容保存到返回地址栏位后,就转到被调函数执行了。在被调函数结束后,被调函数把EBP恢复到call指令时的样子,然后调用函数把返回地址栏位的值恢复到EIP寄存器中,继续执行下一条指令。
图2
被调函数在函数调用后的动作
当函数foo,也就是被调用者取得程序的控制权,它必须做3件事:
建立自己的栈帧,为局部变量分配空间,最后如果需要,保存寄存器EBX、ESI和EDI的值。
首先foo必须建立它自己的栈帧,EBP寄存器现在正指向main的栈帧中的位置,这个值必须被保留。因此,EBP进栈。然后ESP的内容赋值给了EBP。这使得函数的参数可以通过对EBP附加一个偏移量得到,而栈寄存器ESP便可以空出来做其他事情。如此一来,几乎所有的c函数都由如下两个指令开始:
push ebp
mov ebp, esp
此时的栈如下图所示,在这个场景中,第一个参数的地址是EBP加8,因为main的EBP和返回地址各在栈中占了4个字节。
图3
下一步,foo必须为它的局部变量分配内存空间,同时,也必须为它可能用到的一些临时变量分配空间。比如,foo中的一些c语句可能包括复杂的表达式,其子表达式的中间值就必须得有地方存放。这些存放中间值的地方都是临时的,因为他们可以为下一个复杂表达式所复用。为说明方便,我们假设foo函数中有两个int类型(每个4字节大小)的局部变量,需要额外的12个字节的临时存储空间,简单的把栈指针减去20就为这20个字节分配了空间:
sub esp,
现在,局部变量和临时存储都可以通过基础指针EBP加偏移量找到了。
最后,如果foo用到EBX、ESI和EDI寄存器,则它必须在栈里保存它们。结果如下图所示:
图4
foo的函数体现在可以执行了,这其中也许有进栈、出栈的动作,栈指针ESP也会上下移动,但EBP是保持不变的。这意味着我们可以一直用[EBP + 8]找到第一个参数,而不管在函数中有多少进出栈的动作。
函数foo的执行也许还会调用别的函数,甚至递归地调用foo本身。然而,只要EBP寄存器在这些子函数调用返回时被恢复,就可以继续用EBP加上偏移量的方式访问实际参数、局部变量和临时变量。
被调用者返回前的动作
在把程序控制权返还给调用者前,被调用者foo必须先把返回值保存在EAX寄存器中。我们前面已经讨论过,当返回值占用多于4个字节或8个字节时,就收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况下,被调用者直接通过内存拷贝把返回值直接copy到接收地址,从而省去了一次通过栈的中转copy。
其次,foo必须恢复EBX、ESI和EDI寄存器的值。如果这些寄存器被修改,正如前面说的,我们会在foo执行开始时把它们的原始值压入栈中。如果ESP寄存器指向上图所示的正确位置,寄存器的原始值就可以出栈并恢复了。可见,在foo函数的执行过程中正确的跟踪ESP是很重要的,也就是说,进栈和出栈操作的次数必须保持平衡。
这两步之后,我们不再需要foo的局部变量和临时存储了,可以通过下面的指令消除栈帧:
mov esp, ebp
pop ebp
其结果就是现在栈里的内容跟图2中所示的栈内容完全一样。现在可以执行返回指令了,从栈里弹出返回地址,赋值给EIP寄存器,栈如下图5所示:
图5
i386指令集有一条“leave”指令,它与上面提到的mov和pop指令所作的动作完全相同。所以,C函数通常以这样的指令结束:
leave
ret
调用者在返回后的动作
在程序控制权返回到调用者(也就是我们例子中的main)后,栈如图5所示。这时,传递给foo的参数已经不需要了,我们就可以把3个参数一起弹出栈,这个可以通过把栈指针加12(3*4个字节)实现:
add esp,
如果在函数调用前,EAX、ECX和EDX寄存器的值被保存在栈中,调用者main函数现在可以把它们弹出。这个动作之后,栈顶指针回到了我们开始整个函数调用过程前的位置,也就是下图的位置:
图6
总结一下:
任何函数的执行其实都是下面的5步:
(1)设置栈帧边界
(2)开辟本函数的局部区域
(3)保存寄存器的内容
(4)初始化局部区域(int3)
(5)如果有函数调用
(a)push实参入栈
(b)call执行,设置返回地址
(c)调整sp栈顶指针,删除实参
(6)恢复之前保存的寄存器的值
(7)取消栈帧的边界(main函数还要做一次校验)
所以开个玩笑的说:
函数的调用过程是4+2。
理论的知识先说这些,后面在具体的分析一个例子。
C函数调用与栈的更多相关文章
- C函数调用与栈--代码真相
前面详细的说了,C函数调用的过程中,栈的变化情况的原理部分,这里在看一下汇编代码的真正的实现. 有关前面的那一片博客,主要记住的就是函数调用时栈的变化,4+3+2的步骤: (1)设置栈帧边界 (2)开 ...
- C语言函数调用及栈帧结构
source:http://blog.csdn.net/qq_29403077/article/details/53205010 一.地址空间与物理内存 (1)地址空间与物理内存是两个完全不同的概念, ...
- 【.NET进阶】函数调用--函数栈
原文:http://www.cnblogs.com/rain-lei/p/3622057.html 函数调用大家都不陌生,调用者向被调用者传递一些参数,然后执行被调用者的代码,最后被调用者向调用者返回 ...
- 关于C语言函数调用压栈和返回值问题的疑惑
按照C编译器的约定调用函数时压栈的顺序是从右向左,并且返回值是保存在eax寄存器当中.这个命题本该是成立的,下面用一个小程序来反汇编观察执行过程: #include<stdio.h> in ...
- go语言调度器源代码情景分析之四:函数调用栈
本文是<go调度器源代码情景分析>系列 第一章 预备知识的第3小节. 什么是栈 栈是一种“后进先出”的数据结构,它相当于一个容器,当需要往容器里面添加元素时只能放在最上面的一个元素之上,需 ...
- 【Linux学习笔记】栈与函数调用惯例
栈与函数调用惯例(又称调用约定)— 基础篇 记得一年半前参加百度的校招面试时,被问到函数调用惯例的问题.当时只是懂个大概,比如常见函数调用约定类型及对应的参数入栈顺序等.最近看书过程中,重新回顾了这些 ...
- C语言函数调用时候内存中栈的动态变化详细分析(彩图)
版权声明:本文为博主原创文章,未经博主允许不得转载.欢迎联系我qq2488890051 https://blog.csdn.net/kangkanglhb88008/article/details/8 ...
- 从栈上理解 Go语言函数调用
转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com/archives/518 本文使用的go的源码 1.15.7 前言 函数调用类型 这篇文 ...
- lua 函数调用1 -- 闭包详解和C调用
这里, 简单的记录一下lua中闭包的知识和C闭包调用 前提知识: 在lua api小记2中已经分析了lua中值的结构, 是一个 TValue{value, tt}组合, 如果有疑问, 可以去看一下 一 ...
随机推荐
- 将 SQL Server 实例设置为自动启动(SQL Server 配置管理器)
本主题说明如何使用 SQL Server 配置管理器在 SQL Server 2012 中将 SQL Server 实例设置为自动启动. 在安装过程中,SQL Server 通常配置为自动启动. 如果 ...
- Android 三大图片缓存原理、特性对比
这是我在 MDCC 上分享的内容(略微改动),也是源码解析第一期发布时介绍的源码解析后续会慢慢做的事. 从总体设计和原理上对几个图片缓存进行对比,没用到他们的朋友也可以了解他们在某些特性上的实现. 上 ...
- php 配置文件
<?php return array( 'TMPL_L_DELIM'=>'<{', //配置左定界符 'TMPL_R_DELIM'=>'}>', //配置右定界符 'DB ...
- openStack开源云repo db local or on-line 实战部署之Ruiy王者归来
preface/pre,获取OpenStack核心模块组件及其子组件包(当前仅针对centos6*)及其依赖包 eg,picture
- 第八届河南省赛C.最少换乘(最短路建图)
C.最少换乘 Time Limit: 2 Sec Memory Limit: 128 MB Submit: 94 Solved: 25 [Submit][Status][Web Board] De ...
- fastDFS同步问题讨论
一.文件同步延迟问题 前面也讲过fastDFS同组内storage server数据是同步的, Storage server中由专门的线程根据binlog进行文件同步.为了最大程度地避免相互影响以及出 ...
- 让你提前知道软件开发(24):C语言和主要特征的发展史
文章1部分 再了解C语言 C语言的发展历史和主要特点 作为一门众所周知的计算机编程语言,C语言是谁发明的呢?它是怎样演进的?它有何特点?究竟有多少人在使用它? 1. C语言之父 C语言是1972年由美 ...
- 关于js封装框架类库之样式操作
在js中,对样式的操作我们并不感到陌生,在很多框架中都是用极少的代码,实现更强大的功能,在这做出一些的总结.存在不足还望指出! 1.封装一个添加css的方法(这篇引用了前面的框架结构) 在 js 中 ...
- 利用 squid 反向代理提高网站性能
http://www.ibm.com/developerworks/cn/linux/l-cn-squid/ http://www.squid-cache.org/ http://www.beijin ...
- oracle 获取系统时间(转)
Oracle中如何获取系统当前时间 select to_char(sysdate,'yyyy-mm-dd hh24:mi:ss') from dual; ORACLE里获取一个时间的年.季. ...