Decodificador integrado de RiscV

Los objetivos de este instructivo son los siguientes:

  • Aprende cómo se combinan la ISA generada y los decodificadores binarios.
  • Escribe el código C++ necesario para crear un decodificador de instrucciones completo para RiscV RV32I que combine la ISA y los decodificadores binarios.

Comprende el decodificador de instrucciones

El decodificador de instrucciones es responsable de, dada una dirección de instrucción, leer la palabra de instrucción de la memoria y mostrar una instancia completamente inicializada de Instruction que representa esa instrucción.

El decodificador de nivel superior implementa el generic::DecoderInterface que se muestra a continuación:

// This is the simulator's interface to the instruction decoder.
class DecoderInterface {
 public:
  // Return a decoded instruction for the given address. If there are errors
  // in the instruciton decoding, the decoder should still produce an
  // instruction that can be executed, but its semantic action function should
  // set an error condition in the simulation when executed.
  virtual Instruction *DecodeInstruction(uint64_t address) = 0;
  virtual ~DecoderInterface() = default;
};

Como puedes ver, solo hay un método que se debe implementar: cpp virtual Instruction *DecodeInstruction(uint64_t address);.

Ahora, veamos qué se proporciona y qué necesita el código generado.

Primero, considera la clase de nivel superior RiscV32IInstructionSet en el archivo riscv32i_decoder.h, que se generó al final del instructivo sobre el decodificador de ISA. Para volver a ver el contenido, navega al directorio de la solución de ese instructivo y vuelve a compilar todo.

$ cd riscv_isa_decoder/solution
$ bazel build :all
...<snip>...

Ahora, vuelve a cambiar el directorio a la raíz del repositorio y, luego, veamos las fuentes que se generaron. Para ello, cambia el directorio a bazel-out/k8-fastbuild/bin/riscv_isa_decoder (suponiendo que estás en un host x86; para otros hosts, k8-fastbuild será otra cadena).

$ cd ../..
$ cd bazel-out/k8-fastbuild/bin/riscv_isa_decoder

Verás los cuatro archivos de origen que contienen el código C++ generado en la lista:

  • riscv32i_decoder.h
  • riscv32i_decoder.cc
  • riscv32i_enums.h
  • riscv32i_enums.cc

Abre el primer archivo riscv32i_decoder.h. Hay tres clases que debemos analizar:

  • RiscV32IEncodingBase
  • RiscV32IInstructionSetFactory
  • RiscV32IInstructionSet

Ten en cuenta los nombres de las clases. Todas las clases se nombran según la versión en mayúsculas y minúsculas del nombre que se proporciona en la declaración "isa" de ese archivo: isa RiscV32I { ... }

Comencemos con la clase RiscVIInstructionSet. Se muestra a continuación:

class RiscV32IInstructionSet {
 public:
  RiscV32IInstructionSet(ArchState *arch_state,
                         RiscV32IInstructionSetFactory *factory);
  Instruction *Decode(uint64 address, RiscV32IEncodingBase *encoding);

 private:
  std::unique_ptr<Riscv32Slot> riscv32_decoder_;
  ArchState *arch_state_;
};

No hay métodos virtuales en esta clase, por lo que es una clase independiente, pero ten en cuenta dos aspectos. Primero, el constructor toma un puntero a una instancia de la clase RiscV32IInstructionSetFactory. Esta es una clase que usa el decodificador generado para crear una instancia de la clase RiscV32Slot, que se usa para decodificar todas las instrucciones definidas para slot RiscV32, como se define en el archivo riscv32i.isa. En segundo lugar, el método Decode toma un parámetro adicional de puntero de tipo a RiscV32IEncodingBase, que es una clase que proporcionará la interfaz entre el decodificador de ISA generado en el primer instructivo y el decodificador binario generado en el segundo lab.

La clase RiscV32IInstructionSetFactory es una clase abstracta de la que debemos derivar nuestra propia implementación para el decodificador completo. En la mayoría de los casos, esta clase es trivial: solo debes proporcionar un método para llamar al constructor para cada clase de ranura definida en nuestro archivo .isa. En nuestro caso, es muy simple, ya que solo hay una clase de este tipo: Riscv32Slot (caso de Pascal del nombre riscv32 concatenado con Slot). El método no se genera por ti, ya que hay algunos casos de uso avanzados en los que podría ser útil derivar una subclase del espacio y llamar a su constructor en su lugar.

Analizaremos la clase final RiscV32IEncodingBase más adelante en este tutorial, ya que ese es el tema de otro ejercicio.


Definir el decodificador de instrucciones de nivel superior

Define la clase de fábrica

Si volviste a compilar el proyecto del primer instructivo, asegúrate de volver al directorio riscv_full_decoder.

Abre el archivo riscv32_decoder.h. Ya se agregaron todos los archivos de inclusión necesarios y se configuraron los espacios de nombres.

Después del comentario marcado como //Exercise 1 - step 1, define la clase RiscV32IsaFactory que hereda de RiscV32IInstructionSetFactory.

class RiscV32IsaFactory : public RiscV32InstructionSetFactory {};

A continuación, define la anulación para CreateRiscv32Slot. Como no usamos ninguna clase derivada de Riscv32Slot, solo asignamos una instancia nueva con std::make_unique.

std::unique_ptr<Riscv32Slot> CreateRiscv32Slot(ArchState *) override {
  return std::make_unique<Riscv32Slot>(state);
}

Si necesitas ayuda (o quieres revisar tu trabajo), la respuesta completa está aquí.

Define la clase de decodificador

Declaraciones de constructores, destructores y métodos

A continuación, es hora de definir la clase del decodificador. En el mismo archivo que antes, ve a la declaración de RiscV32Decoder. Expande la declaración en una definición de clase en la que RiscV32Decoder hereda de generic::DecoderInterface.

class RiscV32Decoder : public generic::DecoderInterface {
  public:
};

A continuación, antes de escribir el constructor, veamos rápidamente el código generado en nuestro segundo instructivo sobre el decodificador binario. Además de todas las funciones Extract, existe la función DecodeRiscVInst32:

OpcodeEnum DecodeRiscVInst32(uint32_t inst_word);

Esta función toma la palabra de instrucción que se debe decodificar y muestra el código de operación que coincide con esa instrucción. Por otro lado, la clase DecodeInterface que implementa RiscV32Decoder solo pasa una dirección. Por lo tanto, la clase RiscV32Decoder debe poder acceder a la memoria para leer la palabra de instrucción que se pasará a DecodeRiscVInst32(). En este proyecto, la forma de acceder a la memoria es a través de una interfaz de memoria simple definida en .../mpact/sim/util/memory, que también se llama util::MemoryInterface, como se muestra a continuación:

  // Load data from address into the DataBuffer, then schedule the Instruction
  // inst (if not nullptr) to be executed (using the function delay line) with
  // context. The size of the data access is based on size of the data buffer.
  virtual void Load(uint64_t address, DataBuffer *db, Instruction *inst,
                    ReferenceCount *context) = 0;

Además, debemos poder pasar una instancia de clase state a los constructores de las otras clases de decodificador. La clase de estado adecuada es la clase riscv::RiscVState, que se deriva de generic::ArchState, con funcionalidad agregada para RiscV. Esto significa que debemos declarar el constructor para que pueda tomar un puntero a state y memory:

RiscV32Decoder(riscv::RiscVState *state, util::MemoryInterface *memory);

Borra el constructor predeterminado y anula el destructor:

RiscV32Decoder() = delete;
~RiscV32Decoder() override;

Luego, declara el método DecodeInstruction que debemos anular desde generic::DecoderInterface.

generic::Instruction *DecodeInstruction(uint64_t address) override;

Si necesitas ayuda (o quieres revisar tu trabajo), la respuesta completa está aquí.


Definiciones de los miembros de datos

La clase RiscV32Decoder necesitará miembros de datos privados para almacenar los parámetros del constructor y un puntero a la clase de fábrica.

 private:
  riscv::RiscVState *state_;
  util::MemoryInterface *memory_;

También necesita un puntero a la clase de codificación que se deriva de RiscV32IEncodingBase, llamemos a eso RiscV32IEncoding (lo implementaremos en el ejercicio 2). Además, necesita un puntero a una instancia de RiscV32IInstructionSet, así que agrega lo siguiente:

  RiscV32IsaFactory *riscv_isa_factory_;
  RiscV32IEncoding *riscv_encoding_;
  RiscV32IInstructionSet *riscv_isa_;

Por último, debemos definir un miembro de datos para usar con nuestra interfaz de memoria:

  generic::DataBuffer *inst_db_;

Si necesitas ayuda (o quieres revisar tu trabajo), la respuesta completa está aquí.

Define los métodos de la clase Decoder

A continuación, es hora de implementar el constructor, el destructor y el método DecodeInstruction. Abre el archivo riscv32_decoder.cc. Los métodos vacíos ya están en el archivo, al igual que las declaraciones de espacio de nombres y un par de declaraciones de using.

Definición del constructor

El constructor solo necesita inicializar los miembros de datos. Primero, inicializa state_ y memory_:

RiscV32Decoder::RiscV32Decoder(riscv::RiscVState *state,
                               util::MemoryInterface *memory)
    : state_(state), memory_(memory) {

A continuación, asigna instancias de cada una de las clases relacionadas con el decodificador y pasa los parámetros adecuados.

  // Allocate the isa factory class, the top level isa decoder instance, and
  // the encoding parser.
  riscv_isa_factory_ = new RiscV32IsaFactory();
  riscv_isa_ = new RiscV32IInstructionSet(state, riscv_isa_factory_);
  riscv_encoding_ = new RiscV32IEncoding(state);

Por último, asigna la instancia de DataBuffer. Se asigna con una fábrica a la que se puede acceder a través del miembro state_. Asignamos un búfer de datos con el tamaño suficiente para almacenar un solo uint32_t, ya que ese es el tamaño de la palabra de instrucción.

  inst_db_ = state_->db_factory()->Allocate<uint32_t>(1);

Definición del destructor

El destructor es simple, solo libera los objetos que asignamos en el constructor, pero con un giro. La instancia de búfer de datos se cuenta como referencia, por lo que, en lugar de llamar a delete en ese puntero, aplicamos DecRef() al objeto:

RiscV32Decoder::~RiscV32Decoder() {
  inst_db_->DecRef();
  delete riscv_isa_;
  delete riscv_isa_factory_;
  delete riscv_encoding_;
}

Definición del método

En nuestro caso, la implementación de este método es bastante simple. Suponemos que la dirección está alineada correctamente y que no se requiere ninguna verificación de errores adicional.

Primero, la palabra de instrucción se debe recuperar de la memoria a través de la interfaz de memoria y la instancia de DataBuffer.

  memory_->Load(address, inst_db_, nullptr, nullptr);
  uint32_t iword = inst_db_->Get<uint32_t>(0);

A continuación, llamamos a la instancia de RiscVIEncoding para analizar la palabra de instrucción, lo que debe hacerse antes de llamar al decodificador ISA. Recuerda que el decodificador ISA llama directamente a la instancia de RiscVIEncoding para obtener el código de operación y los operandos que se especifican en la palabra de la instrucción. Aún no implementamos esa clase, pero usemos void ParseInstruction(uint32_t) como ese método.

  riscv_encoding_->ParseInstruction(iword);

Por último, llamamos al decodificador ISA, pasando la dirección y la clase de codificación.

  auto *instruction = riscv_isa_->Decode(address, riscv_encoding_);
  return instruction;

Si necesitas ayuda (o quieres revisar tu trabajo), la respuesta completa está aquí.


La clase de codificación

La clase de codificación implementa una interfaz que usa la clase de decodificador para obtener el código de operación de la instrucción, sus operandos de origen y destino, y los operandos de recursos. Todos estos objetos dependen de la información del decodificador de formato binario, como el código de operación, los valores de campos específicos en la palabra de instrucción, etcétera. Esto se separa de la clase del decodificador para que no sea dependiente de la codificación y permita admitir varios esquemas de codificación diferentes en el futuro.

RiscV32IEncodingBase es una clase abstracta. A continuación, se muestra el conjunto de métodos que debemos implementar en nuestra clase derivada.

class RiscV32IEncodingBase {
 public:
  virtual ~RiscV32IEncodingBase() = default;

  virtual OpcodeEnum GetOpcode(SlotEnum slot, int entry) = 0;

  virtual ResourceOperandInterface *
              GetSimpleResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                       SimpleResourceVector &resource_vec, int end) = 0;

  virtual ResourceOperandInterface *
              GetComplexResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                        ComplexResourceEnum resource_op,
                                        int begin, int end) = 0;

  virtual PredicateOperandInterface *
              GetPredicate(SlotEnum slot, int entry, OpcodeEnum opcode,
                           PredOpEnum pred_op) = 0;

  virtual SourceOperandInterface *
              GetSource(SlotEnum slot, int entry, OpcodeEnum opcode,
                        SourceOpEnum source_op, int source_no) = 0;

  virtual DestinationOperandInterface *
              GetDestination(SlotEnum slot, int entry, OpcodeEnum opcode,
                             DestOpEnum dest_op, int dest_no, int latency) = 0;

  virtual int GetLatency(SlotEnum slot, int entry, OpcodeEnum opcode,
                         DestOpEnum dest_op, int dest_no) = 0;
};

A primera vista, parece un poco complicado, en particular con la cantidad de parámetros, pero para una arquitectura simple como RiscV, en realidad ignoramos la mayoría de los parámetros, ya que sus valores se inducirán.

Analicemos cada uno de los métodos.

OpcodeEnum GetOpcode(SlotEnum slot, int entry);

El método GetOpcode muestra el miembro OpcodeEnum para la instrucción actual, lo que identifica el código de operación de la instrucción. La clase OpcodeEnum se define en el archivo de decodificador de ISA generado riscv32i_enums.h. El método toma dos parámetros, que se pueden ignorar para nuestros fines. El primero de estos es el tipo de ranura (una clase de enumeración que también se define en riscv32i_enums.h), que, como RiscV solo tiene una ranura, solo tiene un valor posible: SlotEnum::kRiscv32. El segundo es el número de instancia de la ranura (en caso de que haya varias instancias de la ranura, lo que puede ocurrir en algunas arquitecturas de VLIW).

ResourceOperandInterface *
    GetSimpleResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                     SimpleResourceVector &resource_vec, int end)

ResourceOperandInterface *
    GetComplexResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                      ComplexResourceEnum resource_op,
                                      int begin, int end);

Los siguientes dos métodos se usan para modelar recursos de hardware en el procesador con el fin de mejorar la precisión del ciclo. En nuestros ejercicios del instructivo, no los usaremos, por lo que, en la implementación, se reemplazarán por stubs y mostrarán nullptr.

PredicateOperandInterface *
            GetPredicate(SlotEnum slot, int entry, OpcodeEnum opcode,
                         PredOpEnum pred_op);

SourceOperandInterface *
            GetSource(SlotEnum slot, int entry, OpcodeEnum opcode,
                      SourceOpEnum source_op, int source_no);

DestinationOperandInterface *
            GetDestination(SlotEnum slot, int entry, OpcodeEnum opcode,
                           DestOpEnum dest_op, int dest_no, int latency);

Estos tres métodos muestran punteros a objetos operandos que se usan dentro de las funciones semánticas de la instrucción para acceder al valor de cualquier operando del predicado de la instrucción, a cada uno de los operandos de origen de la instrucción y escribir valores nuevos en los operandos de destino de la instrucción. Dado que RiscV no usa predicados de instrucción, ese método solo debe mostrar nullptr.

El patrón de parámetros es similar en estas funciones. Primero, al igual que con GetOpcode, se pasan el espacio y la entrada. Luego, el código de operación para la instrucción para la que se debe crear el operando. Esto solo se usa si los diferentes códigos de operación deben devolver objetos de operandos diferentes para los mismos tipos de operandos, lo que no es el caso de este simulador de RiscV.

A continuación, se muestra la entrada de enumeración de operandos Predicate, Source y Destination, que identifica el operando que se debe crear. Provienen de las tres OpEnums de riscv32i_enums.h, como se muestra a continuación:

  enum class PredOpEnum {
    kNone = 0,
    kPastMaxValue = 1,
  };

  enum class SourceOpEnum {
    kNone = 0,
    kBimm12 = 1,
    kCsr = 2,
    kImm12 = 3,
    kJimm20 = 4,
    kRs1 = 5,
    kRs2 = 6,
    kSimm12 = 7,
    kUimm20 = 8,
    kUimm5 = 9,
    kPastMaxValue = 10,
  };

  enum class DestOpEnum {
    kNone = 0,
    kCsr = 1,
    kNextPc = 2,
    kRd = 3,
    kPastMaxValue = 4,
  };

Si vuelves a mirar el archivo riscv32.isa, notarás que corresponden a los conjuntos de nombres de operandos de origen y destino que se usan en la declaración de cada instrucción. El uso de diferentes nombres de operandos para operandos que representan diferentes campos de bits y tipos de operandos facilita la escritura de la clase de codificación, ya que el miembro de enum determina de forma única el tipo de operando exacto que se mostrará, y no es necesario tener en cuenta los valores de los parámetros de ranura, entrada o opcode.

Por último, para los operandos de origen y destino, se pasa la posición ordinal del operando (una vez más, podemos ignorar esto) y, para el operando de destino, la latencia (en ciclos) que transcurre entre el momento en que se emite la instrucción y el resultado de destino está disponible para las instrucciones posteriores. En nuestro simulador, esta latencia será de 0, lo que significa que la instrucción escribirá el resultado inmediatamente en el registro.

int GetLatency(SlotEnum slot, int entry, OpcodeEnum opcode,
                         DestOpEnum dest_op, int dest_no);

La función final se usa para obtener la latencia de un operando de destino en particular si se especificó como * en el archivo .isa. Esto no es común y no se usa para este simulador de RiscV, por lo que nuestra implementación de esta función solo mostrará 0.


Define la clase de codificación

Archivo de encabezado (.h)

Métodos

Abre el archivo riscv32i_encoding.h. Ya se agregaron todos los archivos de inclusión necesarios y se configuraron los espacios de nombres. Toda la adición de código se realiza después del comentario // Exercise 2..

Comencemos por definir una clase RiscV32IEncoding que herede de la interfaz generada.

class RiscV32IEncoding : public RiscV32IEncodingBase {
 public:

};

A continuación, el constructor debe tomar un puntero a la instancia de estado, en este caso, un puntero a riscv::RiscVState. Se debería usar el destructor predeterminado.

explicit RiscV32IEncoding(riscv::RiscVState *state);
~RiscV32IEncoding() override = default;

Antes de agregar todos los métodos de la interfaz, agreguemos el método que llama RiscV32Decoder para analizar la instrucción:

void ParseInstruction(uint32_t inst_word);

A continuación, agreguemos aquellos métodos que tienen anulaciones triviales y, al mismo tiempo, quitemos los nombres de los parámetros que no se usan:

// Trivial overrides.
ResourceOperandInterface *GetSimpleResourceOperand(SlotEnum, int, OpcodeEnum,
                                                   SimpleResourceVector &,
                                                   int) override {
  return nullptr;
}

ResourceOperandInterface *GetComplexResourceOperand(SlotEnum, int, OpcodeEnum,
                                                    ComplexResourceEnum ,
                                                    int, int) override {
  return nullptr;
}

PredicateOperandInterface *GetPredicate(SlotEnum, int, OpcodeEnum,
                                        PredOpEnum) override {
  return nullptr;
}

int GetLatency(SlotEnum, int, OpcodeEnum, DestOpEnum, int) override { return 0; }

Por último, agrega las anulaciones de métodos restantes de la interfaz pública, pero con las implementaciones aplazadas al archivo .cc.


OpcodeEnum GetOpcode(SlotEnum, int) override;

SourceOperandInterface *GetSource(SlotEnum , int, OpcodeEnum,
                                  SourceOpEnum source_op, int) override;

DestinationOperandInterface *GetDestination(SlotEnum, int, OpcodeEnum,
                                            DestOpEnum dest_op, int,
                                            int latency) override;

Para simplificar la implementación de cada uno de los métodos get del operando, crearemos dos arrays de callables (objetos de función) indexadas por el valor numérico de los miembros SourceOpEnum y DestOpEnum, respectivamente. De esta manera, los cuerpos de estos dos métodos se reducen a llamar al objeto de función para el valor de enum que se pasa y mostrar su valor devuelto.

Para organizar la inicialización de estos dos arrays, definimos dos métodos privados a los que se llamará desde el constructor de la siguiente manera:

 private:
  void InitializeSourceOperandGetters();
  void InitializeDestinationOperandGetters();

Miembros de datos

Los miembros de datos obligatorios son los siguientes:

  • state_ para contener el valor riscv::RiscVState *
  • inst_word_ de tipo uint32_t que contiene el valor de la palabra de instrucción actual.
  • opcode_ para contener el código de operación de la instrucción actual que actualiza el método ParseInstruction. Tiene el tipo OpcodeEnum.
  • source_op_getters_ es un array para almacenar los elementos que se pueden llamar que se usan para obtener objetos de operandos de origen. El tipo de los elementos del array es absl::AnyInvocable<SourceOperandInterface *>()>.
  • dest_op_getters_: Es un array para almacenar las llamadas que se usan para obtener objetos de operando de destino. El tipo de los elementos del array es absl::AnyInvocable<DestinationOperandInterface *>()>.
  • xreg_alias es un array de nombres de ABI de registro de números enteros de RiscV, p. ej., “zero” y “ra” en lugar de “x0” y “x1”.

  riscv::RiscVState *state_;
  uint32_t inst_word_;
  OpcodeEnum opcode_;

  absl::AnyInvocable<SourceOperandInterface *()>
      source_op_getters_[static_cast<int>(SourceOpEnum::kPastMaxValue)];
  absl::AnyInvocable<DestinationOperandInterface *(int)>
      dest_op_getters_[static_cast<int>(DestOpEnum::kPastMaxValue)];

  const std::string xreg_alias_[32] = {
      "zero", "ra", "sp", "gp", "tp",  "t0",  "t1", "t2", "s0", "s1", "a0",
      "a1",   "a2", "a3", "a4", "a5",  "a6",  "a7", "s2", "s3", "s4", "s5",
      "s6",   "s7", "s8", "s9", "s10", "s11", "t3", "t4", "t5", "t6"};

Si necesitas ayuda (o quieres revisar tu trabajo), la respuesta completa está aquí.

Archivo de origen (.cc).

Abre el archivo riscv32i_encoding.cc. Ya se agregaron todos los archivos de inclusión necesarios y se configuraron los espacios de nombres. Toda la adición de código se realiza después del comentario // Exercise 2.

Funciones auxiliares

Comenzaremos por escribir un par de funciones auxiliares que usaremos para crear operadores de registro de origen y destino. Se crearán plantillas en el tipo de registro y llamarán al objeto RiscVState para obtener un identificador del objeto de registro y, luego, llamarán a un método de fábrica de operandos en el objeto de registro.

Comencemos con los ayudantes de operando de destino:

template <typename RegType>
inline DestinationOperandInterface *GetRegisterDestinationOp(
    RiscVState *state, const std::string &name, int latency) {
  auto *reg = state->GetRegister<RegType>(name).first;
  return reg->CreateDestinationOperand(latency);
}

template <typename RegType>
inline DestinationOperandInterface *GetRegisterDestinationOp(
    RiscVState *state, const std::string &name, int latency,
    const std::string &op_name) {
  auto *reg = state->GetRegister<RegType>(name).first;
  return reg->CreateDestinationOperand(latency, op_name);
}

Como puedes ver, hay dos funciones auxiliares. El segundo toma un parámetro adicional op_name que permite que el operando tenga un nombre o una representación de cadena diferentes al registro subyacente.

De manera similar para los asistentes del operando de origen:

template <typename RegType>
inline SourceOperandInterface *GetRegisterSourceOp(RiscVState *state,
                                                   const std::string &reg_name) {
  auto *reg = state->GetRegister<RegType>(reg_name).first;
  auto *op = reg->CreateSourceOperand();
  return op;
}

template <typename RegType>
inline SourceOperandInterface *GetRegisterSourceOp(RiscVState *state,
                                                   const std::string &reg_name,
                                                   const std::string &op_name) {
  auto *reg = state->GetRegister<RegType>(reg_name).first;
  auto *op = reg->CreateSourceOperand(op_name);
  return op;
}

Funciones de constructor y de interfaz

El constructor y las funciones de la interfaz son muy simples. El constructor solo llama a los dos métodos de inicialización para inicializar los arrays callables para los getters de operandos.

RiscV32IEncoding::RiscV32IEncoding(RiscVState *state) : state_(state) {
  InitializeSourceOperandGetters();
  InitializeDestinationOperandGetters();
}

ParseInstruction almacena la palabra de instrucción y, luego, la operación de código que obtiene de la llamada al código generado por el decodificador binario.

// Parse the instruction word to determine the opcode.
void RiscV32IEncoding::ParseInstruction(uint32_t inst_word) {
  inst_word_ = inst_word;
  opcode_ = mpact::sim::codelab::DecodeRiscVInst32(inst_word_);
}

Por último, los get de operandos devuelven el valor de la función get a la que llama según la búsqueda de array con el valor de enum del operando de destino o fuente.


DestinationOperandInterface *RiscV32IEncoding::GetDestination(
    SlotEnum, int, OpcodeEnum, DestOpEnum dest_op, int, int latency) {
  return dest_op_getters_[static_cast<int>(dest_op)](latency);
}

SourceOperandInterface *RiscV32IEncoding::GetSource(SlotEnum, int, OpcodeEnum,
                                                    SourceOpEnum source_op, int) {
  return source_op_getters_[static_cast<int>(source_op)]();
}

Métodos de inicialización de arrays

Como puedes imaginar, la mayor parte del trabajo consiste en inicializar los arrays de obtención, pero no te preocupes, se hace con un patrón fácil y repetitivo. Comencemos primero con InitializeDestinationOpGetters(), ya que solo hay un par de operandos de destino.

Recupera la clase DestOpEnum generada desde riscv32i_enums.h:

  enum class DestOpEnum {
    kNone = 0,
    kCsr = 1,
    kNextPc = 2,
    kRd = 3,
    kPastMaxValue = 4,
  };

Para dest_op_getters_, debemos inicializar 4 entradas, una para kNone, kCsr, kNextPc y kRd. Para mayor comodidad, cada entrada se inicializa con una lambda, aunque también puedes usar cualquier otra forma de elemento que se pueda llamar. La firma de la lambda es void(int latency).

Hasta ahora, no hemos hablado mucho sobre los diferentes tipos de operandos de destino que se definen en MPACT-Sim. Para este ejercicio, solo usaremos dos tipos: generic::RegisterDestinationOperand definido en register.h y generic::DevNullOperand definido en devnull_operand.h. Los detalles de estos operandos no son realmente importantes en este momento, excepto que el primero se usa para escribir en los registros y el segundo ignora todas las operaciones de escritura.

La primera entrada para kNone es trivial: solo muestra un nullptr y, de manera opcional, registra un error.

void RiscV32IEncoding::InitializeDestinationOperandGetters() {
  // Destination operand getters.
  dest_op_getters_[static_cast<int>(DestOpEnum::kNone)] = [](int) {
    return nullptr;
  };

A continuación, kCsr. Aquí vamos a hacer trampa un poco. El programa “hello world” no se basa en ninguna actualización real de CSR, pero hay código estándar que ejecuta instrucciones de CSR. La solución es simplemente crear un simulador con un registro normal llamado “CSR” y canalizar todas esas operaciones de escritura.

  dest_op_getters_[static_cast<int>(DestOpEnum::kCsr)] = [this](int latency) {
    return GetRegisterDestinationOp<RV32Register>(state_, "CSR", latency);
  };

El siguiente es kNextPc, que hace referencia al registro "pc". Se usa como destino para todas las instrucciones de rama y salto. El nombre se define en RiscVState como kPcName.

  dest_op_getters_[static_cast<int>(DestOpEnum::kNextPc)] = [this](int latency) {
    return GetRegisterDestinationOp<RV32Register>(state_, RiscVState::kPcName, latency);
  }

Por último, está el operando de destino kRd. En riscv32i.isa, el operando rd solo se usa para hacer referencia al registro de números enteros codificado en el campo “rd” de la palabra de instrucción, por lo que no hay ambigüedad a la que se refiere. Solo hay una complicación. El registro x0 (nombre de ABI zero) está conectado a 0, por lo que, para ese registro, usamos DevNullOperand.

Por lo tanto, en este método get, primero extraemos el valor del campo rd con el método Extract generado a partir del archivo .bin_fmt. Si el valor es 0, se muestra un operando "DevNull". De lo contrario, se muestra el operando de registro correcto y se debe usar el alias de registro correspondiente como nombre del operando.

  dest_op_getters_[static_cast<int>(DestOpEnum::kRd)] = [this](int latency) {
    // First extract register number from rd field.
    int num = inst32_format::ExtractRd(inst_word_);
    // For register x0, return the DevNull operand.
    if (num == 0) return new DevNullOperand<uint32_t>(state, {1});
    // Return the proper register operand.
    return GetRegisterDestinationOp<RV32Register>(
      state_, absl::StrCat(RiscVState::kXRegPrefix, num), latency,
      xreg_alias_[num]);
    )
  }
}

Ahora, veamos el método InitializeSourceOperandGetters(), en el que el patrón es muy similar, pero los detalles difieren un poco.

Primero, veamos el SourceOpEnum que se generó a partir de riscv32i.isa en el primer instructivo:

  enum class SourceOpEnum {
    kNone = 0,
    kBimm12 = 1,
    kCsr = 2,
    kImm12 = 3,
    kJimm20 = 4,
    kRs1 = 5,
    kRs2 = 6,
    kSimm12 = 7,
    kUimm20 = 8,
    kUimm5 = 9,
    kPastMaxValue = 10,
  };

Examinamos a los miembros, además de kNone, se clasifican en dos grupos. Uno es operando inmediato: kBimm12, kImm12, kJimm20, kSimm12, kUimm20 y kUimm5. Los otros son operandos de registro: kCsr, kRs1 y kRs2.

El operando kNone se maneja de la misma manera que para los operandos de destino: muestra un valor nulo.

void RiscV32IEncoding::InitializeSourceOperandGetters() {
  // Source operand getters.
  source_op_getters_[static_cast<int>(SourceOpEnum::kNone)] = [] () {
    return nullptr;
  };

A continuación, trabajemos en los operandos de registro. Controlaremos el kCsr de manera similar a como controlamos los operandos de destino correspondientes. Solo llama a la función de ayuda con "CSR" como el nombre del registro.

  // Register operands.
  source_op_getters_[static_cast<int>(SourceOpEnum::kCsr)] = [this]() {
    return GetRegisterSourceOp<RV32Register>(state_, "CSR");
  };

Los operandos kRs1 y kRs2 se manejan de forma equivalente a kRd, excepto que, si bien no queremos actualizar x0 (o zero), queremos asegurarnos de leer siempre 0 de ese operando. Para ello, usaremos la clase generic::IntLiteralOperand<> definida en literal_operand.h. Este operando se usa para almacenar un valor literal (en lugar de un valor inmediato simulado). De lo contrario, el patrón es el mismo: primero extrae el valor rs1/rs2 de la palabra de instrucción. Si es cero, muestra el operando literal con un parámetro de plantilla 0. De lo contrario, muestra un operando de origen de registro normal usando la función auxiliar con el alias de abi como nombre del operando.

  source_op_getters_[static_cast<int>(SourceOpEnum::kRs1)] =
      [this]() -> SourceOperandInterface * {
    int num = inst32_format::ExtractRs1(inst_word_);
    if (num == 0) return new IntLiteralOperand<0>({1}, xreg_alias_[0]);
    return GetRegisterSourceOp<RV32Register>(
        state_, absl::StrCat(RiscVState::kXregPrefix, num), xreg_alias_[num]);
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kRs2)] =
      [this]() -> SourceOperandInterface * {
    int num = inst32_format::ExtractRs2(inst_word_);
    if (num == 0) return new IntLiteralOperand<0>({1}, xreg_alias_[0]);
    return GetRegisterSourceOp<RV32Register>(
        state_, absl::StrCat(RiscVState::kXregPrefix, num), xreg_alias_[num]);
  };

Por último, controlamos los diferentes operandos inmediatos. Los valores inmediatos se almacenan en instancias de la clase generic::ImmediateOperand<> definida en immediate_operand.h. La única diferencia entre los diferentes obtentores para los operandos inmediatos es qué función de extractor se usa y si el tipo de almacenamiento tiene o no una firma, según el campo de bits.

  // Immediates.
  source_op_getters_[static_cast<int>(SourceOpEnum::kBimm12)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractBImm(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kImm12)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractImm12(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kUimm5)] = [this]() {
    return new ImmediateOperand<uint32_t>(
        inst32_format::ExtractUimm5(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kJimm20)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractJImm(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kSimm12)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractSImm(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kUimm20)] = [this]() {
    return new ImmediateOperand<uint32_t>(
        inst32_format::ExtractUimm32(inst_word_));
  };
}

Si necesitas ayuda (o quieres revisar tu trabajo), la respuesta completa está aquí.

Con esto concluye este instructivo. Esperamos que te haya resultado útil.