接上篇,本文对CPU的取指令模块的运行过程进行解释说明和备忘。对取指模块的解释包括如下5个文件:1个位于src/test/scala/目录下的TestBench文件,3个位于src/main/scala/01_fetch/目录下的待测电路DUT代码文件,1个位于src/hex/fetch.hex文件。其中src/hex/fetch.hex就是将要加载到CPU芯片的“指令存储器IMem”中的指令序列。
$ pygount src/main/scala/01_fetch/*.scala src/test/scala/FetchTest.scala
16 Scala src/main/scala/01_fetch src/main/scala/01_fetch/Core.scala
24 Scala src/main/scala/01_fetch src/main/scala/01_fetch/Memory.scala
9 Scala src/main/scala/01_fetch src/main/scala/01_fetch/Top.scala
9 Scala src/test/scala src/test/scala/FetchTest.scala
先看使用ChiselTest框架的TestBench代码(FetchTest.scala):
$ cat src/test/scala/FetchTest.scala
package fetch
import chisel3._
import org.scalatest.flatspec.AnyFlatSpec
import chiseltest._
class HexTest extends AnyFlatSpec with ChiselScalatestTester {
"mycpu" should "work through hex" in {
test(new Top) { c =>
while (!c.io.exit.peek().litToBoolean){
c.clock.step(1)
}
}
}
}
该代码和01_fetch目录下的DUT代码使用相同的名为fetch的package,因此代码中的new Top所创建的电路对象,是定义在01_fetch/Top.scala中的Top模块。如下:
$ cat src/main/scala/01_fetch/Top.scala
package fetch
import chisel3._
class Top extends Module {
val io = IO(new Bundle {
val exit = Output(Bool())
})
val core = Module(new Core())
val memory = Module(new Memory())
core.io.imem <> memory.io.imem
io.exit := core.io.exit
}
这个Top模块从命名上可以看做一个较为规范的范式,代表某个电路模块或子模块的“顶层模块”。从它的代码看,它进一步包含了Core和Memory两个模块,即上面代码中使用new创建的电路模块对象。其中的core模块有一个core.io.exit信号线/导线/Wire直接作为Top模块的io,使用:=赋值运算符。而memory则明显是一个位于更后端的模块,连接在core.io.imem上,使用<>运算符表示多根导线(一束导线)的连接。Top模块对外呈现就是io这个变量,使用了IO类和Bundle类,它们都属于Chisel语言的基本元素/语素。
下面是Memory模块源文件:
$ cat src/main/scala/01_fetch/Memory.scala
package fetch
import chisel3._
import chisel3.util._
//import chisel3.util.experimental.loadMemoryFromFile
import common.Consts._
import scala.io.Source
class ImemPortIo extends Bundle {
val addr = Input(UInt(WORD_LEN.W))
val inst = Output(UInt(WORD_LEN.W))
}
class Memory extends Module {
val io = IO(new Bundle {
val imem = new ImemPortIo()
})
val mem = Mem(16384, UInt(8.W))
//loadMemoryFromFile(mem, "src/hex/fetch.hex")
var fileLines = Source.fromFile("src/hex/fetch.hex").getLines().toList
for((line, index) <- fileLines.zipWithIndex) {
val num: Int = Integer.parseInt(line, 16)
val idxBits: UInt = index.asUInt
val lineBits: UInt = num.asUInt
mem(index) := lineBits
//chisel3.printf(p"fromFile: Index: ${idxBits}, Content: ${lineBits}, mem: ${mem(index)}\n")
}
io.imem.inst := Cat(
mem(io.imem.addr + 3.U(WORD_LEN.W)),
mem(io.imem.addr + 2.U(WORD_LEN.W)),
mem(io.imem.addr + 1.U(WORD_LEN.W)),
mem(io.imem.addr)
)
chisel3.printf("io.imem.addr = 0x%x, io.imem.inst = 0x%x\n",io.imem.addr, io.imem.inst)
}
在这个代码中,ImemPortIo是Bundle类的派生类,其中位宽为WORD_LEN(实际为32bit)的addr作为INPUT,inst作为OUTPUT,也就是从指定的地址(address)读取指令(instruction)。唯一的模块是Module,使用ImemPortIo来定义其IO、而不是使用Bundle来定义其IO,这就是“对象化”--将原本零散的两个IO对象addr和inst汇聚到imem成员下。
代码中的loadMemoryFromFile函数被改为了Source.fromFile()及其后的一个代码块,负责从src/hex/fetch.hex中读取指令序列并放到指令存储器模块(即Memory模块)。下面的io.imem.inst := Cat(xxx)语句,则是电路的“唯一动作”,负责将io.imem.addr开始的连续4个字节拷贝给io.imem.inst,这个动作是在时钟的触发下完成的“唯一动作”。时钟什么时候触发,则在TestBench中。
下面是Core模块源文件:
$ cat src/main/scala/01_fetch/Core.scala
package fetch
import chisel3._
import common.Consts._
class Core extends Module {
val io = IO(new Bundle {
val imem = Flipped(new ImemPortIo())
val exit = Output(Bool())
})
val regfile = Mem(32, UInt(WORD_LEN.W))
//**********************************
// Instruction Fetch (IF) Stage
val pc_reg = RegInit(START_ADDR)
pc_reg := pc_reg + 4.U(WORD_LEN.W)
io.imem.addr := pc_reg
val inst = io.imem.inst
//**********************************
// Debug
io.exit := (inst === 0x34333231.U(WORD_LEN.W))
printf(p"pc_reg : 0x${Hexadecimal(pc_reg)}\n")
printf(p"inst : 0x${Hexadecimal(inst)}\n")
printf("---------\n")
}
其中Core模块的IO,使用Bundle定义了一个imem和一个exit,分别对内(后端)接Memory模块、对外(前端)直接作为Top模块的IO,Core模块的io.imem和Memory模块的io.imem是对接的,因此使用了Flipped进行了“翻转”,对Core模块来说,io.imem.addr信号是其输出信号(被赋值的信号,位于:=的左边)、io.imem.inst信号则是其输入信号(赋值给模块寄存器的信号,位于:=的右边)。
Core模块的主要动作,就是响应时钟信号(在时钟信号的驱动下),将pc_reg寄存器的值写到io.imem.addr上,然后读取io.imem.inst的值到inst变量(对应Core模块的寄存器),并使用printf打印出来(包含pc_reg和inst指令)。如果读取到的指令是0x34333231,则将io.exit管脚状态置为True(可理解为高电平)。Core模块还有一个初始状态的置位,即RegInit(START_ADDR)这个语句,将pc_reg置为START_ADDR(实际为0),该语句仅在TestBench启动时执行一次,后续TestBench在进行时钟翻转驱动整个被测电路响应的过程中,Core模块的这一条RegInit语句不会再被执行,而它下面的那一行pc_reg := pc_reg + 4则会被执行(每次时钟边缘/上升沿或下降沿)。
至此,整个取值模块01_fetch的3个模块Core/Memory/Top都过了一遍,总结一下:
(1)Top代表了电路的“顶层模块”,里面包含Core和Memory两个模块,其中Core模块的io.exit直接作为Top模块的io.exit(使用赋值语句),而Core模块的io.imem则和更后端的Memory模块的io.imem连接(使用了<>连接,以及Flipped表示IO连接的两端的input/output翻转关系)。
(2)Core模块和Memory模块各自有随着TestBench启动而“一次性预先做的工作”,Core模块是将pc_reg寄存器初始化为0,Memory模块是将fetch.hex文件中的指令加载到模块的内存中。
(3)初始化之后的工作都是在时钟信号的驱动下的“响应式”工作,其中TestBench是最上游的控制盒驱动时钟信号的,而Core和Memory则是Datapath的“上游”和“下游”,Core负责读取pc_reg所指向的IMem内存获得指令(将pc_reg的值通过io.imem.addr传给Memory模块),Memory则是将io.imem.addr所代表的IMem内存地址处的内容读取出来并写到io.imem.inst(即反馈给Core模块)。注意理解,这个Memory模块读取IMem的内容并反馈给Core模块,并不是对io.imem.addr的响应,而是对clock时钟边缘信号的响应,可以认为是PUSH给Core模块的!同一个时钟信号连到Core模块、也连到Memory模块,两个模块收到时钟激励是完全同步的,从Core模块将pc_reg的值写到io.imem.addr的“信号线/导线”上是不花时间的。这是我们在现阶段理解这个过程的假设。在真实的电路设计到后续验证过程中,这个假设会做调整并做实际验证的,会体现在代码文件中设置某个延时。了解这一点有助于大小对这个“假设”的疑虑,但现阶段先维持假设以将理解的注意力放在逻辑功能上。
最后看一下src/hex/fetch.hex文件的内容,以及Fetch模块的执行结果:
fetch.hex文件内容如下,每4行代表一个指令,一共5个指令:
$ cat src/hex/fetch.hex
11
12
13
14
21
22
23
24
81
82
83
84
91
92
93
94
31
32
33
34
使用sbt "testOnly Fetch.HexTest"对CPU的取指模块的测试例执行结果如下:
注意,fetch.hex中一共有5条指令,而这个执行结果只显示了4条指令。因为在TestBench代码中检测到Top模块的io.exit信号为1以后,就没有再驱动时钟信号进行翻转了。这个io.exit信号正是TestBench和作为DUT的Top模块的“反馈信号”,而clock信号也是这两者之间的信号但在DUT代码中并没有任何clock信号的代码----因为Chisel语言把clock信号“内化”在了语言中了。
PS:请熟练使用gitcode并关注本项目,实际操作才能真正入门。