Tutorial sulle funzioni semantiche dell'istruzione

Gli obiettivi di questo tutorial sono:

  • Scopri come vengono utilizzate le funzioni semantiche per implementare la semantica dell'istruzione.
  • Scoprire la relazione tra le funzioni semantiche e la descrizione del decoder ISA.
  • Scrivere funzioni semantiche per le istruzioni RiscV RV32I.
  • Testa il simulatore finale eseguendo un piccolo "Hello World" eseguibile.

Panoramica delle funzioni semantiche

Una funzione semantica in MPACT-Sim è una funzione che implementa l'operazione di un'istruzione in modo che i suoi effetti collaterali siano visibili nello stato simulato allo stesso modo in cui gli effetti collaterali dell'istruzione sono visibili hardware. Rappresentazione interna del simulatore di ogni istruzione decodificata contiene un elemento richiamabile utilizzato per richiamare la funzione semantica istruzioni.

Una funzione semantica ha la firma void(Instruction *), ovvero una che prende un puntatore a un'istanza della classe Instruction e restituisce void.

La classe Instruction è definita in instruction.h

Per scrivere funzioni semantiche, siamo particolarmente interessati i vettori dell'interfaccia dell'operando di origine e di destinazione a cui si accede utilizzando Chiamate ai metodi Source(int i) e Destination(int i).

Di seguito sono mostrate le interfacce degli operando di origine e di destinazione:

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

Il modo di base di scrivere una funzione semantica per un operando normale di 3 come un'istruzione add a 32 bit è il seguente:

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

Analizziamo gli elementi di questa funzione. Le due prime righe della Il corpo della funzione legge dagli operandi di origine 0 e 1. La chiamata a AsUint32(0) interpreta i dati sottostanti come un array uint32_t e recupera lo 0° . Ciò vale indipendentemente dal fatto che il registro o il valore sottostante con o senza valore. Le dimensioni (in elementi) dell'operando di origine possono essere ottenuto dal metodo operando di origine shape(), che restituisce un vettore contenente il numero di elementi in ogni dimensione. Questo metodo restituisce {1} per uno scalare, {16} per un vettore di 16 elementi e {4, 4} per un array 4x4.

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

A questo punto, a un uint32_t temporaneo denominato c viene assegnato il valore a + b.

Nella riga successiva potrebbero essere necessarie ulteriori spiegazioni:

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

Un DataBuffer è un oggetto conteggiato ai fini di riferimento utilizzato per archiviare i valori in stato simulato, come i registri. È relativamente non digitato, sebbene abbia una dimensioni in base all'oggetto da cui è allocato. In questo caso, la dimensione è sizeof(uint32_t). Questa istruzione alloca un nuovo buffer di dati dimensionato per destinazione che è la destinazione di questo operando di destinazione, in questo caso una Registro di numeri interi a 32 bit. DataBuffer viene inoltre inizializzato con latenza dell'architettura per l'istruzione. Questo valore viene specificato durante l'istruzione decodificare.

Nella riga successiva l'istanza del buffer viene trattata come un array di uint32_t e scrive il valore memorizzato in c nell'0° elemento.

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

Infine, l'ultima istruzione invia il buffer di dati al simulatore da utilizzare come nuovo valore dello stato della macchina target (in questo caso un registro) dopo latenza dell'istruzione impostata al momento della decodifica dell'istruzione vettore di operando di destinazione compilato.

Anche se si tratta di una funzione ragionevolmente breve, ha comunque un po' di boilerplate che diventa ripetitivo quando si implementa l'istruzione dopo le istruzioni. Inoltre, può oscurare la semantica effettiva dell'istruzione. Nell'ordine per semplificare ulteriormente la scrittura delle funzioni semantiche per la maggior parte delle istruzioni, esistono varie funzioni helper basate su modelli definite in instruction_helpers.h. Questi assistenti nascondono il codice boilerplate per istruzioni con uno, due o tre operandi di origine e un singolo operando di destinazione. Diamo un'occhiata a un paio funzioni helper dell'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);
}

Noterai che invece di utilizzare un'istruzione come la seguente:

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

La funzione helper utilizza:

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

GetInstructionSource è una famiglia di funzioni helper basate su modelli che vengono usati per fornire metodi di accesso basati su modelli all'origine dell'istruzione operandi. Senza di essi, ogni funzione di aiuto delle istruzioni avrebbe specializzato per ogni tipo per accedere all'operando di origine con Funzione As<int type>(). Puoi vedere le definizioni di questi modelli in instruction.h.

Come puoi vedere ci sono tre implementazioni, a seconda che l'origine tipi di operando sono gli stessi della destinazione, indipendentemente dal fatto che la destinazione sia diverse dalle fonti o se sono tutte diverse. Ogni versione la funzione porta un puntatore all'istanza dell'istruzione, nonché una funzione (incluse le funzioni lambda). Ciò significa che ora possiamo riscrivere add funzione semantica riportata sopra:

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

Quando viene compilata con bazel build -c opt e copts = ["-O3"] nella build dovrebbe essere completamente in linea senza overhead, dandoci informazioni concisione senza penalità in termini di prestazioni.

Come accennato in precedenza, esistono le funzioni helper per scalari unari, binarie e ternari istruzioni ed equivalenti vettoriali. Servono anche come modelli utili per creare i tuoi aiutanti per istruzioni che non sono adatte a tutti gli schemi.


Build iniziale

Se non hai modificato la directory in riscv_semantic_functions, procedi nel seguente modo: per ora. Quindi crea il progetto come segue. Questa build dovrebbe riuscire.

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

Non vengono generati file, quindi questa è solo una prova assicurati che sia tutto in ordine.


Aggiungi istruzioni ALU di tre operando

Ora aggiungiamo le funzioni semantiche per alcune ALU generiche a tre operandi istruzioni. Apri il file rv32i_instructions.cc e assicurati che qualsiasi le definizioni mancanti vengono aggiunte al file rv32i_instructions.h man mano che procedi.

Ecco le istruzioni che aggiungeremo:

  • add: aggiunta di numeri interi a 32 bit.
  • and: bit a 32 bit e.
  • or: bit per bit a 32 bit o.
  • sll: spostamento logico a 32 bit verso sinistra.
  • sltu: set non firmato a 32 bit minore di.
  • sra: spostamento destro aritmetico a 32 bit.
  • srl: spostamento logico a destra a 32 bit.
  • sub: sottrarre il numero intero a 32 bit.
  • xor: xor a 32 bit per bit.

Se hai svolto i tutorial precedenti, ricorderai che abbiamo distinto tra le istruzioni registry-register e le istruzioni registry-immediate in il decoder. Quando si tratta di funzioni semantiche, non è più necessario farlo. Le interfacce degli operando leggono il valore dell'operando da qualsiasi registro o immediato, con la funzione semantica completamente indipendente sull'operando dell'origine sottostante.

Fatta eccezione per sra, tutte le istruzioni riportate sopra possono essere considerate operative su Valori non firmati a 32 bit, quindi per questi valori possiamo usare la funzione modello BinaryOp che abbiamo esaminato in precedenza solo con un singolo argomento tipo di modello. Compila il campo della funzione in rv32i_instructions.cc di conseguenza. Tieni presente che solo i 5 bit del secondo operando alle istruzioni di spostamento vengono utilizzati per lo spostamento importo. In caso contrario, tutte le operazioni sono nel 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

Per sra useremo il modello BinaryOp con tre argomenti. Esaminando , il primo argomento di tipo è il tipo di risultato uint32_t. Il secondo è il tipo di operando di origine 0, in questo caso int32_t, e l'ultimo è il tipo dell'operando di origine 1, in questo caso uint32_t. Questo rende il corpo di sra funzione semantica:

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

Apporta le modifiche e crea. Puoi verificare il tuo lavoro rispetto a rv32i_instructions.cc.


Aggiungi istruzioni ALU di due operando

Esistono solo due istruzioni ALU con due operandi: lui e auipc. Il primo copia l'operando di origine pre-spostato direttamente nella destinazione. Quest'ultimo aggiunge l'indirizzo dell'istruzione nell'immediato prima di scriverlo destinazione. L'indirizzo dell'istruzione è accessibile dal metodo address() dell'oggetto Instruction.

Poiché esiste un solo operando di origine, non possiamo usare BinaryOp. dobbiamo usare UnaryOp. Poiché possiamo trattare sia l'origine che operandi di destinazione come uint32_t possiamo usare il modello di argomento singolo completamente gestita.

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

Il corpo della funzione semantica per lui è abbastanza banale, che restituisce solo l'origine. La funzione semantica per auipc introduce un perché devi accedere al metodo address() in Instruction in esecuzione in un'istanza Compute Engine. La risposta è aggiungere instruction all'acquisizione Lambda, in modo che disponibile per l'uso nel corpo della funzione lambda. Invece di [](uint32_t a) { ... } come prima, la funzione lambda deve essere scritta [instruction](uint32_t a) { ... }. Ora è possibile utilizzare instruction nel corpo lambda.

Apporta le modifiche e crea. Puoi verificare il tuo lavoro rispetto a rv32i_instructions.cc.


Aggiungi istruzioni per la modifica del flusso di controllo

Le istruzioni per la modifica del flusso di controllo da implementare sono suddivise in istruzioni di ramo condizionali (rami più brevi eseguiti se un confronto è valido) e le istruzioni jump-and-link, che consentono di implementare le chiamate di funzione (il tag -and-link viene rimosso impostando il link registrare a zero, in modo che le scritture non siano operative).

Aggiungi istruzioni per i ramo condizionali

Non esiste una funzione helper per l'istruzione ramo, quindi sono disponibili due opzioni. Scrivere le funzioni semantiche da zero o scrivere una funzione helper locale. Dato che dobbiamo implementare sei istruzioni di ramo, quest'ultimo sembra valga la pena impegno. Prima di farlo, diamo un'occhiata all'implementazione di un ramo funzione semantica dell'istruzione da zero.

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

L'unica cosa che varia da un'istruzione all'altra è il ramo e i tipi di dati (Int a 32 bit con o senza firma), operandi di origine. Ciò significa che dobbiamo avere un parametro di modello per operandi di origine. La funzione helper stessa deve utilizzare Instruction e un oggetto richiamabile come std::function che restituisce bool come parametri. La funzione helper dovrebbe avere il seguente aspetto:

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

Ora possiamo scrivere la funzione semantica bge (ramo firmato maggiore o uguale) come:

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

Le altre istruzioni per il ramo sono le seguenti:

  • Beq: ramo uguale.
  • Bgeu: ramo maggiore o uguale (non firmato).
  • Blt: ramo minore di (firmato).
  • Bltu: ramo minore di (non firmato).
  • Bne: ramo diverso.

Apporta le modifiche necessarie per implementare queste funzioni semantiche e ricreare. Puoi verificare il tuo lavoro rispetto a rv32i_instructions.cc.

Non ha senso scrivere una funzione helper per il salto e il link istruzioni, perciò dovremo scriverle partendo da zero. Iniziamo con esaminando la semantica delle istruzioni.

L'istruzione jal prende un offset dall'operando di origine 0 e lo aggiunge alla pc (indirizzo di istruzioni) attuale per calcolare la destinazione del salto. Il target jumping viene scritto nell'operando di destinazione 0. L'indirizzo di restituzione è l'indirizzo del l'istruzione sequenziale successiva. Può essere calcolato aggiungendo il valore attuale dimensione dell'istruzione al suo indirizzo. L'indirizzo di restituzione viene scritto l'operando di destinazione 1. Ricordati di includere il puntatore dell'oggetto di istruzione la cattura di Lambda.

L'istruzione jalr prende un registro di base come operando di origine 0 e un offset come operando di origine 1 e li somma per calcolare la destinazione del salto. In caso contrario, è identico all'istruzione jal.

In base a queste descrizioni della semantica dell'istruzione, scrivi le due risposte e build. Puoi verificare il tuo lavoro rispetto a rv32i_instructions.cc.


Aggiungi istruzioni per l'archivio di memoria

È necessario implementare tre istruzioni per lo store: il byte di archiviazione (sb), memorizza la mezza parola (sh) e memorizza la parola (sw). Istruzioni per il negozio da quelle che abbiamo implementato finora, in quanto le istruzioni in scrittura nello stato del processore locale. ma scrivono su una risorsa di sistema memoria principale. MPACT-Sim non tratta la memoria come un operando di istruzione, quindi l'accesso alla memoria deve essere eseguito utilizzando un'altra metodologia.

La soluzione è aggiungere metodi di accesso alla memoria all'oggetto MPACT-Sim ArchState, o, più correttamente, crea un nuovo oggetto di stato RiscV derivato da ArchState in cui è possibile aggiungerlo. L'oggetto ArchState gestisce le risorse principali, ad esempio registri e altri oggetti di stato. Gestisce anche le linee di ritardo utilizzate bufferizza i buffer di dati degli operando di destinazione finché non possono essere riscritti gli oggetti del registro. La maggior parte delle istruzioni può essere implementata all'insaputa questa classe, ma alcune, come le operazioni di memoria e altre risorse istruzioni richiedono la funzionalità per risiedere in questo oggetto di stato.

Diamo un'occhiata alla funzione semantica dell'istruzione fence, già implementata in rv32i_instructions.cc, ad esempio. fence l'istruzione contiene il problema dell'istruzione fino a quando completata. Viene utilizzato per garantire l'ordinamento della memoria tra le istruzioni eseguite prima dell'istruzione e successive.

// 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 principale della funzione semantica dell'istruzione fence sono le ultime due linee. Innanzitutto, l'oggetto di stato viene recuperato utilizzando un metodo in Instruction e downcast<> alla classe derivata specifica RiscV. Fence della classe RiscVState viene chiamato per eseguire l'operazione di recinto.

Le istruzioni per il negozio funzionano allo stesso modo. Innanzitutto, l'indirizzo effettivo del l'accesso alla memoria viene calcolato dagli operandi di origine dell'istruzione di base e offset, il valore da archiviare viene recuperato dall'operando di origine successivo. Poi, Si ottiene mediante la chiamata al metodo state() e static_cast<> e viene chiamato il metodo appropriato.

Il metodo dell'oggetto RiscVState StoreMemory è relativamente semplice, ma presenta una alcune implicazioni da tenere presenti:

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

Come abbiamo visto, il metodo prende tre parametri: il puntatore l'istruzione stessa, l'indirizzo del negozio e un puntatore a un DataBuffer contenente i dati del datastore. Tieni presente che non sono richieste dimensioni L'istanza DataBuffer stessa contiene un metodo size(). Tuttavia, non c'è l'operando di destinazione accessibile all'istruzione e che può essere utilizzato alloca un'istanza DataBuffer delle dimensioni appropriate. Dobbiamo invece utilizza una fabbrica DataBuffer ottenuta dal metodo db_factory() in l'istanza Instruction. Il campo di fabbrica utilizza un metodo Allocate(int size) che restituisce un'istanza DataBuffer delle dimensioni richieste. Ecco un esempio di come utilizzarlo per allocare un'istanza DataBuffer per un archivio di mezza parola (nota che auto è una funzionalità C++ che deduce il tipo dal lato destro lato del compito):

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

Una volta ottenuta l'istanza DataBuffer, possiamo scrivere come al solito:

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

Quindi passalo all'interfaccia del datastore:

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

Non abbiamo ancora finito. Il riferimento viene conteggiato per l'istanza DataBuffer. Questo viene normalmente compreso e gestito con il metodo Submit, in modo da mantenere il il più semplice possibile. Tuttavia, StoreMemory non è scritto in questo modo. IncRef l'istanza DataBuffer mentre è in funzione e DecRef al termine dell'operazione. Tuttavia, se la funzione semantica non DecRef il suo riferimento, non verrà mai rivendicato. Di conseguenza, l'ultima riga in modo che sia:

  db->DecRef();

Ci sono tre funzioni di negozio e l'unica cosa a differenza è la dimensione l'accesso alla memoria. Mi sembra una grande opportunità per un'altra persona la funzione helper basata su modelli. L'unica differenza nella funzione store è il tipo di valore del negozio, quindi il modello deve avere questo valore come argomento. A parte questo, solo l'istanza Instruction deve essere passata:

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

Completa le funzioni semantiche del datastore e crea. Puoi controllare lavorare contro rv32i_instructions.cc.


Aggiungi istruzioni per il caricamento della memoria

Le istruzioni di caricamento da implementare sono le seguenti:

  • lb: byte di caricamento, estensione del segno in una parola.
  • lbu: carica byte non firmati, zero-extend in una parola.
  • lh - carica la metà della parola, firma e amplia in una parola.
  • lhu: carica la metà della parola senza firma, l'estensione zero in una parola.
  • lw: carica parola.

Le istruzioni di caricamento sono le più complesse che abbiamo bisogno di modellare questo tutorial. Sono simili alle istruzioni del negozio, in quanto devono accedere all'oggetto RiscVState, ma aggiunge complessità in ogni caricamento istruzioni è divisa in due funzioni semantiche separate. La prima è simile all'istruzione store, in quanto calcola l'indirizzo effettivo e avvia l'accesso alla memoria. Il secondo viene eseguito quando viene completato e scrive i dati della memoria nella destinazione del registro operando.

Iniziamo esaminando la dichiarazione del metodo LoadMemory in RiscVState:

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

Rispetto al metodo StoreMemory, LoadMemory richiede due volte in più : un puntatore a un'istanza Instruction e un puntatore a un riferimento contato context oggetto. La prima è l'istruzione child che implementa il write-back del registro (descritto nel tutorial sul decoder ISA). it si accede utilizzando il metodo child() nell'istanza Instruction corrente. Quest'ultimo è un puntatore a un'istanza di una classe che deriva ReferenceCount che in questo caso archivia un'istanza DataBuffer che che contengono i dati caricati. L'oggetto di contesto è disponibile tramite Metodo context() nell'oggetto Instruction (anche se per la maggior parte delle istruzioni il valore è impostato su nullptr).

L'oggetto di contesto per i caricamenti di memoria RiscV è definito come il seguente 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;
};

Le istruzioni per il caricamento sono le stesse, ad eccezione della dimensione dei dati (byte, mezza parola e parola) e indica se il valore caricato è esteso o meno. La Quest'ultimo tiene conto solo dell'istruzione child. Creiamo un modello la funzione helper per le istruzioni relative al caricamento principale. Sarà molto simile di archiviare un'istruzione, ma non accederà a un operando di origine per ottenere un valore, e verrà creato un oggetto di contesto.

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

Come puoi vedere, la differenza principale è che l'istanza DataBuffer allocata vengono sia passate alla chiamata LoadMemory come parametro, sia memorizzate nel LoadContext oggetto.

Le funzioni semantiche dell'istruzione child sono tutte molto simili. In primo luogo, LoadContext si ottiene chiamando il metodo Instruction context() e trasmesso in modo statico sul LoadContext *. Secondo, il valore (in base ai dati ) vengono lette dall'istanza DataBuffer con i dati di caricamento. In terzo luogo, un nuovo L'istanza DataBuffer è allocata dall'operando di destinazione. Infine, il valore caricato viene scritto nella nuova istanza DataBuffer e viene inserito Submit. Anche in questo caso, una funzione helper basata su modelli è una buona 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 le ultime funzioni helper e le funzioni semantiche. Paga al tipo di dati che utilizzi nel modello per ogni funzione helper e che corrisponda alle dimensioni e alla natura firmata/non firmata del caricamento istruzioni.

Puoi verificare il tuo lavoro rispetto a rv32i_instructions.cc.


Crea ed esegui il simulatore finale

Ora che abbiamo fatto tutte le domande difficili, possiamo costruire il simulatore finale. La librerie C++ di primo livello che collegano insieme tutto il lavoro di questi tutorial sono si trova in other/. Non è necessario esaminare troppo attentamente quel codice. Me visiterai quell'argomento in un tutorial avanzato futuro.

Cambia la directory di lavoro in other/ e crea. Deve creare senza errori.

$ cd ../other
$ bazel build :rv32i_sim

Nella directory c'è un semplice "Hello World" nel file hello_rv32i.elf. Per eseguire il simulatore su questo file e vedere i risultati:

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

I risultati dovrebbero essere simili ai seguenti:

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
$

Il simulatore può essere eseguito anche in modalità interattiva utilizzando il comando bazel run :rv32i_sim -- -i other/hello_rv32i.elf. Viene visualizzata una semplice shell dei comandi. Digita help al prompt per visualizzare i comandi disponibili.

$ 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] >

Il tutorial termina qui. Ci auguriamo che sia stato utile.