文章目录
- 1. 什么是函数栈帧
- 2. 理解函数栈帧能解决什么问题呢?
- 3. 函数栈帧的创建和销毁解析
- 3.1 什么是栈?
- 3.2 认识相关寄存器和汇编指令
- 常见寄存器
- 常用汇编指令
- 3.3 详解函数栈帧的创建和销毁
- 3.3.1 函数的调用堆栈(main函数也是被其它函数调用的)
- 3.3.2 main函数栈帧的创建
- 3.3.3 小知识:烫烫烫~是如何产生的?
- 3.3.4 main函数中核心代码的执行
- 3.3.5 调用add函数
- 传参
- call指令调用函数
- add函数栈帧的创建
- Add函数核心代码的执行
- 函数结果的返回(先保存到寄存器里面)
- 3.3.6 Add函数栈帧的销毁
- 3.3.7 返回至main函数
- 3.3.8 main函数获取返回值
- 3.3.9 拓展了解
1. 什么是函数栈帧
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。
那函数是如何调用的?函数的返回值又是如何返回的?函数参数是如何传递的?这些问题都和函数栈帧有关系。
函数栈帧(stack frame)就是函数调用过程中程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
2. 理解函数栈帧能解决什么问题呢?
前期学习的时候,我们可能有很多困惑?
比如:
局部变量是怎么创建的?
为什么局部变量不初始化,它的值是随机值?
函数是怎么传参的?传参的顺序是怎样的?
形参和实参是什么关系?
函数调用的具体过程是怎么样的?
函数调用结束后返回值是如何返回的?
那关于这些问题,如果我们了解了函数栈帧的创建和销毁,就会豁然开朗。
那接下来,我们就来一起学习一下函数栈帧的创建和销毁的过程…
3. 函数栈帧的创建和销毁解析
3.1 什么是栈?
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中:
栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。
就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中:
栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
在我们常见的i386或者x86-64下,栈顶由名为 esp 的寄存器进行定位。
3.2 认识相关寄存器和汇编指令
常见寄存器
首先我们来了解一个东西叫做——寄存器:
在计算机体系结构中,寄存器是一种高速存储器,用于保存指令执行过程中的数据和地址。寄存器是与处理器紧密集成的组件,用于临时存储、操作和传递数据。
概念大家简单理解一下。
然后下面我们来介绍一下常用的一些寄存器:
除 EBP 和 ESP 外,其他几个寄存器的用途其实是比较任意的,也就是什么都可以存。
那EBP 和 ESP 我们这里要给大家介绍一下:
ebp 和 esp这两个寄存器在函数栈帧的创建和销毁中起着比较关键的作用。
ebp 和 esp也被称为栈基指针和栈顶指针,它们两个是用来维护函数的栈帧。
那它是如何来维护的呢?
大家应该知道,每一个函数被调用的时候,都会在内存中的栈区上开辟一块空间,这块空间我们就把它称之为该函数的栈帧。
先必须明确的一点是,函数栈是向下生长的。所谓向下生长,是指从内存高地址向低地址的路径延伸。于是,栈帧就有栈底和栈顶,栈顶的地址要比栈底的低。
对 x86 体系的 CPU 而言,寄存器 ebp 可称为栈基(栈底)指针(base pointer),寄存器 esp可称为栈顶指针(stack pointer)
而ebp 和 esp就是维护函数栈帧的,ebp 叫做栈基指针,存储栈底的地址; esp叫做栈顶指针,存储栈顶的地址。
我们的程序中正在调用哪个函数,ebp 和 esp维护的就是哪个函数的栈帧。
常用汇编指令
相关汇编命令:
mov:将第二个操作数(寄存器的内容、内存中的内容或常数值)复制到第一个操作数(寄存器或内存)。但不能用于直接从内存复制到内存
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:用于两个操作数相减,相减的结果保存到第一个操作数中
add:将两个操作数相加,相加的结果保存到第一个操作数中
call:函数调用,1. 压入返回地址 2. 转入目标函数
jmp:通过修改eip(eip:指令寄存器,保存当前指令的下一条指令的地址),转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
lea 指令。地址传送指令,将有效地址传送到指定的的寄存器
那了解了上面的东西,我们接下来就来写一个程序,带大家仔细的分析一下一个完整的函数栈帧的创建和销毁的过程:
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
3.3 详解函数栈帧的创建和销毁
函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2022(Debug下X_86环境)为例。
3.3.1 函数的调用堆栈(main函数也是被其它函数调用的)
那首先,以上面那段代码为例,我们来观察一个东西——函数调用堆栈:
函数调用堆栈是反馈函数调用逻辑的
我们来调式观察一下
此时我们看到的是这样的:
然后我们继续调式让它进入add函数内
那此时我们就能看到这个调用关系,add函数是由main函数调用的。
函数调用堆栈是反馈函数调用逻辑的,那除此之外我们可以清晰的观察到:
main 函数调用之前,是由invoke_main 函数来调用main函数的。
invoke_main 是一个 Microsoft C/C++ 运行时库中的函数,用于调用程序的主函数(main函数)
不过在 invoke_main 函数之前的函数调用我们就暂时不考虑了
但是我们可以确定, invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也有自己的栈帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间
那接下来我们从main函数的栈帧创建开始讲解:
那对于函数栈帧创建和销毁过程的研究这里我们要借助反汇编来观察和分析:
调试到main函数开始执行的第一行,右击鼠标转到反汇编。
注:VS编译器每次调试都会为程序重新分配内存,我们下面展示的反汇编代码是一次调试代码过程中数据,每次调试略有差异。
另外呢为了方便下面的分析和观察,我们可以做这样一件事情:
右击鼠标把显示符号名取消勾选
因为我们下面重点要去观察具体的地址、内存的布局
3.3.2 main函数栈帧的创建
那我们上面分析了,其实main函数也是被其它函数调用的,我们上面观察到main函数是被invoke_main 调用的。
所以在main函数被调用之前,invoke_main 的栈帧就应该是这样的:
invoke_main 再往上我们就不管了
那下面我们就正式开始分析我们的代码:
首先,我们看main函数里面的第一条汇编
是push ebp
(前面的一串数字是该条汇编指令的地址)
那push ebp
做了什么事情呢?
push就是压栈,所以push ebp
就是把ebp寄存器进行压栈,放到栈顶。
那与此同时,栈里面多了一个数据,栈顶的位置是不是就要发生变化啊
esp要往上走,那它存的地址就要减小
当然我们也可以通过监视窗口观察到它的变化
现在我们还没执行push,esp里面的值是0x008ffe20
然后,我们调式汇编代码往下走
大家看,变成了0x008ffe1c
那这里16进制显示的,大家可以算一下,差了4,所以我们当前的平台下,其实esp寄存器的大小就是4字节。
当然我们还可以通过内存窗口看一下它是否真的压进去了:
因为现在esp指向的空间里前4个字节放到就是ebp的值
没问题。
那我们继续往下:
mov ebp,esp
move是把将第二个操作数的值给第一个操作数。
那这里就是把esp的值给ebp,那这样ebp和esp不就指向一个位置了嘛
那大家也可以自己通过监视窗口观察一下,是没问题的,后面我就不带大家以以查看验证了,重点是原理的理解。
这里move指令把esp的值存放到ebp中,其实相当于产生了main函数的ebp,这个值就是invoke_main函数栈帧的esp(我们下面就可以看出来)。
继续下一步:
sub esp,0E4h
sub指令用于两个操作数相减,相减的结果保存到第一个操作数中。
所以这里就是给esp的值减去0E4H
它这里后面有个H的其实还是16进制数
大家可以算一下,它对应的十进制是228
那给esp减去一个值,它的值发生变化,同时指向的位置也发生变化
那esp就指向一个地址更低的位置去了
那现在就是这样一个样子。
那现在我们看到,ebp和esp好像又维护了一块新的空间。
那这块空间是给谁用的呢?
🆗,我们现在已经开始调用main函数了,所以?
是的,ebp和esp新维护的这块空间其实就是给main函数开辟的空间,也就是main函数的栈帧
所以,我们上面也提到:我们的程序中正在调用哪个函数,ebp 和 esp维护的就是哪个函数的栈帧
总结一下:
sub会让esp中的地址减去一个16进制数字0xe4,产生新的esp,此时新的esp就是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一块新的栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据以及调试信息等。
那我们继续:
接着再往下呢我们发现是3次push
push ebx
//将寄存器ebx的值压栈,esp-4
push esi
//将寄存器esi的值压栈,esp-4
push edi
//将寄存器edi的值压栈,esp-4
那这3次操作呢其实我们可以不用太关心:
上面3条指令保存了3个寄存器的值在栈区,因为这3个寄存器的在函数随后执行中可能会被使用和修改,所以先保存寄存器原来的值,以便在退出函数时恢复。
再往下:
这几条我们放在一块看
首先lea edi,[ebp-24h]
lea呢叫做Load Effective Address
即加载有效地址。
所以这句指令呢其实就是把[ebp-24h]
对应的地址放到edi里面
然后mov ecx,9
即把9放在ecx中
接着mov eax,0CCCCCCCCh
把0xCCCCCCCC放在eax中
上面这3步之后,这里真正起作用的其实就是rep stos dword ptr es:[edi]
这一句:
这一句是干嘛呢?
🆗,它是把从[ebp-24h]
这个位置开始向上的9个dword(4字节)直到ebp的内容全部初始化为0CCCCCCCCh
可以带大家看一下
当然它并没有覆盖的之前push进去的3个寄存器的值
这样的话虽然我们下面修改它了,但是最后还可以恢复。
所以:
这4句汇编我们可以认为它做的事情就是初始化main函数的栈帧空间
当然我们当前在vs2022上它这里只初始化了9*4=36
个字节的空间,不同的编译器上可能是不同的,比如vs2013上它这里初始化的这一块空间就比较大,这个不用太纠结。
当然如果main函数里定义的变量啥的不一样的话肯定也会有所差异。
上面的这4句代码,等价于下面的伪代码
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
*(int*)edi = eax;
}
3.3.3 小知识:烫烫烫~是如何产生的?
那了解了上面的内容其实还能解决我们之前的一个疑惑:
之前我们写的代码中如果出现了越界的情况或者打印一个没初始化的数组,有时候就打印出来一堆“烫烫烫烫烫烫烫…”的东西
原因是什么呢?
是因为我们没给数组初始化,而main函数开辟的栈帧里面默认有些空间被初始化成了0CCCCCCCC
而它对应的汉字编码就是“烫”
所以我们打印出来是“烫烫烫烫…”
3.3.4 main函数中核心代码的执行
这些是编译器附加的。
为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,我们可以关闭下面的选项,让汇编代码中排除一些编译器附加的代码
这时再看
就没有那两句了
那走到这里我们发现,其实到现在开始真正执行main函数里面的代码:
那我们也来分析分析:
首先mov dword ptr [ebp-8],0Ah
那就是把0AH(16进制对应10进制就是整数10),放到地址为ebp-8位置的dword 双字(4字节,32位)内存单元中
补充:
x86 架构中
dword ptr:双字(4 字节)
word ptr:字(2 字节)
byte ptr:字节(1 字节)
那这4个字节是不是就是a在内存里面存储的位置啊
我们可以观察一下
那紧接着下面两条指令:
也是一样的道理,分别把10和0放到对应的位置(即局部变量b
、c对应的存储位置)
所以:
上面3句汇编代码表示的变量a、b、c的创建和初始化,这就是局部的变量的创建和初始化
所以局部变量的创建时在局部变量所在函数的栈帧空间中找到对应的空间去创建的
3.3.5 调用add函数
那紧接着下面,就是调用add函数了:
传参
那这里调用add的话是不是首先得传参啊,我们来看看:
首先
mov eax,dword ptr [ebp-14h]
那它其实就是把ebp-14h位置的dword即4字节的内容放到寄存器eax里面
那ebp-14h位置存的是谁?
这个位置存的不就是b的值20嘛,所以这一步就是把20这个整数值
放到eax里面
然后:push eax
即把eax压栈
那我们继续:
接着是mov ecx,dword ptr [ebp-8]
,把ebp-8位置4个字节的值放到ecx寄存器里面
ebp-8位置存的是谁啊?
是a变量的值10
然后push ecx
所以上面这几步是在传参吗?
是的,其实就是在传参,而且我们能发现它是从右向左传的。
所以传参其实就是把参数push到当前函数(即调用者函数)的栈帧空间中
但是大家可能会有点疑惑,这样能传过去嘛,为什么形参还是在main函数的栈帧里面呢?
不急,后面大家就明白了。
call指令调用函数
我们继续往下看:
那传完参,就是调用函数了
call 00EA10B4
,其实就是去调用add函数
这时我们按F11(逐语句,这样才会进入函数)
此时跳转到了jmp这里,并且我们还能发现了一个变化
esp里面的值发生了变化,并且我们上面push的eax上面好像又多push进去了一个值
而且我们会发现新push进去的这个值就是前面call 00EA10B4
这条指令的下一条指令的地址。
所以:
call 指令首先将当前call指令的下一条指令的地址入栈,然后无条件转移到由标签指示的指令
那大家思考一下,这里为什么要保存一下call指令的下一条指令的地址呢?
🆗,其实很容易想通。
因为我们调用完add函数之后是不是要回到main函数中继续往下执行啊。
那调用完回来之后,怎么知道要从哪里继续往下执行呢?
那这时候保存的这个地址是不是就起作用了,找到这个地址,继续往下执行就可以了。
再往下:
我们再按F11,jmp指令就会跳转到对应的函数里面
我们发现,此时就进入到add函数里面了
add函数栈帧的创建
那我们来看一看:
首先我们会发现上面这几句指令其实和当时我们分析的main函数里面的是差不多的
其实就是去给add函数开辟栈帧并初始化,那当然此时ebp和esp指针就要去维护add函数的栈帧了。
那我们还是来带大家简单分析一下:
首先呢又是
push ebp
把此时的ebp的值压栈
然后mov ebp,esp
之前ebp是在维护main函数的栈帧
那现在mov ebp,esp
把esp的值给ebp
那此时ebp就指向这里里,其实此时就变成了Add函数的栈基指针了
接着:
sub esp,0CCh
,给esp的值减去一个0CCh
那esp存的地址就变的更低
那这些工作是在干什么?
是不是就是为Add函数开辟栈帧啊,此时ebp和esp维护的就是Add函数的栈帧了
再下面呢:
也是和之前main函数的一样,把这3个寄存器push压栈
再接着,其实就是对栈帧的一些空间进行初始化,初始化为0CCCCCCCCh
即从ebp-0Ch
(0Ch对应10进制12)开始往上的3个dword的空间初始化为0CCCCCCCCh
Add函数核心代码的执行
那再往下:
就是Add函数里面具体代码的执行了
首先mov dword ptr [ebp-8],0
把0 放到ebp-8的位置
其实就是创建Z
那再往下,终于要执行X+Y的计算了
首先mov eax,dword ptr [ebp+8]
把ebp+8位置的值放到eax里面
那这个位置放的是谁的值啊
ebp+8这个位置不就是我们刚才传参的时候把实参a的值传过来放到这里了嘛,那我们现在是不是就获取到传过来的参数了
再往下看
add eax,dword ptr [ebp+0Ch]
,即:
把ebp+0Ch(10进制是12)位置的值和eax里面的值相加,结果放到eax里面
那eax里存的是传过来的实参a的值10,那ebp+0Ch位置放到又是谁呢?
哦豁!
不就是传过来的实参b的值20嘛。(这就是形参访问)
那现在10+20,结果30就放到了eax里面。
所以:
这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。
那我们调用Add函数不就是要算a+b(对应形参是x、y)的和嘛
那我们现在就得到结果了
那然后呢?
mov dword ptr [ebp-8],eax
,即把eax里面存的值放到ebp-8的位置。
这个位置是谁啊?
不就是Add要返回的Z嘛
30 放到Z的内存单元里。
函数结果的返回(先保存到寄存器里面)
那现在Add函数得出结果了,然后是不是要把结果返回调用函数的地方啊:
那就是
return Z
那我们来分析一些函数返回值是如何返回的:
首先
mov eax,dword ptr [ebp-8]
,ebp-8位置是谁啊?
是最终的结果z。
所以它又把Z的值放到了eax寄存器里面。
那大家思考一下,为什么不直接返回z呢?为什么要要把结果放到寄存器eax里面呢?
因为:
z是一个创建在Add函数栈帧上的局部变量啊,Add函数调用结束,栈帧销毁,我们还找得到z吗?
找不到了。
所以,先把z的值给一个寄存器保存起来,因为寄存器是存在于CPU内部的一组用于存储和处理数据的高速存储器,它是不会随着函数调用结束而销毁的。
这样即使,函数调用结束,回到main函数里面,我们也照样可以安全的拿到返回值。
3.3.6 Add函数栈帧的销毁
那我们继续往下看:
紧接着下面,3个pop
把之前压到栈帧里面的3个寄存器pop(pop edi #弹出栈顶元素送到 edi)出去,恢复这3个寄存器之前存的值,同时esp指针变动(pop一次加一个4)
再往下:
mov esp,ebp
,把ebp的值给esp
那esp就和ebp指向一个位置了
那与之对应的就是,Add函数的栈帧被销毁了
现在就变成这样了:
然后再往下
pop ebp
,弹出栈顶的值给ebp,此时栈顶放到是什么?
是之前压上去的main函数对应的栈基指针的值
那现在把它pop掉,并把它的值给ebp。
那就变成这样了
我们发现此时ebp和esp又重新维护起了main函数的栈帧,这当然没问题,因为此时Add函数已经调用结束,就要回到main函数了。
所以,前面我们为什么要main函数的ebp栈基指针push存起来,其实就是为了函数调用结束回来的时候我们能获取到原来main函数对应的栈基指针的值,从而使ebp重新指向main函数的栈底,维护main函数的栈帧。
3.3.7 返回至main函数
那我们再继续:
我们看到下一句是ret(return),那ret其实就要真正实现Add函数的返回,回到调用它的main函数了。
那ret如何知道要返回到哪里呢?
🆗,我们来看
此时栈顶放的是什么?
是之前call指令压入栈顶的call指令的下一条指令的地址。
而我们的Add返回到main函数之后要从哪里继续往下执行?
就是从call指令的下一条指令开始往下执行啊,因为当初调用函数就是从call指令跳转过去的啊。
所以:
ret指令实现子程序的返回机制,ret 指令pop弹出栈顶保存的call执行的下一条指令的地址,然后无条件转移到保存的指令地址处
所以ret之后main函数的栈帧就是这样的
那此时,大家也应该非常清楚,当初为什么要保存call指令的下一条指令的地址了。
那我们再继续往下执行的时候:
现在在ret
我按F10
直接就回到call指令的下一条指令了
3.3.8 main函数获取返回值
那我们继续看剩下的一些指令:
接下来是add esp,8
给esp的值+8,这是干什么啊?
🆗,我们看到,此时Add函数已经调用结束,形参x、y还有用吗?
当然没有了,所以,让esp+=8
就把形参x、y的空间也释放掉了
那我们再往下看
mov dword ptr [ebp-20h],eax
,把eax(存的Add函数的返回值)的值复制到ebp-20h位置的4个字节的内存单元上
ebp-20h(10进制32)位置放的是谁啊?
就是c啊。
而我们main函数里面接收Add的返回值不就是用c接收的嘛
那此时,main函数终于成功获取到了Add函数的返回值。
可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
那再往下,是printf函数的调用,接着就是main函数栈帧的销毁,那它们和Add函数的调用以及栈帧的销毁是差不多的,我就不过多赘述了。而且我们的文章到这里篇幅已经很长了,我自认为,本篇文章的讲解还是非常详细的,相信大家很容易可以看懂。
3.3.9 拓展了解
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。
到这里我们给大家完整的演示了main函数栈帧的创建,Add函数栈帧的创建和销毁的过程,相信大家已经能够基本理解函数的调用过程,函数传参的方式。
相信现在大家再去看我们刚开始提出的哪些问题,就豁然开朗了…