【C语言】函数(递归、函数栈帧)

目录

目录

函数递归

函数栈帧的创建与销毁

概述

 main 函数栈帧的创建

变量的创建

如何传参

子函数栈帧的创建

函数如何返回值(1)

子函数栈帧的销毁

函数如何返回值(2)

函数易错点


函数递归

将复杂的问题层层化为与原问题相似的规模较小的问题。

递 ---- 递推、归 ---- 回归 

递推 :函数一直自己调用自己(调用一个函数时,要把这个函数执行完才执行接下来的语句)直到不满足或满足某个条件停止递推,无限递推没有意义。

(每一次调用函数,都要在内存的栈区里开辟一块空间,为本次函数调用分配的内存空间叫被称为这次函数调用的栈帧空间。若函数无限的递归或递归次数太多,会把内存的栈区消耗完,称为栈溢出,这是函数递归的缺点。有时候,用函数递归能解决的问题,用迭代(循环)也能解决问题,迭代的效率有时候比递推快,比如用迭代求斐波那契数列的第50个数的效率就比用递推快多了,但迭代也可能出现溢出的问题,比如计算10000的阶乘,会超出 int 类型的取值范围)

回归 :当不满足或满足某个条件后,最后一次调用的函数的函数体也执行完后,返回到上一次调用该函数的地方,执行完上一次调用该函数的函数的剩余的语句后( 也可能没有剩余的语句,直接返回到上上一次调用该函数的地方),再返回到上上一次调用该函数的地方 ... 直到返回到第一次调用该函数的地方。

递归的两个必要条件
● 存在限制条件,当满足这个限制条件的时候,递归便不再继续。

● 每次递归调用之后越来越接近这个限制条件。

例子:

汉诺塔问题

假设第一个人表示为 (1),第二个人表示为 (2),第 n 个人表示为 (n)

A柱有 64 个盘子,B柱和C柱没有盘子

第一个人做的事情:

(1):(2)63 A -> B , A -> C , (2) 63 B -> C

根据第一个人做的事情,如果 A = 1(起),B = 2(辅), C= 3(终)

第二个人第一次做的事情:

(2):(3)62 A -> C , A -> B , (3) 62 C -> B 

或((3)62 1 -> 2 , 1 -> 3 , (3) 62 2 -> 3 )

根据第二个人做的事情,如果 A = 1(起),C = 2(辅), B= 3(终)

第二个人第二次做的事情:

(2):(3)62 B -> A , B -> C , (3) 62 A -> C 

或((3)62 1 -> 2 , 1 -> 3 , (3) 62 2 -> 3 )与上次一样

#include <stdio.h>
void move(char x, char y)
{
	printf("%c->%c\n", x, y);
}
void hanoi(int n, char one, char two, char three)
{
	if (n == 1)
	{
		move(one, three);
	}
	else
	{
		hanoi(n - 1, one, three, two);
		move(one, three);
		hanoi(n - 1,two, one, three);
	}
}

int main()
{
	int n = 0;
	printf("Please input the number of disk:\n");
	scanf("%d", &n);
	hanoi(n, 'A', 'B', 'C');
	return 0;
}

若输入 3,

模拟实现 strlen 函数

#include <stdio.h>
#include <string.h>
int my_strlen(char* a)
{
	if (*a != '\0')
		return 1 + my_strlen(a+1);
	else
		return 0;
}
int main() 
{ 
	char a[] = "bit";
	int r = my_strlen(a);
	printf("%d\n", r);
	
}


红线是递推过程,蓝线是回归过程

 阶乘

int Fac(int n)
{
	if (n <= 1)
		return 1;
	else
		return n * Fac(n - 1);
}
int main()
{
	int n = 0;
	scanf("%d", &n);
	int r = Fac(n);
	printf("%d\n", r);
}

黑线递推,红线回归


函数栈帧的创建与销毁

我们在学习C语言时常常有以下疑问:

比如:
● 局部变量是怎么创建的?
● 为什么局部变量的不初始化的值是随机值?
● 函数是怎么传参的? 传参的顺序是怎样的?
● 形参和实参是什么关系?
● 函数调用是怎么做的?
● 函数调用是结束后怎么返回的?
 

学习函数栈帧的创建与销毁后,这些疑问就能迎刃而解了。

概述

每一次调用函数,都要在内存的栈区里开辟一块空间,为本次函数调用分配的内存空间叫被称为这次函数调用的栈帧空间。

ebp(栈低指针), esp(栈顶指针) 这2个寄存器中存放的是地址

这2个地址是用来维护当前调用的函数的栈帧的

栈底的地址比栈顶的要大,地址增大,指针向下移动,地址减小,指针向上移动


 

具体的细节: (以下面 C 语言代码为例)

上图 main 函数的部分汇编代码: 

 main 函数栈帧的创建

 下图是调用 main 函数之前的情况,edp 和 esp 正在维护__tmainCRTStartup 这个函数
 

接下来开始执行上图的汇编代码了,我们暂不讨论汇编指令的细节,只需知道它们的大概意思。

首先执行第一行汇编代码 push edp:

执行 push edp 之后:

push:压栈,将 edp 放在栈顶,并使 esp 向上移动,栈底的地址比栈顶的要大,可以用监视功能观察 esp 的值减小了。

压栈(push):在栈顶放一个元素,出栈(pop):从栈顶删除一个元素

接下来执行 mov edp ,esp:

  mov edp ,esp 这句话的意思是将 esp 的值赋给 edp ,赋值后 esp 和 edp 就都指向同一个地址了。

接下来执行 sub esp 0E4h;

sub esp 0E4h 的意思是将 esp 的值减去 0E4 ,0E4h是一个八进制数(h 是编译器加的,不用理会,看成0E4就好了)十进制大小是 228 ,之前edp 和 esp维护的是  __tmainCRTStartup 的函数栈帧,到现在为止,edp 和 esp 已经在维护一段新的栈帧,这段新的栈帧其实就是为 main 函数开辟好的栈帧。原来,main 函数的汇编代码的前三句是在为 main 函数开辟栈帧。

接下来执行 :

push ebx

push esi

push edi

不用知道 ebx 、 esi 、 edi 都是什么,只要知道它们都是寄存器。

接下来执行这四条语句:

结果:

lea edi ,[ edp+FFFFFF1Ch] 其中 lea 是 load effictive address (加载有效地址)的缩写,FFFFFF1Ch 的值其实是 - 0E4h,edp - 0E4h 就是图中紫色箭头的位置。

lea edi ,[ edp - 0E4h] 的意思是将edp - 0E4h这个地址加载到 edi 中。

mov ecx ,39h 的意思就是将 39h 赋值给 ecx ,mov eax ,0CCCCCCCCh 的意思是将0CCCCCCCCh赋值给 eax 。

rep stos dword ptr es : [edi] 的意思:

一个word是两个字节,dword 就是 double word 即四个字节的意思,整句话的意思是将从紫色箭头(edi)的位置开始,每次初始化四个字节,一共初始化 39h 次(初始化次数取决于编译器),初始化的内容是 eax 中存储的内容(0CCCCCCCCh)。

(图中CCCCCCCC的数量不代表实际数量,只作示意)

到目前为止,为 main 函数开辟栈帧并初始化的工作已经结束。接下来才轮到 int a = 10;的汇编代码(汇编代码是不是很麻烦,所以C语言简化了程序设计)

变量的创建

接下来执行

mov dword ptr [edp - 8],0Ah (int a = 10;的汇编代码)

mov dword ptr [edp - 14h],14h (int b = 20;的汇编代码)

mov dword ptr [edp - 20h],0 (int c = 0;的汇编代码)

mov dword ptr [edp - 8],0Ah 的意思是将地址为 edp - 8 的存储单元存储的值改为 0Ah (0Ah 就是十进制数 10)edp - 8 就是变量 a 的地址了,b 和 c 变量同理。

栈区的使用习惯:先使用高地址的空间后使用低地址的空间。

如果定义变量的时候不赋初值,那么这个变量不就是存储的 0CCCCCCCCh 吗。所以变量要初始化。变量存储空间之间相隔了几个字节取决于编译器(可能连在一起,也可能分隔)

如何传参

这是接下来的汇编代码:

 接下来执行:

mov eax, dword ptr [ebp-14h]

push eax

mov ecx, dword ptr [ebp-8]

push ecx

 mov eax, dword ptr [ebp-14h] 的意思是将地址为 ebp-14h (变量 b 的地址)的存储单元存放的值(20)放在 eax 这个寄存器中,然后 push eax 即对 eax 压栈。mov ecx, dword ptr [ebp-8] 和 push ecx 同理。

这就是在传参了,我们看到,先传的是 b 的值,说明函数参数是从右向左传的。

接下来执行 call 指令(push ecx 的下一条指令):

 call 指令的作用是记住 call 指令的下一条指令的地址,以便在执行完 Add 函数后返回到 main 函数。

子函数栈帧的创建

调试时按下 F11 以进入 Add 函数,以下是 Add 函数的汇编代码:

可以看到以上汇编代码的第一行到第十行与 main 的汇编代码的第一行到第十行很相似——为 Add 函数创建函数栈帧。

创建 Add 函数的栈帧的过程与创建 main 函数的函数栈帧的过程相似,执行以上汇编代码的第一行到第十行:

edp 和 esp 现在开始维护 Add 函数的栈帧了。

接下来执行:

mov dword ptr [edp - 8],0 (int z = 0;的汇编代码)

与创建变量 a、b、c时一样:mov dword ptr [edp - 8],0 的意思是将地址为 edp - 8 的存储单元存储的值改为 0 ,edp - 8 就是变量 z 的地址了。

接下来执行:

mov eax, dword ptr [ebp+8]

add eax, dword ptr [ebp+0Ch]   // z = x + y;的汇编代码

mov dword ptr [ebp-8], eax


 

mov eax, dword ptr [ebp+8] :将地址为 edp + 8 的存储单元的存放的值(形参x)放在 eax 这个寄存器中。

 add eax, dword ptr [ebp+0Ch] :与地址为 edp + 0Ch 的存储单元的存放的值(形参y)相加,结果放在 eax 这个寄存器中。

(0Ch 是十六进制数,十进制为12,add 是汇编代码指令,不是我们定义的函数 Add)

mov dword ptr [ebp-8], eax :将 eax 这个寄存器中存放的值放在地址为 edp - 8 的存储单元中(变量 z)。

这样,变量 z 就是 x + y 的值了。

函数如何返回值(1)

接下来应该返回 z 的值了,执行:

mov eax,dword ptr [edp - 8]

意思是将地址为 edp - 8 的存储单元的存放的值(变量 z)放在 eax 这个寄存器

当退出 Add 函数后,变量 z 被销毁了,但 eax 寄存器仍然留存变量 z 的值。

子函数栈帧的销毁

这是接下来的汇编代码:

接下来执行:

pop edi

pop esi

pop ebx

pop 就是出栈,esp向下移动

接下来执行:

mov esp,edp

 mov esp,edp 意思是将 edp  的值赋给 esp ,这样,edp 和 esp 指向一样。

接下来执行:

pop edp

这样,esp 和 edp 重新维护 main 函数了,Add 函数的栈帧被销毁了。

函数如何返回值(2)

接下来执行 ret 指令,ret 指令的作用是让程序回到 call 指令的下一条语句(图中蓝色框),现在程序回到了 main 函数。

接下来执行 add  esp,8

 退出 Add 函数后,形参 x,y 没有用处了。add esp,8 的意思是让 esp 的地址增加 8 个字节,esp 现在的位置如上图所示。

接下来执行:

mov dword ptr [ebp-20h], eax

 mov dword ptr [ebp-20h], eax 的意思是将寄存器 eax 存储的值(30)赋值给地址为 edp - 20h(变量 c 的地址)的变量中,这样 函数 Add 的返回值 z 返回到了 main 函数的变量 c 中。

总结:

函数返回值时,通常将返回的值先存储在寄存器中,等退出函数时,函数栈帧被销毁,此时待返回的值在寄存器中。在返回到的函数栈帧中,寄存器的值赋值给调用函数的表达式,作为该表达式的值。寄存器的大小通常只有 4 字节。

如果返回的值是静态的变量,那么该返回值仍会被存储到寄存器里去吗?

答案是不会,因为这样可以简化编译器的设计,编译器不想考虑多种情况,它想统一处理。 

函数易错点

1、

返回指针的函数要小心!函数一旦返回值,该函数栈帧就不存在了,该函数占用的内存空间也还给了操作系统。

int* test()
{
    int a = 10; 
    return &a;
    //a 变量的存储空间已经还给了操作系统
}

int main()
{

    int* p = test(); 
    //print("hehe\n");//如果在打印*p前有其他代码,*p 的值就不一定是 10 了
    printf("%d\n", *p); //虽然打印了 10 ,但这是侥幸的,因为 a 的空间没有被覆盖

    return 0;
}

在打印 *p 之前调用了 printf 函数,这个 printf 函数的函数栈帧就可能还是使用 test 函数使用过的空间,就把 test 函数使用过的空间覆盖掉了,*p 的值就不确定了。

2、

main 函数调用并执行完一个函数后,再调用另一个函数,这两个函数是使用同一片函数栈帧空间

(F1 和 F2 使用同一片函数栈帧空间)

3、(斐波那契的函数递归调用顺序)

函数的递归调用顺序如何呢?Fib(N) 是同时调用 Fib(N-1) 和 Fib(N-2) 吗?正确的调用顺序:

虽然 Fib(N) 要调用 FIb(N-1) 和 Fib(N-2) ,FIb(N-1) 要调用 Fib(N-2) 和 Fib(N-3) ......,但是 Fib(N) 不是同时调用 FIb(N-1) 和 Fib(N-2) 的,是等 FIb(N-1) 返回值后再调用 Fib(N-2) 的,以此类推,FIb(N-1) 也不是同时调用 Fib(N-2) 和 Fib(N-3) 的。正确的调用顺序是:Fib(N) 先调用 FIb(N-1),FIb(N-1) 再调用 Fib(N-2) ......直到 Fib(2) 返回 1 给 Fib(3) 后,Fib(3) 再调用 Fib(1),Fib(3) 返回值给 Fib(4) 后,Fib(4) 再调用 Fib(2) ......,而 Fib(3) 调用 Fib(2) 和 Fib(1) 都是使用的同一片函数栈帧空间,所以当 Fib(N) 执行完后,一共开辟了 N 个函数栈帧空间。

示意图:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值