The objectives of this tutorial are:
- Learn how instructions are represented in the MPACT-Sim simulator.
- Learn the structure and syntax of the ISA description file.
- Write the ISA descriptions for the RiscV RV32I subset of instructions
Overview
In MPACT-Sim the target instructions are decoded and stored in an internal representation to make their information more available and the semantics faster to execute. These instruction instances are cached in an instruction cache so as to reduce the number of times frequently executed instructions are executed.
The instruction class
Before we start, it is useful to look a little bit at how instructions are
represented in MPACT-Sim. The Instruction
class is defined in
mpact-sim/mpact/sim/generic/instruction.h.
The Instruction class instance contains all the information necessary to simulate the instruction when it is "executed", such as:
- Instruction address, simulated instruction size, i.e., size in .text.
- Instruction opcode.
- Predicate operand interface pointer (if applicable).
- Vector of source operand interface pointers.
- Vector of destination operand interface pointers.
- Semantic function callable.
- Pointer to architectural state object.
- Pointer to context object.
- Pointer to child and next Instruction instances.
- Disassembly string.
These instances are generally stored in an instruction (instance) cache, and reused whenever the instruction is re-executed. This improves performance during runtime.
Except for the pointer to the context object, all are filled in by the instruction decoder that is generated from the ISA description. For this tutorial it is not necessary to know details of these items as we will not be using them directly. Instead, a high level grasp on how they are used is sufficient.
The semantic function callable is the C++ function/method/function object
(including lambdas) that implements the semantics of the instruction. For
instance, for an add
instruction it loads each source operand, adds the two
operands, and writes the result to a single destination operand. The topic of
semantic functions is covered in depth in the semantics function tutorial.
Instruction operands
The instruction class includes pointers to three types of operand interfaces: predicate, source and destination. These interfaces allows semantic functions to be written independently of the actual type of the underlying instruction operand. For instance, accessing the values of registers and immediates are done through the same interface. This means that instructions that perform the same operation but on different operands (e.g., registers vs immedates) can be implemented using the same semantic function.
The predicate operand interface, for those ISAs that support predicated instruction execution (for other ISAs it is null), is used to determine if a given instruction should execute based on the boolean value of the predicate.
// The predicte operand interface is intended primarily as the interface to
// read the value of instruction predicates. It is separated from source
// predicates to avoid mixing it in with the source operands needed for modeling
// the instruction semantics.
class PredicateOperandInterface {
public:
virtual bool Value() = 0;
// Return a string representation of the operand suitable for display in
// disassembly.
virtual std::string AsString() const = 0;
virtual ~PredicateOperandInterface() = default;
};
The source operand interface allows the instruction semantic function to read values from the instructions operands without regard to the underlying operand type. The interface methods support both scalar and vector valued operands.
// The source operand interface provides an interface to access input values
// to instructions in a way that is agnostic about the underlying implementation
// of those values (eg., register, fifo, immediate, predicate, etc).
class SourceOperandInterface {
public:
// Methods for accessing the nth value element.
virtual bool AsBool(int index) = 0;
virtual int8_t AsInt8(int index) = 0;
virtual uint8_t AsUint8(int index) = 0;
virtual int16_t AsInt16(int index) = 0;
virtual uint16_t AsUint16(int) = 0;
virtual int32_t AsInt32(int index) = 0;
virtual uint32_t AsUint32(int index) = 0;
virtual int64_t AsInt64(int index) = 0;
virtual uint64_t AsUint64(int index) = 0;
// Return a pointer to the object instance that implements the state in
// question (or nullptr) if no such object "makes sense". This is used if
// the object requires additional manipulation - such as a fifo that needs
// to be pop'ed. If no such manipulation is required, nullptr should be
// returned.
virtual std::any GetObject() const = 0;
// Return the shape of the operand (the number of elements in each dimension).
// For instance {1} indicates a scalar quantity, whereas {128} indicates an
// 128 element vector quantity.
virtual std::vector<int> shape() const = 0;
// Return a string representation of the operand suitable for display in
// disassembly.
virtual std::string AsString() const = 0;
virtual ~SourceOperandInterface() = default;
};
The destination operand interface provides methods for allocating and handling
DataBuffer
instances (the internal datatype used to store register values). A
destination operand also has a latency associated with it, which is the number
of cycles to wait until the data buffer instance allocated by the instruction
semantic function is used to update the value of the target register. For
instance, the latency of an add
instruction may be 1, while for an mpy
instruction it may be 4. This is covered in more detail in the
tutorial on semantic functions.
// The destination operand interface is used by instruction semantic functions
// to get a writable DataBuffer associated with a piece of simulated state to
// which the new value can be written, and then used to update the value of
// the piece of state with a given latency.
class DestinationOperandInterface {
public:
virtual ~DestinationOperandInterface() = default;
// Allocates a data buffer with ownership, latency and delay line set up.
virtual DataBuffer *AllocateDataBuffer() = 0;
// Takes an existing data buffer, and initializes it for the destination
// as if AllocateDataBuffer had been called.
virtual void InitializeDataBuffer(DataBuffer *db) = 0;
// Allocates and initializes data buffer as if AllocateDataBuffer had been
// called, but also copies in the value from the current value of the
// destination.
virtual DataBuffer *CopyDataBuffer() = 0;
// Returns the latency associated with the destination operand.
virtual int latency() const = 0;
// Return a pointer to the object instance that implmements the state in
// question (or nullptr if no such object "makes sense").
virtual std::any GetObject() const = 0;
// Returns the order of the destination operand (size in each dimension).
virtual std::vector<int> shape() const = 0;
// Return a string representation of the operand suitable for display in
// disassembly.
virtual std::string AsString() const = 0;
};
ISA description
The ISA (Instruction Set Architecture) of a processor defines the abstract model by which software interacts with the hardware. It defines the set of instructions available, the data types, registers, and other machine state the instructions operate on, as well as their behavior (semantics). For the purposes of MPACT-Sim, the ISA does not include the actual encoding of the instructions. That is treated separately.
The processor ISA is expressed in a description file that describes the instruction set at an abstract, encoding agnostic level. The description file enumerates the set of available instructions. For each instruction it is mandatory to list its name, the number and names of its operands, and its binding to a C++ function/callable that implements its semantics. Additionally, one can specify a disassembly formatting string, and the instruction's usage of hardware resource names. The former is useful for producing a textual representation of the instruction for debug, tracing, or interactive use. The latter can be used to build in more cycle-accuracy in the simulation.
The ISA description file is parsed by the isa-parser which generates code for the representation-agnostic instruction decoder. This decoder is responsible for populating the fields of the instruction objects. The specific values, say the destination register number, are obtained from a format specific instruction decoder. One such decoder is the binary decoder, which is the focus of the next tutorial.
This tutorial covers how to write an ISA description file for a simple, scalar architecture. We will use a subset of the RiscV RV32I instruction set to illustrate this, and together with the other tutorials, build a simulator capable of simulating a "Hello World" program. For more details on the RiscV ISA see Risc-V Specifications.
Start by opening up the file:
riscv_isa_decoder/riscv32i.isa
The content of the file is broken down into multiple sections. First is the ISA declaration:
isa RiscV32I {
namespace mpact::sim::codelab;
slots { riscv32; }
}
This declares RiscV32I
to be the name of the ISA and the code generator will
create a class called RiscV32IEncodingBase
that defines the interface the
generated decoder will use to get opcode and operand information. The name of
this class is generated by converting the ISA name to Pascal-case, then
concatenating it with EncodingBase
. The declaration slots { riscv32; }
specifies that there is only a single instruction slot riscv32
in the RiscV32I
ISA (as opposed to multiple slots in a VLIW instruction), and that the only
valid instructions are those defined to execute in riscv32
.
// First disasm fragment is 15 char wide and left justified.
disasm widths = {-15};
This specifies that the first disassembly fragment of any dissasembly specification (see more below), will be left justified in a 15 character wide field. Any subsequent fragments will be appended to this field without any additional spacing.
Below this there are three slot declarations: riscv32i
, zicsr
and riscv32
.
Based on the isa
definition above, only instructions defined for the riscv32
slot will be part of the RiscV32I
isa. What are the other two slots for?
Slots can be used to factor instructions into separate groups, which then can be
combined into a single slot at the end. Notice the notation : riscv32i, zicsr
in the riscv32
slot declaration. This specifies that slot riscv32
inherits
all instructions defined in slots zicsr
and riscv32i
. The RiscV 32 bit ISA
consists of a base ISA called RV32I, to which a set of optional extensions may
be added. The slot mechanism allows the instructions in these extensions to be
specified separately and then combined as needed in the end to define the
overall ISA. In this case, instructions in the RiscV 'I' group are defined
separately from those in the 'zicsr' group. Additional groups could be defined
for 'M' (multiply/divide), 'F' (single-precision floating point), 'D'
(double-precision floating point), 'C' (compact 16-bit instructions) etc. as
needed for the desired final RiscV ISA.
// The RiscV 'I' instructions.
slot riscv32i {
...
}
// RiscV32 CSR manipulation instructions.
slot zicsr {
...
}
// The final instruction set combines riscv32i and zicsr.
slot riscv32 : riscv32i, zicsr {
...
}
The zicsr
and riscv32
slot definitions do not need to be changed. However
the focus on this tutorial is to add the necessary definitions to the riscv32i
slot. Let's take a closer look at what is currently defined in this slot:
// The RiscV 'I' instructions.
slot riscv32i {
// Include file that contains the declarations of the semantic functions for
// the 'I' instructions.
includes {
#include "learning/brain/research/mpact/sim/codelab/riscv_semantic_functions/solution/rv32i_instructions.h"
}
// These are all 32 bit instructions, so set default size to 4.
default size = 4;
// Model these with 0 latency to avoid buffering the result. Since RiscV
// instructions have sequential semantics this is fine.
default latency = 0;
// The opcodes.
opcodes {
fence{: imm12 : },
semfunc: "&RV32IFence"c
disasm: "fence";
ebreak{},
semfunc: "&RV32IEbreak",
disasm: "ebreak";
}
}
First, there is an includes {}
section that lists the header files that needs
to be included in the generated code when this slot is referenced, directly or
indirectly, in the final ISA. Include files can also be listed in a globally
scoped includes {}
section, in which case they are always included. This can
be handy if the same include file would otherwise have to be added to every slot
definition.
The default size
and default latency
declarations define that, unless
otherwise specified, the size of an instruction is 4, and that the latency of a
destination operand write is 0 cycles. Note, the size of the instruction
specified here, is the size of the program counter increment to compute the
address of the next sequential instruction to execute in the simulated
processor. This may or may not be the same as the size in bytes of the
instruction representation in the input executable file.
Central to the slot definition is the opcode section. As you can see, only two
opcodes (instructions) fence
and ebreak
have been defined so far in
riscv32i
. The fence
opcode is defined by specifying the name (fence
) and
the operand specification ({: imm12 : }
), followed by the optional disassembly
format ("fence"
), and the callable that is to be bound as the semantic
function ("&RV32IFence"
).
The instruction operands are specified as a triple, with each component
separated with a semicolon, predicate ':' source operand list ':'
destination operand list. The source and destination operand lists are comma
separated lists of operand names. As you can see, the instruction operands for
the fence
instruction contain, no predicate operands, only a single source
operand name imm12
, and no destination operands. The RiscV RV32I subset does
not support predicated execution, so the predicate operand will always be empty
in this tutorial.
The semantic function is specified as the string necessary to specify the C++
function or callable to use to call the semantic function. The signature of the
semantic function/callable is void(Instruction *)
.
The disassembly specification consists of a comma separated list of strings.
Typically only two strings are used, one for the opcode, and one for the
operands. When formatted (using the AsString()
call in Instruction), each
string is formatted within a field according to the disasm widths
specification described above.
The following exercises helps you add instructions to the riscv32i.isa
file
sufficient to simulate a "Hello World" program. For those in a hurry, the
solutions can be found in
riscv32i.isa
and
rv32i_instructions.h.
Perform Initial Build
If you haven't changed directory to riscv_isa_decoder
, do so now. Then
build the project as follows - this build should succeed.
$ cd riscv_isa_decoder
$ bazel build :all
Now change your directory back to the repository root, then let's take a look
at the sources that were generated. For that, change directory to
bazel-out/k8-fastbuild/bin/riscv_isa_decoder
(assuming you are on an x86
host - for other hosts, the k8-fastbuild will be another string).
$ cd ..
$ cd bazel-out/k8-fastbuild/bin/riscv_isa_decoder
In this directory there among othere files there will be the following generated C++ files:
riscv32i_decoder.h
riscv32i_decoder.cc
riscv32i_enums.h
riscv32i_enums.cc
Let's look at riscv32i_enums.h
by clicking on it in the browser. You should
see that it contains something like:
#ifndef RISCV32I_ENUMS_H
#define RISCV32I_ENUMS_H
namespace mpact {
namespace sim {
namespace codelab {
enum class SlotEnum {
kNone = 0,
kRiscv32,
};
enum class PredOpEnum {
kNone = 0,
kPastMaxValue = 1,
};
enum class SourceOpEnum {
kNone = 0,
kCsr = 1,
kImm12 = 2,
kRs1 = 3,
kPastMaxValue = 4,
};
enum class DestOpEnum {
kNone = 0,
kCsr = 1,
kRd = 2,
kPastMaxValue = 3,
};
enum class OpcodeEnum {
kNone = 0,
kCsrs = 1,
kCsrsNw = 2,
kCsrwNr = 3,
kEbreak = 4,
kFence = 5,
kPastMaxValue = 6
};
constexpr char kNoneName[] = "none";
constexpr char kCsrsName[] = "Csrs";
constexpr char kCsrsNwName[] = "CsrsNw";
constexpr char kCsrwNrName[] = "CsrwNr";
constexpr char kEbreakName[] = "Ebreak";
constexpr char kFenceName[] = "Fence";
extern const char *kOpcodeNames[static_cast<int>(
OpcodeEnum::kPastMaxValue)];
enum class SimpleResourceEnum {
kNone = 0,
kPastMaxValue = 1
};
enum class ComplexResourceEnum {
kNone = 0,
kPastMaxValue = 1
};
enum class AttributeEnum {
kPastMaxValue = 0
};
} // namespace codelab
} // namespace sim
} // namespace mpact
#endif // RISCV32I_ENUMS_H
As you can see, each slot, opcode and operand that was defined in the
riscv32i.isa
file is defined in one of the enumeration types. Additionally
there is an OpcodeNames
array that stores all the names of the opcodes (it is
defined in riscv32i_enums.cc
). The other files contain the generated decoder,
which will be covered more in another tutorial.
Bazel Build Rule
The ISA decoder target in Bazel is defined using a custom rule macro named
mpact_isa_decoder
, which is loaded from mpact/sim/decoder/mpact_sim_isa.bzl
in the mpact-sim
repository. For this tutorial the build target defined in
riscv_isa_decoder/BUILD
is:
mpact_isa_decoder(
name = "riscv32i_isa",
src = "riscv32i.isa",
includes = [],
isa_name = "RiscV32I",
deps = [
"//riscv_semantic_functions:riscv32i",
],
)
This rule calls the ISA parser tool and generator to generate the C++ code,
then compiles the generated into a library that other rules can depend on using
the label //riscv_isa_decoder:riscv32i_isa
. The includes
section is used
to specify additional .isa
files that the source file may include. The
isa_name
is used to specify which specific isa, required if more than one is
specified, in the source file for which to generate the decoder.
Add Register-Register ALU Instructions
Now it's time to add some new instructions to the riscv32i.isa
file. The first
group of instructions are register-register ALU instructions such as add
,
and
, etc. On RiscV32, these all use the R-type binary instruction format:
31..25 | 24..20 | 19..15 | 14..12 | 11..7 | 6..0 |
---|---|---|---|---|---|
7 | 5 | 5 | 3 | 5 | 7 |
func7 | rs2 | rs1 | func3 | rd | opcode |
While the .isa
file is used to generate a format agnostic decoder, it is still
useful to consider the binary format and its layout to guide the entries. As you
can see, there are three fields that are relevant to the decoder populating the
instruction objects: rs2
, rs1
, and rd
. At this point we will choose to use
these names for integer registers that are encoded the same way (bit sequences),
in the same instruction fields, in all instruction.
The instructions we are going to add are the following:
add
- integer add.and
- bitwise and.or
- bitwise or.sll
- shift left logical.sltu
- set less-than, unsigned.sub
- integer subtract.xor
- bitwise xor.
Each of these instructions will be added to the opcodes
section of the
riscv32i
slot definition. Recall that we have to specify the name, opcodes,
disassembly and the semantic function for each instruction. The name is easy,
let's just use the opcode names above. Also, they all use the same operands, so
we can use { : rs1, rs2 : rd}
for the operand specification. This means that
the register source operand specified by rs1 will have index 0 in the source
operand vector in the instruction object, the register source operand specified
by rs2 will have index 1, and the register destination operand specified by rd
will be the only element in the destination operand vector (at index 0).
Next is the semantic function specification. This is done using the keyword
semfunc
and a C++ string that specifies a callable that can be used to assign
to a std::function
. In this tutorial we will use functions, so the callable
string will be "&MyFunctionName"
. Using the naming scheme suggested by the
fence
instruction, these should be "&RV32IAdd"
, "&RV32IAnd"
, etc.
Last is the disassembly specification. It starts with the keyword disasm
and
is followed by a comma separated list of strings that specifies how the
instruction should be printed as a string. Using a %
sign in front of an
operand name indicates a string substitution using the string representation of
that operand. For the add
instruction, this would be: disasm: "add", "%rd,
%rs1,%rs2"
. This means that the entry for the add
instruction should look
like:
add{ : rs1, rs2 : rd},
semfunc: "&RV32IAdd",
disasm: "add", "%rd, %rs1, %rs2";
Go ahead and edit the riscv32i.isa
file and add all these instructions to the
.isa
description. If you need help (or want to check your work), the full
description file is
here.
Once the instructions are added to the riscv32i.isa
file it will be necessary
to add function declarations for each of the new semantic functions that were
referenced to the file rv32i_instructions.h
located in
`../semantic_functions/. Again, if you need help (or want to check your work),
the answer is
here.
Once this is all done, go ahead and change back into the riscv_isa_decoder
directory and rebuild. Feel free to examine the generated source files.
Add ALU Instructions with Immediates
The next set of instructions we will add are ALU instructions that use an immediate value instead of one of the registers. There are three groups of these instructions (based on the immediate field): the I-Type immediate instructions with a 12 bit signed immediate, the specialized I-Type immediate instructions for shifts, and the U-Type immediate, with a 20-bit unsigned immediate value. The formats are shown below:
The I-Type immediate format:
31..20 | 19..15 | 14..12 | 11..7 | 6..0 |
---|---|---|---|---|
12 | 5 | 3 | 5 | 7 |
imm12 | rs1 | func3 | rd | opcode |
The specialized I-Type immediate format:
31..25 | 24..20 | 19..15 | 14..12 | 11..7 | 6..0 |
---|---|---|---|---|---|
7 | 5 | 5 | 3 | 5 | 7 |
func7 | uimm5 | rs1 | func3 | rd | opcode |
The U-Type immediate format:
31..12 | 11..7 | 6..0 |
---|---|---|
20 | 5 | 7 |
uimm20 | rd | opcode |
As you can see, the operand names rs1
and rd
refer to the same bit fields as
previously, and are used to represent integer registers, so these names can be
retained. The immediate value fields are of different length and location, and
two (uimm5
and uimm20
) are unsigned, whereas imm12
is signed. Each of
these will use their own name.
The operands for the I-Type instructions should therefore be { : rs1, imm12 :rd
}
. For the specialized I-Type instructions it should be { : rs1, uimm5 : rd}
.
The U-Type instruction operand specification should be { : uimm20 : rd }
.
The I-Type instructions we need to add are:
addi
- Add immediate.andi
- Bitwise and with immediate.ori
- Bitwise or with immediate.xori
- Bitwise xor with immediate.
The specialized I-Type instructions we need to add are:
slli
- Shift left logical by immediate.srai
- Shift right arithmetic by immediate.srli
- Shift right logical by immediate.
The U-Type instructions we need to add are:
auipc
- Add upper immediate to pc.lui
- Load upper immediate.
The names to use for the opcodes follow naturally from the instruction names
above (no need to come up with new ones - they are all unique). When it comes to
specifying the semantic functions, recall that the instruction objects encode
interfaces to the source operands that are agnostic to the underlying operand
type. What this means is that for instructions that have the same operation, but
may differ in operand types, can share the same semantic function. For instance,
the addi
instruction performs the same operation as the add
instruction if
one ignores the operand type, so they can use the same semantic function
specification "&RV32IAdd"
. Similarly for andi
, ori
, xori
, and slli
.
The other instructions use new semantic functions, but they should be named
based on the operation, not operands, so for srai
use "&RV32ISra"
. The
U-Type instructions auipc
and lui
don't have register equivalents, so it ok
to use "&RV32IAuipc"
and "&RV32ILui"
.
The disassembly strings are very similar to those in the previous exercise, but
as you would expect, references to %rs2
are replaced with %imm12
, %uimm5
,
or %uimm20
, as appropriate.
Go ahead and make the changes and build. Check the generated output. Just as previously, you can check your work against riscv32i.isa and the rv32i_instructions.h.
Add Branch and Jump-And-Link Instructions
The branch and jump-and-link instructions we need to add both use a destination
operand that is only implied in the instruction itself, namely the next pc
value. At this stage we will treat this as a proper operand with the name
next_pc
. It will be further defined in a later tutorial.
Branch Instructions
The branches we are adding all use the B-Type encoding.
31 | 30..25 | 24..20 | 19..15 | 14..12 | 11..8 | 7 | 6..0 |
---|---|---|---|---|---|---|---|
1 | 6 | 5 | 5 | 3 | 4 | 1 | 7 |
imm | imm | rs2 | rs1 | func3 | imm | imm | opcode |
The different immediate fields are concatenated into a 12 bit signed immediate
value. Since the format is not truly relevant, we will call this immediate
bimm12
, for 12-bit branch immediate. The fragmentation will be addressed in
the next tutorial on creating the binary decoder. All the
branch instructions compare the integer registers specified by rs1 and rs2, if
the condition is true, the immediate value is added to the current pc value to
produce the address of the next instruction to be executed. The operands for the
branch instructions should therefore be { : rs1, rs2, bimm12 : next_pc }
.
The branch instructions we need to add, are:
beq
- Branch if equal.bge
- Branch if greater than or equal.bgeu
- Branch if greater than or equal unsigned.blt
- Branch if less than.bltu
- Branch if less than unsigned.bne
- Branch if not equal.
These opcode names are all unique, so they can be reused in the .isa
description. Of course, new semantic function names must be added, e.g.,
"&RV32IBeq"
, etc.
The disassembly specification is now a little trickier, since the address of the
instruction is used to compute the destination, without it actually being part
of the instruction operands. However, it is part of the information stored in
the instruction object, so it is available. The solution is to use the
expression syntax in the disassembly string. Instead of using '%' followed by
the operand name, you can type %(expression: print format). Only very simple
expressions are supported, but address plus offset is one of them, with the @
symbol used for the current instruction address. The print format is similar to
C style printf formats, but without the leading %
. The disassembly format for
the beq
instruction then becomes:
disasm: "beq", "%rs1, %rs2, %(@+bimm12:08x)"
Jump-And-Link Instructions
Only two jump-and-link instructions need to be added, jal
(jump-and-link) and
jalr
(indirect jump-and-link).
The jal
instruction uses the J-Type encoding:
31 | 30..21 | 20 | 19..12 | 11..7 | 6..0 |
---|---|---|---|---|---|
1 | 10 | 1 | 8 | 5 | 7 |
imm | imm | imm | imm | rd | opcode |
Just as for the branch instructions, the 20-bit immediate is fragmented across
multiple fields, so we will name it jimm20
. The fragmentation is not important
at this time, but will be addressed in the next
tutorial on creating the binary decoder. The operand
specification then becomes { : jimm20 : next_pc, rd }
. Note that there are two
destination operands, the next pc value and the link register specified in the
instruction.
Similar to the branch instructions above, the disassembly format becomes:
disasm: "jal", "%rd, %(@+jimm20:08x)"
The indirect jump-and-link uses the I-Type format with the 12-bit immediate. It
adds the sign-extended immediate value to the integer register specified by
rs1
to produce the target instruction address. The link register is the
integer register specified by rd
.
31..20 | 19..15 | 14..12 | 11..7 | 6..0 |
---|---|---|---|---|
12 | 5 | 3 | 5 | 7 |
imm12 | rs1 | func3 | rd | opcode |
If you have seen the pattern you would now deduce that the operand specification
for jalr
should be { : rs1, imm12 : next_pc, rd }
, and the disassembly
specification:
disasm: "jalr", "%rd, %rs1, %imm12"
Go ahead and make the changes and then build. Check the generated output. Just as previously, you can check your work against riscv32i.isa and rv32i_instructions.h.
Add Store Instructions
The store instructions are very simple. They all use the S-Type format:
31..25 | 24..20 | 19..15 | 14..12 | 11..7 | 6..0 |
---|---|---|---|---|---|
7 | 5 | 5 | 3 | 5 | 7 |
imm | rs2 | rs1 | func3 | imm | opcode |
As you can see, this is yet another case of a fragmented 12-bit immediate, let's
call it simm12
. The store instructions all store the value of the integer
register specified by rs2 to the effective address in memory obtained by adding
the value of the integer register specified by rs1 to the sign-extended value of
the 12-bit immediate. The operand format should be { : rs1, simm12, rs2 }
for
all the store instructions.
The store instructions that need to be implemented are:
sb
- Store byte.sh
- Store half word.sw
- Store word.
The disassembly specification for sb
is as you would expect:
disasm: "sb", "%rs2, %simm12(%rs1)"
The semantic function specifications are also what you'd expect: "&RV32ISb"
,
etc.
Go ahead and make the changes and then build. Check the generated output. Just as previously, you can check your work against riscv32i.isa and rv32i_instructions.h.
Add Load Instructions
Load instructions are modeled a little differently than other instructions in the simulator. In order to be able to model cases where the load latency is uncertain, load instructions are divided into two separate actions: 1) effective address computation and memory access, and 2) result write-back. In the simulator this is done by splitting the semantic action of the load into two separate instructions, the main instruction and a child instruction. Moreover, when we specify operands, we need to specify them for both the main and the child instruction. This is done by treating the operand specification as a list of triplets. The syntax is:
{(predicate : sources : destinations),
(predicate : sources : destinations), ... }
The load instructions all use the I-Type format just as many of the previous instructions:
31..20 | 19..15 | 14..12 | 11..7 | 6..0 |
---|---|---|---|---|
12 | 5 | 3 | 5 | 7 |
imm12 | rs1 | func3 | rd | opcode |
The operand specification splits the operands necessary to compute the address
and initiate the memory access from the register destination for the load data:
{( : rs1, imm12 : ), ( : : rd) }
.
Since the semantic action is split over two instructions, the semantic functions
similarly need to specify two callables. For lw
(load word), this would be
written:
semfunc: "&RV32ILw", "&RV32ILwChild"
The disassembly specification is more conventional. No mention is made of the
child instruction. For lw
, it should be:
disasm: "lw", "%rd, %imm12(%rs1)"
The load instructions that need to be implemented are:
lb
- Load byte.lbu
- Load byte unsigned.lh
- Load halfword.lhu
- Load halfword unsigned.lw
- Load word.
Go ahead and make the changes and then build. Check the generated output. Just as previously, you can check your work against riscv32i.isa and rv32i_instructions.h.
Thank you for getting this far. We hope this has been useful.