栈帧结构与函数调用约定

栈帧结构与函数调用约定

栈,是一种先入后出的数据结构,就像咱们堆放书籍同样,先放的在最底下,后放置的在顶上,当咱们要取的时候就是拿最上面一本,即最后放置的那一本。即FILO(first in last out)。程序员

对大多数的应用程序员来讲,栈就是这么一个数据结构的概念,而对于嵌入式工程师来讲,栈还表明着另外一种举足轻重的角色,今天咱们就来聊一聊内存中的栈结构。编程

什么是栈?

在早期的计算机系统中,事实上是没有栈这个概念的,内存中栈的设计是因为过程式语言的面世。缓存

CPU寄存器是CPU内部的存储设备,而内存是独立于CPU以外的存储设备,存储着数据、程序等等,当CPU须要执行程序时,将内存中的数据读入寄存器,而后执行相应指令,寄存器是内存与CPU之间交互的桥梁,寄存器中缓存着须要执行程序的数据或者指令地址等等。这种执行模式一直持续到过程式语言被创造以前。数据结构

后来逐渐有了Fortran、C语言的面世,函数的概念被提出,这致使一个什么结果呢?架构

当一个函数调用另外一个函数时,势必涉及到保存调用函数的状态保存,由于在调用完成以后必需要可以原封不动地还原调用函数的状态,继续往下执行函数,并且每每这个调用过程是递归的,可是这些数据存在哪里呢?编程语言

显然,光靠寄存器是不够的,经典的8086才8个16位通用寄存器。在这个时候,人们就想到了是否是能够在内存中开辟出一部分专门用来存储这些函数调用的中间数据,答案固然是能够的,虽然比起寄存器的访问速度,内存访问的效率明显低不少,可是这也是没有办法的办法。函数

可是问题又来了,这部份内存采起什么样的管理结构呢?优化

咱们来模拟一下调用过程,当A调用B时,在B中又调用了函数C,当函数C返回时,清除C的信息,返回到B的执行状态中,当B执行完以后,清除B的信息,返回到A中。整个过程是这样的:this

咱们能够看到,执行顺序为:A->B->C,而返回顺序是C->B->A,因为内存是线性空间,很显然这是一个栈的结构,先进后出,因此栈就应运而生。spa

为了支持函数调用而在内存中开辟出来的一段空间,这个数据空间的规则是先进后出。

那么,既然是为了支持函数调用而产生的,它究竟是以一个怎样的方式来支持函数调用的呢?

栈到底在哪里?

既然栈是内存中开辟出来的空间,那么咱们是怎么指定它的位置的?

在经典操做系统中,栈老是向下生长的,向下生长意味着由高地址向低地址延伸,当数据压栈时,栈向低地址延伸,当从栈中弹出数据时,栈缩回高地址。

CPU有一个内部寄存器esp专门用来存储栈顶位置,随着栈的伸缩而改变,始终指向栈顶,由这个寄存器咱们就能够访问当前栈中的内容。

还有一个寄存器ebp,这个ebp寄存器存储的则是当前执行函数的栈基地址(下面还会详细讲到)。

栈帧结构

从上一部分咱们知道了怎么定位栈的地址,esp和ebp两个寄存器定位栈活动记录的方式就叫栈帧结构,如今咱们以一个简单的例子来分析一下栈帧结构:

int func(int x,int y)
{
    int a=x,b=y;
    return a+b;
}
int main()
{
    int c = func(3,4);
    return c;
}

事实上光是简单地看函数实现是看不出什么东西的,咱们必须得从底层来分析,最好的方式就是直接看汇编代码的实现。

在这里咱们生成汇编代码:

gcc -g test.c -o test
objdump -S test > asm_file

这样咱们就能够直接在asm_file中查看反汇编代码,为何不直接使用gcc -S test.c直接编译生成汇编代码呢?其实这是由于格式问题,objdump生成的代码更清晰,并且也嵌入了源代码作对应。下面就是部分主要的汇编(注1)

int func(int x,int y)
{
1        4004d6:    55                      push   %rbp
2        4004d7:    48 89 e5                mov    %rsp,%rbp
3        4004da:    89 7d ec                mov    %edi,-0x14(%rbp)
4        4004dd:    89 75 e8                mov    %esi,-0x18(%rbp)
5            int a=x,b=y;
6        4004e0:    8b 45 ec                mov    -0x14(%rbp),%eax
7        4004e3:    89 45 f8                mov    %eax,-0x8(%rbp)
8        4004e6:    8b 45 e8                mov    -0x18(%rbp),%eax
9        4004e9:    89 45 fc                mov    %eax,-0x4(%rbp)
10            return a+b;
11       4004ec:    8b 55 f8                mov    -0x8(%rbp),%edx
12        4004ef:   8b 45 fc                mov    -0x4(%rbp),%eax
13        4004f2:   01 d0                   add    %edx,%eax
    }
14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq   
    int main()
   {
16        4004f6:   55                      push   %rbp
17        4004f7:   48 89 e5                mov    %rsp,%rbp
18        4004fa:   48 83 ec 10             sub    $0x10,%rsp
19            int c = func(3,4);
20        4004fe:   be 04 00 00 00          mov    $0x4,%esi
21        400503:   bf 03 00 00 00          mov    $0x3,%edi
22        400508:   e8 c9 ff ff ff          callq  4004d6 <func>
23        40050d:   89 45 fc                mov    %eax,-0x4(%rbp)
24            return c;
25        400510:   8b 45 fc                mov    -0x4(%rbp),%eax
    }

在上述的汇编代码中,第一列是代码地址,第二列是机器指令,这一部分咱们不须要关注,而第三四列就是汇编指令。这里反汇编出来的汇编指令是AT&T格式的,与咱们常学的intel格式的汇编指令有所区别,这里咱们须要知道源操做单元的目标操做单元与intel格式中是相反的。

汇编代码分析

接下来咱们从main函数开始逐行分析这些汇编指令,为了方便讲解,我人为地为它们添加了行号。

保存及切换栈帧

咱们先看main()最前两行:

16  4004f6:   55                        push   %rbp
17  4004f7:   48 89 e5                  mov    %rsp,%rbp

几乎每个函数的前两条指令都是这两个语句,恰好这条汇编指令就代表了栈帧结构的核心内容,咱们能够先看这个结构图:
stack frame

  • 首先,咱们须要明确的一点就是,main()函数并不是程序真正的入口函数,在main()函数以前系统会作一系列的初始化操做,而后再调用main()
  • 在上面咱们就有提到过,rsp(因为平台的不一致,这里的rsp就是上述提到的esp)一直是指向栈顶位置,而ebp则指向当前执行函数的栈基地址。
  • 如图所示,当发生函数调用时,调用者先将被调用函数的参数压栈,而后将返回地址压栈,而后在被调用函数中,将调用函数的ebp压栈,再将esp寄存器的值赋值给ebp,由于esp在调用以前老是指向栈顶的,将其赋值给ebp,就是从栈顶位置开始为被调用函数分配空间。

举个例子,当发生函数调用已经进入到被调用函数时,假如当前esp值(即栈顶位置)为0x10010,ebp的值为0x10000,先将ebp压栈,esp的指针往栈顶移四个字节,而后将ebp的值放入0x10014的位置(汇编中push操做先将栈顶指针后移,而后再将数据放入)。

而后将esp的值赋给ebp,这时候ebp的值为0x10014,而后为被调用函数分配空间.当函数返回时,再pop ebp,就能够将栈帧状态返回到被调用函数继续执行的状态。

分配内存空间

18        4004fa:   48 83 ec 10             sub    $0x10,%rsp

这一条指令是 rsp-0x10,就如上述提到的,栈是由高地址向低地址延伸,因此减去0x10便是为main()函数分配0x10的局部空间。

调用函数的准备

19            int c = func(3,4);
20        4004fe:   be 04 00 00 00          mov    $0x4,%esi
21        400503:   bf 03 00 00 00          mov    $0x3,%edi
22        400508:   e8 c9 ff ff ff          callq  4004d6 <func>

这三条指令就是将被调用函数的参数放入寄存器中进行参数传递(经典实现为经过栈传递),从这里能够看到,显示传递参数4,而后再是参数3,从这里能够看出,无论是寄存器传递仍是栈传递,C语言参数传递顺序都是从右往左。

第三条汇编指令callq,从字面意思能够看出这一条就是进行函数调用,call指令至关于:

pushl %eip          //将eip寄存器中地址压栈,

movl func, %eip     //将func()函数的地址放入eip寄存器中,当函数返回时取出这个地址放入eip寄存器便可。

eip寄存器中存放的是下一条将要执行指令的地址

进入到被调用函数

1        4004d6:    55                      push   %rbp
2        4004d7:    48 89 e5                mov    %rsp,%rbp

熟悉的这两行,跟上面说的同样,保存调用者rbp,而后定位被调用函数ebp和esp。

获取参数

3        4004da:    89 7d ec                mov    %edi,-0x14(%rbp)
4        4004dd:    89 75 e8                mov    %esi,-0x18(%rbp)

将参数从寄存器中取出,放到栈上,这里的栈并不是像数据结构栈同样,虽然是同样的实现方式,可是这里除了支持pop和push,同时支持ebp的直接寻址操做。

赋值操做

5            int a=x,b=y;
6        4004e0:    8b 45 ec                mov    -0x14(%rbp),%eax
7        4004e3:    89 45 f8                mov    %eax,-0x8(%rbp)
8        4004e6:    8b 45 e8                mov    -0x18(%rbp),%eax
9        4004e9:    89 45 fc                mov    %eax,-0x4(%rbp)

这一部分就是赋值操做,先将实参从栈上取出,而后再赋值给形参a,b。

函数返回值

10            return a+b;
11       4004ec:    8b 55 f8                mov    -0x8(%rbp),%edx
12        4004ef:   8b 45 fc                mov    -0x4(%rbp),%eax
13        4004f2:   01 d0                   add    %edx,%eax

将a和b相加,而后放在eax寄存器中返回。

执行返回

14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq

第一条指令将调用函数的rbp值赋值给rbp寄存器,这样就实现了栈帧结构的返回。

第二条指令retq,等价于

pop %eip

将返回值读出,做为下一条执行指令。

整个汇编程序的过程就是这样的,若是朋友们尚未看懂,咱们再用gdb调试的方式来查看栈以及栈上的内容(博主在64位机上作的实验,因此字宽和寄存器容量与32位机上的结果不一样,64位为8字节,32位为4字节)。

gdb下的程序执行

一样是如下的程序:

1   int func(int x,int y)
2   {
3       int a=x,b=y;
4       return a+b;
5   }
6   int main()
7   {
8       int c = func(3,4);
9       return c;
10  }

输入编译指令以及进入调试模式:

gcc -g test.c -o test
gdb test

进入调试模式以后,咱们先在函数调用前、被调用函数执行开始、和函数调用以后打上断点:

b 8
b 3
b 10

而后键入'r'(run)执行程序,这时程序停在了第一个断点,即第8行。在这里咱们先查看一下寄存器的值:

info registers esp ebp

输出结果为:

rsp            0x7fffffffdda0   0x7fffffffdda0
rbp            0x7fffffffddb0   0x7fffffffddb0

能够看到,在调用func()以前,main函数栈基地址为0x7fffffffddb0,而栈顶地址为0x7fffffffdda0,相差0x10,与上面输出的反汇编语句

18        4004fa:   48 83 ec 10             sub    $0x10,%rsp

是对得上的,这是在栈上分配内存空间致使栈顶的向下延伸。

而后咱们键入'c'(continue)继续执行程序,程序运行到第二个断点即func()函数中,第3行,咱们再来看到当前的栈帧寄存器值:

info registers esp ebp

输出结果为:

rsp            0x7fffffffdd90   0x7fffffffdd90
rbp            0x7fffffffdd90   0x7fffffffdd90

这时候rsp和rbp寄存器的结果都是0x7fffffffdd90,为何会是这个结果,并且两个寄存器结果同样呢?

咱们再来看看栈上的具体内容:

x/20xw $rsp              (x/n查看栈上指定长度的内存数据)

输出结果是这样的:

0x7fffffffdd90: 0xffffddb0  0x00007fff  0x0040050d  0x00000000
0x7fffffffdda0: 0xffffde90  0x00007fff  0x00000000  0x00000000
0x7fffffffddb0: 0x00400520  0x00000000  0xf7a2d830  0x00007fff
0x7fffffffddc0: 0x00000000  0x00000000  0xffffde98  0x00007fff
0x7fffffffddd0: 0x00000000  0x00000001  0x004004f6  0x00000000

能够看到,栈顶位置的数据为0x00007fffffffddb0,对比上面的数据能够发现这正是main()函数中rbp的值,而栈顶向高地址的第二个64位数据是0x000000000040050d。

对比上面的反汇编代码,能够发现这是main()函数中因函数调用而产生的断点,当函数返回时在这里继续向下执行。

因此func()函数返回时的这两条汇编指令能够返回到main()函数中:

14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq

至于为何rsp和rbp在函数中是同一个值,就是由于函数中没有产生分配空间的行为,事实上与经典操做系统不一样的是,这里的局部变量操做被直接放在寄存器中进行。
接着键入'c'(continue)继续向下执行,执行到第三个断点处,即func()函数已经返回,这时候咱们再来看寄存器内容:

info registers esp ebp

输出结果为:

rsp            0x7fffffffdda0   0x7fffffffdda0
rbp            0x7fffffffddb0   0x7fffffffddb0

栈帧结构恢复到调用前。

调用时栈帧结构总结

即便是反汇编的详解加上对应的跟踪gdb调试器中的栈数据活动,博主以为仍是须要仔细地再梳理一遍,以避免某些同窗仍是没有弄懂这个过程。咱们从新贴上汇编代码,

int func(int x,int y)
{
1        4004d6:    55                      push   %rbp
2        4004d7:    48 89 e5                mov    %rsp,%rbp
3        4004da:    89 7d ec                mov    %edi,-0x14(%rbp)
4        4004dd:    89 75 e8                mov    %esi,-0x18(%rbp)
5            int a=x,b=y;
6        4004e0:    8b 45 ec                mov    -0x14(%rbp),%eax
7        4004e3:    89 45 f8                mov    %eax,-0x8(%rbp)
8        4004e6:    8b 45 e8                mov    -0x18(%rbp),%eax
9        4004e9:    89 45 fc                mov    %eax,-0x4(%rbp)
10            return a+b;
11       4004ec:    8b 55 f8                mov    -0x8(%rbp),%edx
12        4004ef:   8b 45 fc                mov    -0x4(%rbp),%eax
13        4004f2:   01 d0                   add    %edx,%eax
    }
14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq   
    int main()
   {
16        4004f6:   55                      push   %rbp
17        4004f7:   48 89 e5                mov    %rsp,%rbp
18        4004fa:   48 83 ec 10             sub    $0x10,%rsp
19            int c = func(3,4);
20        4004fe:   be 04 00 00 00          mov    $0x4,%esi
21        400503:   bf 03 00 00 00          mov    $0x3,%edi
22        400508:   e8 c9 ff ff ff          callq  4004d6 <func>
23        40050d:   89 45 fc                mov    %eax,-0x4(%rbp)
24            return c;
25        400510:   8b 45 fc                mov    -0x4(%rbp),%eax
    }

同时写出详细的流程:

  • 16行:将调用main()的调用者函数的rbp值压栈
  • 17行:将当前rsp的值赋给rbp,由此肯定了main()的栈基地址。
  • 18行:在栈上开辟0x10的地址空间,用于main()函数的执行
  • 20-21行:将参数存入寄存器,以参数从右到左的顺序(为何是存入寄存器而不是栈,上面有简单提到,下面咱们也会详细讲到)
  • 22行:调用func()函数,这里的调用即将eip寄存器压栈,eip寄存器存放是下一条将要指令的地址,在15行func()函数中的retq指令就是取出栈上的返回地址从新放回eip寄存器。
  • 1-2行:进入到func()函数,将调用者main()函数的栈基地址入栈,而后将rsp的值赋给rbp。

咱们能够以前的gdb分析看出,如今的ebp即栈基地址为:0x7fffffffdd90,而main函数的栈顶地址为:0x7fffffffdda0,这其中有两次push操做,一次为callq,一次为push %ebp,因为是64位,每一次操做占用8个字节,因此rsp向下增加0x10字节,即rsp从0x7fffffffdda0变成了0x7fffffffdd90,再将rsp赋值给rbp,天然就是0x7fffffffdd90。

  • 3-9行:执行函数的相关操做。
  • 11-13行:先执行加法操做,而后将结果放入eax寄存器做为返回值
  • 14行:pop操做,此时栈顶为main()函数的rbp值,将栈帧还原到main()函数中。
  • 15行:retq,pop操做,通过上一次pop操做,栈顶为23行main()函数中的断点地址,将其放入eip,即下一条指令将跳转回main()函数中。
  • 23行:从eax寄存器中接收返回值,放入栈中相对与rbp的-0x4地址处,这里并无作任何处理。
  • 25行:main函数返回,将上一步放入栈中的值做为返回值放入eax寄存器中。

这个整个函数调用时栈帧变化的过程。若是到这里你尚未看懂的话.....


函数调用约定

在一些经典操做系统的书中,介绍的都是参数压栈,绝大部分的中间操做都是在栈上执行,可是博主上面贴出的示例明显不同,例子中显示其实不少操做都是在寄存器中执行的,而没有使用到栈。这又是为何呢?

这实际上是计算机硬件的发展带来的优化结果,经典的操做系统如8086(16位),80386(32位),这两种操做系统都只有8个通用寄存器来存储函数调用时的中间数据,例如参数、返回值、栈帧结构指针等等。

即便是32位的计算机,8个寄存器极限状态(通常不会达到)最多也是8*4(8位/字节)=32字节数据,可是到了64位系统中,寄存器从8个到16个,同时扩展到了64位,容量扩充了不少,因此在某些状况下能够直接使用寄存器操做。

这里须要重点说起的主要是调用约定的问题。

调用约定种类

在函数调用时,关于压栈顺序、堆栈恢复等有一套相应的规则来进行约束,想一想若是每一个厂商都互不相让,那么就没有可移植性可言了。主要的调用约定有这几个:

  • stdcall
  • cdecl
  • fastcall
  • thiscall
  • naked call
    而stdcall、cdecl、fast call是咱们要讨论的,他们分别有这样的特点:

stdcall

  • 函数的参数压栈顺序为从右到左
  • 参数的平衡由被调用者保持,即参数压栈时由调用者执行,可是执行完以后由被调用函数来销毁栈上的参数,保持堆栈平衡
  • 参数优先用栈传递

这种调用约定的典型就是pascal语言,可能你们不太熟悉这种编程语言,它还被普遍用于win32的API中。

关于第二点由被调用参数来销毁栈上的数据,这致使这种调用方式并不支持可变参数函数的实现,由于可变参数函数相似printf()是在执行的时候才知道参数个数,可是被调用函数并不知道有多少个参数传入,因此也就不能正确地释放栈上的数据。(注2)

cdecl

  • 函数的参数压栈顺序为从右到左
  • 参数的平衡由调用者保持,即参数压栈时由调用者执行,执行完以后由调用函数来销毁栈上的参数,保持堆栈平衡
  • 参数优先用栈传递

这是经典操做系统中C语言默认的调用方式,这种调用者保持堆栈平衡的调用方式是可变参数函数实现的基础,可是因为每一个平台或者编译器的栈实现方式和结构不同,将会加大实现程序可移植性的难度。

咱们能够这样理解,在stdcall调用中,是本身的事情本身作,本身执行完就将空间还给系统,而这种调用方式下,是本身的事情爸爸作,这样在调用状况复杂的状况下会形成调用函数空间以及时间上的负担。

fastcall

  • 函数的参数压栈顺序为从右到左
  • 参数的平衡由调用者保持,即参数压栈时由调用者执行,执行完以后由调用函数来销毁栈上的参数,保持堆栈平衡
  • 参数优先用寄存器传递

顾名思义,这种调用方式的目标就是快!
主要是因为第三个属性:参数优先使用寄存器传递。CPU访问内部寄存器就像是拿本身家的东西,而访问内存就像从仓库中取,须要在地址总线和数据总线的传输上消耗时间,寄存器速度比访问内存要快不少,因此能用寄存器天然不会舍近求远。

博主在X86-64机器上作了实验并结合相关资料,显示当实参小于6个时,参数由寄存器传递,当多余6个时,将多余参数压栈,知道这些咱们就能够写出更高效的代码-参数尽可能小于6个。

注1:在经典16或32位X86操做系统中,CPU有八个通用寄存器,可是随着CPU的高速更新换代,64位的X86-64架构有16个64位通用寄存器,效率更高,并且寄存器名也作了小小修改,博主的电脑为64位,因此是rsp而不是esp。(返回正文)

注2:这里所说的销毁(释放)栈上的数据事实上并非像操做flash一下进行擦除操做,这里仅仅是rsp指针的移动,并不会对地址上的数据进行任何操做,因此栈上的数据依旧存在,下一次运行时会覆盖。因此咱们常常会强调不要返回局部变量指针,这是由于局部变量中的数据并非稳定的。另外一个例子是函数体内定义的变量须要初始化,由于局部变量是在栈上分配内存,若是不初始化,局部变量的值就沿用了栈上上一次执行遗留下来的值,是不肯定的。

好了,关于C/C++栈帧结构的讨论就到此为止啦,若是朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言

原创博客,转载请注明出处!

祝各位早日实现项目丛中过,bug不沾身.

相关文章
相关标签/搜索