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.
  • Aprende cómo se relacionan las funciones semánticas con la descripción del decodificador ISA.
  • Escribe funciones semánticas de instrucción para instrucciones RiscV RV32I.
  • Probar el simulador final ejecutando un pequeño juego de "Hello World" ejecutable.

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 del mismo modo en que los efectos secundarios de la instrucción son visibles cuando se ejecutan en hardware. 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 la instrucciones.

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

La clase Instruction se define en instruction.h

Para los fines de escribir funciones semánticas, nos interesan especialmente los vectores de la interfaz de operando de origen y destino a los que se accede utilizando Llamadas a los 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;
};

Forma básica de escribir una función semántica para un operando 3 normal 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 las partes de esta función. Las dos primeras líneas de la el cuerpo de la función lee 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 valor 0 . Esto es así independientemente de si el registro o valor subyacente un array con un valor o no. El tamaño (en elementos) del operando de origen puede ser obtenido del método de operando de origen shape(), que muestra un vector que contiene la cantidad de elementos de 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, a una uint32_t temporal llamada c se le asigna el valor a + b.

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

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

Un DataBuffer es un objeto contado de referencia que se usa para almacenar valores en estado simulado, como los registros. Es relativamente sin tipo, aunque tiene un según el objeto desde el que está asignado. En este caso, ese tamaño es sizeof(uint32_t) Esta instrucción asigna un nuevo búfer de datos de tamaño para la destino que es el objetivo de este operando de destino; en este caso, un Registro de números enteros de 32 bits. El DataBuffer también se inicializa con el latencia arquitectónica para la instrucción. Esto se especifica durante la instrucción la decodificación y la decodificación.

En la siguiente línea, se trata la instancia del búfer de datos como un array de uint32_t. 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 su uso. como el nuevo valor del estado de la máquina de destino (en este caso, un registro) después de la latencia de la instrucción que se configuró cuando la instrucción se decodificó, y el vector de operando de destino propagado.

Si bien se trata de una función breve, tiene algo de código estándar código que se vuelve repetitivo cuando se implementa una instrucción después de una instrucción. Además, puede ocultar la semántica real de la instrucción. En orden para simplificar aún más la escritura de funciones semánticas para la mayoría de las instrucciones, Existen varias funciones helper con plantillas definidas en instruction_helpers.h. Estos asistentes ocultan el código estándar para instrucciones con uno, dos o tres operandos de origen y un solo operando de destino. Veamos 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 basadas en plantillas que se usan para proporcionar métodos de acceso con plantilla a la fuente de instrucciones operandos. Sin ellas, cada una de las funciones auxiliares de la instrucción tendría especializado para cada tipo para acceder al operando de origen con el función As<int type>(). Puedes ver las definiciones de estas plantillas funciones en instruction.h.

Como puedes ver, hay tres implementaciones, según si la fuente los tipos de operandos son los mismos que el destino, ya sea que el destino sea diferentes a las fuentes o si son todas diferentes. Cada versión de la función toma un puntero a la instancia de la instrucción, así como un elemento (que incluye las funciones lambda). Esto significa que ahora podemos reescribir el add. función semántica 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 la compilación esto debería estar intercalado por completo, sin sobrecarga, y darnos cuenta brevedad sin penalizaciones de rendimiento.

Como mencionamos, hay funciones auxiliares para escalares unarios, binarios y ternarios instrucciones, así como equivalentes vectoriales. También sirven como plantillas útiles para crear tus propios asistentes para instrucciones que no caben en el molde general.


Compilación inicial

Si no cambiaste el directorio a riscv_semantic_functions, hazlo ahora mismo. Luego, compila el proyecto de la siguiente manera: esta debería completarse correctamente.

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

No se generan archivos, así que esto es solo una prueba para asegurarte de que todo esté en orden.


Agrega instrucciones para la ALU de tres operandos

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

Las instrucciones que agregaremos son las siguientes:

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

Si hiciste los instructivos anteriores, recordarás que distinguimos entre instrucciones de registro-registro e instrucciones de registro inmediato en el decodificador. Cuando se trata de funciones semánticas, ya no necesitamos hacer eso. Las interfaces de operando leerán el valor de operando del operando es, de registro o inmediato, con la función semántica completamente agnóstica cuál es realmente el operando de origen subyacente.

A excepción de sra, todas las instrucciones anteriores se pueden considerar como operativas en Valores sin firma de 32 bits, por lo que podemos usar la función de plantilla BinaryOp que vimos antes con un solo argumento de tipo de plantilla. Completa el cuerpos de función en rv32i_instructions.cc según corresponda. Ten en cuenta que solo los 5 bits del segundo operando para las instrucciones de cambio se usan para el cambio importe. 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 el 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 del operando de origen 1, en este caso uint32_t. De esta manera, el cuerpo del sra función semántica:

  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 comparar tu trabajo con rv32i_instructions.cc


Agrega instrucciones de ALU de dos operandos

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

Como solo hay un operando de origen, no podemos usar BinaryOp, en su lugar necesitamos usar UnaryOp. Como podemos tratar tanto a la fuente como al operandos de destino como uint32_t, podemos usar la plantilla de argumento único versión.

// 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 devuelve la fuente. La función semántica para auipc presenta un elemento secundario ya que debes acceder al método address() en Instruction instancia. La respuesta es agregar instruction a la captura de lambda para que sea 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 se puede usar instruction en el cuerpo de lambda.

Realiza los cambios y compila. Puedes comparar tu trabajo con rv32i_instructions.cc


Agrega instrucciones de cambio en el flujo de control

Las instrucciones de cambio en el flujo de control que debes implementar están divididas en instrucciones de rama condicionales (ramas más cortas que se realizan si ocurre una comparación) y las instrucciones de salto y enlace, que se utilizan para implementar llamadas a función (se quita -and-link mediante la configuración del vínculo registrar a cero, lo que hace que esas escrituras sean no-ops).

Cómo agregar instrucciones para las ramas condicionales

No hay una función auxiliar para la instrucción de la rama, así 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 útil esfuerzo. Antes de hacerlo, veamos la implementación de una rama función semántica de instrucció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 que la rama estándar y los tipos de datos, con firma frente a 32 bits sin firma, de los dos operandos de origen. Eso significa que necesitamos tener un parámetro de plantilla para el operandos de origen. La función auxiliar debe tomar el 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). como:

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 (con signo).
  • Bltu: Rama menor que (sin signo).
  • Bne: La rama no es igual.

Realiza los cambios para implementar estas funciones semánticas. reconstruir. Puedes comparar tu trabajo con rv32i_instructions.cc

No tiene sentido escribir una función auxiliar para jump and link. instrucciones, así que tendremos que escribirlas desde cero. Empecemos observando la semántica de sus instrucciones.

La instrucción jal toma un desplazamiento del operando de origen 0 y lo agrega al pc actual (dirección de instrucción) para calcular el objetivo de 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 a la siguiente instrucción secuencial. Se puede calcular sumando el valor el tamaño de una instrucción a su dirección. La dirección de devolución se escribe en operando de destino 1. Recuerda incluir el puntero del 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 computar el objetivo de salto. De lo contrario, es idéntico a la instrucción jal.

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


Cómo agregar instrucciones de almacenamiento de memoria

Hay tres instrucciones de almacenamiento que debemos implementar: byte de almacenamiento (sb), almacena media palabra (sh) y palabra (sw). Instrucciones de la tienda son diferentes de las instrucciones que implementamos hasta ahora escribir en el estado del procesador local. En su lugar, escriben en un recurso del sistema. memoria principal. La MPACT-Sim no trata la memoria como un operando de instrucción, por lo que el acceso a la memoria tiene que realizarse 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 correcta, crea un nuevo objeto de estado RiscV que derive de ArchState donde esto se puede agregar. El objeto ArchState administra recursos principales, como registros y otros objetos de estado. También administra las líneas de demora que se usan 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 parte de la instrucción puede implementarse sin tener conocimiento de esta clase, pero otras, como operaciones de memoria y otros sistemas de estado requieren que la funcionalidad resida en este objeto de estado.

Observemos la función semántica para la instrucción fence que se implementado en rv32i_instructions.cc como ejemplo. El fence la instrucción retiene el problema de la instrucción hasta que se cumplan ciertas operaciones el proyecto se completó. Se usa para garantizar el orden de la memoria entre instrucciones. que se ejecutan antes de la instrucción y 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 los dos últimos a una línea de producción de datos. Primero, se recupera el objeto de estado con un método en Instruction. y downcast<> a la clase derivada específica de RiscV. Luego, el elemento Fence de la clase RiscVState para realizar la operación de valla.

Las instrucciones de la tienda funcionarán de manera similar. En primer lugar, la dirección efectiva de la el acceso a la memoria se computa a partir de los operandos de la fuente de instrucción base y offset, entonces el valor que se almacenará se recupera del siguiente operando de origen. A continuación, 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 una 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 hacia la tienda instrucción en sí, la dirección de la tienda y un puntero a una DataBuffer que contiene los datos de Store. Ten en cuenta que no se requiere tamaño, La instancia DataBuffer contiene un método size(). Sin embargo, no hay operando de destino accesible para la instrucción que se puede usar para asigna una instancia DataBuffer del tamaño adecuado. En cambio, debemos usa 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. Aquí hay 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) parte de la tarea):

  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 hemos terminado. La instancia DataBuffer se cuenta como referencia. Esta normalmente se entiende y maneja con el método Submit para mantener la caso de uso más frecuente. Sin embargo, StoreMemory no es escrito de esa manera. Se IncRef la instancia DataBuffer mientras funciona y DecRef cuando termines. Sin embargo, si la función semántica no DecRef su propia referencia, nunca se recuperará. Por lo tanto, la última línea tiene debe ser:

  db->DecRef();

Hay tres funciones de almacenamiento, y lo único que difiere es el tamaño de el acceso a la memoria. Esta parece ser una gran oportunidad para que otro local función auxiliar basada en plantilla. Lo único diferente en la función de tienda es el tipo de valor de la tienda, por lo que la plantilla debe tener eso como un argumento. Aparte de eso, solo se debe pasar la instancia 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();
}

Finaliza las funciones semánticas de la tienda y compila. Puedes consultar tu trabajar contra 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 a una palabra.
  • lbu: Carga el byte sin firma y se extiende desde cero a una palabra.
  • lh: carga media palabra y el signo se extiende en una palabra.
  • lhu: Carga media palabra sin firma, se extiende cero a una palabra.
  • lw: carga la palabra.

Las instrucciones de carga son las instrucciones más complejas que debemos modelar en este instructivo. Son similares a las instrucciones de almacenamiento, en el sentido de que deben acceder al objeto RiscVState, pero agrega complejidad en el sentido de que cada carga instrucciones se divide en dos funciones semánticas independientes. La primera es similar a la instrucción del almacén, ya que calcula la dirección eficaz e inicia el acceso a la memoria. La segunda se ejecuta cuando la memoria el acceso esté completo y escriba los datos de la memoria en el destino del registro operando.

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 valores adicionales parámetros: un puntero para una instancia de Instruction y un puntero para una referencia contada context. La primera es la instrucción secundaria que implementa la reescritura del registro (descrita en el instructivo del decodificador ISA). Integra se accede con el método child() en la instancia actual Instruction. Este último es un puntero a una instancia de una clase que se deriva de ReferenceCount que, en este caso, almacena una instancia de DataBuffer que almacenará contienen los datos cargados. El objeto de contexto está disponible a través del 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 (bytes, media palabra y palabra) y si el valor cargado es un signo extendido o no. El este último solo tiene en cuenta la instrucción child. Creemos una plantilla para las instrucciones de carga principal. Será muy similar al almacenar una instrucción, 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. En primer lugar, LoadContext se obtiene llamando al método Instruction context(). se transmite de forma estática a LoadContext *. En segundo lugar, el valor (de acuerdo con los datos type) se lee desde la instancia DataBuffer de load-data. En tercer lugar, un nuevo La instancia DataBuffer se asigna desde el operando de destino. Por último, la el valor cargado se escribe en la nueva instancia DataBuffer y se escribe Submit. Una vez más, una función auxiliar con plantilla es una buena idea:

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();
}

Implementa estas últimas funciones auxiliares y semánticas. Pagar atención al tipo de datos que usas en la plantilla para cada función auxiliar llamada y que corresponda al tamaño y a la naturaleza firmada/sin firmar de la carga instrucciones.

Puedes comparar tu trabajo con rv32i_instructions.cc


Crea y ejecuta el simulador final

Ahora que cumplimos con todas las palabras difíciles, podemos crear el simulador final. El las bibliotecas C++ de primer nivel que unen todo el trabajo en estos instructivos son ubicado en other/. No es necesario trabajar demasiado en ese código. Mié ese tema en un futuro instructivo avanzado.

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

$ cd ../other
$ bazel build :rv32i_sim

En ese directorio, aparece el simple “Hello World” programa 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 modo interactivo con el comando bazel run :rv32i_sim -- -i other/hello_rv32i.elf. Esto nos lleva a una simple de comandos de shell. Escribe help en el símbolo del sistema 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 haya sido útil.