C函数的调用过程原理和栈分析

函数调用过程(带图详解) 函数调用我们大家都很熟悉,我们都知道函数调用是发生在上的。首先,我们来了解一下程序对内存使用的分区情况: 区域 作用 区(stack) 由编译器自动分配释放,存放函数的参数值,局部变量的值等。操作方式类似与数据结构中的 堆区(heap) 一般由程序员分配释放,若程序员不释放,程序结束时可能由操作系统回收。与数据结构中的堆是两码事,分配方式类似于链表 静态区(sta 阅读详情

  在编程中,相信每个人对函数都不陌生,那么你真正理解函数的调用过程吗?当一个c函数被调用时,一个栈帧(stack frame)是如何被建立,又如何被消除的。本文主要就是来解决这些问题的,不同的操作系统和编译器可能有所不同,本文主要介绍在linux下的gcc编译器。

栈帧

  我们先来看一下,一个典型的栈帧的样子:
  这里写图片描述 
  首先介绍一下这里面非两个重要的指针:ebp和esp;
   ebp(base pointer )可称为“帧指针”或“基址指针”,其实语意是相同的。在未受改变之前始终指向栈帧的开始,也就是栈底,所以ebp的用途是在堆栈中寻址用的。

esp(stack pointer)可称为“ 栈指针”。 esp是会随着数据的入栈和出栈移动的,也就是说,esp始终指向栈顶。

  了解内存结构的伙伴肯定知道,从上往下来说,地址从高向低,栈位于内核态之下,是向下生长的,所谓向下生长是指从内存高地址->低地址的路径延伸,那么就很明显了,栈有栈底和栈顶,那么栈顶的地址要比栈底低。
  在了解了栈帧的结构之后,下面我们就来看一看函数的调用过程及栈帧的变化。

函数调用过程

比如说main函数中有如下函数:

int func(int a , int b); 

  func有两个局部的int变量。这里,main是调用者,func是被调用者。
  ESP被func使用来指示栈顶。EBP相当于一个“基准指针”。从main传递到func的参数以及func函数本身的局部变量都可以通过这个基准指针为参考,加上偏移量找到。
  由于被调用者允许使用EAX,ECX和EDX寄存器,所以如果调用者希望保存这些寄存器的值,就必须在调用子函数之前显式地把他们压栈,保存在栈中。另一方面,如果除了上面提到的几个寄存器,被调用者还想使用别的寄存器,比如EBX,ESI和EDI,那么,被调用者就必须在栈中保存这些被额外使用的寄存器,并在调用返回前恢复他们。也就是说,如果被调用者只使用约定的EAX,ECX和EDX寄存器,他们由调用者负责保存(push)并恢复(pop),但如果被调用这还额外使用了别的寄存器,则必须有他们自己保存并回复这些寄存器的值。

函数的入参

  传递给func的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。所以说函数是从右往左进行参数的入栈的,这和变长参数有关。此外,func中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。

返回值

  小于等于4个字节的返回值会被保存到EAX中,如果大于4字节,小于8字节,那么EDX也会被用来保存返回值。如果返回值占用的空间还要大,那么调用者会向被调用者传递一个额外的参数,这个额外的参数指向将要保存返回值的地址。用C语言来说,就是函数调用:

x = foo(a, b, c);  被转化为: func(&x, a, b, c);

  注意,这仅仅在返回值占用大于8个字节时才发生。有的编译器不用EDX保存返回值,所以当返回值大于4个字节时,就用这种转换。
  当然,并不是所有函数调用都直接赋值给一个变量,还可能是直接参与到某个表达式的计算中,如:

m = foo(a, b, c) + foo(d, e, f);

有或者作为另外的函数的参数, 如:

fooo(foo(a, b, c), 3);

 这些情况下,foo的返回值会被保存在一个临时变量中参加后续的运算,所以,foo(a, b, c)还是可以被转化成

foo(&tmp, a, b, c)。

调用过程

假设函数A调用函数B,我们称A函数为”调用者”,B函数为“被调用者”则函数调用过程可以这么描述:

(1)先将调用者(A)的堆栈的基址(ebp)入栈,以保存之前任务的信息,函数返回之后可以继续执行之前的逻辑。
(2)然后将调用者(A)的栈顶指针(esp)的值赋给ebp,作为新的基址(即被调用者B的栈底)。
(3)然后在这个基址(被调用者B的栈底)上开辟(一般用sub指令)相应的空间用作被调用者B的栈空间,进行函数入参的压栈等操作。
(4)函数B返回后,从当前栈帧的ebp即恢复为调用者A的栈顶(esp),使栈顶恢复函数B被调用前的位置;然后调用者A再从恢复后的栈顶可弹出之前的ebp值(可以这么做是因为这个值在函数调用前一步被压入堆栈)。这样,ebp和esp就都恢复了调用函数B前的位置,也就是栈恢复函数B调用前的状态。

下面,让我们一步步地看一下在c函数调用过程中,一个栈帧是如何建立及消除的。

函数调用前调用者的动作

  在上面的示例中,调用者是main,它准备调用函数func。在函数调用前,main正在用ESP和EBP寄存器指示它自己的栈帧。
  首先,main把EAX,ECX和EDX压栈。这是一个可选的步骤,只在这三个寄存器内容需要保留的时候执行此步骤。
  接着,main把传递给func的参数一一进栈,最后的参数最先进栈。
  最后,main用call指令调用子函数:call func。

  当call指令执行的时候,EIP指令指针寄存器的内容会先被压入栈中。因为EIP寄存器是指向main中的下一条指令,所以现在返回地址就在栈顶了。在call指令执行完之后,下一个执行周期将从名为foo的标记处开始。
图2展示了call指令完成后栈的内容。图2及后续图中的粗线指示了函数调用前栈顶的位置。我们将会看到,当整个函数调用过程结束后,栈顶又回到了这个位置。
这里写图片描述

被调用者在函数调用后的动作

  ①、建立它自己的栈帧,
  ②、为局部变量分配空间
  ③、如果函数中需要使用寄存器EBX,ESI和EDI,则压栈保存寄存器的值,出栈时恢复。

此时栈空间如下:
这里写图片描述
具体过程如下:
  首先被调用的函数必须建立它自己的栈帧。EBP寄存器现在正指向main的栈帧中的某个位置,这个值必须被保留,因此,EBP进栈。然后ESP的内容赋值给了EBP。这使得函数的参数可以通过对EBP附加一个偏移量得到,而栈寄存器ESP便可以空出来做其他事情。第一个参数的地址是EBP加8,因为main的EBP和返回地址各在栈中占了4个字节。
  下一步,被调用的函数必须为它的局部变量分配空间,同时,也必须为它可能用到的一些临时变量分配空间。比如,foo中的一些C语句可能包括复杂的表达式,其子表达式的中间值就必须得有地方存放。这些存放中间值的地方同城被称为临时的,因为他们可以为下一个复杂表达式所复用。
  最后,如果foo用到EBX,ESI和EDI寄存器,则它f必须在栈里保存它们。

被调用者返回前的动作

  被调用的函数返回前,必须先把返回值保存在EAX寄存器中。当返回值占用多于4个或8个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况下,被调用者直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。
  其次,被调用的函数必须恢复EBX,ESI和EDI寄存器的值。如果这些寄存器被修改,正如我们前面所说,我们会在foo执行开始时把它们的原始值压入栈中。
  这两步之后,我们不再需要foo的局部变量和临时存储了,我们可以通过下面的指令消除栈帧:

mov esp, ebp
pop ebp

  最后直接执行返回指令。从栈里弹出返回地址,赋值给EIP寄存器。

调用者在返回后的动作

  在程序控制权返回到调用者后,传递给被调函数的参数已经不需要了。我们可以把所有个参数一起弹出栈,实现堆栈平衡。
  如果在函数调用前,EAX,ECX和EDX寄存器的值被保存在栈中,调用者main函数现在可以把它们弹出。这个动作之后,栈顶就回到了我们开始整个函数调用过程前的位置。

  至此,函数的调用过程就已经分析完毕了。下面,看个具体的实例:

实例

源代码

c源码:

#include <stdio.h>

int add(int a , int b)
{
    int c = a + b;

    return c;
}

int main()
{
    int result = 0;
    result = add(1 , 2);

    printf("%d\n",result);

    return 0;
}

  在linux下,通过: gcc -S test.c -o test.s 命令将源文件编译成汇编文件,若对c语言的编译过程感兴趣的可以看我的博文c程序编译全过程
  相应的汇编代码如下:

汇编代码

    .file   "test.c"
    .text
    .globl  add
    .type   add, @function
add:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    %edi, -20(%rbp)
    movl    %esi, -24(%rbp)
    movl    -24(%rbp), %eax
    movl    -20(%rbp), %edx
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   add, .-add
    .section    .rodata
.LC0:
    .string "%d\n"
    .text
    .globl  main
    .type   main, @function
main:
.LFB1:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movl    $0, -4(%rbp)
    movl    $2, %esi
    movl    $1, %edi
    call    add
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1:
    .size   main, .-main
    .ident  "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-28)"
    .section    .note.GNU-stack,"",@progbits

  在linux下的汇编代码和windows下的有些许差别,但依然类似,比如:windows下的push就是linux下的pushp,不过linux下的源操作数在左边,目的操作数在右边。在linux下,%开头表示寄存器,$开头表示立即数;在一个汇编函数的开头和结尾, 分别有.cfi_startproc和.cfi_endproc标示着函数的起止。
  下面,分别对main函数和被调函数的执行过程分析:

main:函数段
函数调用前调用者的动作

主要汇编代码如下:

    pushq   %rbp               #rbp入栈 ,保存main的栈帧中的某个位置
    movq    %rsp, %rbp         #ESP的内容赋值给了EBP。解放esp用于指向栈顶
    movl    $2, %esi           #参数放入寄存器
    movl    $1, %edi
    call    add               #调用add函数
被调函数的动作

主要汇编代码如下

    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -20(%rbp)
    movl    %esi, -24(%rbp)
    movl    -24(%rbp), %eax
    movl    -20(%rbp), %edx
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    popq    %rbp
    ret
函数调用后调用者的动作
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    ret
函数调用的基本原理 转自 程序从main函数开始顺序执行,函数调用可以看做是一个无条件跳转,跳转到对应函数的指令处开始执行,碰到return语句或者函数结尾的时候,再执行一次无条件跳转,跳转会调用方,执行调用函数后的下一条指令。存在问题: 参数如何传递? 函数如何知道返回到什么地方?在if/else, for中,跳转的地址都是确定的,但函数自己并不知道会被谁调用,而且可能会被很多地方调用,它并不能提前知道执行结束后返 阅读详情

相关推荐

函数调用原理总结

函数调用是个很有意思的东西,之前一直都很好奇:函数调用结束后是怎么知道返回什么地方?函数中的各个参数又是从哪来的呢?调用结束后又怎么将相关数据返回到调用方的?基于这些疑问,参阅很多资料,现在把自己所理解的总结一下。 实际上,函数调用是在上进行的,的生长方向是由高地址向低地址,即底为高地址,顶为低地址。每个函数在被调用时都对应着各自一个帧,用来记录函数自身的一些信息(返回地址、局部变量…...

HerofH_的博客 3279

函数深入解析(1) - C语言函数

C语言函数是程序运行时用于管理函数调用的一种后进先出内存结构,每个函数调用时会在顶分配一个帧,用于存储局部变量、函数参数、返回地址以及保存的寄存器信息。

1435

C生万物 | 反汇编深挖【函数帧】的创建销毁

从汇编角度深度挖掘函数帧建立销毁的全过程,保姆式教学,超详细解说

做技术人 · 产优质博客 · 找好工作 4047

函数帧详解

函数帧详解

m0_74268677的博客 2555

c语言函数调用过程原理函数分析

是什么 (1)简单来说,是一种LIFO形式的数据结构,所有的数据都是后进先出。这种形式的数据结构正好满足我们调用函数的方式: 父函数调用函数,父函数在前,子函数在后;返回时,子函数先返回,父函数后返回。支持两种基本操作,pushpop。push将数据压入中,pop将中的数据弹出并存储到指定寄存器或者内存中。 (2)的生长方向是从高地址到低地址的,这是因为在下文讲的帧中,就是

yishizuofei的博客 5583

C函数调用原理----

函数调用原理----帧 1.关于 首先必须明确一点也是非常重要的一点,是向下生长的,所谓向下生长是指从内存高地址->低地址的路径延伸,那么就很明显了,顶,那么顶的地址要比底低。对x86体系的CPU而言,其中 ---> 寄存器ebp(base pointer )可称为“帧指针”或“基址指针”,其实语意是相同的。 ---> 寄存器esp(stack poi

rengui1228的博客 634

函数帧的销毁建立

在上一篇介绍的递归文章中,提到了溢出,那么什么是函数帧,它有什么用,又该如何创建销毁呢?下面让我们一起来揭开它的面纱。我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都函数帧有关系。函数参数函数返回值临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。(stack)

z317920的博客 811

【C语言函数帧的创建与销毁

救命太难了!!

恒等于C的博客欢迎您 1074

【C语言】——函数帧的创建与销毁

函数帧的创建与销毁,逐语句分析

dab112的博客 1365

【C语言函数帧的创建与销毁】

详细介绍函数帧创建与销毁的过程,超多图解

weixin_69380220的博客 1159

函数帧的创建销毁

函数帧的创建销毁 1、什么是函数帧 我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数是如何调用的?函数的返回值是如何带回的?函数参数是如何传递的?这些问题都函数帧有关系。 函数帧(stack frame)就是函数调用过程中在程序的调用(call stack)所开辟的空间,这些空间是用来存放: 函数参数函数返回值 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量) 保存上下文信息(包括在函数调用前后需要保持不变的寄存器.

Yuan_o_的博客 1049

一文带你深入了解函数帧的创建销毁

深入解剖函数帧的创建形成,带你玩转函数

m0_68931081的博客 579

函数帧的创建销毁】(超详细图解)

函数帧的创建与销毁】

m0_63635730的博客 3355

操作系统系列(四)——函数调用关系

本期主题: 函数调用关系1.什么是2.并发的主要场景1.对称多处理器(SMP)的多个CPU2.单CPU内的进程与抢占进程3.中断与进程3.面对竞态的常用方法1.中断屏蔽2.原子操作3.自旋锁4.信号量5.互斥体 1.什么是 并发是指多个执行单元同时、并行的被执行; 并发时,执行单元的共享资源的访问(硬件资源、软件变量等)会导致竞态; 例如两个进程都打开了串口设备,并且同时往串口发送缓存中写入不同的值,这样就会导致竞态。 2.并发的主要场景 1.对称多处理器(SMP)的多个CPU SMP是一种

weixin_37620587的博客 2424

C语言加餐——神秘的函数帧的创建销毁

接下来我会持续更新C语言数据结构、Java语法、Java数据结构、MySQL、JavaEE、微服务、Redis等等内容的知识点整理。后续我也会精心制作算法解析、项目经验系列内容,内容绝对干货。相信这些文章能够成为我大家的“葵花宝典”,喜欢的话就关注一下吧!敬请期待!

2502_91380778的博客 1146

函数调用分析

函数调用过程帧变化详解 函数调用过程带图详解 C函数调用过程 帧 C函数调用过程原理函数分析帧与函数调用过程分析 指针&& 帧指针详解 从汇编的角度深入理解C函数调用背后的各种细节 通过汇编程序看函数调用过程中的原理 ...

u014044624的博客 188

线程-函数调用

引言 本专题是手动实现一个“线程”, 写这个项目的起因主要是为了为学习操作系统的人而准备的练手巩固线程进程调用的知识. 而这个小项目是从我所写的玩具操作系统中抽离出来再加以改善的, 所以并不乎涉及更加底层的东西, 毕竟我也不会. 前言 在讲解小项目之前要先明白什么是函数调用? 函数又是怎么实现调用并返回到调用函数函数体中? 可能你不明白为什么线程会跟函数调用有关, 那是因为我们实现的功能只是...

啦啦啦 2162
上一篇: 深度剖析c语言main函数---main函数的执行顺序
下一篇: MySQL删除表操作(delete、truncate、drop的区别)
z_ryan
博客等级 码龄9年 255粉丝 43原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值