Introduction to x86 instruction system

i386手册有一章专门列出了所有指令的细节, 附录中的opcode map也很有用. 在这里, 我们先对x86指令系统作一些简单的梳理. 当你对x86指令系统有任何疑惑时, 请查阅i386手册, 关于指令系统的一切细节都在里面.

i386手册勘误

由于PDF版本的i386手册的印刷错误较多, 一定程度上影响理解, 我们在github上开放了一个repoopen in new window, 用于提供修复印刷错误的版本. 同时我们也为修复错误后的版本提供在线的HTML版本open in new window.

如果你在做实验的过程中也发现了新的错误, 欢迎帮助我们修复这些错误.

指令格式

x86指令的一般格式如下:

+-----------+-----------+-----------+--------+------+------+------+------------+-----------+
|instruction| address-  |  operand- |segment |opcode|ModR/M| SIB  |displacement| immediate |
|  prefix   |size prefix|size prefix|override|      |      |      |            |           |
|-----------+-----------+-----------+--------+------+------+------+------------+-----------|
|   0 OR 1  |  0 OR 1   |   0 OR 1  | 0 OR 1 |1 OR 2|0 OR 1|0 OR 1| 0,1,2 OR 4 |0,1,2 OR 4 |
| - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -|
|                                     number of bytes                                      |
+------------------------------------------------------------------------------------------+

除了opcode(操作码)必定出现之外, 其余组成部分可能不出现, 而对于某些组成部分, 其长度并不是固定的. 但给定一条具体指令的二进制形式, 其组成部分的划分是有办法确定的, 不会产生歧义(即把一串比特串看成指令的时候, 不会出现两种不同的解释). 例如对于以下指令:

100017:	66 c7 84 99 00 e0 ff ff 01 00  	movw   $0x1,-0x2000(%ecx,%ebx,4)

其组成部分的划分如下:

+-----------+-----------+-----------+--------+------+------+------+------------+-----------+
|instruction| address-  |  operand- |segment |opcode|ModR/M| SIB  |displacement| immediate |
|  prefix   |size prefix|size prefix|override|      |      |      |            |           |
|-----------+-----------+-----------+--------+------+------+------+------------+-----------|
|                            66                 c7     84     99    00 e0 ff ff    01 00   |
+------------------------------------------------------------------------------------------+

凭什么0x84要被解释成ModR/M字节呢? 这是由opcode决定的, opcode决定了这是什么指令的什么形式, 同时也决定了opcode之后的比特串如何解释. 如果你要问是谁来决定opcode, 那你就得去问Intel了.

PA中的x86经过简化, address-size prefixsegment override prefix都不会用到, 因此NEMU也不需要实现这两者的功能.

编码的艺术

对于以下5个集合:

  1. 所有instruction prefix
  2. 所有address-size prefix
  3. 所有operand-size prefix
  4. 所有segment override prefix
  5. 所有opcode的第一个字节

它们是两两不相交的. 这是必须的吗? 这背后反映了怎样的隐情?

另外我们在这里先给出ModR/M字节和SIB字节的格式, 它们是用来确定指令的操作数的, 详细的功能会在将来进行描述:

ModR/M byte
7    6    5    4    3    2    1    0
+--------+-------------+-------------+
|  mod   | reg/opcode  |     r/m     |
+--------+-------------+-------------+


SIB (scale index base) byte
7    6    5    4    3    2    1    0
+--------+-------------+-------------+
|   ss   |    index    |    base     |
+--------+-------------+-------------+

事实上, 一个字节最多只能区分256种不同的指令形式. 当指令形式的数目大于256时, 我们需要使用另外的方法来识别它们. x86中有主要有两种方法来解决这个问题:

  • 一种方法是使用转义码(escape code). x86中有一个2字节转义码0x0f, 当指令opcode的第一个字节是0x0f时, 表示需要再读入一个字节才能决定具体的指令形式(部分条件跳转指令就属于这种情况). 后来随着各种SSE指令集的加入, 使用2字节转义码也不足以表示所有的指令形式了, x86在2字节转义码的基础上又引入了3字节转义码, 当指令opcode的前两个字节是0x0f0x38时,表示需要再读入一个字节才能决定具体的指令形式.
  • 另一种方法是使用ModR/M字节中的扩展opcode域来对opcode的长度进行扩充. 有些时候, 读入一个字节也还不能完全确定具体的指令形式, 这时候需要读入紧跟在opcode后面的ModR/M字节, 把其中的reg/opcode域当做opcode的一部分来解释, 才能决定具体的指令形式. x86把这些指令划分成不同的指令组(instruction group), 在同一个指令组中的指令需要通过ModR/M字节中的扩展opcode域来区分.

指令集细节

要实现一条指令, 首先你需要知道这条指令的格式和功能, 格式决定如何解释, 功能决定如何执行. 而这些信息都在instruction set page中, 因此你务必知道如何阅读它们. 我们以mov指令的opcode表为例来说明如何阅读:

     Opcode       Instruction       Clocks        Description

< 1> 88 /r        MOV r/m8,r8       2/2           Move byte register to r/m byte
< 2> 89 /r        MOV r/m16,r16     2/2           Move word register to r/m word
< 3> 89 /r        MOV r/m32,r32     2/2           Move dword register to r/m dword
< 4> 8A /r        MOV r8,r/m8       2/4           Move r/m byte to byte register
< 5> 8B /r        MOV r16,r/m16     2/4           Move r/m word to word register
< 6> 8B /r        MOV r32,r/m32     2/4           Move r/m dword to dword register
< 7> 8C /r        MOV r/m16,Sreg    2/2           Move segment register to r/m word
< 8> 8D /r        MOV Sreg,r/m16    2/5,pm=18/19  Move r/m word to segment register
< 9> A0           MOV AL,moffs8     4             Move byte at (seg:offset) to AL
<10> A1           MOV AX,moffs16    4             Move word at (seg:offset) to AX
<11> A1           MOV EAX,moffs32   4             Move dword at (seg:offset) to EAX
<12> A2           MOV moffs8,AL     2             Move AL to (seg:offset)
<13> A3           MOV moffs16,AX    2             Move AX to (seg:offset)
<14> A3           MOV moffs32,EAX   2             Move EAX to (seg:offset)
<15> B0 + rb ib   MOV r8,imm8       2             Move immediate byte to register
<16> B8 + rw iw   MOV r16,imm16     2             Move immediate word to register
<17> B8 + rd id   MOV r32,imm32     2             Move immediate dword to register
<18> C6 /0 ib (*) MOV r/m8,imm8     2/2           Move immediate byte to r/m byte
<19> C7 /0 iw (*) MOV r/m16,imm16   2/2           Move immediate word to r/m word
<20> C7 /0 id (*) MOV r/m32,imm32   2/2           Move immediate dword to r/m dword

---------------------------------------------------------------------------
NOTES:
moffs8, moffs16, and moffs32 all consist of a simple offset relative
to the segment base. The 8, 16, and 32 refer to the size of the data. The
address-size attribute of the instruction determines the size of the
offset, either 16 or 32 bits.
---------------------------------------------------------------------------

注:
标记了(*)的指令形式的Opcode相对于i386手册有改动, 具体情况见下文的描述.

上表中的每一行给出了mov指令的不同形式, 每一列分别表示这种形式的opcode, 汇编语言格式, 执行所需周期, 以及功能描述. 由于NEMU关注的是功能的模拟, 因此Clocks一列不必关心. 另外需要注意的是, i386手册中的汇编语言格式都是Intel格式, 而objdump的默认格式是AT&T格式, 两者的源操作数和目的操作数位置不一样, 千万不要把它们混淆了! 否则你将会陷入难以理解的bug中.

首先我们来看mov指令的第一种形式:

     Opcode       Instruction       Clocks        Description
< 1> 88 /r        MOV r/m8,r8       2/2           Move byte register to r/m byte
  • 从功能描述可以看出, 它的作用是"将一个8位寄存器中的数据传送到8位的寄存器或者内存中", 其中r/m表示"寄存器或内存".
  • Opcode一列中的编码都是用十六进制表示, 88表示这条指令的opcode的首字节是0x88, /r表示后面跟一个ModR/M字节, 并且ModR/M字节中的reg/opcode域解释成通用寄存器的编码, 用来表示其中一个操作数.
  • 通用寄存器的编码如下:
二进制编码000001010011100101110111
8位寄存器ALCLDLBLAHCHDHBH
16位寄存器AXCXDXBXSPBPSIDI
32位寄存器EAXECXEDXEBXESPEBPESIEDI
  • Instruction一列中, r/m8表示操作数是8位的寄存器或内存, r8表示操作数是8位寄存器, 按照Intel格式的汇编语法来解释, 表示将8位寄存器(r8)中的数据传送到8位寄存器或内存(r/m8)中, 这和功能描述是一致的. 至于r/m表示的究竟是寄存器还是内存, 这是由ModR/M字节的mod域决定的: 当mod域取值为3的时候, r/m表示的是寄存器; 否则r/m表示的是内存. 表示内存的时候又有多种寻址方式, 具体信息参考i386手册中的表格17-3.

看明白了上面的第一种形式之后, 接下来的两种形式也就不难看懂了:

< 2> 89 /r        MOV r/m16,r16     2/2           Move word register to r/m word
< 3> 89 /r        MOV r/m32,r32     2/2           Move dword register to r/m dword

但你会发现, 这两种形式的Opcode都是一样的, 难道不会出现歧义吗? 不用着急, 还记得指令一般格式中的operand-size prefix吗? x86正是通过它来区分上面这两种形式的. operand-size prefix的编码是0x66, 作用是指示当前指令需要改变操作数的宽度. 在i386中, 通常来说, 如果这个前缀没有出现, 操作数宽度默认是32位; 当这个前缀出现的时候, 操作数宽度就要改变成16位 (也有相反的情况, 这个前缀的出现使得操作数宽度从16位变成32位, 但这种情况在i386中极少出现). 换句话说, 如果把一个开头为89 ...的比特串解释成指令, 它就应该被解释成MOV r/m32,r32的形式; 如果比特串的开头是66 89..., 它就应该被解释成MOV r/m16,r16.

操作数宽度前缀的由来

i386是从8086open in new window发展过来的. 8086是一个16位的时代, 很多指令的16位版本在当时就已经实现好了. 要踏进32位的新时代, 兼容就成了需要仔细考量的一个重要因素.

一种最直接的方法是让32位的指令使用新的操作码, 但这样1字节的操作码很快就会用光. 假设8086已经实现了200条16位版本的指令形式, 为了加入这些指令形式的32位版本, 这种做法需要使用另外200个新的操作码, 使得大部分指令形式的操作码需要使用两个字节来表示, 这样直接导致了32位的程序代码会变长. 现在你可能会觉得每条指令的长度增加一个字节也没什么大不了, 但在i386诞生的那个遥远的时代(你可以在i386手册的封面看到那个时代), 内存是一种十分珍贵的资源, 因此这种使用新操作码的方法并不是一种明智的选择.

Intel想到的解决办法就是引入操作数宽度前缀, 来达到操作码复用的效果. 当处理器工作在16位模式(实模式open in new window)下的时候, 默认执行16位版本的指令; 当处理器工作在32位模式(保护模式open in new window)下的时候, 默认执行32位版本的指令. 当某些需要的时候, 才通过操作数宽度前缀来指示操作数的宽度. 这种方法最大的好处就是不需要引入额外的操作码, 从而也不会明显地使得程序代码变长. 虽然在NEMU里面可以使用很简单的方法来模拟这个功能, 但在真实的芯片设计过程中, CPU的译码部件需要增加很多逻辑才能实现.

到现在为止, <4>-<6>三种形式你也明白了:

< 4> 8A /r        MOV r8,r/m8       2/4           Move r/m byte to byte register
< 5> 8B /r        MOV r16,r/m16     2/4           Move r/m word to word register
< 6> 8B /r        MOV r32,r/m32     2/4           Move r/m dword to dword register

<7><8> 两种形式的mov指令涉及到段寄存器:

< 7> 8C /r        MOV r/m16,Sreg    2/2           Move segment register to r/m word
< 8> 8D /r        MOV Sreg,r/m16    2/5,pm=18/19  Move r/m word to segment register

PA中的x86去掉了段寄存器的实现, 我们可以忽略这两种形式的mov指令.

<9> - <14> 这6种形式涉及到一种新的操作数记号moffs:

< 9> A0           MOV AL,moffs8     4             Move byte at (seg:offset) to AL
<10> A1           MOV AX,moffs16    4             Move word at (seg:offset) to AX
<11> A1           MOV EAX,moffs32   4             Move dword at (seg:offset) to EAX
<12> A2           MOV moffs8,AL     2             Move AL to (seg:offset)
<13> A3           MOV moffs16,AX    2             Move AX to (seg:offset)
<14> A3           MOV moffs32,EAX   2             Move EAX to (seg:offset)
---------------------------------------------------------------------------
NOTES:
moffs8, moffs16, and moffs32 all consist of a simple offset relative
to the segment base. The 8, 16, and 32 refer to the size of the data. The
address-size attribute of the instruction determines the size of the
offset, either 16 or 32 bits.
---------------------------------------------------------------------------

NOTES中给出了moffs的含义, 它用来表示段内偏移量, 但PA中的x86没有"段"的概念, 目前可以理解成"相对于物理地址0处的偏移量". 这6种形式是mov指令的特殊形式, 它们可以不通过ModR/M字节, 让displacement直接跟在opcode后面, 同时让displacement来指示一个内存地址.

<15> - <17> 三种形式涉及到两种新的操作数记号:

<15> B0 + rb ib   MOV r8,imm8       2             Move immediate byte to register
<16> B8 + rw iw   MOV r16,imm16     2             Move immediate word to register
<17> B8 + rd id   MOV r32,imm32     2             Move immediate dword to register

其中:

  • +rb, +rw, +rd分别表示8位, 16位, 32位通用寄存器的编码. 和ModR/M中的reg域不一样的是, 这三种记号表示直接将通用寄存器的编号按数值加到opcode中 (也可以看成通用寄存器的编码嵌在opcode的低三位), 因此识别指令的时候可以通过opcode的低三位确定一个寄存器操作数.
  • ib, iw, id分别表示8位, 16位, 32位立即数

最后3种形式涉及到一种新的操作码记号/digit, 其中digit0~7中的一个数字:

<18> C6 /0 ib (*) MOV r/m8,imm8     2/2           Move immediate byte to r/m byte
<19> C7 /0 iw (*) MOV r/m16,imm16   2/2           Move immediate word to r/m word
<20> C7 /0 id (*) MOV r/m32,imm32   2/2           Move immediate dword to r/m dword

注:
标记了(*)的指令形式的Opcode相对于i386手册有改动, 具体情况见下文的描述.

上述形式中的/0表示一个ModR/M字节, 并且ModR/M字节中的reg/opcode域解释成扩展opcode, 其值取0. 对于含有/digit记号的指令形式, 需要通过指令本身的opcodeModR/M中的扩展opcode共同决定指令的形式, 例如80 /0表示add指令的一种形式, 而80 /5则表示sub指令的一种形式, 只看opcode的首字节80不能区分它们.

注: 在i386手册中, 这3种形式的mov指令并没有/0的记号, 在这里加入/0纯粹是为了说明/digit记号的意思. 但同时这条指令在i386中也比较特殊, 它需要使用ModR/M字节来表示一个寄存器或内存的操作数, 但ModR/M字节中的reg/opcode域却没有用到 (一般情况下, ModR/M字节中的reg/opcode域要么表示一个寄存器操作数, 要么作为扩展opcode), i386手册也没有对此进行特别的说明, 直觉上的解释就是"无论ModR/M字节中的reg/opcode域是什么值, 都可以被CPU识别成这种形式的mov指令". x86是商业CPU, 我们无法从电路级实现来考证这一解释, 但对编译器生成代码来说, 这条指令中的reg/opcode域总得有个确定的值, 因此编译器一般会把这个值设成0. 在NEMU的框架代码中, 对这3种形式的mov指令的实现和i386手册中给出Opcode保持一致, 忽略ModR/M字节中的reg/opcode域, 没有判断其值是否为0. 如果你不能理解这段话在说什么, 你可以忽略它, 因为这并不会影响实验的进行.

到此为止, 你已经学会了如何阅读大部分的指令集细节了. 需要说明的是, 这里举的mov指令的例子并没有完全覆盖i386手册中指令集细节的所有记号, 若有疑问, 请参考i386手册.

除了opcode表之外, Operation, DescriptionFlags Affected这三个条目都要仔细阅读, 这样你才能完整地掌握一条指令的功能. Exceptions条目涉及到执行这条指令可能产生的异常, 由于PA中的x86不打算加入异常处理的机制, 你可以不用关心这一条目.