X86-64寄存器和栈帧

简介

通用寄存器可用于传送和暂存数据,也可参与算术逻辑运算,并保存运算结果。除此之外,它们还各自具有一些特殊功能。通用寄存器的长度取决于机器字长,汇编语言程序员必须熟悉每个寄存器的一般用途和特殊用途,只有这样,才能在程序中做到正确、合理地使用它们。

16位cpu通用寄存器共有 8 个:AX,BX,CX,DX,BP,SP,SI,DI.

八个寄存器都可以作为普通的数据寄存器使用。

但有的有特殊的用途:AX为累加器,CX为计数器,BX,BP为基址寄存器,SI,DI为变址寄存器,BP还可以是基

指针,SP为堆栈指针

32位cpu通用寄存器共有 8 个: EAX,EBX,ECX,EDX,EBP,ESP,ESI,EDI功能和上面差不多

分类

数据寄存器

  数据寄存器主要用来保存操作数和运算结果等信息,从而节省读取操作数所需占用总线和访问存储器的时间。[1]  
  32位CPU有4个32位的通用寄存器EAX、EBX、ECX和EDX。对低16位数据的存取,不会影响高16位的数据。这些低16位寄存器分别命名为:AX、BX、CX和DX,它和先前的CPU中的寄存器相一致。 [1]

4个16位寄存器又可分割成8个独立的8位寄存器(AX:AH-AL、BX:BH-BL、CX:CH-CL、DX:DH-DL),每个寄存器都有自己的名称,可独立存取。程序员可利用数据寄存器的这种“可分可合”的特性,灵活地处理字/字节的信息。 [1]

寄存器AX和AL通常称为累加器(Accumulator),用累加器进行的操作可能需要更少时间。累加器可用于乘、除、输入/输出等操作,它们的使用频率很高; 寄存器BX称为基地址寄存器(Base Register)。它可作为存储器指针来使用; 寄存器CX称为计数寄存器(Count Register)。在循环和字符串操作时,要用它来控制循环次数;在位操作中,当移多位时,要用CL来指明移位的位数; 寄存器DX称为数据寄存器(Data Register)。在进行乘、除运算时,它可作为默认的操作数参与运算,也可用于存放I/O的端口地址。 [1]

在16位CPU中,AX、BX、CX和DX不能作为基址和变址寄存器来存放存储单元的地址,但在32位CPU中,其32位寄存器EAX、EBX、ECX和EDX不仅可传送数据、暂存数据保存算术逻辑运算结果,而且也可作为指针寄存器,所以,这些32位寄存器更具有通用性。详细内容请见第3.8节——32位地址的寻址方式。[1]

变址寄存器

  32位CPU有2个32位通用寄存器ESI和EDI。其低16位对应先前CPU中的SI和DI,对低16位数据的存取,不影响高16位的数据。 [1]

寄存器ESI、EDI、SI和DI称为变址寄存器(Index Register),它们主要用于存放存储单元在段内的偏移量,用它们可实现多种存储器操作数的寻址方式(在第3章有详细介绍),为以不同的地址形式访问存储单元提供方便。 变址寄存器不可分割成8位寄存器。作为通用寄存器,也可存储算术逻辑运算的操作数和运算结果。 [1]

它们可作一般的存储器指针使用。在字符串操作指令的执行过程中,对它们有特定的要求,而且还具有特殊的功能。[1]

指针寄存器

32位CPU有2个32位通用寄存器EBP和ESP。其低16位对应先前CPU中的SBP和SP,对低16位数据的存取,不影响高16位的数据。 寄存器EBP、ESP、BP和SP称为指针寄存器(Pointer
Register),主要用于存放堆栈内存储单元的偏移量,用它们可实现多种存储器操作数的寻址方式(在第3章有详细介绍),为以不同的地址形式访问存储单元提供方便。指针寄存器不可分割成8位寄存器。作为通用寄存器,也可存储算术逻辑运算的操作数和运算结果。[1]

段寄存器

段寄存器是根据内存分段的管理模式而设置的。内存单元的物理地址由段寄存器的值和一个偏移量组合而成的,这样可用两个较少位数的值组合成一个可访问较大物理空间的内存地址。[1]

指令指针寄存器

32位CPU把指令指针扩展到32位,并记作EIP,EIP的低16位与先前CPU中的IP作用相同。 指令指针EIP、IP(Instruction Pointer)是存放下次将要执行的指令在代码段的偏移量。在具有预取指令功能的系统中,下次要执行的指令通常已被预取到指令队列中,除非发生转移情况。所以,在理解它们的功能时,不考虑存在指令队列的情况。 在实方式下,由于每个段的最大范围为64K,所以,EIP中的高16位肯定都为0,此时,相当于只用其低16位的IP来反映程序中指令的执行次序。[1]

主要用途

通用寄存器数据

寄存器AX乘、除运算,字的输入输出,中间结果的缓存

AL字节的乘、除运算,字节的输入输出,十进制算术运算

AH字节的乘、除运算,存放中断的功能号

BX存储器指针

CX串操作、循环控制的计数器

CL移位操作的计数器

DX字的乘、除运算,间接的输入输出

变址

寄存器SI存储器指针、串指令中的源操作数指针

DI存储器指针、串指令中的目的操作数指针

变址

分类示意图

寄存器BP存储器指针、存取堆栈的指针

SP堆栈的栈顶指针

指令指针IP/EIP

标志位寄存器Flag/EFlag

32位

CPU的

段寄存器16位CPU的

段寄存器ES 附加段寄存器

CS 代码段寄存器

SS 堆栈段寄存器

DS 数据段寄存器

新增加的

段寄存器FS 附加段寄存器

GS 附加段寄存器

相关信息

寄存器是CPU内部重要的数据存储资源,用来暂存数据和地址,是汇编程序员能直接使用的硬件资源之一。由于寄存器的存取速度比内存快,所以,在用汇编语言编写程序时,要尽可能充分利用寄存器的存储功能。

运算器结构

寄存器一般用来保存程序的中间结果,为随后的指令快速提供操作数,从而避免把中间结果存入内存,再读取内存的操作。在高级语言(如:C/C++语言)中,也有定义变量为寄存器类型的,这就是提高寄存器利用率的一种可行的方法。

另外,由于寄存器的个数和容量都有限,不可能把所有中间结果都存储在寄存器中,所以,要对寄存器进行适当的调度。根据指令的要求,如何安排适当的寄存器,避免操作数过多的传送操作是一项细致而又周密的工作。

阅读提示:通过增加CPU的寄存器数量来提升64位处理器的处理速度和性能。

64 位寄存器
寄存器是一个系统可提供的最快内存类型。它们创建并存储 CPU 操作和其他计算的结果。32 位 x86 CPU 包括 8 个通用寄存器。64 位 x64 处理器有 16 个寄存器。 Itanium 处理器拥有更先进的寄存器引擎(128 个浮点寄存器和120 个通用寄存器),并且支持更复杂的寄存器操作,这些都是通过全新的体系结构实现的。通过提供更多寄存器以及更多寄存器空间,64 位处理器(以及为它们编写的应用程序和操作系统)可以更有效地处理数据,每个时钟周期可以移动更多信息。
增加的可靠性
Windows 64 位系统更可靠、更灵活并且更安全,所有这一切加在一起就是更高的可靠性。64 位版本的 Microsoft Windows XP Professional 和 Windows Server 2003 支持机器检查体系结构(Machine Check
Architecture,MCA),该体系结构为所有作为机器检查结果而报告的硬件错误提供一个到操作系统的接口。随着新的可靠性功能(如多路径 I/O 或动态系统分区)添加到未来版本的操作系统,在允许这些功能恢复到发生严重硬件错误之前的状态时,MCA 能够起到重要的作用。

32位CPU所含有的寄存器有:

4个数据寄存器(EAX、EBX、ECX和EDX)

2个变址和指针寄存器(ESI和EDI)

2个指针寄存器(ESP和EBP)

6个段寄存器(ES、CS、SS、DS、FS和GS)

1个指令指针寄存器(EIP)

1个标志寄存器(EFlags)

其中EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI  这8个称为通用寄存器

32位的CPU包含了8个32位的通用寄存器

EAX与AX不是独立的,他们属于一个包含的关系,EAX是32位的寄存器,而AX是EAX的低16位,AX又可以分为8位的 AL(低8位寄存器)
和 AH(高8位寄存器) 寄存器

同样的       EBX与BX、BL、BH;        ECX和CX、CL,CH;        EDX和DX、DL、DH  
    都可以分为这些。

如果对上面所说的寄存器还疑惑不解的话,这里有一张网友提供的图来帮助大家理解:

先请看图,图看懂了就基本解决这个了疑问了。

00000000 00000000 00000000 00000000

|===============EAX===============|--32个0,     4个字节

|======AX=======|--16个0,   2个字节,

|==AH===|-----------8个0,   1个字节,

|===AL==|---8个0,  1个字节,

EAX上面每个0是一位,一段里面是8个0也就是8位(bit); 一共4段,因为1个字节=8 bit, 所以是32位 = 4个字节,下面的AX也是如此,他们并不是相互独立的,而是一个整体,可以说是包含的关系,当然他们有他们各自存在的价值。

下面是常用的寄存器的功能和用处:

AX:乘、除运算,字的输入输出,中间结果的缓存

AL:字节的乘、除运算,字节的输入输出,十进制算术运算

AH:字节的乘、除运算,存放中断的功能号

BX:存储器指针

CX:串操作、循环控制的计数器

CL:移位操作的计数器

DX:字的乘、除运算,间接的输入输出

SI:存储器指针、串指令中的源操作数指针

DI:存储器指针、串指令中的目的操作数指针

BP:存储器指针、存取堆栈的指针

SP:堆栈的栈顶指针

说了这么多,我想你们已经对32为CPU的寄存器有所了解,那么接下来我来说一下64位CPU的寄存器:

中心句:64位CPU的寄存器与32位CPU的寄存器大同小异。

64位CPU所用的通用寄存器是64位的通用寄存器,换句话说,通用寄存器为64为的CPU才能被公认为是64为的CPU.

CPU位数有两种不同的定义方式:
有用CPU核心中通用寄存器的位宽定义的,也有用数据总线位宽定义。

后一种定义方式确实就等于是用于传递数据的引脚的数量,
不过大多数人(包括我)更倾向于用前一种定义——
因为假设用后一种定义方式,那么早在Pentium Pro / Ⅱ / Ⅲ 时代,
就已经使用64位数据总线了,但是大多数人不认为它们是 64 位CPU。
(64位浮点寄存器,32位通用寄存器,36位地址总线,64位数据总线。)

我们更倾向于认为只有使用 64位通用寄存器的CPU才是真正的 64位,

而前面说的那种64位数据线,32位通用寄存器的CPU只能算是 32位的。

64为CPU和32位CPU的寄存器数目一样,只不过是寄存器的位数变成了64,也就是说,处理器一次可以运行64bit的数据,而32为的CPU一次只可以运行32bit的数据,所以说64位的CPU的处理能力要高于32位的CPU,但是必须具备3个条件,才能发挥出优势:

  1. 具备一个64位的CPU
  2. 具备一个64为的操作系统(系统软件)
  3. 在系统上运行的是64位的应用软件

X86-64寄存器和栈帧

CPU/寄存器/内存

  因为要了解多线程,自然少不了一些硬件知识的科普,我没有系统学习过硬件知识,仅仅是从书上以及网络上看来的,如果有错误请指出来。

  CPU,全名Central
Processing Unit(*处理器)。这是一块超大规模的集成电路,包含上亿的晶体管,是一台计算机的运算核心(Core)和控制核心(ControlUnit)。它的功能主要是解释计算机指令以及处理计算机软件中的数据。
  它的主要构成是:运算器、控制器、寄存器
  运算器:可以执行定点或浮点算术运算操作、移位操作以及逻辑操作,也可执行地址运算和转换。
  控制器:主要是负责对指令译码,并且发出为完成每条指令所要执行的各个操作的控制信号。其结构有两种:一种是以微存储为核心的微程序控制方式;一种是以逻辑硬布线结构为主的控制方式。
  寄存器:寄存器部件,包括寄存器、专用寄存器和控制寄存器。通用寄存器又可分定点数和浮点数两类,它们用来保存指令执行过程中临时存放的寄存器操作数和中间(或最终)的操作结果。 通用寄存器是*处理器的重要部件之一。

  工作过程:
  第一阶段,提取,从存储器或高速缓冲存储器中检索指令(为数值或一系列数值)。由程序计数器(Program
Counter)指定存储器的位置。(程序计数器保存供识别程序位置的数值。换言之,程序计数器记录了CPU在程序里的踪迹。)
  第二阶段:解码(控制器)
  第三阶段:执行,算术逻辑单元(ALU,Arithmetic Logic Unit)将会连接到一组输入和一组输出。输入提供了要相加的数值,而输出将含有总和的结果。ALU内含电路系统,易于输出端完成简单的普通运算和逻辑运算(比如加法和位元运算)。如果加法运算产生一个对该CPU处理而言过大的结果,在标志暂存器里可能会设置运算溢出(Arithmetic
Overflow)标志。
  第四阶段:回写,缓冲Cache或者更大更廉价的低俗存储器(内存、硬盘等等)

  寄存器:是集成电路中非常重要的一种存储单元,通常由触发器组成。在集成电路设计中,寄存器可分为电路内部使用的寄存器和充当内外部接口的寄存器这两类。内部寄存器不能被外部电路或软件访问,只是为内部电路的实现存储功能或满足电路的时序要求。而接口寄存器可以同时被内部电路和外部电路或软件访问,CPU中的寄存器就是其中一种,作为软硬件的接口,为广泛的通用编程用户所熟知。

  常见类型
  1)数据寄存器-
用来储存整数数字(参考以下的浮点寄存器)。在某些简单/旧的CPU,特别的数据寄存
  2)寄存器
  3)寄存器
  4)器是累加器,作为数学计算之用。
  5)地址寄存器-
持有存储器地址,用来访问存储器。在某些简单/旧的CPU里,特别的地址寄存器是索引寄存器(可能出现一个或多个)。
  6)通用目的寄存器(GPRs) - 可以保存数据或地址两者,也就是说它们是结合数据/地址 寄存器的功用。
  7)浮点寄存器(FPRs) - 用来储存浮点数字。
  8)常数寄存器-
用来持有只读的数值(例如0、1、圆周率等等)。
  9)向量寄存器-
用来储存由向量处理器运行SIMD(Single Instruction, Multiple Data)指令所得到的数据。
  10)特殊目的寄存器-
储存CPU内部的数据,像是程序计数器(或称为指令指针),堆栈寄存器,以及状态寄存器(或称微处理器状态字组)。
  11)指令寄存器(instruction
register)- 储存现在正在被运行的指令。
  12)索引寄存器(index
register)- 是在程序运行时用来更改运算对象地址之用。

特点
寄存器又分为内部寄存器与外部寄存器,所谓内部寄存器,其实也是一些小的存储单元,也能存储数据。但同存储器相比,寄存器又有自己独有的特点:
①寄存器位于CPU内部,数量很少,仅十四个
②寄存器所能存储的数据不一定是8bit,有一些寄存器可以存储16bit数据,对于386/486处理器中的一些寄存器则能存储32bit数据
③每个内部寄存器都有一个名字,而没有类似存储器的地址编号。

  作用
  1.可将寄存器内的数据执行算术及逻辑运算
  2.存于寄存器内的地址可用来指向内存的某个位置,即寻址
  3.可以用来读写数据到电脑的周边设备。

  简单的说:指令解析 - 数据/操作(寄存器)- 回写(cache/memory/disk)

  计算机的存储层次(memory hierarchy)之中,寄存器最快,内存其次,最慢的是硬盘。同样都是晶体管存储设备,为什么寄存器比内存快呢?Mike
Ash写了一篇很好的解释,非常通俗地回答了这个问题,有助于加深对硬件的理解。

  原因一:距离不同
  距离不是主要因素,但是最好懂,所以放在最前面说。内存离CPU比较远,所以要耗费更长的时间读取。
以3GHz的CPU为例,电流每秒钟可以振荡30亿次,每次耗时大约为0.33纳秒。光在1纳秒的时间内,可以前进30厘米。也就是说,在CPU的一个时钟周期内,光可以前进10厘米。因此,如果内存距离CPU超过5厘米,就不可能在一个时钟周期内完成数据的读取,这还没有考虑硬件的限制和电流实际上达不到光速。相比之下,寄存器在CPU内部,当然读起来会快一点。距离对于桌面电脑影响很大,对于手机影响就要小得多。手机CPU的时钟频率比较慢(iPhone 5s为1.3GHz),而且手机的内存紧挨着CPU。

  原因二:硬件设计不同(1 Byte表示一个字节, 1B=8bit)
  最新的iPhone 5s,CPU是A7,寄存器有6000多位(31个64位寄存器,加上32个128位寄存器)。而iPhone 6s的内存是1GB,约为80亿位(bit)。这意味着,高性能、高成本、高耗电的设计可以用在寄存器上,反正只有6000多位,而不能用在内存上。因为每个位的成本和能耗只要增加一点点,就会被放大80亿倍。事实上确实如此,内存的设计相对简单,每个位就是一个电容和一个晶体管,而寄存器的设计则完全不同,多出好几个电子元件。并且通电以后,寄存器的晶体管一直有电,而内存的晶体管只有用到的才有电,没用到的就没电,这样有利于省电。这些设计上的因素,决定了寄存器比内存读取速度更快。

  原因三:工作方式不同
  寄存器的工作方式很简单,只有两步:(1)找到相关的位,(2)读取这些位。
  内存的工作方式就要复杂得多:
  (1)找到数据的指针。(指针可能存放在寄存器内,所以这一步就已经包括寄存器的全部工作了。)
  (2)将指针送往内存管理单元(MMU),由MMU将虚拟的内存地址翻译成实际的物理地址。
  (3)将物理地址送往内存控制器(memory
controller),由内存控制器找出该地址在哪一根内存插槽(bank)上。
  (4)确定数据在哪一个内存块(chunk)上,从该块读取数据。
  (5)数据先送回内存控制器,再送回CPU,然后开始使用。
  内存的工作流程比寄存器多出许多步。每一步都会产生延迟,累积起来就使得内存比寄存器慢得多。为了缓解寄存器与内存之间的巨大速度差异,硬件设计师做出了许多努力,包括在CPU内部设置缓存Cache、优化CPU工作方式,尽量一次性从内存读取指令所要用到的全部数据等等。

  上面说到”缓存“,大部分程序员都知道什么是软件架构中缓存的概念。这里所说的缓存是指硬件“高速缓冲存储器”,是存在于主存与CPU之间的一级存储器(常见于计算机cpu性能指标中:一级缓存、二级缓存,高配置的服务器会有三级缓存), 由静态存储芯片(SRAM)组成,容量比较小但速度比主存高得多, 接近于CPU的速度。在计算机存储系统的层次结构中,是介于*处理器和主存储器之间的高速小容量存储器。它和主存储器一起构成一级的存储器。高速缓冲存储器和主存储器之间信息的调度和传送是由硬件自动进行的。高速缓冲存储器最重要的技术指标是它的命中率(一级缓存(a=n*80%)
- 二级缓存(b=a*80%) - 三级缓存(c=b*80%))。所谓的命中就是在缓存上读取到指定的数据。
    既然是缓存,那么大小肯定是有局限,也就是说不是所有cpu需要的数据都能在缓存中命中,因为它有着自己的更新策略。如下
   1. 根据程序局部性规律可知:程序在运行中,总是频繁地使用那些最近被使用过的指令和数据。这就提供了替换策略的理论依据。综合命中率、实现的难易及速度的快慢各种因素,替换策略可有随机法、先进先出法、最近最少使用法等。
  (1).随机法(RAND法)
随机法是随机地确定替换的存储块。设置一个随机数产生器,依据所产生的随机数,确定替换块。这种方法简单、易于实现,但命中率比较低。
  (2).先进先出法(FIFO法)
先进先出法是选择那个最先调入的那个块进行替换。当最先调入并被多次命中的块,很可能被优先替换,因而不符合局部性规律。这种方法的命中率比随机法好些,但还不满足要求。先进先出方法易于实现,
  (3).最近最少使用法(LRU法)
LRU法是依据各块使用的情况, 总是选择那个最近最少使用的块被替换。这种方法比较好地反映了程序局部性规律。 实现LRU策略的方法有多种。
  2 在多体并行存储系统中,由于
I/O 设备向主存请求的级别高于 CPU 访存,这就出现了 CPU 等待 I/O 设备访存的现象,致使 CPU 空等一段时间,甚至可能等待几个主存周期,从而降低了 CPU 的工作效率。为了避免 CPU 与 I/O 设备争抢访存,可在 CPU 与主存之间加一级缓存,这样,主存可将 CPU 要取的信息提前送至缓存,一旦主存在与 I/O 设备交换时, CPU 可直接从缓存中读取所需信息,不必空等而影响效率。
  3 目前提出的算法可以分为以下三类(第一类是重点要掌握的):
  (1)传统替换算法及其直接演化,其代表算法有 :①LRU( Least Recently Used)算法:将最近最少使用的内容替换出Cache ;②LFU( Lease Frequently Used)算法:将访问次数最少的内容替换出Cache;③如果Cache中所有内容都是同一天被缓存的,则将最大的文档替换出Cache,否则按LRU算法进行替换 。④FIFO(
First In First Out):遵循先入先出原则,若当前Cache被填满,则替换最早进入Cache的那个。
  (2)基于缓存内容关键特征的替换算法,其代表算法有:①Size替换算法:将最大的内容替换出Cache②LRU— MIN替换算法:该算法力图使被替换的文档个数最少。设待缓存文档的大小为S,对Cache中缓存的大小至少是S的文档,根据LRU算法进行替换;如果没有大小至少为S的对象,则从大小至少为S/2的文档中按照LRU算法进行替换;③LRU—Threshold替换算法:和LRU算法一致,只是大小超过一定阈值的文档不能被缓存;④Lowest Lacency First替换算法:将访问延迟最小的文档替换出Cache。
  (3)基于代价的替换算法,该类算法使用一个代价函数对Cache中的对象进行评估,最后根据代价值的大小决定替换对象。其代表算法有:①Hybrid算法:算法对Cache中的每一个对象赋予一个效用函数,将效用最小的对象替换出Cache;②Lowest
Relative Value算法:将效用值最低的对象替换出Cache;③Least
Normalized Cost Replacement(LCNR)算法:该算法使用一个关于文档访问频次、传输时间和大小的推理函数来确定替换文档;④Bolot等人 提出了一种基于文档传输时间代价、大小、和上次访问时间的权重推理函数来确定文档替换;⑤Size—Adjust LRU(SLRU)算法:对缓存的对象按代价与大小的比率进行排序,并选取比率最小的对象进行替换。

概要

说到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行的指令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系列汇编语言程序设计

上一篇:mvc设计模式的优点


下一篇:Android AR平台