文章目录
学习函数栈帧时,建议不要使用太高级的编译器,越高级的编译器,越不容易学习和观察;在不同的编译器下,函数调用过程中的栈帧的创建是略有差异的,具体细节取决于编译器。
学习函数栈帧,能够帮我我们深刻的认识以下问题:
局部变量是怎么创建的?
为什么局部变量不初始化它的值是随机值?
函数是怎么传参的?传参的顺序是怎么样的?
形参和实参的关系是什么?为什么他们是这样的关系?
函数调用是怎么做的?函数调用的结果是怎么返回的?
1.寄存器
寄存器的功能是存储二进制代码二进制代码,它是由具有存储功能的触发器组合起来构成的。一个触发器可以存储1位二进制代码,故存放n位二进制代码的寄存器,需用n个触发器来构成。
C语言中,我们可以把寄存器看成一种指针变量,它可以存储一些值的地址,通过这些地址可以访问到那些值。
常见的寄存器变量名有:eax ebx ecx edx ebp esp
ebp和esp两个寄存器中存放的是函数栈帧的栈底地址和栈顶地址,这两个地址是用来维护函数栈帧的。每个函数调用都要创建一个空间(栈区)。
2.代码演示
我们下面要做的就是把下述代码创建函数栈帧销毁函数栈帧的过程演示一遍。
#include <stdio.h>
int ADD(int a, int b)
{
int z=0;
z=a+b;
return z;
}
int main()
{
int a=10;
int b=30;
int c = 0;
c = ADD(a, b);
printf("%d", c);
return 0;
}
为了观察细节,F10调试,右键转到反汇编
I、为main函数创建栈帧
此时,已经要为main函数创建栈帧了,说明_tmainCRTStartup函数的栈帧已经创建好了,我们假设下图是 _mainCRTStartup的栈帧。
步骤1 epb压栈
push是压栈的意思,在栈里头放个ebp元素进去,由于压栈动作会让栈顶指针自动移到栈顶,所以这一步后效果如图:
从监视来看这一步:
从内存来看这一步,如果我们的描述是正确的,esp现在应该能够看到ebp的值:
步骤2 epb移动到main的栈底
move指令是把后面的值赋给前面的变量,esp的值给ebp,相当于把ebp指到现在esp指的位置,图解这一步:
也可以从监视观察这一步(我们把监视调成了16进制)
步骤3 把esp移上去,创建main函数的空间
sub是减法的意思,esp=esp-0E4h,0E4h是八进制数字,十进制大小为228,这一步就是把esp往上(栈先使用高地址再使用低地址)移动E4(十六进制)个位置,图解这一步。
从监视来看:
此时esp ebp不再维护原来的空间了,好像维护了一个新的空间,起始地址是0x012ffeb0,终止地址是0x012ffdcc,这一步其实我们就已经为main函数先开辟好了新的空间
在内存中看这一步
这中间的内存空间就是给main函数准备的。
步骤4 ebx esi edi三次压栈
三次压栈,出于知识限制,我并不知道这三步骤的具体作用,图解这一步
从内存和监视来看这三步:
步骤5 把这块空间都填成cc
这里我们改为了VS2013里头来看,因为2019这一步比较复杂
lea:load effective address,加载有效地址,把后面那个地址加载到edi上,然后把39h放到了ecx里头,然后又把0CCCCCCCCh放到了eax中,然后rep stop dword ptr es:[edi]的意思是把从edi向下ecx个大小的元素全都赋成eax。
ebp-0E4h这个地址不就是本来main函数栈帧的栈顶地址嘛,所以这个意思就是把从main函数栈顶到ebp(栈底)往上都初始化成CC
我们终于把main函数的栈帧创建好了,接下来才开始执行有效的代码
II int a=10;
把0Ah也就是10放到ebp-8这个地址的空间上。
我们假定图中一个格子是4个字节。
在内存中观察这一步
III int b=30;
同理,这一步的意思是把b的值30放到ebp-14h=ebp-20里头,图解这一步:
(下图中所有的20都改成30.。。写错了)
在内存中观察这一步
IV int c=0;
同理,把c的值0放到ebp-20h=ebp-32这个位置上。
V c=ADD(a,b);
传参:
步骤1 eax存b的值
把ebp-14h地址上的值赋给eax,ebp-14h不就是&b嘛,所以eax=30;
步骤2 eax压栈
步骤3 ecx存a的值 ecx压栈
把ebp-8这个地址中的元素就是10,放到ecx里头去,然后ecx压栈。
这四个动作就是在传参。
调用ADD函数
步骤1: call指令的下一条指令的地址压栈
call是调用的意思,按下F11,我们观察内存来看这一步做了什么。
所以这一步首先把下一条指令的地址压栈了。
调用函数后我们还得回来吧,回哪呢,回call函数的下一条指令,恰好我们在这里把call指令的下一条指令的地址压栈了,记住了这个位置,方便以后回来。
步骤2 进入ADD函数
再按F11:
我们终于真正进入了ADD函数。
为ADD函数创建栈帧
上面这串和当初给main函数创建栈帧的步骤是类似的,猜想上面的步骤是在为ADD函数创建栈帧,这里由于我知识的限制,再次以vs2013为样板来展示
步骤1 epb压栈
把epb在main中的值压入栈,方便后续弹出时返回。
push ebp
步骤2 把ebp移动到ADD函数栈帧的栈底
mov ebp,esp
步骤3 把esp移上去,创建ADD函数的空间
sub esp,0Ch
步骤3 三次push
步骤4 把ADD的栈帧元素全部初始化cc
执行ADD函数中的命令
int z=0;
mov dword ptr [ebp-8],0
为z找个区域,放入0.
z=a+b;
步骤1 eax寄存器存一下刚刚压栈的临时的b的值
move eax,dword ptr[ebp+8]
步骤2 让eax和刚刚压栈的临时的a的值加一下
add eax,dword ptr[ebp+0ch(12)]
步骤3 把eax的值移动到z处
mov dword ptr [epb-8],eax
这也可以说明参数压栈是自右向左进行的
return z;
步骤1 把z的值放到寄存器eax里头
mov eax,dword ptr[ebp-8]
把ebp-8的值放到寄存器eax里头去
步骤2 弹出edi esi ebx
步骤3 销毁ADD函数的栈帧
mov esp,ebp
把ebp的值赋给esp,相当于把esp指向ebp的当前位置。
步骤4 使epb回到main函数中epb的位置
pop ebp
把ebp-main弹走了,然后会执行这条指令,使得ebp指回原本main的那个ebp的位置,现在明白了,压入原本epb在main中的地址就是为了能够回去。
步骤5 回到main函数中call指令的下一条指令同时销毁形参
ret
弹出栈顶的元素,并执行里头的指令,就是call指令的下一条指令的地址
弹出栈顶元素:
执行call下一条指令
这一步相当于销毁形参的空间。
VI 回到main函数了
把寄存器eax的值赋给c
后续调用printf函数和销毁main函数的栈帧的过程是类似的。
3.疑惑解释
3.1 局部变量是怎么创建的?
是我们在ADD的函数栈帧空间里头找空间创建的。
3.2 为什么局部变量不初始化它的值是随机值?
因为我们在创建这个局部变量所在的函数的栈帧的时候,会往里头放一些随机值如cc等。
3.3 函数是怎么传参的?传参的顺序是怎么样的
我们在调用函数之前,就会通过寄存器储存参数的值,然后压栈,这一步就完成了传参,然后我们要用形参的时候,通过指针的偏移量,找回我们的形参。
传参顺序自右向左。
3.4 形参和实参的关系是什么?
形参是实参的一份临时拷贝,它们之间只有值是相同的,所在空间完全不同,改变形参不会改变实参。
我们创建的栈帧之前会传参数,会独立使用一些空间临时储存参数以备后用,这个空间和之前实参所在的空间完全独立,我们用函数栈帧的知识验证了这一点。
3.5 函数调用是怎么做的?函数调用的结果是怎么返回的?
利用汇编中的call指令,call指令会调用我们的函数并且把call指令的下一条指令的地址压栈,这一步完成了函数调用。
函数是怎么传参的?传参的顺序是怎么样的
我们在调用函数之前,就会通过寄存器储存参数的值,然后压栈,这一步就完成了传参,然后我们要用形参的时候,通过指针的偏移量,找回我们的形参。
传参顺序自右向左。
3.4 形参和实参的关系是什么?
形参是实参的一份临时拷贝,它们之间只有值是相同的,所在空间完全不同,改变形参不会改变实参。
我们创建的栈帧之前会传参数,会独立使用一些空间临时储存参数以备后用,这个空间和之前实参所在的空间完全独立,我们用函数栈帧的知识验证了这一点。
3.5 函数调用是怎么做的?函数调用的结果是怎么返回的?
利用汇编中的call指令,call指令会调用我们的函数并且把call指令的下一条指令的地址压栈,这一步完成了函数调用。
调用的结果的值会用寄存器带回,然后弹出栈pop到我们压栈的call指令的下一条指令的地址时,我们会执行这条指令,然后返回调用这个函数的原本函数。