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 eor
: 32 bits ousll
: 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.
Adicionar instruções de link direto
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.