函数调用的底层原理

转自:https://blog.csdn.net/z_ryan/article/details/81029195 

另外:https://www.cnblogs.com/ysocean/p/7625917.html  这文章也讲的必将详细

在编程中,相信每个人对函数都不陌生,那么你真正理解函数的调用过程吗?当一个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


 

  • 4
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
函数调用是编程中的一个基本操作,它允许程序在不同的代码块之间进行切换,并传递参数和执行不同的任务。函数调用的底层实现原理涉及到许多复杂的计算机科学概念,包括内存管理、栈、寄存器、调用约定等。 在底层实现中,函数调用通常涉及以下几个步骤: 1. **参数传递**:当一个函数被调用时,它的参数会被压入调用函数的栈帧中。这些参数包括输入和输出参数,以及局部变量。 2. **代码执行**:当函数开始执行时,控制权会转移到该函数的代码上。这个过程通常涉及到将程序的执行上下文(包括寄存器的内容、内存中的数据等)保存到栈帧中,以便函数执行完毕后可以恢复这些信息。 3. **返回地址保存**:当函数执行完毕并准备返回时,它会将程序计数器的当前值(即下一条要执行的指令的地址)保存到一个特殊的寄存器(通常是EIP)中,以便函数可以返回调用它的代码。 4. **返回**:函数执行完毕后,会从栈帧中取出返回地址(通常是EIP),然后跳转到这个地址继续执行程序。此时,函数调用就完成了。 这个过程在许多不同的编程语言中都是相似的,但是实现方式可能会有所不同。具体实现会取决于所使用的编程语言和操作系统,以及硬件架构(如x86、ARM等)。此外,不同的编译器和运行时环境可能会有不同的调用约定,这也会影响函数调用的底层实现。 值得注意的是,函数调用的底层实现通常涉及到许多底层的细节和复杂性,对于大多数编程任务来说并不需要了解这些细节。如果你对这方面的知识感兴趣,可以进一步学习计算机体系结构和操作系统课程,以了解更多关于函数调用和程序执行的基础知识。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值