x86_64如何寻址内存

2020-06-14 04:59:18

今天,我将编写x86_64指令语义的一个小片段(但仍然非常复杂):内存寻址。

具体地说,我将写出x86_64允许用户仅通过一条指令来寻址内存的不同方式:mov。

我不会尝试讨论其他可以触及内存的指令(多亏了CISC,这几乎是所有的指令),那些写大量内存的指令(看看你,fxsave),或者任何相邻的主题(代码模型、独立于位置的代码、二进制重新定位)。我甚至不会尝试讨论历史寻址模式或当x86_64处理器不是64位模式时工作的模式(即,除了64位代码的长模式之外的任何模式)。

尽管(或者也许要感谢?)。传统的地狱,即x86_64的指令编码,在如何寻址内存方面有一些限制。

这两种寻址模式都要求所有寄存器的大小相同。换句话说,我们不能做一些奇怪的事情,比如混合64、32和16位寄存器来产生有效地址-x86_64编码中根本没有这样做的空间。

所有寄存器必须彼此大小相同,但不必与处理器模式相同。特别地,通过在我们的编码中包括地址前缀字节(0x67),我们可以使用32位寄存器而不是64位寄存器。

我称这种模式为“比例-指数-基础-位移”,因为我不知道还能叫什么。

据我所知,Intel和AMD实际上都不认为这是一种单一模式;相反,他们将其称为具有各种不同编码的相关模式的一般集合。

但我们今天讨论的不是编码:我们讨论的是语义,从语义上讲,这些相关模式中的每一种都依赖于参数的某种组合:

位移:整体偏移量。即使在64位模式下,这通常也被限制为32位,但可以是64位,带有一些选择编码。稍后再讲。

这四种组合(包括所有四种)都是有效的。以下是有效的组合,大致按复杂性递增顺序排列:

这可以说是x86系列中最简单的寻址机制:位移字段被视为绝对内存地址。

不幸的是,它在x86_64上也几乎完全无用。还记得那个关于位移的注释吗?大多数情况下都是32位?这意味着您不能表示绝对地址,因为绝对x86_64地址是64位(实际上是48位,但不管怎样),不适合位移。

有一个例外:x86_64允许使用a*寄存器进行64位移位。

;将位于0x0000000000000000ff的qword存储到rax mov rax,[0xff];将位于0x0000000000000000ff的双字存储到eax mov eax,[0xff];将位于0x0000000000000000ff的字存储到ax mov ax,[0xff];将位于0x000000000000000000ff的字节存储到almov al,[0xff]。

GAS(GNU汇编程序)将它们称为32位和64位模式下的movab。

首先,出于与本文无关的代码模型原因。伊莱·本德斯基有一个很棒的博客波斯顿那些。

更具体地说:大多数程序至少有几个在编译时确定的静态地址,比如全局变量。

按RBP mov RBP,RSP movabs rax,偏移x;这里!mov qword PTR[RBP-8]、rax mov rax、qword PTr[RBP-8]mov rax、qword PTR[rax]popRBP ret。

通过基址寄存器的寻址在绝对寻址的基础上增加了一层间接层:不是编码到指令的位移字段中的绝对地址,而是从指定的通用寄存器(任何GPR!万岁!)。

这种间接性允许我们通过以下模式对任意目标寄存器进行绝对寻址:

;将立即数(不是位移)存储到rbx mov rbx,0xacabacabacabacab;将存储在rbx中的地址处的qword存储到rcx mov rcx,[rbx]。

…。但考虑到我们即将看到的更丰富的寻址方式,我们没有多少理由这样做。

因为有时我们已经从另一个操作中获得了一个计算出的地址,我们只想使用它。

上面的置换示例的分解也很好地说明了这一点:

这与通过基址寄存器寻址一样,不同之处在于我们还添加了索引寄存器的值。

;将rcx中的qword存储到计算的内存地址中;作为rax和rbx mov[rax+rbx]中的值之和,rcx。

我费了好大劲才想出一个这样的例子,这当然意味着我的同事马上就找到了一个:

推送RBP mov RBP、RSP mov qword PTR[RBP-8]、RDI mov dword PTR[RBP-12]、ESI mov rax、qword PTR[RBP-8];rax是buf movsxd rcx、dword ptr[RBP-12];rcx是索引movsx eax、字节ptr[rax+rcx];将buf[index]存储到eax POP RBP。

回想起来,这一点显而易见:Base+Index非常适合对数组访问建模,在这种情况下,数组的起始地址和数组偏移量在编译时都不是固定的。

更间接的!如果您还没有猜到,使用基址寄存器和位移字段计算有效地址对应于两个操作:

然后,我们把这笔钱作为我们的有效地址。举个例子:

;将0xafe加到rax中存储的值中,然后将计算出的地址处的qword存储到rbx mov rbx中,[rax+0xcae]。

正如我们在Base+Index中看到的,一些寻址模式自然反映了类似C语言的语义。

基址+位移可以用类似的方式来考虑,但对于结构语义而言:基址寄存器保存结构开始处的地址,而位移字段保存该结构中的固定偏移量。

PUSH RBP mov RBP、RSP mov qword PTR[RBP-8]、RDI mov rax、qword PTR[RBP-8];rax是页栏mov rax、qword PTR[rax+8];rax+8是foobar->b;存储回rax弹出RBP ret。

如果你想一开始的堆栈构造和布局,这也是有意义的,因为每个函数都是一个自定义结构:像[RBP-N]这样的访问基本上是堆栈->对象。

如果最后一种模式对您有意义,那么这一种就是合乎逻辑的下一步:它在语义上是相同的,只是我们还添加了索引寄存器的值。

;将存储在rax和rcx中的值添加0xafe,然后将计算机地址处的qword存储到rbx mov rbx中,[rax+rcx+0xcae]。

就像Base+Index自然地为数组访问建模,Base+位移自然地为结构访问建模一样,Base+Index+Disposition自然地为数组内的结构访问建模!

我费了好大劲才在Godbolt上发出这样的声音,但最终得到了一个带有-O1的:

struct foo{long a;long b;};long square(struct foo foos[],long i){struct foo x=foos[i];return x。b;}。

shl rsi,4 mov rax,qword ptr[rdi+rsi+8];rdi是foos,rsi是i,8是字段偏移量ret。

刻度域类似于位移,因为它是编码到指令中的恒定因子。然而,与置换不同的是,比例受到极大的限制:它只有两位宽,这意味着它只能是4个可能值中的1个:1、2、4或8。

顾名思义,Scale字段用于缩放(即乘以)另一个字段,特别是它总是缩放索引寄存器-Scale不能在没有索引的情况下使用。

在许多其他功能中,Base+(Index*Scale)自然地将访问建模为指针数组(与如上所述的布局结构数组不同):

struct foo{long a;long b;};long bar(struct foo*foos[],long i){struct foo*x=foos[i];return x->;b;}。

MOV rax,qword ptr[rdi+8*rsi];rdi是foos,rsi是i,8是刻度(指针大小!)。MOV RAX,QWORD PTR[RAX+8]RET

我们继续走吧。这与上一个模式几乎相同,只是我们为位移场去掉了基址寄存器。那里没有特别的复杂性。

(index*scale)+位移自然会对数组访问的特殊情况进行建模:当数组可静态寻址(例如,全局),并且元素大小可通过标度计算时。

movsxd rax,EDI mov eax,dword ptr[4*rax+tbl];rax是i,4是比例(sizeof(Int)==4)ret。

现在我们用煤气做饭。这是最后的也是最复杂的x86_64寻址形式,但在概念上绝对没有什么特殊之处:它只是在三参数寻址模式之上再进行一次算术操作。

Lea rax,[rdi+4*rdi]shl rax,4 mov rax,qword ptr[rax+8*rsi+tbl]ret。

上面记录的寻址模式几乎与其历史上的x86_32等效寻址模式相同-其最大变化是允许64位GPRS和(有时)64位位移。

x86_64的真正不同之处在于它添加了一种全新的寻址模式,即众所周知的“相对RIP”寻址。

为什么叫“RIP-Relative”?因为它编码相对于RIP寄存器的值的位移(具体地说,是下一条指令的RIP,而不是当前指令的RIP)。这通常用熟悉的[基址+位移]语法表示,除了基址寄存器现在是RIP而不是GPR:

出于我最初说过我不会在这篇博客中详细讨论的原因:与位置无关的代码和代码模型。

我们将做一个简短的例外:使用RIP相对寻址使得与位置无关的代码更小、更简单,并且非常适合“小”(和默认)代码模型,在这种模型中,所有代码和数据都需要在32位偏移量内可寻址。

foo:mov rax,qword PTR[RIP+tbl@GOTPCREL]mov rax,qword PTR[rax+8*rdi]ret。

foo:call.L0$p.L0$pb:POP eax.Ltmp0:add eax,OFFSET_GLOBAL_OFFSET_TABLE_+(.Ltmp0-.L0$PB)mov ecx,dword PTR[esp+4]mov eax,dword PTR[eax+tbl@get]mov eax,dword PTR[eax+4*ecx]ret。

x86_64几乎扼杀了分段。差不多了。由于平面地址空间,段寄存器不再是必需的,但它们仍然出现在几个地方:

Linux(实际上是glibc)在用户空间中使用fs来访问内核配置的TLS段,您可以在每个CPU的GDT配置中找到这些段。假设glibc中的其他东西(或您使用的任何libc)没有使用它,那么gs似乎可以在用户空间中免费使用。

Linux在内核空间中使用GS来存储每个CPU变量区域的基址。我们可以在PER_CPU_VAR的宏定义中看到这一点:

所以,不幸的是,我们仍然需要关心这些。好消息是,关心它们并不是太糟糕:它们本质上归结为将段寄存器中的值与地址计算的其余部分相加。

Push RBP mov RBP,RSP mov rax,qword PTR fs:[0];抓取线程本地存储区Lea rax的基地址[rax+x@TPOFF];计算TLS mov qword PTR[RBP-8],rax内x的有效地址;将x的地址存储到y mov rax,qword PTR[RBP-8]mov eax,dword PTR[rax]popRBP ret中。

我们的第一个问题是:当使用64位寄存器进行寻址时,这是正确的,但在使用32位寄存器时则不是这样。当使用32位寄存器寻址时,我们可以使用除esp之外的任何32位gpr作为索引,这要归功于编码怪癖(指示esp(0b100)的位模式改为用来指示…。某事)。(↩