This project is a side product of my master’s thesis, where I needed to develop new instructions for the VexRiscv, a soft core for 32 bit RISC-V.
The framework cuts down boilerplate for defining own ISA extensions, generates opcodes, and can also be used to do some easy dependency resolution.
By default, developing new instructions (at least simple ones), is straight forward, but involves some boilerplate. Let me explain this with an example instruction from the VexRiscv's
README.
import spinal.core._
import vexriscv.plugin.Plugin
import vexriscv.{Stageable, DecoderService, VexRiscv}
class SimdAddPlugin extends Plugin[VexRiscv]{
// Define the concept of IS_SIMD_ADD signals, which specify if the current instruction is destined for this plugin
object IS_SIMD_ADD extends Stageable(Bool)
// Callback to setup the plugin and ask for different services
override def setup(pipeline: VexRiscv): Unit = {
import pipeline.config._
// Retrieve the DecoderService instance
val decoderService = pipeline.service(classOf[DecoderService])
// Specify the IS_SIMD_ADD default value when instructions are decoded
decoderService.addDefault(IS_SIMD_ADD, False)
// Specify the instruction decoding which should be applied when the instruction matches the 'key' parttern
decoderService.add(
// Bit pattern of the new SIMD_ADD instruction
key = M"0000011----------000-----0110011",
// Decoding specification when the 'key' pattern is recognized in the instruction
List(
IS_SIMD_ADD -> True,
REGFILE_WRITE_VALID -> True, //Enable the register file write
BYPASSABLE_EXECUTE_STAGE -> True, //Notify the hazard management unit that the instruction result is already accessible in the EXECUTE stage (Bypass ready)
BYPASSABLE_MEMORY_STAGE -> True, //Same as above but for the memory stage
RS1_USE -> True, //Notify the hazard management unit that this instruction uses the RS1 value
RS2_USE -> True //Same as above but for RS2.
)
)
}
override def build(pipeline: VexRiscv): Unit = {
import pipeline._
import pipeline.config._
// Add a new scope on the execute stage (used to give a name to signals)
execute plug new Area {
// Define some signals used internally by the plugin
val rs1 = execute.input(RS1).asUInt
// 32 bits UInt value of the regfile[RS1]
val rs2 = execute.input(RS2).asUInt
val rd = UInt(32 bits)
// Do some computations
rd(7 downto 0) := rs1(7 downto 0) + rs2(7 downto 0)
rd(16 downto 8) := rs1(16 downto 8) + rs2(16 downto 8)
rd(23 downto 16) := rs1(23 downto 16) + rs2(23 downto 16)
rd(31 downto 24) := rs1(31 downto 24) + rs2(31 downto 24)
// When the instruction is a SIMD_ADD, write the result into the register file data path.
when(execute.input(IS_SIMD_ADD)) {
execute.output(REGFILE_WRITE_DATA) := rd.asBits
}
}
}
}
As you can see, the computation itself take up only 4 lines, whereas the rest is boilerplate and configuration. This is of course no problem in itself, as we are blessed with CTRL+C CTRL-V, but at least I tend to forget some minor changes and spent the next day debugging.
Using this framework, the same instruction can be written in the following way:
import vexriscv._
import spinal.core._
import RegisterAccess._
class SIMDAdd extends BaseInstruction(rs1 = Regular, rs2 = Regular, rd = Regular) with RTypeInstruction {
// Add a new scope on the execute stage (used to give a name to signals)
execute { implicit ctx =>
// Define some signals used internally to the plugin
val rs1 = firstOperand.asUInt //32 bits UInt value of the regfile[RS1]
val rs2 = secondOperand.asUInt //32 bits UInt value of the regfile[RS2]
val tmp = UInt(32 bits)
// Do some computation
tmp(31 downto 24) := rs1(31 downto 24) + rs2(31 downto 24)
tmp(23 downto 16) := rs1(23 downto 16) + rs2(23 downto 16)
tmp(15 downto 8) := rs1(15 downto 8) + rs2(15 downto 8)
tmp(7 downto 0) := rs1(7 downto 0) + rs2(7 downto 0)
writeResult(tmp.asBits)
}
}
The main computation now takes up the biggest part of the code and the configuration is done automatically base on the information given in the constructor. The opcode is generated for you within a predefined space.
The framework was developed using VexRiscv
commit fe739b907a6a2dc046b707e9f6dba2db959b8362
(May 11th 2021) and its dependencies. The framework consists currently only of a few files you can easily drop into your project and use for the instruction you seem fit.
Do not take the build.sc
and .mill-version
files, they only exist to verify that the code can compile successfully.
Functionality regarding memory access is currently commented out, as the required stageables are defined in the respective plugins and not globally.
As VexRiscv is not available on Maven etc. I see no use in bundling the framework in a library either.
Create a new class for your instruction and extend BaseInstruction
. Depending on your desired functionality, you should mix-in the appropriate encoding (RTypeInstruction
etc.) or overload the required defs yourself.
The BaseInstruction
has to be supplied with your desired configuration options, for example rd = Regular
if you intend to use the destination register.
class MyInstruction extends BaseInstruction(rd = Regular) with RTypeInstruction
The behavior of your instruction comes into the class body inside of blocks which are named accordingly to the pipeline phases execute
, memory
, and writeBack
. A phase has to be given a function which maps an implicit context to your desired execution.
execute { implicit ctx =>
// Stuff
}
The implicit context is used for some magic on can be ignored. Once VexRiscv
reaches Scala3, this can be completely hidden by using context functions.
Inside these blocks, you can normally describe your logic. You may read and write stageables using a few functions.
val rs1 = firstOperand // Reads rs1 as Bits
val rs2 = secondOperand // Reads rs2 as Bits
val temp = read(SOME_STAGEABLE)
SOME_STAGEABLE := B(0) // Writes a stageable using insert
writeResult(rs1 ^ rs2) // Write the destination stageabl using output
If you need to access the stageables the normal way, you can still do so.
import ctx.stage._ // Import stage from the context
import ctx.config._ // Import the config from the context
val a = input(SOME_STAGEABLE)
insert(SOME_OTHER_STAGEABLE) := B(0)
The opcode generation is very handy if you want to automate the whole core generation but very useless otherwise.
To disable it and choose your own opcode, you can just override instructionPattern
:
// inside your class extending BaseInstruction
override def instructionPattern: String = "-----------------101-----010101"