深入浅出计算机组成原理 函数调用(自我提升第二十二天)

昨天6月30号,菜鸟去搞了半天的手机套餐什么的,然后我妈换了个华为的手机,忍不住想玩玩,所以鸽了/(ㄒoㄒ)/~~

今天就补昨天的咯,然后再写一篇或者两篇,具体看看有没有什么事,话不多说,冲冲冲

这里我一篇是菜鸟提取的,不然就是讲单片机了,不是菜鸟自己总结的/(ㄒoㄒ)/~~

文章目录

  • 函数调用 和 if...else/for/while 的异同
  • 程序栈
  • stack overflow
  • 如何利用函数内联进行性能优化?

函数调用 和 if…else/for/while 的异同

函数调用是在原来顺序执行的指令过程里,执行了一个内存地址的跳转指令,让指令从原来顺序执行的过程里跳开,从新的跳转后的位置开始执行。(类似于上一节的if,while)

这两个跳转有个区别,if…else 和 for/while 的跳转,是跳转走了就不再回来了,就在跳转后的新地址开始顺序地执行指令。而函数调用的跳转,在对应函数的指令执行完了之后,还要再回到函数调用的地方,继续执行之后的指令

程序栈

那我们有没有一个可以不跳转回到原来开始的地方,来实现函数的调用呢?

你可能会想,可以把调用的函数指令,直接插入在调用函数的地方,然后在编译器编译代码的时候,直接就把函数调用变成对应的指令替换掉

答案是不行。因为如果两个函数相互调用,那么就会产生无穷无尽地替换。就好像两面镜子面对面放在一块儿,任何一面镜子里面都会看到无穷多面镜子。

看来,把被调用函数的指令直接插入在调用处的方法行不通。那我们就换一个思路,能不能把后面要跳回来执行的指令地址给记录下来呢?

就像前面讲 PC 寄存器一样,我们可以专门设立一个“程序调用寄存器”,来存储接下来要跳转回来执行的指令地址。等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址,继续执行就好了。

但是在多层函数调用里,简单只记录一个地址也是不够的。我们在调用函数 A 之后,A 还可以调用函数 B,B 还能调用函数 C。这一层又一层的调用并没有数量上的限制。在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是我们 CPU 里的寄存器数量并不多。像我们一般使用的 Intel i7 CPU 只有 16 个 64 位寄存器,调用的层数一多就存不下了。

最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法。我们在内存里面开辟一段空间,用栈这个后进先出(LIFO,Last In First Out)的数据结构。栈就像一个乒乓球桶,每次程序调用函数之前,我们都把调用返回后的地址写在一个乒乓球上,然后塞进这个球桶。这个操作其实就是我们常说的压栈。如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈

拿到出栈的乒乓球,找到上面的地址,把程序跳转过去,就返回到了函数调用后的下一条指令了。如果函数 A 在执行完成之前又调用了函数 B,那么在取出乒乓球之前,我们需要往球桶里塞一个乒乓球。而我们从球桶最上面拿乒乓球的时候,拿的也一定是最近一次的,也就是最下面一层的函数调用完成后的地址。乒乓球桶的底部,就是栈底,最上面的乒乓球所在的位置,就是栈顶
深入浅出计算机组成原理 函数调用(自我提升第二十二天)_第1张图片
在真实的程序里,压栈的不只有函数调用完成后的返回地址。比如函数 A 在调用 B 的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中。整个函数 A 所占用的所有内存空间,就是函数 A 的栈帧(Stack Frame)。Frame 在中文里也有“相框”的意思,所以,有种感觉,整个函数 A 所需要的内存空间就像是被这么一个“相框”给框了起来,放在了栈里面。

而实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大

stack overflow

通过引入栈,我们可以看到,无论有多少层的函数调用,或者在函数 A 里调用函数 B,再在函数 B 里调用 A,这样的递归调用,我们都只需要通过维持 rbp[栈帧指针(Frame Pointer)]和 rsp[栈指针(Stack Pointer)],这两个维护栈顶所在地址的寄存器,就能管理好不同函数之间的跳转。

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

要构造一个栈溢出的错误并不困难,最简单的办法,就是我们上面说的 Infiinite Mirror Effect 的方式,让函数 A 调用自己,并且不设任何终止条件。这样一个无限递归的程序,在不断地压栈过程中,将整个栈空间填满,并最终遇上 stack overflow

int a()
{
	return a();
}

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

除了无限递归,递归层数过深,在栈空间里面创建非常占内存的变量(比如一个巨大的数组),这些情况都很可能给你带来 stack overflow。相信你理解了栈在程序运行的过程里面是怎么回事,未来在遇到 stack overflow 这个错误的时候,不会完全没有方向了。

如何利用函数内联进行性能优化?

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

这样没有调用其他函数,只会被调用的函数,我们一般称之为叶子函数(或叶子过程)
深入浅出计算机组成原理 函数调用(自我提升第二十二天)_第2张图片
事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫函数内联(Inline)。我们只要在 GCC 编译的时候,加上对应的一个让编译器自动优化的参数 -O(是大写的O,小写会出错),编译器就会在可行的情况下,进行这样的指令替换。

#include 
#include 
#include 
int static add(int a, int b)
{
	return a+b;
}
int main()
{
	srand(time(NULL));
	int x = rand() % 5
	int y = rand() % 10;
	int u = add(x, y)
	printf("u = %d\n", u)
}

上面的 function_example_inline.c 的编译出来的汇编代码

gcc -g -c -O function_example_inline.c
objdump -d -M intel -S function_example_inline.o

没有把 add 函数单独编译成一段指令顺序,而是在调用 u = add(x, y) 的时候,直接替换成了一个 add 指令。

极客时间是这个:

return a+b;
4c: 01 de add esi,ebx

但是菜鸟在自己的上面找了好久也没找到(可能是地址不同),所以直接列出自己的了,希望会的读者,积极留言!!!

Disassembly of section .text:

0000000000000000 <main>:

}

int main()

{
   0:   53                      push   rbx

srand(time(NULL));
   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>

int x = rand() % 5;
  12:   e8 00 00 00 00          call   17 <main+0x17>
  17:   89 c3                   mov    ebx,eax

int y = rand() % 10;
  19:   e8 00 00 00 00          call   1e <main+0x1e>
  1e:   89 c1                   mov    ecx,eax
int x = rand() % 5;
  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
int y = rand() % 10;
  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
return a+b;
  4e:   8d 34 0b                lea    esi,[rbx+rcx*1]

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:   5b                      pop    rbx
  61:   c3                      ret 

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

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

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

你可能感兴趣的:(极客)