编译Lisp:指令编码插曲

2020-10-19 13:54:02

欢迎回到编译Lisp系列。在这个激动人心的新更新中,我们将学习更多关于x86-64指令编码的知识,而不是在堆上分配更多有趣的东西或添加过程调用。

我之所以写这段插曲,是因为我把编译器代码中的一个寄存器(krbp改成了krsp),所有的地狱都崩溃了-结果程序崩溃了,rasm2/Cutter在给我的二进制文件时解码古怪的指令,等等。在两个非常有趣但非常令人沮丧的小时里,我了解了为什么会有这些问题以及如何解决它们。你也应该学一学。

Void emit_mov_reg_imm32(buffer*buf,Register dst,int32_t src){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0xc7);buffer_write8(buf,0xc0+dst);buffer_write32(buf,src);}。

这些函数都声称要对x86-64指令进行编码。大多数情况下,他们是这样做的,但他们不会讲述整个故事。此函数假定对mov reg64,imm32格式的指令进行编码。它是怎么做到的?我不知道!。

他们里面都有这些神奇的数字!什么是kRexPrefix?嗯,现在是0x48。这对我们有什么意义吗?不!情况变得更糟了。0xc7和0xc0在那里做什么?为什么我们要将DST添加到0xc0?在这场调试和阅读盛会之前,我不可能告诉你。还记得我在上一篇文章中提到的我是如何从Compiler Explorer上读取编译后的输出中获得这些十六进制字节的吗?嗯。

事实证明,这不是一个健壮的开发策略,至少对于x86-64是这样。对于一些更规则或更可预测的指令集,它可能是可以的,但不是这个指令集。

那么我们接下来要去哪里呢?我们如何找出如何将这些神秘的魔法和咒语带到更好地映射到硬件上的东西上呢?那么,我们再一次将Tom 1拖入调试会话,并调出大号的“英特尔软件开发人员手册”(Intel Software Developer Manual)。

这是一本长达26MB、5000页的手册,由四卷组成。这非常吓人。这就是为什么我不想早点把它拔出来,从一开始就正确地做这件事的原因…。但我们到了这里,最终需要做好这件事。

我不会假装理解这本手册的全部内容,这篇文章也不会成为手册的指南。我将只解释我发现哪些部分和图表对理解这些东西是如何工作有用的。

我只打开过第二卷,指令集参考。在嗡嗡作响2300页中,描述了每条Intelx86-64指令及其编码方式。指令按字母顺序列出,并根据每个指令名的第一个字母拆分集合。

让我们来看看第3章,特别是第1209页的MOV指令。对于那些不想下载大量PDF的读者,本网站提供了一组HTML格式的相同数据。这是MOV的页面。

此页面包含MOV指令的所有变体。还有其他以MOV开头的指令,如MOVAPD、MOVAPS等,但它们的差异足够大,以至于它们是不同的指令。

操作码,它描述了指令流中字节的布局。它描述了我们将如何对指令进行编码。

指令,它给出指令的类似文本汇编的表示形式。这对于找出我们实际上想要编码的是哪一个很有用。

OP/EN代表“操作数编码”,据我所知,它使用一个符号来描述操作数顺序,下一页的“指令操作数编码”表中将进一步解释该符号。

64位模式,它告诉您指令是否可以在64位模式下使用(“有效”)或不可以(我猜是其他模式)。

Compat/Leg模式,它告诉您指令是否可以在其他模式下使用,我认为是32位模式还是16位模式。我不知道。但这与我们无关。

DESCRIPTION,它提供操作码的“简明英语”描述,用于单词“简明”和“英语”的一些定义。

其他说明的表格布局略有不同,因此您必须弄清楚其他列的含义。

下面是表格中一些行的预览,HTML由FelixCloutier前面提到的Web文档提供:

如果您查看表中的最后一个条目,您将看到REX.W+C7/0id。这看起来眼熟吗?也许吧,如果你眯着眼睛看一下的话?

结果发现,这就是对我们最初想要的指令进行编码的描述,但是它的编码器很糟糕。让我们试着想办法用这个来使我们的编码器做得更好。要做到这一点,我们首先需要了解英特尔指令的一般布局。

我在第2卷第2章(第527页)的开头部分“保护模式、真实地址模式和虚拟8086模式的指令格式”中找到了这些信息。

您可能和我一样想知道“Optional”、“If Required”和“…”之间的区别。,或者一个也没有“。我无法解释,抱歉。

我将在这里简要解释每个组件,然后逐个剖析我们想要的特定MOV指令,这样我们就可以进行一些实践练习。

有几种指令前缀,如REX(第2.2.1节)和VEX(第2.3节)。我们将重点介绍REX前缀,因为许多人(大多数?)都需要它们。X86-64指令,我们没有发出向量指令。

REX前缀用于指示通常可能引用32位寄存器的指令应该改为引用64位寄存器。也有一些其他的东西,但是我们最关心的是寄存器的大小。

有关操作码的简要说明,请参阅第2.1.2节(第529页)。其要点是操作码是指令的核心。这是Mova MOV而不是停止的原因。其他字段都会修改此字段所赋予的含义。

有关ModR/M和SIB字节的简要说明,请参阅第2.1.3节(第529页)。要点是它们对要使用的寄存器源和目标进行编码。

有关位移和立即字节的简要说明,请参阅第2.1.4节(第529页)。要点是它们对指令中使用的文字数字进行编码,而不对寄存器或任何东西进行编码。

如果你糊涂了,没关系。一旦我们把手弄脏了,情况可能会变得更明朗。如果这是您第一次像这样处理汇编,那么在真空中阅读所有这些信息是相当有用的,但我首先包含这一节是为了帮助解释如何使用参考资料。

都拿到了吗?也许吧?。不是吗?是啊,我也是。但不管怎样,让我们继续前进吧。下面是我们要编码的指令:REX.W+C7/0id。

首先,让我们了解一下REX.W。根据更详细地解释REX前缀的第2.2.1节,有几个不同的前缀。有一个有用的表格(第535页的表2-4)记录了它们。以下是包含相同信息的位图:

0100 W R X B高位低位REX位3是W前缀。如果为1,则表示操作数为64位。如果为0,则“操作数大小[由CS.D确定]”。不确定那是什么意思。

位2、1和0是我们最终可能不会使用的其他类型的REX前缀,因此我在这里省略它们。如果你有好奇心,请进一步阅读手册!

此MOV指令调用REX.W,这意味着此字节将类似于0b01001000,也称为我们的朋友0x48。一号谜团,解开了!

这是十六进制文字0xc7。这是操作码。还有几个具有操作码C7的其他条目,由指令中的其他字节修改(modr/M、sib、rex、…)。那就是。将其写入指令流。第二个谜团,解开了!

如果指令不需要第二操作数,则REG/OPCODE字段可用作操作码扩展。这种用法由表中的第六行(标记为“/digit(操作码)”)表示。请注意,第六行中的值以十进制形式表示。

这有点混乱,因为此操作显然有第二个操作数,由表中的“MI”表示,它显示操作数1为ModRM:r/m(W),操作数2为imm8/16/32/64。我认为这是因为它没有第二个寄存器操作数,所以这个空间是空闲的-立即数在指令中的不同位置。

在任何情况下,这意味着我们必须确保在ModR/M字节的reg部分放入十进制0。稍后我们将详细介绍ModR/M字节。

ID是指立即双字(32位)。它被称为双字,因为一个字(Iw)是16位。按规模递增的顺序,我们有:

这意味着我们必须将我们的32位值写出到指令流中。这些符号和编码在3.1.1.1节(第596页)中有进一步的解释。

REX Op ModR/M IMMEDIATE 0 1 2 3 7如果我们尝试对特定指令mov rax,100进行编码,它将如下所示:

REX Op ModR/M Immediate 0 1 2 3 7 0x48 0xc7 0xc0 0x64 0x00 0x00 0x00这就是您阅读表格的方式!慢慢地,一块一块地,用一杯好茶来帮助你渡过难关。现在我们已经读完了表,让我们继续编写一些代码。

在编写代码时,您通常需要比我们到目前为止看到的表多引用两个表。这些表格是表2-2“具有ModR/M字节的32位寻址表单”(第532页)和表2-3“带有SIB字节的32位寻址表单”(第533页)。虽然这些表描述的是32位的量,但是有了REX前缀,所有的ES都被替换为R,并且突然之间它们就可以描述64位的量了。

在弄清楚如何将ModR/M和SIB字节组合在一起时,这些表非常有用。

给定一个寄存器dst和一个立即的32位整数src,我们将对此指令进行编码。让我们按顺序做所有的步骤。

由于指令调用REX.W,因此我们可以保持第一行与前面相同:

ModR/M字节是代码稍有不同的地方。我们想要一个抽象来为我们构建它们,而不是像某种动物一样手动抛出整数。

要做到这一点,我们应该知道它们是如何组合在一起的。ModR/M字节包括:

MOD(高2位),描述要在ModR/M表中使用的大行。

REG(中间3位),描述第二个寄存器操作数或操作码扩展(如上面的/0)。

Byte modrm(byte mod,byte rm,byte reg){return(mod&;0x3)<;<;6)|((reg&;0x7)<;<;3)|(rm&;0x7);}。

参数的顺序与位的顺序略有不同。我这样做是因为在调用函数时会使调用者看起来更自然一些。因为太混乱了,我以后再换吧。

将0b11(3)作为mod传递,因为我们希望直接移入64位寄存器,而不是[reg],这意味着我们希望取消引用指针中的值。

Void emit_mov_reg_imm32(buffer*buf,Register dst,int32_t src){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0xc7);buffer_write8(buf,modrm(/*direct*/3,dst,0));//...}

对于上面的指令mov rax,100,其产生与此布局相同的modrm字节:

ModR/M mod reg RM 11 000 direct/0 Rax 000我没有把mods的数据类型放在一起,因为我不知道我是否能够很好地表达它。所以现在我只是添加了一个评论。

最后,我们有即期价值。正如我上面所说的,所有这一切都需要写出一个32位的数量,就像我们一直做的那样:

Void emit_mov_reg_imm32(buffer*buf,Register dst,int32_t src){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0xc7);buffer_write8(buf,modrm(/*direct*/3,dst,0));buffer_write32(buf,src);}。

现在你就知道了!我们花了2500个字才读到这微不足道的4个字节。真正的成功是我们一路结交的朋友。

“但是Max,”你说,“这会产生和以前所有情况下一样的输出!为什么要这么麻烦呢?这是怎么回事?“。

嗯,亲爱的读者,MOD为3(直接)意味着当DST是RSP时没有特殊情况下的逃生舱口。这与其他MOD不同,在其他MOD中,表中的RSP应该是[--][--]。该FunkySymbol表示ModR/M字节后面必须有一个标度-索引-基数(SIB)字节。这意味着此指令的总体格式应为以下布局:

REX Op ModR/M 0 1 2 3 SIB 4 disp 5例如,如果您尝试对mov[RSP-8]、rax进行编码,则值应如下所示:

REX Op ModR/M 0 1 2 3 0x48 0x89 0x44 SIB 4 disp 5 0x24 0xf8这就是像emit_store_reg_direct(mov[reg+disp],src)这样的指令与我设计的自制编码方案出现严重错误的地方。当该指令中的DST为RSP时,预计下一个字节为SIB。而当你输出其他数据时(比方说,立即8位位移),你就会得到非常时髦的寻址模式。这到底是什么玩意儿?

这是实际的反汇编程序集,我通过rasm2运行我的二进制代码得到的。我们的编译器绝对不会发出任何复杂的东西,这就是我发现问题的原因。

好吧,所以这是错的。我们不能只是盲目地乘法和加法。那么Dowe做了什么?

再看一下表2-2(第532页)。请注意,尝试将RSP与任何类型的位移一起使用都需要SIB。

现在再看一下表2-3(第533页)。我们要用这个把SIB组装起来。

我们从第2.1.3节了解到,与ModR/M一样,SIB由三个字段组成:

英特尔的语言不是很清楚,有点循环。让我们来看一下示例说明来澄清一下:

请注意,index和base指的是寄存器,scale指的是1、2、4或8中的一个,disp是某个立即值。

这是一种指定内存偏移量的紧凑方式。它便于读取和写入数组和结构。如果我们想写入和读取堆栈指针rsp的随机偏移量,那么它也是必需的。

让我们先回到列举所有类型的MOV指令的表(第1209页)。我们要查找的特定操作码是REX.W+89/r或MOV r/m64,r64。

到现在为止还好。看起来很眼熟。现在我们有了指令前缀和操作码,是时候写入ModR/M字节了。我们的ModR/M将包含以下信息:

因为我们有两个寄存器操作数,所以第二个操作数是任何寄存器的REG(操作码字段说/r)。

Void emit_store_reg_direct(buffer*buf,Indirect dst,Register src){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0x89);//错误!Buffer_Write8(buf,modrm(/*disp8*/1,dst.。注册表,源));//...}。

但不,这是不对的。结果是,正如我不断提到的,当dst.reg是RSP时,您仍然需要做这个特殊的事情。在这种情况下,Rm必须是特殊的None值(由表指定)。然后您还必须写入ASIB字节。

Void emit_store_reg_direct(buffer*buf,间接DST,寄存器源){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0x89);if(dst.。Reg==krsp){buffer_write8(buf,modrm(/*disp8*/1,kIndexNone,src));//...}否则{buffer_write8(buf,modrm(/*disp8*/1,dst.。注册表,服务器));}//...}。

精明的读者会知道krsp和kIndexNoone的整数值是4。我不知道这是否是英特尔设计者故意的。也许它应该是这样的,所以编码更容易,并且不需要对ModR/M和SIB都有特殊情况。也许这是偶然的。不管怎样,我发现它非常微妙,我想明确地说出来。

ModR/M mod reg rm 11 100 disp8 Rax NONE 000让我们继续写入SIB字节。我做了一个类似modrm的sib助手函数,只有两个小的不同:参数是从低位到高位的顺序,参数有自己的特殊类型,而不只是字节。

Tyfinf enum{Scale1=0,Scale2,Scale4,Scale8,}scale;tyfinf enum{kIndexRax=0,kIndexRcx,kIndexRdx,kIndexRbx,kIndexNone,kIndexRbp,kIndexRsi,kIndexRdi}Index;byte sib(寄存器基数,索引索引,小数位数){return((scale&;0x3)<;<;6)|(index&;0x7)<;<;3)|(base&;0x7);}。

我创建所有这些数据类型都是为了提高可读性,但是如果您不想使用它们,也可以不使用它们。Index one是唯一有一个小问题的:kIndexRsp应该在哪里是kIndexNone,因为您不能将RSP用作索引寄存器。

Void emit_store_reg_direct(buffer*buf,间接DST,寄存器源){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0x89);if(dst.。Reg==krsp){buffer_write8(buf,modrm(/*disp8*/1,kIndexNone,src));buffer_write8(buf,sib(krsp,kIndexNone,scale1));}{buffer_write8(buf,modrm(/*disp8*/1,dst.。注册表,服务器));}//...}。

SIB规模索引基数00 100 0无RSP 100这是一种非常冗长的表示[RSP+DISP]的方式,但它也可以。现在剩下的就是编码那个位移了。要做到这一点,我们只需将其写出来:

Void emit_store_reg_direct(buffer*buf,间接DST,寄存器源){buffer_write8(buf,kRexPrefix);buffer_write8(buf,0x89);if(dst.。Reg==krsp){buffer_write8(buf,modrm(/*disp8*/1,kIndexNone,src));buffer_write8(buf,sib(krsp,kIndexNone,scale1));}{buffer_write8(buf,modrm(/*disp8*/1,dst.。Reg,src));}buffer_write8(buf,disp8(间接。Disp));}。

非常好。现在轮到您着手在编译器中转换其余的汇编函数了!我发现将modrm/sib/displ8调用提取到一个helper函数中非常有用,因为它们大多是相同的并且非常重复。

这是一篇很长的帖子。这是到目前为止整个系列中最长的帖子,甚至是。我们可能应该有一些具体的外卖。

也许还有第三件事,我不知道-这个帖子有点多。

希望你喜欢。我要去试着睡个好觉。直到下一次,我们将实现过程调用!

这是POST中所有指令编码图的组合。如果您再次看到此文本,则意味着您的浏览器无法呈现SVG。

如果你是这个博客的狂热读者(这些人存在吗?请把手伸向我。我很乐意聊天。),你可能会注意到汤姆经常被拉进恶作剧的圈套。这是因为Tom是我遇到过的最好的调试器,他擅长反向工程,而且他知道很多低级的事情。我想现在他正致力于改进RISC-V板的开源工具,以此为乐。但他也非常善良,乐于助人,而且通常对我陷入的任何荒谬的情况都感兴趣。也许我应该在这个网站的某个地方加一份汤姆编年史的名单。不管怎么说,每个人都需要一个TOM。--↩