RiscV ISA decoder

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:

  1. Instruction address, simulated instruction size, i.e., size in .text.
  2. Instruction opcode.
  3. Predicate operand interface pointer (if applicable).
  4. Vector of source operand interface pointers.
  5. Vector of destination operand interface pointers.
  6. Semantic function callable.
  7. Pointer to architectural state object.
  8. Pointer to context object.
  9. Pointer to child and next Instruction instances.
  10. 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.


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)"

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.