Tutorial de funções semânticas

Os objetivos deste tutorial são:

  • Saiba como as funções semânticas são usadas para implementar a semântica de instruções.
  • Saiba como as funções semânticas estão relacionadas à descrição do decodificador da ISA.
  • Escreva funções semânticas de instrução para as instruções do RiscV RV32I.
  • Teste o simulador final executando um pequeno "Hello World" executável.

Visão geral das funções semânticas

Uma função semântica em MPACT-Sim é uma função que implementa a operação de uma instrução para que seus efeitos colaterais sejam visíveis no estado simulado, da mesma forma que os efeitos colaterais da instrução ficam visíveis quando executados ao hardware. Representação interna do simulador de cada instrução decodificada. contém uma função chamável que é usada para chamar a função semântica desse instrução.

Uma função semântica tem a assinatura void(Instruction *), ou seja, uma que usa um ponteiro para uma instância da classe Instruction e retorna void.

A classe Instruction é definida em instruction.h

Para escrever funções semânticas, estamos particularmente interessados os vetores de interface do operando de origem e destino acessados usando o Chamadas de método Source(int i) e Destination(int i)

As interfaces de operando de origem e destino são mostradas abaixo:

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

A maneira básica de escrever uma função semântica para um operando normal 3. como uma instrução add de 32 bits, é a seguinte:

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

Vamos detalhar as partes dessa função. As duas primeiras linhas o corpo da função lê os operandos de origem 0 e 1. A chamada AsUint32(0) interpreta os dados como uma matriz uint32_t e busca o 0 . Isso acontece independentemente de o registro ou o valor subjacente matriz com valor ou não. O tamanho (em elementos) do operando de origem pode ser extraído do método de operando de origem shape(), que retorna um vetor que contém o número de elementos em cada dimensão. Esse método retorna {1}. para um escalar, {16} para um vetor de 16 elementos e {4, 4} para uma matriz 4x4.

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

Em seguida, um uint32_t temporário chamado c recebe o valor a + b.

A próxima linha pode exigir um pouco mais de explicação:

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

Um DataBuffer é um objeto contado por referência que é usado para armazenar valores em um estado simulado, como registros. Ele é relativamente não digitado, mas tem um com base no objeto do qual está alocado. Nesse caso, esse tamanho é sizeof(uint32_t): Essa instrução aloca um novo buffer de dados dimensionado para o destino que é o alvo deste operando de destino - neste caso, um Registro de números inteiros de 32 bits. O DataBuffer também é inicializado com a latência de arquitetura para a instrução. Isso é especificado durante a instrução decodificação.

A próxima linha trata a instância do buffer de dados como uma matriz de uint32_t e grava o valor armazenado em c no elemento 0.

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

Por fim, a última instrução envia o buffer de dados ao simulador para uso. como o novo valor do estado da máquina de destino (neste caso, um registro) após o latência da instrução definida quando ela foi decodificada e a vetor de operando de destino preenchido.

Embora essa função seja razoavelmente breve, ela tem um pouco de código boilerplate código que se torna repetitivo na hora de implementar a instrução após a instrução. Além disso, pode ocultar a semântica real da instrução. Para para simplificar ainda mais a escrita das funções semânticas para a maioria das instruções, há uma série de funções helper com modelo definidas em instruction_helpers.h. Esses auxiliares escondem o código boilerplate para instruções com um, dois ou três operandos de origem e um único operando de destino. Vamos analisar um dois funções auxiliares do 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);
}

Você perceberá que, em vez de usar uma instrução como:

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

A função auxiliar usa:

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

O GetInstructionSource é uma família de funções auxiliares com modelo que são usados para fornecer métodos de acesso de modelo à fonte da instrução operandos. Sem eles, cada função auxiliar de instrução teria especializada para cada tipo para acessar o operando de origem com o função As<int type>(). Para conferir as definições desses modelos funções na instruction.h.

Como você pode ver, há três implementações, dependendo se a origem operando forem os mesmos que o destino, independente de o destino ser diferentes das origens ou se são todas diferentes. Cada versão do a função toma um ponteiro para a instância de instrução, bem como uma string (o que inclui funções lambda). Isso significa que agora podemos reescrever a add função semântica acima da seguinte maneira:

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

Quando compilado com bazel build -c opt e copts = ["-O3"] no build o arquivo deve estar inline, sem overhead, oferecendo uma ser sucinto, sem nenhuma penalidade de desempenho.

Como mencionado, existem funções auxiliares para funções unárias, binárias e escalares ternárias bem como equivalentes de vetores. Eles também servem como modelos úteis para criar seus próprios ajudantes para instruções que não se encaixam no molde geral.


Build inicial

Se você não mudou o diretório para riscv_semantic_functions, faça isso agora. Em seguida, crie o projeto da seguinte maneira: ela deverá ser bem-sucedida.

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

Nenhum arquivo é gerado, então esta é apenas uma simulação para para ter certeza de que tudo está em ordem.


Adicionar instruções de ALU de três operandos

Agora vamos adicionar as funções semânticas para uma ALU genérica com três operandos. instruções. Abra o arquivo rv32i_instructions.cc e verifique se alguma definições ausentes são adicionadas ao arquivo rv32i_instructions.h à medida que avançamos.

As instruções que adicionaremos são:

  • add: adição de inteiro de 32 bits.
  • and: 32 bits e
  • or: 32 bits ou
  • sll: deslocamento lógico de 32 bits para a esquerda.
  • sltu: conjunto não assinado de 32 bits menor que.
  • sra: deslocamento aritmético para a direita de 32 bits.
  • srl: deslocamento lógico para a direita de 32 bits.
  • sub: subtração de números inteiros de 32 bits.
  • xor: xor bit a bit de 32 bits.

Se você já fez os tutoriais anteriores, deve se lembrar que nós diferenciamos entre as instruções de registro-registro e as instruções imediatas de registro em o decodificador. Quando se trata de funções semânticas, não precisamos mais fazer isso. As interfaces de operando lerão o valor do operando qualquer é registrado ou imediato, com a função semântica completamente agnóstica para o que é o operando de origem subjacente.

Com exceção de sra, todas as instruções acima podem ser tratadas como se operando em Valores não assinados de 32 bits. Para eles, podemos usar a função de modelo BinaryOp. que analisamos anteriormente com apenas um argumento de tipo de modelo. Preencha o corpo da função em rv32i_instructions.cc corretamente. Observe que apenas os 5 bits do segundo operando para as instruções de deslocamento são usados para a de dados. Caso contrário, todas as operações estarão no formato src0 op src1:

  • add: a + b
  • and: a & b
  • or : a | b
  • sll : a << (b & 0x1f)
  • sltu : (a < b) ? 1 : 0
  • srl : a >> (b & 0x1f)
  • sub : a - b
  • xor : a ^ b

Para sra, vamos usar o modelo BinaryOp com três argumentos. Analisando o primeiro argumento de tipo será o tipo de resultado uint32_t. O segundo é o tipo de operando de origem 0, neste caso, int32_t, e o último é o tipo; do operando de origem 1, neste caso, uint32_t. Isso torna o corpo do sra função semântica:

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

Vá em frente, faça as alterações e crie. Você pode comparar seu trabalho rv32i_instructions.cc.


Adicionar instruções de ALU de dois operandos

Há apenas duas instruções de ALU com dois operandos: lui e auipc. O primeiro copia o operando de origem pré-deslocado diretamente para o destino. A última opção adiciona o endereço de instrução ao imediato antes de escrevê-lo no destino. O endereço da instrução pode ser acessado pelo método address(). do objeto Instruction.

Como há apenas um operando de origem, não podemos usar BinaryOp. precisamos usar UnaryOp. Como podemos tratar a origem e o operandos de destino como uint32_t, podemos usar o modelo de argumento único para a versão anterior.

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

O corpo da função semântica de lui é o mais trivial possível, apenas retorna a origem. A função semântica de auipc introduz uma problema, já que você precisa acessar o método address() no objeto Instruction instância. A resposta é adicionar instruction à captura de lambda, tornando disponível para uso no corpo da função lambda. Em vez de usar [](uint32_t a) { ... } como antes, a lambda precisa ser escrita como [instruction](uint32_t a) { ... }. Agora, instruction pode ser usado no corpo da lambda.

Vá em frente, faça as alterações e crie. Você pode comparar seu trabalho rv32i_instructions.cc.


Adicionar instruções de mudança do fluxo de controle

As instruções de mudança do fluxo de controle que você precisa implementar estão divididas em instruções de ramificação condicional (ramificações mais curtas que são executadas se uma comparação é verdadeira) e instruções de link direto, usadas para implementar chamadas de função (o -and-link é removido ao definir o link registro em zero, o que torna essas gravações em ambiente autônomo).

Adicionar instruções de ramificação condicional

Não há uma função auxiliar para instruções de ramificação, então há duas opções. Escreva as funções semânticas do zero ou uma função auxiliar local. Como precisamos implementar seis instruções de ramificação, a última parece valer a pena esforço de Antes de fazer isso, vamos conferir a implementação de uma ramificação e a função semântica de instruções do 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();
  }
}

A única coisa que varia entre as instruções da ramificação e os tipos de dados, assinado ou não assinado de 32 bits int, dos dois operandos de origem. Isso significa que precisamos de um parâmetro de modelo para o operandos de origem. A função auxiliar precisa usar Instruction. instância e um objeto chamável, como std::function, que retorna bool como parâmetros. A função auxiliar ficaria assim:

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

Agora podemos escrever a função semântica bge (ramificação assinada maior ou igual) como:

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

As instruções de ramificação restantes são as seguintes:

  • Beq - ramificação igual.
  • Bgeu - ramificação maior ou igual (não assinada).
  • Blt - ramificação menor que (assinada).
  • Bltu - ramificação menor que (não assinada).
  • Bne - ramificação diferente.

Faça as alterações para implementar essas funções semânticas. reconstruir. Você pode comparar seu trabalho rv32i_instructions.cc.

Não faz sentido escrever uma função auxiliar para o jump and link então precisamos escrevê-las do zero. Vamos começar analisando a semântica de instruções deles.

A instrução jal usa um deslocamento do operando de origem 0 e o adiciona ao pc atual (endereço de instrução) para calcular o alvo de salto. O alvo de salto é gravado no operando de destino 0. O endereço de devolução é o endereço próxima instrução sequencial. Ele pode ser calculado com a adição do tamanho da instrução ao seu endereço. O endereço de devolução é gravado operando de destino 1. Lembre-se de incluir o ponteiro do objeto de instrução a captura lambda.

A instrução jalr usa um registro base como operando de origem 0 e um deslocamento como operando de origem 1 e os adiciona juntos para calcular o destino de salto. Caso contrário, ela é idêntica à instrução jal.

Com base nessas descrições da semântica de instrução, escreva as duas semânticas e criação. Você pode comparar seu trabalho rv32i_instructions.cc.


Adicionar instruções de armazenamento de memória

Precisamos implementar três instruções de armazenamento: byte de armazenamento (sb), armazenar meia palavra (sh) e palavra da loja (sw). Instruções da loja diferentes das instruções que implementamos até agora gravação no estado do processador local. Em vez disso, eles gravam em um recurso do sistema, na memória principal. O MPACT-Sim não trata a memória como um operando de instrução. Portanto, o acesso à memória precisa ser feito usando outra metodologia.

A resposta é adicionar métodos de acesso à memória ao objeto ArchState MPACT-Sim, ou, de forma mais adequada, crie um novo objeto de estado do RiscV derivado de ArchState onde ela pode ser adicionada. O objeto ArchState gerencia os recursos principais, como e outros objetos de estado. Ele também gerencia as linhas de atraso usadas para armazenar em buffer os buffers de dados do operando de destino até que eles possam ser gravados de volta no os objetos de registro. A maioria das instruções pode ser implementada sem conhecimento de dessa classe, mas algumas, como operações de memória e outras funções precisam de funcionalidade para residir nesse objeto de estado.

Vamos dar uma olhada na função semântica da instrução fence, que é já implementado em rv32i_instructions.cc como exemplo. O fence a instrução mantém problemas de instrução até que certas operações de memória concluído. É usado para garantir a ordem da memória entre as instruções que são executadas antes e depois da instrução.

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

A parte mais importante da função semântica da instrução fence são as duas últimas. linhas Primeiro, o objeto de estado é buscado usando um método na Instruction. e downcast<> para a classe derivada específica do RiscV. Depois, o Fence da classe RiscVState é chamado para realizar a operação de limite.

As instruções para lojas funcionarão da mesma forma. Primeiro, o endereço efetivo da o acesso à memória é calculado a partir dos operandos de origem de origem da instrução base e de deslocamento. o valor a ser armazenado é buscado no próximo operando de origem. Em seguida, O objeto de estado RiscV é recebido pela chamada de método state() e static_cast<>, e o método apropriado é chamado.

O método StoreMemory do objeto RiscVState é relativamente simples, mas tem uma algumas implicações das quais precisamos estar cientes:

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

Como podemos ver, o método usa três parâmetros: o ponteiro para o repositório a instrução em si, o endereço da loja e um ponteiro para uma DataBuffer que contém os dados do armazenamento. Observe que nenhum tamanho é necessário, A instância DataBuffer contém um método size(). No entanto, não há operando de destino acessível para a instrução que pode ser usado para alocar uma instância DataBuffer do tamanho apropriado. Em vez disso, precisamos use uma fábrica DataBuffer extraída do método db_factory() na a instância Instruction. A fábrica tem um método Allocate(int size). que retorna uma instância DataBuffer do tamanho necessário. Aqui está um exemplo de como usar isso para alocar uma instância de DataBuffer para um repositório de meia palavra Observe que auto é um recurso em C++ que deduz o tipo da da tarefa):

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

Assim que tivermos a instância DataBuffer, poderemos gravar nela normalmente:

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

Em seguida, transmita-o para a interface de armazenamento de memória:

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

Ainda não terminamos. A instância DataBuffer é contada como referência. Isso normalmente é entendido e processado pelo método Submit, de modo a manter o caso de uso mais frequente o mais simples possível. No entanto, o StoreMemory não é escrita dessa maneira. Ele vai IncRef a DataBuffer instância enquanto opera e DecRef quando terminar. No entanto, se a função semântica não DecRef é a própria referência, ele nunca será recuperado. Assim, a última linha tem para ser:

  db->DecRef();

Há três funções de armazenamento, e a única diferença é o tamanho o acesso à memória. Esta parece uma ótima oportunidade para outra empresa local função auxiliar com modelo. A única coisa diferente na função da loja é o tipo de valor da loja, portanto, o modelo precisa ter isso como argumento. Fora isso, somente a instância Instruction precisa ser transmitida:

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

Conclua as funções semânticas de armazenamento e o build. Você pode conferir trabalhar contra rv32i_instructions.cc.


Adicionar instruções de carregamento de memória

As instruções de carregamento que precisam ser implementadas são:

  • lb: byte de carregamento, extensão de sinal em uma palavra.
  • lbu: byte de carregamento sem assinatura, extensão zero em uma palavra.
  • lh - carregar meia palavra, extensão de sinal em uma palavra.
  • lhu: carrega meia palavra sem assinatura, extensão zero em uma palavra.
  • lw: carregar palavra.

As instruções de carregamento são as instruções mais complexas que temos para modelar neste tutorial. Eles são semelhantes às instruções de armazenamento, porque precisam acessar o objeto RiscVState, mas aumenta a complexidade de cada carga é dividida em duas funções semânticas separadas. A primeira é semelhante à instrução store, que calcula o endereço efetivo e inicia o acesso à memória. O segundo é executado quando a memória acesso é concluído e grava os dados da memória no registro de destino operando.

Vamos começar analisando a declaração do método LoadMemory em RiscVState:

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

Em comparação com o método StoreMemory, o LoadMemory usa dois parâmetros: um ponteiro para uma instância Instruction e outro para uma o objeto context da referência foi contabilizado. O primeiro é a instrução child que implementa o write-back de registro (descrito no tutorial do decodificador da ISA). Ela é acessado usando o método child() na instância atual do Instruction. O último é um indicador para uma instância de uma classe derivada da ReferenceCount que, nesse caso, armazena uma instância de DataBuffer que vai que contêm os dados carregados. O objeto de contexto está disponível por meio do Método context() no objeto Instruction. No entanto, para a maioria das instruções, definido como nullptr).

O objeto de contexto para carregamentos de memória do RiscV é definido como a seguinte estrutura:

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

As instruções de carregamento são todas as mesmas, exceto pelo tamanho dos dados (byte, meia palavra e palavra) e se o valor carregado é estendido por sinal ou não. A O último fator é considerado na instrução filho. Vamos criar um modelo para as instruções de carregamento principais. Será muito semelhante à armazenar instrução, mas não acessará um operando de origem para obter um valor, e criará um objeto de contexto.

template <typename ValueType>
inline void LoadValue(Instruction *instruction) {
  auto base = generic::GetInstructionSource<uint32_t>(instruction, 0);
  auto offset = generic::GetInstructionSource<uint32_t>(instruction, 1);
  uint32_t address = base + offset;
  auto *state = down_cast<RiscVState *>(instruction->state());
  auto *db = state->db_factory()->Allocate(sizeof(ValueType));
  db->set_latency(0);
  auto *context = new riscv::LoadContext(db);
  state->LoadMemory(instruction, address, db, instruction->child(), context);
  context->DecRef();
}

A principal diferença é que a instância DataBuffer alocada são transmitidos para a chamada LoadMemory como um parâmetro e armazenados no objeto LoadContext.

As funções semânticas da instrução child são muito semelhantes. Primeiro, os LoadContext é recebido chamando o método context() do Instruction. transmitido estático para o LoadContext *. Segundo, o valor (de acordo com o type) é lida na instância DataBuffer de dados de carregamento. Terceiro, um novo A instância DataBuffer é alocada do operando de destino. Por fim, o o valor carregado é gravado na nova instância de DataBuffer e Submit. Novamente, uma função auxiliar com modelo é uma boa ideia:

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

Implemente as últimas funções auxiliares e semânticas. Pagar atenção ao tipo de dados usado no modelo para cada função auxiliar e que ela corresponde ao tamanho e à natureza assinada/não assinada da carga instrução.

Você pode comparar seu trabalho rv32i_instructions.cc.


Criar e executar o simulador final

Agora que já fizemos todos os últimos ajustes, podemos criar o simulador final. A bibliotecas C++ de nível superior que unem todo o trabalho nestes tutoriais são localizado em other/. Não é necessário analisar muito esse código. Qa vão acessar esse tópico em um tutorial avançado futuro.

Mude seu diretório de trabalho para other/ e crie. Ele deve ser criado sem erros.

$ cd ../other
$ bazel build :rv32i_sim

Nesse diretório, há um código "Hello World" programa no arquivo hello_rv32i.elf: Para executar o simulador nesse arquivo e conferir os resultados:

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

Você verá algo ao longo das linhas de:

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
$

O simulador também pode ser executado em modo interativo usando o comando bazel run :rv32i_sim -- -i other/hello_rv32i.elf. Isso traz à tona shell de comando. Digite help no prompt para conferir os comandos disponíveis.

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

Isso conclui este tutorial. Esperamos que essas informações tenham sido úteis.