花了两个星期的时间,终于把《深入理解计算机系统》的第三章(程序的机器级表示)看完了,收获不小,尤其是对于函数帧栈的理解。这里简单地对IA-32与x86-64 代码中函数调用的栈与帧作一点总结。
IA-32程序用栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分栈称为栈帧(stack frame)。栈帧可以由两个指针界定,在IA-32中,寄存器%ebp为帧指针,%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。
一个简单的整型数交换的函数,C语言源程序代码如下:
void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
int main()
{
int x = 0, y =1;
swap(&x,&y);
return 0;
}
这段代码没有使用任何C/C++语言的库函数,汇编生成的代码阅读起来相对比较容易。
Dump of assembler code for function main:
0x00001f80<+0>: push %ebp //将调用者帧指针压栈
0x00001f81<+1>: mov %esp,%ebp //将当前栈指针赋为帧指针
0x00001f83<+3>: sub $0x18,%esp //开辟栈空间,共24字节
0x00001f86<+6>: lea -0x8(%ebp),%eax //取得x的地址
0x00001f89<+9>: lea -0xc(%ebp),%ecx //取得y的地址
0x00001f8c<+12>: movl $0x0,-0x4(%ebp) //???
0x00001f93<+19>: movl $0x0,-0x8(%ebp) //给x赋0
0x00001f9a<+26>: movl $0x1,-0xc(%ebp) //给y赋1
0x00001fa1<+33>: mov %eax,(%esp) //将x地址存入%esp指向内存
0x00001fa4<+36>: mov %ecx,0x4(%esp) //将y地址存入相应内存
0x00001fa8<+40>: call 0x1f40 <swap> //调用swap函数
0x00001fad<+45>: mov $0x0,%eax //%eax存储main的返回值
0x00001fb2<+50>: add $0x18,%esp //收回栈空间
0x00001fb5<+53>: pop %ebp //返回保存的%ebp
0x00001fb6<+54>: ret //返回
End of assembler dump.
Dump of assembler code for function swap:
0x00001f40<+0>: push %ebp //保存调用者帧指针
0x00001f41<+1>: mov %esp,%ebp //设置当前帧指针
0x00001f43 <+3>: sub $0xc,%esp //分配栈空间,共12bytes
0x00001f46<+6>: mov 0xc(%ebp),%eax //get b
0x00001f49<+9>: mov 0x8(%ebp),%ecx //get a
0x00001f4c<+12>: mov %ecx,-0x4(%ebp) //sa = a
0x00001f4f<+15>: mov %eax,-0x8(%ebp) //sb = b
0x00001f52<+18>: mov -0x4(%ebp),%eax //get sa
0x00001f55<+21>: mov (%eax),%eax //get sb
0x00001f57<+23>: mov %eax,-0xc(%ebp) //temp = *sa
0x00001f5a<+26>: mov -0x8(%ebp),%eax //get sb
0x00001f5d<+29>: mov (%eax),%eax //get*sb
0x00001f5f<+31>: mov -0x4(%ebp),%ecx //get sa
0x00001f62<+34>: mov %eax,(%ecx) //*sa = *sb, *a = *b
0x00001f64<+36>: mov -0xc(%ebp),%eax //get temp
0x00001f67<+39>: mov -0x8(%ebp),%ecx //get sb
0x00001f6a <+42>: mov %eax,(%ecx) //*sb = temp, *b= *a
0x00001f6c<+44>: add $0xc,%esp //收回栈空间
0x00001f6f<+47>: pop %ebp //恢复帧指针
0x00001f70<+48>: ret //返回调用者
End of assembler dump.
Main函数与swap函数栈空间分配如下
%ebp->
Saved %ebp
-4
0
-8
X
-12
Y
-16
-20
&y
-24
&x
-28
Return address
Swap函数栈空间分配如下
%ebp->
Saved %ebp
-4
Sa
-8
Sb
-12
temp
值得指出的是,main函数栈空间并没有使用完全,所占的32个字节中,有8个字节并没有使用。这是因为gcc坚持一个x86编程指导方针,一个函数所使用的所有栈空间必须是16字节的整数倍。这样,包括saved %ebp的4个字节和返回地址的4个字节,整好是32个字节。同理,swap函数的栈空间则不存在空闲的问题,恰好占16个字节。
由于编译时没有使用任何优化,两个函数的汇编代码里过度地使用了栈空间,而没有能够完全利用寄存器。IA-32对寄存器的使用惯例是,%eax、%edx和%ecx为调用者保存寄存器。即当过程P调用过程Q时,Q可以覆盖这些寄存器,而不会存坏P所需要的数据。另一方面,%ebx、%esi和%edi则被划分为调用者保存寄存器,这意味着Q必须在覆盖这些寄存器前,先把它们保存到栈中,并在返回前恢复它们。
假如使用-O1级优化来编译代码,所得到的swap函数的汇编代码将大大简化。Main函数传进来的参数,将不再被保存在栈中,而是直接保存在寄存器%eax和%edx中。
Dump of assembler code for function swap:
0x00001f60<+0>: push %ebp
0x00001f61<+1>: mov %esp,%ebp
0x00001f63<+3>: push %esi
0x00001f64<+4>: mov 0x8(%ebp),%eax
0x00001f67<+7>: mov (%eax),%ecx
0x00001f69<+9>: mov 0xc(%ebp),%edx
0x00001f6c<+12>: mov (%edx),%esi
0x00001f6e<+14>: mov %esi,(%eax)
0x00001f70<+16>: mov %ecx,(%edx)
0x00001f72<+18>: pop %esi
0x00001f73<+19>: pop %ebp
0x00001f74<+20>: ret
End of assembler dump.
以上是对IA-32的栈帧结构的一点总结。IA-32的后继版本x86-64对寄存器进行了扩展,寄存器数量增加至16个,每个寄存器长度增加一倍至64位。寄存器的扩展为汇编代码的优化提供了更为广阔的空间。大量的堆栈开销可以省略,而直接用寄存器来存储原本需要存储在栈空间中的局部变量。
同样用上面swap函数的例子来说明该IA-32与x86-64生成汇编代码的不同。执行gcc –m64 –fomit-frame-pointer –O1 test.c,得到的汇编代码如下所示。
Dump of assembler code for function swap:
0x0000000100000f20 <+0>: mov (%rdi),%eax
0x0000000100000f22 <+2>: mov (%rsi),%ecx
0x0000000100000f24 <+4>: mov %ecx,(%rdi)
0x0000000100000f26 <+6>: mov %eax,(%rsi)
0x0000000100000f28 <+8>: retq
0x0000000100000f29 <+9>: nopl 0x0(%rax)
End of assembler dump.
这段代码使用了-fomit-frame-pointer编译选项,意思是省略帧指针。《深入理解计算机系统》一书中文版187页中的例子并没这个优化选项,实际上我在MacOS X 10.9与gcc4.6.1测试时,-O1并没有包含-fomit-frame-pointer。
以上代码与IA-32代码的不同之处在于:
(1) 寄存器名称的变化,看到了64位寄存器——%rsi,%rdi等;
(2) 函数调用的参数传递不再依赖于%ebp的位置,在P调用Q前,参数将被事先存在寄存器中,而不是栈中。事实上,x86-64支持最多6个寄存器来传递参数,如果多于6个,还是要利用栈来传递参数,此时多余的参数将通过与 %rsp的相对位置来指定,而不是帧指针。
从IA-32到x86-64是一个飞跃,x86-64完全兼容IA-32,并且提供了比IA-32更多的寄存器长度和空间。这样内存的寻址范围一下从4GB扩展到了256TB,支持更高精度的运算,拓展的寄存器数量使得函数调用的参数传递变得更为简单和直接。值得一提的是,x86-64最初并不是由IC行业的霸主Intel提出,而是出自追赶者AMD。而Intel自己的64位架构IA-64则由于缺乏对IA-32的兼容,并未被市场接纳。