写在前面:从腾讯实习回来之后,就感觉到自己的知识体系过于散乱。于是萌生了写一个自己的操作系统这样的心思,此为系列第一章,主要是讲解一些汇编知识的,内容大多从CSAPP中也可以获得。
本篇内容主要讲解:函数的汇编级实现
在正式讲栈之前,我想先大概讲讲在汇编级别机器是怎么支持函数的。要提供对函数的机器级别支持,必须要处理许多不同的属性。假设函数P调用函数Q,Q之后执行完毕返回P,这些动作细分如下:
- 传递控制:在进入过程Q的时候,程序计数器需要被设置为Q的起始地址,然后在返回的时候,要把程序计数器设置为P中调用Q的那条指令的后一条指令
- 传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P返回值
- 分配和释放内存:在开始的时候,Q可能需要为局部变量分配空间,而在返回前,又需要去释放这些内存
函数内存管理 —— 栈
C语言进行调用函数的时候使用了栈这种数据结构来进行内存管理。当Q运行的时候,它只需要为局部变量分配新的存储空间,这个过程具体就是将栈指针%rsp
下移进行空间开辟,而这个开辟的空间就称之为栈帧!
而对于有形参的函数,在开辟栈帧之前会进行参数压栈(过程从右向左),并压入call
指令的下一条待执行指令的地址。
注:
对于参数传递,x86-64体系下对于函数有6个或者更少的参数,所有的参数都会通过寄存器传递,根本不需要开辟空间!!但是如果需要更多的参数,则是需要上述的开辟栈帧的方式传递。
而对于Q调用结束,则是单纯的将%rsp寄存器的值上移就可以了:
传递控制
对于从函数P到调用函数Q所作的工作就是把程序计数器设置为Q的代码的起始位置,同时在Q返回的时候,处理器需要记录它需要继续P的执行的代码位置。在x86-64体系中,通过指令call Q
来完成这一系列操作。
下表是函数调用所用的指令:
刚刚讲的都是原理性的东西,现在我们来看看汇编代码的实现:
//Begin of function multstore
0000000000400540 <multstore>:
400540: 53 push %rbx //参数压栈
400541: 48 89 d3 mov %rdx, %rbx //%rbx = %rdx
...
//Return from function multstore
40054d: c3 retq //函数返回
...
//Call to multstore from main
400563: e8 d8 ff ff ff callq 400540 <multstore> //调用函数 multstore
400568: 48 8b 54 24 08 mov 0x8(%rsp), %rdx //%rdx = %rsp+8 获取参数的地址
我们看一下这个过程的图示:
练习题
练习题答案
数据传递
当调用一个过程的时候,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能返回一个值。
刚刚我们也提到了,参数传递最多有6个寄存器可以用,超过六个可能就需要开辟栈帧了,下表是这六个参数的对应关系:
当参数都到位之后,程序就可以执行call
指令将控制转移到过程Q,废话不多说,我们看一个实际的例子来参透这个过程:
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;
}
以上函数汇编之后会显示如下的汇编代码:
//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 at %rsp+8 ( 8 bits)
//a4p at %rsp+16 (64 bits)
proc :
movq 16(%rsp), %rax //取得 *a4p的值放入 %rax
addq %rdi, (%rsi) //*a1p+=a1
addl %edx, (%rcx) //*a2p+=a2
addw %r8w, (%r9) //*a3p+=a3
movl 8(%rsp), %edx //%rdx=a4
addb %dl, (%rax) //*a4p+=a4
ret
而此时函数栈帧的情况如下:
练习题
练习题答案
栈上局部存储
到目前为止的大多数函数例子其实都不需要超过寄存器大小的本地存储区域,但是在以下几种情况,局部数据必须放在内存之中:
- 寄存器不够存放所有的本地数据
- 对于一个局部变量使用地址运算符&,因此其必须要有地址
- 某些局部变量是数据或者结构,因此必须能够通过数组或结构引用被访问到
我们看一下下面这个例子:
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函数的反汇编代码:
caller :
subq $16, %rsp //开辟 16字节大小的空间
movq $534, (%rsp) //*%rsp = 534 -->arg1
movq $1057, 8(%rsp) //*(%rsp+8) = 1057 -->arg2
leaq 8(%rsp), %rsi //%rsi = arg2
movq %rsp, %rdi //%rdi = arg1
call swap_add //调用函数swap_add
movq (%rsp), %rdx //%rdx = arg1
subq 8(%rsp), %rdx //%rdx = arg1-arg2
imulq %rdx, %rax //%rax = %rdx * %rax -->sum*diff
addq $16, %rsp //收回空间
ret
而在call swap_add
之前的caller栈情况如下:
寄存器中的局部存储
寄存器组是唯一被所有函数所共享的资源,既然是共享资源,其必然会面对着冲突。所以我们必须确保当一个函数调用另一个函数的时候,被调用者不会覆盖调用者稍后会使用的寄存器。
x86-64中规定:寄存器%rbx
,%rbp
和%r12~%r15
被划分为被调用者保存的寄存器,当一个函数P调用函数Q的时候,Q必须保存P中的值至这些寄存器中,然后在Q中返回的时候再恢复P中的值。而所有其他的寄存器,除了%rsp
之外,都被划分为调用者保存寄存器。
注:
当然,寄存器就那么多,不够了就会放到栈中保存
我们看看下面这个例子:
long P(long x,long y)
{
long u = Q(y);
long v = Q(x);
return u + v;
}
下面是这个代码的反汇编版本:
//x in %rdi, y in %rsi
P:
pushq %rbp //%rbp 压栈
pushq %rbx //%rbx 压栈,此时在 P栈帧
subq $8, %rsp //开辟
movq %rdi, %rbp //%rbp中保存 x的值
movq %rsi, %rdi //%rdi = y
call Q //调用 Q函数,%rdi作为传入参数
movq %rax, %rbx //%rbx中保存 Q函数的返回值
movq %rbp, %rdi //%rdi = x
call Q //调用 Q函数,%rdi作为传入参数
addq %rbx, %rax //%rax += %rbx
addq $8, %rsp //释放 8字节空间
popq %rbx //出栈
popq %rbp //出栈
ret
递归函数
每个函数调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。其实和函数调用没有太大的区别,只是会涉及到跳转指令罢了。
看一下下面这个例子:
long rfact(long n)
{
long result;
if(n <=1)
{
result = 1;
}
else
{
result = n * rfact(n - 1);
}
return result;
}
以下是上面代码的反汇编版本:
//n in %rdi
rfact:
pushq %rbx //%rbx 压栈
movq %rdi, %rbx //保存 n的值
movl $1, %eax //%rax = 1
cmpq $1, %rdi //比较%rdi 和 1,即比较返回值和 1
jle .L35 //如果%rdi <= 1,跳转到 L35
leaq -1(%rdi), %rdi //%rdi = %rdi -1
call rfact //调用 rfact
imu1q %rbx, %rax //%rax = %rax * %rbx
.L35:
popq %rbx //出栈
ret
参考文献
[1] 深入理解计算机系统 第三章 程序的机器级表示