RTFM
我们在上一小节中已经在概念上介绍了一条指令具体如何执行, 其中有的概念甚至显而易见得难以展开. 不过x86这一庞然大物背负着太多历史的包袱, 但当我们决定往TRM中添加各种高效的x86指令时, 也同时意味着我们无法回避这些繁琐的细节.
首先你需要了解指令确切的行为, 为此, 你需要阅读i386手册中指令集相关的章节. 这里有一个简单的阅读教程.
RTFSC(2)
下面我们来介绍NEMU的框架代码是如何执行指令的.
数据结构
首先先对这个过程中的两个重要的数据结构进行说明.
nemu/src/cpu/exec/exec.c
中的opcode_table
数组. 这就是我们之前提到的译码查找表了, 这一张表通过操作码opcode来索引, 每一个opcode对应相应指令的译码函数, 执行函数, 以及操作数宽度.nemu/src/cpu/decode/decode.c
中的decoding
结构. 它用于记录一些全局译码信息供后续使用, 包括操作数的类型, 宽度, 值等信息. 其中的src
成员,src2
成员和dest
成员分别代表两个源操作数和一个目的操作数.nemu/include/cpu/decode.h
中定义了三个宏id_src
,id_src2
和id_dest
, 用于方便地访问它们.
执行流程
然后对exec_wrapper()
的执行过程进行简单介绍.
- 首先将当前的
%eip
保存到全局译码信息decoding
的成员seq_eip
中, 然后将其地址被作为参数送进exec_real()
函数中.seq
代表顺序的意思, 当代码从exec_real()
返回时,decoding.seq_eip
将会指向下一条指令的地址.exec_real()
函数通过宏make_EHelper
来定义:
其含义是"定义一个执行阶段相关的helper函数", 这些函数都带有一个参数#define make_EHelper(name) void concat(exec_, name) (vaddr_t *eip)
eip
. NEMU通过不同的helper函数来模拟不同的步骤.
在exec_real()
中:
- 首先通过
instr_fetch()
函数(在nemu/include/cpu/exec.h
中定义)进行取指, 得到指令的第一个字节, 将其解释成opcode
并记录在全局译码信息decoding
中. - 根据
opcode
查阅译码查找表, 得到操作数的宽度信息, 并通过调用set_width()
函数将其记录在全局译码信息decoding
中. - 调用
idex()
对指令进行进一步的译码和执行
idex()
函数会调用译码查找表中的相应的译码函数进行操作数的译码.
译码函数统一通过宏make_DHelper
来定义(在nemu/src/cpu/decode/decode.c
中):
#define make_DHelper(name) void concat(decode_, name) (vaddr_t *eip)
它们的名字主要采用i386手册附录A中的操作数表示记号,
例如I2r
表示将立即数移入寄存器, 其中I
表示立即数, 2
表示英文to
, r
表示通用寄存器,
更多的记号请参考i386手册.
译码函数会把指令中的操作数信息分别记录在全局译码信息decoding
中
这些译码函数会进一步分解成各种不同操作数的译码的组合, 以实现操作数译码的解耦.
操作数译码函数统一通过宏make_DopHelper
来定义
(在nemu/src/cpu/decode/decode.c
中, decode_op_rm()
除外):
#define make_DopHelper(name) void concat(decode_op_, name) (vaddr_t *eip, Operand *op, bool load_val)
它们的名字主要采用i386手册附录A中的操作数表示记号.
操作数译码函数会把操作数的信息记录在结构体op
中,
如果操作数在指令中, 就会通过instr_fetch()
将它们从eip
所指向的内存位置取出.
为了使操作数译码函数更易于复用, 函数中的load_val
参数会控制
是否需要将该操作数读出到全局译码信息decoding
供后续使用.
例如如果一个内存操作数是源操作数, 就需要将这个操作数从内存中读出来供后续执行阶段来使用;
如果它仅仅是一个目的操作数, 就不需要从内存读出它的值了,
因为执行这条指令并不需要这个值, 而是将新数据写入相应的内存位置.
idex()
函数中的译码过程结束之后, 会调用译码查找表中的相应的执行函数来进行真正的执行操作.
执行函数统一通过宏make_EHelper
来定义, 它们的名字是指令操作本身.
执行函数通过RTL来描述指令真正的执行功能(RTL将在下文介绍).
其中operand_write()
函数(在nemu/src/cpu/decode/decode.c
中定义)
会根据第一个参数中记录的类型的不同进行相应的写操作, 包括写寄存器和写内存.
从idex()
返回后, exec_real()
最后会通过update_eip()
对%eip
进行更新.
上文已经把一条指令在NEMU中执行的流程进行了大概的介绍. 如果觉得上文的内容不易理解, 可以结合这个例子来RTFSC. 但这个例子中会描述较多细节, 阅读的时候需要一定的耐心.
在decode_op_I()
函数中通过instr_fetch()
函数获得指令中的立即数.
别看这里就这么一行代码, 其实背后隐藏着针对字节序的慎重考虑.
我们知道x86是小端机, 当你使用高级语言或者汇编语言写了一个32位常数0x1234
的时候,
在生成的二进制代码中, 这个常数对应的字节序列如下(假设这个常数在内存中的起始地址是x):
x x+1 x+2 x+3
+----+----+----+----+
| 34 | 12 | 00 | 00 |
+----+----+----+----+
而大多数PC机都是小端架构(我们相信没有同学会使用IBM大型机来做PA), 当NEMU运行的时候,
op_src->imm = instr_fetch(eip, 4);
这行代码会将34 12 00 00
这个字节序列原封不动地从内存读入imm
变量中,
主机的CPU会按照小端方式来解释这一字节序列, 于是会得到0x1234
, 符合我们的预期结果.
Motorola 68k系列的处理器都是大端架构的. 现在问题来了, 考虑以下两种情况:
- 假设我们需要将NEMU运行在Motorola 68k的机器上(把NEMU的源代码编译成Motorola 68k的机器码)
- 假设我们需要编写一个新的模拟器NEMU-Motorola-68k, 模拟器本身运行在x86架构中, 但它模拟的是Motorola 68k程序的执行
在这两种情况下, 你需要注意些什么问题? 为什么会产生这些问题? 怎么解决它们?
事实上不仅仅是立即数的访问, 长度大于1字节的内存访问都需要考虑类似的问题. 我们在这里把问题统一抛出来, 以后就不再单独讨论了.
结构化程序设计
细心的你会发现以下规律:
- 对于同一条指令的不同形式, 它们的执行阶段是相同的.
例如
add_I2E
和add_E2G
等, 它们的执行阶段都是把两个操作数相加, 把结果存入目的操作数. - 对于不同指令的同一种形式, 它们的译码阶段是相同的.
例如
add_I2E
和sub_I2E
等, 它们的译码阶段都是识别出一个立即数和一个E
操作数. - 对于同一条指令同一种形式的不同操作数宽度, 它们的译码阶段和执行阶段都是非常类似的.
例如
add_I2E_b
,add_I2E_w
和add_I2E_l
, 它们都是识别出一个立即数和一个E
操作数, 然后把相加的结果存入E
操作数.
这意味着, 如果独立实现每条指令不同形式不同操作数宽度的helper函数, 将会引入大量重复的代码. 需要修改的时候, 相关的所有helper函数都要分别修改, 遗漏了某一处就会造成bug, 工程维护的难度急速上升. 一种好的做法是把译码, 执行和操作数宽度的相关代码分离开来, 实现解耦, 也就是在程序设计课上提到的结构化程序设计.
在框架代码中, 实现译码和执行之间的解耦的是idex()
函数,
它依次调用opcode_table
表项中的译码和执行的helper函数,
这样我们就可以分别编写译码和执行的helper函数了.
实现操作数宽度和译码, 执行这两者之间的解耦的是id_src
, id_src2
和id_dest
中的width
成员,
它们记录了操作数宽度, 译码和执行的过程中会根据它们进行不同的操作,
通过同一份译码函数和执行函数实现不同操作数宽度的功能.
为了易于使用, 框架代码中使用了一些宏, 我们在这里把相关的宏整理出来, 供大家参考.
宏 | 含义 |
---|---|
nemu/include/macro.h |
|
str(x) |
字符串"x" |
concat(x, y) |
tokenxy |
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/decode.h |
|
id_src |
全局变量decoding 中源操作数成员的地址 |
id_src2 |
全局变量decoding 中2号源操作数成员的地址 |
id_dest |
全局变量decoding 中目的操作数成员的地址 |
make_Dhelper(name) |
名为decode_name 的译码函数的原型说明 |
nemu/src/cpu/decode.c |
|
make_Dophelper(name) |
名为decode_op_name 的操作数译码函数的原型说明 |
nemu/include/cpu/exec.h |
|
make_Ehelper(name) |
名为exec_name 的执行函数的原型说明 |
print_asm(...) |
将反汇编结果的字符串打印到缓冲区decoding.assembly 中 |
suffix_char(width) |
操作数宽度width 对应的后缀字符 |
print_asm_template1(instr) |
打印单目操作数指令instr 的反汇编结果 |
print_asm_template2(instr) |
打印双目操作数指令instr 的反汇编结果 |
print_asm_template3(instr) |
打印三目操作数指令instr 的反汇编结果 |
如果你知道C++的"模板"功能, 你可能会建议使用它, 但事实上在这里做不到.
我们知道宏是在编译预处理阶段进行处理的,
这意味着宏的功能不受编译阶段的约束(包括词法分析, 语法分析, 语义分析);
而C++的模板是在编译阶段进行处理的, 这说明它会受到编译阶段的限制.
理论上来说, 必定有一些事情是宏能做到, 但C++模板做不到.
一个例子就是框架代码中的拼接宏concat()
, 它可以把两个token连接成一个新的token;
而在C++模板进行处理的时候, 词法分析阶段已经结束了, 因而不可能通过C++模板生成新的token.
计算机世界处处都是tradeoff, 有好处自然需要付出代价. 由于处理宏的时候不会进行语法检查, 因为宏而造成的错误很有可能不会马上暴露. 例如以下代码:
#define N 10;
int a[N];
在编译的时候, 编译器会提示代码的第2行有语法错误. 但如果你光看第2行代码, 你很难发现错误, 甚至会怀疑编译器有bug. 那宏到底要不要用呢? 一种客观的观点是, 在你可以控制的范围中使用. 这就像goto语句一样, 当你希望在多重循环中从最内层循环直接跳出所有循环, goto是最方便的做法. 但如果代码中到处都是goto, 已经严重影响到代码段的可读性了, 这种情况当然是不可取的.
用RTL表示指令行为
NEMU使用RTL(寄存器传输语言)来描述x86指令的行为. 这样做的好处是可以提高代码的复用率, 使得指令模拟的实现更加规整. 同时RTL也可以作为一种IR(中间表示)语言, 将来可以很方便地引入即时编译技术对NEMU进行优化, 即使你在PA中不一定有机会感受到这一好处.
下面我们对NEMU中使用的RTL进行一些说明, 首先是RTL寄存器的定义.
RTL寄存器是RTL指令专门使用的寄存器.
在NEMU中, RTL寄存器统一使用rtlreg_t
来定义,
而rtlreg_t
(在nemu/include/common.h
中定义)其实只是一个uint32_t
类型:
typedef uint32_t rtlreg_t;
在NEMU中, RTL寄存器只有以下这些
- x86的八个通用寄存器(在
nemu/include/cpu/reg.h
中定义) id_src
,id_src2
和id_dest
中的访存地址addr
和操作数内容val
(在nemu/include/cpu/decode.h
中定义). 从概念上看, 它们分别与MAR和 MDR有异曲同工之妙- 临时寄存器
t0~t3
(在nemu/src/cpu/decode/decode.c
中定义) - 0寄存器
tzero
(在nemu/src/cpu/decode/decode.c
中定义), 它只能读出0
, 不能写入
有了RTL寄存器, 我们就可以定义RTL指令对它们进行的操作了.
在NEMU中, RTL指令有两种(在nemu/include/cpu/rtl.h
中定义).
一种是RTL基本指令, 它们的特点是在即时编译技术里面可以只使用一条机器指令来实现相应的功能,
同时也不需要使用临时寄存器, 可以看做是最基本的x86指令中的最基本的操作.
RTL基本指令包括:
- 立即数读入
rtl_li
- 算术运算和逻辑运算, 包括寄存器-寄存器类型
rtl_(add|sub|and|or|xor|shl|shr|sar|slt|sltu)
和立即数-寄存器类型rtl_(add|sub|and|or|xor|shl|shr|sar|slt|sltu)i
- 内存的访存
rtl_lm
和rtl_sm
- 通用寄存器的访问
rtl_lr_(b|w|l)
和rtl_sr_(b|w|l)
第二种RTL指令是RTL伪指令, 它们是通过RTL基本指令或者已经实现的RTL伪指令来实现的, 包括:
- 带宽度的通用寄存器访问
rtl_lr
和rtl_sr
- EFLAGS标志位的读写
rtl_set_(CF|OF|ZF|SF|IF)
和rtl_get_(CF|OF|ZF|SF|IF)
- 其它常用功能, 如数据移动
rtl_mv
, 符号扩展rtl_sext
等
其中大部分RTL伪指令还没有实现, 必要的时候你需要实现它们. 有了这些RTL指令之后, 我们就可以方便地通过若干条RTL指令来实现每一条x86指令的行为了.
实现新指令
对译码, 执行和操作数宽度的解耦实现以及RTL的引入对NEMU中实现一条新的x86指令提供了很大的便利, 为了实现一条新指令, 你只需要
- 在
opcode_table
中填写正确的译码函数, 执行函数以及操作数宽度 - 用RTL实现正确的执行函数, 需要注意使用RTL伪指令时不要把临时变量中有意义的值覆盖了
框架代码把绝大部分译码函数和执行函数都定义好了, 你可以很方便地使用它们.
如果你读过上文的扩展阅读材料中关于RISC与CISC融为一体的故事, 你也许会记得CISC风格的x86指令最终被分解成RISC风格的微指令在计算机中运行, 才让x86在这场扩日持久的性能大战中得以存活下来的故事. 如今NEMU在经历了第二次重构之后, 也终于引入了RISC风格的RTL来实现x86指令, 这也许是冥冥之中的安排吧.
运行第一个C程序
说了这么多, 现在到了动手实践的时候了.
你在PA2的第一个任务, 就是实现若干条指令, 使得第一个简单的C程序可以在NEMU中运行起来.
这个简单的C程序的代码是nexus-am/tests/cputest/tests/dummy.c
, 它什么都不做就直接返回了.
在nexus-am/tests/cputest
目录下键入
make ARCH=x86-nemu ALL=dummy run
编译dummy
程序, 并启动NEMU运行它.
事实上, 并不是每一个程序都可以在NEMU中运行,
nexus-am/
子项目专门用于编译出能在NEMU中运行的程序, 我们在下一小节中会再来介绍它.
在NEMU中运行dummy
程序, 你会发现NEMU输出以下信息:
invalid opcode(eip = 0x0010000a): e8 01 00 00 00 90 55 89 ...
There are two cases which will trigger this unexpected exception:
1. The instruction at eip = 0x0010000a is not implemented.
2. Something is implemented incorrectly.
Find this eip value(0x0010000a) in the disassembling result to distinguish which case it is.
If it is the first case, see
_ ____ ___ __ __ __ _
(_)___ \ / _ \ / / | \/ | | |
_ __) | (_) |/ /_ | \ / | __ _ _ __ _ _ __ _| |
| ||__ < > _ <| '_ \ | |\/| |/ _ | '_ \| | | |/ _ | |
| |___) | (_) | (_) | | | | | (_| | | | | |_| | (_| | |
|_|____/ \___/ \___/ |_| |_|\__,_|_| |_|\__,_|\__,_|_|
for more details.
If it is the second case, remember:
* The machine is always right!
* Every line of untested code is always wrong!
这是因为你还没有实现以0xe8
为首字节的指令, 因此, 你需要开始在NEMU中添加指令了.
要实现哪些指令才能让dummy
在NEMU中运行起来呢?
答案就在其反汇编结果(nexus-am/tests/cputest/build/dummy-x86-nemu.txt
)中.
查看反汇编结果, 你发现只需要添加call
, push
, sub
, xor
, pop
, ret
六条指令就可以了.
每一条指令还有不同的形式, 根据KISS法则, 你可以先实现只在dummy
中出现的指令形式,
通过指令的opcode
可以确定具体的形式.
call
:call
指令有很多形式, 不过在PA中只会用到其中的几种, 现在只需要实现CALL rel32
的形式就可以了.%eip
的跳转可以通过将decoding.is_jmp
设为1
, 并将decoding.jmp_eip
设为跳转目标地址来实现, 这时在update_eip()
函数中会把跳转目标地址作为新的%eip
, 而不是顺序意义下的下一条指令的地址push
,pop
: 现在只需要实现PUSH r32
和POP r32
的形式就可以了, 它们可以很容易地通过rtl_push
和rtl_pop
来实现sub
: 在实现sub
指令之前, 你首先需实现EFLAGS寄存器. 你只需要在寄存器结构体中添加EFLAGS寄存器即可. EFLAGS是一个32位寄存器, 它的结构如下:
在NEMU中, 我们只会用到EFLAGS中以下的5个位:31 23 15 7 0 +-------------------+-------------------+-------+-+-+-+-+-+-+-------+-+-+ | |O| |I| |S|Z| | |C| | X | |X| |X| | | X |1| | | |F| |F| |F|F| | |F| +-------------------+-------------------+-------+-+-+-+-+-+-+-------+-+-+
CF
,ZF
,SF
,IF
,OF
, 标记成X
的位不必关心, 它们的功能可暂不实现. 关于EFLAGS中每一位的含义, 请查阅i386手册. 添加EFLAGS寄存器需要用到结构体的位域(bit field)功能, 如果你从未听说过位域, 请查阅相关资料. 关于EFLGAS的初值, 我们遵循i386手册中提到的约定, 你需要在i386手册的第10章中找到这一初值, 然后在restart()
函数中对EFLAGS寄存器进行初始化. 实现了EFLAGS寄存器之后, 再实现相关的RTL指令, 之后你就可以通过这些RTL指令来实现sub
指令了xor
,ret
: RTFM吧
在NEMU中通过RTL指令实现上文提到的指令, 具体细节请务必参考i386手册.
实现成功后, 在NEMU中运行客户程序dummy
, 你将会看到HIT GOOD TRAP
的信息.
PA2阶段1到此结束.