X86-64寄存器和栈帧--牛掰降解汇编函数寄存器相关操做

X86-64寄存器和栈帧

概要

说到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上的一块存储区域,不过更喜欢使用标识符来表示,而不是地址而已。spa

X86-64中,全部寄存器都是64位,相对32位的x86来讲,标识符发生了变化,好比:从原来的%ebp变成了%rbp。为了向后兼容性,%ebp依然可使用,不过指向了%rbp的低32位。命令行

X86-64寄存器的变化,不只体如今位数上,更加体如今寄存器数量上。新增长寄存器%r8到%r15。加上x86的原有8个,一共16个寄存器。
刚刚说到,寄存器集成在CPU上,存取速度比存储器快好几个数量级,寄存器多了,GCC就能够更多的使用寄存器,替换以前的存储器堆栈使用,从而大大提高性能。debug

让寄存器为己所用,就得了解它们的用途,这些用途都涉及函数调用,X86-64有16个64位寄存器,分别是:设计

%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。3d

其中:指针

  • %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行的指令Callfoo其实干了两件事情:

  • 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 testtest.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

 

在函数sfact_helper中,用到了寄存器%rbx和%rbp,在覆盖以前,GCC选择了先保存他们的值,代码6~9说明该行为。在函数返回以前,GCC依次恢复了他们,就如代码27-28展现的那样。

看这段代码你可能会困惑?为何%rbx在函数进入的时候,指向的是-16(%rsp),而在退出的时候,变成了32(%rsp) 。上文不是介绍过一个重要的特性吗?访问栈帧以外的空间,这是GCC不用先分配空间再使用;而是先使用栈空间,而后在适当的时机分配。第11行代码展现了空间分配,以后栈指针发生变化,因此同一个地址的引用偏移也相应作出调整。


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

 

 

Main函数中,代码31~37准备函数foo的参数,从参数7开始,存储在栈上,%rsp指向的位置;参数6存储在寄存器%r9d;参数5存储在寄存器%r8d;参数4对应于%ecx;参数3对应于%edx;参数2对应于%esi;参数1对应于%edi。

Foo函数中,代码14-15,分别取出参数7和参数1,参与运算。这里数组引用,用到了最经典的寻址方式,-40(%rsp,%rdi,4)=%rsp + %rdi *4 + (-40);其中%rsp用做数组基地址;%rdi用做了数组的下标;数字4表示sizeof(int)=4。


结构体传参

应@桂南要求,再加一节,相信你们也很想知道结构体是如何存储,如何引用的,若是做为参数,会如何传递,若是做为返回值,又会如何返回。

看下面的例子:

 

#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

 

上面的代码加了一些注释,方便你们理解,
问题1:结构体如何传递?它被分红了两个部分,var8和var32合并成8个字节的大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。也就是结构体分解了。
问题2:结构体如何存储? 注意看foo函数的第15~17行注意到,结构体的引用变成了一个偏移量访问。这和数组很像,只不过他的元素大小可变。

问题3:结构体如何返回,本来%rax充当了返回值的角色,如今添加了返回值2:%rdx。一样,GCC用两个寄存器来表示结构体。
恩, 即便在缺省状况下,GCC依然是想尽办法使用寄存器。随着结构变的愈来愈大,寄存器不够用了,那就只能使用栈了。

总结

了解寄存器和栈帧的关系,对于gdb调试颇有帮助;过些日子,必定找个合适的例子和你们分享一下。

参考

1. 深刻理解计算机体系结构 2. x86系列汇编语言程序设计

相关文章
相关标签/搜索