YPC的研究
一、前言
本文尝试去分析 YSYX 中的一个实例 YPC,YPC 使用 Chisel 来实现基本的指令执行过程:定义指令结构、取指、解码(操作码、操作数)、执行、更新 PC。
二、写被执行的程序
这个程序主要使用内嵌汇编的方法来执行一些汇编代码,我们可以确认它仅仅包含这些指令,应为 YPC
被设计为只能执行 I类指令
的两种指令,addi
和 ebreak
。
1 |
|
这个程序很简单,不管是 putch
还是 halt
,它都会自己调用函数 ebreak()
,其对应着 3 条指令:
1 |
|
这里需要澄清的是,这是一个程序,它会在riscv 上自己执行, 它不会做额外的事情。
我们可以把这个程序编译一下:
1 |
|
可以看到这个程序真的只含有两条指令(最后一条 jal 指令是跳转指令,目的是制造死循环,不用管)。
但是我们可以手动写一个解释器,它可以用来执行
riscv 代码,甚至我们可以根据 ebreak 的参数来确定接下来的行为。比如,当调用函数 ebreak( 0,ch)
的时候,这时候 a0 就是 0,a11 就是 ch,我们可以定义它为输出字符;当调用函数 ebreak( 1,code)
的时候,这时候 a0 就是 1,a11 就是 code,我们甚至可以根据不同的 code 来触发不同的行为。
由于我只需要二进制程序 prog 中的汇编指令,因此我们可以利用命令将一个可执行二进制文件中的汇编指令抽离出来:
1 |
|
这样里面就仅仅包含了我们想要的二进制指令,然后就可以放在解释器上执行它了。
在 PYC 中,我们定义了两种有限的行为:
- 打印 ch
- 停机
YPC
1 |
|
这个程序定一个了 YPC 模块,它有 4 个组件,分别是:
io.halt: 这是一个输出,它会输出是否停机的信号;
R: 这是寄存器,一共 32 个,每一个 32 位;
M: 内存单元,每个单元都是 32 位,换句话说,这些单元的下标对应着的地址是 4 字节对齐的;
PC: 初始化为 0,标记着指令在 M 中的地址;
Ibundle: 这是一个 I 型指令的bundle;
inst: 指令;
isAddi、isEbreak: 指令判断,bool 类型;
接下来的操作是YPC 的核心:
如果是 ebreak 类型的指令,分别读取 a0、a1,根据 a0、a1 来赋予指令语义,比如是输出字符还是终止;
如果是 addi 类型的指令,分别读取 源寄存器、零寄存器,它们会被当做两个源寄存器,以便后面进行加法操作;
当是 ebreak 指令的时候, R(inst.rd) := rs1Val + SignEXT(inst.imm11_0);
当是 ebreak && a0 是 0,就输出字符 a1中的值,也就是传给的 ch;
当是 ebreak && a0 是 1,就标记输出 io.halt 为 1。这个输出在运行时环境中会被用来判断是否停机。
最后更新 pc。
Chisel 到 Verilog 再到C++仿真
写好了 Chsel,想要利用 C++ 进行仿真,还得将它翻译成 Verilog,然后借助 verilator 将 HDL 翻译成 C++ 代码,然后我们可以写一个运行时环境,给它装载程序、控制停机、执行等:
1 |
|
假设已经生成了 HDL ,也就是 YPC.v,现在我们将它翻译成 C++。为了方便操作,可以写一个简单的 Makefile 来简化操作:
1 |
|
然后直接make run:
1 |
|
看来,YPC 已经达到了预期效果。
事实上,这个 YPC 可以很强大,只要实现所有的命令,它就可以运行所有的 riscv 程序!
思考
第一个问题:注意到 YPC.scala
中的一些调用,比如 assert、printf
,这其实是 Scala
的函数调用,当然它依然会被 sbt
转化为 HDL
:
1 |
|
可以看到,这并不是真正的 HDL,他依然是函数调用,需要借助于仿真环境,它这次就会被 verilator 转化为 C++ 的系统调用(in verilated.h):
1 |
|
第二个问题:程序中有 jal 指令,按理说会打印出 Assertion failed: Invalid instruction...
,因为我并没有实现 jal 指令,但是并没有注意到这个信息,为什么?
答案很简答,因为这个调用:
1 |
|
首先是 halt 了,然后才死循环调用 jal 了,事实上当:
1 |
|
的时候,运行时环境已经结束了二进制程序的继续推进:
1 |
|