آموزش توابع معنایی

اهداف این آموزش عبارتند از:

  • بیاموزید که چگونه از توابع معنایی برای پیاده سازی معنایی دستورالعمل استفاده می شود.
  • بیاموزید که چگونه توابع معنایی با توضیحات رمزگشا ISA مرتبط هستند.
  • توابع معنایی دستورالعمل را برای دستورالعمل های RiscV RV32I بنویسید.
  • شبیه ساز نهایی را با اجرای یک فایل اجرایی کوچک "Hello World" تست کنید.

مروری بر توابع معنایی

یک تابع معنایی در MPACT-Sim تابعی است که عملکرد یک دستورالعمل را به گونه‌ای پیاده‌سازی می‌کند که اثرات جانبی آن در حالت شبیه‌سازی شده قابل مشاهده باشد، همانطور که هنگام اجرای سخت‌افزار، عوارض جانبی دستورالعمل قابل مشاهده است. نمایش داخلی شبیه ساز از هر دستور رمزگشایی شده حاوی یک فراخوانی است که برای فراخوانی تابع معنایی آن دستورالعمل استفاده می شود.

یک تابع معنایی دارای علامت void(Instruction *) است، یعنی تابعی که یک اشاره گر به نمونه ای از کلاس Instruction می گیرد و void برمی گرداند.

کلاس Instruction در instruction.h تعریف شده است

برای اهداف نوشتن توابع معنایی، ما به ویژه به بردارهای رابط عملوند مبدا و مقصد که با استفاده از فراخوانی روش Source(int i) و Destination(int i) قابل دسترسی هستند، علاقه مندیم.

رابط های عملوند مبدا و مقصد در زیر نشان داده شده است:

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

روش اصلی نوشتن یک تابع معنایی برای یک دستور عملوند معمولی 3 مانند یک دستور add 32 بیتی به شرح زیر است:

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

بیایید قطعات این تابع را تجزیه کنیم. دو خط اول بدنه تابع از عملوندهای منبع 0 و 1 خوانده می شود. فراخوانی AsUint32(0) داده های زیرین را به عنوان یک آرایه uint32_t تفسیر می کند و عنصر 0 را واکشی می کند. این صرف نظر از اینکه ثبات یا مقدار زیربنایی ارزش آرایه دارد یا خیر صادق است. اندازه (بر حسب عناصر) عملوند مبدا را می توان از روش عملوند مبدا shape() بدست آورد که یک بردار حاوی تعداد عناصر در هر بعد را برمی گرداند. این روش برای یک اسکالر {1} ، برای یک بردار 16 عنصری {16} و برای یک آرایه 4× {4, 4} را برمی گرداند.

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

DataBuffer یک شی شمارش مرجع است که برای ذخیره مقادیر در حالت شبیه سازی شده مانند ثبات ها استفاده می شود. نسبتاً بدون تایپ است، اگرچه اندازه آن بر اساس شیئی که از آن اختصاص داده شده است. در این مورد، آن اندازه sizeof(uint32_t) است. این عبارت یک بافر داده جدید به اندازه مقصدی که هدف این عملوند مقصد است - در این مورد یک ثبات عدد صحیح 32 بیتی اختصاص می دهد. DataBuffer همچنین با تأخیر معماری برای دستورالعمل مقدار دهی اولیه می شود. این در هنگام رمزگشایی دستورالعمل مشخص می شود.

خط بعدی نمونه بافر داده را به عنوان آرایه ای از uint32_t در نظر می گیرد و مقدار ذخیره شده در c را در عنصر 0 می نویسد.

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

در نهایت، آخرین دستور، بافر داده را به شبیه ساز ارسال می کند تا به عنوان مقدار جدید حالت ماشین هدف (در این مورد یک ثبات) پس از تأخیر دستورالعملی که هنگام رمزگشایی دستورالعمل تنظیم شده بود و بردار عملوند مقصد استفاده شود. پر جمعیت

در حالی که این یک تابع نسبتاً مختصر است، اما مقداری کد boilerplate دارد که هنگام اجرای دستورات پس از دستورالعمل تکرار می شود. علاوه بر این، می تواند معنای واقعی دستورالعمل را مبهم کند. به منظور ساده‌تر کردن نوشتن توابع معنایی برای بیشتر دستورالعمل‌ها، تعدادی توابع کمکی قالب‌بندی شده در instruction_helpers.h تعریف شده‌اند. این کمک کننده ها کد دیگ بخار را برای دستورالعمل ها با یک، دو یا سه عملوند مبدا و یک عملوند مقصد مخفی می کنند. بیایید نگاهی به دو تابع کمکی عملوند بیندازیم:

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

متوجه خواهید شد که به جای استفاده از عبارتی مانند:

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

تابع کمکی از موارد زیر استفاده می کند:

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

GetInstructionSource خانواده ای از توابع کمکی قالبی است که برای ارائه روش های دسترسی الگو به عملوندهای منبع دستورالعمل استفاده می شود. بدون آنها، هر یک از تابع های کمکی دستورالعمل باید برای هر نوع برای دسترسی به عملوند منبع با تابع صحیح As<int type>() تخصصی شود. می توانید تعاریف این توابع الگو را در instruction.h مشاهده کنید.

همانطور که می بینید، سه پیاده سازی وجود دارد، بسته به اینکه نوع عملوند مبدا با مقصد یکسان است، آیا مقصد با منابع متفاوت است یا همه آنها متفاوت هستند. هر نسخه از تابع یک اشاره گر به نمونه دستورالعمل و همچنین یک قابل فراخوانی (که شامل توابع لامبدا است) می گیرد. این بدان معناست که اکنون می‌توانیم تابع معنایی add را به صورت زیر بازنویسی کنیم:

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

هنگامی که با bazel build -c opt و copts = ["-O3"] در فایل ساخت کامپایل می شود، این باید کاملاً بدون سربار تنظیم شود و موجز بودن نماد را بدون هیچ جریمه عملکردی به ما بدهد.

همانطور که گفته شد توابع کمکی برای دستورالعمل های اسکالر تک، دوتایی و سه تایی و همچنین معادل های برداری وجود دارد. آنها همچنین به‌عنوان الگوهای مفیدی برای ایجاد کمک‌های خود برای دستورالعمل‌هایی که با قالب عمومی مناسب نیستند، خدمت می‌کنند.


ساخت اولیه

اگر دایرکتوری را به riscv_semantic_functions تغییر نداده اید، اکنون این کار را انجام دهید. سپس پروژه را به صورت زیر بسازید - این ساخت باید موفق شود.

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

هیچ فایلی تولید نشده است، بنابراین این واقعاً فقط یک اجرای خشک است تا مطمئن شوید همه چیز مرتب است.


سه دستور عملوند ALU را اضافه کنید

حالا بیایید توابع معنایی را برای برخی دستورالعمل‌های ALU 3 عملوندی عمومی اضافه کنیم. فایل rv32i_instructions.cc را باز کنید و مطمئن شوید که هر گونه تعاریف گم شده به فایل rv32i_instructions.h اضافه می شود.

دستورالعمل هایی که اضافه خواهیم کرد عبارتند از:

  • add - افزودن عدد صحیح 32 بیتی.
  • and - 32 بیتی بیتی و.
  • or - 32 بیتی بیتی یا.
  • sll - شیفت منطقی 32 بیتی به چپ.
  • sltu - مجموعه 32 بیتی بدون علامت کمتر از.
  • sra - شیفت حسابی 32 بیتی به راست.
  • srl - شیفت به راست منطقی 32 بیتی.
  • sub - تفریق عدد صحیح 32 بیتی.
  • xor - xor 32 بیتی بیتی.

اگر آموزش‌های قبلی را انجام داده‌اید، ممکن است به خاطر بیاورید که ما بین دستورالعمل‌های ثبت-ثبت و دستورالعمل‌های ثبت-فوری در رمزگشا تمایز قائل شدیم. وقتی صحبت از توابع معنایی می شود، دیگر نیازی به انجام آن نداریم. رابط های عملوند مقدار عملوند را از هر کدام که عملوند باشد، ثبت یا فوری، با تابع معنایی کاملاً آگنوستیک به آنچه عملوند منبع اصلی واقعاً است، می خوانند.

به جز sra ، همه دستورالعمل‌های بالا را می‌توان به عنوان عملکرد بر روی مقادیر بدون علامت 32 بیتی در نظر گرفت، بنابراین برای آنها می‌توانیم از تابع قالب BinaryOp که قبلاً به آن نگاه کردیم، تنها با آرگومان نوع قالب استفاده کنیم. بدنه های تابع را در rv32i_instructions.cc بر این اساس پر کنید. توجه داشته باشید که فقط 5 بیت پایین عملوند دوم به دستورالعمل های shift برای مقدار شیفت استفاده می شود. در غیر این صورت، تمام عملیات به شکل 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

برای sra از الگوی سه آرگومان BinaryOp استفاده خواهیم کرد. با نگاهی به الگو، آرگومان نوع اول، نوع نتیجه uint32_t است. دومی نوع عملوند منبع 0، در این مورد int32_t ، و آخرین نوع عملوند منبع 1، در این مورد uint32_t . که بدنه تابع معنایی sra را می سازد:

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

ادامه دهید و تغییرات را ایجاد کنید و بسازید. می توانید کار خود را با rv32i_instructions.cc بررسی کنید.


دو دستور عملوند ALU اضافه کنید

فقط دو دستور ALU 2 عملوندی وجود دارد: lui و auipc . اولی عملوند منبع از پیش تغییر داده شده را مستقیماً به مقصد کپی می کند. دومی قبل از نوشتن آن در مقصد، آدرس دستورالعمل را به فوری اضافه می کند. آدرس دستورالعمل از طریق متد address() شیء Instruction قابل دسترسی است.

از آنجایی که تنها یک عملوند منبع وجود دارد، نمی‌توانیم از BinaryOp استفاده کنیم، در عوض باید از UnaryOp استفاده کنیم. از آنجایی که می توانیم هر دو عملوند مبدا و مقصد را به عنوان uint32_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);
}

بدنه تابع معنایی برای lui تقریباً بی اهمیت است، فقط منبع را برگردانید. تابع معنایی auipc یک مشکل جزئی را معرفی می کند، زیرا شما باید به متد address() در نمونه Instruction دسترسی داشته باشید. پاسخ این است که instruction را به ضبط لامبدا اضافه کنید و آن را برای استفاده در بدنه تابع لامبدا در دسترس قرار دهید. به جای [](uint32_t a) { ... } مانند قبل، لامبدا باید نوشته شود [instruction](uint32_t a) { ... } . اکنون می توان instruction در بدنه لامبدا استفاده کرد.

ادامه دهید و تغییرات را ایجاد کنید و بسازید. می توانید کار خود را با rv32i_instructions.cc بررسی کنید.


دستورالعمل های تغییر جریان کنترل را اضافه کنید

دستورالعمل‌های تغییر جریان کنترلی که باید پیاده‌سازی کنید به دستورالعمل‌های شاخه شرطی (شاخه‌های کوتاه‌تر که در صورت درست بودن مقایسه انجام می‌شوند) و دستورالعمل‌های پرش و پیوند، که برای اجرای فراخوانی‌های تابع استفاده می‌شوند، تقسیم می‌شوند (-and-link) با صفر کردن رجیستر پیوند حذف می‌شود و آن‌ها را بدون عملیات می‌نویسد).

دستورالعمل های شاخه مشروط را اضافه کنید

هیچ تابع کمکی برای آموزش شاخه وجود ندارد، بنابراین دو گزینه وجود دارد. توابع معنایی را از ابتدا بنویسید یا یک تابع کمکی محلی بنویسید. از آنجایی که ما نیاز به اجرای 6 دستورالعمل شاخه داریم، دومی ارزش تلاش را دارد. قبل از اینکه این کار را انجام دهیم، اجازه دهید پیاده سازی یک تابع معنایی دستورالعمل شاخه را از ابتدا بررسی کنیم.

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

تنها چیزی که در دستورالعمل های شاخه متفاوت است، شرط شاخه، و انواع داده، امضا شده در مقابل 32 بیت بدون علامت، دو عملوند منبع است. این بدان معناست که ما باید یک پارامتر الگو برای عملوندهای منبع داشته باشیم. خود تابع helper باید نمونه Instruction و یک شیء قابل فراخوانی مانند std::function بگیرد که bool به عنوان پارامتر برمی گرداند. تابع کمکی به صورت زیر است:

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

اکنون می‌توانیم تابع معنایی bge (شاخه علامت‌دار بزرگتر یا مساوی) را به صورت زیر بنویسیم:

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

دستورالعمل شعب باقی مانده به شرح زیر است:

  • بق - شعبه مساوی.
  • Bgeu - شاخه بزرگتر یا مساوی (بدون علامت).
  • Blt - شعبه کمتر از (امضا).
  • Bltu - شاخه کمتر از (بدون امضا).
  • Bne - شاخه برابر نیست.

ادامه دهید و تغییرات را برای اجرای این توابع معنایی ایجاد کنید و دوباره بسازید. می توانید کار خود را با rv32i_instructions.cc بررسی کنید.

هیچ فایده ای برای نوشتن یک تابع کمکی برای دستورالعمل های پرش و پیوند وجود ندارد، بنابراین باید اینها را از ابتدا بنویسیم. بیایید با بررسی معنایی دستورالعمل آنها شروع کنیم.

دستور jal یک آفست از عملوند منبع 0 می گیرد و آن را به رایانه فعلی (آدرس دستورالعمل) اضافه می کند تا هدف پرش را محاسبه کند. هدف پرش به عملوند مقصد 0 نوشته می شود. آدرس برگشتی آدرس دستورالعمل بعدی است. می توان آن را با افزودن اندازه دستور فعلی به آدرس آن محاسبه کرد. آدرس برگشتی در عملوند مقصد 1 نوشته می شود. به یاد داشته باشید که نشانگر شی دستورالعمل را در ضبط لامبدا قرار دهید.

دستور jalr یک ثبات پایه را به عنوان عملوند مبدا 0 و یک افست را به عنوان عملوند منبع 1 می گیرد و آنها را با هم جمع می کند تا هدف پرش را محاسبه کند. در غیر این صورت با دستور jal یکسان است.

بر اساس این توصیفات از معناشناسی دستورالعمل، دو تابع معنایی را بنویسید و بسازید. می توانید کار خود را با rv32i_instructions.cc بررسی کنید.


دستورالعمل های ذخیره حافظه را اضافه کنید

سه دستورالعمل ذخیره وجود دارد که باید پیاده سازی کنیم: ذخیره بایت ( sb )، ذخیره نیم کلمه ( sh )، و ذخیره کلمه ( sw ). دستورالعمل‌های ذخیره‌سازی با دستورالعمل‌هایی که تاکنون پیاده‌سازی کرده‌ایم متفاوت است زیرا در حالت پردازنده محلی نمی‌نویسند. در عوض آنها در یک منبع سیستم - حافظه اصلی می نویسند. MPACT-Sim حافظه را به عنوان یک عملوند دستورالعمل در نظر نمی گیرد، بنابراین دسترسی به حافظه باید با استفاده از روش دیگری انجام شود.

پاسخ این است که روش‌های دسترسی به حافظه را به شی MPACT-Sim ArchState اضافه کنید، یا به‌طور صحیح‌تر، یک شیء حالت جدید RiscV ایجاد کنید که از ArchState مشتق می‌شود، جایی که می‌توان آن را اضافه کرد. شی ArchState منابع اصلی مانند ثبات ها و سایر اشیاء حالت را مدیریت می کند. همچنین خطوط تأخیر مورد استفاده برای بافر بافرهای داده عملوند مقصد را تا زمانی که بتوان آنها را به اشیاء ثبت برگرداند، مدیریت می کند. بیشتر دستورات را می‌توان بدون آگاهی از این کلاس پیاده‌سازی کرد، اما برخی مانند عملیات حافظه و سایر دستورالعمل‌های خاص سیستم نیاز به عملکردی دارند تا در این شیء حالت قرار گیرند.

بیایید نگاهی به تابع معنایی دستور fence که قبلاً در rv32i_instructions.cc به عنوان مثال پیاده سازی شده است بیاندازیم. دستورالعمل fence تا زمانی که برخی عملیات حافظه کامل نشده باشد، مشکل دستورالعمل را حفظ می کند. برای تضمین ترتیب حافظه بین دستورالعمل‌هایی که قبل از دستور اجرا می‌شوند و دستورالعمل‌هایی که بعد از آن اجرا می‌شوند، استفاده می‌شود.

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

بخش کلیدی عملکرد معنایی دستورالعمل fence دو خط آخر است. ابتدا شی state با استفاده از یک متد در کلاس Instruction واکشی می شود و به کلاس مشتق شده خاص RiscV downcast<> . سپس متد Fence از کلاس RiscVState برای انجام عملیات fence فراخوانی می شود.

دستورالعمل های فروشگاه نیز به همین ترتیب کار خواهند کرد. ابتدا آدرس موثر دسترسی به حافظه از عملوندهای منبع دستورالعمل پایه و افست محاسبه می شود، سپس مقدار ذخیره شده از عملوند منبع بعدی واکشی می شود. در مرحله بعد، شی state RiscV از طریق فراخوانی متد state() و static_cast<> بدست می آید و متد مناسب فراخوانی می شود.

روش StoreMemory شی RiscVState نسبتاً ساده است، اما چند مفهوم دارد که باید از آنها آگاه باشیم:

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

همانطور که می بینیم، این روش سه پارامتر را می گیرد، اشاره گر به خود دستور ذخیره، آدرس فروشگاه، و اشاره گر به نمونه DataBuffer که حاوی داده های ذخیره است. توجه داشته باشید، هیچ اندازه ای مورد نیاز نیست، نمونه DataBuffer خود حاوی یک متد size() است. با این حال، هیچ عملوند مقصد قابل دسترسی برای دستورالعمل وجود ندارد که بتوان از آن برای تخصیص یک نمونه DataBuffer با اندازه مناسب استفاده کرد. در عوض باید از یک کارخانه DataBuffer استفاده کنیم که از متد db_factory() در نمونه Instruction به دست می آید. کارخانه یک متد Allocate(int size) دارد که یک نمونه DataBuffer با اندازه مورد نیاز را برمی گرداند. در اینجا مثالی از نحوه استفاده از آن برای تخصیص یک نمونه DataBuffer برای ذخیره نیم کلمه آورده شده است (توجه داشته باشید که auto یک ویژگی C++ است که نوع را از سمت راست تخصیص استنتاج می کند):

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

هنگامی که نمونه DataBuffer را داشتیم، می توانیم طبق معمول روی آن بنویسیم:

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

سپس آن را به رابط حافظه ذخیره کنید:

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

ما هنوز کاملاً تمام نشده ایم. نمونه DataBuffer مرجع شمارش می شود. این معمولاً با روش Submit درک و مدیریت می شود تا بیشترین موارد استفاده را تا حد امکان ساده نگه دارد. با این حال، StoreMemory به این صورت نوشته نشده است. هنگامی که بر روی آن کار می کند، نمونه DataBuffer را IncRef می کند و پس از اتمام، DecRef انجام می دهد. با این حال، اگر تابع معنایی مرجع خود را DecRef نکند، هرگز بازیابی نخواهد شد. بنابراین، خط آخر باید این باشد:

  db->DecRef();

سه عملکرد ذخیره وجود دارد و تنها چیزی که متفاوت است اندازه دسترسی به حافظه است. این به نظر یک فرصت عالی برای یکی دیگر از تابع های کمکی قالب محلی است. تنها چیزی که در تابع store متفاوت است، نوع مقدار ذخیره است، بنابراین الگو باید آن را به عنوان آرگومان داشته باشد. به غیر از آن، فقط نمونه 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();
}

ادامه دهید و توابع معنایی فروشگاه را تمام کنید و بسازید. می توانید کار خود را با rv32i_instructions.cc بررسی کنید.


دستورالعمل های بارگیری حافظه را اضافه کنید

دستورالعمل های بارگذاری که باید اجرا شوند به شرح زیر است:

  • lb - بارگذاری بایت، علامت گسترش به یک کلمه.
  • lbu - بارگذاری بایت بدون علامت، صفر-بسط به یک کلمه.
  • lh - بارگذاری نیم کلمه، علامت-بسط در یک کلمه.
  • lhu - بارگذاری نیم کلمه بدون علامت، صفر-بسط در یک کلمه.
  • lw - بارگذاری کلمه.

دستورالعمل های بارگذاری پیچیده ترین دستورالعمل هایی هستند که در این آموزش باید مدل سازی کنیم. آنها شبیه دستورالعمل‌های ذخیره‌سازی هستند، از این نظر که نیاز به دسترسی به شی RiscVState دارند، اما پیچیدگی را اضافه می‌کنند زیرا هر دستورالعمل بارگذاری به دو تابع معنایی جداگانه تقسیم می‌شود. اولین مورد مشابه دستورالعمل ذخیره سازی است، زیرا آدرس موثر را محاسبه می کند و دسترسی به حافظه را آغاز می کند. دومی با تکمیل دسترسی به حافظه اجرا می‌شود و داده‌های حافظه را در عملوند مقصد ثبت می‌نویسد.

بیایید با نگاهی به اعلان متد LoadMemory در RiscVState شروع کنیم:

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

در مقایسه با روش StoreMemory ، LoadMemory دو پارامتر اضافی را می گیرد: یک اشاره گر به یک نمونه Instruction و یک اشاره گر به یک شی context شمارش شده مرجع. اولی دستورالعمل فرزند است که بازگشت رجیستر را پیاده سازی می کند (توضیح داده شده در آموزش رمزگشای ISA). با استفاده از متد child() در نمونه Instruction فعلی قابل دسترسی است. دومی نشانگر یک نمونه از کلاسی است که از ReferenceCount مشتق شده است که در این مورد یک نمونه DataBuffer را ذخیره می کند که حاوی داده های بارگذاری شده است. شیء context از طریق متد context() در شیء Instruction در دسترس است (اگرچه برای اکثر دستورالعمل‌ها این مقدار روی nullptr تنظیم می‌شود).

شی زمینه برای بارگذاری حافظه RiscV به صورت ساختار زیر تعریف می شود:

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

دستورالعمل های بارگذاری همه یکسان هستند به جز اندازه داده (بایت، نیم کلمه و کلمه) و اینکه آیا مقدار بارگذاری شده به صورت علامت توسعه یافته است یا خیر. دومی فقط در آموزش کودک نقش دارد. بیایید یک تابع کمکی قالب برای دستورالعمل های بار اصلی ایجاد کنیم. بسیار شبیه به دستور فروشگاه خواهد بود، با این تفاوت که برای دریافت مقدار به عملوند منبع دسترسی نخواهد داشت و یک شیء متنی ایجاد می کند.

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

همانطور که می بینید، تفاوت اصلی این است که نمونه DataBuffer اختصاص داده شده هم به عنوان پارامتر به فراخوانی LoadMemory داده می شود و هم در شی LoadContext ذخیره می شود.

توابع معنایی دستورالعمل کودک همگی بسیار مشابه هستند. ابتدا LoadContext با فراخوانی Instruction method context() بدست می آید و به LoadContext * به صورت static فرستاده می شود. دوم، مقدار (با توجه به نوع داده) از نمونه load-data DataBuffer خوانده می شود. سوم، یک نمونه DataBuffer جدید از عملوند مقصد تخصیص داده می شود. در نهایت، مقدار بارگذاری شده در نمونه DataBuffer جدید نوشته می‌شود و Submit 'ed. باز هم، یک تابع کمکی قالب ایده خوبی است:

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

ادامه دهید و این آخرین توابع کمکی و توابع معنایی را پیاده سازی کنید. به نوع داده‌ای که در الگو برای هر فراخوانی تابع کمکی استفاده می‌کنید، توجه کنید، و اینکه با اندازه و ماهیت امضا شده/بدون علامت دستورالعمل بارگذاری مطابقت دارد.

می توانید کار خود را با rv32i_instructions.cc بررسی کنید.


شبیه ساز نهایی را بسازید و اجرا کنید

اکنون که تمام کلمات سخت را انجام داده ایم، می توانیم شبیه ساز نهایی را بسازیم. کتابخانه‌های سطح بالای C++ که تمام کارهای این آموزش‌ها را به هم پیوند می‌دهند در other/ قرار دارند. لازم نیست خیلی به آن کد نگاه کنید. ما در یک آموزش پیشرفته آینده از آن موضوع بازدید خواهیم کرد.

دایرکتوری کاری خود را به other/ تغییر دهید و بسازید. باید بدون خطا ساخته شود.

$ cd ../other
$ bazel build :rv32i_sim

در آن دایرکتوری یک برنامه ساده "hello world" در فایل hello_rv32i.elf وجود دارد. برای اجرای شبیه ساز بر روی این فایل و مشاهده نتایج:

$ bazel run :rv32i_sim -- 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.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
$

شبیه ساز همچنین می تواند در حالت تعاملی با استفاده از دستور bazel run :rv32i_sim -- -i other/hello_rv32i.elf اجرا شود. این یک پوسته فرمان ساده را به ارمغان می آورد. برای مشاهده دستورات موجود help را در اعلان تایپ کنید.

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

با این کار این آموزش به پایان می رسد. امیدواریم مفید بوده باشد.