x86的mov指令执行例子剖析
我们举两个mov
指令的例子, 它们是NEMU自带的客户程序mov
中的两条指令:
100000: b8 34 12 00 00 mov $0x1234,%eax
......
100017: 66 c7 84 99 00 e0 ff movw $0x1,-0x2000(%ecx,%ebx,4)
10001e: ff 01 00
简单mov指令的执行
我们先来剖析第一条mov $0x1234, %eax
指令的执行过程.
取指(instruction fetch, IF)
首先通过inst_fetch()
取得这条指令的第一个字节0xb8
.
译码(instruction decode, ID)
在isa_exec_once()
函数中用这条指令的第一个字节0xb8
来进行模式匹配, 发现这一指令的操作数宽度是4
字节的mov
指令, 形式是将立即数移入寄存器(move immediate to register).
事实上, 一个字节最多只能区分256种不同的指令形式. 当指令形式的数目大于256时, 我们需要使用另外的方法来识别它们. x86中有主要有两种方法来解决这个问题(在PA2中你都会遇到这两种情况):
- 一种方法是使用转义码(escape code), x86中有一个2字节转义码
0x0f
, 当指令opcode
的第一个字节是0x0f
时, 表示需要再读入一个字节才能决定具体的指令形式(部分条件跳转指令就属于这种情况). 后来随着各种SSE指令集的加入, 使用2字节转义码也不足以表示所有的指令形式了, x86在2字节转义码的基础上又引入了3字节转义码, 当指令opcode
的前两个字节是0x0f
和0x38
时, 表示需要再读入一个字节才能决定具体的指令形式. - 另一种方法是使用
ModR/M
字节中的扩展opcode域来对opcode
的长度进行扩充. 有些时候, 读入一个字节也还不能完全确定具体的指令形式, 这时候需要读入紧跟在opcode
后面的ModR/M
字节, 把其中的reg/opcode
域当做opcode
的一部分来解释, 才能决定具体的指令形式. x86把这些指令划分成不同的指令组(instruction group), 在同一个指令组中的指令需要通过ModR/M
字节中的扩展opcode域来区分.
接下来还需要识别指令的操作数. 对于mov $0x1234, %eax
指令来说, 识别操作数其实就是识别寄存器%eax
和立即数$0x1234
. 在x86中, 通用寄存器都有自己的编号,I2r
形式的指令把寄存器编号也放在指令的第一个字节里面, 我们可以通过位运算将寄存器编号抽取出来; 立即数存放在指令的第二个字节, 可以很容易得到它. 需要说明的是, 由于立即数是指令的一部分, 我们还需要通过inst_fetch()
函数来获得它.
此外, x86的译码类型主要以i386手册附录A中的操作数表示记号来命名, 直接反映了操作数的类型和数据流向. 例如I2r
表示将立即数移入寄存器, 其中I
表示立即数, 2
表示英文to
, r
表示通用寄存器, 更多的记号请参考i386手册.
执行(execute, EX)
对于mov $0x1234, %eax
指令来说, 执行阶段的工作就是把立即数$0x1234
送到寄存器%eax
中. 直接通过Rw()
宏实现这一功能即可.
更新PC
把s->dnpc
赋值给cpu.pc
即可.
复杂mov指令的执行
对于第二个例子movw $0x1, -0x2000(%ecx,%ebx,4)
, 执行这条执行还是分取指, 译码, 执行三个阶段.
首先是取指. 这条mov指令比较特殊, 它的第一个字节是0x66
, 如果你查阅i386手册, 你会发现0x66
是一个operand-size prefix
. 因为这个前缀的存在, 本例中的mov
指令才能被CPU识别成movw
. NEMU通过一个局部变量标志is_operand_size_16
来记录操作数宽度前缀是否出现, 模式匹配中的0x66
将设置该标志, 然后通过goto
语句回到函数的开头重新取出操作码, 此时取得了真正的操作码0xc7
. 由于is_operand_size_16
标志已设置, 在后续译码过程中将会确定操作数长度为2
字节.
x86的操作数宽度处理
大部分x86指令都有不同操作数宽度的版本, 因此x86的操作数宽度信息记录会更复杂: 首先模式匹配规则中给出的宽度信息确定操作数宽度; 若该操作数宽度结果为0
, 表示仅仅根据操作码来判断, 操作数宽度还不能确定, 可能是16位或者32位, 需要通过is_operand_size_16
来决定.
接下来是识别操作数. 根据操作码0xc7
查找相应的模式匹配规则, 发现译码类型为I2E
, 于是分别调用decode_rm()
和imm()
来取出操作数. 由于本例中的mov
指令需要访问内存, 因此除了要识别出立即数之外, 还需要确定好要访问的内存地址. x86通过ModR/M
字节来指示内存操作数, 支持各种灵活的寻址方式. 其中最一般的寻址格式是
displacement(R[base_reg], R[index_reg], scale_factor)
相应内存地址的计算方式为
addr = R[base_reg] + R[index_reg] * scale_factor + displacement
其它寻址格式都可以看作这种一般格式的特例, 例如
displacement(R[base_reg])
可以认为是在一般格式中取R[index_reg] = 0, scale_factor = 1
的情况. 这样, 确定内存地址就是要确定base_reg
, index_reg
, scale_factor
和displacement
这4个值, 而它们的信息已经全部编码在ModR/M
字节里面了.
我们以本例中的movw $0x1, -0x2000(%ecx,%ebx,4)
说明如何识别出内存地址:
100017: 66 c7 84 99 00 e0 ff movw $0x1,-0x2000(%ecx,%ebx,4)
10001e: ff 01 00
根据I2E
的指令形式, 0xc7
是opcode
, 0x84
是ModR/M
字节. 在i386手册中查阅表格17-3得知, 0x84
的编码表示在ModR/M
字节后面还跟着一个SIB
字节, 然后跟着一个32位的displacement
. 于是读出SIB
字节, 发现是0x99
. 在i386手册中查阅表格17-4得知, 0x99
的编码表示base_reg = ECX, index_reg = EBX, scale_factor = 4
. 在SIB
字节后面读出一个32位的displacement
, 发现是00 e0 ff ff
, 在小端存储方式下, 它被解释成-0x2000
. 于是内存地址的计算方式为
addr = R[ECX] + R[EBX] * 4 - 0x2000
框架代码已经实现了decode_rm()
函数和load_addr()
函数, 其函数原型为
void decode_rm(Decode *s, int *rm_reg, word_t *rm_addr, int *reg, int width);
void load_addr(Decode *s, ModR_M *m, word_t *rm_addr);
它们将s->snpc
所指向的内存位置解释成ModR/M
字节, 根据上述方法对ModR/M
字节和SIB
字节进行译码, 把译码结果存放到参数rm_reg
和rm_addr
指向的变量中. 虽然i386手册中的表格17-3和表格17-4内容比较多, 仔细看会发现, ModR/M
字节和SIB
字节的编码都是有规律可循的, 所以load_addr()
函数可以很简单地识别出计算内存地址所需要的4个要素(当然也处理了一些特殊情况). 不过你现在可以不必关心其中的细节, 框架代码已经为你封装好这些细节, 并且提供了各种用于译码的接口函数.
本例中的执行阶段就是要把立即数写入到相应的内存位置. 译码阶段已经把操作数准备好了, 通过RMw()
宏完成数据移动的操作, 最终更新PC.