Tutoriel sur les fonctions sémantiques d'instruction

Les objectifs de ce tutoriel sont les suivants:

  • Découvrez comment les fonctions sémantiques sont utilisées pour implémenter la sémantique des instructions.
  • Découvrez le lien entre les fonctions sémantiques et la description du décodeur ISA.
  • Écrire les fonctions sémantiques d'instructions pour les instructions RiscV RV32I.
  • Testez le simulateur final en exécutant un petit "Hello World" exécutable.

Présentation des fonctions sémantiques

Dans MPACT-Sim, une fonction sémantique met en œuvre l'opération d'une instruction afin que ses effets secondaires soient visibles à l'état simulé de la même manière que les effets secondaires de l'instruction sont visibles lorsqu'ils sont exécutés dans matériel. Représentation interne de chaque instruction décodée par le simulateur contient un appelable qui est utilisé pour appeler la fonction sémantique correspondant à instruction.

Une fonction sémantique a la signature void(Instruction *), c'est-à-dire une qui accepte un pointeur vers une instance de la classe Instruction et renvoie void.

La classe Instruction est définie dans instruction.h

Pour écrire des fonctions sémantiques, nous nous intéressons les vecteurs d'interface d'opérande source et de destination auxquels vous accédez à l'aide de la méthode Appels de méthode Source(int i) et Destination(int i).

Les interfaces d'opérande source et de destination sont présentées ci-dessous:

// The source operand interface provides an interface to access input values
// to instructions in a way that is agnostic about the underlying implementation
// of those values (eg., register, fifo, immediate, predicate, etc).
class SourceOperandInterface {
 public:
  // Methods for accessing the nth value element.
  virtual bool AsBool(int index) = 0;
  virtual int8_t AsInt8(int index) = 0;
  virtual uint8_t AsUint8(int index) = 0;
  virtual int16_t AsInt16(int index) = 0;
  virtual uint16_t AsUint16(int) = 0;
  virtual int32_t AsInt32(int index) = 0;
  virtual uint32_t AsUint32(int index) = 0;
  virtual int64_t AsInt64(int index) = 0;
  virtual uint64_t AsUint64(int index) = 0;

  // Return a pointer to the object instance that implements the state in
  // question (or nullptr) if no such object "makes sense". This is used if
  // the object requires additional manipulation - such as a fifo that needs
  // to be pop'ed. If no such manipulation is required, nullptr should be
  // returned.
  virtual std::any GetObject() const = 0;

  // Return the shape of the operand (the number of elements in each dimension).
  // For instance {1} indicates a scalar quantity, whereas {128} indicates an
  // 128 element vector quantity.
  virtual std::vector<int> shape() const = 0;

  // Return a string representation of the operand suitable for display in
  // disassembly.
  virtual std::string AsString() const = 0;

  virtual ~SourceOperandInterface() = default;
};
// The destination operand interface is used by instruction semantic functions
// to get a writable DataBuffer associated with a piece of simulated state to
// which the new value can be written, and then used to update the value of
// the piece of state with a given latency.
class DestinationOperandInterface {
 public:
  virtual ~DestinationOperandInterface() = default;
  // Allocates a data buffer with ownership, latency and delay line set up.
  virtual DataBuffer *AllocateDataBuffer() = 0;
  // Takes an existing data buffer, and initializes it for the destination
  // as if AllocateDataBuffer had been called.
  virtual void InitializeDataBuffer(DataBuffer *db) = 0;
  // Allocates and initializes data buffer as if AllocateDataBuffer had been
  // called, but also copies in the value from the current value of the
  // destination.
  virtual DataBuffer *CopyDataBuffer() = 0;
  // Returns the latency associated with the destination operand.
  virtual int latency() const = 0;
  // Return a pointer to the object instance that implmements the state in
  // question (or nullptr if no such object "makes sense").
  virtual std::any GetObject() const = 0;
  // Returns the order of the destination operand (size in each dimension).
  virtual std::vector<int> shape() const = 0;
  // Return a string representation of the operand suitable for display in
  // disassembly.
  virtual std::string AsString() const = 0;
};

La méthode de base pour écrire une fonction sémantique pour un opérande 3 normal d'une instruction telle qu'une instruction add 32 bits est la suivante:

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

Décomposons les éléments de cette fonction. Les deux premières lignes du le corps de la fonction lit les opérandes sources 0 et 1. L'appel AsUint32(0) interprète les données sous-jacentes comme un tableau uint32_t et extrait la valeur 0 . Cela est vrai que le registre ou la valeur sous-jacents avec ou sans valeur dans le tableau. La taille (en éléments) de l'opérande source peut être de obtenu à partir de la méthode d'opérande source shape(), qui renvoie un vecteur contenant le nombre d'éléments dans chaque dimension. Cette méthode renvoie {1} pour une valeur scalaire, {16} pour un vecteur à 16 éléments et {4, 4} pour un tableau 4x4.

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

Ensuite, un uint32_t temporaire nommé c se voit attribuer la valeur a + b.

La ligne suivante peut nécessiter quelques explications supplémentaires:

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

Un DataBuffer est un objet compté de référence qui est utilisé pour stocker des valeurs dans des états simulés tels que des registres. Il n'est pas typé, bien qu'il comporte une en fonction de l'objet auquel elle est allouée. Dans ce cas, cette taille est sizeof(uint32_t) Cette instruction alloue un nouveau tampon de données de taille destination qui est la cible de cet opérande de destination. Dans le cas présent, Registre d'entiers 32 bits. Le DataBuffer est également initialisé avec la latence de l'architecture pour l'instruction. Cette valeur est spécifiée lors de l'instruction le décodage.

La ligne suivante traite l'instance de tampon de données comme un tableau de uint32_t et écrit la valeur stockée dans c dans l'élément 0.

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

Enfin, la dernière instruction envoie le tampon de données au simulateur pour qu'il soit utilisé comme nouvelle valeur de l'état de la machine cible (dans ce cas, un registre) après la la latence de l'instruction définie lors du décodage de l'instruction et vecteur d'opérande de destination renseigné.

Bien qu'il s'agisse d'une fonction relativement brève, elle comporte un peu de code récurrent qui devient répétitif lors de l'implémentation d'instructions à la suite de l'instruction. De plus, cela peut masquer la sémantique réelle de l'instruction. Dans la commande afin de simplifier davantage l'écriture des fonctions sémantiques pour la plupart des instructions, un certain nombre de fonctions helper modélisées sont définies dans instruction_helpers.h : Ces assistants masquent le code récurrent des instructions avec un, deux ou trois des opérandes sources et un seul opérande de destination. Jetons un coup d’œil à deux fonctions d'assistance d'opérande:

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

Vous remarquerez qu'au lieu d'utiliser une instruction comme:

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

La fonction d'assistance utilise:

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

GetInstructionSource est une famille de fonctions d'assistance modélisées qui sont utilisés pour fournir des méthodes d'accès modélisées à la source d'instructions opérandes. Sans elles, chacune des fonctions d'assistance des instructions aurait "spécialisé pour chaque type" pour accéder à l'opérande source contenant fonction As<int type>(). Vous pouvez consulter les définitions de ces modèles fonctions dans instruction.h.

Comme vous pouvez le voir, il existe trois implémentations, selon que la source les types d'opérande sont identiques à la destination, que celle-ci soit différentes des sources, ou si elles sont toutes différentes. Chaque version de La fonction utilise un pointeur vers l'instance d'instruction (qui incluent les fonctions lambda). Cela signifie que nous pouvons désormais réécrire add fonction sémantique ci-dessus, comme suit:

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

Lorsqu'il est compilé avec bazel build -c opt et copts = ["-O3"] dans le build celui-ci doit s'aligner entièrement et ne pas avoir de frais généraux, ce qui nous donne succincte sans nuire aux performances.

Comme indiqué précédemment, il existe des fonctions d'assistance pour les valeurs scalaires unaires, binaires et ternaires. d'instructions et d'équivalents vectoriels. Ils servent aussi de modèles utiles pour créer vos propres assistants pour des instructions qui ne correspondent pas au modèle général.


Compilation initiale

Si vous n'avez pas remplacé le répertoire par riscv_semantic_functions, faites-le dès maintenant. Compilez ensuite le projet comme suit. Cette compilation devrait aboutir.

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

Aucun fichier n'est généré. Il s'agit donc d'une simulation vous assurer que tout est en ordre.


Ajouter trois instructions ALU d'opérande

Ajoutons maintenant les fonctions sémantiques pour une ALU générique à 3 opérandes. instructions. Ouvrez le fichier rv32i_instructions.cc et assurez-vous que les les définitions manquantes sont ajoutées au fichier rv32i_instructions.h au fur et à mesure.

Les instructions que nous allons ajouter sont les suivantes:

  • add : nombre total de nombres entiers 32 bits additionnel.
  • and : et 32 bits au niveau du bit.
  • or : ou 32 bits au niveau du bit.
  • sll : décalage logique de 32 bits vers la gauche.
  • sltu : ensemble 32 bits non signé inférieur à.
  • sra : décalage arithmétique vers la droite 32 bits.
  • srl : décalage logique vers la droite sur 32 bits.
  • sub : soustraction d'un entier de 32 bits.
  • xor : fonction XOR 32 bits.

Si vous avez suivi les tutoriels précédents, vous vous souvenez peut-être que nous avons distingué entre les instructions d'enregistrement et d'enregistrement et les instructions d'enregistrement immédiat dans le décodeur. Nous n'avons plus besoin de faire cela pour les fonctions sémantiques. Les interfaces d'opérande lisent la valeur de l'opérande à partir de n'importe quel opérande est, registre ou immédiat, avec la fonction sémantique complètement agnostique par rapport à ce qu'est vraiment l'opérande source sous-jacent.

À l'exception de sra, toutes les instructions ci-dessus peuvent être considérées comme fonctionnant sur Valeurs non signées de 32 bits. Pour celles-ci, nous pouvons utiliser la fonction de modèle BinaryOp. que nous avons vu précédemment avec un seul argument de type de modèle. Remplissez corps de fonction dans rv32i_instructions.cc en conséquence. Notez que seuls les 5 premiers chiffres bits du deuxième opérande vers les instructions shift sont utilisés pour le décalage montant. Sinon, toutes les opérations se présentent sous la forme 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

Pour sra, nous utiliserons le modèle à trois arguments BinaryOp. En consultant modèle, le premier argument "type" est le type de résultat uint32_t. Le deuxième est le type d'opérande source 0, ici int32_t, le dernier étant le type de l'opérande source 1, dans ce cas uint32_t. Ainsi, le corps de sra fonction sémantique suivante:

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

Apportez les modifications nécessaires et continuez. Vous pouvez comparer votre travail par rapport rv32i_instructions.cc.


Ajouter deux instructions ALU d'opérande

Il n'y a que deux instructions ALU à deux opérandes: lui et auipc. L'ancienne copie l'opérande source pré-décalé directement dans la destination. La seconde ajoute l'adresse d'instruction à la valeur "immédiate" avant de l'écrire dans le vers votre destination. L'adresse de l'instruction est accessible à partir de la méthode address(). de l'objet Instruction.

Puisqu'il n'y a qu'un seul opérande source, nous ne pouvons pas utiliser BinaryOp à la place. nous devons utiliser UnaryOp. Puisque nous pouvons traiter à la fois la source et opérandes de destination comme uint32_t, nous pouvons utiliser le modèle à argument unique version.

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

Le corps de la fonction sémantique de lui est aussi simple que possible. il suffit de renvoyer la source. La fonction sémantique de auipc introduit un élément mineur car vous devez accéder à la méthode address() dans Instruction Compute Engine. La solution consiste à ajouter instruction à la capture lambda, ce qui disponibles dans le corps de la fonction lambda. Au lieu de [](uint32_t a) { ... } comme précédemment, le lambda doit être écrit [instruction](uint32_t a) { ... }. instruction peut maintenant être utilisé dans le corps du lambda.

Apportez les modifications nécessaires et continuez. Vous pouvez comparer votre travail par rapport rv32i_instructions.cc.


Ajouter des instructions pour modifier le flux de contrôle

Les instructions de modification du flux de contrôle que vous devez implémenter sont réparties en deux parties : en instructions de branche conditionnelles (branches plus courtes exécutées si une comparaison est vraie) et les instructions de renvoi et de lien, qui sont utilisées pour implémenter les appels de fonction (l'élément -and-link est supprimé en définissant le lien à zéro, ce qui rend ces écritures no-ops).

Ajouter des instructions pour les branches conditionnelles

Il n'existe pas de fonction d'assistance pour les instructions de branchement. Il existe donc deux options. Écrivez les fonctions sémantiques à partir de zéro ou écrivez une fonction d'assistance locale. Étant donné que nous devons implémenter six instructions pour les branches, la dernière semble en valoir la peine d'efforts. Avant cela, examinons l'implémentation d'une branche la fonction sémantique d'instruction en partant de zéro.

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

La seule chose qui varie selon les instructions sur la branche est la branche et les types de données, int 32 bits signés et non signés, des deux les opérandes sources. Cela signifie que nous devons disposer d'un paramètre de modèle les opérandes sources. La fonction d'assistance elle-même doit prendre la Instruction et un objet appelable comme std::function qui renvoie bool comme paramètres. La fonction d'assistance ressemblerait à ceci:

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

Nous pouvons maintenant écrire la fonction sémantique bge (branche signée supérieure ou égale). en tant que:

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

Les instructions pour les branches restantes sont les suivantes:

  • Beq - branch equal.
  • Bgeu - Branche supérieure ou égale (non signée)
  • BLT - Branche inférieure à (signée).
  • Bltu : branche inférieure à (non signée).
  • Bne - branche différente.

Apportez les modifications nécessaires pour implémenter ces fonctions sémantiques. recompiler. Vous pouvez comparer votre travail par rapport rv32i_instructions.cc.

Il n'y a aucun intérêt à écrire une fonction d'assistance pour le saut et le lien. des instructions, nous devrons donc les écrire à partir de zéro. Commençons par en examinant la sémantique de leurs instructions.

L'instruction jal extrait un décalage de l'opérande source 0 et l'ajoute à la pc actuelle (adresse d'instruction) pour calculer la cible du saut. La cible du saut est écrite dans l'opérande de destination 0. L'adresse de retour est celle l'instruction séquentielle suivante. Elle peut être calculée en ajoutant la valeur la taille de l'instruction à son adresse. L'adresse de retour est écrite dans Opérande de destination 1. N'oubliez pas d'inclure le pointeur d'objet d'instruction la capture lambda.

L'instruction jalr utilise un registre de base en tant qu'opérande source 0 et un décalage en tant qu'opérande source 1 et les additionne pour calculer la cible de saut. Dans le cas contraire, elle est identique à l'instruction jal.

Sur la base de ces descriptions de la sémantique d'instruction, rédigez les deux les fonctions et la compilation. Vous pouvez comparer votre travail par rapport rv32i_instructions.cc.


Ajouter des instructions concernant le stockage en mémoire

Il y a trois instructions de magasin que nous devons implémenter: store byte (sb), stocker le mot (sh) et le mot (sw). Instructions pour les magasins diffèrent des instructions que nous avons implémentées jusqu'à présent, car elles ne en écriture dans l'état local du processeur. À la place, ils écrivent dans une ressource système : mémoire principale. MPACT-Sim ne traite pas la mémoire comme un opérande d'instruction, donc l'accès à la mémoire doit être effectué en utilisant une autre méthodologie.

La solution consiste à ajouter des méthodes d'accès à la mémoire à l'objet ArchState MPACT-Sim, ou plus exactement, créez un objet d'état RiscV dérivé de ArchState. où cela peut être ajouté. L'objet ArchState gère les ressources principales, telles que des registres et d'autres objets d'état. Il gère également les lignes à retard utilisées pour les tampons de données de l'opérande de destination jusqu'à ce qu'ils puissent être réécrits les objets registre. La plupart des instructions peuvent être mises en œuvre sans connaître dans cette classe, mais d'autres, comme les opérations de mémoire et d'autres les instructions nécessitent que la fonctionnalité se trouve dans cet objet d'état.

Examinons la fonction sémantique de l'instruction fence, qui est déjà implémentée dans rv32i_instructions.cc à titre d'exemple. fence contient le problème jusqu'à ce que certaines opérations de mémoire terminé. Il est utilisé pour garantir l'ordre de la mémoire entre les instructions qui s'exécutent avant l'instruction et celles qui s'exécutent aprè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 partie clé de la fonction sémantique de l'instruction fence correspond aux deux derniers lignes. L'objet d'état est d'abord récupéré à l'aide d'une méthode dans Instruction. et downcast<> à la classe dérivée spécifique de RiscV. Ensuite, le Fence de la classe RiscVState est appelée pour effectuer l'opération de cloisonnement.

Les instructions concernant le magasin fonctionneront de la même manière. Tout d'abord, l'adresse effective du l'accès à la mémoire est calculé à partir des opérandes sources de l'instruction de base et de décalage, alors la valeur à stocker est extraite de l'opérande source suivant. Ensuite, L'objet d'état RiscV est obtenu via l'appel de la méthode state() et static_cast<>, et la méthode appropriée est appelée.

La méthode StoreMemory de l'objet RiscVState est relativement simple, mais présente une deux conséquences que nous devons connaître:

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

Comme vous pouvez le voir, la méthode utilise trois paramètres : le pointeur vers le magasin l'instruction elle-même, l'adresse du magasin et un pointeur vers un DataBuffer qui contient les données du magasin. Notez qu'aucune taille n'est requise, L'instance DataBuffer contient une méthode size(). Toutefois, il n'est pas possible opérande de destination accessible à l'instruction, qui peut être utilisé pour allouez une instance DataBuffer de la taille appropriée. Au lieu de cela, nous devons utilisez une fabrique DataBuffer obtenue à partir de la méthode db_factory() dans l'instance Instruction. La fabrique dispose d'une méthode Allocate(int size) qui renvoie une instance DataBuffer de la taille requise. Voici un exemple : de l'utilisation de cette valeur afin d'allouer une instance DataBuffer à un magasin de demi-mots Notez que auto est une fonctionnalité C++ qui déduit le type à partir de la droite. côté de la mission):

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

Une fois que nous avons l'instance DataBuffer, nous pouvons l'écrire comme d'habitude:

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

Transmettez-le ensuite à l'interface du Memory Store:

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

Nous n'avons pas encore terminé. L'instance DataBuffer est comptabilisée comme référence. Ce est normalement compris et géré par la méthode Submit, de manière à conserver le cas d'utilisation le plus fréquent aussi simple que possible. Cependant, StoreMemory n'est pas écrit de cette façon. Elle IncRef l'instance DataBuffer pendant son fonctionnement dessus, puis DecRef une fois que vous avez terminé. Cependant, si la fonction sémantique DecRef sa propre référence, elle ne sera jamais récupérée. Ainsi, la dernière ligne a doit être:

  db->DecRef();

Il existe trois fonctions de magasin. La seule différence est la taille l'accès à la mémoire. Cela semble être une excellente opportunité pour une autre entreprise locale à partir d'un modèle. La seule chose différente dans la fonction Store est le type de la valeur du magasin, le modèle doit donc l'utiliser en tant qu'argument. En dehors de cela, seule l'instance Instruction doit être transmise:

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

Terminez la compilation des fonctions sémantiques du magasin. Vous pouvez consulter travailler contre rv32i_instructions.cc.


Ajouter les instructions de chargement de la mémoire

Les instructions de chargement à implémenter sont les suivantes:

  • lb : octet de chargement, extension des signes dans un mot.
  • lbu : charge les octets non signés, qui sont remplacés par des valeurs nulles.
  • lh : chargement d'un demi-mot, extension des signes dans un mot.
  • lhu : charge un demi-mot non signé, qui se transforme en un mot.
  • lw - charger le mot.

Les instructions de chargement sont les instructions les plus complexes pour la modélisation ce tutoriel. Elles sont semblables aux instructions de stockage, dans la mesure où elles doivent accéder à l'objet RiscVState, mais cela complique le fait que chaque charge les instructions est divisée en deux fonctions sémantiques distinctes. Le premier est semblable à l'instruction "Store", dans la mesure où elle calcule l'adresse effective et lance l'accès à la mémoire. La seconde est exécutée lorsque la mémoire l'accès est terminé, et écrit les données de mémoire dans le registre de destination opérande.

Commençons par examiner la déclaration de la méthode LoadMemory dans RiscVState:

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

Par rapport à la méthode StoreMemory, LoadMemory prend deux autres les paramètres: un pointeur vers une instance Instruction et un pointeur vers un référence a compté l'objet context. La première est l'instruction child, met en œuvre l'écriture différée du registre (décrit dans le tutoriel sur les décodeurs ISA). Il est accessible à l'aide de la méthode child() dans l'instance Instruction actuelle. Cette dernière pointe vers une instance d'une classe dérivée de ReferenceCount qui, dans ce cas, stocke une instance DataBuffer qui, dans ce cas, contenant les données chargées. L'objet de contexte est disponible via la Méthode context() dans l'objet Instruction (bien que, pour la plupart des instructions, définie sur nullptr).

L'objet de contexte pour les chargements de mémoire RiscV est défini comme le struct suivant:

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

Les instructions de chargement sont les mêmes, sauf pour la taille des données (octets, un demi-mot et un mot) et détermine si la valeur chargée est étendue à un signe ou non. La Le second ne prend en compte que l'instruction child. Créons un modèle pour les instructions de chargement principales. Il est très similaire stocker une instruction, sauf qu'elle n'accède pas à un opérande source pour obtenir une valeur, pour créer un objet de contexte.

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

Comme vous pouvez le voir, la principale différence est que l'instance DataBuffer allouée est à la fois transmis à l'appel LoadMemory en tant que paramètre et stocké dans la LoadContext.

Les fonctions sémantiques de l'instruction child sont toutes très similaires. Tout d'abord, le LoadContext s'obtient en appelant la méthode Instruction context(). transférés de manière statique vers le LoadContext *. Deuxièmement, la valeur (selon les données type) est lu à partir de l'instance DataBuffer de chargement de données. Troisièmement, L'instance DataBuffer est allouée à partir de l'opérande de destination. Enfin, la fonction la valeur chargée est écrite dans la nouvelle instance DataBuffer et Submit. Encore une fois, une fonction d'assistance modélisée est une bonne idée:

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

Implémentez ces dernières fonctions d'assistance et fonctions sémantiques. Payer attention au type de données que vous utilisez dans le modèle pour chaque fonction d'assistance et qu'il correspond à la taille et à la nature signée/non signée de la charge. instruction.

Vous pouvez comparer votre travail par rapport rv32i_instructions.cc.


Créer et exécuter le simulateur final

Maintenant que nous avons terminé, nous pouvons créer le simulateur final. La des bibliothèques C++ de premier niveau qui associent tout le travail de ces tutoriels sont situé dans le pays suivant : other/. Il n'est pas nécessaire de regarder trop attentivement ce code. Mer consulteront ce sujet dans un prochain didacticiel avancé.

Remplacez le répertoire de travail par other/, puis compilez l'opération. Il doit se construire sans les erreurs.

$ cd ../other
$ bazel build :rv32i_sim

Ce répertoire contient un simple "hello world" programme dans le fichier hello_rv32i.elf Pour exécuter le simulateur sur ce fichier et afficher les résultats:

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

Le résultat doit ressembler à ceci:

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
$

Vous pouvez également exécuter le simulateur en mode interactif à l'aide de la commande bazel run :rv32i_sim -- -i other/hello_rv32i.elf. Cela fait apparaître un shell de commande. Saisissez help lorsque vous y êtes invité pour afficher les commandes 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] >

Ce tutoriel est maintenant terminé. Nous espérons que ces informations vous ont été utiles.