RTFSC(2)
上一小节中的内容全部出自i386手册, 现在我们结合框架代码来理解上面的内容.
在PA1中, 你已经阅读了monitor部分的框架代码, 了解了NEMU执行的粗略框架. 但现在, 你需要进一步弄明白, 一条指令是怎么在NEMU中执行的, 即我们需要进一步探究 exec()
函数中的细节. 为了说明这个过程, 我们举了两个 mov
指令的例子, 它们是框架代码自带的用户程序mov( testcase/src/mov.S
)中的两条指令(mov的反汇编结果在 obj/testcase/mov.txt
中):
100014: b9 00 80 00 00 mov $0x8000,%ecx
......
1000fe: 66 c7 84 99 00 e0 ff movw $0x1,-0x2000(%ecx,%ebx,4)
100105: ff 01 00
helper函数命名约定
对于每条指令的每一种形式, NEMU分别使用一个helper函数来模拟它的执行. 为了易于维护, 框架代码对helper函数的命名有一种通用的形式:
指令_形式_操作数后缀
例如对于helper函数 mov_i2rm_b()
, 它模拟的指令是 mov
, 形式是 把立即数移动到寄存器或内存
, 操作数后缀是 b
, 表示操作数长度是8位. 在PA2中, 你需要实现很多helper函数, 这种命名方式可以很容易地让你知道一个helper函数的功能.
一个特殊的操作数后缀是 v
, 表示variant, 意味着光看操作码的首字节, 操作数长度还不能确定, 可能是16位或者32位, 需要通过 ops_decoded.is_data_size_16
成员变量来决定. 其实这种helper函数做的事情, 就是在根据指令是否出现 operand-size prefix
来确定操作数长度, 从而决定最终的指令形式, 调用最终的helper函数来模拟指令的执行.
也有一些指令不需要区分形式和操作数后缀, 例如 int3
, 这时可以直接用指令的名称来命名其helper函数. 如果你觉得上述命名方式不易看懂, 你可以使用其它命名方式, 我们不做强制要求.
简单mov指令的执行
我们先来剖析第一条 mov $0x8000, %ecx
指令的执行过程. 当NEMU执行到这条指令的时候(eip = 0x100014), 当前 %eip
的值被作为参数送进 exec()
函数(在 nemu/src/cpu/exec/exec.c
中定义)中. 其中 make_helper
是个宏, 你需要编写一系列helper函数来模拟指令执行的过程, 而 make_helper
则定义了helper函数的声明形式:
从 make_helper
的定义可以看到, helper函数都带有一个参数 eip
, 返回值类型都是 int
. 从抽象的角度来说, 一个helper函数做的事情就是对参数 eip
所指向的内存单元进行某种操作, 然后返回这种操作涉及的代码长度. 例如 exec()
函数的功能是"执行参数 eip
所指向的指令, 并返回这条指令的长度"; 框架代码中还定义了一些获取指令中的立即数的helper函数, 它们的功能是"获取参数 eip
所指向的立即数, 并返回这个立即数的长度".
对于大部分指令来说, 执行它们都可以抽象成取指-译码-执行的指令周期. 为了使描述更加清晰, 我们借助指令周期中的一些概念来说明指令执行的过程.
取指(instruction fetch, IF)
要执行一条指令, 首先要拿到这条指令. 指令究竟在哪里呢? 还记得冯诺依曼体系结构的核心思想吗? 那就是"存储程序, 程序控制". 你以前听说这两句话的时候可能没有什么概念, 现在是实践的时候了. 这两句话告诉你, 指令在存储器中, 由PC(program counter, 在x86中就是 %eip
)指出当前指令的位置. 事实上, %eip
就是一个指针! 在计算机世界中, 指针的概念无处不在, 如果你觉得对指针的概念还不是很熟悉, 就要赶紧复习指针这门必修课啦. 取指令要做的事情自然就是将 %eip
指向的指令从内存读入到CPU中. 在NEMU中, 有一个函数 instr_fetch()
(在 nemu/include/cpu/helper.h
中定义)专门负责取指令的工作.
译码(instruction decode, ID)
在取指阶段, CPU拿到的是指令的比特串. 如果想知道这串比特串究竟代表什么意思, 就要进行译码的工作了. 我们可以把译码的工作作进一步的细化:
首先要决定具体是哪一条指令的哪一种形式, 这主要是通过查看指令的 opcode
来决定的. 对于大多数指令来说, CPU只要看指令的第一个字节就可以知道具体指令的形式了. 在NEMU中, exec()
函数首先通过 instr_fetch()
取出指令的第一个字节, 然后根据取到的这个字节查看 opcode_table
, 得到指令的helper函数, 从而调用这个helper函数来继续模拟这条指令的执行. 以 mov $0x8000, %ecx
指令为例, 首先通过 instr_fetch()
取得这条指令的第一个字节 0xb9
, 然后根据这个字节来索引 opcode_table
, 找到了一个名为 mov_i2r_v
的helper函数, 这样就可以确定取到的是一条 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 $0x8000, %ecx
来说明这个过程, 但在这之前, 我们需要作一些额外的说明. 在上文的描述中, 我们通过这条指令的第一个字节 0xb9
找到了 mov_i2r_v()
的helper函数, 这个helper函数的定义在 nemu/src/cpu/exec/data-mov/mov.c
中:
其中 make_helper_v()
是个宏, 它在 nemu/include/cpu/exec/helper.h
中定义:
进行宏展开之后, mov_i2r_v()
的函数体如下:
它的作用是根据全局变量 ops_decoded
(在 nemu/src/cpu/decode/decode.c
中定义)中的 is_data_size_16
成员变量来决定操作数的长度, 然后从复用 opcode
的两个helper函数中选择一个进行调用. 全局变量 ops_decoded
用于存放一些译码的结果, 其中的 is_data_size_16
成员和指令中的 operand-size prefix
有关, 而且会经常用到, 框架代码把类似于 mov_i2r_v()
这样的功能抽象成一个宏 make_helper_v()
, 方便代码的编写. 关于 is_data_size_16
成员的更多内容会在下文进行说明. 根据指令 mov $0x8000, %ecx
的功能, 它的操作数长度为4字节, 因此这里会调用 mov_i2r_l()
的helper函数. mov_i2r_l()
的helper函数在 nemu/src/cpu/exec/data-mov/mov-template.h
中定义, 它的函数体是通过宏展开得到的, 在这里我们直接给出宏展开的结果, 关于宏的使用请阅读相应的框架代码:
其中 idex()
函数的原型为
它的作用是通过 decode
函数对参数 eip
指向的指令进行译码, 然后通过 execute
函数执行这条指令.
对于 mov $0x8000, %ecx
指令来说, 确定操作数其实就是确定寄存器 %ecx
和立即数 $0x8000
. 在x86中, 通用寄存器都有自己的编号, mov_i2r
形式的指令把寄存器编号也放在指令的第一个字节里面, 我们可以通过位运算将寄存器编号抽取出来. 对于 mov_i2r
形式的指令来说, 立即数存放在指令的第二个字节, 可以很容易得到它. 然而很多指令都具有i2r的形式, 框架代码提供了几个函数( decode_i2r_l()
等), 专门用于进行对i2r形式的指令的译码工作. decode_i2r_l()
函数会把指令中的立即数信息和寄存器信息分别记录在全局变量 ops_decoded
中的 src
成员和 dest
成员中, nemu/include/cpu/helper.h
中定义了两个宏 op_src
和 op_dest
, 用于方便地访问这两个成员.
在 decode_i_l()
函数中通过 instr_fetch()
函数获得指令中的立即数, 别看这里就这么一行代码, 其实背后隐藏着针对字节序的慎重考虑. 我们知道x86是小端机, 当你使用高级语言或者汇编语言写了一个32位常数 0x8000
的时候, 在生成的二进制代码中, 这个常数对应的字节序列如下(假设这个常数在内存中的起始地址是x):
x x+1 x+2 x+3
+----+----+----+----+
| 00 | 80 | 00 | 00 |
+----+----+----+----+
而大多数PC机都是小端架构(我们相信没有同学会使用IBM大型机来做PA), 当NEMU运行的时候,
op_src->imm = instr_fetch(eip, 4);
这行代码会将 00 80 00 00
这个字节序列原封不动地从内存读入 imm
变量中, 主机的CPU会按照小端方式来解释这一字节序列, 于是会得到 0x8000
, 符合我们的预期结果.
Motorola 68k系列的处理器都是大端架构的, 现在问题来了, 考虑以下两种情况:
- 假设我们需要将NEMU运行在Motorola 68k的机器上(把NEMU的源代码编译成Motorola 68k的机器码)
- 假设我们需要编写一个新的模拟器NEMU-Motorola-68k, 模拟器本身运行在x86架构中, 但它模拟的是Motorola 68k程序的执行
在这两种情况下, 你需要注意些什么问题? 为什么会产生这些问题? 怎么解决它们?
事实上不仅仅是立即数的访问, 长度大于1字节的内存访问都需要考虑类似的问题. 我们在这里把问题统一抛出来, 以后就不再单独讨论了.
执行(execute, EX)
译码阶段的工作完成之后, CPU就知道当前指令具体要做什么了, 执行阶段就是真正完成指令的工作. 对于 mov $0x8000, %ecx
指令来说, 执行阶段的工作就是把立即数 $0x8000
送到寄存器 %ecx
中. 由于 mov
指令的功能可以统一成"把源操作数的值传送到目标操作数中", 而译码阶段已经把操作数都准备好了, 所以只需要针对 mov
指令编写一个模拟执行过程的函数即可. 这个函数就是 do_mov_l()
, 它是通过在 nemu/src/cpu/exec/data-mov/mov-template.h
中定义的 do_execute()
函数进行宏展开后得到的:
其中 write_operand_l()
函数会根据第一个参数中记录的类型的不同进行相应的写操作, 包括写寄存器和写内存.
更新 %eip
执行完一条指令之后, CPU就要执行下一条指令. 在这之前, CPU需要更新 %eip
的值, 让 %eip
指向下一条指令的位置. 为此, 我们需要确定刚刚执行完的指令的长度. 在NEMU中, 指令的长度是通过helper函数的返回值进行传递的, 最终会传回到 cpu_exec()
函数中, 完成对 %eip
的更新.
复杂mov指令的执行
对于第二个例子 movw $0x1, -0x2000(%ecx,%ebx,4)
, 执行这条执行还是分取指, 译码, 执行三个阶段.
首先是取指. 这条mov指令比较特殊, 它的第一个字节是 0x66
, 如果你查阅i386手册, 你会发现 0x66
是一个 operand-size prefix
. 因为这个前缀的存在, 本例中的 mov
指令才能被CPU识别成 movw
. NEMU使用 ops_decoded.is_data_size_16
成员变量来记录操作数长度前缀是否出现, 0x66
的helper函数 data_size()
实现了这个功能.
data_size()
函数对 ops_decoded.is_data_size_16
成员变量做了标识之后, 越过前缀重新调用 exec()
函数, 此时取得了真正的操作码 0xc7
, 通过查看 opcode_table
调用了helper函数 mov_i2rm_v()
. 由于 ops_decoded.is_data_size_16
成员变量进行过标识, 在 mov_i2rm_v()
中将会调用 mov_i2rm_w()
的helper函数. 到此为止才识别出本例中的指令是一条 movw
指令.
接下来是识别操作数. 同样地, 我们先给出 mov_i2rm_w()
函数的宏展开结果:
这里使用 decode_i2rm_w()
函数来进行译码的工作, 阅读代码, 你会发现它最终会调用 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)
说明如何识别出内存地址:
1000fe: 66 c7 84 99 00 e0 ff movw $0x1,-0x2000(%ecx,%ebx,4)
100105: ff 01 00
根据 mov_i2rm
的指令形式, 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
中定义), 它们的函数原型为
它们将变量 eip
所指向的内存位置解释成 ModR/M
字节, 根据上述方法对 ModR/M
字节和 SIB
字节进行译码, 把译码结果存放到参数 rm
和 reg
指向的变量中, 同时返回这一译码过程所需的字节数. 在上面的例子中, 为了计算出内存地址, 用到了 ModR/M
字节, SIB
字节和32位的 displacement
, 总共6个字节, 所以 read_ModR_M()
返回6. 虽然i386手册中的表格17-3和表格17-4内容比较多, 仔细看会发现, ModR/M
字节和 SIB
字节的编码都是有规律可循的, 所以 load_addr()
函数可以很简单地识别出计算内存地址所需要的4个要素(当然也处理了一些特殊情况). 不过你现在可以不必关心其中的细节, 框架代码已经为你封装好这些细节, 并且提供了各种用于译码的接口函数.
本例中的执行阶段就是要把立即数写入到相应的内存位置, 这是通过 do_mov_w()
函数实现的. 执行结束后返回指令的长度, 最终在 cpu_exec()
函数中更新 %eip
.
结构化程序设计
细心的你会发现以下规律:
- 对于同一条指令的不同形式, 它们的执行阶段是相同的. 例如
add_i2rm
和add_rm2r
等, 它们的执行阶段都是把两个操作数相加, 把结果存入目的操作数. - 对于不同指令的同一种形式, 它们的译码阶段是相同的. 例如
add_i2rm
和sub_i2rm
等, 它们的译码阶段都是识别出一个立即数和一个rm
操作数. - 对于同一条指令同一种形式的不同长度, 它们的译码阶段和执行阶段都是非常类似的. 例如
add_i2rm_b
,add_i2rm_w
和add_i2rm_l
, 它们都是识别出一个立即数和一个rm
操作数, 然后把相加的结果存入rm
操作数.
这意味着, 如果独立实现每条指令不同形式不同长度的helper函数, 将会引入大量重复的代码, 需要修改的时候, 相关的所有helper函数都要分别修改, 遗漏了某一处就会造成bug, 工程维护的难度急速上升. 一种好的做法是把译码, 执行和操作数长度的相关代码分离开来, 实现解耦, 也就是在程序设计课上提到的结构化程序设计.
在框架代码中, 实现译码和执行之间的解耦的是 idex()
函数, 它把译码和执行的helper函数的指针作为参数, 依次调用它们, 这样我们就可以分别编写译码和执行的helper函数了. 实现操作数长度和译码, 执行这两者之间的解耦的是宏 DATA_BYTE
, 它把不同操作数长度的共性抽象出来, 编写一份模板, 分别进行3次实例化, 就可以得到3分不同操作数长度的代码.
为了实现进一步的封装和抽象, 框架代码中使用了大量的宏, 我们在这里把相关的宏整理出来, 供大家参考.
宏 | 含义 |
---|---|
nemu/include/macro.h |
|
str(x) |
字符串 "x" |
concat(x, y) |
token xy |
nemu/include/cpu/reg.h |
|
reg_l(index) |
编码为 index 的32位GPR |
reg_w(index) |
编码为 index 的16位GPR |
reg_b(index) |
编码为 index 的8位GPR |
nemu/include/cpu/exec/template-start.h |
|
SUFFIX |
表示 DATA_BYTE 相应长度的后缀字母, 为 b , w , l 其中之一 |
DATA_TYPE |
表示 DATA_BYTE 相应长度的无符号数据类型, 为 uint8_t , uint16_t , uint32_t 其中之一 |
DATA_TYPE_S |
表示 DATA_BYTE 相应长度的有符号数据类型, 为 int8_t , int16_t , int32_t 其中之一 |
REG(index) |
编码为 index , 长度为 DATA_BYTE 的GPR |
REG_NAME(index) |
编码为 index , 长度为 DATA_BYTE 的GPR的名称 |
MEM_R(addr) |
从内存位置 addr 读出 DATA_BYTE 字节的数据 |
MEM_W(addr) |
把长度为 DATA_BYTE 字节的数据 data 写入内存位置 addr |
OPERAND_W(op, src) |
把结果 src 写入长度为 DATA_BYTE 字节的目的操作数 op |
MSB(n) |
取出长度为 DATA_BYTE 字节的数据 n 的MSB位 |
nemu/include/cpu/helper.h |
|
make_helper(name) |
名为 name 的helper函数的原型说明 |
op_src |
全局变量 ops_decoded 中源操作数成员的地址 |
op_src2 |
全局变量 ops_decoded 中2号源操作数成员的地址 |
op_dest |
全局变量 ops_decoded 中目的操作数成员的地址 |
nemu/include/cpu/exec/helper.h |
|
make_helper_v(name) |
名为 name_v 的helper函数的定义, 用于根据指令的操作数长度前缀进一步确定调用哪一个helper函数 |
do_execute |
用于模拟指令真正的执行操作的函数名 |
make_instr_helper(type) |
名为 指令_形式_操作数后缀 的helper函数的定义, 其中 type 为指令的形式, 通过调用 idex() 函数来进行执行的译码和执行 |
print_asm(...) |
将反汇编结果的字符串打印到缓冲区 assembly 中 |
print_asm_template1() |
打印单目操作数指令的反汇编结果 |
print_asm_template2() |
打印双目操作数指令的反汇编结果 |
print_asm_template3() |
打印三目操作数指令的反汇编结果 |
nemu/src/cpu/exec/*/*-template.h |
|
instr |
指令的名称, 被 do_execute , make_instr_helper(type) 和 print_asm_template?() 使用 |
如果你知道C++的"模板"功能, 你可能会建议使用它, 但事实上在这里做不到. 我们知道宏是在编译预处理阶段进行处理的, 这意味着宏的功能不受编译阶段的约束(包括词法分析, 语法分析, 语义分析); 而C++的模板是在编译阶段进行处理的, 这说明它会受到编译阶段的限制. 理论上来说, 必定有一些事情是宏能做到, 但C++模板做不到. 一个例子就是框架代码中的拼接宏 concat()
, 它可以把两个token连接成一个新的token; 而在C++模板进行处理的时候, 词法分析阶段已经结束了, 因而不可能通过C++模板生成新的token.
计算机世界处处都是tradeoff, 有好处自然需要付出代价. 由于处理宏的时候不会进行语法检查, 因为宏而造成的错误很有可能不会马上暴露. 例如以下代码:
在编译的时候, 编译器会提示代码的第2行有语法错误, 但如果你光看第2行代码, 你很难发现错误, 甚至会怀疑编译器有bug. 因此如果你对宏不太熟悉, 可能会对阅读框架代码带来困难. 我们准备了命令, 用来专门生成 nemu/src/cpu/decode
目录和 nemu/src/cpu/exec
目录下源文件的预处理结果, 键入
make cpp
会在这些目录中生成 .i
的预处理结果, 它们可以帮助你阅读框架代码, 调试与宏相关的错误. 键入
make clean-cpp
可以移除这些预处理结果.
源文件组织
最后我们来聊聊 nemu/src/cpu/exec
目录下源文件的组织方式.
nemu/src/cpu/exec
├── all-instr.h
├── arith
│ └── ...
├── data-mov
│ ├── mov.c
│ ├── mov.h
│ ├── mov-template.h
│ ├── xchg.c
│ ├── xchg.h
│ └── xchg-template.h
├── exec.c
├── logic
│ └── ...
├── misc
│ ├── misc.c
│ └── misc.h
├── prefix
│ ├── prefix.c
│ └── prefix.h
├── special
│ ├── special.c
│ └── special.h
└── string
├── rep.c
└── rep.h
exec.c
中定义了操作码表opcode_table
和helper函数exec()
,exec()
根据指令的opcode
首字节查阅opcode_table
, 并调用相应的helper函数来模拟相应指令的执行. 除此之外, 和2字节转义码相关的2字节操作码表_2byte_opcode_table
, 以及各种指令组表也在exec.c
中定义.all-instr.h
中列出了所有用于模拟指令执行的helper函数的声明, 这个头文件被exec.c
包含, 这样就可以在exec.c
中的opcode_table
直接使用各种helper函数了.- 除了
exec.c
和all-instr.h
两个源文件之外, 目录下还有若干子目录, 这些子目录分别存放用于模拟不同功能的指令的源文件. i386手册根据功能对所有指令都进行了分类, 框架代码中对相关文件的管理参考了手册中的分类方法(其中special
子目录下模拟了和NEMU相关的功能, 与i386手册无关). 以nemu/src/cpu/exec/data-mov
目录下与mov
指令相关的文件为例, 我们对其文件组织进行进一步的说明:mov.h
中列出了用于模拟mov
指令所有形式的helper函数的声明, 这个头文件被all-instr.h
包含.mov-template.h
是mov
指令helper函数定义的模板,mov
指令helper函数的函数体都在这个文件中定义. 模板的功能是通过宏来实现的: 对于一条指令, 不同操作数长度的相近形式都有相似的行为, 可以将它们的公共行为用宏抽象出来.mov-template.h
的开头包含了头文件nemu/include/cpu/exec/template-start.h
, 结尾包含了头文件nemu/include/cpu/exec/template-end.h
, 它们包含了一些在模板头文件中使用的宏定义, 例如DATA_TYPE, REG()
等, 使用它们可以编写出简洁的代码.mov.c
中定义了mov
指令的所有helper函数, 其中分三次对mov-template.h
中定义的模板进行实例化, 进行宏展开之后就可以得到helper函数的完整定义了; 另外操作数后缀为v
的helper函数也在mov.c
中定义.
在PA2中, 你需要编写很多helper函数, 好的源文件组织方式可以帮助你方便地管理工程.