函数调用栈和递归函数分析以及尾递归、缓冲区溢出的讲解

知乎上有大神曾经说递归有时候更像去查字典,我们一开始想要知道这句话什么意思,里面有词语不认识,查字典,字典的解释又有新的我们不认识的词语,然后一直查找,直到我们弄懂了,我们开始回去理解上一层词语的意思,不断往上,直到最后我们理解了最初的那句话的意思。 

查字典:调用栈中的递归函数的一个个返回结果
回去理解:利用一个个返回结果进行后续函数执行(理解)!
以下代码理解栈:

/*!
 * \file main.cpp
 * \date 2018/12/26 20:42
 *
 * \author wangjinju
 * Contact: user@company.com
 *
 * \brief  仅用递归函数和栈操作逆序一个栈
 *
 * TODO: long description
 *
 * \note
*/
#include <stack>
#include <vector>
#include <iostream>
using namespace std;


//将栈stack的栈底元素返回并移除
template <typename T>
T getAndRemoveLastEle(stack<T>& s)
{
	T result = s.top();
	s.pop();
	if (s.empty())
		return result;
	else
	{
		T last = getAndRemoveLastEle(s);
		s.push(result);
		return last;
	}
}

//逆序一个栈
template <typename T>
void reverse(stack<T>& s)
{
	if (s.empty())
		return;
	else
	{
		auto last = getAndRemoveLastEle(s);
		reverse(s);
		s.push(last);
	}
}

int main()
{
	vector<int> v = { 0,1,2,3 };
	stack<int> s;
	for (auto item : v)
		s.push(item);

	reverse(s);

	for (; !s.empty();)
	{
		cout << s.top() << endl;
		s.pop();
	}

	cin.get();
	return 0;
}

 

  1. 什么是调用栈?作用是什么

调用栈(执行栈)

在Windows 等大部分操作系统中, 每个运行中的二进制程序都配有一个调用栈( call stack ) 或执行栈( execution stack ) 。

调用栈的作用首先在于跟踪属于同一程序的所有函数, 记录它们之间的相互调用, 以保证被调用的函数执行完毕后可以准确地返回调用函数。

调用栈以帧( frame )作为基本单位。每次函数调用时, 都会相应地创建一帧, 记录其在二进制程序中的返回地址( return address ) , 井将该帧(地址)压人调用栈,若在该函数返回之前又发生新的调用, 则同样地要将与新函数对应的一帧压人栈中, 成为新的栈顶,函数一旦运行完毕, 对应的帧随即弹出, 操作系统将把运行控制权交还给该函数的上层调用函数,并按照该帧中记录的返回地址确定在二进制程序中继续执行的位置。

因此, 在任一时刻调用栈中的各帧,分别对应于那些己被调用但尚未返回的函数实例,称作这时刻的活跃函数实例( active function instance ) ,只有一个。特别地,位于栈底的那帧必然对应于程序运行的入口主函数main() , 它从调用栈中弹出意味着整个程序的运行结束,此后控制权将交还给操作系统。

栈结构(https://wdxtub.com/2016/04/16/thin-csapp-2/,过程调用部分)

在 x86-64 中,所谓的栈,实际上一块内存区域,这个区域的数据进出满足先进后出的原则。越新入栈的数据,地址越低,所以栈顶的地址是最小的。下图中箭头所指的就是寄存器 %rsp 的值,这个寄存器是栈指针,用来记录栈顶的位置
我们假设一开始 %rsp 为红色,对于 push 操作,对应的是 pushq Src 指令,具体会完成下面三个步骤:

  1. 从地址 Src 中取出操作数
  2. 把 %rsp 中的地址减去 8(也就是到下一个位置)
  3. 把操作数写入到 %rsp 的新地址中

这个时候 %rsp 就对应蓝色。

重来一次,假设一开始 %rsp 为红色,对于 pop 操作,对应的是 popq Dest 指令,具体会完成下面三个步骤:

  1. 从 %rsp 中存储的地址中读入数据
  2. 把 %rsp 中的地址增加 8(回到上一个位置)
  3. 把刚才取出来的值放到 Dest 中(这里必须是一个寄存器)

这时候 %rsp 就对应黄色。

递归

  1. 递归和函数调用的关系?递归只有一个函数,怎么对应上调用栈
  2. 怎么避免递归无休止?
  3. 递归函数都可以用循环的方式表达吗?哪种逻辑清晰?
  4. 栈溢出是什么,在递归中使怎么体现出来的?

递归的理解

递归既可等效地理解为函数的自我调用

 

可以将递归实例视作一般的函数调用,井在调用栈中为其创建对应的一帧。 如此,同一个函数可能同时拥有多个实例, 并在调用栈中分别存有一帧。当然,即便是同一函数的不同实例,在各自的帧中对同名的参数或变量都有独立的副本, 故其数值不尽相同。

递归的注意事项

  1. 一定要有平凡情况称递归基,出现递归基时,可以结束算法
  2. 防止栈溢出。在计算机中,函数调用是通过栈(stack)这种数据结构实现的,每当进入一个函数调用,栈就会加一层栈帧,每当函数返回,栈就会减一层栈帧由于栈的大小不是无限的,所以,递归调用的次数过多,会导致栈溢出

  3. 用好递归,要时刻明白函数是干什么用的!!!并且注意每一帧函数值和传入参数的改变

递归的分类

线性递归,二分递归,多分支递归等

递归的例子

  • 汉诺塔移动问题:a盘上有n个盘子(上小下大),全部移动到c盘上,大盘在下,小盘在上

思路:

  1. 仍然是倒着考虑问题,现将a盘最下面的盘子放到目标c上,通过递归很容易实现!
  2. 步骤1之后,a盘空,b盘有n-1个盘子!
def hannotaMove(n,a,b,c):
    if n == 1:
        print("move",a,'-->',c) #递归基(非递归处理)base case of recursion,,可能不止一个
    else:
        hannotaMove(n-1,a,c,b) 
        hannotaMove(1,a,b,c)
        hannotaMove(n-1,b,a,c)
hannotaMove(4,'A','B','C')

 

  • 利用不同方法的递归,计算Fibonacci数列的第n项:输出Fibonacci数列的第n项

1.二分递归版

int fib(int n)//O(2^n):指数复杂度,实际中无意义
{
    //若到达递归基,直接取值
    return (2>n)?(int)n:fib(n-1)+fib(n-2);
}

 

2.线性递归版:改进

为什么会改进?因为将每次计算的结果存了起来,不用重复计算了

int fib(int n, int &prev)//prev辅助变量记录前一项,复杂度O(n)
{
    if(n == 0)
    {
        prev = 1;
        return 0;
    }
    else
    {
        int prevPrev;
        prev = fib(n-1,prevPrev);//递归计算前两项
        return prevPrev+prev;
    }
}

3.动态规划版(循环问题用while往往起到意想不到的效果):借助少量的辅助空间,记录子问题的解答 

动态规划的思想:

  • 把原始问题划分为一系列子问题
  • 求解每个子问题仅一次,并将其结果保存在一个表中,以后用到时到时直接存取,不重复计算,节省计算时间
  • 自底向上地计算
int fibI(int n) //O(n)
{
    int f=1,g=0; //初始化fib(1)=1,fib(0)=0
    while(0 < n--)
    {
        int temp=g;
        g+=f;
        f=temp;
    }
    return g;
}

  尾递归的优点

来源:https://blog.csdn.net/zcyzsy/article/details/77151709

普通递归需要压栈和出栈,时间空间消耗大;尾递归则没有(可以通过编译器优化);

解释:

如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。

原理:

当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。

以尾递归方式实现阶乘函数的实现:

int facttail(int n, int res)
{
    if (n < 0)
        return 0;
    else if(n == 0)
        return 1;
    else if(n == 1)
        return res;
    else
        return facttail(n - 1, n *res);
}

res(初始化为1)维护递归层次的深度。这就让我们避免了每次还需要将返回值再乘以n。然而,在每次递归调用中,令res=n*res并且n=n-1。继续递归调用,直到n=1,这满足结束条件,此时直接返回res即可。

其实最精髓就是 通过参数传递结果,达到不压栈的目的

缓冲区溢出(程序在内存中的组织)

堆:地址增长(有 8MB 的大小限制,一般用来保存局部变量)
栈:地址减小(动态的内存分配会在这里处理,例如 malloc()calloc()new() 等)
然后是数据,指的是静态分配的数据,比如说全局变量,静态变量,常量字符串。最后是共享库等可执行的机器指令,这一部分是只读的。
可以见到,栈在最上面,也就是说,栈再往上就是另一个程序的内存范围了,这种时候我们就可以通过这种方式修改内存的其他部分了。

比如:

typedef struct 
{
    int a[2];
    double d;
} struct_t;
double fun(int i)
{
    volatile struct_t s;
    s.d = 3.14;
    s.a[i] = 1073741824; // 可能会越界
    return s.d;
}

不同的 i 可能的执行结果是:

  • fun(0) -> 3.14
  • fun(1) -> 3.14
  • fun(2) -> 3.1399998664856
  • fun(3) -> 2.00000061035156
  • fun(4) -> 3.14
  • fun(6) -> Segmentation fault

之所以会产生这种错误,是因为访问内存的时候跨过了数组本身的界限修改了 d 的值。你没看错,这是个大问题!如果不检查输入字符串的长度,就很容易出现这种问题,尤其是针对在栈上有界限的字符数组。

在 Unix 中,gets() 函数的实现是这样的:

// 从 stdin 中获取输入
char *gets(char *dest)
{
    int c = getchar();
    char *p = dest;
    while (c != EOF && c != '\n')
    {
        *p++ = c;
        c = getchar();
    }
    *p = '\0';
    return dest;
}

可以看到并没有去检测最多能读入多少字符(于是很容易出问题),类似的情况还在 strcpystrcatscanffscanfsscanf 中出现。比如说

void echo() {
	char buf[4]; // 太小
	gets(buf);
	puts(buf);
}
void call_echo() {
	echo();
}

我们来测试一下这个函数,可能的结果是:

unix> ./echodemo
 Input: 012345678901234567890123
Output: 012345678901234567890123
unix> ./echodemo
 Input: 0123456789012345678901234
Segmentation Fault

为什么明明在 echo() 中声明 buf 为 4 个 char,居然一开始输入这么多都没问题?我们到汇编代码里去看看:

00000000004006cf <echo>:
    4006cf: 48 83 ec 18         sub   $0x18, %rsp
    4006d3: 48 89 e7            mov   %rsp, %rdi
    4006d6: e8 a5 ff ff ff      callq 400680 <gets>
    4006db: 48 89 e7            mov   %rsp, %rdi
    4006de: e8 3d fe ff ff      callq 400520 <puts@plt>
    4006e3: 48 83 c4 18         add   $0x18, %rsp
    4006e7: c3                  retq
# call_echo 部分
    4006e8: 48 83 ec 08         sub   $0x8, %rsp
    4006ec: b8 00 00 00 00      mov   $0x0, %eax
    4006f1: e8 d9 ff ff ff      callq 4006cf <echo>
    4006f6: 48 83 c4 08         add   $0x8, %rsp
    4006fa: c3                  retq

我们看 4006cf 这一行,可以发现实际上给 %rsp 分配了 0x18 的空间,所以可以容纳不止 4 个 char。
在调用 gets 函数之前(第 4006d6 行),内存中栈帧示意图为:

结合上面代码可以看到,call_echo 栈帧中保存着调用之前执行指令的地址 4006f6,用于返回之后继续执行。我们输入字符串 01234567890123456789012 之后,栈帧中缓冲区被填充,如下:

虽然缓冲区溢出了,但是并没有损害当前的状态,程序还是可以继续运行(也就是没有出现段错误),但是如果再多一点的话,也就是输入 0123456789012345678901234,内存中的情况是这样的:

就把返回地址给覆盖掉了,当 echo 执行完成要回到 call_echo 函数时,就跳转到 0x400034 这个内容未知的地址中了。也就是说,通过缓冲区溢出,我们可以在程序返回时跳转到任何我们想要跳转到的地方!攻击者可以利用这种方式来执行恶意代码!

 那么我们现在来看看,怎么处理缓冲区溢出攻击,有几种方式:

  1. 好好写代码,尽量不让缓冲区异常
  2. 程序容易出问题,那么提供系统层级的保护
  3. 编译器也可以来个认证(stack canaries)

第一种,避免缓冲区溢出,我们用更安全的方法,如:fgetsstrncpy 等等。

第二种,栈的位置不确定,让缓冲区溢出没办法影响到,并且每次位置都不一样,就不怕被暴力破解。并且也可以把一段内存标记为只读,那么就避免因为缓冲区溢出而导致的重写。

第三种,使用认证机制(Stack Canaries)。简单来说,就是在超出缓冲区的位置加一个特殊的值,如果发现这个值变化了,那么就知道出问题了。

但是,除了缓冲区溢出,还有另一种攻击的方式,称为返回导向编程[4]。可以利用修改已有的代码,来绕过系统和编译器的保护机制,攻击者控制堆栈调用以劫持程序控制流并执行针对性的机器语言指令序列(称为Gadgets)。每一段 gadget 通常结束于 return 指令,并位于共享库代码中的子程序。系列调用这些代码,攻击者可以在拥有更简单攻击防范的程序内执行任意操作。

具体利用缓冲区进行攻击的例子,会在【读厚 CSAPP】III Attack Lab 中进行讲解,这里不再赘述。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值