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 yor
: 32 bits a nivel de bits osll
: 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
Agrega instrucciones de salto y enlace
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.