链接与装载---函数调用过程栈帧变化分析

目录

概述

函数调用过程中栈帧变化分析

准备知识

汇编代码语法风格

x86寄存器介绍

函数调用约定

函数栈帧分析

总结

参考

附录

cdecl


概述

学过c语言的同学都知道,函数调用过程是通过栈结构来实现的, 在内存空间中, 栈可用于保存函数的参数,局部变量, 返回值,返回地址等。

为什么要用栈来表示呢?

简单来说,栈是一种LIFO形式的数据结构,所有的数据都是后进先出。这种形式的数据结构恰好满足我们调用函数的方式: 父函数调用子函数,父函数在前,子函数在后;返回时,子函数先返回,父函数后返回。栈支持两种基本操作,push和pop。push将数据压入栈中,pop将栈中的数据弹出并存储到指定寄存器或者内存中。

我们在写c语言代码时,在函数内部会定义很多局部变量, 在计算时,这些局部变量我们是直接拿来使用的, 而按照栈仅有的两种操作pop和push,  当我们需要取栈中部的数据时,则需要将之前的数据出栈,直到拿到我们需要的数据, 这显然无法满足函数调用过程中对栈的需要,事实上, 函数调用过程中对栈的操作是比较灵活的, 除了有pop和push操作外,还有诸如

movl    -8(%ebp), %edx  // 取出栈底偏移8字节的数据,相当于出栈
movl    $1, -8(%ebp)// 将数据存入到栈底偏移8字节处,相当于入栈

在linux环境下, main函数作为c语言代码的入口, 并不是真正的程序执行入口(关于程序执行入口的说明可以参考:linux c语言main函数调用原理),事实上,main函数是被glibc库函数_start调用的, 这部分代码可以在glibc库中找到。这里我们为了方便阐述函数调用过程,我们不直接分析main函数的栈帧,而是设计了如下的一套代码,通过分析子函数add被调用前, 执行中,调用返回后的栈帧变化,来理解函数栈帧变化过程。 

c语言代码如下:

  1 #include <stdio.h>
  2 #include <stdlib.h>
  3 
  4 
  5 unsigned int get_x(int _a, int _b)
  6 {
  7         unsigned int a = 1;
  8         unsigned int b = 2;
  9 
 10         return a+b + _a + _b;
 11 }
 12 
 13 
 14 unsigned int add(unsigned int A,unsigned int B)
 15 {
 16         unsigned int a = 0x12;
 17         unsigned int b = 0x13;
 18         unsigned int x = get_x(a,b);
 19         unsigned int z = a+b +x;
 20 
 21         return z; 
 22 }
 23
 24 int main()
 25 {
 26     unsigned int a = 0x55667788;
 27     unsigned int b = 0x11223344;
 28     unsigned int ret = add(a,b);
 29         
 30     printf("ret:%d\n",ret);
 31     return 0;
 32 }

编译平台:

$uname -a
Linux mt-VirtualBox 4.15.0-64-generic #73~16.04.1-Ubuntu SMP Fri Sep 13 09:54:42 UTC 2019 i686 i686 i686 GNU/Linux
$ gcc -v
Target: i686-linux-gnu
gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.10) 

汇编代码如下(gcc -S main.c):

  1         .file   "stack.c"
  2         .text
  3         .globl  get_x
  4         .type   get_x, @function
  5 get_x:
  6 .LFB2:
  7         .cfi_startproc
  8         pushl   %ebp
  9         .cfi_def_cfa_offset 8
 10         .cfi_offset 5, -8
 11         movl    %esp, %ebp
 12         .cfi_def_cfa_register 5
 13         subl    $16, %esp
 14         movl    $1, -8(%ebp)
 15         movl    $2, -4(%ebp)
 16         movl    -8(%ebp), %edx
 17         movl    -4(%ebp), %eax
 18         addl    %eax, %edx
 19         movl    8(%ebp), %eax
 20         addl    %eax, %edx
 21         movl    12(%ebp), %eax
 22         addl    %edx, %eax
 23         leave
 24         .cfi_restore 5
 25         .cfi_def_cfa 4, 4
 26         ret
 27         .cfi_endproc
 28 .LFE2:
 29         .size   get_x, .-get_x
 30         .globl  add
 31         .type   add, @function
 32 add:
 33 .LFB3:
 34         .cfi_startproc
 35         pushl   %ebp
 36         .cfi_def_cfa_offset 8
 37         .cfi_offset 5, -8
 38         movl    %esp, %ebp
 39         .cfi_def_cfa_register 5
 40         subl    $16, %esp
 41         movl    $18, -16(%ebp)
 42         movl    $19, -12(%ebp)
 43         movl    -12(%ebp), %edx
 44         movl    -16(%ebp), %eax
 45         pushl   %edx
 46         pushl   %eax
 47         call    get_x
 48         addl    $8, %esp
 49         movl    %eax, -8(%ebp)
 50         movl    -16(%ebp), %edx
 51         movl    -12(%ebp), %eax
 52         addl    %eax, %edx
 53         movl    -8(%ebp), %eax
 54         addl    %edx, %eax
 55         movl    %eax, -4(%ebp)
 56         movl    -4(%ebp), %eax
 57         leave
 58         .cfi_restore 5
 59         .cfi_def_cfa 4, 4
 60         ret
 61         .cfi_endproc
 62 .LFE3:
 63         .size   add, .-add
 64         .section        .rodata
 65 .LC0:
 66         .string "ret:%d\n"
 67         .text
 68         .globl  main
 69         .type   main, @function
 70 main:
 71 .LFB4:
 72         .cfi_startproc
 73         leal    4(%esp), %ecx
 74         .cfi_def_cfa 1, 0
 75         andl    $-16, %esp
 76         pushl   -4(%ecx)
 77         pushl   %ebp
 78         .cfi_escape 0x10,0x5,0x2,0x75,0
 79         movl    %esp, %ebp
 80         pushl   %ecx
 81         .cfi_escape 0xf,0x3,0x75,0x7c,0x6
 82         subl    $20, %esp
 83         movl    $1432778632, -20(%ebp)
 84         movl    $287454020, -16(%ebp)
 85         pushl   -16(%ebp)
 86         pushl   -20(%ebp)
 87         call    add
 88         addl    $8, %esp
 89         movl    %eax, -12(%ebp)
 90         subl    $8, %esp
 91         pushl   -12(%ebp)
 92         pushl   $.LC0
 93         call    printf
 94         addl    $16, %esp
 95         movl    $0, %eax
 96         movl    -4(%ebp), %ecx
 97         .cfi_def_cfa 1, 0
 98         leave
 99         .cfi_restore 5
100         leal    -4(%ecx), %esp
101         .cfi_def_cfa 4, 4
102         ret
103         .cfi_endproc
104 .LFE4:
105         .size   main, .-main
106         .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"
107         .section        .note.GNU-stack,"",@progbits
                                             

函数调用过程中栈帧变化分析

准备知识

了解以下知识点,可以帮助我们更快的分析c语言和汇编代码。

汇编代码语法风格

汇编语言分为intel风格和AT&T风格,前者被Microsoft Windows/Visual C++采用,Linux下,基本采用的是AT&T风格汇编,两者语法有很多不同的地方,这里我们只需要简单了解即可。

类别

AT&T

Intel

寄存器访问格式

pushl %eax

push eax

立即数表示方式pushl $1push 1
操作数顺序addl $2,%eaxadd eax , 2
字长表示movb val,%eaxmov al,byte ptr val
寻址方式section:disp(base, index, scale)section:[base + index*scale + disp]

x86寄存器介绍

  • 1.通用寄存器

顾名思义,通用寄存器是那些你可以根据自己的意愿使用的寄存器,但有些也有特殊作用,IA32处理器包括8个通用寄存器,分为3组

1️⃣数据寄存器

     EAX 累加寄存器,常用于运算;在乘除等指令中指定用来存放操作数,另外,所有的I/O指令都使用这一寄存器与外界设备传送数据。

    EBX 基址寄存器,常用于地址索引

    ECX 计数寄存器,常用于计数;常用于保存计算值,如在移位指令,循环(loop)和串处理指令中用作隐含的计数器.

    EDX 数据寄存器,常用于数据传递。

2️⃣变址寄存器

    ESI 源地址指针

    EDI 目的地址指针

3️⃣指针寄存器

    EBP为基址指针(Base Pointer)寄存器,存储当前栈帧的底部地址。

    ESP为堆栈指针(Stack Pointer)寄存器,一直记录栈顶位置,不可直接访问,push时ESP减小,pop时增大。

  • 2. 指令指针寄存器

EIP 保存了下一条要执行的指令的地址, 每执行完一条指令EIP都会增加当前指令长度的位移,指向下一条指令。用户不可直接修改EIP的值,但jmp、call和ret等指令也会改变EIP的值,jmp将EIP修改为目的指令地址,call修改EIP为被调函数第一条指令地址,ret从栈中取出(pop)返回地址存入EIP。

另外,在64位环境下,寄存器的名字也有变化, 是以r开头命名,比如rbp代表基址指针寄存器,rsp代表堆栈指针寄存器。

函数调用约定

调用函数将被调用函数参数入栈,入栈顺序由调用约定规定,包括cdecl,stdcall,fastcall,naked call等,c语言默认使用cdecl约定,参数从右往左入栈。关于调用约定的详细描述,可以参考:关于函数调用约定的一些知识.

平台调用约定
x86cdecl,stdcall
x64fastcall
arm, arm64ATPCS

函数栈帧分析

通常来说,我们将%ebp与%esp之间的区域称为函数栈帧(之所以这么划分,是因为只有这部分空间是属于本函数的),每个函数都有自己独立的函数栈,每调用一个新函数,就会生成一个新的栈帧。在一个函数栈中, %ebp代表函数栈底,在函数执行过程中是不会变的(除非发生新的函数调用), %esp作为栈顶指针会随着入栈出栈操作来变化, 我们将重点分析这两个寄存器在传递参数, 局部变量分配空间,函数返回过程中的变化。

在函数调用过程中,我们将调用函数的函数称为“调用者(caller)”,将被调用的函数称为“被调用者(callee)”。在这个过程中,我们需要弄明白以下几个问题,这也是分析函数栈帧的核心:

1. “被调用者”需要知道传入的参数在哪里;

2. “调用者”需要知道在哪里获取“被调用者”返回的值;

3. 局部变量在栈上是怎么分配空间的;

4. 返回的地址在哪里;

5. 需要保证在“被调用者”返回后,%ebp%esp 等寄存器的值应该和调用前一致,仿佛从来没有发生过函数调用一样;

下面,我们就以前文的c语言和汇编代码来分析函数栈帧的变化,我们重点分析add函数在调用前,调用中,调用返回后的栈帧变化, 相信,经过以下分析之后,我们就能够解决上面提到5个问题,对函数调用过程中栈帧变化有了新的认知。

汇编从line70~line77之间的代码是固定的, 在linux下编译出的程序汇编代码都是一样的,line77行可以认为是main函数栈帧的起始,将%ebp入栈,作为main函数的栈底。

函数栈帧变化总览图

 执行到这里, 就从get_x函数返回了,紧接着将会执行line48,  get_x的栈空间也被释放, 但是此时, 传递给get_x的两个参数依然位于栈上,所以line48就要将传递给get_x的参数从栈上去除。

总结

1. %ebp作为函数的栈底指针,在函数执行过程中是通过ebp寄存器来保存栈底指针, 是不变的(而且也不需要将ebp存入内存), 除非发生函数调用切换,被调用者将调用者的ebp入栈(这个不要理解错了,  调用者自己是不会保存自己的ebp的), %esp作为栈顶指针,会随着入栈出栈的操作来变化。

2. 函数参数传递方式依赖于选择的函数调用约定, x86平台c语言默认的函数调用约定为cdecl,即从右向左入栈,这个和基于ARM平台进行嵌入式开发时的ATPCS是不一样的。

3. 函数调用通过call实现, call命令做了两件事情,一是将EIP寄存器内的值压入栈中,称为返回地址,函数完成后还要返回到这个地址继续执行程序。然后将被调用函数第一条指令地址存入EIP中,由此进入被调函数,相当于执行push %eip和jump指令

4. 被调用函数栈帧空间是通过以下3条指令组合实现的:

pushl   %ebp        // 将调用者的栈底指针%ebp入栈
movl    %esp, %ebp  // 将栈顶指针赋值给栈底指针寄存器, 从此以后, 被调用者有了自己新的栈底指针
subl    $16, %esp   // 被调用者分配16字节的栈帧空间, 事实上是空出16字节

所有的函数调用,汇编代码的前几行 都是这样的。

5. 函数返回时要恢复现场(其实就是将本函数的栈帧空间释放), return语句相当于leave,ret两条指令

leave指令相当于

movl %ebp, %esp   //将栈底指针赋值给栈顶指针, 即释放被调函数栈空间
popl %ebp         //此操作将取出调用者的栈底指针, 赋值给ebp寄存器, 即恢复ebp为调用函数基址

ret指令相当于

pop %EIP

函数栈帧变化总览图可以看出,经过leave指令之后, 栈顶存放的是eip,即返回地址,所以,经过ret指令后, 就成功的从被调用函数返回了。

经过leave,ret指令,被调用函数的栈帧空间被释放,但是依然没有恢复到调用之前的状态, 想一想,还差点什么?   由于在函数调用发生之前,调用者需要将被调用函数的参数入栈, 所以, 函数调用返回后,我们还需要从调用者的栈空间中还保留着传递给被调用函数的参数,所以,还需要执行

addl $8, %esp

将栈顶指针恢复到调用操作之前的状态(因为有两个整形参数,所以,栈顶需要移8字节), 到此, 调用者的栈空间就恢复如初了,仿佛没有发生过函数调用一样。

6.  被调用函数的栈空间

函数add作为main函数的被调用者, get_x函数的调用者, 它的函数栈帧空间包含了

  • 调用者main的栈底指针
  • 函数自身的局部变量
  • 传递给子函数get_x的参数
  • 子函数get_x的返回地址

7. 函数返回值

我们分析了函数栈帧变化过程,唯独没有在栈上发现函数返回值,事实上,它比较神秘。

在get_x函数中, 汇编line22行显示,最终的计算结果存放在%eax寄存器中, 而没有存放在栈中。

22         addl    %edx, %eax

在get_x函数返回后, 程序继续执行line48,line49,

48         addl    $8, %esp
49         movl    %eax, -8(%ebp)

line49行将%eax入栈, 即将get_x函数的返回值存入栈中, 为了方便后续的计算.

为什么get_x将结果存入%eax而不是直接入栈呢, 这其实是调用约定cdecl的规定,具体参考附录.

8. 值传递,引用传递本质区别

从函数栈帧变化过程中,我们也能看到通过值传递的形式传递参数,是将参数重新拷贝一份保存在栈中(试想,如果通过值传递形式传递一个大结构体,效率将是多么低下),这也就证实了我们对值传递的参数进行修改是无法真正修改变量本身的, 我们修改的只是变量的一份拷贝。如果使用指针传递参数,则在汇编代码中会借助leal指令将变量地址入栈, 使得子函数可以真正的修改变量的值。

参考

c函数调用过程栈帧分析

常见函数调用约定

附录

cdecl

在x86架构上,其内容包括:

  • 函数实参在线程栈上按照从右至左的顺序依次压栈。
  • 函数结果保存在寄存器EAX/AX/AL中
  • 浮点型结果存放在寄存器ST0中
  • 编译后的函数名前缀以一个下划线字符
  • 调用者负责从线程栈中弹出实参(即清栈)
  • 8比特或者16比特长的整形实参提升为32比特长。
  • 受到函数调用影响的寄存器(volatile registers):EAX, ECX, EDX, ST0 - ST7, ES, GS
  • 不受函数调用影响的寄存器: EBX, EBP, ESP, EDI, ESI, CS, DS
  • RET指令从函数被调用者返回到调用者(实质上是读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)
     
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值