目录
本节课我们接上节的函数,继续来讲函数的递归中的栈帧,还有两个经典问题
一.栈帧
1.概念
每一个函数调用,都要在栈区创建个空间
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
1、函数参数和函数返回值
2、临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3、保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
(1) 栈
栈是一种数据结构,是一种只能在一端进行插入和删除操作的特殊线性表。它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后放入的数据被最先读出来)。
简单来讲你可以把栈理解为一个弹夹,而我们放的数据就像子弹,当我们射子弹时,总是会把后压入的弹先射出去,因为后压入的弹一定是放在最上面的,而先压入的弹后射出去,因为先压入的弹在最下面。这就是栈最大的特点"先入后出,后入先出",而往栈中放数据我们称作压栈(push),拿出栈中的数据我们叫出栈(pop)。
(2)压栈push
(3)出栈pop
2.了解寄存器
寄存器的功能是存储二进制代码,它是由具有存储功能的触发器组合起来构成的。一个触发器可以存储1位二进制代码,故存放n位二进制代码的寄存器,需用n个触发器来构成
我们这节课需要知道的
eax (通用寄存器) | 通常用来执行加法,函数调用的返回值一般也放在这里面 |
ebx (通用寄存器) | 保留临时数据 |
eip (指令寄存器) | 最重要的寄存器,它指向了下一条要执行的指令所存放的地址 |
esp (通用寄存器) | 栈顶寄存器,指向栈的顶部 |
ebp (通用寄存器) | 栈底寄存器,指向栈的底部,通常用ebp+偏移量的形式来定位函数存放在栈中的局部变量 |
我们最重要的看esp和ebp这两个,这两个寄存器是放地址的地方,这两个地址就是维护函数栈帧的地址寄存器。
简单来讲就是esp和ebp是两个指针,ebp指向当前栈帧栈底,esp指向函数栈栈顶。
能看到,ebp并不是指向整个函数栈的栈底,而是指向当前栈帧的栈底,而由于esp总是指向栈顶,且栈只允许一个方向的操作,因此esp指向其实也是当前栈帧的栈顶,不过当前栈帧的栈顶始终与栈顶相同,因此说esp指向的是栈顶。
现在我们图解来看一下入栈和出栈
(1)入栈
(2)出栈
3.了解汇编指令
汇编指令是汇编语言中使用的一些操作符和助记符,还包括一些伪指令(如assume,end),汇编指令同机器指令一一对应。每一种CPU都有自己的汇编指令集。
我们这节课需要知道的
mov (通用数据传送指令) | 数据转移指令 |
push (通用数据传送指令) | 数据入栈,同时esp栈顶寄存器也要发生改变 |
pop (通用数据传送指令) | 数据弹出至指定位置,同时esp栈顶寄存器也要发生改变 |
sub (算术运算指令) | 减法 |
add (算术运算指令) | 加法 |
call (子程序调用指令) | 函数调用1. 压入返回地址 2. 转入目标函数 |
jump(无条件程序转移指令) | 通过修改eip,转入目标函数,进行调用 |
ret (子程序或函数返回指令) | 恢复返回地址,压入eip,类似pop eip命令 |
4.main函数的调用
我们用加法代码来仔细分析
#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;
}
main 函数是被 __tmainCRTStartup 调用的,而这个函数是被 mainCRTStartup给调用
5.main函数栈帧的创建
(1)main汇编语言
int main()
{
003A18B0 push ebp//在栈中压入ebp的值
003A18B1 mov ebp,esp//把esp的值给ebp
003A18B3 sub esp,0E4h//把esp的值减去0E4h
003A18B9 push ebx//在栈中压入ebx的值
003A18BA push esi//在栈中压入esi的值
003A18BB push edi//在栈中压入edi的值
003A18BC lea edi,[ebp-24h]//把ebp-24h放入edi中
003A18BF mov ecx,9//把9的值给ecx
003A18C4 mov eax,0CCCCCCCCh//把0CCCCCCCCh放入eax
003A18C9 rep stos dword ptr es:[edi]//从edi开始向下ecx的区域放入eax
(2)图解
6.变量的栈帧创建
(1)变量的汇编语言
int a = 10;
003A18D5 mov dword ptr [ebp-8],0Ah//在ebp-8位置处放入0Ah,即a的值
int b = 20;
003A18DC mov dword ptr [ebp-14h],14h//在ebp-14h位置处放入14,即b的值
int c = 0;
003A18E3 mov dword ptr [ebp-20h],0//在ebp-20h位置处放入0,即c的值
(2)图解
7.函数传参
(1)传参的汇编语言
c = Add(a, b);
003A18EA mov eax,dword ptr [ebp-14h]//把ebp-14h地址的值放入eax中
003A18ED push eax//压入eax
003A18EE mov ecx,dword ptr [ebp-8]//把ebp-8地址的值放入ecx中
003A18F1 push ecx//压入ecx
003A18F2 call 003A10B4//调用add函数,栈顶保存call指令的下一条指令
003A18F7 add esp,8//形参销毁
003A18FA mov dword ptr [ebp-20h],eax//形参销毁
(2)图解
8.函数内部运算和销毁
(1)函数的汇编代码
int Add(int x, int y)
{
003A1770 push ebp
003A1771 mov ebp,esp
003A1773 sub esp,0CCh
003A1779 push ebx
003A177A push esi
003A177B push edi
003A177C lea edi,[ebp-0Ch]
003A177F mov ecx,3
003A1784 mov eax,0CCCCCCCCh
003A1789 rep stos dword ptr es:[edi]
003A178B mov ecx,3AC008h
003A1790 call 003A131B
int z = 0;
003A1795 mov dword ptr [ebp-8],0
z = x + y;
003A179C mov eax,dword ptr [ebp+8]
003A179F add eax,dword ptr [ebp+0Ch]
003A17A2 mov dword ptr [ebp-8],eax
return z;
003A17A5 mov eax,dword ptr [ebp-8]//把ebp-8的值放到eax寄存器中,让寄存器把结果带出函数
}
003A17A8 pop edi//弹出edi,同时esp地址增加
003A17A9 pop esi
003A17AA pop ebx
003A17AB add esp,0CCh
003A17B1 cmp ebp,esp
003A17B3 call 003A1244
003A17B8 mov esp,ebp
003A17BA pop ebp//通过ebp找回main的栈底
003A17BB ret
(2)图解
(3)函数中的总结
1. 将main函数的 ebp 压栈
2. 计算新的 ebp 和 esp
3. 将 ebx , esi , edi 寄存器的值保存
4. 计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的函数参数,这就是形参访问。
5. 将求出的和放在 eax 寄存器中准备带回
6.销毁Add函数栈帧,返回eax值
7.程序继续在main函数中运行
9.总结
最后我们放个完整的图解
内存空间大小是固定的,所谓的开辟内存指的是把一块内存空间变为有效空间得以利用。
用寄存器ebp和esp存储地址来进行内存管理,所谓的创建与销毁内存也是通过对ebp和esp存储的地址的改变来实现的,在ebp和esp之间的空间才是系统分配的空间,其他的都是无权限的空间。
10.解决问题
什么是函数栈帧?
答:函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,用以存放
1.函数参数和函数返回值
2.临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3.保存上下文信息(调用信息)。
函数是如何调用的?
答:先传参,也就是把参数的值分别放在寄存器中,然后再push压入栈中;把主调函数ebp的值和下一条指令的地址push压入栈中,随后进入调用的函数中,创建函数栈帧并初始化,然后执行函数内的语句。
函数调用时参数时如何传递的?传参的顺序是怎样的?
答:其实传参就是把参数push到栈帧空间中,传参时先压入的是后面参数的值,(参数,参数,...)从右往左压入。
函数的形参和实参分别是怎样实例化的?
(实例化一个对象就是为对象开辟内存空间)
答:形参通过寄存器的值压栈创建,而实参通过 ebp 存储的地址进行偏移,由编译器决定一块未使用空间创建。
形参和实参又是什么样的关系呢?
答:形参是实参的一份临时拷贝,改变形参不会影响实参。
函数的返回值又是如何返回的?
答:通过寄存器保留副本,不会随栈帧销而销毁,毁待函数调用完栈帧销毁后把寄存器的值拷贝到主调函数中对应的变量里,实现返回值的带回。
局部变量是如何创建的?
答:局部变量是在局部变量所在函数的栈帧空间中创建的,通过 ebp 存储的地址进行偏移,由编译器决定一块未使用空间创建。
为什么局部变量若不人为初始化,内容是随机的?
答:函数栈帧创建后会自动将空间中存储的值全部初始化为一个特定值(如VS2019下为0xcccccccc),编译器不同值也不同。
借鉴博主:蒋灵瑜的笔记本
二.两个经典问题
1.汉诺塔
(1)背景
汉诺塔(Tower of Hanoi),又称河内塔,是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。(源自百度百科)
(2)图解
(3)移动次数
我们可以分析一下,从3层的,往上数,找出里面的规律
阶数 | 次数 | 规律 |
1 | 1 | 2^1-1 |
2 | 3 | 2^2-1 |
3 | 7 | 2^3-1 |
4 | 15 | 2^4-1 |
..... | ..... | 2^n-1 |
对于n阶汉诺塔的移动次数:
- 步骤1所含步数就是n-1个圆盘移动所需的次数,我们可以将其步数看做f(n-1)。
- 步骤2所含步数为1。
- 步骤3所含步数与步骤1相似,我们也将其步数看做f(n-1)。
再观察表格中汉诺塔的移动次数,对于一阶汉诺塔移动次数就为1,对于其他的阶数则为前一阶汉诺塔移动次数 + 1 + 前一阶汉诺塔移动次数。
我们就能得出表达式:f(n-1) + 1 + f(n-1) = 2 * f(n - 1) + 1
(4)代码实现
#include<stdio.h>
int hanoi_step(int n)
{
if(n<=1)
return 1;
else
return 2*hanoi_step(n-1)+1;
}
int main()
{
int n = 0;
scanf("%d",&n);
int ret = hanoi_step(n);
printf("%d\n",ret);
return 0;
}
得出几阶的需要移动几次
(5)打印步骤
我们还是类比来写,我们写出前四阶的来看,分析后面的
阶数 | 步骤 |
1 | A->C |
2 | A->B,A->C,B->C |
3 | A->C,A->B,C->B,A->C,B->A,B->C,A->C |
4 | A->B,A->C,B->C,A->B,C->A,C->B,A->B,A->C,B->C,B->A,C->A,B->C,A->B,A->C,B->C |
我们观察移动步骤,发现只有一个圆盘时移动步骤为A->C;两个圆盘时,为A->B,A->C,B->C。
那么对于n阶汉诺塔呢,我们对其进行推演:
把n-1个圆盘从A移动到B
把第n个圆盘从A移动到C
把n-1个圆盘从B移动到C
那n-1个圆盘如何从A移动到B呢?
把n-2个圆盘从A移动到C
把第n-1个圆盘从A移动到B
把n-2个圆盘从C移动到B
同样的,对于把n-1个圆盘从B移动到C,也可以推测出来:
把n-2个圆盘从B移动到A
把第n-1个圆盘从B移动到C
把n-2个圆盘从A移动到C
但是这样很难理解,我们化为具体的数字,我们化为64
步骤1、通过一种符合要求的方式将A柱上63个圆盘从A移动到B(我们不要关心这个方式具体是什么)
步骤2、经过步骤1之后A柱上只剩下一个圆盘,我们将这个圆盘从A移动到C(我们所要关心的只有最后将1个圆盘从A移动到C)
步骤3、经过步骤1和步骤2之后,B柱上有了63个圆盘,C上有1个圆盘,我们再通过某种方式将B柱上63个圆盘从B移动到C(同样也不要关心这个具体的方式是什么)
经过了步骤1、 2 、3之后,我们就将所有的圆盘从A移动到了C
步骤1又可以继续拆分:
步骤1.1、将62个圆盘从A柱移动到C柱
步骤1.2、将1个圆盘从A柱移动到B柱
步骤1.3、将62个圆盘从C柱移动到B柱
步骤1.1又可以继续拆分:
1.1.1、将61个圆盘从A柱移动到B柱
1.1.2、将1个圆盘从A柱移动到C柱
1.1.3、将61个圆盘从B柱移动到C柱
继续向下拆分
.
.
.
经过63次拆分后,第64次只需要移动1个圆盘,只需要将1个圆盘移动到领一个圆盘上即可,所以我们就能递归来写
思路:定义A,B,C三个字符,表示A,B,C三柱,定义n为阶数,那么n-1也就是移动步骤中,需要移动的圆盘数。
对于一阶汉诺塔,直接移动即可,对于其他的阶数,则需要通过递归展开,为n阶汉诺塔的移动步骤。
#include<stdio.h>
void hanoi_move(int n,char A,char B,char C)
{
if(1==n)
{
printf("%c->%c\n",A,C);
}
else
{
hanoi_move(n-1,A,C,B);//将n-1个圆盘从A移动到B
printf("%c->%c\n",A,C);//将第n个圆盘从A柱移动到C柱
hanoi_move(n-1,B,A,C);//将n-1个圆盘从B柱移动到C柱
}
}
int main()
{
int n = 0;
scanf("%d",&n);
hanoi_move(n,'A','B','C');
return 0;
}
我们来画个图,然后再用俗话给大家讲一下。
我们用俗话说一下,我们用下面这个来说
这里可能代码不好理解,我们分开写为两代码
#include<stdio.h>
void Move_(char From, char Dest) //移动一个圆盘,将圆盘从来源移动到目的地 从From 移动到Dest
{
printf("将一个圆盘从%c柱子 -> %c柱子\n", From, Dest);
}
void Hanoi( char A,char B,char C,int n) //总共有n个圆盘,将这n个圆盘 借助 B 柱子 从 A 柱子移动到 C 柱子
{
if (n == 1) //当只有一个圆盘时,直接圆盘从 A 柱 移动到 C 柱
{
Move_(A, C);
}
else
{
Hanoi(A,C,B,n - 1); //当不只一个圆盘时,我们先将上面 (n -1)个圆盘 借助 C柱子 从 A 柱子移动到 B 柱子
Move_(A, C); //A柱剩余一个圆盘,将剩下的一个圆盘从 A 移动到 C
Hanoi(B, A, C, n - 1); //再将(n-1)个圆盘 借助 A柱子 从 B柱子 移动到 C柱子
}
}
int main()
{
int n = 0; //汉诺塔层数
char A = 'A'; //A柱子
char B = 'B'; //B柱子
char C = 'C'; //C柱子
scanf("%d", &n);
Hanoi(A,B,C,n); //将n个圆盘,借助于B柱子,从A柱子移动到C柱子
return 0;
}
Hanoi(A,C,B,n - 1);
Move_(A, C);
Hanoi(B, A, C, n - 1);我们就看这三步,我们把这个问题就是想成,A上的最下面的是n,上面的都是n-1,我们就是要把这n-1个借助C来放到B上,所以我们的代码就是ACB
然后我们第二步就是,把A上的那个最下面的n放到C上去,所以我们的代码就是AC
然后我们第三步就是,把B上的n-1,借助于A放到现在C最大的n的上面,所以我们的代码就是BAC
这就是我们的递归,n-1上面的重复这个过程,就解决了我们的这个问题。
2.青蛙跳台阶
一只青蛙一次最少可以跳跃1级台阶,一次最多可以跳跃2级台阶,求:该只青蛙跳上n级台阶共有多少种跳跃方法?
其实这道题你分析完会发现就是很像斐波那契数列,我们来一步一步分析
(1)图解
(2)分析
我们发现我们知道前两个跳台阶的方法数,加一起就是第三种台阶的跳法
加入我们求跳n阶台阶的方法数,n=(n-1)+(n-2);
(3)代码实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
int JumpFloor3(int n)
{
int an = 0;
int a1 = 1;
int a2 = 2;
if (n == 1) //第1级台阶,青蛙有1种跳跃方法
{
an = a1;
}
else if (n == 2) //第2级台阶,青蛙有2种跳跃方法
{
an = a2;
}
else //第2级往后台阶,跳跃方法种数为前两级跳跃方法总数之和
{
int i;
for (i = 3; i <= n; i++)
{
an = a1 + a2;
a1 = a2;
a2 = an;
}
}
return an;
}
int main()
{
int x; //总共需要跳的台阶级数
printf("请输入青蛙总共需要跳跃的台阶级数:");
scanf("%d", &x);
int count = JumpFloor3(x); //调用青蛙跳台阶函数解决问题
printf("一只青蛙要跳上%d级的台阶,每次跳跃1或2级,共有%d种跳跃方法\n", x, count); //输出结果
return 0;
}
这个还是相对于汉诺塔相对简单的,我们认真分析里的递归就可以
结束语
这节课讲了栈帧,不好理解,多看多演示,还有两个经典递归,递归的思想就是可以重复的倒着推演,下节课我们讲数组,记得关注!!!