Mục tiêu của hướng dẫn này là:
- Tìm hiểu cách sử dụng các hàm ngữ nghĩa để triển khai ngữ nghĩa của lệnh.
- Tìm hiểu mối quan hệ giữa các hàm ngữ nghĩa với nội dung mô tả bộ giải mã ISA.
- Viết hàm ngữ nghĩa của lệnh cho các lệnh RiscV RV32I.
- Kiểm thử trình mô phỏng cuối cùng bằng cách chạy một tệp thực thi nhỏ "Hello World" ("Xin chào thế giới").
Tổng quan về các hàm ngữ nghĩa
Hàm ngữ nghĩa trong MPACT-Sim là một hàm triển khai hoạt động của một lệnh để các hiệu ứng phụ của lệnh đó hiển thị trong trạng thái mô phỏng giống như cách các hiệu ứng phụ của lệnh hiển thị khi được thực thi trong phần cứng. Bản trình bày nội bộ của trình mô phỏng về từng lệnh được giải mã có chứa một lệnh gọi dùng để gọi hàm ngữ nghĩa cho lệnh đó.
Hàm ngữ nghĩa có chữ ký void(Instruction *)
, tức là một hàm lấy con trỏ trỏ đến một thực thể của lớp Instruction
và trả về void
.
Lớp Instruction
được xác định trong instruction.h
Để viết các hàm ngữ nghĩa, chúng ta đặc biệt quan tâm đến các vectơ giao diện toán hạng nguồn và đích truy cập bằng các lệnh gọi phương thức Source(int i)
và Destination(int i)
.
Dưới đây là các giao diện toán hạng nguồn và đích:
// 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;
};
Cách cơ bản để viết hàm ngữ nghĩa cho lệnh bình thường có 3 toán hạng, chẳng hạn như lệnh add
32 bit, như sau:
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();
}
Hãy cùng phân tích các phần của hàm này. Hai dòng đầu tiên của phần nội dung hàm đọc từ toán hạng nguồn 0 và 1. Lệnh gọi AsUint32(0)
diễn giải dữ liệu cơ bản là một mảng uint32_t
và tìm nạp phần tử thứ 0. Điều này đúng bất kể thanh ghi hoặc giá trị cơ bản có giá trị mảng hay không. Kích thước (trong các phần tử) của toán hạng nguồn có thể nhận được từ phương thức toán hạng nguồn shape()
. Phương thức này trả về một vectơ chứa số lượng phần tử trong mỗi chiều. Phương thức đó trả về {1}
cho đại lượng vô hướng, {16}
cho vectơ 16 phần tử và {4, 4}
cho mảng 4x4.
uint32_t a = inst->Source(0)->AsUint32(0);
uint32_t b = inst->Source(1)->AsUint32(0);
Sau đó, một uint32_t
tạm thời có tên là c
được gán giá trị a + b
.
Bạn có thể cần giải thích chi tiết hơn trên dòng tiếp theo:
DataBuffer *db = inst->Destination(0)->AllocateDataBuffer();
DataBuffer là một đối tượng được tính tham chiếu dùng để lưu trữ các giá trị ở trạng thái mô phỏng, chẳng hạn như thanh ghi. Thuộc tính này tương đối không có kiểu, mặc dù có kích thước dựa trên đối tượng được phân bổ. Trong trường hợp này, kích thước đó là sizeof(uint32_t)
. Câu lệnh này phân bổ vùng đệm dữ liệu mới có kích thước cho đích đến là mục tiêu của toán hạng đích đến này – trong trường hợp này là một thanh ghi số nguyên 32 bit. DataBuffer cũng được khởi tạo bằng độ trễ cấu trúc cho lệnh. Giá trị này được chỉ định trong quá trình giải mã lệnh.
Dòng tiếp theo coi thực thể vùng đệm dữ liệu là một mảng uint32_t
và ghi giá trị được lưu trữ trong c
vào phần tử thứ 0.
db->Set<uint32_t>(0, c);
Cuối cùng, câu lệnh cuối cùng gửi vùng đệm dữ liệu đến trình mô phỏng để dùng làm giá trị mới của trạng thái máy mục tiêu (trong trường hợp này là một thanh ghi) sau độ trễ của lệnh được đặt khi lệnh được giải mã và vectơ toán hạng đích được điền.
Mặc dù đây là một hàm khá ngắn gọn, nhưng hàm này có một chút mã nguyên mẫu trở nên lặp lại khi triển khai hướng dẫn sau hướng dẫn. Ngoài ra, việc này có thể che khuất ngữ nghĩa thực tế của hướng dẫn. Để đơn giản hoá việc viết các hàm ngữ nghĩa cho hầu hết các lệnh, có một số hàm trợ giúp mẫu được định nghĩa trong instruction_helpers.h. Những trình trợ giúp này ẩn mã nguyên mẫu để đi xem hướng dẫn bằng một, hai hoặc ba toán hạng nguồn và một toán hạng đích duy nhất. Hãy cùng xem 2 hàm trợ giúp toán hạng:
// 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);
}
Bạn sẽ nhận thấy rằng thay vì sử dụng câu lệnh như:
uint32_t a = inst->Source(0)->AsUint32(0);
Hàm trợ giúp sử dụng:
generic::GetInstructionSource<Argument>(instruction, 0);
GetInstructionSource
là một nhóm hàm trợ giúp theo mẫu dùng để cung cấp phương thức truy cập theo mẫu cho các toán hạng nguồn lệnh. Nếu không có toán hạng nguồn, mỗi hàm trợ giúp hướng dẫn sẽ phải chuyên biệt hoá cho từng loại để truy cập vào toán hạng nguồn bằng hàm As<int type>()
chính xác. Bạn có thể xem định nghĩa của các hàm mẫu này trong instruction.h.
Như bạn có thể thấy, có ba cách triển khai, tuỳ thuộc vào việc các loại toán hạng nguồn có giống với đích đến hay không, đích đến có khác với nguồn hay không hoặc tất cả đều khác nhau. Mỗi phiên bản của hàm sẽ lấy một con trỏ đến thực thể hướng dẫn và một hàm có thể gọi (bao gồm các hàm lambda). Điều này có nghĩa là giờ đây chúng ta có thể viết lại hàm ngữ nghĩa add
ở trên như sau:
void MyAddFunction(Instruction *inst) {
generic::BinaryOp<uint32_t>(inst,
[](uint32_t a, uint32_t b) { return a + b; });
}
Khi được biên dịch bằng bazel build -c opt
và copts = ["-O3"]
trong tệp bản dựng, thuộc tính này sẽ nội tuyến hoàn toàn mà không có mức hao tổn, mang lại cho chúng tôi tính ngắn gọn về mặt hiệu suất mà không có bất kỳ hình phạt về hiệu suất nào.
Như đã đề cập, có các hàm trợ giúp cho các lệnh vô hướng, nhị phân và ba ngôi cũng như các lệnh tương đương vectơ. Các hàm này cũng đóng vai trò là mẫu hữu ích để tạo trình trợ giúp của riêng bạn cho các hướng dẫn không phù hợp với khuôn mẫu chung.
Bản dựng ban đầu
Nếu bạn chưa thay đổi thư mục thành riscv_semantic_functions
, hãy thực hiện ngay. Sau đó, hãy tạo dự án như sau – bản dựng này sẽ thành công.
$ bazel build :riscv32i
...<snip>...
Không có tệp nào được tạo, vì vậy, đây thực sự chỉ là một lần chạy thử để đảm bảo mọi thứ đều ổn.
Thêm ba hướng dẫn ALU toán hạng
Bây giờ, hãy thêm các hàm ngữ nghĩa cho một số lệnh ALU 3 toán hạng chung. Mở tệp rv32i_instructions.cc
và đảm bảo rằng mọi định nghĩa bị thiếu đều được thêm vào tệp rv32i_instructions.h
khi chúng ta tiếp tục.
Sau đây là hướng dẫn mà chúng tôi sẽ thêm:
add
– Cộng số nguyên 32 bit.and
– bitwise 32 bit và.or
– Toán tử hoặc bit 32 bit.sll
– Di chuyển logic 32 bit sang trái.sltu
– 32 bit chưa ký được đặt nhỏ hơn.sra
– Lệnh dịch sang phải số học 32 bit.srl
– Chuyển phải logic 32 bit.sub
– phép trừ số nguyên 32 bit.xor
– xor bitwise 32 bit.
Nếu đã xem các hướng dẫn trước, bạn có thể nhớ lại rằng chúng ta đã phân biệt giữa lệnh đăng ký tài khoản và lệnh đăng ký ngay lập tức trong bộ giải mã. Khi nói đến các hàm ngữ nghĩa, chúng ta không cần làm như vậy nữa. Các giao diện toán hạng sẽ đọc giá trị toán hạng từ bất kỳ toán hạng nào là, đăng ký hay tức thì, với hàm ngữ nghĩa hoàn toàn không phụ thuộc vào toán hạng nguồn cơ bản thực sự là gì.
Ngoại trừ sra
, tất cả các hướng dẫn ở trên đều có thể được coi là hoạt động trên các giá trị 32 bit chưa ký. Vì vậy, đối với các hướng dẫn này, chúng ta có thể sử dụng hàm mẫu BinaryOp
mà chúng ta đã xem xét trước đó chỉ với một đối số loại mẫu. Điền nội dung hàm vào rv32i_instructions.cc
cho phù hợp. Xin lưu ý rằng chỉ 5 bit thấp của toán hạng thứ hai cho lệnh dịch được dùng cho số lượng dịch. Nếu không, tất cả các toán tử đều có dạng 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
Đối với sra
, chúng ta sẽ sử dụng mẫu BinaryOp
có 3 đối số. Xem xét mẫu, đối số loại đầu tiên là loại kết quả uint32_t
. Thứ hai là loại toán hạng nguồn 0, trong trường hợp này là int32_t
, và cuối cùng là loại toán hạng nguồn 1, trong trường hợp này là uint32_t
. Điều đó làm cho phần nội dung của hàm ngữ nghĩa sra
:
generic::BinaryOp<uint32_t, int32_t, uint32_t>(
instruction, [](int32_t a, uint32_t b) { return a >> (b & 0x1f); });
Hãy tiếp tục thực hiện các thay đổi và tạo bản dựng. Bạn có thể kiểm tra công việc của mình với rv32i_instructions.cc.
Thêm hai lệnh ALU về toán hạng
Chỉ có hai lệnh ALU 2 toán hạng: lui
và auipc
. Lệnh trước sao chép toán hạng nguồn đã được dịch chuyển trước trực tiếp đến đích. Hàm sau thêm địa chỉ lệnh vào ngay trước khi ghi địa chỉ lệnh vào đích đến. Bạn có thể truy cập vào địa chỉ hướng dẫn từ phương thức address()
của đối tượng Instruction
.
Vì chỉ có một toán hạng nguồn duy nhất, nên chúng ta không thể sử dụng BinaryOp
. Thay vào đó, chúng ta cần dùng UnaryOp
. Vì chúng ta có thể coi cả toán hạng nguồn và toán hạng đích là uint32_t
, nên có thể sử dụng phiên bản mẫu đối số duy nhất.
// 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);
}
Phần nội dung của hàm ngữ nghĩa cho lui
không có gì đáng kể, chỉ cần trả về nguồn. Hàm ngữ nghĩa cho auipc
gây ra một vấn đề nhỏ, vì bạn cần truy cập vào phương thức address()
trong thực thể Instruction
. Câu trả lời là thêm instruction
vào hàm lambda để có thể sử dụng trong phần nội dung hàm lambda. Thay vì [](uint32_t a) { ...
}
như trước, bạn nên viết lambda là [instruction](uint32_t a) { ... }
.
Giờ đây, bạn có thể sử dụng instruction
trong phần nội dung lambda.
Hãy tiếp tục và thay đổi rồi tạo bản dựng. Bạn có thể kiểm tra công việc của mình dựa trên rv32i_instructions.cc.
Thêm hướng dẫn thay đổi luồng điều khiển
Các lệnh thay đổi luồng điều khiển mà bạn cần triển khai được chia thành lệnh nhánh có điều kiện (các nhánh ngắn hơn được thực hiện nếu một phép so sánh đúng) và lệnh nhảy và liên kết, được dùng để triển khai lệnh gọi hàm (-and-link bị xoá bằng cách đặt thanh ghi liên kết thành 0, khiến các lệnh ghi đó không hoạt động).
Thêm hướng dẫn nhánh có điều kiện
Không có hàm trợ giúp cho lệnh nhánh, vì vậy, có hai tuỳ chọn. Viết các hàm ngữ nghĩa từ đầu hoặc viết một hàm trợ giúp cục bộ. Vì chúng ta cần triển khai 6 hướng dẫn nhánh, nên hướng dẫn sau có vẻ xứng đáng với công sức. Trước khi làm việc đó, hãy xem cách triển khai một hàm ngữ nghĩa lệnh hướng dẫn nhánh từ đầu.
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();
}
}
Điều duy nhất thay đổi giữa các lệnh ở nhánh là điều kiện nhánh và các kiểu dữ liệu int 32 bit có dấu và chưa ký của hai toán hạng nguồn. Điều đó nghĩa là chúng ta cần có tham số mẫu cho các toán hạng nguồn. Bản thân hàm trợ giúp cần lấy thực thể Instruction
và một đối tượng có thể gọi như std::function
trả về bool
làm tham số. Hàm trợ giúp sẽ có dạng như sau:
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();
}
}
Bây giờ, chúng ta có thể viết hàm ngữ nghĩa bge
(nhánh đã ký lớn hơn hoặc bằng) như sau:
void RV32IBge(Instruction *instruction) {
BranchConditional<int32_t>(instruction,
[](int32_t a, int32_t b) { return a >= b; });
}
Các hướng dẫn nhánh còn lại như sau:
- Beq – nhánh bằng.
- Bgeu – nhánh lớn hơn hoặc bằng (chưa ký).
- Blt – nhánh nhỏ hơn (đã ký).
- Bltu – nhánh nhỏ hơn (chưa ký).
- Bne - nhánh không bằng.
Hãy tiếp tục thực hiện các thay đổi để triển khai các hàm ngữ nghĩa này và tạo lại. Bạn có thể kiểm tra công việc của mình dựa trên rv32i_instructions.cc.
Thêm hướng dẫn nhảy và liên kết
Không có lý do gì để viết hàm trợ giúp cho lệnh nhảy và liên kết, vì vậy, chúng ta sẽ cần viết các hàm này từ đầu. Hãy bắt đầu bằng cách xem xét ngữ nghĩa của hướng dẫn.
Lệnh jal
lấy độ dời từ toán hạng nguồn 0 và thêm vào pc (địa chỉ lệnh) hiện tại để tính toán mục tiêu nhảy. Mục tiêu nhảy được ghi vào toán hạng đích 0. Địa chỉ trả về là địa chỉ của lệnh tuần tự tiếp theo. Bạn có thể tính toán bằng cách thêm kích thước của lệnh hiện tại vào địa chỉ của lệnh đó. Địa chỉ trả về được ghi vào toán hạng đích 1. Hãy nhớ đưa con trỏ đối tượng hướng dẫn vào quá trình thu thập lambda.
Lệnh jalr
lấy một thanh ghi cơ sở làm toán hạng nguồn 0 và một độ dời làm toán hạng nguồn 1, sau đó cộng các toán hạng này lại với nhau để tính toán đích nhảy.
Nếu không, lệnh này giống hệt với lệnh jal
.
Dựa trên các nội dung mô tả này về ngữ nghĩa của lệnh, hãy viết hai hàm ngữ nghĩa và tạo. Bạn có thể kiểm tra công việc của mình dựa trên rv32i_instructions.cc.
Thêm hướng dẫn lưu trữ bộ nhớ
Có 3 hướng dẫn cửa hàng mà chúng ta cần triển khai: store byte (sb
), lưu trữ nửa từ (sh
) và lưu trữ từ (sw
). Hướng dẫn lưu trữ khác với hướng dẫn chúng tôi đã triển khai ở chỗ chúng không ghi vào trạng thái bộ xử lý cục bộ. Thay vào đó, chúng ghi vào tài nguyên hệ thống – bộ nhớ chính. MPACT-Sim không coi bộ nhớ là toán hạng lệnh, vì vậy, bạn phải thực hiện việc truy cập vào bộ nhớ bằng một phương pháp khác.
Câu trả lời là thêm các phương thức truy cập bộ nhớ vào đối tượng ArchState
MPACT-Sim, hoặc chính xác hơn là tạo một đối tượng trạng thái RiscV mới bắt nguồn từ ArchState
nơi có thể thêm đối tượng này. Đối tượng ArchState
quản lý các tài nguyên chính, chẳng hạn như các thanh ghi và các đối tượng trạng thái khác. Bộ nhớ đệm này cũng quản lý các đường trễ dùng để lưu vào bộ đệm dữ liệu toán hạng đích cho đến khi có thể ghi lại vào các đối tượng thanh ghi. Bạn có thể triển khai hầu hết các lệnh mà không cần biết về lớp này, nhưng một số lệnh, chẳng hạn như thao tác bộ nhớ và các lệnh hệ thống cụ thể khác, yêu cầu chức năng nằm trong đối tượng trạng thái này.
Hãy xem hàm ngữ nghĩa cho lệnh fence
đã được triển khai trong rv32i_instructions.cc
làm ví dụ. Lệnh fence
giữ vấn đề về lệnh cho đến khi một số thao tác bộ nhớ nhất định hoàn tất. Phương thức này dùng để đảm bảo thứ tự bộ nhớ giữa các lệnh thực thi trước lệnh và các lệnh thực thi sau lệnh.
// 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);
}
Phần quan trọng trong hàm ngữ nghĩa của lệnh fence
là 2 dòng cuối. Trước tiên, đối tượng trạng thái được tìm nạp bằng phương thức trong lớp Instruction
và downcast<>
đến lớp dẫn xuất dành riêng cho RiscV. Sau đó, phương thức Fence
của lớp RiscVState
sẽ được gọi để thực hiện thao tác hàng rào.
Hướng dẫn lưu trữ cũng hoạt động theo cách tương tự. Trước tiên, địa chỉ hiệu quả của truy cập bộ nhớ được tính toán từ toán hạng nguồn lệnh cơ sở và độ dời, sau đó giá trị cần lưu trữ được tìm nạp từ toán hạng nguồn tiếp theo. Tiếp theo, đối tượng trạng thái RiscV được lấy thông qua lệnh gọi phương thức state()
và static_cast<>
, đồng thời phương thức thích hợp được gọi.
Phương thức StoreMemory
của đối tượng RiscVState
tương đối đơn giản, nhưng có một vài tác động mà chúng ta cần lưu ý:
void StoreMemory(const Instruction *inst, uint64_t address, DataBuffer *db);
Như chúng ta có thể thấy, phương thức này nhận 3 tham số, con trỏ đến chính lệnh cửa hàng, địa chỉ của cửa hàng và con trỏ đến thực thể DataBuffer
chứa dữ liệu cửa hàng. Lưu ý rằng không yêu cầu kích thước. Bản thân thực thể DataBuffer
chứa phương thức size()
. Tuy nhiên, không có toán hạng đích nào có thể truy cập vào lệnh mà có thể dùng để phân bổ thực thể DataBuffer
có kích thước phù hợp. Thay vào đó, chúng ta cần sử dụng nhà máy DataBuffer
lấy từ phương thức db_factory()
trong thực thể Instruction
. Nhà máy có một phương thức Allocate(int size)
trả về một thực thể DataBuffer
có kích thước bắt buộc. Sau đây là ví dụ về cách sử dụng tính năng này để phân bổ một thực thể DataBuffer
cho một cửa hàng nửa từ (lưu ý auto
là một tính năng C++ suy ra loại từ bên phải của chỉ định):
auto *state = down_cast<RiscVState *>(instruction->state());
auto *db = state->db_factory()->Allocate(sizeof(uint16_t));
Sau khi có thực thể DataBuffer
, chúng ta có thể ghi vào thực thể đó như bình thường:
db->Set<uint16_t>(0, value);
Sau đó, truyền mã này vào giao diện lưu trữ bộ nhớ:
state->StoreMemory(instruction, address, db);
Chúng tôi vẫn chưa hoàn tất. Thực thể DataBuffer
được tính. Phương thức Submit
thường hiểu và xử lý vấn đề này để giữ cho trường hợp sử dụng thường xuyên nhất đơn giản nhất có thể. Tuy nhiên, StoreMemory
không được viết theo cách đó. Phương thức này sẽ IncRef
thực thể DataBuffer
trong khi hoạt động trên thực thể đó, sau đó DecRef
khi hoàn tất. Tuy nhiên, nếu hàm ngữ nghĩa không DecRef
tham chiếu riêng, thì hàm đó sẽ không bao giờ được xác nhận lại. Do đó, dòng cuối cùng phải là:
db->DecRef();
Có ba hàm lưu trữ và điều duy nhất khác biệt là kích thước của quyền truy cập bộ nhớ. Đây có vẻ là một cơ hội tuyệt vời cho một hàm trợ giúp mẫu cục bộ khác. Điểm khác biệt duy nhất giữa các hàm lưu trữ là kiểu của giá trị lưu trữ, vì vậy, mẫu phải có kiểu đó làm đối số.
Ngoài ra, bạn chỉ cần truyền thực thể Instruction
:
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();
}
Hãy tiếp tục và hoàn tất các hàm ngữ nghĩa lưu trữ và tạo. Bạn có thể kiểm tra công việc của mình dựa trên rv32i_instructions.cc.
Thêm hướng dẫn tải bộ nhớ
Sau đây là các hướng dẫn tải cần triển khai:
lb
– tải byte, ký hiệu mở rộng thành một từ.lbu
– tải byte chưa ký, mở rộng về 0 thành một từ.lh
– tải một nửa từ, ký hiệu mở rộng thành một từ.lhu
– tải nửa từ chưa ký, mở rộng bằng 0 thành một từ.lw
- tải từ.
Lệnh tải là lệnh phức tạp nhất mà chúng ta phải lập mô hình trong hướng dẫn này. Các lệnh này tương tự như lệnh lưu trữ, ở chỗ cần truy cập vào đối tượng RiscVState
, nhưng có thêm độ phức tạp ở chỗ mỗi lệnh tải được chia thành 2 hàm ngữ nghĩa riêng biệt. Lệnh đầu tiên
tương tự như lệnh cửa hàng, ở chỗ lệnh này tính toán địa chỉ có hiệu lực
và bắt đầu truy cập vào bộ nhớ. Phương thức thứ hai được thực thi khi hoạt động truy cập bộ nhớ hoàn tất và ghi dữ liệu bộ nhớ vào toán hạng đích của thanh ghi.
Hãy bắt đầu bằng cách xem phần khai báo phương thức LoadMemory
trong RiscVState
:
void LoadMemory(const Instruction *inst, uint64_t address, DataBuffer *db,
Instruction *child_inst, ReferenceCount *context);
So với phương thức StoreMemory
, LoadMemory
có thêm hai tham số: con trỏ trỏ đến một thực thể Instruction
và con trỏ trỏ đến một đối tượng context
được tính tham chiếu. Lệnh trước là lệnh con triển khai tính năng ghi lại thanh ghi (được mô tả trong hướng dẫn về bộ giải mã ISA). Bạn có thể truy cập vào đối tượng này bằng phương thức child()
trong thực thể Instruction
hiện tại.
Đối tượng sau là con trỏ trỏ đến một thực thể của lớp bắt nguồn từ ReferenceCount
. Trong trường hợp này, con trỏ sẽ lưu trữ thực thể DataBuffer
chứa dữ liệu đã tải. Bạn có thể sử dụng đối tượng ngữ cảnh thông qua phương thức context()
trong đối tượng Instruction
(mặc dù đối với hầu hết các hướng dẫn, giá trị này được đặt thành nullptr
).
Đối tượng ngữ cảnh cho các lượt tải bộ nhớ RiscV được xác định là cấu trúc sau:
// 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;
};
Các hướng dẫn tải đều giống nhau, ngoại trừ kích thước dữ liệu (byte, nửa từ và từ) và liệu giá trị tải có phải là mở rộng ký hiệu hay không. Giá trị sau chỉ ảnh hưởng đến lệnh con. Hãy tạo một hàm trợ giúp được tạo sẵn cho các lệnh tải chính. Lệnh này sẽ rất giống với lệnh lưu trữ, ngoại trừ việc lệnh này sẽ không truy cập vào toán hạng nguồn để lấy giá trị và sẽ tạo một đối tượng ngữ cảnh.
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();
}
Như bạn có thể thấy, điểm khác biệt chính là thực thể DataBuffer
đã phân bổ vừa được truyền đến lệnh gọi LoadMemory
dưới dạng tham số, vừa được lưu trữ trong đối tượng LoadContext
.
Các hàm ngữ nghĩa của lệnh con đều rất giống nhau. Trước tiên, bạn lấy LoadContext
bằng cách gọi phương thức Instruction
context()
và truyền tĩnh đến LoadContext *
. Thứ hai, giá trị (theo loại dữ liệu) được đọc từ thực thể DataBuffer
của dữ liệu tải. Thứ ba, một thực thể DataBuffer
mới được phân bổ từ toán hạng đích. Cuối cùng, giá trị đã tải được ghi vào phiên bản DataBuffer
mới và Submit
. Một lần nữa, bạn nên sử dụng hàm trợ giúp theo mẫu:
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();
}
Hãy tiếp tục triển khai các hàm trợ giúp và hàm ngữ nghĩa cuối cùng này. Hãy chú ý đến loại dữ liệu mà bạn sử dụng trong mẫu cho mỗi lệnh gọi hàm trợ giúp và loại dữ liệu đó phải tương ứng với kích thước và bản chất đã ký/chưa ký của lệnh tải.
Bạn có thể kiểm tra công việc của mình dựa trên rv32i_instructions.cc.
Tạo bản dựng và chạy trình mô phỏng cuối cùng
Bây giờ, sau khi đã hoàn thành các nhiệm vụ khó, chúng ta có thể xây dựng trình mô phỏng cuối cùng. Các thư viện C++ cấp cao nhất liên kết tất cả công việc trong các hướng dẫn này nằm trong other/
. Bạn không cần phải nhìn quá khó vào mã đó. Chúng ta sẽ truy cập chủ đề đó trong một hướng dẫn nâng cao trong tương lai.
Thay đổi thư mục đang hoạt động thành other/
rồi tạo bản dựng. Mã này nên được xây dựng mà không gặp lỗi.
$ cd ../other
$ bazel build :rv32i_sim
Trong thư mục đó, có một chương trình "hello world" đơn giản trong tệp hello_rv32i.elf
. Cách chạy trình mô phỏng trên tệp này và xem kết quả:
$ bazel run :rv32i_sim -- other/hello_rv32i.elf
Bạn sẽ thấy nội dung tương tự như sau:
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
$
Bạn cũng có thể chạy trình mô phỏng ở chế độ tương tác bằng lệnh bazel
run :rv32i_sim -- -i other/hello_rv32i.elf
. Thao tác này sẽ trả về một shell lệnh đơn giản. Nhập help
tại lời nhắc để xem các lệnh có sẵn.
$ 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] >
Bài viết này sẽ kết thúc hướng dẫn này. Chúng tôi hy vọng thông tin này hữu ích cho bạn.