重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!

用Google搜异常信息,肯定都访问过Stack Overflow网站

全球最大的程序员问答网站,名字来自于一个常见的报错,就是栈溢出(stack overflow)

从函数调用开始,在计算机指令层面函数间的相互调用是怎么实现的,以及什么情况下会发生栈溢出

1 栈的意义

先看一个简单的C程序

  • function.c
  • 直接在Linux中使用GCC编译运行
[[email protected] Documents]$ vim function.c
[[email protected] Documents]$ gcc -g -c function.c
[[email protected] Documents]$ objdump -d -M intel -S function.o

function.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <add>:
#include <stdio.h>
int static add(int a, int b)
{
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
   a:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
   d:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
  10:   01 d0                   add    eax,edx
  12:   5d                      pop    rbp
  13:   c3                      ret    

0000000000000014 <main>:
    return a+b;
}

int main()
{
  14:   55                      push   rbp
  15:   48 89 e5                mov    rbp,rsp
  18:   48 83 ec 10             sub    rsp,0x10
    int x = 5;
  1c:   c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
    int y = 10;
  23:   c7 45 f8 0a 00 00 00    mov    DWORD PTR [rbp-0x8],0xa
    int u = add(x, y);

  2a:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  2d:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  30:   89 d6                   mov    esi,edx
  32:   89 c7                   mov    edi,eax
  34:   e8 c7 ff ff ff          call   0 <add>
  39:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
    return 0;
  3c:   b8 00 00 00 00          mov    eax,0x0
}
  41:   c9                      leave
  42:   c3                      ret    

main函数和上一节我们讲的的程序执行区别不大,主要是把jump指令换成了函数调用的call指令,call指令后面跟着的,仍然是跳转后的程序地址

看看add函数

add函数编译后,代码先执行了一条push指令和一条mov指令

在函数执行结束的时候,又执行了一条pop和一条ret指令

这四条指令的执行,其实就是在进行我们接下来要讲压栈(Push)和出栈(Pop)

函数调用和上一节我们讲的if…else和for/while循环有点像

都是在原来顺序执行的指令过程里,执行了一个内存地址的跳转指令,让指令从原来顺序执行的过程里跳开,从新的跳转后的位置开始执行。

但是,这两个跳转有个区别

  • if…else和for/while的跳转,是跳转走了就不再回来了,就在跳转后的新地址开始顺序地执行指令,后会无期
  • 函数调用的跳转,在对应函数的指令执行完了之后,还要再回到函数调用的地方,继续执行call之后的指令,地球毕竟是圆的

有没有一个可以不跳回原来开始的地方,从而实现函数的调用呢

似乎有.可以把调用的函数指令,直接插入在调用函数的地方,替换掉对应的call指令,然后在编译器编译代码的时候,直接就把函数调用变成对应的指令替换掉。

不过思考一下,你会发现漏洞

如果函数A调用了函数B,然后函数B再调用函数A,我们就得面临在A里面插入B的指令,然后在B里面插入A的指令,这样就会产生无穷无尽地替换。

就好像两面镜子面对面放在一块儿,任何一面镜子里面都会看到无穷多面镜子

Infinite Mirror Effect

如果函数A调用B,B再调用A,那么代码会无限展开

那就换一个思路,能不能把后面要跳回来执行的指令地址给记录下来呢?

就像PC寄存器一样,可以专门设立一个“程序调用寄存器”,存储接下来要跳转回来执行的指令地址

等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址,继续执行就好了。

但在多层函数调用里,只记录一个地址是不够的

在调用函数A之后,A还可以调用函数B,B还能调用函数C

这一层又一层的调用并没有数量上的限制

在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是我们CPU里的寄存器数量并不多

像我们一般使用的Intel i7 CPU只有16个64位寄存器,调用的层数一多就存不下了。

最终,CSer们想到了一个比单独记录跳转回来的地址更完善的办法

在内存里面开辟一段空间,用栈这个后进先出(LIFO,Last In First Out)的数据结构

栈就像一个乒乓球桶,每次程序调用函数之前,我们都把调用返回后的地址写在一个乒乓球上,然后塞进这个球桶
这个操作其实就是我们常说的压栈。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈。

拿到出栈的乒乓球,找到上面的地址,把程序跳转过去,就返回到了函数调用后的下一条指令了

如果函数A在执行完成之前又调用了函数B,那么在取出乒乓球之前,我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候,拿的也一定是最近一次的,也就是最下面一层的函数调用完成后的地址

乒乓球桶的底部,就是栈底,最上面的乒乓球所在的位置,就是栈顶

压栈的不只有函数调用完成后的返回地址

比如函数A在调用B的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中

整个函数A所占用的所有内存空间,就是函数A的栈帧(Stack Frame)

Frame在中文里也有“相框”的意思,所以,每次到这里,都有种感觉,整个函数A所需要的内存空间就像是被这么一个“相框”给框了起来,放在了栈里面。

而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的

底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大

对应上面函数add的汇编代码,我们来仔细看看,main函数调用add函数时

  • add函数入口在0~1行
  • add函数结束之后在12~13行

在调用第34行的call指令时,会把当前的PC寄存器里的下一条指令的地址压栈,保留函数调用结束后要执行的指令地址

  • 而add函数的第0行,push rbp指令,就是在压栈
    这里的rbp又叫栈帧指针(Frame Pointer),存放了当前栈帧位置的寄存器。push rbp就把之前调用函数,也就是main函数的栈帧的栈底地址,压到栈顶。
  • 第1行的一条命令mov rbp, rsp,则是把rsp这个栈指针(Stack Pointer)的值复制到rbp里,而rsp始终会指向栈顶
    这个命令意味着,rbp这个栈帧指针指向的地址,变成当前最新的栈顶,也就是add函数的栈帧的栈底地址了。
  • 在函数add执行完成之后,又会分别调用第12行的pop rbp
    将当前的栈顶出栈,这部分操作维护好了我们整个栈帧
  • 然后调用第13行的ret指令,这时候同时要把call调用的时候压入的PC寄存器里的下一条指令出栈,更新到PC寄存器中,将程序的控制权返回到出栈后的栈顶。

2 构造Stack Overflow

通过引入栈,我们可以看到,无论有多少层的函数调用,或者在函数A里调用函数B,再在函数B里调用A

这样的递归调用,我们都只需要通过维持rbp和rsp,这两个维护栈顶所在地址的寄存器,就能管理好不同函数之间的跳转

不过,栈的大小也是有限的。如果函数调用层数太多,我们往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误,这就是stack overflow

构造一个栈溢出的错误

并不困难,最简单的办法,就是我们上面说的Infiinite Mirror Effect的方式,让函数A调用自己,并且不设任何终止条件

这样一个无限递归的程序,在不断地压栈过程中,将整个栈空间填满,并最终遇上stack overflow。

int a()
{
  return a();
}

int main()
{
  a();
  return 0;
}

除了无限递归,递归层数过深,在栈空间里面创建非常占内存的变量(比如一个巨大的数组),这些情况都很可能给你带来stack overflow

相信你理解了栈在程序运行的过程里面是怎么回事,未来在遇到stackoverflow这个错误的时候,不会完全没有方向了。

3 利用函数内联实现性能优化

上面我们提到一个方法,把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令。尽管这个通用的函数调用方案,被我们否决了,但是如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。

事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫函数内联(Inline)

只要在GCC编译的时候,加上对应的一个让编译器自动优化的参数-O,编译器就会在可行的情况下,进行这样的指令替换。

  • 案例

    为了避免编译器优化掉太多代码,小小修改了一下function.c,让参数x和y都变成了,通过随机数生成,并在代码的最后加上将u通过printf打印
[[email protected] Documents]$ vim function.c
[[email protected] Documents]$ gcc -g -c -O function.c
[[email protected] Documents]$ objdump -d -M intel -S function.o

function.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
{
    return a+b;
}

int main()
{
   0:   53                      push   rbx
   1:   bf 00 00 00 00          mov    edi,0x0
   6:   e8 00 00 00 00          call   b <main+0xb>
   b:   89 c7                   mov    edi,eax
   d:   e8 00 00 00 00          call   12 <main+0x12>
  12:   e8 00 00 00 00          call   17 <main+0x17>
  17:   89 c3                   mov    ebx,eax
  19:   e8 00 00 00 00          call   1e <main+0x1e>
  1e:   89 c1                   mov    ecx,eax
  20:   bf 67 66 66 66          mov    edi,0x66666667
  25:   89 d8                   mov    eax,ebx
  27:   f7 ef                   imul   edi
  29:   d1 fa                   sar    edx,1
  2b:   89 d8                   mov    eax,ebx
  2d:   c1 f8 1f                sar    eax,0x1f
  30:   29 c2                   sub    edx,eax
  32:   8d 04 92                lea    eax,[rdx+rdx*4]
  35:   29 c3                   sub    ebx,eax
  37:   89 c8                   mov    eax,ecx
  39:   f7 ef                   imul   edi
  3b:   c1 fa 02                sar    edx,0x2
  3e:   89 d7                   mov    edi,edx
  40:   89 c8                   mov    eax,ecx
  42:   c1 f8 1f                sar    eax,0x1f
  45:   29 c7                   sub    edi,eax
  47:   8d 04 bf                lea    eax,[rdi+rdi*4]
  4a:   01 c0                   add    eax,eax
  4c:   29 c1                   sub    ecx,eax
#include <time.h>
#include <stdlib.h>

int static add(int a, int b)
{
    return a+b;
  4e:   8d 34 0b                lea    esi,[rbx+rcx*1]
{
    srand(time(NULL));
    int x = rand() % 5;
    int y = rand() % 10;
    int u = add(x, y);
    printf("u = %d\n", u);
  51:   bf 00 00 00 00          mov    edi,0x0
  56:   b8 00 00 00 00          mov    eax,0x0
  5b:   e8 00 00 00 00          call   60 <main+0x60>
  60:   b8 00 00 00 00          mov    eax,0x0
  65:   5b                      pop    rbx
  66:   c3                      ret    

上面的function.c的编译出来的汇编代码,没有把add函数单独编译成一段指令顺序,而是在调用u = add(x, y)的时候,直接替换成了一个add指令。

除了依靠编译器的自动优化,你还可以在定义函数的地方,加上inline的关键字,来提示编译器对函数进行内联。

内联带来的优化是,CPU需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。

不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。

这样没有调用其他函数,只会被调用的函数,我们一般称之为叶子函数(或叶子过程)

3 总结

这一节,我们讲了一个程序的函数间调用,在CPU指令层面是怎么执行的。其中一定需要你牢记的,就是程序栈这个新概念。

我们可以方便地通过压栈和出栈操作,使得程序在不同的函数调用过程中进行转移。而函数内联和栈溢出,一个是我们常常可以选择的优化方案,另一个则是我们会常遇到的程序Bug。

通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。这个也为我们在程序开发的过程中,提供了“函数”这样一个抽象,使得我们在软件开发的过程中,可以复用代码和指令,而不是只能简单粗暴地复制、粘贴代码和指令。

4 推荐阅读

可以仔细读一下《深入理解计算机系统(第三版)》的3.7小节《过程》,进一步了解函数调用是怎么回事。

另外,我推荐你花一点时间,通过搜索引擎搞清楚function.c每一行汇编代码的含义,这个能够帮你进一步深入了解程序栈、栈帧、寄存器以及Intel CPU的指令集。

参考

深入浅出计算机组成原理

原文地址:https://www.cnblogs.com/JavaEdge/p/11361162.html

时间: 2024-10-06 10:03:01

重学计算机组成原理(六)- 函数调用怎么突然Stack Overflow了!的相关文章

重学计算机组成原理(二)- 制定学习路线,攀登“性能”之巅

0 学习路线的知识点概括 学习计算机组成原理,就是学习计算机是如何协调运行的 计算机组成原理的英文叫Computer Organization Organization 意"组织机构". 该组织机构能够进行各种计算.控制.读取输入,进行输出,达成各种强大的功能. 把整个计算机组成原理的知识点拆分成了四大部分 计算机的基本组成 计算机的指令和计算 处理器设计 存储器和I/O设备. 0.1 计算机的基本组成 计算机的硬件组成 这些硬件,怎么对应到经典的冯·诺依曼体系结构的 除此之外,还需要

重学计算机组成原理(九)- 动态链接

把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件 链接的方式,让我们在写代码的时候做到了"复用". 同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了. "链接"其实有点儿像我们日常生活中的标准化.模块化生产. 有一个可以生产标准螺帽的生产线,就可生产很多不同的螺帽. 只要需要螺帽,都可以通过链接的方式,去复制一个出来,放到需要的地方 但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,

重学计算机组成原理(十)- &quot;烫烫烫&quot;乱码的由来

程序 = 算法 + 数据结构 对应到计算机的组成原理(硬件层面) 算法 --- 各种计算机指令 数据结构 --- 二进制数据 计算机用0/1组成的二进制,来表示所有信息 程序指令用到的机器码,是使用二进制表示的 存储在内存里面的字符串.整数.浮点数也都是用二进制表示的 万物在计算机里都是0和1,搞清楚各种数据在二进制层面是怎么表示的,是我们的必修课. 在实际应用中最常遇到的问题,也就是文本字符串是怎么表示成二进制的,特别是我们会遇到的乱码究竟是怎么回事儿 在开发的时候,所说的Unicode和UT

重学计算机组成原理(十一)- 门电路的&quot;千里传音&quot;

人用纸和笔来做运算,都是用十进制,直接用十进制和我们最熟悉的符号不是最简单么? 为什么计算机里我们最终要选择二进制呢? 来看看,计算机在硬件层面究竟是怎么表示二进制的,你就会明白,为什么计算机会选择二进制. 1 怎么做到"千里传书" 马拉松的故事相信你听说过.公元前490年,在雅典附近的马拉松海边,发生了波斯和希腊之间的希波战争.雅典和斯巴达领导的希腊联军胜利之后,雅典飞毛腿菲迪皮德斯跑了历史上第一个马拉松,回雅典报喜.这个时候,人们在远距离报信的时候,采用的是派人跑腿,传口信或者送信

【重学计算机】计组D1章:计算机系统概论

1.冯诺依曼计算机组成 主机(cpu+内存),外设(输入设备+输出设备+外存),总线(地址总线+数据总线+控制总线) 2.计算机层次结构 应用程序-高级语言-汇编语言-操作系统-指令集架构层-微代码层-硬件逻辑层 3.计算机性能指标 非时间指标 [字长]机器一次能处理的二进制位数 ,常见的有32位或64位 [总线宽度]数据总线一次能并行处理的最大信息位数,一般指运算器与存储器之间的数据总线的位数 [主存容量]主存的大小 [存储带宽]单位时间内与主存交换的二进制位数 B/s 时间指标 [主频f]时

【重学计算机】机组D8章:输入输出系统

1. 输入输出系统概述 组成:外设.接口.总线.管理软件 基本功能 完成计算机内外的信息传递 保证CPU正确选择输出设备 利用缓冲等,实现主机与外设的速度匹配 特点:异步性.实时性.设备无关性 输入过程:CPU把地址值放入总线 --> CPU等候设备数据有效 --> CPU从总线读入数据存入寄存器 输出过程:CPU把地址值放入总线 --> CPU把数据值放入总线 --> 设备等数据有效取走数据 IO系统性能:存储IO.通信IO 连接特性:哪些设备可以和IO相连 IO系统容量:IO系

计算机组成原理(六)——计算机发展趋势

计算机发展趋势 巨型化 巨型化是指为了适应尖端科学技术的需要,发展高速度.大存储容量和功能强大的超级计算机.特别是在军事和科研教育方面对计算机的存储空间和运行速度等要求会越来越高.此外计算机的功能更加多元化. 微型化 随着微型处理器(CPU)的出现,计算机中开始使用微型处理器,使计算机体积缩小了,成本降低了.另一方面,软件行业的飞速发展提高了计算机内部操作系统的便捷度,计算机外部设备也趋于完善.四十年来,计算机的体积不断的缩小,台式电脑.笔记本电脑.掌上电脑.平板电脑体积逐步微型化.因此,未来计

重学JAVA基础(六):多线程的同步

1.synchronized关键字 /** * 同步关键字 * @author tomsnail * @date 2015年4月18日 下午12:12:39 */ public class SyncThreadTest { private static final byte[] lock = new byte[1]; /** * 同步方法 * @author tomsnail * @date 2015年4月18日 下午12:15:30 */ public synchronized void te

【重学计算机】计组D3章:运算方法与运算器

1. 定点数运算及溢出 定点数加减法:减法化加法,用补码直接相加,忽略进位 溢出:运算结果超出了某种数据类型的表示范围 溢出检测方法:统一思想概括为正正得负或负负得正则溢出,正负或负正不可能溢出 方法1:V = XYS + XYS(XY为两个加数的符号位,S为结果的符号位,_表示非),那么V = 1则为溢出 方法2:V = C0 ⊕ C1(C0是最高数据位产生的进位,C1是符号位产生的进位),那么V = 1则为溢出 方法3:V = Xf1 ⊕ Xf2(数据采用变型补码 Xf1Xf2 X0X1X2