Zintegrowany dekoder RiscV

Cele tego samouczka to:

  • Dowiedz się, jak wygenerowane dekodery ISA i binarne się ze sobą łączą.
  • Napisz niezbędny kod C++, aby utworzyć pełny dekoder instrukcji dla RiscV RV32I, który łączy dekoder ISA i binarny.

Korzystanie z dekodera instrukcji

Dekoder instrukcji odpowiada za odczytanie słowa instrukcji z pamięci i zwrócenie w pełni zainicjowanej instancji klasy Instruction reprezentującej tę instrukcję.

Dekoder najwyższego poziomu implementuje generic::DecoderInterface pokazany poniżej:

// This is the simulator's interface to the instruction decoder.
class DecoderInterface {
 public:
  // Return a decoded instruction for the given address. If there are errors
  // in the instruciton decoding, the decoder should still produce an
  // instruction that can be executed, but its semantic action function should
  // set an error condition in the simulation when executed.
  virtual Instruction *DecodeInstruction(uint64_t address) = 0;
  virtual ~DecoderInterface() = default;
};

Jak widać, trzeba zaimplementować tylko jedną metodę: cpp virtual Instruction *DecodeInstruction(uint64_t address);

Teraz przyjrzyjmy się temu, co jest dostarczane i czego potrzebuje wygenerowany kod.

Najpierw zastanów się nad klasą najwyższego poziomu RiscV32IInstructionSet w pliku riscv32i_decoder.h, który został wygenerowany pod koniec samouczka na temat dekodera ISA. Aby ponownie wyświetlić zawartość, przejdź do katalogu rozwiązania tego samouczka i ponownie skompiluj wszystko.

$ cd riscv_isa_decoder/solution
$ bazel build :all
...<snip>...

Teraz zmień katalog z powrotem na katalog główny repozytorium i sprawdź wygenerowane źródła. W tym celu zmień katalog na bazel-out/k8-fastbuild/bin/riscv_isa_decoder (zakładając, że korzystasz z hosta x86 – w przypadku innych hostów kolejnym ciągiem znaków jest k8-fastbuild).

$ cd ../..
$ cd bazel-out/k8-fastbuild/bin/riscv_isa_decoder

Zobaczysz listę czterech plików źródłowych zawierających wygenerowany kod w C++:

  • riscv32i_decoder.h
  • riscv32i_decoder.cc
  • riscv32i_enums.h
  • riscv32i_enums.cc

Otwórz pierwszy plik riscv32i_decoder.h. Warto przyjrzeć się 3 klasom:

  • RiscV32IEncodingBase
  • RiscV32IInstructionSetFactory
  • RiscV32IInstructionSet

Zanotuj nazwy klas. Wszystkie klasy mają nazwy oparte na wersji nazwy w wielkim nawiasie spiętym podanej w deklaracji „isa” w tym pliku:isa RiscV32I { ... }

Zacznijmy od zajęć RiscVIInstructionSet. Widać ją poniżej:

class RiscV32IInstructionSet {
 public:
  RiscV32IInstructionSet(ArchState *arch_state,
                         RiscV32IInstructionSetFactory *factory);
  Instruction *Decode(uint64 address, RiscV32IEncodingBase *encoding);

 private:
  std::unique_ptr<Riscv32Slot> riscv32_decoder_;
  ArchState *arch_state_;
};

W tej klasie nie ma metod wirtualnych, jest to więc klasa samodzielna, z 2 uwagami. Najpierw konstruktor przyjmuje wskaźnik do wystąpienia klasy RiscV32IInstructionSetFactory. To klasa, której wygenerowany dekoder używa do utworzenia wystąpienia klasy RiscV32Slot, która służy do dekodowania wszystkich instrukcji zdefiniowanych dla slot RiscV32 zgodnie z definicją w pliku riscv32i.isa. Po drugie, metoda Decode przyjmuje dodatkowy parametr typu wskaźnik do RiscV32IEncodingBase. Jest to klasa, która zapewni interfejs między dekoderem isa wygenerowanym w pierwszym samouczku a dekoderem binarnym wygenerowanym w drugim laboratorium.

Klasa RiscV32IInstructionSetFactory jest klasą abstrakcyjną, z której musimy wyprowadzić własną implementację pełnego dekodera. W większości przypadków ta klasa jest trywialna: wystarczy podać metodę wywołania konstruktora dla każdej klasy slotów zdefiniowanej w pliku .isa. W naszym przypadku jest to bardzo proste, ponieważ istnieje tylko jedna taka klasa: Riscv32Slot (wielka litera o nazwie riscv32 połączona z zasadą Slot). Metoda nie jest generowana dla Ciebie, ponieważ w pewnych zaawansowanych przypadkach użycia może okazać się przydatne uzyskanie podklasy z przedziału i wywołanie jej konstruktora.

W dalszej części tego samouczka zajmiemy się ostatnim zajęciami RiscV32IEncodingBase, ponieważ są one przedmiotem innego ćwiczenia.


Definiowanie dekodera instrukcji najwyższego poziomu

Definiowanie klasy fabrycznej

Jeśli projekt został ponownie utworzony na potrzeby pierwszego samouczka, pamiętaj, aby wrócić do katalogu riscv_full_decoder.

Otwórz plik riscv32_decoder.h. Wszystkie niezbędne pliki include zostały już dodane, a przestrzenie nazw zostały skonfigurowane.

Po komentarzu oznaczonym jako //Exercise 1 - step 1 określ klasę RiscV32IsaFactory dziedziczącą z RiscV32IInstructionSetFactory.

class RiscV32IsaFactory : public RiscV32InstructionSetFactory {};

Następnie określ zastąpienie dla CreateRiscv32Slot. Ponieważ nie używamy żadnych klas pochodnych klasy Riscv32Slot, po prostu przydzielamy nową instancję za pomocą klasy std::make_unique.

std::unique_ptr<Riscv32Slot> CreateRiscv32Slot(ArchState *) override {
  return std::make_unique<Riscv32Slot>(state);
}

Jeśli potrzebujesz pomocy (lub chcesz sprawdzić swoje zadanie), pełną odpowiedź znajdziesz tutaj.

Definiowanie klasy dekodera

Deklaracje konstruktorów, destruktorów i metod

Teraz zdefiniuj klasę dekodera. W tym samym pliku co wyżej przejdź do deklaracji RiscV32Decoder. Rozwiń deklarację na definicję klasy, w której RiscV32Decoder dziedziczy z generic::DecoderInterface.

class RiscV32Decoder : public generic::DecoderInterface {
  public:
};

Zanim napiszemy konstruktor, przyjrzyjmy się bliżej kodom wygenerowanym w drugim samouczku na temat dekodera binarnego. Oprócz wszystkich funkcji Extract dostępna jest też funkcja DecodeRiscVInst32:

OpcodeEnum DecodeRiscVInst32(uint32_t inst_word);

Ta funkcja przyjmuje słowo instrukcji, które ma zostać zdekodowane, i zwraca kod operacji odpowiadający tej instrukcji. Z drugiej strony, klasa DecodeInterface, którą implementuje RiscV32Decoder, przekazuje tylko adres. Dlatego klasa RiscV32Decoder musi mieć dostęp do pamięci, aby odczytać słowo instrukcji przekazywane do klasy DecodeRiscVInst32(). W tym projekcie dostęp do pamięci uzyskuje się za pomocą prostego interfejsu zdefiniowanego w funkcji .../mpact/sim/util/memory o odpowiedniej nazwie util::MemoryInterface, jak pokazano poniżej:

  // Load data from address into the DataBuffer, then schedule the Instruction
  // inst (if not nullptr) to be executed (using the function delay line) with
  // context. The size of the data access is based on size of the data buffer.
  virtual void Load(uint64_t address, DataBuffer *db, Instruction *inst,
                    ReferenceCount *context) = 0;

Dodatkowo musimy mieć możliwość przekazania instancji klasy state konstruktorom innych klas dekoderów. Właściwą klasą stanu jest klasa riscv::RiscVState, która pochodzi od generic::ArchState, z dodatkowymi funkcjami RiscV. Oznacza to, że musimy zadeklarować konstruktor, aby mógł on przyjmować wskaźnik do statememory:

RiscV32Decoder(riscv::RiscVState *state, util::MemoryInterface *memory);

Usuń domyślny konstruktor i zastąp destruktor:

RiscV32Decoder() = delete;
~RiscV32Decoder() override;

Następnie zadeklaruj metodę DecodeInstruction, którą musisz zastąpić w klasie generic::DecoderInterface.

generic::Instruction *DecodeInstruction(uint64_t address) override;

Jeśli potrzebujesz pomocy (lub chcesz sprawdzić swoją pracę), pełną odpowiedź znajdziesz tutaj.


Definicje użytkowników danych

Klasa RiscV32Decoder będzie potrzebować prywatnych elementów danych do przechowywania parametrów konstruktora i wskaźnika do klasy fabrycznej.

 private:
  riscv::RiscVState *state_;
  util::MemoryInterface *memory_;

Potrzebuje też wskaźnika do klasy kodowania, która jest pochodną klasy RiscV32IEncodingBase. Nazwijmy ją RiscV32IEncoding (zaimplementujemy ją w ćwiczeniu 2). Wymaga też wskaźnika do instancji RiscV32IInstructionSet, więc dodaj:

  RiscV32IsaFactory *riscv_isa_factory_;
  RiscV32IEncoding *riscv_encoding_;
  RiscV32IInstructionSet *riscv_isa_;

Na koniec musimy zdefiniować element danych do użycia w interfejsie pamięci:

  generic::DataBuffer *inst_db_;

Jeśli potrzebujesz pomocy (lub chcesz sprawdzić swoją pracę), pełną odpowiedź znajdziesz tutaj.

Definiowanie metod klasy Decoder

Teraz czas zaimplementować konstruktor, destruktor i metodę DecodeInstruction. Otwórz plik riscv32_decoder.cc. Puste metody są już w pliku, podobnie jak deklaracje przestrzeni nazw i kilka deklaracji using.

Definicja konstruktora

Konstruktor musi tylko zainicjować elementy danych. Najpierw zainicjuj zmienne state_memory_:

RiscV32Decoder::RiscV32Decoder(riscv::RiscVState *state,
                               util::MemoryInterface *memory)
    : state_(state), memory_(memory) {

Następnie przypisz instancje każdej z klas powiązanych z dekoderem, przekazując odpowiednie parametry.

  // Allocate the isa factory class, the top level isa decoder instance, and
  // the encoding parser.
  riscv_isa_factory_ = new RiscV32IsaFactory();
  riscv_isa_ = new RiscV32IInstructionSet(state, riscv_isa_factory_);
  riscv_encoding_ = new RiscV32IEncoding(state);

Na koniec przydziel instancję DataBuffer. Jest on przydzielany za pomocą fabryki dostępnej za pomocą elementu state_. Przydzielamy bufor danych o rozmiarze umożliwiającym przechowywanie pojedynczego uint32_t, ponieważ jest to rozmiar słowa instrukcji.

  inst_db_ = state_->db_factory()->Allocate<uint32_t>(1);

Definicja destruktora

Destruktor jest prosty, wystarczy uwolnić obiekty przydzielone w konstruktorze, ale z jednym wyjątkiem. Inicjatywny bufor danych jest zliczany, więc zamiast wywoływać funkcję delete tego wskaźnika, DecRef() obiekt:

RiscV32Decoder::~RiscV32Decoder() {
  inst_db_->DecRef();
  delete riscv_isa_;
  delete riscv_isa_factory_;
  delete riscv_encoding_;
}

Definicja metody

W naszym przypadku wdrożenie tej metody jest dość proste. Zakładamy, że adres jest prawidłowo wyrównany i nie wymaga dodatkowej kontroli błędów.

Najpierw słowo instrukcji musi zostać pobrane z pamięci za pomocą interfejsu pamięci i instancji DataBuffer.

  memory_->Load(address, inst_db_, nullptr, nullptr);
  uint32_t iword = inst_db_->Get<uint32_t>(0);

Następnie wywołujemy instancję RiscVIEncoding, aby przeanalizować słowo instrukcji. Należy to zrobić przed wywołaniem dekodera ISA. Pamiętaj, że dekoder ISA wywołuje instancję RiscVIEncoding bezpośrednio, aby uzyskać kod operacji i operandy określone przez słowo instrukcji. Nie wdrożyliśmy jeszcze tej klasy, ale użyjemy tej metody jako void ParseInstruction(uint32_t).

  riscv_encoding_->ParseInstruction(iword);

Na koniec wywołujemy dekoder ISA, przekazując adres i klasę kodowania.

  auto *instruction = riscv_isa_->Decode(address, riscv_encoding_);
  return instruction;

Jeśli potrzebujesz pomocy (lub chcesz sprawdzić swoją pracę), pełną odpowiedź znajdziesz tutaj.


Klasa kodowania

Klasa kodowania implementuje interfejs, który jest używany przez klasę dekodera do uzyskiwania kodu operacji instrukcji, jej operandów źródłowych i docelowych oraz operandów zasobów. Wszystkie te obiekty zależą od informacji z dekodera formatu binarnego, np. kodu opcode, wartości określonych pól w słowie instrukcji itp. Jest on oddzielony od klasy dekodera, aby zapewnić niezależność kodowania i umożliwić obsługę wielu różnych schematów kodowania w przyszłości.

RiscV32IEncodingBase to klasa abstrakcyjna. Poniżej przedstawiamy zestaw metod, które musimy zaimplementować w klasie derywowanej.

class RiscV32IEncodingBase {
 public:
  virtual ~RiscV32IEncodingBase() = default;

  virtual OpcodeEnum GetOpcode(SlotEnum slot, int entry) = 0;

  virtual ResourceOperandInterface *
              GetSimpleResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                       SimpleResourceVector &resource_vec, int end) = 0;

  virtual ResourceOperandInterface *
              GetComplexResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                        ComplexResourceEnum resource_op,
                                        int begin, int end) = 0;

  virtual PredicateOperandInterface *
              GetPredicate(SlotEnum slot, int entry, OpcodeEnum opcode,
                           PredOpEnum pred_op) = 0;

  virtual SourceOperandInterface *
              GetSource(SlotEnum slot, int entry, OpcodeEnum opcode,
                        SourceOpEnum source_op, int source_no) = 0;

  virtual DestinationOperandInterface *
              GetDestination(SlotEnum slot, int entry, OpcodeEnum opcode,
                             DestOpEnum dest_op, int dest_no, int latency) = 0;

  virtual int GetLatency(SlotEnum slot, int entry, OpcodeEnum opcode,
                         DestOpEnum dest_op, int dest_no) = 0;
};

Na pierwszy rzut oka może się wydawać, że jest to trochę skomplikowane, zwłaszcza ze względu na liczbę parametrów, ale w przypadku prostej architektury, takiej jak RiscV, większość parametrów można zignorować, ponieważ ich wartości są domyślne.

Przyjrzyjmy się każdej z nich z osobna.

OpcodeEnum GetOpcode(SlotEnum slot, int entry);

Metoda GetOpcode zwraca element OpcodeEnum dla bieżącej instrukcji, identyfikując kod operacji instrukcji. Klasa OpcodeEnum jest zdefiniowana w wygenerowanym pliku dekodera isa riscv32i_enums.h. Metoda przyjmuje 2 parametry, które w naszym przypadku można zignorować. Pierwszy z nich to typ slotu (klasa enum zdefiniowana również w riscv32i_enums.h), która, ponieważ RiscV ma tylko jeden slot, ma tylko jedną możliwą wartość: SlotEnum::kRiscv32. Drugi to numer instancji przedziału (jeśli istnieje wiele instancji przedziału, co może występować w niektórych architekturach VLIW).

ResourceOperandInterface *
    GetSimpleResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                     SimpleResourceVector &resource_vec, int end)

ResourceOperandInterface *
    GetComplexResourceOperand(SlotEnum slot, int entry, OpcodeEnum opcode,
                                      ComplexResourceEnum resource_op,
                                      int begin, int end);

Kolejne 2 metody służą do modelowania zasobów sprzętowych w procesorze w celu zwiększenia dokładności cyklu. W naszych ćwiczeniach samouczka nie będziemy ich używać, więc w implementacji będą one zastąpione, zwracając wartość nullptr.

PredicateOperandInterface *
            GetPredicate(SlotEnum slot, int entry, OpcodeEnum opcode,
                         PredOpEnum pred_op);

SourceOperandInterface *
            GetSource(SlotEnum slot, int entry, OpcodeEnum opcode,
                      SourceOpEnum source_op, int source_no);

DestinationOperandInterface *
            GetDestination(SlotEnum slot, int entry, OpcodeEnum opcode,
                           DestOpEnum dest_op, int dest_no, int latency);

Te 3 metody zwracają wskaźniki do obiektów operandów, które są używane w funkcjach semantycznych instrukcji do uzyskiwania dostępu do wartości dowolnego operanda predykatu instrukcji, wszystkich źródeł operandów instrukcji oraz zapisywania nowych wartości do docelowych operandów instrukcji. Ponieważ RiscV nie używa predykatów instrukcji, ta metoda musi zwracać tylko nullptr.

Schemat parametrów jest podobny w przypadku tych funkcji. Po pierwsze, podobnie jak w przypadku funkcji GetOpcode, slot i entry są przekazywane. Następnie kod operacji dla instrukcji, dla której należy utworzyć operand. Jest to używane tylko wtedy, gdy różne instrukcje muszą zwracać różne obiekty operandów dla tych samych typów operandów, co nie jest wymagane w przypadku tego symulatora RiscV.

Następnie podaj w układance argumentów pozycję „Predykat”, „Źródło” i „Destynacja”, która identyfikuje argument, który ma zostać utworzony. Te wartości pochodzą z 3 OpEnumsriscv32i_enums.h, jak widać poniżej:

  enum class PredOpEnum {
    kNone = 0,
    kPastMaxValue = 1,
  };

  enum class SourceOpEnum {
    kNone = 0,
    kBimm12 = 1,
    kCsr = 2,
    kImm12 = 3,
    kJimm20 = 4,
    kRs1 = 5,
    kRs2 = 6,
    kSimm12 = 7,
    kUimm20 = 8,
    kUimm5 = 9,
    kPastMaxValue = 10,
  };

  enum class DestOpEnum {
    kNone = 0,
    kCsr = 1,
    kNextPc = 2,
    kRd = 3,
    kPastMaxValue = 4,
  };

Patrząc na plik riscv32.isa, można zauważyć, że odpowiadają one zbiorom nazw argumentów źródła i miejsca docelowego użytym w deklaracji poszczególnych instrukcji. Użycie różnych nazw operandów dla operandów, które reprezentują różne pola bitowe i typy operandów, ułatwia pisanie klasy kodowania, ponieważ element wyliczeniowy jednoznacznie określa dokładny typ operandu do zwrócenia, nie trzeba więc uwzględniać wartości parametrów przedziału, wpisu ani opcode.

Wreszcie w przypadku operandów źródła i docelowa pozycja operandu jest przekazywana (możemy to zignorować), a w przypadku operanda docelowego – opóźnienie (w cyklach) między czasem wydania instrukcji a dostępnością wyniku docelowego dla kolejnych instrukcji. W naszym symulatorze opóźnienie będzie wynosić 0, co oznacza, że instrukcja zapisuje wynik bezpośrednio w rejestrze.

int GetLatency(SlotEnum slot, int entry, OpcodeEnum opcode,
                         DestOpEnum dest_op, int dest_no);

Ostatnia funkcja służy do uzyskania opóźnienia określonego operanda docelowego, jeśli w pliku .isa jest on określony jako *. Jest to rzadkie i nie jest używane w tym symulatorze RiscV, więc nasza implementacja tej funkcji zwróci wartość 0.


Zdefiniuj klasę kodowania

Plik nagłówka (.h)

Metody

Otwórz plik riscv32i_encoding.h. Wszystkie niezbędne pliki include zostały już dodane, a przestrzenie nazw zostały skonfigurowane. Dodawanie kodu odbywa się po komentarzu // Exercise 2.

Zacznijmy od zdefiniowania klasy RiscV32IEncoding, która dziedziczy po wygenerowanym interfejsie.

class RiscV32IEncoding : public RiscV32IEncodingBase {
 public:

};

Następnie konstruktor powinien wskazać instancję stanu (w tym przypadku wskaźnik riscv::RiscVState). Należy użyć domyślnego destruktora.

explicit RiscV32IEncoding(riscv::RiscVState *state);
~RiscV32IEncoding() override = default;

Zanim dodajemy wszystkie metody interfejsu, dodajmy metodę wywoływaną przez RiscV32Decoder, która pozwala przeanalizować instrukcję:

void ParseInstruction(uint32_t inst_word);

Następnie dodamy te metody, które mają proste zastąpienia, pomijając nazwy parametrów, których nie używamy:

// Trivial overrides.
ResourceOperandInterface *GetSimpleResourceOperand(SlotEnum, int, OpcodeEnum,
                                                   SimpleResourceVector &,
                                                   int) override {
  return nullptr;
}

ResourceOperandInterface *GetComplexResourceOperand(SlotEnum, int, OpcodeEnum,
                                                    ComplexResourceEnum ,
                                                    int, int) override {
  return nullptr;
}

PredicateOperandInterface *GetPredicate(SlotEnum, int, OpcodeEnum,
                                        PredOpEnum) override {
  return nullptr;
}

int GetLatency(SlotEnum, int, OpcodeEnum, DestOpEnum, int) override { return 0; }

Na koniec dodaj pozostałe zastąpienia metody w interfejsie publicznym, ale implementacje zostaną odroczone do pliku .cc.


OpcodeEnum GetOpcode(SlotEnum, int) override;

SourceOperandInterface *GetSource(SlotEnum , int, OpcodeEnum,
                                  SourceOpEnum source_op, int) override;

DestinationOperandInterface *GetDestination(SlotEnum, int, OpcodeEnum,
                                            DestOpEnum dest_op, int,
                                            int latency) override;

Aby uprościć implementację każdej z metod pobierania operandów, utworzymy 2 tablice funkcji (obiektów funkcji) indeksowanych odpowiednio według wartości numerycznej elementów SourceOpEnumDestOpEnum. Dzięki temu zawartość tych metod jest ograniczona do wywoływania obiektu funkcji dla przekazywanej wartości wyliczenia i zwracania tej wartości.

Aby zorganizować inicjalizację tych dwóch tablic, definiujemy 2 metody prywatne, które będą wywoływane z konstruktora w ten sposób:

 private:
  void InitializeSourceOperandGetters();
  void InitializeDestinationOperandGetters();

Elementy danych

Wymagani użytkownicy danych:

  • state_, aby przechowywać wartość riscv::RiscVState *.
  • inst_word_ typu uint32_t, który przechowuje wartość bieżącego słowa instrukcji.
  • opcode_, aby przechowywać kod operacji bieżącej instrukcji zaktualizowanym metodą ParseInstruction. Ten typ zasobu zawiera: OpcodeEnum.
  • source_op_getters_ tablica do przechowywania wywołań używanych do uzyskania obiektów operandu źródłowego. Typ elementów tablicy to absl::AnyInvocable<SourceOperandInterface *>()>.
  • dest_op_getters_ tablica do przechowywania wywołań używanych do uzyskania obiektów operandu miejsca docelowego. Typ elementów tablicy to absl::AnyInvocable<DestinationOperandInterface *>()>.
  • xreg_alias tablica nazw ABI rejestru liczb całkowitych RiscV, np. „zero” i „ra” zamiast „x0” i „x1”.

  riscv::RiscVState *state_;
  uint32_t inst_word_;
  OpcodeEnum opcode_;

  absl::AnyInvocable<SourceOperandInterface *()>
      source_op_getters_[static_cast<int>(SourceOpEnum::kPastMaxValue)];
  absl::AnyInvocable<DestinationOperandInterface *(int)>
      dest_op_getters_[static_cast<int>(DestOpEnum::kPastMaxValue)];

  const std::string xreg_alias_[32] = {
      "zero", "ra", "sp", "gp", "tp",  "t0",  "t1", "t2", "s0", "s1", "a0",
      "a1",   "a2", "a3", "a4", "a5",  "a6",  "a7", "s2", "s3", "s4", "s5",
      "s6",   "s7", "s8", "s9", "s10", "s11", "t3", "t4", "t5", "t6"};

Jeśli potrzebujesz pomocy (lub chcesz sprawdzić swoje zadanie), pełną odpowiedź znajdziesz tutaj.

Plik źródłowy (.cc).

Otwórz plik riscv32i_encoding.cc. Wszystkie niezbędne pliki „include” zostały już dodane, a przestrzenie nazw zostały skonfigurowane. Dodawanie kodu odbywa się po komentarzu // Exercise 2.

Funkcje pomocnicze

Najpierw napiszemy kilka funkcji pomocniczych, które posłużą nam do utworzenia operandów rejestrów źródłowego i docelowego. Będą one tworzone na podstawie szablonu typu rejestru i wywoływać obiekt RiscVState, aby uzyskać uchwyt do obiektu rejestru, a następnie wywoływać metodę fabryki operandów w obiekcie rejestru.

Zacznijmy od pomocników operandu docelowego:

template <typename RegType>
inline DestinationOperandInterface *GetRegisterDestinationOp(
    RiscVState *state, const std::string &name, int latency) {
  auto *reg = state->GetRegister<RegType>(name).first;
  return reg->CreateDestinationOperand(latency);
}

template <typename RegType>
inline DestinationOperandInterface *GetRegisterDestinationOp(
    RiscVState *state, const std::string &name, int latency,
    const std::string &op_name) {
  auto *reg = state->GetRegister<RegType>(name).first;
  return reg->CreateDestinationOperand(latency, op_name);
}

Jak widać, są 2 funkcje pomocnicze. W drugim wymagany jest dodatkowy parametr op_name, dzięki któremu operand może mieć inną nazwę (reprezentację ciągu znaków) niż w rejestrze bazowym.

Podobnie w przypadku pomocników operandów źródłowych:

template <typename RegType>
inline SourceOperandInterface *GetRegisterSourceOp(RiscVState *state,
                                                   const std::string &reg_name) {
  auto *reg = state->GetRegister<RegType>(reg_name).first;
  auto *op = reg->CreateSourceOperand();
  return op;
}

template <typename RegType>
inline SourceOperandInterface *GetRegisterSourceOp(RiscVState *state,
                                                   const std::string &reg_name,
                                                   const std::string &op_name) {
  auto *reg = state->GetRegister<RegType>(reg_name).first;
  auto *op = reg->CreateSourceOperand(op_name);
  return op;
}

Konstruktor i funkcje interfejsu

Konstruktor i funkcje interfejsu są bardzo proste. Konstruktor wywołuje tylko 2 metody inicjowania, aby zainicjować tablice callables dla funkcji pobierających operandów.

RiscV32IEncoding::RiscV32IEncoding(RiscVState *state) : state_(state) {
  InitializeSourceOperandGetters();
  InitializeDestinationOperandGetters();
}

ParseInstruction przechowuje słowo instrukcji, a potem kod operacji, który uzyskuje z wywołania kodu wygenerowanego przez dekoder binarny.

// Parse the instruction word to determine the opcode.
void RiscV32IEncoding::ParseInstruction(uint32_t inst_word) {
  inst_word_ = inst_word;
  opcode_ = mpact::sim::codelab::DecodeRiscVInst32(inst_word_);
}

Na koniec funkcje pobierające operandy zwracają wartość z funkcji pobierającej, którą wywołują, na podstawie wyszukiwania w tablicy za pomocą wartości typu zbiorczego operanda docelowego/źródłowego.


DestinationOperandInterface *RiscV32IEncoding::GetDestination(
    SlotEnum, int, OpcodeEnum, DestOpEnum dest_op, int, int latency) {
  return dest_op_getters_[static_cast<int>(dest_op)](latency);
}

SourceOperandInterface *RiscV32IEncoding::GetSource(SlotEnum, int, OpcodeEnum,
                                                    SourceOpEnum source_op, int) {
  return source_op_getters_[static_cast<int>(source_op)]();
}

Metody inicjowania tablic

Jak już pewnie się domyślasz, większość pracy polega na inicjowaniu tablic getter, ale nie martw się, to jest realizowane za pomocą prostego, powtarzalnego wzorca. Zacznijmy od InitializeDestinationOpGetters(), ponieważ zawiera on tylko kilka operandów docelowych.

Wycofaj wygenerowaną klasę DestOpEnum z riscv32i_enums.h:

  enum class DestOpEnum {
    kNone = 0,
    kCsr = 1,
    kNextPc = 2,
    kRd = 3,
    kPastMaxValue = 4,
  };

W przypadku dest_op_getters_ musimy zainicjować 4 elementy: kNone, kCsr, kNextPckRd. Dla wygody każdy wpis jest inicjowany za pomocą funkcji lambda, ale możesz też użyć dowolnej innej formy wywołania. Podpis wyrażenia lambda to void(int latency).

Do tej pory nie mówiliśmy zbyt wiele o różnych rodzajach operatorów docelowych zdefiniowanych w MPACT-Sim. W tym ćwiczeniu użyjemy tylko 2 typów: generic::RegisterDestinationOperand zdefiniowanego w register.h oraz generic::DevNullOperand zdefiniowanego w devnull_operand.h. Szczegóły tych operandów nie są obecnie szczególnie ważne, z wyjątkiem tego, że poprzedni służy do zapisu w rejestrach, a ten drugi ignoruje wszystkie zapisy.

Pierwszy wpis dla kNone jest trywialny – wystarczy zwrócić nullptr i opcjonalnie odnotować błąd.

void RiscV32IEncoding::InitializeDestinationOperandGetters() {
  // Destination operand getters.
  dest_op_getters_[static_cast<int>(DestOpEnum::kNone)] = [](int) {
    return nullptr;
  };

Kolejny serwis to kCsr. Tutaj trochę oszukujemy. Program „hello world” nie korzysta z żadnej rzeczywistej aktualizacji CSR, ale zawiera kod szablonowy, który wykonuje instrukcje CSR. Rozwiązaniem jest użycie zwykłego rejestru o nazwie „CSR” i przekierowanie do niego wszystkich takich zapisów.

  dest_op_getters_[static_cast<int>(DestOpEnum::kCsr)] = [this](int latency) {
    return GetRegisterDestinationOp<RV32Register>(state_, "CSR", latency);
  };

Następny jest kNextPc, który odnosi się do rejestru „pc”. Jest używany jako cel we wszystkich instrukcjach rozgałęzień i przejścia. Nazwa ta jest zdefiniowana w RiscVState jako kPcName.

  dest_op_getters_[static_cast<int>(DestOpEnum::kNextPc)] = [this](int latency) {
    return GetRegisterDestinationOp<RV32Register>(state_, RiscVState::kPcName, latency);
  }

Na końcu jest operand docelowy kRd. W riscv32i.isa operand rd jest używany tylko do odwołania się do rejestru całkowitego zakodowanego w polu „rd” słowa instrukcji, więc nie ma niejasności, do czego się odnosi. Jest tylko 1 komplikacja. Rejestr x0 (nazwa ABI zero) jest przypisany do 0, więc w przypadku tego rejestru używamy rejestru DevNullOperand.

W tej metodzie getter najpierw wyodrębniamy wartość w polu rd za pomocą metody Extract wygenerowanej z pliku .bin_fmt. Jeśli wartość wynosi 0, zwracamy operand „DevNull”, w przeciwnym razie zwracamy prawidłowy operand rejestru, używając jako nazwy operandu odpowiedniego aliasu rejestru.

  dest_op_getters_[static_cast<int>(DestOpEnum::kRd)] = [this](int latency) {
    // First extract register number from rd field.
    int num = inst32_format::ExtractRd(inst_word_);
    // For register x0, return the DevNull operand.
    if (num == 0) return new DevNullOperand<uint32_t>(state, {1});
    // Return the proper register operand.
    return GetRegisterDestinationOp<RV32Register>(
      state_, absl::StrCat(RiscVState::kXRegPrefix, num), latency,
      xreg_alias_[num]);
    )
  }
}

Teraz metoda InitializeSourceOperandGetters(), w której wzór jest prawie taki sam, ale szczegóły nieco się różnią.

Najpierw przyjrzyjmy się SourceOpEnum wygenerowanemu w pierwszym samouczku na podstawie riscv32i.isa:

  enum class SourceOpEnum {
    kNone = 0,
    kBimm12 = 1,
    kCsr = 2,
    kImm12 = 3,
    kJimm20 = 4,
    kRs1 = 5,
    kRs2 = 6,
    kSimm12 = 7,
    kUimm20 = 8,
    kUimm5 = 9,
    kPastMaxValue = 10,
  };

Po zbadaniu użytkowników oprócz kNone można je podzielić na 2 grupy. Pierwszy to operandy natychmiastowe: kBimm12, kImm12, kJimm20, kSimm12, kUimm20kUimm5. Pozostałe to operandy rejestru: kCsr, kRs1kRs2.

Operand kNone jest obsługiwany tak samo jak operandy docelowe – zwraca wartość nullptr.

void RiscV32IEncoding::InitializeSourceOperandGetters() {
  // Source operand getters.
  source_op_getters_[static_cast<int>(SourceOpEnum::kNone)] = [] () {
    return nullptr;
  };

Teraz zajmijmy się operandami rejestrów. kCsr będziemy obsługiwać podobnie jak odpowiednie operandy miejsca docelowego – wystarczy wywołać funkcję pomocniczą, używając „CSR” jako nazwy rejestru.

  // Register operands.
  source_op_getters_[static_cast<int>(SourceOpEnum::kCsr)] = [this]() {
    return GetRegisterSourceOp<RV32Register>(state_, "CSR");
  };

Operandy kRs1kRs2 są traktowane tak samo jak operand kRd, z tym że nie chcemy aktualizować operandu x0 (ani zero), ale chcemy mieć pewność, że zawsze odczytujemy z niego wartość 0. W tym celu użyjemy klasy generic::IntLiteralOperand<> zdefiniowanej w literal_operand.h. Ten operand służy do przechowywania wartości literału (w odróżnieniu od symulowanej wartości bezpośredniej). W pozostałych przypadkach schemat jest taki sam: najpierw wyodrębnij wartość rs1/rs2 z słowa instrukcji, a jeśli jest ona równa 0, zwracaj literały operandu za pomocą parametru szablonu 0. W przeciwnym razie zwracaj zwykły operand źródła rejestru za pomocą funkcji pomocniczej, używając aliasu ABI jako nazwy operandu.

  source_op_getters_[static_cast<int>(SourceOpEnum::kRs1)] =
      [this]() -> SourceOperandInterface * {
    int num = inst32_format::ExtractRs1(inst_word_);
    if (num == 0) return new IntLiteralOperand<0>({1}, xreg_alias_[0]);
    return GetRegisterSourceOp<RV32Register>(
        state_, absl::StrCat(RiscVState::kXregPrefix, num), xreg_alias_[num]);
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kRs2)] =
      [this]() -> SourceOperandInterface * {
    int num = inst32_format::ExtractRs2(inst_word_);
    if (num == 0) return new IntLiteralOperand<0>({1}, xreg_alias_[0]);
    return GetRegisterSourceOp<RV32Register>(
        state_, absl::StrCat(RiscVState::kXregPrefix, num), xreg_alias_[num]);
  };

Na koniec obsługujemy różne natychmiastowe operandy. Wartości natychmiastowe są przechowywane w instancjach klasy generic::ImmediateOperand<> zdefiniowanej w zadaniu immediate_operand.h. Jedyną różnicą między różnymi metodami pobierania dla natychmiastowych operandów jest to, która funkcja wyodrębniania jest używana i czy typ pamięci masowej jest podpisany czy niepodpisany (w zależności od pola bitowego).

  // Immediates.
  source_op_getters_[static_cast<int>(SourceOpEnum::kBimm12)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractBImm(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kImm12)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractImm12(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kUimm5)] = [this]() {
    return new ImmediateOperand<uint32_t>(
        inst32_format::ExtractUimm5(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kJimm20)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractJImm(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kSimm12)] = [this]() {
    return new ImmediateOperand<int32_t>(
        inst32_format::ExtractSImm(inst_word_));
  };
  source_op_getters_[static_cast<int>(SourceOpEnum::kUimm20)] = [this]() {
    return new ImmediateOperand<uint32_t>(
        inst32_format::ExtractUimm32(inst_word_));
  };
}

Jeśli potrzebujesz pomocy (lub chcesz sprawdzić swoją pracę), pełną odpowiedź znajdziesz tutaj.

To już koniec tego samouczka. Mamy nadzieję, że te informacje okażą się przydatne.