Tutorial de funções semânticas

Os objetivos deste tutorial são:

  • Saiba como as funções semânticas são usadas para implementar semânticas de instrução.
  • 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 instruções RiscV RV32I.
  • Execute um pequeno executável "Hello World" para testar o simulador final.

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

Uma função semântica no MPACT-Sim implementa a operação de uma instrução para que os efeitos colaterais dela fiquem visíveis no estado simulado da mesma forma que os efeitos colaterais da instrução são visíveis quando executados no hardware. A representação interna do simulador de cada instrução decodificada contém um elemento que é usado para chamar a função semântica dessa instrução.

Uma função semântica tem a assinatura void(Instruction *), ou seja, uma função que recebe 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 nos vetores de interface do operando de origem e destino acessados usando as 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 uma instrução de operandos normais, 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 analisar os elementos dessa função. As duas primeiras linhas do corpo da função leem os operandos de origem 0 e 1. A chamada AsUint32(0) interpreta os dados subjacentes como uma matriz uint32_t e busca o 0o elemento. Isso é verdadeiro, independentemente de o registro ou valor subjacente ter valor de matriz ou não. O tamanho (em elementos) do operando de origem pode ser obtido do método de operando de origem shape(), que retorna um vetor contendo 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 com contagem de referência usado para armazenar valores em estado simulado, como registros. Ele é relativamente não tipado, embora tenha um tamanho baseado no objeto em que é alocado. Nesse caso, esse tamanho é sizeof(uint32_t). Essa instrução aloca um novo buffer de dados dimensionado para o destino que é o destino desse operando de destino. Nesse caso, um registro de número inteiro de 32 bits. O DataBuffer também é inicializado com a latência da arquitetura para a instrução. Isso é especificado durante a decodificação de instruções.

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 ser usado como o novo valor do estado da máquina de destino (nesse caso, um registro) após a latência da instrução definida quando ela foi decodificada e o vetor de operando de destino preenchido.

Embora essa função seja razoavelmente breve, ela tem um pouco de código boilerplate que se torna repetitivo ao implementar instruções após a instrução. Além disso, ele pode obscurecer a semântica real da instrução. Para simplificar ainda mais a gravação das funções semânticas para a maioria das instruções, há várias funções auxiliares com modelo definidas em instruction_helpers.h. Esses auxiliares ocultam o código boilerplate para instruções com um, dois ou três operandos de origem e um único operando de destino. Vamos conferir duas funções auxiliares de 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 de modelo usadas para fornecer métodos de acesso com modelo aos operandos de origem da instrução. Sem eles, cada função auxiliar de instrução precisaria se especializar em cada tipo para acessar o operando de origem com a função As<int type>() correta. Consulte as definições dessas funções de modelo em instruction.h.

Como você pode ver, há três implementações, dependendo se os tipos de operandos de origem são iguais ao destino, se o destino é diferente das origens ou se todos são diferentes. Cada versão da função recebe um ponteiro para a instância de instrução, bem como uma função chamável, que inclui funções lambda. Isso significa que agora podemos reescrever a função semântica add 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 arquivo de build, ele precisa estar totalmente inline e sem overhead, proporcionando um resumo sucinto e sem penalidades de desempenho.

Como mencionado, há funções auxiliares para instruções escalares unárias, binárias e ternárias, bem como equivalentes de vetor. Eles também servem como modelos úteis para criar seus próprios auxiliares 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 isso é apenas uma simulação para garantir que tudo esteja em ordem.


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

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

As instruções que vamos adicionar 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ê fez os tutoriais anteriores, deve se lembrar de que diferenciamos instruções de registro-registro e instruções de registro-imediato no decodificador. Quando se trata de funções semânticas, não precisamos mais fazer isso. As interfaces de operando vão ler o valor do operando, seja registro ou imediato, com a função semântica completamente independente de qual é o operando de origem subjacente.

Exceto sra, todas as instruções acima podem ser tratadas como operando em valores não assinados de 32 bits. Portanto, para elas, podemos usar a função de modelo BinaryOp que analisamos anteriormente com apenas o argumento de tipo de modelo único. Preencha os corpos da função em rv32i_instructions.cc. Observe que apenas os 5 bits baixos do segundo operando para as instruções de deslocamento são usados para o valor de deslocamento. 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 modelo, o primeiro argumento de tipo é o tipo de resultado uint32_t. O segundo é o tipo de operando de origem 0, neste caso int32_t, e o último é o tipo de operando de origem 1, neste caso, uint32_t. Isso faz com que o corpo da função semântica sra seja:

  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 verificar seu trabalho em relação a rv32i_instructions.cc.


Adicionar instruções de ALU de dois operandos

Há apenas duas instruções de ALU de dois operandos: lui e auipc. O antigo copia o operando de origem pré-deslocado diretamente para o destino. O último adiciona o endereço de instrução ao imediato antes de gravá-lo no destino. O endereço de instrução é acessível pelo método address() do objeto Instruction.

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

// 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 para lui é muito simples, basta retornar a fonte. A função semântica de auipc apresenta um pequeno problema, já que você precisa acessar o método address() na instância Instruction. A resposta é adicionar instruction à captura de lambda, deixando-a 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.

Faça as mudanças e crie. Você pode verificar seu trabalho em rv32i_instructions.cc.


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

As instruções de alteração do fluxo de controle que você precisa implementar são divididas em instruções de ramificação condicional (ramificações mais curtas que são executadas se uma comparação for verdadeira) e instruções de jump-and-link, que são usadas para implementar chamadas de função (o -and-link é removido definindo o registro de link como zero, tornando 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 o esforço. Antes disso, vamos analisar a implementação de uma função semântica de instrução de ramificação 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 é a condição da ramificação e os tipos de dados, assinados ou não assinados de 32 bits int, dos dois operadores de origem. Isso significa que precisamos ter um parâmetro de modelo para as operandos de origem. A própria função auxiliar precisa usar a instância Instruction 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: branch 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 - branch not equal.

Faça as alterações para implementar essas funções semânticas e recrie. Você pode verificar seu trabalho em rv32i_instructions.cc.

Não há sentido em escrever uma função auxiliar para as instruções de salto e link, portanto, precisaremos escrevê-las do zero. Vamos começar examinando a semântica de instrução deles.

A instrução jal usa um deslocamento do operando de origem 0 e o adiciona ao pc (endereço de instrução) atual para calcular o destino do salto. O destino do salto é gravado no operando de destino 0. O endereço de retorno é o endereço da próxima instrução sequencial. Ela pode ser calculada adicionando o tamanho da instrução atual ao endereço dela. O endereço de retorno é gravado no operando de destino 1. Inclua o ponteiro do objeto de instrução na captura de 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 para calcular o destino do salto. Caso contrário, ela será idêntica à instrução jal.

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


Adicionar instruções de armazenamento de memória

Há três instruções de armazenamento que precisamos implementar: armazenar byte (sb), armazenar meia-palavra (sh) e armazenar palavra (sw). As instruções de armazenamento diferem das instruções que implementamos até agora, porque elas não gravam no estado do processador local. Em vez disso, eles gravam em um recurso do sistema, a 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 realizado usando outra metodologia.

A resposta é adicionar métodos de acesso à memória ao objeto ArchState MPACT-Sim ou, mais adequadamente, criar um novo objeto de estado RiscV derivado de ArchState, em que ele pode ser adicionado. O objeto ArchState gerencia os recursos principais, como registros 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 nos objetos de registro. A maioria das instruções pode ser implementada sem o conhecimento dessa classe, mas algumas, como operações de memória e outras instruções específicas do sistema, exigem funcionalidade para residir nesse objeto de estado.

Vamos conferir a função semântica da instrução fence que já está implementada em rv32i_instructions.cc como exemplo. A instrução fence mantém o problema de instrução até que determinadas operações de memória sejam concluídas. Ele é usado para garantir a ordenação de memória entre instruções que são executadas antes e depois.

// 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 principal 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 classe Instruction e downcast<> para a classe derivada específica do RiscV. Em seguida, o método Fence da classe RiscVState é chamado para executar a operação de limite.

As instruções para lojas funcionarão da mesma forma. Primeiro, o endereço efetivo do acesso à memória é calculado a partir dos operandos de origem da instrução base e de deslocamento. Em seguida, o valor a ser armazenado é buscado no próximo operando de origem. Em seguida, o objeto de estado do 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 algumas implicações que precisamos conhecer:

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

Como podemos ver, o método usa três parâmetros: o ponteiro para a própria instrução de armazenamento, o endereço do armazenamento e um ponteiro para uma instância DataBuffer que contém os dados do armazenamento. Nenhum tamanho é necessário, a própria instância DataBuffer contém um método size(). No entanto, não há um operando de destino acessível à instrução que possa ser usado para alocar uma instância DataBuffer do tamanho adequado. Em vez disso, precisamos usar uma fábrica DataBuffer extraída do método db_factory() na instância Instruction. A fábrica tem um método Allocate(int size) que retorna uma instância DataBuffer do tamanho necessário. Confira um exemplo de como usar isso para alocar uma instância DataBuffer para uma loja de meia palavra (observe que o auto é um recurso do C++ que deduz o tipo do lado direito da atribuição):

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

Depois de ter a instância DataBuffer, podemos gravar nela normalmente:

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

Em seguida, transmita para a interface do armazenamento em 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 para manter o caso de uso mais frequente o mais simples possível. No entanto, o StoreMemory não é escrito dessa forma. Ele IncRef a instância DataBuffer enquanto ela opera nele e depois DecRef quando terminar. No entanto, se a função semântica não DecRef a própria referência, ela nunca será recuperada. Assim, a última linha precisa ser:

  db->DecRef();

Há três funções de armazenamento, e a única diferença é o tamanho do acesso à memória. Essa parece uma ótima oportunidade para outra função auxiliar com modelo local. A única coisa diferente na função store é o tipo de valor da loja. Portanto, o modelo precisa ter esse valor como argumento. Além disso, apenas 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 verificar seu trabalho em relação a rv32i_instructions.cc.


Adicionar instruções de carregamento de memória

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

  • lb: carrega o byte, estende o sinal para uma palavra.
  • lbu: byte de carregamento não assinado, extensão zero em uma palavra.
  • lh: carregar meia palavra, ampliar o sinal de uma palavra.
  • lhu: carrega meia palavra sem sinal, com extensão de zero em uma palavra.
  • lw: carrega a palavra.

As instruções de carregamento são as instruções mais complexas que temos para modelar neste tutorial. Elas são semelhantes às instruções de armazenamento, porque precisam acessar o objeto RiscVState, mas adicionam complexidade, já que cada instrução de carregamento é 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. A segunda é executada quando o acesso à memória é concluído e grava os dados da memória no operando de destino do registro.

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 adicionais: um ponteiro para uma instância Instruction e um ponteiro para um objeto context com contagem de referência. A primeira é a instrução filha que implementa o registro de retorno (descrito no tutorial do decodificador ISA). Ele é acessado usando o método child() na instância atual de Instruction. O último é um ponteiro para uma instância de uma classe derivada de ReferenceCount que, nesse caso, armazena uma instância de DataBuffer que vai conter os dados carregados. O objeto de contexto está disponível pelo método context() no objeto Instruction, embora para a maioria das instruções ele seja definido como nullptr.

O objeto de contexto para cargas de memória 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. O último fator só é levado em consideração na instrução secundária. Vamos criar uma função auxiliar com modelo para as instruções de carga principais. Ela será muito semelhante à instrução de armazenamento, mas não acessará um operando de origem para receber 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();
}

Como você pode ver, a principal diferença é que a instância DataBuffer alocada é transmitida para a chamada LoadMemory como um parâmetro e armazenada no objeto LoadContext.

As funções semânticas da instrução child são muito semelhantes. Primeiro, o LoadContext é recebido chamando o método Instruction context() e transmitido de forma estática para o LoadContext *. Em segundo lugar, o valor (de acordo com o tipo de dados) é lido da instância DataBuffer de dados de carregamento. Terceiro, uma nova instância DataBuffer é alocada do operando de destino. Por fim, o valor carregado é gravado na nova instância 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. Preste atenção ao tipo de dados usado no modelo para cada chamada de função auxiliar e se ele corresponde ao tamanho e à natureza assinada/não assinada da instrução de carregamento.

Você pode conferir seu trabalho em relação a rv32i_instructions.cc.


Criar e executar o simulador final

Agora que já fizemos todos os últimos ajustes, podemos criar o simulador final. As bibliotecas C++ de nível superior que unem todo o trabalho nesses tutoriais estão localizadas em other/. Não é necessário olhar muito para esse código. Vamos abordar esse tópico em um tutorial avançado futuro.

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

$ cd ../other
$ bazel build :rv32i_sim

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

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

Você vai encontrar algo como:

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 abre um shell de comando simples. 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.