前言
在上C语言的函数部分的时,每个老师都会强调”形参是实参的一份临时拷贝”,当时我只是简单地记住了它,并把它认为是理所当然的事.在接触了初级数据结构一段时间以后(包括学习Java语法的过程中),随着使用函数次数的增加,对其了解也越来越深.以前有了解过”函数栈帧”,但没能完全弄明白,最近因为要开始学二叉树等需要用到递归和函数调用的地方,所以重新了解了函数栈帧,体会颇深
本篇文章力求简单易懂,读完它我们能知道:
- 局部变量是如何创建的?
- 为什么未初始化的局部变量是”随机值”?
- 函数如何传参?顺序?
- 形参与实参的关系?
- 函数是如何被调用的?
- 函数被调用结束后如何返回?
1. 理解”函数栈帧”
用一个简单的例子理解”栈帧”:#include <stdio.h>//加法函数int Add(int x, int y){return x + y;}//减法函数(并调用了Add)int Sub(int x, int y){return x - Add(x, y);}//main函数int main(){int a = 10;int b = 20;//这是一种函数间的链式访问printf("%d\n", Sub(a, b));return 0;}
- 何为函数间的链式访问?
将上一个函数的结果作为当前函数的参数
注意:
main函数也是被另一个函数调用的,下面会提到
- 上面的例子中,Sub函数调用了Add函数,那么加上printf函数和main函数后,这四个函数被调用的顺序如何呢?
- main函数被调用
- printf函数被main函数调用
- Sub函数被printf函数调用
- Add函数被Sub函数调用
而函数返回值的顺序则相反.知道”栈”这种数据结构的同学知道,函数被调用和返回的过程其实就是一个栈的结构,即先进后出结构.
这就是”栈帧”的”栈”.
把每个函数视为栈中的元素,调用函数是入栈,返回函数是出栈.每个函数在栈中所占空间则称为”帧”.(就像影片中的帧一样)
2. 寄存器和汇编指令的介绍
2.1 寄存器
寄存器(Register)是中央处理器内用来暂存指令、数据和地址的电脑存储器。寄存器的存贮容量有限,读写速度非常快。在计算机体系结构里,寄存器存储在已知时间点所作计算的中间结果,通过快速地访问数据来加速计算机程序的执行。 —维基百科
我们只需要知道寄存器是独立于系统和内存之外的、集成于CPU中用来存储数据的的硬件即可.
本文中需要了解的几类寄存器:
一般寄存器 | 名称 | 功能 | | —- | —- | | eax(accumulator) | 累加器 | | ebx(base) | 存地址 | | ecx(counter) | 计数器 | | edx(data) | 存数据 |
堆叠、基底寄存器 | 名称 | 功能 | | —- | —- | | esp(Extended Stack Pointer) | 栈指针寄存器,存放函数栈顶地址 | | ebp(Extended Base Pointer) | 帧指针寄存器,存放函数栈底地址 |
索引(变址)寄存器 | 名称 | 功能 | | —- | —- | | esi | 存放源变址 | | edi | 存放目的变址 |
小结:
- esp和ebp维护的是当前被调用函数的栈帧
- 重点记忆esp和ebp
- 了解四个一般寄存器的功能即可
- esp是会随着栈顶数据的变化而变化的,也就是说esp始终指向栈顶元素
2.2 汇编指令
在此仅列出本文所需的汇编指令
| 指令 | 功能 |
|---|---|
| push x | 将x压入栈中 |
| pop x | 将x弹出栈中 |
| mov a, b | 将b赋值给a,即b指向a |
| sub a, num | a的值减去num,即a向低地址移动 |
| lea(load effective adress) | 加载有效地址(在示例中理解) |
注:不同平台,不同编译器对应的汇编语句是不同的,寄存器的名字也有所不同,但逻辑相同,下面皆以visual studio 2019 x86平台为例
提醒:如果在后文有遇到上文提到的内容但记不起来,记得返回查阅
3. 函数栈帧创建
以一个简单的程序为例,其中将代码分得足够细,以致于能够清晰地理解计算机中底层是如何创建函数栈帧的.
#include <stdio.h>int Add(int x, int y){int z = x + y;return z;}int main(){int a = 10;int b = 20;int c = Add(a, b);return 0;}
3.1 main函数栈帧的创建
事实上,main函数也是被另一个函数调用的,在此以main函数栈帧的创建为例
int main(){push ebpmov ebp,espsub esp,0E4hpush ebxpush esipush edilea edi,[ebp-24h]mov ecx,9mov eax,0CCCCCCCChrep stos dword ptr es:[edi]int a = 10;mov dword ptr [ebp-8],0Ahint b = 20;mov dword ptr [ebp-14h],14hint c = Add(a, b);mov eax,dword ptr [ebp-14h]push eaxmov ecx,dword ptr [ebp-8]push ecxcall 011C10B4add esp,8mov dword ptr [ebp-20h],eaxreturn 0;xor eax,eax}pop edipop esipop ebxadd esp,0E4hcmp ebp,espcall 011C1235mov esp,ebppop ebpret
以上是main函数对应的汇编代码,虽然看起来有些多,但都是一些简单的工作,耐心看下去还是很有意思的.
下面将汇编代码以C语句为分区解释其作用.
push ebpmov ebp,espsub esp,0E4hpush ebxpush esipush edi
- 前6句:
- 首先将ebp的值压入栈顶
- 将esp移动到ebp的位置
- 将esp向低地址移动0E4h个字节的位置
- 暂时无需理会ebx,esi和edi
注意:
- 局部变量和函数栈帧占用的内存是在栈区开辟的.记住栈区的地址是从高到低.(暂时先记住它好了,附程序在内存中运行的奥秘)
- 既然是栈区,那么它符合栈的特点,即先进后出.
- 关于最后的ebx,esi,edi,只需知道它们的存在即可
lea edi,[ebp-24h]mov ecx,9mov eax,0CCCCCCCChrep stos dword ptr es:[edi]
- 后6句:
- 将[ebp-24h]存入edi中
- 将9存入ecx中
- 将0CCCCCCCCh存入eax中
- 将edi的值对应的地址处开始,将高于该地址共ecx个单位的值置为0CCCCCCCCh

int a = 10;mov dword ptr [ebp-8],0Ahint b = 20;mov dword ptr [ebp-14h],14hint c = Add(a, b);mov eax,dword ptr [ebp-14h]push eaxmov ecx,dword ptr [ebp-8]push ecxcall 011C10B4add esp,8mov dword ptr [ebp-20h],eax
- 1-9句:
- 为临时变量a,b,c创建空间,并同时将它们的值存入
- 将上面b和a的值分别放入eax和ecx中,并将它们先后压入栈中
- 12句:
- 记录当前语句的下一条语句的地址(即add esp, 8的地址),以便后续调用完函数以后能继续从原地开始执行
- 调用Add函数
- 13-14句:
- 先进入Add函数后才会跳回来执行

至此,为main函数开辟的函数栈帧已经创建完毕,回顾并小结
3.1.1 小结
- 函数栈帧的创建就是元素入栈的过程
- esp始终指向栈顶元素,即esp会随着栈顶元素的增加而移动.所以main函数的栈帧所占内存会根据esp的指向而变化
- 在该函数中的临时变量会根据先后顺序,从低地址开始向高地址存放.(注意:各临时变量的相对距离由编译器和平台决定,但相对位置是一定的)
- 实际上main函数的栈帧开辟完成后,还要传参.也就是说:函数传参并不是在被调用函数中进行的,而是在为main函数创建栈帧进行的
3.2 Add函数的栈帧的创建
从main函数的call指令开始,Add函数被调用,Add函数的栈帧开始被创建
下面给出其C语句对应的汇编代码:
int Add(int x, int y){push ebpmov ebp,espsub esp,0CChpush ebxpush esipush edilea edi,[ebp-0Ch]mov ecx,3mov eax,0CCCCCCChrep stos dword ptr es:[edi]mov ecx,11CC003hcall 011C130Cint z = x + y;mov eax,dword ptr [ebp+8]add eax,dword ptr [ebp+0Ch]mov dword ptr [ebp-8],eaxreturn z;mov eax,dword ptr [ebp-8]}pop edipop esipop ebxadd esp,0CChcmp ebp,espcall 011C1235mov esp,ebppop ebpret
可以发现:在call指令之前的所有指令都与main函数栈帧的创建别无二致.
- 16-18
- 通过ebp的值加上一个值,找到刚刚在main函数中复制的参数.
- 将参数运算以后的值存入eax
- 再通过ebp的值减去一个值,找到在Add函数中的z,并将eax的值存入
- 20
- 在为函数栈帧开辟好内存空间以后,首先会为函数中的临时变量找到位置
- 函数的形参并不会在该函数内部创建
- 函数在返回后,z的值并不会消失,因为eax是独立于内存和系统之外的硬件
- ebp起着非常重要的作用,通过它才能向上和向下查找变量的位置
4. 函数栈帧的销毁
由于函数栈帧的销毁过程大致一样,所以下面仅演示Add函数栈帧的销毁过程
pop edipop esipop ebxadd esp,0CCh

1-4:
- 将edi,esi和ebx弹出栈.
- 将esp下移
call 011C1235mov esp,ebppop ebpret

1-4:
- 内存空间的销毁,是针对某个对象的”销毁”,而不是将某些内存块的值改成某些数字.对于函数栈帧,ebp和esp维护的内存范围就是该函数的栈帧,如果想要销毁某部分,只需要让这两个指针的范围发生相应的变化即可.就像在顺序表链表中我们要删除某个元素,只需要将计数器或者指针发生变化即可.
- 同一段代码在不同编译器和平台上对应的汇编指令可能是不同的,但其逻辑是不变的
总结
- 函数栈帧的创建和销毁是两个(大致)互逆的过程。都需要做前期准备。
- esp和ebp指针维护的内存范围即为该函数的栈帧,esp会随着栈顶元素的增加而变化。
- call指令的重要性:它是进入函数的入口。也是从a函数回到调用a函数的函数的入口,因为call指令在调用函数之前,把当前语句的下一个语句的地址压入栈中。当调用函数完毕后,被调用函数的栈帧被销毁,栈顶一定会遇到之前保存的地址,通过该地址就能回到之前调用函数的地方,继续执行语句。
- epb的重要性:这里强调的是不同函数对应的epb的值,而不是epb这个指针(当然它也很重要)。第5点会举例。
- 函数传参实际上在上一个函数就已经传递完毕,形参实质上就是实参在原函数中的一份拷贝,它们都处于原函数的栈帧中。第4点的举例:前一句话说明:被调用函数的形参不是在该函数的栈帧中创建的,是通过ebp减去某个值,找到上一个函数中的拷贝。而被调用的函数的运算结果需要通过ebp加上某个值,回到被调用函数的栈帧中,存放在创建的临时变量中。
- 函数返回值(如果有返回值)在被调用完毕后是不会随着栈帧的销毁而消失的。因为返回值在函数栈帧被销毁之前被存放在某个寄存器中,而寄存器是独立于内存和系统之外的硬件,上一个函数直接取得该寄存器中的值即能得到返回值。
