NJU计算机课程基础实验 PA2笔记(一)2022-08-12
冯诺依曼计算机,从riscv32指令集开始的魔法
PA2.1 冯诺依曼计算机
写在前面:
首先要感谢jyy群一直帮助我的pony小哥、17号小哥、YSYX论坛(还有很多帮助我的人,没写出来但我都很感谢!)
没有你们我是 不可能能这么快理解(甚至完全做不出)这一部分的。
我的基础很薄弱,对于ISA真的一无所知,感谢无私的帮助
注意,包老师的中文版手册有些地方是有错的,建议只看概念,具体的编码参考英文的资料,比如:https://msyksphinz-self.github.io/riscv-isadoc/html/rvi.html#fence-i
具体还是要以英文手册的译码为准。
【前半部分都是纯粹的抄讲义阶段。。但是抄了后真的能比较好理解】
PA2一开始的任务是实现译码的流程,补充辅助译码函数(注意一下伪指令不用实现,函数体可以留空)
RTFSC(2)
fetch_decode_exec_updatepc(): 取指, 译码, 执行, 更新PC
在cpu_exec——fetch_decode_exec_updatepc——fetch_decode
fetch_decode_exec_updatepc()
接受一个Decode
类型的结构体指针s
, 这个结构体用于存放在执行一条指令过程中的译码和执行信息, 包括指令的PC, 执行方式, 以及操作数的信息. 还有一些信息是ISA相关的, NEMU用 一个结构类型ISADecodeInfo
来对这些信息进行抽象, 具体的定义在nemu/src/isa/$ISA/include/isa-def.h
中.fetch_decode_exec_updatepc()
首先会调用fetch_decode()
进行取指和译码,fetch_decode()
会先把当前的PC保存到s
的成员pc
和snpc
中, 其中s->pc
就是当前指令的PC, 而s->snpc
则是下一条指令的PC, 这里的snpc
是 "static next PC"的意思.然后代码会调用
isa_fetch_decode()
函数(在nemu/src/isa/$ISA/instr/decode.c
中定义), 它会随着取指的过程修改s->snpc
的值, 使得从isa_fetch_decode()
返回后s->snpc
正好为下一条指令的PC. 这里的dnpc
**是"dynamic next PC"的意思. **此外,
isa_fetch_decode()
还会返回一个编号idx
, 用于对g_exec_table
这一数组进行索引.g_exec_table
是一个函数指针的数组, 数组中的每个元素都会指向一个用于模拟指令执行的函数, 我们把这样的函数称为"执行辅助函数"(execution helper function). 通过idx
索引这个数组, 可以找到与一条指令相匹配的执行辅助函数, 并把它记录到s->EHelper
中.忽略
fetch_decode()
中剩下与trace相关的代码, 我们就返回到fetch_decode_exec_updatepc()
中. ****将会调用刚才记录到的执行辅助函数, 来模拟指令执行的真正操作. 最后会更新PC, 让PC指向下一条指令.显然,
fetch_decode_exec_updatepc()
函数覆盖了指令周期的所有阶段: 取指, 译码, 执行, 更新PC. 在这些阶段中, 代码都可以对s
进行记录和访问.
取指(instruction fetch, IF)
isa_fetch_decode()
做的第一件事情就是取指令. 在NEMU中, 有一个函数instr_fetch()
(在nemu/include/cpu/ifetch.h
中定义)专门负责取指令的工作.instr_fetch()
最终会根据参数len
来调用vaddr_ifetch()
(在nemu/src/memory/vaddr.c
中定义), 而目前vaddr_ifetch()
又会通过paddr_read()
来访问物理内存中的内容. 因此, 取指操作的本质只不过就是一次内存的访问而已.
isa_fetch_decode()
在调用instr_fetch()
的时候传入了s->snpc
的地址, 因此instr_fetch()
最后还会根据len
来更新s->snpc
, 从而让s->snpc
指向下一条指令.
Motorola 68k系列的处理器都是大端架构的. 现在问题来了, 考虑以下两种情况:
假设我们需要将NEMU运行在Motorola 68k的机器上(把NEMU的源代码编译成Motorola 68k的机器码)
- 假设我们需要把Motorola 68k作为一个新的ISA加入到NEMU中
在这两种情况下, 你需要注意些什么问题? 为什么会产生这些问题? 怎么解决它们?
答:我猜想可以在memory的host.h处以及map.c的地方通过位置调整(改变addr指针的偏移)解决问题。有朋友提出可以固定len然后再拼起来,也有朋友建议可以了解一下ntohl和ntohs的现有转换库
译码(instruction decode, ID)
译码的目的是得到指令的操作和操作对象, 这主要是通过查看指令的opcode来决定的. 不同ISA的opcode会出现在指令的不同位置, 我们只需要根据指令的编码格式, 从取出的指令中识别出相应的opcode即可.
和YEMU相比, NEMU使用一种抽象层次更高的译码方式: 模式匹配, NEMU可以通过一个模式字符串来指定指令中opcode:(def_INSTR_IDTAB 在 decode.c)
nemu/include/cpu/decode.h 定义了这些各种各样的宏,包括模式匹配规则。
宏展开def的定义宏后是这样的:(根据def_INSTR_raw)
do {
uint32_t key, mask, shift;
pattern_decode("??????? ????? ????? ??? ????? 01101 11", 37, &key, &mask, &shift);
if (((s->isa.instr.val >> shift) & mask) == key) {
decode_U(s, 0);
return table_lui(s);
}
} while (0);
操作对象(比如立即数是多少, 读入到哪个寄存器). 为了解决这个问题, 代码需要进行进一步的译码工作, 这是通过调用相应的
译码辅助函数
(decode helper function)来完成的. 译码辅助函数统一通过宏def_DHelper
(在nemu/include/cpu/decode.h
中定义)来定义每个译码辅助函数负责进行一种类型的操作数译码, 把指令中的操作数信息分别记录在译码信息
s
的dest
成员,src1
成员和src2
成员中, 它们分别代表目的操作数和两个源操作数.nemu/include/cpu/decode.h
中还定义了三个宏id_dest
,id_src1
和id_src2
, 用于方便地访问它们.【注意,这里的关键是原操作数和目的操作数的是否可写入权限】寄存器和立即数这些操作数, 其实是非常常见的操作数类型. 为了进一步实现操作数译码和指令译码的解耦, 框架代码对这些操作数的译码进行了抽象封装, 指令译码过程由若干
译码操作数辅助函数
(decode operand helper function)组成. 译码操作数辅助函数统一通过宏def_DopHelper
来定义DopHelper带有一个
flag
参数, 不同的DopHelper可以用它来进行不同的处理. 例如寄存器的DopHelper可以通过flag
来指示是否写入(可以参考def_DopHelper(r)实现中的)
void concat(decode_op_, name) (Decode *s, Operand *op, word_t val, bool flag)
可以匹配decode_op_r和decode_op_i(def_DopHelper(r)
,def_DopHelper(i)
)DopHelper带有一个
flag
参数, 不同的DopHelper可以用它来进行不同的处理. 例如寄存器的DopHelper可以通过flag
来指示是否写入(可参考def_DopHelper(r)
中的实现)有了这些译码操作数辅助函数, 我们就可以用它们来编写译码辅助函数:
def_DHelper(I)
、
def_DHelper(U)
、def_DHelper(S)
由于CISC指 令变长的特性, x86指令长度和指令形式需要一边取指一边译码来确定, 而不像RISC指令集那样可以泾渭分明地处理取指和译码阶段, 因此你会在x86的译码操作数辅助函数中看到的操作.
Q:mips32和riscv32的指令长度只有32位, 因此它们不能像x86那样, 把C代码中的32位常数直接编码到一条指令中. 思考一下, mips32和riscv32应该如何解决这个问题?
A:指令和储存分开,而且可以用两个拼成一个,把数据存在32位寄存器,或者变成几个寄存器加减结果。或者有朋友说的可以高位低位分开储存(实际上你在手册中能看到部指令这样的实现之类的)。
回到def_INSTR_IDTAB
的宏展开结果,:
do {
uint32_t key, mask, shift;
pattern_decode("??????? ????? ????? ??? ????? 01101 11", 37, &key, &mask, &shift);
if (((s->isa.instr.val >> shift) & mask) == key) {
decode_U(s, 0);
return table_lui(s);
}
} while (0);
对于lui
指令, 在译码辅助函数decode_U()
执行结束后, 代码将会执 行table_lui()
. table_lui()
的定义方式比较特殊, 部分宏展开后的定义:
def_THelper(lui) {
return EXEC_ID_lui;
}
其中宏
def_THelper
(在nemu/include/cpu/decode.h
中定义) 用于**统一定义"表格辅助函数"(table helper function). **table_lui()
做的事情很简单, 它直接返回一个标识lui
指令的唯一ID. 这个ID会作为译码结果的返回值, 在fetch_decode()
中索引g_exec_table
数组.事实上, 译码的过程可以看成是若干查表的操作, 每一条模式匹配的规则都可以看成是表格中的一个表项, 因此我们可以使用表格辅助函数来描述这些译码的规则. 以riscv为例:
这一模式字符串只能通过
opcode
匹配到load类型的指令, 为了进一步确定是哪一条load指令, 我们还需要匹配funct3
字段(具体查看riscv手册就直到为什么要匹配那三个了。), 因此我们引入一个新的表格辅助函数table_load()
, 匹配到load类型指令的时候, 会进一步调用table_load()
, 然后在其中通过额外的模式字符串来匹配funct3
字段, 例如: 以riscv为例:def_THelper(load) {
def_INSTR_TAB("??????? ????? ????? 010 ????? ????? ??", lw);
return EXEC_ID_inv;
}
def_INSTR_TAB
也是一条字符串匹配规则, 但它并不需要调用译码辅助函数.这条规则描述了"在load类型指令中, 如果
funct3
为010
, 则为lw
指令".** NEMU把译码时的如下情况都看作是查表过程:**
在
isa_fetch_decode()
中查主表(main decode table)在译码过程中分别匹配指令中的每一个域(如上文介绍的
table_load()
译码出最终的指令时认为是一种特殊的查表操作, 直接返回 标识该指令的唯一ID
如果所有模式匹配规则都无法成功匹配, 代码将会返回一个标识非法指令的ID.
执行(execute, EX)
之前的关键是译码辅助函数(decode_U)——译码辅助函数统一通过宏
def_DHelper
(在nemu/include/cpu/decode.h
)来定义;以及"表格辅助函数"def_THelper
(也在nemu/include/cpu/decode.h
),他返回一个标识lui
指令的唯一ID. 这个ID会作为译码结果的返回值, 在fetch_decode()
中索引g_exec_table
数组。一种好的做法是把译码, 执行和操作数宽度的相关代码分离来, 实现解耦. 在框架代码中, 实现译码和执行之间的解耦的是
isa_fetch_decode()
返回的编号, 这样我们就可以分别编写译码和执行的辅助函数, 然后进行组合:这很容易实现执行行为相同但译码方式不同的多条指令.
现在我们需要注意的是执行辅助函数def_EHelper
(在g_exec_table)。
译码过程结束之后, 接下来会返回到
fetch_decode()
中, 并通过返回的ID来从g_exec_table
数组中选择相应的执行辅助函数(execution helper function), 然后记录到s->EHelper
中. 返回到fetch_decode_exec_updatepc()
后, 代码将会调用刚才记录的执行辅助函数. 执行辅助函数统一通过宏def_EHelper
(在nemu/include/cpu/exec.h
中定义)来定义(对x86来说, 大部分计算指令都可以访问内存, 来根据目的操作数类型的不同, 决定是写入寄存器还是 写入内存; 对于mips32和riscv32, 访问内存只能通过特定的访存指令进行, 因此每条指令的目的操作数类型都是唯一的.)
每个执行辅助函数都需要有一个标识该指令的ID以及一个表格辅助函数与之相对应, 通过一系列宏定义实现的. 在
nemu/src/isa/$ISA/include/isa-all-instr.h
中定义用于表示指令列表的宏INSTR_LIST
, 它定义了NEMU支持的所有指令. 然后代码通过一种类似函数式编程的方式来定义如下相关的内容:
- 在
nemu/include/cpu/decode.h
中为所有的执行辅助函数定义相应的ID. 以riscv32为例, 对def_all_EXEC_ID()
进行宏展开后, 结果如下:#define def_all_EXEC_ID() enum { MAP(INSTR_LIST, def_EXEC_ID) TOTAL_INSTR }
变为enum { EXEC_ID_lui, EXEC_ID_lw, EXEC_ID_sw, EXEC_ID_inv, EXEC_ID_nemu_trap, TOTAL_INSTR }
其中
TOTAL_INSTR
的值正好为目前所有指令的总数只需要维护中的指令列表, 就可以正确维护执行辅助函数和译码之间的关系了.
更新PC
更新PC的操作非常简单, 在fetch_decode_exec_updatepc把
s->dnpc
赋值给cpu.pc
即可.之前提到了
snpc
和dnpc
, 它们的区别如下:在程序分析领域中, 静态指令是指程序代码中的指令, 动态指令是指程序运行过程中的指令
snpc
是指代码中的下一条指令, 而dnpc
是指程序运行过程中的下一条指令.
dnpc
应该指向跳转目标的指令. 显然, 我们应该使用s->dnpc
来更新PC, 并且在执行辅助函数中正确维护s->dnpc
.
用RTL表示指令行为
这个很重要!大部分译码函数都通过这个实现(具体有些行为在op_c中可以找到)
需要详细查看
在NEMU中,使用RTL(寄存器传输语言)来描述(先实现这些简单操作, 然后再用它们来实现指令).
RTL寄存器的定义. 在NEMU中, RTL寄存器统一使用
rtlreg_t
来定义, 而rtlreg_t
(在nemu/include/common.h
中定义)其实只是一个word_t
类型:typedef word_t rtlreg_t;
在NEMU中的RTL寄存器:
不同ISA的通用寄存器(在
nemu/src/isa/$ISA/include/isa-def.h
中定义)临时寄存器
s0, s1, s2
和t0
(在nemu/include/rtl/rtl.h
中定义)零寄存器
rz
(在nemu/include/rtl/rtl.h
中定义), 它的值总是0
(其实不需要使用 临时寄存器就可以完成大部分指令
实现新指令
对译码, 执行和操作数宽度的解耦实现以及RTL的引入, 对在NEMU中实现客户指令提供了很大的便利, 为了实现一条新指令, 只需要
在
nemu/src/isa/$ISA/instr/decode.c
中添加正确的模式匹配规则用RTL实现正确的执行辅助函数, 需要注意使用RTL伪指令时要遵守上文提到的小型调用约定
在
nemu/src/isa/$ISA/include/isa-all-instr.h
中把指令添加到INSTR_LIST
中必要时在
nemu/src/isa/$ISA/include/isa-exec.h
中添加相应的头文件