介绍
本文从机器级的层面来介绍下函数的调用过程,读自《深入理解操作系统》第三章
虚拟内存
虚拟内存是内存管理的一种方式,它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),实际上是对主存和磁盘I/O设备的抽象表示。根据功能不同我们把虚拟内存自上而下(高地址到低地址)划分为我们常见得以下几个部分:
- 内核空间:总是驻留在主存中,一般来说是操作系统的一部分,不为应用使用
- 栈:是由编译器自动分配和释放,用来存储临时数据和栈帧等(向下扩展)
- 内存映射段:一般用来装载动态库
- 堆:用于存放进程运行时分配的内存空间,一般由程序员来分配和释放(向上扩展)
- BSS段:静态内存分配,保存未初始化的全局变量和静态变量(为0)
- 数据段:静态内存分配,保存初始化的全局变量和静态变量
- 代码段:保存可执行的机器代码
以上是对虚拟内存的简短描述,中间有一些省略,本课程我们会用的是栈。
寄存器
首先我们介绍下我们常见的寄存器:
- 程序计数器(PC):给出将要执行的下一条指令在内存中的地址
- 整数寄存器文件:包含16个命名的位置,分别存储64位的值,可保存存储的地址和数值
- 条件码寄存器: 保存最近执行的的状态信息,用来根据条件控制程序的流向
- 向量寄存器:可存放多个或者一个整数或者浮点数值
关于通用寄存器,用来存储整数数据和指针,最初的8086中有8个16位的寄存器,%ax~%bp,每个寄存器都有特殊的用处,扩展到IA32架构时,寄存器扩展到了32位,即%eax ~ %ebp,扩展到x86-64后,原来的8个寄存器扩展到64位,即%rax ~ %rbp,除此之外,还增加了8个新的寄存器,%r8 ~ %r15。如图所示:
如图片右边解释说明,不同的寄存器扮演不同角色,比如栈指针%rsp,用来指明运行时栈的结束位置,后边我们会具体的讲到如何使用寄存器来管理栈,传递函数参数,获得函数返回值,以及存储局部和临时数据。
函数调用过程
首先介绍下函数调用的一些机制,即关键点:(假设P调用Q)
- 首先程序计数器必须被设置为Q代码的起始地址,然后在Q执行完返回时,要把程序计数器设置为P调用Q后边的那条指令
- P须能向Q提供一个或多个参数,Q须能够像P返回一个值
- Q执行时须为局部变量分配空间,返回前释放这些空间
C函数调用过程使用了栈数据结构提供后进先出的内存管理原则,在P调用Q的例子里,当Q在执行时,P以及调用P的函数,及向上的调用链的函数都是暂时被挂起的。同时栈可以管理所需要的存储空间,P调用Q时,P对Q的控制和数据信息添加到栈尾,P返回时,这些信息会被释放掉,同样如果Q还要继续调用别的函数,与之类似。然后我们就看下需要保存哪些控制和数据信息和在栈中是如何保存信息的。
调用栈
调用栈如图所示,简单解释一下调用栈,栈的地址是由下到上递增的,下边是栈顶,上边是栈底。栈指针%rsp指向栈顶元素,即寄存器rsp中保存的是栈顶这个元素所在的地址。这样%rsp的值减少即在栈上分配空间,增加即释放空间。
当函数调用过程中需要的存储空间超过里寄存器能够存放的大小时,就会在栈上分配空间,这个部分称为栈帧,如果所示栈被划分成的栈帧,正在执行的帧总是在栈顶。
最简单的调用
我们先看下最简单的调用过程, 列出了简单的反汇编代码:
void multstore(int b)
{
int a = b;
return ;
}
int main()
{
// ...
int b = 0;
multstore(b);
b = 8;
// ...
}
// Beginning of function multstore
1 0000000000400540 <multstore>
2 400541: 48 89 d3 mov %rdx,%rbx
3 40054d: c3 retq
// Call to multstore from main
4 400563: e8 d8 ff ff ff callq 400540 <multstore>
5 400568: 48 8b 54 24 08 mov 0x8,%rdx
由main函数调用multstore的过程中栈指针%rsp和程序计数器%rip的值
在准备要执行call时,%rsp指向目前的栈顶,%rip指向call指令的位置(400563),当执行call时,%rsp里的值减少在栈上分配空间,将call指令下一条指令压到栈中(400568),然后开始
multstore函数的执行,等到multstore函数执行完成后,即ret执行之后,出栈获得call指令的下一条指令给%rip,更新%rsp的值。然后继续执行,以此类推,这是一个简单的执行过程描述。
参数传递
在函数调用过程中,出来简单的函数调用和返回,有时候是需要进行参数的传递的。那么参数传递是怎么样实现的呢?
通过寄存器:其实大部分的参数传递都是通过寄存器来传递的,如寄存器的图,可通过寄存器传递6个整型(整数和指针)参数。P调用Q时,P的代码首先把参数复制到适当的寄存器中,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。另外参数的传递通过寄存器是有顺序的,同时通过参数的位数可以使用不同的寄存器,第一个参数使用的寄存器是%rdi系列,如果第一个参数是32位,使用的就是%edi,以此类推。
通过栈:如果参数个数超过6个,则使用栈来保存 ,如调用栈图中的P的栈里是参数7到参数n等等的区域(在Q的帧里是参数构造区部分),参数7位于栈顶处。所有的数据大小都向8字节(a4是char也占用了8字节)的倍数对齐。
通过例子说明:
// c代码
void proc(long a1, long *a1p,
int a2, int *a2p,
short a3, short *a3p,
char a4, char *a4p) {
*a1p += a1;
*a2p += a2;
*a3p += a3;
*a4p += a4;
}
// 汇编代码
void proc(a1, a1p, a2, a2p, a3, a3p, a4, a4p)
Arguments passed as follows:
a1 in %rdi (64 bits)
a1p in %rsi (64 bits)
a2 in %edx (32 bits)
a2p in %rcx (64 bits)
a3 in %r8w (16 bits)
a3p in %r9 (64 bits)
a4 in %rsp+8 (8 bits)
a4p in %rsp+16 (64 bits)
1 proc:
2 movq 16(%rsp),%rax Fetch a4p (64 bits)
3 addq %rdi,(%rsi) *a1p += a1 (64 bits)
4 addl %edx,(%rcx) *a2p += a2 (32 bits)
5 addw %r8x,(%r9) *a3p += a3 (16 bits)
6 movl 8(%rsp),%edx Fetch a4 (8bits)
7 addb %dl,(%rax) *a4p += a4
8 ret Return
如上所示,前边6个参数通过寄存器传递,另外两个通过栈传递,同时由于返回地址也会被压入栈中,所以后边两个参数的相对于栈顶的距离位8和16的位置,汇编代码中使用了add的不同版本,addq加64位的(long),addl加32位的(int),addw加16位的(short),addb加8位的(char)。movl指令从内存读取4字节,而后边的addb指令只使用了低位一字节。
局部存储
我们看栈的图所示,参数构造区的我们讲过了,然后是局部变量区,在函数执行过程中有几种情况需要在栈中存储局部变量。
- 寄存器不足够存放的所有本地数据
- 需要对一个局部变量使用地址运算符‘&’,因为他必须要能够产生一个地址
- 数组或者结构(结构体/联合体)之类的局部变量,因为必须要通过数组或者结构的引用访问到。
看例子:
// C代码
long swap_add(long *xp, long *yp)
{
long x = *xp;
long y = *yp;
*xp = y;
*yp = x;
return x + y;
}
long caller()
{
long arg1 = 534;
long arg2 = 1057;
long sum = swap_add(&arg1, &arg2);
long diff = arg1 - arg2;
return sum * diff;
}
// caller的汇编代码
long calller()
1 caller:
2 subq $16,%rsp Allocate 16 bytes for stack frame
3 movq $534,(%rsp) Store 534 in arg1
4 movq $1057, 8(%rsp) Store 1057 in arg2
5 leaq 8(%rsp),%rsi Compute &arg2 as second argument
6 movq %rsp,%rdi Compute &arg1 as first argument
7 call swap_add Call swap_add(&arg1, &arg2)
8 movq (%rsp),%rdx Get arg1
9 subq 8(%rsp),%rdx Compute diff = arg1 - arg2
10 imulq %rdx,%rax Compute sum * diff
11 addq $16,%rsp Deallocate stack frame
12 ret Return
例子中展示caller调用swap_add时,在swap_add中会通过&用到局部变量,所以在caller中的arg1和arg2需要保存到栈中,因为这样可以通过地址来获取到。我们接下来解释下caller的汇编代码:
首先栈中分配空间(2行),然后存放局部变量(3行,4行),这样我们就可以引用这两个局部变量的地址了(5行,6行)作为参数传递给swap_add函数,最后调用完成后,释放空间(11行)。
被保存的寄存器
如栈图所示,我们接下来说一下在栈中被保存的寄存器是怎么一回事。
我们知道,寄存器在函数调用过程中是充当共享的角色,那如果一个函数使用,当他调用了另一个函数同样使用这个寄存器,那数据岂不是被覆盖了,所以这其中有些规则我们需要知道。
- 寄存器%rbx,%rbp和%r12~%r15被称作被调用者保存寄存器,如寄存器图所示。解释一下,P调用Q时,Q负责保存这些寄存器的值,保证Q返回到P时与P调用Q时这些寄存器的值是一样的,所以如果要实现这个结果,要么Q在执行过程中不去改变这些寄存器,要么就是把原始值压入栈中,然后去改变寄存器的值,在返回时,从栈中弹出旧值,正好是我们前边所说的栈中被保存寄存器的那块区域。
- 其余的寄存器(除%rsp外)被称作调用者保存寄存器,如寄存器图所示。P在某个此类的寄存器中有局部数据,在调用Q之前,需要保存好这些数据,因为Q可以任意去修改这些此类的寄存器。
我们通过例子来看一下:
// C代码
long P(long x, long y)
{
long u = Q(y);
long v = Q(x);
return u + v;
}
// 汇编代码
long P(long x, long y)
x in %rdi, y in %rsi
1 P:
2 pushq %rbp Save %rbp
3 pushq %rbx Save %rbx
4 sub $8,%rsp Align stack frame
5 movq %rdi,%rbp Save x
6 movq %rsi,%rdi Move y to first argument
7 call Q Call Q(y)
8 movq %rax,%rbx Save result
9 movq %rbp,%rdi Move x to first argument
10 call Q Call(x)
11 addq %rbx,%rax Add result
12 addq $8,%rsp Deallocate last part of stack
13 popq %rbx Restore %rbx
14 popq %rbp Restore %rbp
15 ret
例子所示,首先因为P函数需要用到%rbp和%rbx,且%rbp和%rbx是被调用者保存的,所以首先需要把这两个的值保存在栈帧里,即2行和3行所示。因为要开始调用函数Q,需要用到存放第一个参数的寄存器%rdi,所以需要把%rdi里的x值放到%rbp里,然后进行调用,返回值放到%rbx中,然后再把x作为第一个参数传递,然后获得返回值,最终从栈中弹出%rbp和%rbx的值完成。
完整的函数调用
目前为止我们已经把函数调用的各种情况都说了一遍,接下来我们总体的顺一下这个过程,同样还是以P调用Q为例子。
在P执行过程中:
- 如果P执行中后边需要用到被调用者保存寄存器类的寄存器,先把寄存器的值压栈
- 如果P中会用到调用者保存的寄存器类的寄存器,然后在调用Q时恐怕Q会使用这些寄存器,先把寄存器的值压栈
- 如果需要用到一些局部变量需要在栈中保存,将这些局部变量值压栈
- 如果调用Q时需要用到多于6个参数的情况下,将这些参数压栈
- 调用Q(首先会把调用Q的下一条指令压栈,然后跳转到Q起始地址),此时P栈帧处于挂起状态。
- 然后进入Q的栈帧,同以上P的过程
- Q返回(取出调用Q的下一条指令执行)
- 函数执行到最后时
- 弹出需要使用的值(比如说被调用着保存的寄存器的值)更新%rsp的值,释放栈空间
- P返回(取出调用P的下一条指令执行)
注
- call命令功能可以使得压入call后边的一条指令,然后跳转到目标位置开始执行,ret指令取出这条返回地址指令开始执行,其余的压栈(分配空间)和弹栈(释放空间)是都是由函数内的汇编指令来操作的。
- 以上是x86_64体系结构的函数调用过程