C函数调用机制与栈帧原理详解

嵌入式技术

1332人已加入

描述

当一个C函数被调用时,函数的参数如何传递、堆栈指针如何变化、栈帧是如何被建立以及如何被消除的,一直缺乏系统性的理解,因此决定花时间学习下函数调用时整个调用机制并总结成文,以便加深理解。本文将从汇编的角度讲解函数调用时,堆栈的变化,参数的传递方式、以及栈帧的建立和消除等方面知识。

这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在 Intel 至强处理器芯片上 Linux 的 gcc 编译器而言。C语言的标准并没有描述实现的方式,所以,不同的编译器,处理器,操作系统都可能有自己的建立栈帧的方式。

堆栈指针及相关寄存器

堆栈是操作系统中,最为常见的一种数据结构。严谨的说,堆栈是包括堆和栈两种数据结构的,但是我们通常说堆栈,其实就是指栈。在栈中,最重要的两个指针是 SP(栈指针) 和 BP(基址指针)。

SP(Stack Pointer) ,栈指针,在 32 位系统中,ESP(Extended SP) 寄存器存放的就是栈指针。在 64 位系统中,表现为 RSP 寄存器。SP 永远指向系统栈最上面一个栈帧的栈顶。所以 SP 是栈顶指针。(SP与ESP共用相同寄存器,SP是ESP的低16位,ESP是32位) BP(Base Pointer) ,基址指针,在 32 位系统中,EBP(Extended BP)寄存器存放的就是基址指针。在 64 位系统中,表现为 RBP 寄存器。BP 指向栈帧的底部,一般称之为栈底指针。(BP与EBP共用相同寄存器,BP是ESP的低16位,EBP是32位)

注:由于当下主要使用32位及以上寄存器,因此本文以32位寄存器讲解为主,下文亦主要使用ESP,EBP为例进行介绍。

这些指针及寄存器的作用到底是什么呢?ESP,指针即地址,存放栈顶指针,目的就是,下一次对栈操作的时候,系统可以及时找到栈的当前位置。举个例子来说,push 压入一个操作数,会在 esp - 4 的地址的内存空间,存入一个2个字长的操作数。EBP 的作用,会在下文讲述。

因本文后续可能会用到很多通用寄存器,为防止读者不懂寄存器的含义,这里小编整理了通用寄存器的功能和名称对应关系表,如忘记了可返回查看该表,表格如下图:

操作系统

函数调用汇编指令

一个函数调用另外一个函数,堆栈到底是怎么样变化的呢?ESP和EBP是如何变更的?函数形参又是如何传递的呢?后面我们会写一个简单的Demo程序,加深对堆栈相关寄存器的理解。在一个函数中调用另外一个函数,汇编指令往往有以下几个步骤:

汇编指令 指令归属函数 ESP 变化 作用
push arg3 主调函数 esp-4 将被调函数参数3压入栈中,供被调函数执行时使用。
push arg2 主调函数 esp-4 将被调函数参数2压入栈中,供被调函数执行时使用。
push arg1 主调函数 esp-4 将被调函数参数3压入栈中,供被调函数执行时使用。
call function 主调函数 esp-4 开始调用被调函数,同时保存返回地址。
push ebp 被调函数 esp-4 将主调函数的ebp中的基址值压入栈中,以便被调函数执行完毕后,恢复主调函数的基址到ebp。
mov ebp, esp 被调函数 无变化 将当前esp(esp此时指向本函数栈帧的栈底)的值存入ebp寄存器,目的是让被调函数的基址指针指向本函数的栈帧的栈底,后续可通过ebp来定位函数参数。
sub esp, #num 被调函数 esp-num 为被调函数分配栈空间
... 被调函数 ... 被调函数的具体实现逻辑
pop ebp 被调函数 esp+4 将栈中保存的主调函数的基址地址弹出栈,保存到ebp寄存器。
ret 被调函数 sp+4 将栈中保存的被调函数调用处的下一条指令的地址弹出栈并保存至eip寄存器,同时esp+4

说明

  • push arg 在调用一个函数之前,需要把传递的参数压入栈。每次 push 之后,栈多了2个字长(32 位系统 --> 4 字节),因此栈顶需要往上移动 4 字节,该指令暗含 sub esp, #4
  • call call 指令用来调用某个函数,该指令含有两个操作(1)将返回地址压入栈;(2)esp = esp - 4
  • push ebp; mov ebp, esp 这样的操作,会经常出现在各个函数反汇编的开头,保存上一个函数栈的基址,并更新本函数的基址
  • ret,即 return,此时 esp 应该指向 call 指令压入的返回地址;执行 ret 其实就是将此时栈中的数据弹出,存至 eip 寄存器。eip 存放的是当前被调用函数被调用位置处的下一条即将执行的指令的地址(即返回地址)。同时 esp = esp + 4

ret 指令相当于 pop eip; esp = esp + 4

call 指令相当于 push eip; esp = esp - 4

通过以上汇编代码及解释说明,你可能还是不能完全了解函数调用过程堆栈的变化,没关系,我们看下一个典型的栈帧是如何构成的,见下图。

操作系统图1

绿色表示调用函数的汇编指令和栈空间, 蓝色表示被调用函数的汇编指令和相应的栈空间。红色箭头表示通过被调用函数的ebp访问被调用函数的参数以及局部变量。

上图栈顶在下,栈底在上,栈空间由高地址向低地址增长。

如下函数的调用时堆栈变化即可用图1近似表示:

#include< stdio.h >
int func(int arg1, int arg2, int arg3)
{
     int x = 1;
     int y = 2;
     return (arg1 + arg2 + arg3);
}

int main()
{
    func(5,6,7);
    return 0;
}

func函数有两个局部int型局部变量(每个变量4字节)。在这个简化的场景中,main调用func,而程序的控制仍在func中。此处,main是调用函数(caller),func是被调用函数(callee)。

esp被func函数使用来表示栈顶。ebp相当于一个“基准指针”。从main传递到func的参数以及func函数本身的局部变量都可以以这个基准指针为参考,加上偏移量找到。

由于被调用函数也允许使用EAX,ECX和EDX寄存器,所以如果调用函数希望保存这些寄存器的值,就必须在调用被调用函数之前显式地将这些寄存器的值保存在栈中。另外,除了上面提到的几个寄存器,被调用函数还想使用其他别的寄存器,比如EBX,ESI和EDI,那么被调用函数就必须在栈中保存这些被使用的额外的寄存器,并且需要在调用返回前恢复他它们。换一句话说,即如果被调用函数只使用约定的EAX,ECX和EDX寄存器,它们则由调用函数负责保存并恢复;如果被调用函数还额外使用了别的寄存器,则必须由被调用函数自己保存并恢复这些寄存器的值。

传递给func的参数被压到栈中的顺序为最后一个参数先进栈,第二个参数其次进栈,第一个参数最后进栈。因此图1中,arg3比arg1先入栈。func函数中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都在保存在栈中。

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

x = func(i,j,k);

被转化为

func(&x,i,j,k);

上述情况仅仅在返回值占用空间超过8个字节时才会发生。有的编译器不用EDX保存返回值,所以当返回值大于4个字节时,就会用这种转换。

当然,不是所有的函数调用都是将返回值直接赋值给一个变量,还有可能是直接参与到某个表达式的计算中,如:

n = func(i,j,k) + func(x,y,z);

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

func(func(i,j,k),4);

这种情况下,func的返回值会被保存在一个临时的变量中参加后续的运算,所以func(i,j,k)还是可以被转化成func(&tmp,i,j,k)。

接下来,让我们一起看下在c函数的调用中,一个栈帧的建立以及消除过程。

函数调用前调用函数的动作

我们仍以上面例子为例,调用函数是main,它准备调用被调函数func。在函数调用前,main函数正在用esp和ebp寄存器指示它自己的栈帧。

首先,main函数把传递给func的参数压入栈中。不过,该步骤是可选的,只在这三个寄存器内容需要保留的时候执行此步骤。

紧接着,main函数会把传递给func的参数一一压入栈中,最后的参数最先进栈,第一个参数最后进栈。假如,我们的函数调用是:

x = func(5,6,7);

则对应的汇编语言指令如下:

push 0x7
push 0x6
push 0x5

最后,main函数用call指令调用被调函数

call func

如前面所说,call指令含有两个操作,首先是先将eip指令寄存器中的返回地址(即被调函数在被调用处的下一条指令的地址)压入栈中;其次是栈顶指针esp的值减4,即esp=esp-4。此时返回地址就在栈顶了。在call指令执行完毕以后,下一个执行周期将从名为func的标记处开始。

图2展示了call指令执行完以后栈的内容。图2以及后续图中的绿色粗虚线表示了被调用函数在被调用之前栈顶的位置。当整个func函数调用过程结束以后,栈顶将又会回到该位置。

操作系统图2

函数调用发生后被调用函数的动作

当函数func,即被调用函数取得程序的控制权,它必须做三件事:

  1. 建立它自己的栈帧
  2. 为局部变量分配空间
  3. 如果有必要,保存寄存器EBX,ESI和EDI的值

首先,func函数必须建立它自己的栈帧。ebp寄存器现在正在指向main函数的栈帧中的某个位置,这个值必须被保留,因此,ebp保存的值需要进栈,即push ebp。之后,就可以随意操作ebp寄存器了(因为ebp内保存的main的基址已入栈),此时,将esp的内容赋值给ebp,即mov ebp, esp;由图2我们可知,在调用func函数的过程中,原本指向main函数的栈顶指针,会随着EAX、ECX和EDX寄存器以及实际参数的入栈而不断发生变化,在call指令执行之后,此时esp栈顶指针已指向返回地址(被调用函数在被调用处的下一条指令的地址)位置,此时,func的ebp即和esp栈顶指针一样指向返回地址处。

当func函数建立它自己栈帧时,保留ebp寄存器内容(即main函数的ebp)的位置所对应的地址,即为func函数的基址,也就是func函数的ebp指向的地址。换一句话说,就是func函数的ebp指向的位置保存了main函数的ebp。

func的ebp寄存器在被esp赋值后,func函数的参数就可以通过对ebp附加一个偏移量得到,而栈顶寄存器就可以空出来做其它事情。如此一来,几乎所有的c函数都由如下两个指令开始:

push ebp
mov ebp,esp

此时,堆栈分布如图3所示。在该场景中,第一个参数的地址是ebp+8,因为main的ebp和返回地址各在栈中占了4个字节。

操作系统图3

接下来,func必须为它的局部变量分配栈空间,与此同时,也必须为它可能会用到的一些临时变量分配栈空间。比如func中可能包括一些复杂的表达式,其子表达式的中间值就必须得有地方存放。这些存放中间值的地方统称为临时的,因为它们可以被下一个复杂的表达式所使用。为方便说明,我们假设func中有两个int类型(每个4字节)的局部变量,同时,需要额外的2字节的临时存储空间,则可以简单地把栈顶指针减去10便为这10个字节分配了栈空间,汇编指令如下:

sub esp,10

此时,局部变量以及临时变量都可以通过基址指针ebp加上偏移量来找到了。

最后,如果func函数用到EBX,ESI和EDI寄存器,则它必须在自己的栈帧里保存它们。如下图4所示:

操作系统图4

func函数的函数体现在可以执行了。这其中可能包含进栈、出栈的动作,栈指针esp会上下移动,但ebp则是保持不变的。这也就表示我们可以一直使用[esp+8]找到第一个参数,而不需要管函数中有多少进出栈的动作。

函数func执行过程中也许还会调用其它函数,甚至递归地调用func自身。然而只要ebp寄存器在这些子调用返回时被恢复,就可以继续用ebp加上偏移量的方式访问实际参数、局部变量以及临时变量。

被调用函数返回前的动作

func函数把程序控制权返回给调用函数之前,被调用函数func必须先把返回值保存在EAX寄存器中。正如前面所讨论过的,当返回值占用多于4个或8个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数func中,而函数func本身就不需要返回值了。这种情况下,被调用函数直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。

其次,func必须恢复EBX,ESI和EDI寄存器的值。如果这些寄存器被修改,正如前面所说,我们会在func执行开始时把它们的原始值压入栈中。如果esp寄存器指向如图4所示的正确位置,寄存器的原始值即可出栈并恢复。由此可见,func函数执行过程中正确地跟踪esp是多么重要,也即进栈和出栈的次数必须保持平衡。

上面两步之后,我们便不再需要func函数的局部变量和临时变量了,我们可以通过下面的指令消除栈帧:

mov esp,ebp
pop ebp

上面执行后的结果就是栈里面的内容跟图2中所示的栈完全一样。

现在可以执行返回指令了。从栈里弹出返回地址,赋值给eip寄存器。栈如图5所示:

操作系统图5

i386指令集有一条"leave"指令,它与上面提到的mov和pop指令所做的动作完全相同。所以c函数通常以这样的指令结束:

leave
ret

这样被调用函数执行完毕,下一步将继续执行被调用函数调用处的下一条的指令。但是此时, esp 指向原先的 arg1,并没有指向原先主函数的栈顶 。如果原先栈中还有其他数据,esp 没有归位会导致调用函数引用栈中数据出错。

堆栈平衡

在这种背景下,出现了堆栈平衡的概念。即,还需对esp进行单独操作,才能将esp指向调用函数的栈顶。以常见的c语言,函数有好几种调用规则。比如 cdecl 方式和 stdcall 方式。

cdecl 方式中,由调用函数执行 add esp, n 指令调整 esp,达到堆栈平衡。在 stdcall 方式中,由被调用函数在返回时,执行 ret n 平衡堆栈。n 其实就是函数的参数所占的空间大小。

在程序控制权又返回到调用函数(即我们例子中的main函数)后,栈如图5所示。这时传递给func的参数通常已经不需要了。我们可以把3个参数一起弹出栈,这可以通过把栈顶指针加0xc(即3个4字节)实现:

add esp,0xc

如果在函数调用前,EAX,ECX和EDX寄存器的值被保存在栈中,调用函数main现在则可以把它们弹出栈。在这个动作以后,栈顶就回到了我们开始整个函数调用前的位置,也就是图5中绿色粗线的位置。

实例演示

C函数源码和前面一样,如下:

#include< stdio.h >
int func(int arg1, int arg2, int arg3)
{
     int x = 1;
     int y = 2;
     return (arg1 + arg2 + arg3);
}

int main()
{
    func(5,6,7);
    return 0;
}

执行编译指令如下:

#gcc test.c -m32 -o test 
In file included from /usr/include/features.h:462,
                 from /usr/include/bits/libc-header-start.h:33,
                 from /usr/include/stdio.h:27,
                 from test.c:2:
/usr/include/gnu/stubs.h:7:11: fatal error: gnu/stubs-32.h: No such file or directory
 # include < gnu/stubs-32.h >
           ^~~~~~~~~~~~~~~~
compilation terminated.

我们会看到编译报错,让我们先解析一下gcc编译参数, -m32表示生成32位的代码,如果没有-m32,则会生成跟操作系统位数一致的代码。

经过检索得知,64位机器由于缺少32位兼容包,所以在编译32代码时,会报错,通过如下指令安装开发包即可解决:

#sudo yum -y install glibc-devel.i686

注意小编是用的CentOS机器,如果是Ubuntu机器,则可通过如下命令安装:

#sudo apt-get install libc6-dev-i386

安装完开发包以后,则编译不会再报错,编译成功后,使用 objdump 或者 ida 查看汇编代码,可以看出,默认使用 cdecl 方式平衡堆栈。经过反汇编得到部分截图如下:

操作系统

部分汇编源码如下:

080484ad < func >:
 80484ad:       55                      push   ebp
 80484ae:       89 e5                   mov    ebp,esp
 80484b0:       83 ec 10                sub    esp,0x10
 80484b3:       c7 45 fc 01 00 00 00    mov    DWORD PTR [ebp-0x4],0x1
 80484ba:       c7 45 f8 02 00 00 00    mov    DWORD PTR [ebp-0x8],0x2
 80484c1:       8b 55 08                mov    edx,DWORD PTR [ebp+0x8]
 80484c4:       8b 45 0c                mov    eax,DWORD PTR [ebp+0xc]
 80484c7:       01 c2                   add    edx,eax
 80484c9:       8b 45 10                mov    eax,DWORD PTR [ebp+0x10]
 80484cc:       01 d0                   add    eax,edx
 80484ce:       c9                      leave
 80484cf:       c3                      ret

080484d0 < main >:
 80484d0:       8d 4c 24 04             lea    ecx,[esp+0x4]
 80484d4:       83 e4 f0                and    esp,0xfffffff0
 80484d7:       ff 71 fc                push   DWORD PTR [ecx-0x4]
 80484da:       55                      push   ebp
 80484db:       89 e5                   mov    ebp,esp
 80484dd:       51                      push   ecx
 80484de:       83 ec 04                sub    esp,0x4
 80484e1:       6a 07                   push   0x7
 80484e3:       6a 06                   push   0x6
 80484e5:       6a 05                   push   0x5
 80484e7:       e8 c1 ff ff ff          call   80484ad < func >
 80484ec:       83 c4 0c                add    esp,0xc
 80484ef:       83 ec 08                sub    esp,0x8
 80484f2:       50                      push   eax
 80484f3:       68 9c 85 04 08          push   0x804859c
 80484f8:       e8 53 fe ff ff          call   8048350 < printf@plt >
 80484fd:       83 c4 10                add    esp,0x10
 8048500:       b8 00 00 00 00          mov    eax,0x0
 8048505:       8b 4d fc                mov    ecx,DWORD PTR [ebp-0x4]
 8048508:       c9                      leave
 8048509:       8d 61 fc                lea    esp,[ecx-0x4]
 804850c:       c3                      ret
 804850d:       66 90                   xchg   ax,ax
 804850f:       90                      nop
                xchg   ax,ax

注1:

lea,官方解释Load Effective Address,即装入有效地址的意思,它的操作数就是地址;常见几种用法:

1)、lea eax,[addr]

就是将表达式addr的值放入eax寄存器,如:lea eax,[401000h]; 将值401000h写入eax寄存器中;lea指令右边的操作数表示一个精指针,上述指令和mov eax,401000h是等价的。

2)lea eax,dword ptr [ebx];将ebx的值赋值给eax。

3)lea eax,c;其中c为一个int型的变量,该条语句的意思是把c的地址赋值给eax。

注2:

dword:双字,就是四个字节

ptr:pointer缩写 即指针

[]里的数据是一个地址值,这个地址指向一个双字型数据 比如mov eax, dword ptr [12345678] 把内存地址12345678中的双字型(32位)数据赋给eax。

到此,相信小伙伴们就可以按照前文的讲解,看懂最后的函数汇编源码了,为检测学习成果,请对照前文所讲,自行翻译最后的汇编源码。

最后,借用一句话送给大家:"纸上得来终觉浅,绝知此事要躬行"。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分