mov指令执行例子剖析
在PA1中, 你已经阅读了monitor部分的框架代码, 了解了NEMU执行的粗略框架.
但现在, 你需要进一步弄明白, 一条指令是怎么在NEMU中执行的,
即我们需要进一步探究exec_wrapper()
函数中的细节.
为了说明这个过程, 我们举了两个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)
要执行一条指令, 首先要拿到这条指令. 指令究竟在哪里呢?
还记得冯诺依曼体系结构的核心思想吗? 那就是"存储程序, 程序控制".
你以前听说这两句话的时候可能没有什么概念, 现在是实践的时候了.
这两句话告诉你, 指令在存储器中, 由PC(program counter, 在x86中就是%eip
)指出当前指令的位置.
事实上, %eip
就是一个指针!
在计算机世界中, 指针的概念无处不在, 如果你觉得对指针的概念还不是很熟悉, 就要赶紧复习指针这门必修课啦.
取指令要做的事情自然就是将%eip
指向的指令从内存读入到CPU中.
在NEMU中, 有一个函数instr_fetch()
(在nemu/include/cpu/exec.h
中定义)专门负责取指令的工作.
译码(instruction decode, ID)
在取指阶段, CPU拿到的是指令的比特串.
如果想知道这串比特串究竟代表什么意思, 就要进行译码的工作了.
我们可以把译码的工作作进一步的细化:
首先要决定具体是哪一条指令的哪一种形式, 这主要是通过查看指令的opcode
来决定的.
对于大多数指令来说, CPU只要看指令的第一个字节就可以知道具体指令的形式了.
在NEMU中, exec_real()
函数首先通过instr_fetch()
取出指令的第一个字节,
将其解释成opcode
并记录在全局译码信息decoding
中.
然后通过set_width()
函数(在nemu/src/cpu/exec/exec.c
中定义)记录默认的操作数宽度.
若操作数宽度结果为0
, 表示光看操作码的首字节, 操作数宽度还不能确定,
可能是16位或者32位, 需要通过decoding.is_operand_size_16
成员变量来决定.
这其实实现了"操作数宽度前缀"的相关功能, 关于is_operand_size_16
成员的更多内容会在下文进行说明.
返回后, exec_real()
接下来会根据取到的opcode
查看opcode_table
,
得到指令的译码helper函数和执行helper函数, 并将其作为参数调用idex()
函数来继续模拟这条指令的执行.
idex()
函数的原型为
void idex(vaddr_t *eip, opcode_entry *e);
它的作用是通过e->decode
函数(若不为NULL
)对参数eip
指向的指令进行译码,
然后通过e->execute
函数执行这条指令.
以mov $0x1234, %eax
指令为例, 首先通过instr_fetch()
取得这条指令的第一个字节0xb8
,
然后将这个字节作为opcode来索引opcode_table
, 发现这一指令的操作数宽度是4
字节,
并通过set_width()
函数记录.
接着按照同样的方式来索引opcode_table
, 确定取到的是一条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域来区分.
决定了具体的指令形式之后, 译码工作还需要决定指令的操作数.
事实上, 在确定了指令的opcode
之后, 指令形式就能确定下来了,
CPU可以根据指令形式来确定具体的操作数.
对于mov $0x1234, %eax
指令来说, 确定操作数其实就是确定寄存器%eax
和立即数$0x1234
.
在x86中, 通用寄存器都有自己的编号,I2r
形式的指令把寄存器编号也放在指令的第一个字节里面,
我们可以通过位运算将寄存器编号抽取出来; 立即数存放在指令的第二个字节, 可以很容易得到它.
需要说明的是, 由于立即数是指令的一部分, 我们还是通过instr_fetch()
函数来获得它.
总的来说, 由于指令变长的特性, 指令长度和指令形式需要一边取指一边译码来确定,
而不像RISC指令集那样可以泾渭分明地处理取指和译码阶段,
因此你会在NEMU的实现中看到译码函数里面也会有instr_fetch()
的操作.
执行(execute, EX)
译码阶段的工作完成之后, CPU就知道当前指令具体要做什么了, 执行阶段就是真正完成指令的工作.
对于mov $0x1234, %eax
指令来说, 执行阶段的工作就是把立即数$0x1234
送到寄存器%eax
中.
由于mov
指令的功能可以统一成"把源操作数的值传送到目标操作数中",
而译码阶段已经把操作数都准备好了, 所以只需要针对mov
指令编写一个模拟执行过程的函数即可.
这个函数就是exec_mov()
, 它是通过make_EHelper
宏来定义的:
make_EHelper(mov) {
write_operand((id_dest, &id_src->val);
print_asm_template2(mov);
}
其中write_operand()
函数会根据第一个参数中记录的类型的不同进行相应的写操作, 包括写寄存器和写内存.
print_asm_template2()
是个宏, 用于输出带有两个操作数的指令的汇编形式.
更新%eip
执行完一条指令之后, CPU就要执行下一条指令.
在这之前, CPU需要更新%eip
的值, 让%eip
指向下一条指令的位置.
为此, 我们需要确定刚刚执行完的指令的长度.
事实上, 在instr_fetch()
中, 每次取指都会更新它的eip
参数,
而这个参数就是在exec_wrapper()
调用exec_real()
时传入的decoding.seq_eip
.
因此当exec_wrapper()
执行完一条指令调用update_eip()
时,
decoding.seq_eip
已经正确指向下一条指令了, 这时候直接更新%eip
即可.
复杂mov指令的执行
对于第二个例子movw $0x1, -0x2000(%ecx,%ebx,4)
, 执行这条执行还是分取指, 译码, 执行三个阶段.
首先是取指.
这条mov指令比较特殊, 它的第一个字节是0x66
, 如果你查阅i386手册, 你会发现0x66
是一个operand-size prefix
.
因为这个前缀的存在, 本例中的mov
指令才能被CPU识别成movw
.
NEMU使用decoding.is_operand_size_16
成员变量来记录操作数宽度前缀是否出现,
0x66
的helper函数operand_size()
实现了这个功能.
operand_size()
函数对decoding.is_operand_size_16
成员变量做了标识之后,
越过前缀重新调用exec_real()
函数, 此时取得了真正的操作码0xc7
.
由于decoding.is_operand_size_16
成员变量进行过标识, 在set_width()
函数中将会确定操作数长度为2
字节.
接下来是识别操作数.
根据操作码0xc7
查看opcode_table
, 调用译码函数decode_mov_I2E()
,
这个译码函数又分别调用decode_op_I()
和decode_op_rm()
来取出操作数.
阅读代码, 你会发现decode_op_rm()
最终会调用read_ModR_M()
函数.
由于本例中的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
根据mov_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
框架代码已经实现了load_addr()
函数和read_ModR_M()
函数(在nemu/src/cpu/decode/modrm.c
中定义), 它们的函数原型为
void load_addr(swaddr_t *eip, ModR_M *m, Operand *rm);
void read_ModR_M(swaddr_t *eip, Operand *rm, bool load_rm_val, Operand *reg, bool load_reg_val);
它们将变量eip
所指向的内存位置解释成ModR/M
字节,
根据上述方法对ModR/M
字节和SIB
字节进行译码, 把译码结果存放到参数rm
和reg
指向的变量中.
虽然i386手册中的表格17-3和表格17-4内容比较多, 仔细看会发现,
ModR/M
字节和SIB
字节的编码都是有规律可循的,
所以load_addr()
函数可以很简单地识别出计算内存地址所需要的4个要素(当然也处理了一些特殊情况).
不过你现在可以不必关心其中的细节, 框架代码已经为你封装好这些细节, 并且提供了各种用于译码的接口函数.
本例中的执行阶段就是要把立即数写入到相应的内存位置.
译码阶段已经把操作数准备好了, 执行函数exec_mov()
会完成数据移动的操作,
最终在update_eip()
函数中更新%eip
.