打开这篇文章的读者,我相信对函数调用的概念肯定不会陌生。函数调用在我们平常的开发过程中用的实在是太广泛了,只要稍微学过一点点计算机知识的人都不会认为这是一个很难理解的概念。但是不知道大家有没有想过,函数调用是CPU当中是怎么实现的呢?
这篇文章的目的就是希望能了解一下函数调用在汇编层面上是如何实现的。大家不要看到汇编两个字就害怕了,这篇文章涉及到的汇编指令并不多,加上几个例子,我相信大家可以很流畅的阅读下来。
1. 函数调用的三个步骤
函数调用一般可以分为三个步骤:
- 传递控制
- 传递数据,包括调用者传递函数参数,被调用者返回一个返回值
- 分配和释放内存
1.1 传递控制
对于CPU执行指令的过程,大家应该都有一个大概的印象。CPU上电之后就不断的取指、译码、执行,这三个步骤不断的循环循环再循环,只要不断电,我想它可以一直循环到海枯石烂、地老天荒,多么浪漫,比男人的誓言靠谱太多了!对于CPU而言,其实没有什么函数调用的概念,它能看到的就是一条接一条的指令。但是我们所写的函数总是被另外一个函数所调用(也许有人会说main函数没有人调用,但是实际上main函数也是被另外一个函数调用的,对这个问题感兴趣的朋友可以看一下《Unix环境高级编程》),而且两个函数的代码加载到内存后,指令地址几乎不可能连续的!那怎么办?
其实在CPU内部有一个叫做程序计数器的寄存器,这个寄存器专门用来保存CPU正在执行指令的下一条指令的地址,修改这个寄存器的值就可以实现指令跳转,从而实现函数调用了。但是光跳转还不行,在被调用的函数执行完之后,程序必须再次跳转回来,接着执行调用者本身没有执行完的代码。因此我们不能光跳转,还必须记住调用者执行到的位置,在被调用者执行完之后,必须把调用者执行到的位置写入PC接着执行。因此总结一下在函数调用前后,有三件事情是必须做的:
- 保存调用者执行的代码位置
- 把程序计数器修改为被调用者的代码位置
- 在被调用者执行完成后,把调用者的代码地址写入PC。
这三个功能是由两条指令来实现的,在x86-64的汇编指令集当中有一条指令call,这条指令可以实现1、2两个功能。假如有函数A调用函数B,在执行前B函数之前,会执行call B指令。这条指令首先把地址a压入栈中,然后将PC设置为函数B的起始地址。压入栈的地址a称为返回地址,是函数A中紧跟在call B指令的下一条指令。B函数的代码执行完之后,在最末尾会调用指令ret,这条指令会从栈中弹出地址a,并把PC设置为a,这样就可以愉快的接着执行A函数了。整个函数调用的控制转移只需要使用call和ret两条指令即可完成。
下面来看一个实际一点的例子:
1 | int add(int a, int b); |
这段代码没有任何实用价值,但是也足够我们了解函数调用的机制了。编译得到可执行文件后,我们用objdump指令来对可执行文件进行反编译,把结果写入到临时文件text中。
1 | $ objdump -d a.out > text |
打开text文件,我们会发现这个文件很长,仔细看一下可以发现有很多在执行main函数之前的初始化工作,还有一些程序执行完成后的收尾工作,我们都不管这些(因为我也看不懂……)。在这里我只截取了main函数和add函数反汇编的结果。
1 | a.out: file format elf64-x86-64 |