本文主要是介绍链接与装载---函数调用过程栈帧变化分析,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
目录
概述
函数调用过程中栈帧变化分析
准备知识
汇编代码语法风格
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 }2324 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 .text3 .globl get_x4 .type get_x, @function5 get_x:6 .LFB2:7 .cfi_startproc8 pushl %ebp9 .cfi_def_cfa_offset 810 .cfi_offset 5, -811 movl %esp, %ebp12 .cfi_def_cfa_register 513 subl $16, %esp14 movl $1, -8(%ebp)15 movl $2, -4(%ebp)16 movl -8(%ebp), %edx17 movl -4(%ebp), %eax18 addl %eax, %edx19 movl 8(%ebp), %eax20 addl %eax, %edx21 movl 12(%ebp), %eax22 addl %edx, %eax23 leave24 .cfi_restore 525 .cfi_def_cfa 4, 426 ret27 .cfi_endproc28 .LFE2:29 .size get_x, .-get_x30 .globl add31 .type add, @function32 add:33 .LFB3:34 .cfi_startproc35 pushl %ebp36 .cfi_def_cfa_offset 837 .cfi_offset 5, -838 movl %esp, %ebp39 .cfi_def_cfa_register 540 subl $16, %esp41 movl $18, -16(%ebp)42 movl $19, -12(%ebp)43 movl -12(%ebp), %edx44 movl -16(%ebp), %eax45 pushl %edx46 pushl %eax47 call get_x48 addl $8, %esp49 movl %eax, -8(%ebp)50 movl -16(%ebp), %edx51 movl -12(%ebp), %eax52 addl %eax, %edx53 movl -8(%ebp), %eax54 addl %edx, %eax55 movl %eax, -4(%ebp)56 movl -4(%ebp), %eax57 leave58 .cfi_restore 559 .cfi_def_cfa 4, 460 ret61 .cfi_endproc62 .LFE3:63 .size add, .-add64 .section .rodata65 .LC0:66 .string "ret:%d\n"67 .text68 .globl main69 .type main, @function70 main:71 .LFB4:72 .cfi_startproc73 leal 4(%esp), %ecx74 .cfi_def_cfa 1, 075 andl $-16, %esp76 pushl -4(%ecx)77 pushl %ebp78 .cfi_escape 0x10,0x5,0x2,0x75,079 movl %esp, %ebp80 pushl %ecx81 .cfi_escape 0xf,0x3,0x75,0x7c,0x682 subl $20, %esp83 movl $1432778632, -20(%ebp)84 movl $287454020, -16(%ebp)85 pushl -16(%ebp)86 pushl -20(%ebp)87 call add88 addl $8, %esp89 movl %eax, -12(%ebp)90 subl $8, %esp91 pushl -12(%ebp)92 pushl $.LC093 call printf94 addl $16, %esp95 movl $0, %eax96 movl -4(%ebp), %ecx97 .cfi_def_cfa 1, 098 leave99 .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 $1 | push 1 |
操作数顺序 | addl $2,%eax | add eax , 2 |
字长表示 | movb val,%eax | mov 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约定,参数从右往左入栈。关于调用约定的详细描述,可以参考:关于函数调用约定的一些知识.
平台 | 调用约定 |
x86 | cdecl,stdcall |
x64 | fastcall |
arm, arm64 | ATPCS |
函数栈帧分析
通常来说,我们将%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寄存器)
这篇关于链接与装载---函数调用过程栈帧变化分析的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!