热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

X8664寄存器和栈帧牛掰降解汇编函数寄存器相关操作

X86-64寄存器和栈帧概要说到x86-64,总不免要说说AMD的牛逼,x86-64是x86系列中集大成者,继承了向后兼容的优良传统&#x

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

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

 

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

#include

 

void sfact_helper ( long int x, long int * resultp)

{

    if (x<&#61;1)

       *resultp &#61; 1;

    else {

       long int nresult;

       sfact_helper(x-1,&nresult);

       *resultp &#61; 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 &#61; sfact(10);

   fprintf(stdout, "sum&#61;%d\n", sum);

    return EXIT_SUCCESS;

}               /* ----------  end of function main  ---------- */

 

命令行中调用gcc&#xff0c;生成汇编语言&#xff1a;

 

Shell>: gcc –O1 –S –o test2.s test2.c

 

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

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


X86时代&#xff0c;参数传递是通过入栈实现的&#xff0c;相对CPU来说&#xff0c;存储器访问太慢&#xff1b;这样函数调用的效率就不高&#xff0c;在x86-64时代&#xff0c;寄存器数量多了&#xff0c;GCC就可以利用多达6个寄存器来存储参数&#xff0c;多于6个的参数&#xff0c;依然还是通过入栈实现。了解这些对我们写代码很有帮助&#xff0c;起码有两点启示&#xff1a;

  • 尽量使用6个以下的参数列表&#xff0c;不要让GCC为难啊。
  • 传递大对象&#xff0c;尽量使用指针或者引用&#xff0c;鉴于寄存器只有64位&#xff0c;而且只能存储整形数值&#xff0c;寄存器存不下大对象

让我们具体看看参数是如何传递的&#xff1a;

 

#include

#include

 

int foo ( int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7 )

{

    int array[] &#61; {100,200,300,400,500,600,700};

    int sum &#61; array[arg1]&#43; array[arg7];

    return sum;

}      /* -----  end of function foo  ----- */

 

    int

main ( int argc, char *argv[] )

{

    int i &#61; 1;

    int j &#61; foo(0,1,2, 3, 4, 5,6);

   fprintf(stdout, "i&#61;%d,j&#61;%d\n", i, j);

    return EXIT_SUCCESS;

}               /* ----------  end of function main  ---------- */

 

 

命令行中调用gcc&#xff0c;生成汇编语言&#xff1a;

 

Shell>: gcc –O1 –S –o test1.s test1.c

 

 

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

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


结构体传参

应&#64;桂南要求&#xff0c;再加一节&#xff0c;相信大家也很想知道结构体是如何存储&#xff0c;如何引用的&#xff0c;如果作为参数&#xff0c;会如何传递&#xff0c;如果作为返回值&#xff0c;又会如何返回。

看下面的例子&#xff1a;

 

#include

#include

 

struct demo_s {

    char var8;

    int  var32;

    long var64;

};

 

struct demo_s foo (struct demo_s d)

{

    d.var8&#61;8;

    d.var32&#61;32;

    d.var64&#61;64;

    return d;

}      /* -----  end of function foo  ----- */

 

    int

main ( int argc, char *argv[] )

{

    struct demo_s d, result;

   result &#61; foo (d);

   fprintf(stdout, "demo: %d, %d, %ld\n", result.var8,result.var32, result.var64);

    return EXIT_SUCCESS;

}               /* ----------  end of function main  ---------- */

 

我们缺省编译选项&#xff0c;加了优化编译的选项可以留给大家思考。

 

 

Shell>gcc  -S -o test.s test.c

 

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

问题3&#xff1a;结构体如何返回&#xff0c;原本%rax充当了返回值的角色&#xff0c;现在添加了返回值2&#xff1a;%rdx。同样&#xff0c;GCC用两个寄存器来表示结构体。
恩&#xff0c; 即使在缺省情况下&#xff0c;GCC依然是想尽办法使用寄存器。随着结构变的越来越大&#xff0c;寄存器不够用了&#xff0c;那就只能使用栈了。

总结

了解寄存器和栈帧的关系&#xff0c;对于gdb调试很有帮助&#xff1b;过些日子&#xff0c;一定找个合适的例子和大家分享一下。

参考

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



推荐阅读
  • BZOJ4240 Gym 102082G:贪心算法与树状数组的综合应用
    BZOJ4240 Gym 102082G 题目 "有趣的家庭菜园" 结合了贪心算法和树状数组的应用,旨在解决在有限时间和内存限制下高效处理复杂数据结构的问题。通过巧妙地运用贪心策略和树状数组,该题目能够在 10 秒的时间限制和 256MB 的内存限制内,有效处理大量输入数据,实现高性能的解决方案。提交次数为 756 次,成功解决次数为 349 次,体现了该题目的挑战性和实际应用价值。 ... [详细]
  • 如何利用正则表达式(regexp)实现高效的模式匹配?本文探讨了正则表达式在编程中的应用,并分析了一个示例程序中存在的问题。通过具体的代码示例,指出该程序在定义和使用正则表达式时的不当之处,旨在帮助读者更好地理解和应用正则表达式技术。 ... [详细]
  • 深入解析C语言中结构体的内存对齐机制及其优化方法
    为了提高CPU访问效率,C语言中的结构体成员在内存中遵循特定的对齐规则。本文详细解析了这些对齐机制,并探讨了如何通过合理的布局和编译器选项来优化结构体的内存使用,从而提升程序性能。 ... [详细]
  • 在 Windows 10 环境中,通过配置 Visual Studio Code (VSCode) 实现基于 Windows Subsystem for Linux (WSL) 的 C++ 开发,并启用智能代码提示功能。具体步骤包括安装 VSCode 及其相关插件,如 CCIntelliSense、TabNine 和 BracketPairColorizer,确保在 WSL 中顺利进行开发工作。此外,还详细介绍了如何在 Windows 10 中启用和配置 WSL,以实现无缝的跨平台开发体验。 ... [详细]
  • GDB 使用心得与技巧总结
    在使用 GDB 进行调试时,可以采用以下技巧提升效率:1. 通过设置 `set print pretty on` 来美化打印输出,使数据结构更加易读;2. 掌握常见数据结构的打印方法,如链表、树等;3. 利用 `info locals` 命令查看当前作用域内的所有局部变量;4. 在需要进行类型强制转换时,正确使用语法,例如 `p (Test::A *) pObj`。这些技巧能够显著提高调试的便捷性和准确性。 ... [详细]
  • 深入解析 ELF 文件格式与静态链接技术
    本文详细探讨了ELF文件格式及其在静态链接过程中的应用。在C/C++代码转化为可执行文件的过程中,需经过预处理、编译、汇编和链接等关键步骤。最终生成的可执行文件不仅包含系统可识别的机器码,还遵循了严格的文件结构规范,以确保其在操作系统中的正确加载和执行。 ... [详细]
  • PTArchiver工作原理详解与应用分析
    PTArchiver工作原理及其应用分析本文详细解析了PTArchiver的工作机制,探讨了其在数据归档和管理中的应用。PTArchiver通过高效的压缩算法和灵活的存储策略,实现了对大规模数据的高效管理和长期保存。文章还介绍了其在企业级数据备份、历史数据迁移等场景中的实际应用案例,为用户提供了实用的操作建议和技术支持。 ... [详细]
  • 在C#编程中,数值结果的格式化展示是提高代码可读性和用户体验的重要手段。本文探讨了多种格式化方法和技巧,如使用格式说明符、自定义格式字符串等,以实现对数值结果的精确控制。通过实例演示,展示了如何灵活运用这些技术来满足不同的展示需求。 ... [详细]
  • ### 优化后的摘要本文对 HDU ACM 1073 题目进行了详细解析,该题属于基础字符串处理范畴。通过分析题目要求,我们可以发现这是一道较为简单的题目。代码实现中使用了 C++ 语言,并定义了一个常量 `N` 用于字符串长度的限制。主要操作包括字符串的输入、处理和输出,具体步骤涉及字符数组的初始化和字符串的逆序操作。通过对该题目的深入探讨,读者可以更好地理解字符串处理的基本方法和技巧。 ... [详细]
  • 在C语言中,指针的高级应用及其实例分析具有重要意义。通过使用 `&` 符号可以获取变量的内存地址,而 `*` 符号则用于定义指针变量。例如,`int *p;` 定义了一个指向整型的指针变量 `p`。其中,`p` 代表指针变量本身,而 `*p` 则表示指针所指向的内存地址中的内容。此外,指针在不同函数中可以具有相同的变量名,但其作用域和生命周期会有所不同。指针的灵活运用能够有效提升程序的效率和可维护性。 ... [详细]
  • MATLAB字典学习工具箱SPAMS:稀疏与字典学习的详细介绍、配置及应用实例
    SPAMS(Sparse Modeling Software)是一个强大的开源优化工具箱,专为解决多种稀疏估计问题而设计。该工具箱基于MATLAB,提供了丰富的算法和函数,适用于字典学习、信号处理和机器学习等领域。本文将详细介绍SPAMS的配置方法、核心功能及其在实际应用中的典型案例,帮助用户更好地理解和使用这一工具箱。 ... [详细]
  • C++ 异步编程中获取线程执行结果的方法与技巧及其在前端开发中的应用探讨
    本文探讨了C++异步编程中获取线程执行结果的方法与技巧,并深入分析了这些技术在前端开发中的应用。通过对比不同的异步编程模型,本文详细介绍了如何高效地处理多线程任务,确保程序的稳定性和性能。同时,文章还结合实际案例,展示了这些方法在前端异步编程中的具体实现和优化策略。 ... [详细]
  • PHP预处理常量详解:如何定义与使用常量 ... [详细]
  • 全局变量与常量在内存中的布局分析及应用
    本文详细探讨了全局变量与常量在内存中的存储布局及其应用。通过分析不同编译器和操作系统对全局变量与常量的处理方式,揭示了它们在内存中的具体分配机制。此外,文章还讨论了这些布局对程序性能和安全的影响,并提供了优化建议,帮助开发者更好地理解和利用全局变量与常量的内存管理。 ... [详细]
  • 开源实习机会 | Compiler SIG 正式发布实习任务,诚邀您加入申请!
    对编译技术充满兴趣却苦于无从入手?当前疫情形势下,外出实习变得困难重重?现在,Compiler SIG 正式发布了一系列实习任务,为有志之士提供了宝贵的机会。无论你是初学者还是有一定基础的学生,都能在这里找到适合自己的实践项目。我们诚挚邀请您的加入,共同探索编译技术的无限可能! ... [详细]
author-avatar
wInnIe小店
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有