Die Ziele dieser Anleitung sind:
- Anleitung zum Implementieren einer Anweisungssemantik mit semantischen Funktionen
- Hier erfahren Sie, wie semantische Funktionen zur Beschreibung des ISA-Decodierers zusammenhängen.
- Schreibe semantische Funktionen für RiscV RV32I-Anweisungen.
- Testen Sie den endgültigen Simulator, indem Sie eine kleine ausführbare Datei namens „Hello World“ ausführen.
Übersicht über semantische Funktionen
Eine semantische Funktion in MPACT-Sim ist eine Funktion, die den Befehl so implementiert, dass seine Nebenwirkungen im simulierten Zustand genauso sichtbar sind wie bei der Ausführung in der Hardware. Die interne Darstellung jeder decodierten Anweisung im Simulator enthält ein Callable, mit dem die semantische Funktion für diese Anweisung aufgerufen wird.
Eine semantische Funktion hat die Signatur void(Instruction *)
. Das bedeutet, dass sie einen Verweis auf eine Instanz der Klasse Instruction
annimmt und void
zurückgibt.
Die Klasse Instruction
ist in instruction.h definiert.
Für das Schreiben semantischer Funktionen sind wir besonders an den Quell- und Zieloperand-Schnittstellenvektoren interessiert, auf die über die Methodenaufrufe Source(int i)
und Destination(int i)
zugegriffen wird.
Nachfolgend sehen Sie die Schnittstellen für Quell- und Ziel-Operanden:
// 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;
};
So schreiben Sie eine semantische Funktion für eine normale Anweisung mit drei Operanden, z. B. eine 32‑Bit-add
-Anweisung:
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();
}
Sehen wir uns die einzelnen Teile dieser Funktion an. Die ersten beiden Zeilen des Funktionskörpers lesen die Quelloperanden 0 und 1. Der AsUint32(0)
-Aufruf interpretiert die zugrunde liegenden Daten als uint32_t
-Array und ruft das 0. Element ab. Dies gilt unabhängig davon, ob das zugrunde liegende Register oder der zugrunde liegende Wert einen Arraywert hat oder nicht. Die Größe (in Elementen) des Quelloperanden kann über die Quelloperandmethode shape()
abgerufen werden. Diese Methode gibt einen Vektor mit der Anzahl der Elemente in jeder Dimension zurück. Diese Methode gibt {1}
für einen Skalar, {16}
für einen Vektor mit 16 Elementen und {4, 4}
für ein 4 × 4 Array zurück.
uint32_t a = inst->Source(0)->AsUint32(0);
uint32_t b = inst->Source(1)->AsUint32(0);
Dann wird der temporären uint32_t
mit dem Namen c
der Wert a + b
zugewiesen.
Die nächste Zeile erfordert möglicherweise etwas mehr Erklärung:
DataBuffer *db = inst->Destination(0)->AllocateDataBuffer();
Ein DataBuffer ist ein gezähltes Referenzobjekt, mit dem Werte in simuliertem Zustand wie Registern gespeichert werden. Sie ist relativ nicht typisiert, obwohl ihre Größe auf dem Objekt basiert, dem sie zugewiesen ist. In diesem Fall ist dies sizeof(uint32_t)
. Mit dieser Anweisung wird ein neuer Datenpuffer für das Ziel zugewiesen, das Ziel dieses Zieloperanden ist – in diesem Fall ein 32‑Bit-Ganzzahlregister. Der DataBuffer wird auch mit der architektonischen Latenz für die Anweisung initialisiert. Diese wird während der Anweisungsdecodierung angegeben.
Die nächste Zeile behandelt die Datenpufferinstanz als Array von uint32_t
und schreibt den in c
gespeicherten Wert in das 0. Element.
db->Set<uint32_t>(0, c);
Schließlich sendet die letzte Anweisung den Datenpuffer an den Simulator, der nach der Latenz des Befehls, der bei der Decodierung des Befehls und dem Ausfüllen des Zieloperandenvektors festgelegt wurde, als neuer Wert des Zielmaschinenstatus (in diesem Fall ein Register) verwendet werden soll.
Dies ist zwar eine relativ kurze Funktion, enthält jedoch Boilerplate-Code, der sich bei der Implementierung von Anweisungen nach Anweisung wiederholt. Außerdem kann dadurch die eigentliche Semantik der Anleitung verdeckt werden. Um das Schreiben der semantischen Funktionen für die meisten Anweisungen weiter zu vereinfachen, gibt es in instruction_helpers.h eine Reihe von Vorlagenfunktionen vom Typ helper. Diese Hilfsfunktionen blenden den Standardcode für Anweisungen mit einem, zwei oder drei Quelloperanden und einem einzelnen Zieloperanden aus. Sehen wir uns zwei Operanden- Hilfsfunktionen an:
// 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);
}
Sie werden feststellen, dass statt einer Anweisung wie
uint32_t a = inst->Source(0)->AsUint32(0);
Die Hilfsfunktion verwendet:
generic::GetInstructionSource<Argument>(instruction, 0);
GetInstructionSource
ist eine Gruppe von Vorlagen-Hilfsfunktionen, die zum Bereitstellen von Vorlagen-Zugriffsmethoden auf die Operanden der Anweisungsquelle verwendet werden. Ohne sie müsste jede der Hilfsfunktionen für Anweisungen für jeden Typ spezialisiert sein, um mit der richtigen As<int type>()
-Funktion auf den Quelloperanden zuzugreifen. Die Definitionen dieser Vorlagenfunktionen finden Sie in instruction.h.
Wie Sie sehen, gibt es drei Implementierungen, je nachdem, ob die Quell-Operandentypen mit dem Ziel identisch sind, ob sich das Ziel von den Quellen unterscheidet oder ob sie alle unterschiedlich sind. Jede Version der Funktion nimmt einen Verweis auf die Anweisungsinstanz sowie einen aufrufbaren Wert (einschließlich Lambda-Funktionen) an. Das bedeutet, dass wir die obige semantische add
-Funktion jetzt so umschreiben können:
void MyAddFunction(Instruction *inst) {
generic::BinaryOp<uint32_t>(inst,
[](uint32_t a, uint32_t b) { return a + b; });
}
Wenn es mit bazel build -c opt
und copts = ["-O3"]
in der Builddatei kompiliert wird, sollte es vollständig ohne Overhead inline eingefügt werden. So erhalten wir eine prägnante Schreibweise ohne Leistungseinbußen.
Wie bereits erwähnt, gibt es Hilfsfunktionen für unäre, binäre und ternäre Skalarbefehle sowie Vektoräquivalente. Sie dienen auch als nützliche Vorlagen zum Erstellen eigener Hilfselemente für Anleitungen, die nicht in das allgemeine Schema passen.
Erster Build
Wenn Sie das Verzeichnis nicht in riscv_semantic_functions
geändert haben, tun Sie dies jetzt. Erstellen Sie dann das Projekt wie folgt – dieser Build sollte erfolgreich sein.
$ bazel build :riscv32i
...<snip>...
Es werden keine Dateien generiert. Es handelt sich also nur um einen Testlauf, um sicherzustellen, dass alles in Ordnung ist.
Drei Operand-ALU-Anweisungen hinzufügen
Fügen wir nun die semantischen Funktionen für einige generische ALU-Anweisungen mit drei Operanden hinzu. Öffnen Sie die Datei rv32i_instructions.cc
und achten Sie darauf, dass alle fehlenden Definitionen der Datei rv32i_instructions.h
hinzugefügt werden.
Wir fügen folgende Anweisungen hinzu:
add
: Addition von 32-Bit-Ganzzahlen.and
– 32-Bit-bitweises AND.or
: 32-Bit-Bitweise oder.sll
– 32‑Bit-logische Linksverschiebung.sltu
: 32-Bit-Zeichen ohne Vorzeichen ist kleiner als.sra
– 32-Bit-arithmetische Rechtsverschiebung.srl
: logische 32-Bit-Verschiebung nach rechts.sub
– Subtraktion von 32-Bit-Ganzzahlen.xor
: 32-Bit-Bitweises XOR.
Wenn Sie die vorherigen Anleitungen durchgegangen sind, erinnern Sie sich vielleicht daran, dass wir im Decoder zwischen Anweisungen für die Registrierung und sofortiger Registrierung unterschieden haben. Bei semantischen Funktionen ist das nicht mehr nötig. Die Operanden-Schnittstellen lesen den Operandenwert aus dem Operanden, unabhängig davon, welcher der Operanden ist, registrieren oder sofort. Dabei ist die semantische Funktion völlig unabhängig vom zugrunde liegenden Quelloperanden.
Mit Ausnahme von sra
können alle oben genannten Anweisungen als 32-Bit-unsignierte Werte betrachtet werden. Daher können wir für diese die bereits erwähnte Vorlagenfunktion BinaryOp
mit nur einem einzelnen Vorlagentyp-Argument verwenden. Füllen Sie die Funktionskörper in rv32i_instructions.cc
entsprechend aus. Hinweis: Für die Verschiebungsanzahl werden nur die unteren 5 Bits des zweiten Operanden der Verschiebungsanweisungen verwendet. Andernfalls haben alle Vorgänge die Form 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
Für sra
verwenden wir die Vorlage BinaryOp
mit drei Argumenten. In der Vorlage ist das erste Typargument der Ergebnistyp uint32_t
. Der zweite ist der Typ des Quelloperanden 0, in diesem Fall int32_t
, und der letzte ist der Typ des Quelloperanden 1, in diesem Fall uint32_t
. Damit ergibt sich der Text der semantischen Funktion sra
:
generic::BinaryOp<uint32_t, int32_t, uint32_t>(
instruction, [](int32_t a, uint32_t b) { return a >> (b & 0x1f); });
Nehmen Sie die Änderungen vor und führen Sie einen Build aus. Sie können Ihre Arbeit mit rv32i_instructions.cc vergleichen.
Zwei Operand-ALU-Anweisungen hinzufügen
Es gibt nur zwei ALU-Anweisungen mit zwei Operanden: lui
und auipc
. Bei der ersten wird der vorverschobene Quelloperand direkt in das Ziel kopiert. Letzterer fügt die Anweisungsadresse der unmittelbaren URL hinzu, bevor sie in das Ziel geschrieben wird. Die Anweisungsadresse ist über die address()
-Methode des Instruction
-Objekts zugänglich.
Da es nur einen einzigen Quelloperanden gibt, können wir BinaryOp
nicht verwenden, sondern müssen stattdessen UnaryOp
verwenden. Da wir sowohl die Quell- als auch die Zieloperanden als uint32_t
behandeln können, können wir die Vorlagenversion mit einem einzelnen Argument verwenden.
// 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);
}
Der Text der semantischen Funktion für lui
ist in etwa so einfach wie möglich. Geben Sie einfach die Quelle zurück. Die semantische Funktion für auipc
führt zu einem kleinen Problem, da Sie in der Instruction
-Instanz auf die address()
-Methode zugreifen müssen. Die Antwort besteht darin, der Lambda-Aufnahme instruction
hinzuzufügen, damit sie im Lambda-Funktionstext verwendet werden kann. Anstelle von [](uint32_t a) { ...
}
wie zuvor sollte die Lambda-Funktion [instruction](uint32_t a) { ... }
geschrieben werden.
Jetzt kann instruction
im Lambda-Body verwendet werden.
Nehmen Sie die Änderungen vor und erstellen Sie den Build. Sie können Ihre Arbeit mit rv32i_instructions.cc vergleichen.
Anleitungen für Änderungen an der Ablaufsteuerung hinzufügen
Die zu implementierenden Änderungsanweisungen für den Kontrollfluss sind in bedingte Zweiganweisungen (kürzere Zweige, die ausgeführt werden, wenn ein Vergleich zutrifft), und Jump-and-Link-Anweisungen unterteilt, die zur Implementierung von Funktionsaufrufen verwendet werden (das „-and-link“ wird entfernt, indem das Link-Register auf null gesetzt wird, sodass diese Schreibvorgänge nicht möglich sind).
Bedingte Zweiganleitung hinzufügen
Es gibt keine Hilfsfunktion für Verzweigungen, daher gibt es zwei Optionen. Schreiben Sie die semantischen Funktionen von Grund auf oder eine lokale Hilfsfunktion. Da wir eine Anleitung mit sechs Zweigen implementieren müssen, scheint Letzteres den Aufwand wert zu sein. Dazu sehen wir uns zuerst an, wie eine neue semantische Funktion für die Zweiganweisungsanweisung implementiert wird.
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();
}
}
Das einzige, was je nach Verzweigungsanleitung variiert, ist die Verzweigungsbedingung und die Datentypen, 32-Bit-Ganzzahl mit Vorzeichen im Vergleich zu vorzeichenlos, der beiden Quelloperanden. Das bedeutet, dass wir einen Vorlagenparameter für die Quelloperanden benötigen. Die Hilfsfunktion selbst muss die Instruction
-Instanz und ein aufrufbares Objekt wie std::function
verwenden, das bool
als Parameter zurückgibt. Die Hilfsfunktion würde so aussehen:
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();
}
}
Jetzt können wir die semantische Funktion bge
(vorzeichenbehafteter Zweig größer oder gleich) so schreiben:
void RV32IBge(Instruction *instruction) {
BranchConditional<int32_t>(instruction,
[](int32_t a, int32_t b) { return a >= b; });
}
Die verbleibenden Anleitungen für die Filiale sind:
- Beq – Zweig gleich.
- Bgeu: Zweig größer oder gleich (ohne Vorzeichen).
- Blt – Verzweigung kleiner als (signiert)
- Bltu - Zweig kleiner als (unsigniert).
- Bne - Zweig ungleich.
Nehmen Sie die Änderungen vor, um diese semantischen Funktionen zu implementieren, und erstellen Sie die Website neu. Sie können Ihre Arbeit mit rv32i_instructions.cc prüfen.
Anleitung für Sprung- und Link-Navigation hinzufügen
Es macht keinen Sinn, eine Hilfsfunktion für die Sprung- und Verknüpfungsanweisungen zu schreiben. Wir müssen sie also von Grund auf neu schreiben. Sehen wir uns zuerst die Anweisungssemantik an.
Die jal
-Anweisung nimmt einen Offset aus dem Quelloperanden 0 und addiert ihn zum aktuellen PC (Instruction Address), um das Sprungziel zu berechnen. Das Sprungziel wird in den Zieloperanden 0 geschrieben. Die Rücksendeadresse ist die Adresse der nächsten sequenziellen Anweisung. Sie kann berechnet werden, indem die Größe der aktuellen Anweisung zu ihrer Adresse hinzugefügt wird. Die Rücksendeadresse wird in Zieloperand 1 geschrieben. Denken Sie daran, den Zeiger auf das Anweisungsobjekt in die Lambda-Aufnahme aufzunehmen.
Die jalr
-Anweisung nimmt ein Basisregister als Quelloperand 0 und einen Offset als Quelloperand 1 und addiert sie, um das Sprungziel zu berechnen.
Ansonsten entspricht sie der jal
-Anweisung.
Schreiben Sie anhand dieser Beschreibungen der Anweisungssemantik die beiden semantischen Funktionen und bauen Sie sie auf. Sie können Ihre Arbeit mit rv32i_instructions.cc vergleichen.
Anleitung zum Speichern von Erinnerungen hinzufügen
Es gibt drei Speicheranweisungen, die wir implementieren müssen: Byte speichern (sb
), Halbwort speichern (sh
) und Wort speichern (sw
). Speicheranweisungen unterscheiden sich von den bisher implementierten Anweisungen dadurch, dass sie nicht in den lokalen Prozessorstatus geschrieben werden. Stattdessen schreiben sie in eine Systemressource – den Arbeitsspeicher. MPACT-Sim behandelt den Speicher nicht als Befehlsoperanden, daher muss der Speicherzugriff mit einer anderen Methode erfolgen.
Die Antwort besteht darin, dem MPACT-Sim-Objekt ArchState
Speicherzugriffsmethoden hinzuzufügen oder, genauer gesagt, ein neues RiscV-Statusobjekt zu erstellen, das von ArchState
abgeleitet ist, wo dieses hinzugefügt werden kann. Das ArchState
-Objekt verwaltet Kernressourcen wie Register und andere Statusobjekte. Außerdem verwaltet sie die Verzögerungszeilen, die zum Zwischenspeichern der Zieloperanden-Datenpuffer verwendet werden, bis sie in die Registerobjekte zurückgeschrieben werden können. Die meisten Anweisungen können ohne Kenntnis dieser Klasse implementiert werden. Einige, z. B. Speichervorgänge und andere spezifische Systemanweisungen, erfordern jedoch Funktionen, die sich in diesem Statusobjekt befinden.
Sehen wir uns als Beispiel die semantische Funktion für die fence
-Anweisung an, die bereits in rv32i_instructions.cc
implementiert ist. Die Anweisung fence
hält die Anweisungsausgabe an, bis bestimmte Speichervorgänge abgeschlossen sind. Es wird verwendet, um die Speicherreihenfolge zwischen den vor der Anweisung ausgeführten Anweisungen und denen, die danach ausgeführt werden, zu gewährleisten.
// 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);
}
Der wichtigste Teil der semantischen Funktion der fence
-Anweisung sind die letzten beiden Zeilen. Zuerst wird das Zustandsobjekt mit einer Methode in der Instruction
-Klasse und mit downcast<>
in der RiscV-spezifischen abgeleiteten Klasse abgerufen. Anschließend wird die Methode Fence
der Klasse RiscVState
aufgerufen, um den Fencing-Vorgang auszuführen.
Die Anleitung für Ladengeschäfte funktioniert ähnlich. Zuerst wird die effektive Adresse des Speicherzugriffs aus den Basis- und Offset-Instruction-Quelloperanden berechnet. Anschließend wird der zu speichernde Wert aus dem nächsten Quelloperanden abgerufen. Als Nächstes wird das RiscV-Statusobjekt über den Methodenaufruf state()
und static_cast<>
abgerufen und die entsprechende Methode aufgerufen.
Die Methode StoreMemory
des RiscVState
-Objekts ist relativ einfach, hat aber einige Auswirkungen, die wir berücksichtigen müssen:
void StoreMemory(const Instruction *inst, uint64_t address, DataBuffer *db);
Wie Sie sehen, verwendet die Methode drei Parameter: den Zeiger auf die Speicheranweisung selbst, die Adresse des Speichers und einen Zeiger auf eine DataBuffer
-Instanz, die die Speicherdaten enthält. Beachten Sie, dass keine Größe erforderlich ist, da die DataBuffer
-Instanz selbst eine size()
-Methode enthält. Für die Anweisung, mit der eine DataBuffer
-Instanz der entsprechenden Größe zugewiesen werden kann, ist jedoch kein Zieloperanden zugänglich. Stattdessen müssen wir eine DataBuffer
-Factory verwenden, die über die Methode db_factory()
in der Instruction
-Instanz abgerufen wird. Die Factory hat eine Methode Allocate(int size)
, die eine DataBuffer
-Instanz der erforderlichen Größe zurückgibt. Hier ist ein Beispiel dafür, wie Sie damit eine DataBuffer
-Instanz für einen Halb-Wort-Speicher zuweisen können. Beachten Sie, dass auto
eine C++-Funktion ist, die den Typ von der rechten Seite der Zuweisung ableitet:
auto *state = down_cast<RiscVState *>(instruction->state());
auto *db = state->db_factory()->Allocate(sizeof(uint16_t));
Sobald wir die DataBuffer
-Instanz haben, können wir wie gewohnt darauf schreiben:
db->Set<uint16_t>(0, value);
Übergeben Sie ihn dann an die Speicherspeicher-Schnittstelle:
state->StoreMemory(instruction, address, db);
Wir sind noch nicht ganz fertig. Die Instanz DataBuffer
wird als Referenz gezählt. Dies wird normalerweise von der Submit
-Methode verstanden und verarbeitet, um den häufigsten Anwendungsfall so einfach wie möglich zu halten. StoreMemory
ist jedoch nicht so geschrieben. Die DataBuffer
-Instanz wird während der Ausführung IncRef
und nach Abschluss DecRef
. Wenn die semantische Funktion jedoch nicht ihre eigene Referenz DecRef
, wird sie niemals zurückgefordert. Die letzte Zeile muss also so lauten:
db->DecRef();
Es gibt drei Speicherfunktionen. Der einzige Unterschied ist die Größe des Arbeitsspeicherzugriffs. Das klingt nach einer guten Gelegenheit für eine weitere lokale Vorlagen-Hilfsfunktion. Das einzige, was sich bei der Speicherfunktion unterscheidet, ist der Typ des Speicherwerts. Daher muss die Vorlage dies als Argument haben.
Ansonsten muss nur die Instanz Instruction
übergeben werden:
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();
}
Fahren Sie fort, schließen Sie die semantischen Funktionen ab und erstellen Sie den Build. Sie können Ihre Arbeit mit rv32i_instructions.cc vergleichen.
Anleitung zum Laden des Arbeitsspeichers hinzufügen
Die folgenden Ladeanweisungen müssen implementiert werden:
lb
– Byte laden, mit Vorzeichen in ein Wort erweitern.lbu
: Lädt Byte ohne Vorzeichen, nullerweitert in ein Wort.lh
– Halbwort laden, mit Vorzeichen in ein Wort erweitern.lhu
: Ein halbes Wort ohne Vorzeichen wird mit Null-Erweiterung in ein Wort geladen.lw
– Wort laden.
Die Ladeanweisungen sind die komplexesten Anweisungen, die wir in diesem Tutorial modellieren müssen. Sie ähneln Speicheranweisungen, da sie auf das RiscVState
-Objekt zugreifen müssen. Sie sind jedoch komplexer, da jede Ladeanweisung in zwei separate semantische Funktionen unterteilt ist. Die erste ähnelt der Store-Anweisung, da sie die effektive Adresse berechnet und den Speicherzugriff initiiert. Die zweite wird ausgeführt, wenn der Speicherzugriff abgeschlossen ist, und schreibt die Speicherdaten in den Zieloperanden des Registers.
Sehen wir uns zuerst die Deklaration der LoadMemory
-Methode in RiscVState
an:
void LoadMemory(const Instruction *inst, uint64_t address, DataBuffer *db,
Instruction *child_inst, ReferenceCount *context);
Im Vergleich zur StoreMemory
-Methode benötigt LoadMemory
zwei zusätzliche Parameter: einen Zeiger auf eine Instruction
-Instanz und einen Zeiger auf ein als Referenz gezähltes context
-Objekt. Ersteres ist die Anweisung child, mit der die Rückschreibefunktion für das Register implementiert wird, wie in der Anleitung zum ISA-Decodierer beschrieben. Der Zugriff erfolgt über die Methode child()
in der aktuellen Instruction
-Instanz.
Letzteres ist ein Zeiger auf eine Instanz einer Klasse, die von ReferenceCount
abgeleitet ist und in diesem Fall eine DataBuffer
-Instanz speichert, die die geladenen Daten enthält. Das Kontextobjekt ist über die Methode context()
im Objekt Instruction
verfügbar (in den meisten Anweisungen ist es jedoch auf nullptr
gesetzt).
Das Kontextobjekt für RiscV-Speicherladungen ist als folgende Struktur definiert:
// 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;
};
Die Ladeanweisungen sind alle gleich, mit Ausnahme der Datengröße (Byte, Halbwort und Wort) und ob der geladene Wert vorzeichenerweitert ist oder nicht. Letzteres wird nur in die child-Anweisung einbezogen. Erstellen wir eine Vorlagen-Hilfsfunktion für die Hauptladeanweisungen. Sie ähnelt der Store-Anweisung, mit der Ausnahme, dass sie nicht auf einen Quelloperanden zugreift, um einen Wert abzurufen, und ein Kontextobjekt erstellt.
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();
}
Wie Sie sehen, besteht der Hauptunterschied darin, dass die zugewiesene Instanz DataBuffer
sowohl als Parameter an den LoadMemory
-Aufruf übergeben als auch im Objekt LoadContext
gespeichert wird.
Die semantischen Funktionen der Anweisung child sind alle sehr ähnlich. Zuerst wird das LoadContext
durch Aufrufen der Instruction
-Methode context()
abgerufen und statisch in LoadContext *
umgewandelt. Danach wird der Wert (entsprechend dem Datentyp) aus der DataBuffer
-Instanz „load-data“ gelesen. Drittens wird eine neue DataBuffer
-Instanz aus dem Zieloperanden zugewiesen. Schließlich wird der geladene Wert in die neue DataBuffer
-Instanz geschrieben und Submit
ausgegeben. Auch hier ist eine Hilfsvorlage eine gute Idee:
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();
}
Implementieren Sie nun die letzten Hilfsfunktionen und semantischen Funktionen. Achten Sie darauf, dass der Datentyp, den Sie in der Vorlage für jeden Aufruf der Hilfsfunktion verwenden, der Größe und dem signierten/unsignierten Charakter der Ladeanweisung entspricht.
Sie können Ihre Arbeit mit rv32i_instructions.cc vergleichen.
Endgültigen Simulator erstellen und ausführen
Nachdem wir die ganze harte Arbeit erledigt haben, können wir den endgültigen Simulator erstellen. Die C++-Bibliotheken der obersten Ebene, die alle Aufgaben in diesen Anleitungen zusammenfassen, befinden sich in other/
. Sie müssen sich diesen Code nicht genauer ansehen. Wir werden dieses Thema in einer späteren Anleitung für Fortgeschrittene behandeln.
Ändern Sie Ihr Arbeitsverzeichnis in other/
und erstellen Sie den Build. Das sollte ohne Fehler funktionieren.
$ cd ../other
$ bazel build :rv32i_sim
In diesem Verzeichnis befindet sich in der Datei hello_rv32i.elf
ein einfaches „Hello World“-Programm. So führen Sie den Simulator für diese Datei aus und sehen sich die Ergebnisse an:
$ bazel run :rv32i_sim -- other/hello_rv32i.elf
Sie sollten etwas Ähnliches sehen:
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
$
Der Simulator kann auch mit dem Befehl bazel
run :rv32i_sim -- -i other/hello_rv32i.elf
in einem interaktiven Modus ausgeführt werden. Dadurch wird eine einfache Befehlsshell geöffnet. Geben Sie help
in die Eingabeaufforderung ein, um die verfügbaren Befehle anzuzeigen.
$ 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] >
Damit ist diese Anleitung abgeschlossen. Wir hoffen, dass Ihnen diese Informationen weiterhelfen.