amd64
X86-64寄存器和栈帧
http://www.searchtb.com/2013/03/x86-64_register_and_function_frame.html
概要
说到x86-64,总不免要说说AMD的牛逼,x86-64是x86系列中集大成者,继承了向后兼容的优良传统,最早由AMD公司提出,代号AMD64;正是由于能向后兼容,AMD公司打了一场漂亮翻身战。导致Intel不得不转而生产兼容AMD64的CPU。这是IT行业以弱胜强的经典战役。不过,大家为了名称延续性,更习惯称这种系统结构为x86-64
X86-64在向后兼容的同时,更主要的是注入了全新的特性,特别的:x86-64有两种工作模式,32位OS既可以跑在传统模式中,把CPU当成i386来用;又可以跑在64位的兼容模式中,更加神奇的是,可以在32位的OS上跑64位的应用程序。有这种好事,用户肯定买账啦,
值得一提的是,X86-64开创了编译器的新纪元,在之前的时代里,Intel
CPU的晶体管数量一直以摩尔定律在指数发展,各种新奇功能层出不穷,比如:条件数据传送指令cmovg,SSE指令等。但是GCC只能保守地假设目标机器的CPU是1985年的i386,额。。。这样编译出来的代码效率可想而知,虽然GCC额外提供了大量优化选项,但是这对应用程序开发者提出了很高的要求,会者寥寥。X86-64的出现,给GCC提供了一个绝好的机会,在新的x86-64机器上,放弃保守的假设,进而充分利用x86-64的各种特性,比如:在过程调用中,通过寄存器来传递参数,而不是传统的堆栈。又如:尽量使用条件传送指令,而不是控制跳转指令
寄存器简介
先明确一点,本文关注的是通用寄存器(后简称寄存器)。既然是通用的,使用并没有限制;后面介绍寄存器使用规则或者惯例,只是GCC(G++)遵守的规则。因为我们想对GCC编译的C(C++)程序进行分析,所以了解这些规则就很有帮助。
在体系结构教科书中,寄存器通常被说成寄存器文件,其实就是CPU上的一块存储区域,不过更喜欢使用标识符来表示,而不是地址而已。
X86-64中,所有寄存器都是64位,相对32位的x86来说,标识符发生了变化,比如:从原来的%ebp变成了%rbp。为了向后兼容性,%ebp依然可以使用,不过指向了%rbp的低32位。
X86-64寄存器的变化,不仅体现在位数上,更加体现在寄存器数量上。新增加寄存器%r8到%r15。加上x86的原有8个,一共16个寄存器。
刚刚说到,寄存器集成在CPU上,存取速度比存储器快好几个数量级,寄存器多了,GCC就可以更多的使用寄存器,替换之前的存储器堆栈使用,从而大大提升性能。
让寄存器为己所用,就得了解它们的用途,这些用途都涉及函数调用,X86-64有16个64位寄存器,分别是:%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。其中:
- %rax 作为函数返回值使用。
- %rsp 栈指针寄存器,指向栈顶
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数。。。
- %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
- %r10,%r11 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值
栈帧结构
C语言属于面向过程语言,他最大特点就是把一个程序分解成若干过程(函数),比如:入口函数是main,然后调用各个子函数。在对应机器语言中,GCC把过程转化成栈帧(frame),简单的说,每个栈帧对应一个过程。X86-32典型栈帧结构中,由%ebp指向栈帧开始,%esp指向栈顶。
函数的进入和退出,通过指令call和ret来完成,给一个例子
#include
#include
</code>
int
foo (
int
x )
{
int
array[] = {1,3,5};
return
array[x];
}
/*
----- end of function foo ----- */
int
main (
int
argc,
char
*argv[] )
{
int
i = 1;
int
j = foo(i);
fprintf
(stdout,
"i=%d,j=%d\n"
,
i, j);
return
EXIT_SUCCESS;
}
/*
---------- end of function main ---------- */
命令行中调用gcc,生成汇编语言:
Shell
> gcc –S –o test.s test.c
Main函数第40行的指令Call foo其实干了两件事情:
- Pushl %rip //保存下一条指令(第41行的代码地址)的地址,用于函数返回继续执行
- Jmp foo //跳转到函数foo
Foo函数第19行的指令ret 相当于:
- popl %rip //恢复指令指针寄存器
栈帧的建立和撤销
还是上一个例子,看看栈帧如何建立和撤销
说题外话,以”点”做为前缀的指令都是用来指导汇编器的命令。无意于程序理解,统统忽视之,比如第31行。
栈帧中,最重要的是帧指针%ebp和栈指针%esp,有了这两个指针,我们就可以刻画一个完整的栈帧
函数main的第30~32行,描述了如何保存上一个栈帧的帧指针,并设置当前的指针。
第49行的leave指令相当于:
Movq %rbp %rsp //撤销栈空间,回滚%rsp
Popq %rbp //恢复上一个栈帧的%rbp
同一件事情会有很多的做法,GCC会综合考虑,并作出选择。选择leave指令,极有可能因为该指令需要存储空间少,需要时钟周期也少。
你会发现,在所有的函数中,几乎都是同样的套路,
我们通过gdb观察一下进入foo函数之前main的栈帧,进入foo函数的栈帧,退出foo的栈帧情况
Shell>
gcc -g -o test test.c
Shell>
gdb --args test
Gdb
> break main
Gdb
> run
进入foo函数之前:
你会发现rbp-rsp=0×20,这个是由代码第11行造成的。
进入foo函数的栈帧:
回到main函数的栈帧,rbp和rsp恢复成进入foo之前的状态,就好像什么都没发生一样。
你刚刚搞清楚帧指针,是不是很期待要马上派上用场,这样你可能要大失所望,因为大部分的程序,都加了优化编译选项:-O2,这几乎是普遍的选择。在这种优化级别,甚至更低的优化级别-O1,都已经去除了帧指针,也就是%ebp中再也不是保存帧指针,而且另作他途。
在x86-32时代,当前栈帧总是从保存%ebp开始,空间由运行时决定,通过不断push和pop改变当前栈帧空间;x86-64开始,GCC有了新的选择,优化编译选项-O1,可以让GCC不再使用栈帧指针,下面引用 gcc manual 一段话 :
-O
also turns on -fomit-frame-pointer on machines where doing so does not interfere with debugging.
这样一来,所有空间在函数开始处就预分配好,不需要栈帧指针;通过%rsp的偏移就可以访问所有的局部变量。
说了这么多,还是看看例子吧。同一个例子, 加上-O1选项:
Shell>:
gcc –O1 –S –o test.s test.c
分析main函数,GCC分析发现栈帧只需要8个字节,于是进入main之后第一条指令就分配了空间(第23行):
Subq
$8, %rsp
然后在返回上一栈帧之前,回收了空间(第34行):
Addq
$8, %rsp
等等,为啥main函数中并没有对分配空间的引用呢?这是因为GCC考虑到栈帧对齐需求,故意做出的安排。
再来看foo函数,这里你可以看到%rsp是如何引用栈空间的。
等等,不是需要先预分配空间吗?这里为啥没有预分配,直接引用栈顶之外的地址?
这就要涉及x86-64引入的牛逼特性了。
访问栈顶之外
通过readelf查看可执行程序的header信息:
红色区域部分指出了x86-64遵循ABI规则的版本,它定义了一些规范,遵循ABI的具体实现应该满足这些规范,其中,他就规定了程序可以使用栈顶之外128字节的地址。
这说起来很简单,具体实现可有大学问,这超出了本文的范围,具体大家参考虚拟存储器。别的不提,接着上例,我们发现GCC利用了这个特性,干脆就不给foo函数分配栈帧空间了,而是直接使用栈帧之外的空间。@恨少说这就相当于内联函数呗,我要说:这就是编译优化的力量。
寄存器保存惯例
过程调用中,调用者栈帧需要寄存器暂存数据,被调用者栈帧也需要寄存器暂存数据。如果调用者使用了%rbx,那被调用者就需要在使用之前把%rbx保存起来,然后在返回调用者栈帧之前,恢复%rbx。遵循该使用规则的寄存器就是被调用者保存寄存器,对于调用者来说,%rbx就是非易失的。
反过来,调用者使用%r10存储局部变量,为了能在子函数调用后还能使用%r10,调用者把%r10先保存起来,然后在子函数返回之后,再恢复%r10。遵循该使用规则的寄存器就是调用者保存寄存器,对于调用者来说,%r10就是易失的,
举个例子:
#include
<stdio.h>
#include
<stdlib.h>
void
sfact_helper (
long
int
x,
long
int
* resultp)
{
if
(x<=1)
*resultp
= 1;
else
{
long
int
nresult;
sfact_helper(x-1,&nresult);
*resultp
= x * nresult;
}
}
/*
----- end of function foo ----- */
long
int
sfact
(
long
int
x )
{
long
int
result;
sfact_helper(x,
&result);
return
result;
}
/*
----- end of function sfact ----- */
int
main
(
int
argc,
char
*argv[] )
{
int
sum = sfact(10);
fprintf
(stdout,
"sum=%d\n"
,
sum);
return
EXIT_SUCCESS;
}
/*
---------- end of function main ---------- */
命令行中调用gcc,生成汇编语言:
Shell>:
gcc –O1 –S –o test2.s test2.c
参数传递
X86时代,参数传递是通过入栈实现的,相对CPU来说,存储器访问太慢;这样函数调用的效率就不高,在x86-64时代,寄存器数量多了,GCC就可以利用多达6个寄存器来存储参数,多于6个的参数,依然还是通过入栈实现。了解这些对我们写代码很有帮助,起码有两点启示:
- 尽量使用6个以下的参数列表,不要让GCC为难啊。
- 传递大对象,尽量使用指针或者引用,鉴于寄存器只有64位,而且只能存储整形数值,寄存器存不下大对象
让我们具体看看参数是如何传递的:
#include
<stdio.h>
#include
<stdlib.h>
int
foo (
int
arg1,
int
arg2,
int
arg3,
int
arg4,
int
arg5,
int
arg6,
int
arg7 )
{
int
array[] = {100,200,300,400,500,600,700};
int
sum = array[arg1] + array[arg7];
return
sum;
}
/*
----- end of function foo ----- */
int
main
(
int
argc,
char
*argv[] )
{
int
i = 1;
int
j = foo(0, 1, 2, 3, 4, 5, 6);
fprintf
(stdout,
"i=%d,j=%d\n"
,
i, j);
return
EXIT_SUCCESS;
}
/*
---------- end of function main ---------- */
命令行中调用gcc,生成汇编语言:
Shell>:
gcc –O1 –S –o test1.s test1.c
结构体传参
应@桂南要求,再加一节,相信大家也很想知道结构体是如何存储,如何引用的,如果作为参数,会如何传递,如果作为返回值,又会如何返回。
看下面的例子:
#include
<stdio.h>
#include
<stdlib.h>
struct
demo_s {
char
var8;
int
var32;
long
var64;
};
struct
demo_s foo (
struct
demo_s d)
{
d.var8=8;
d.var32=32;
d.var64=64;
return
d;
}
/*
----- end of function foo ----- */
int
main
(
int
argc,
char
*argv[] )
{
struct
demo_s d, result;
result
= foo (d);
fprintf
(stdout,
"demo:
%d, %d, %ld\n"
,
result.var8, result.var32, result.var64);
return
EXIT_SUCCESS;
}
/*
---------- end of function main ---------- */
我们缺省编译选项,加了优化编译的选项可以留给大家思考。
Shell>gcc
-S -o test.s test.c
总结
了解寄存器和栈帧的关系,对于gdb调试很有帮助;过些日子,一定找个合适的例子和大家分享一下。
参考
1. 深入理解计算机体系结构
2. x86系列汇编语言程序设计