Instructivo de funciones semánticas de instrucción

Los objetivos de este instructivo son los siguientes:

  • Descubre cómo se usan las funciones semánticas para implementar semánticas de instrucciones.
  • Obtén información sobre cómo se relacionan las funciones semánticas con la descripción del decodificador de ISA.
  • Escribe funciones semánticas de instrucción para instrucciones RiscV RV32I.
  • Para probar el simulador final, ejecuta un pequeño ejecutable de "Hello World".

Descripción general de las funciones semánticas

Una función semántica en MPACT-Sim es una función que implementa la operación de una instrucción para que sus efectos secundarios sean visibles en el estado simulado de la misma manera que los de la instrucción son visibles cuando se ejecutan en hardware. La representación interna del simulador de cada instrucción decodificada contiene una función que admite llamadas y se usa para llamar a la función semántica para esa instrucción.

Una función semántica tiene la firma void(Instruction *), es decir, una función que toma un puntero a una instancia de la clase Instruction y muestra void.

La clase Instruction se define en instruction.h.

A los efectos de escribir funciones semánticas, nos interesan en particular los vectores de interfaz de operandos de origen y destino a los que se accede con las llamadas a métodos Source(int i) y Destination(int i).

A continuación, se muestran las interfaces de operando de origen y destino:

// 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 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;
};

La forma básica de escribir una función semántica para una instrucción normal de 3 operandos, como una instrucción add de 32 bits es la siguiente:

void MyAddFunction(Instruction *inst) {
  uint32_t a = inst->Source(0)->AsUint32(0);
  uint32_t b = inst->Source(1)->AsUint32(0);
  uint32_t c = a + b;
  DataBuffer *db = inst->Destination(0)->AllocateDataBuffer();
  db->Set<uint32_t>(0, c);
  db->Submit();
}

Desglosemos los componentes de esta función. Las dos primeras líneas del cuerpo de la función leen de los operandos de origen 0 y 1. La llamada a AsUint32(0) interpreta los datos subyacentes como un array uint32_t y recupera el elemento 0. Esto es cierto independientemente de si el registro o valor subyacente tiene un valor de array o no. El tamaño (en elementos) del operando de origen se puede obtener del método del operando de origen shape(), que muestra un vector que contiene la cantidad de elementos en cada dimensión. Ese método muestra {1} para un escalar, {16} para un vector de 16 elementos y {4, 4} para un array de 4 x 4.

  uint32_t a = inst->Source(0)->AsUint32(0);
  uint32_t b = inst->Source(1)->AsUint32(0);

Luego, se asigna el valor a + b a un uint32_t temporal llamado c.

La siguiente línea puede requerir un poco más de explicación:

  DataBuffer *db = inst->Destination(0)->AllocateDataBuffer();

Un DataBuffer es un objeto con recuento de referencias que se usa para almacenar valores en un estado simulado, como los registros. Es relativamente sin tipo, aunque tiene un tamaño basado en el objeto desde el que se asigna. En este caso, ese tamaño es sizeof(uint32_t). Esta sentencia asigna un nuevo búfer de datos de tamaño para el destino que es el objetivo de este operando de destino, en este caso, un registro de número entero de 32 bits. DataBuffer también se inicializa con la latencia arquitectónica de la instrucción. Esto se especifica durante la decodificación de instrucciones.

La siguiente línea trata la instancia de búfer de datos como un array de uint32_t y escribe el valor almacenado en c en el elemento 0.

  db->Set<uint32_t>(0, c);

Por último, la última sentencia envía el búfer de datos al simulador para que se use como el valor nuevo del estado de la máquina de destino (en este caso, un registro) después de la latencia de la instrucción que se estableció cuando se decodificó la instrucción y se propagó el vector de operando de destino.

Si bien esta es una función bastante breve, tiene un poco de código de texto sin formato que se vuelve repetitivo cuando se implementa instrucción tras instrucción. Además, puede ocultar la semántica real de la instrucción. Para simplificar aún más la escritura de las funciones semánticas para la mayoría de las instrucciones, hay una serie de funciones auxiliares con plantillas definidas en instruction_helpers.h. Estos ayudantes ocultan el código de plantilla para instrucciones con uno, dos o tres operandos de origen y un solo operando de destino. Analicemos dos funciones auxiliares de operando:

// This is a templated helper function used to factor out common code in
// two operand instruction semantic functions. It reads two source operands
// and applies the function argument to them, storing the result to the
// destination operand. This version supports different types for the result and
// each of the two source operands.
template <typename Result, typename Argument1, typename Argument2>
inline void BinaryOp(Instruction *instruction,
                     std::function<Result(Argument1, Argument2)> operation) {
  Argument1 lhs = generic::GetInstructionSource<Argument1>(instruction, 0);
  Argument2 rhs = generic::GetInstructionSource<Argument2>(instruction, 1);
  Result dest_value = operation(lhs, rhs);
  auto *db = instruction->Destination(0)->AllocateDataBuffer();
  db->SetSubmit<Result>(0, dest_value);
}

// This is a templated helper function used to factor out common code in
// two operand instruction semantic functions. It reads two source operands
// and applies the function argument to them, storing the result to the
// destination operand. This version supports different types for the result
// and the operands, but the two source operands must have the same type.
template <typename Result, typename Argument>
inline void BinaryOp(Instruction *instruction,
                     std::function<Result(Argument, Argument)> operation) {
  Argument lhs = generic::GetInstructionSource<Argument>(instruction, 0);
  Argument rhs = generic::GetInstructionSource<Argument>(instruction, 1);
  Result dest_value = operation(lhs, rhs);
  auto *db = instruction->Destination(0)->AllocateDataBuffer();
  db->SetSubmit<Result>(0, dest_value);
}

// This is a templated helper function used to factor out common code in
// two operand instruction semantic functions. It reads two source operands
// and applies the function argument to them, storing the result to the
// destination operand. This version requires both result and source operands
// to have the same type.
template <typename Result>
inline void BinaryOp(Instruction *instruction,
                     std::function<Result(Result, Result)> operation) {
  Result lhs = generic::GetInstructionSource<Result>(instruction, 0);
  Result rhs = generic::GetInstructionSource<Result>(instruction, 1);
  Result dest_value = operation(lhs, rhs);
  auto *db = instruction->Destination(0)->AllocateDataBuffer();
  db->SetSubmit<Result>(0, dest_value);
}

Notarás que, en lugar de usar una instrucción como:

  uint32_t a = inst->Source(0)->AsUint32(0);

La función auxiliar usa lo siguiente:

generic::GetInstructionSource<Argument>(instruction, 0);

GetInstructionSource es una familia de funciones auxiliares con plantilla que se usan para proporcionar métodos de acceso con plantilla a los operandos de fuente de instrucciones. Sin ellos, cada una de las funciones auxiliares de instrucciones tendría que especializarse para cada tipo para acceder al operando de origen con la función As<int type>() correcta. Puedes ver las definiciones de estas funciones de plantillas en instruction.h.

Como puedes ver, hay tres implementaciones, según si los tipos de operandos de origen son los mismos que los del destino, si el destino es diferente de las fuentes o si todos son diferentes. Cada versión de la función toma un puntero a la instancia de instrucción, así como una función que admite llamadas (que incluye funciones lambda). Esto significa que ahora podemos volver a escribir la función semántica add anterior de la siguiente manera:

void MyAddFunction(Instruction *inst) {
  generic::BinaryOp<uint32_t>(inst,
                              [](uint32_t a, uint32_t b) { return a + b; });
}

Cuando se compila con bazel build -c opt y copts = ["-O3"] en el archivo de compilación, esto debería intercalarse por completo sin sobrecarga, lo que nos brinda concisión notacional sin penalizaciones de rendimiento.

Como se mencionó, existen funciones auxiliares para instrucciones escalares unarias, binarias y ternarias, así como equivalentes vectoriales. También sirven como plantillas útiles para crear tus propios ayudantes para instrucciones que no se ajustan al modelo general.


Compilación inicial

Si no cambiaste el directorio a riscv_semantic_functions, hazlo ahora. Luego, compila el proyecto de la siguiente manera. Esta compilación no debería tener errores.

$  bazel build :riscv32i
...<snip>...

No se generan archivos, por lo que esta es solo una prueba para asegurarte de que todo esté en orden.


Agrega instrucciones de ALU de tres operandos

Ahora, agreguemos las funciones semánticas para algunas instrucciones genéricas de ALU de 3 operandos. Abre el archivo rv32i_instructions.cc y asegúrate de que las definiciones que falten se agreguen al archivo rv32i_instructions.h a medida que avancemos.

Las instrucciones que agregaremos son las siguientes:

  • add: Suma de número entero de 32 bits.
  • and: Operador "and" a nivel de bits de 32 bits.
  • or: OR a nivel de bits de 32 bits
  • sll: Desplazamiento lógico hacia la izquierda de 32 bits.
  • sltu: Conjunto menor que de 32 bits sin firma.
  • sra: Desplazamiento aritmético a la derecha de 32 bits.
  • srl: Desplazamiento lógico hacia la derecha de 32 bits.
  • sub: Resta de números enteros de 32 bits.
  • xor: Xor a nivel de bits de 32 bits.

Si hiciste los instructivos anteriores, tal vez recuerdes que distinguimos entre instrucciones de registro-registro e instrucciones de registro inmediato en el decodificador. Cuando se trata de funciones semánticas, ya no es necesario hacerlo. Las interfaces de operando leerán el valor de operando del operando, independientemente del registro o inmediato, con la función semántica completamente independiente de cuál es en realidad el operando de origen subyacente.

A excepción de sra, se puede tratar que todas las instrucciones anteriores funcionen en valores sin firma de 32 bits, por lo que, para ellos, podemos usar la función de plantilla BinaryOp que vimos antes con un solo argumento de tipo de plantilla. Completa los cuerpos de las funciones en rv32i_instructions.cc según corresponda. Ten en cuenta que solo se usan los 5 bits más bajos del segundo operando de las instrucciones de desplazamiento para el importe de desplazamiento. De lo contrario, todas las operaciones tendrán el formato src0 op src1:

  • add: a + b
  • and: a & b
  • or: a | b
  • sll: a << (b & 0x1f)
  • sltu: (a < b) ? 1 : 0
  • srl: a >> (b & 0x1f)
  • sub: a - b
  • xor: a ^ b

Para sra, usaremos la plantilla de tres argumentos BinaryOp. Si observas la plantilla, el primer argumento de tipo es el tipo de resultado uint32_t. El segundo es el tipo de operando de origen 0 (en este caso, int32_t) y el último es el tipo de operando de origen 1 (en este caso, uint32_t). Eso hace que el cuerpo de la función semántica sra sea el siguiente:

  generic::BinaryOp<uint32_t, int32_t, uint32_t>(
      instruction, [](int32_t a, uint32_t b) { return a >> (b & 0x1f); });

Realiza los cambios y compila. Puedes verificar tu trabajo en rv32i_instructions.cc.


Agrega instrucciones de ALU de dos operandos

Solo hay dos instrucciones de ALU de 2 operandos: lui y auipc. El primero copia el operando de origen desplazado previamente directamente al destino. Este último agrega la dirección de la instrucción a la inmediata antes de escribirla en el destino. Se puede acceder a la dirección de instrucción desde el método address() del objeto Instruction.

Como solo hay un operando de origen, no podemos usar BinaryOp; en su lugar, debemos usar UnaryOp. Dado que podemos tratar los operandos de origen y de destino como uint32_t, podemos usar la versión de la plantilla de argumento único.

// This is a templated helper function used to factor out common code in
// single operand instruction semantic functions. It reads one source operand
// and applies the function argument to it, storing the result to the
// destination operand. This version supports the result and argument having
// different types.
template <typename Result, typename Argument>
inline void UnaryOp(Instruction *instruction,
                    std::function<Result(Argument)> operation) {
  Argument lhs = generic::GetInstructionSource<Argument>(instruction, 0);
  Result dest_value = operation(lhs);
  auto *db = instruction->Destination(0)->AllocateDataBuffer();
  db->SetSubmit<Result>(0, dest_value);
}

// This is a templated helper function used to factor out common code in
// single operand instruction semantic functions. It reads one source operand
// and applies the function argument to it, storing the result to the
// destination operand. This version requires that the result and argument have
// the same type.
template <typename Result>
inline void UnaryOp(Instruction *instruction,
                    std::function<Result(Result)> operation) {
  Result lhs = generic::GetInstructionSource<Result>(instruction, 0);
  Result dest_value = operation(lhs);
  auto *db = instruction->Destination(0)->AllocateDataBuffer();
  db->SetSubmit<Result>(0, dest_value);
}

El cuerpo de la función semántica para lui es lo más trivial posible, solo muestra la fuente. La función semántica para auipc presenta un problema menor, ya que debes acceder al método address() en la instancia Instruction. La respuesta es agregar instruction a la captura de lambda, lo que la hace disponible para usar en el cuerpo de la función lambda. En lugar de [](uint32_t a) { ... } como antes, la lambda debe escribirse [instruction](uint32_t a) { ... }. Ahora, instruction se puede usar en el cuerpo de lambda.

Continúa y realiza los cambios y compila. Puedes comparar tu trabajo con rv32i_instructions.cc.


Agrega instrucciones de cambio de flujo de control

Las instrucciones de cambio de flujo de control que debes implementar se dividen en instrucciones de rama condicionales (ramas más cortas que se realizan si se cumple una comparación) y en instrucciones de salto y vínculo, que se usan para implementar llamadas a funciones (se quita "y el vínculo" cuando se establece el registro de vínculos en cero, lo que hace que esas operaciones de escritura no sean operaciones).

Cómo agregar instrucciones para las ramas condicionales

No hay una función de ayuda para la instrucción de bifurcación, por lo que hay dos opciones. Escribe las funciones semánticas desde cero o escribe una función auxiliar local. Como necesitamos implementar 6 instrucciones de rama, esta última parece que vale la pena el esfuerzo. Antes de hacerlo, veamos la implementación de una función semántica de instrucción de bifurcación desde cero.

void MyConditionalBranchGreaterEqual(Instruction *instruction) {
  int32_t a = generic::GetInstructionSource<int32_t>(instruction, 0);
  int32_t b = generic::GetInstructionSource<int32_t>(instruction, 1);
  if (a >= b) {
    uint32_t offset = generic::GetInstructionSource<uint32_t>(instruction, 2);
    uint32_t target = offset + instruction->address();
    DataBuffer *db = instruction->Destination(0)->AllocateDataBuffer();
    db->Set<uint32_t>(0,m target);
    db->Submit();
  }
}

Lo único que varía en las instrucciones de la rama es la condición de la rama y los tipos de datos (int de 32 bits con firma frente a int de 32 bits sin firma) de los dos operandos de origen. Eso significa que necesitamos tener un parámetro de plantilla para los operandos de origen. La función auxiliar debe tomar la instancia de Instruction y un objeto que admite llamadas, como std::function, que muestra bool como parámetros. La función auxiliar se vería de la siguiente manera:

template <typename OperandType>
static inline void BranchConditional(
    Instruction *instruction,
    std::function<bool(OperandType, OperandType)> cond) {
  OperandType a = generic::GetInstructionSource<OperandType>(instruction, 0);
  OperandType b = generic::GetInstructionSource<OperandType>(instruction, 1);
  if (cond(a, b)) {
    uint32_t offset = generic::GetInstructionSource<uint32_t>(instruction, 2);
    uint32_t target = offset + instruction->address();
    DataBuffer *db = instruction->Destination(0)->AllocateDataBuffer();
    db->Set<uint32_t>(0, target);
    db->Submit();
  }
}

Ahora podemos escribir la función semántica bge (rama con signo mayor o igual) de la siguiente manera:

void RV32IBge(Instruction *instruction) {
  BranchConditional<int32_t>(instruction,
                             [](int32_t a, int32_t b) { return a >= b; });
}

Las instrucciones restantes son las siguientes:

  • Beq: Rama igual.
  • Bgeu: Rama mayor o igual (sin signo).
  • Blt: Rama menor que (firmada).
  • Bltu: rama menor que (sin firmar).
  • Bne: La rama no es igual.

Continúa y realiza los cambios para implementar estas funciones semánticas y vuelve a compilar. Puedes verificar tu trabajo en rv32i_instructions.cc.

No tiene sentido escribir una función de ayuda para las instrucciones de salto y vinculación, por lo que tendremos que escribirlas desde cero. Para comenzar, observemos la semántica de sus instrucciones.

La instrucción jal toma un desplazamiento del operando de origen 0 y lo agrega a la PC actual (dirección de instrucción) para calcular el destino del salto. El objetivo de salto se escribe en el operando de destino 0. La dirección de devolución es la dirección de la siguiente instrucción secuencial. Se puede calcular agregando el tamaño de la instrucción actual a su dirección. La dirección de retorno se escribe en el operando de destino 1. Recuerda incluir el puntero de objeto de instrucción en la captura de lambda.

La instrucción jalr toma un registro base como operando de origen 0 y un desplazamiento como operando de origen 1, y los suma para calcular el destino del salto. De lo contrario, es idéntico a la instrucción jal.

En función de estas descripciones de la semántica de la instrucción, escribe las dos funciones semánticas y compila. Puedes comparar tu trabajo con rv32i_instructions.cc.


Agrega instrucciones para almacenar memoria

Hay tres instrucciones de almacenamiento que debemos implementar: almacenar byte (sb), almacenar media palabra (sh) y almacenar palabra (sw). Las instrucciones de almacenamiento difieren de las que implementamos hasta ahora en que no escriben en el estado del procesador local. En su lugar, escriben en un recurso del sistema: la memoria principal. MPACT-Sim no trata la memoria como un operando de instrucción, por lo que el acceso a la memoria se debe realizar con otra metodología.

La respuesta es agregar métodos de acceso a la memoria al objeto ArchState de MPACT-Sim o, de manera más apropiada, crear un nuevo objeto de estado RiscV que derive de ArchState, donde se pueda agregar. El objeto ArchState administra recursos principales, como los registros y otros objetos de estado. También administra las líneas de retardo que se usan para almacenar en búfer los búferes de datos del operando de destino hasta que se puedan volver a escribir en los objetos de registro. La mayoría de las instrucciones se pueden implementar sin conocer esta clase, pero algunas, como las operaciones de memoria y otras instrucciones específicas del sistema, requieren que la funcionalidad resida en este objeto de estado.

A modo de ejemplo, echemos un vistazo a la función semántica para la instrucción fence que ya se implementó en rv32i_instructions.cc. La instrucción fence retiene el error de instrucción hasta que se completan ciertas operaciones de memoria. Se usa para garantizar el orden de la memoria entre las instrucciones que se ejecutan antes y las que se ejecutan después.

// Fence.
void RV32IFence(Instruction *instruction) {
  uint32_t bits = instruction->Source(0)->AsUint32(0);
  int fm = (bits >> 8) & 0xf;
  int predecessor = (bits >> 4) & 0xf;
  int successor = bits & 0xf;
  auto *state = static_cast<RiscVState *>(instruction->state());
  state->Fence(instruction, fm, predecessor, successor);
}

La parte clave de la función semántica de la instrucción fence son las dos últimas líneas. Primero, el objeto de estado se recupera con un método en la clase Instruction y downcast<> en la clase derivada específica de RiscV. Luego, se llama al método Fence de la clase RiscVState para realizar la operación de valla.

Las instrucciones de la tienda funcionarán de manera similar. Primero, se calcula la dirección efectiva del acceso a la memoria a partir de los operandos de origen de la instrucción de base y compensación, y, luego, se recupera el valor que se almacenará desde el siguiente operando de origen. Luego, el objeto de estado de RiscV se obtiene a través de la llamada de método state() y static_cast<>, y se llama al método apropiado.

El método StoreMemory del objeto RiscVState es relativamente simple, pero tiene algunas implicaciones que debemos tener en cuenta:

  void StoreMemory(const Instruction *inst, uint64_t address, DataBuffer *db);

Como podemos ver, el método toma tres parámetros: el puntero a la instrucción del almacén en sí, la dirección del almacén y un puntero a una instancia de DataBuffer que contiene los datos del almacén. Ten en cuenta que no se requiere ningún tamaño, ya que la instancia DataBuffer contiene un método size(). Sin embargo, no hay un operando de destino al que la instrucción pueda acceder para asignar una instancia de DataBuffer del tamaño adecuado. En su lugar, debemos usar una fábrica de DataBuffer que se obtiene del método db_factory() en la instancia Instruction. La fábrica tiene un método Allocate(int size) que muestra una instancia DataBuffer del tamaño requerido. A continuación, se muestra un ejemplo de cómo usar esto para asignar una instancia de DataBuffer para un almacén de media palabra (ten en cuenta que auto es una función de C++ que deduce el tipo desde la derecha de la asignación):

  auto *state = down_cast<RiscVState *>(instruction->state());
  auto *db = state->db_factory()->Allocate(sizeof(uint16_t));

Una vez que tenemos la instancia DataBuffer, podemos escribir en ella como de costumbre:

  db->Set<uint16_t>(0, value);

Luego, pásalo a la interfaz del almacén de memoria:

  state->StoreMemory(instruction, address, db);

Aún no terminamos. La instancia DataBuffer se cuenta como referencia. Por lo general, el método Submit comprende y controla esto para que el caso de uso más frecuente sea lo más simple posible. Sin embargo, el StoreMemory no se escribe de esa manera. IncRef la instancia DataBuffer mientras opera en ella y, luego, DecRef cuando finalice. Sin embargo, si la función semántica no DecRef su propia referencia, nunca se reclamará. Por lo tanto, la última línea debe ser:

  db->DecRef();

Hay tres funciones de almacenamiento, y lo único que difiere es el tamaño del acceso a la memoria. Parece una gran oportunidad para otra función de ayuda con plantillas local. Lo único que difiere en la función de almacenamiento es el tipo del valor de almacenamiento, por lo que la plantilla debe tenerlo como argumento. Aparte de eso, solo se debe pasar la instancia de Instruction:

template <typename ValueType>
inline void StoreValue(Instruction *instruction) {
  auto base = generic::GetInstructionSource<uint32_t>(instruction, 0);
  auto offset = generic::GetInstructionSource<uint32_t>(instruction, 1);
  uint32_t address = base + offset;
  auto value = generic::GetInstructionSource<ValueType>(instruction, 2);
  auto *state = down_cast<RiscVState *>(instruction->state());
  auto *db = state->db_factory()->Allocate(sizeof(ValueType));
  db->Set<ValueType>(0, value);
  state->StoreMemory(instruction, address, db);
  db->DecRef();
}

Continúa y termina las funciones semánticas de la tienda y crea la compilación. Puedes verificar tu trabajo con rv32i_instructions.cc.


Agrega instrucciones de carga de memoria

Las instrucciones de carga que se deben implementar son las siguientes:

  • lb: Carga en bytes y se extiende el signo en una palabra.
  • lbu: Carga el byte sin firma y se extiende desde cero a una palabra.
  • lh: Carga una media palabra y extiende el signo a una palabra.
  • lhu: Carga una palabra media sin firmar y con extensión a cero en una palabra.
  • lw: Carga una palabra.

Las instrucciones de carga son las más complejas que tenemos que modelar en este instructivo. Son similares a las instrucciones de almacenamiento, ya que deben acceder al objeto RiscVState, pero agregan complejidad en la medida en que cada instrucción de carga se divide en dos funciones semánticas independientes. El primero es similar a la instrucción de almacenamiento, ya que calcula la dirección efectiva e inicia el acceso a la memoria. El segundo se ejecuta cuando se completa el acceso a la memoria y escribe los datos de la memoria en el operando de destino del registro.

Para comenzar, observemos la declaración del método LoadMemory en RiscVState:

  void LoadMemory(const Instruction *inst, uint64_t address, DataBuffer *db,
                  Instruction *child_inst, ReferenceCount *context);

En comparación con el método StoreMemory, LoadMemory toma dos parámetros adicionales: un puntero a una instancia Instruction y un puntero a un objeto context contado de referencia. La primera es la instrucción secundaria que implementa la reescritura del registro (descrita en el instructivo del decodificador ISA). Se accede a él con el método child() en la instancia Instruction actual. Este último es un puntero a una instancia de una clase que deriva de ReferenceCount que, en este caso, almacena una instancia de DataBuffer que contendrá los datos cargados. El objeto de contexto está disponible a través del método context() en el objeto Instruction (aunque para la mayoría de las instrucciones se establece en nullptr).

El objeto de contexto para las cargas de memoria RiscV se define como la siguiente struct:

// A simple load context class for convenience.
struct LoadContext : public generic::ReferenceCount {
  explicit LoadContext(DataBuffer *vdb) : value_db(vdb) {}
  ~LoadContext() override {
    if (value_db != nullptr) value_db->DecRef();
  }

  // Override the base class method so that the data buffer can be DecRef'ed
  // when the context object is recycled.
  void OnRefCountIsZero() override {
    if (value_db != nullptr) value_db->DecRef();
    value_db = nullptr;
    // Call the base class method.
    generic::ReferenceCount::OnRefCountIsZero();
  }
  // Data buffers for the value loaded from memory (byte, half, word, etc.).
  DataBuffer *value_db = nullptr;
};

Las instrucciones de carga son todas iguales, excepto por el tamaño de los datos (byte, media palabra y palabra) y si el valor cargado tiene signo extendido o no. Esta última solo se tiene en cuenta en la instrucción child. Cree una función de ayuda con plantilla para las instrucciones de carga principales. Será muy similar a la instrucción de almacenamiento, con la excepción de que no accederá a un operando de origen para obtener un valor y creará un objeto de contexto.

template <typename ValueType>
inline void LoadValue(Instruction *instruction) {
  auto base = generic::GetInstructionSource<uint32_t>(instruction, 0);
  auto offset = generic::GetInstructionSource<uint32_t>(instruction, 1);
  uint32_t address = base + offset;
  auto *state = down_cast<RiscVState *>(instruction->state());
  auto *db = state->db_factory()->Allocate(sizeof(ValueType));
  db->set_latency(0);
  auto *context = new riscv::LoadContext(db);
  state->LoadMemory(instruction, address, db, instruction->child(), context);
  context->DecRef();
}

Como puedes ver, la principal diferencia es que la instancia DataBuffer asignada se pasa a la llamada LoadMemory como parámetro y se almacena en el objeto LoadContext.

Las funciones semánticas de la instrucción child son muy similares. Primero, se obtiene LoadContext llamando al método Instruction context() y se convierte a LoadContext * de forma estática. En segundo lugar, el valor (según el tipo de datos) se lee de la instancia DataBuffer de carga de datos. En tercer lugar, se asigna una instancia nueva de DataBuffer desde el operando de destino. Por último, el valor cargado se escribe en la nueva instancia de DataBuffer y se Submit. Una vez más, es una buena idea usar una función auxiliar con plantillas:

template <typename ValueType>
inline void LoadValueChild(Instruction *instruction) {
  auto *context = down_cast<riscv::LoadContext *>(instruction->context());
  uint32_t value = static_cast<uint32_t>(context->value_db->Get<ValueType>(0));
  auto *db = instruction->Destination(0)->AllocateDataBuffer();
  db->Set<uint32_t>(0, value);
  db->Submit();
}

Continúa e implementa estas últimas funciones auxiliares y semánticas. Presta atención al tipo de datos que uses en la plantilla para cada llamada a una función auxiliar y que corresponda al tamaño y a la naturaleza firmada/sin firmar de la instrucción de carga.

Puedes verificar tu trabajo en rv32i_instructions.cc.


Compila y ejecuta el simulador final

Ahora que hicimos todo el trabajo duro, podemos compilar el simulador final. Las bibliotecas de C++ de nivel superior que unen todo el trabajo de estos instructivos se encuentran en other/. No es necesario que analices demasiado ese código. Analizaremos ese tema en un instructivo avanzado en el futuro.

Cambia tu directorio de trabajo a other/ y compila. Debe compilarse sin errores.

$ cd ../other
$ bazel build :rv32i_sim

En ese directorio, hay un programa simple llamado “Hello World” en el archivo hello_rv32i.elf. Para ejecutar el simulador en este archivo y ver los resultados, haz lo siguiente:

$ bazel run :rv32i_sim -- other/hello_rv32i.elf

Deberías ver algo como lo siguiente:

INFO: Analyzed target //other:rv32i_sim (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //other:rv32i_sim up-to-date:
  bazel-bin/other/rv32i_sim
INFO: Elapsed time: 0.203s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/other/rv32i_sim other/hello_rv32i.elf
Starting simulation
Hello World
Simulation done
$

El simulador también se puede ejecutar en un modo interactivo con el comando bazel run :rv32i_sim -- -i other/hello_rv32i.elf. Se abrirá una shell de comandos simple. Escribe help en el mensaje para ver los comandos disponibles.

$ bazel run :rv32i_sim -- -i other/hello_rv32i.elf
INFO: Analyzed target //other:rv32i_sim (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //other:rv32i_sim up-to-date:
  bazel-bin/other/rv32i_sim
INFO: Elapsed time: 0.180s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/other/rv32i_sim -i other/hello_rv32i.elf
_start:
80000000   addi           ra, 0, 0
[0] > help


    quit                           - exit command shell.
    core [N]                       - direct subsequent commands to core N
                                     (default: 0).
    run                            - run program from current pc until a
                                     breakpoint or exit. Wait until halted.
    run free                       - run program in background from current pc
                                     until breakpoint or exit.
    wait                           - wait for any free run to complete.
    step [N]                       - step [N] instructions (default: 1).
    halt                           - halt a running program.
    reg get NAME [FORMAT]          - get the value or register NAME.
    reg NAME [FORMAT]              - get the value of register NAME.
    reg set NAME VALUE             - set register NAME to VALUE.
    reg set NAME SYMBOL            - set register NAME to value of SYMBOL.
    mem get VALUE [FORMAT]         - get memory from location VALUE according to
                                     format. The format is a letter (o, d, u, x,
                                     or X) followed by width (8, 16, 32, 64).
                                     The default format is x32.
    mem get SYMBOL [FORMAT]        - get memory from location SYMBOL and format
                                     according to FORMAT (see above).
    mem SYMBOL [FORMAT]            - get memory from location SYMBOL and format
                                     according to FORMAT (see above).
    mem set VALUE [FORMAT] VALUE   - set memory at location VALUE(1) to VALUE(2)
                                     according to FORMAT. Default format is x32.
    mem set SYMBOL [FORMAT] VALUE  - set memory at location SYMBOL to VALUE
                                     according to FORMAT. Default format is x32.
    break set VALUE                - set breakpoint at address VALUE.
    break set SYMBOL               - set breakpoint at value of SYMBOL.
    break VALUE                    - set breakpoint at address VALUE.
    break SYMBOL                   - set breakpoint at value of SYMBOL.
    break clear VALUE              - clear breakpoint at address VALUE.
    break clear SYMBOL             - clear breakpoint at value of SYMBOL.
    break clear all                - remove all breakpoints.
    help                           - display this message.

_start:
80000000   addi           ra, 0, 0
[0] >

Con esto concluye este instructivo. Esperamos que esta información te resulte útil.