บทแนะนำฟังก์ชันอรรถศาสตร์ในการสอน

วัตถุประสงค์ของบทแนะนำนี้ได้แก่

  • ดูวิธีใช้ฟังก์ชันเชิงความหมายเพื่อใช้ความหมายของคำสั่ง
  • เรียนรู้ว่าฟังก์ชันความหมายเกี่ยวข้องกับคำอธิบายตัวถอดรหัส ISA อย่างไร
  • เขียนฟังก์ชันอรรถศาสตร์คำสั่งสำหรับคำสั่ง RiscV RV32I
  • ทดสอบเครื่องจำลองขั้นสุดท้ายโดยเรียกใช้ไฟล์ "Hello World" ขนาดเล็กที่เรียกใช้งานได้

ภาพรวมของฟังก์ชันความหมาย

ฟังก์ชันเชิงความหมายใน MPACT-Sim คือฟังก์ชันที่ใช้การดำเนินการของคำสั่งเพื่อให้เห็นผลข้างเคียงของคำสั่งในสถานะที่จำลอง เช่นเดียวกับที่จะเห็นผลข้างเคียงของคำสั่งเมื่อดำเนินการในฮาร์ดแวร์ การแสดงภายในของเครื่องมือจำลองของคำสั่งที่ถอดรหัสแต่ละรายการจะมี Callable ที่ใช้เรียกฟังก์ชันความหมายสำหรับคำสั่งนั้น

ฟังก์ชันเชิงความหมายมีลายเซ็น 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();
}

มาดูรายละเอียดของฟังก์ชันนี้กัน 2 บรรทัดแรกของเนื้อหาฟังก์ชันจะอ่านจากโอเปอเรนด์ต้นทาง 0 และ 1 การเรียกใช้ AsUint32(0) จะตีความข้อมูลพื้นฐานเป็นอาร์เรย์ uint32_t และดึงข้อมูลองค์ประกอบที่ 0 และจะเป็นเช่นนี้เสมอ ไม่ว่ารีจิสเตอร์หรือค่าที่สำคัญจะมีค่าในอาร์เรย์หรือไม่ คุณดูขนาด (จำนวนองค์ประกอบ) ของอ็อพเจ็กต์ต้นทางได้จากเมธอดของอ็อพเจ็กต์ต้นทาง shape() ซึ่งจะแสดงผลเวกเตอร์ที่มีจำนวนองค์ประกอบในแต่ละมิติข้อมูล เมธอดดังกล่าวจะแสดงผล {1} สำหรับสเกลาร์ {16} สำหรับเวกเตอร์องค์ประกอบ 16 และ {4, 4} สำหรับอาร์เรย์ 4x4

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

สุดท้าย คำสั่งสุดท้ายจะส่งบัฟเฟอร์ข้อมูลไปยังเครื่องจำลองเพื่อใช้เป็นค่าใหม่ของสถานะของเครื่องเป้าหมาย (ในกรณีนี้คือรีจิสเตอร์) หลังจากเวลาในการตอบสนองของคำสั่งซึ่งตั้งค่าไว้เมื่อถอดรหัสคำสั่งและแสดงเวกเตอร์ตัวถูกดำเนินการปลายทางแล้ว

แม้ว่าฟังก์ชันนี้จะสั้นพอสมควร แต่ก็ยังมีโค้ดที่ซ้ำกันอยู่บ้างเมื่อใช้คำสั่งทีละรายการ นอกจากนี้ ยังอาจทำให้ความหมายจริงของคำสั่งนั้นคลุมเครือ เพื่อให้การเขียนฟังก์ชันความหมายสำหรับคำสั่งส่วนใหญ่ง่ายขึ้น มีฟังก์ชัน helper ที่มีเทมเพลตหลายรายการที่กำหนดไว้ใน instruction_helpers.h ผู้ช่วยเหล่านี้จะซ่อนโค้ดต้นแบบสำหรับวิธีการที่มีตัวถูกดำเนินการต้นทาง 1 2 หรือ 3 รายการและตัวถูกดำเนินการปลายทางเดียว ลองมาดูฟังก์ชันตัวช่วยเหลือ ตัวดำเนินการ 2 ตัวต่อไปนี้

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

ดังที่คุณเห็น การใช้งานมี 3 แบบ โดยขึ้นอยู่กับว่าประเภทโอเปอเรเตอร์ของแหล่งที่มาเหมือนกับปลายทางหรือไม่ ปลายทางแตกต่างจากแหล่งที่มาหรือไม่ หรือทั้ง 2 อย่างแตกต่างกันหรือไม่ ฟังก์ชันแต่ละเวอร์ชันใช้พอยน์เตอร์ไปยังอินสแตนซ์คำสั่ง รวมถึงฟังก์ชันที่เรียกใช้ได้ (ซึ่งรวมถึงฟังก์ชัน Lambda) ซึ่งหมายความว่าตอนนี้เราจะเขียนฟังก์ชันความหมาย 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 ที่มีออปเรอเรนต์ 3 รายการ

ตอนนี้มาเพิ่มฟังก์ชันเชิงความหมายสําหรับคำสั่ง ALU แบบ 3 ออบเจ็กต์ทั่วไปกัน เปิดไฟล์ rv32i_instructions.cc และตรวจสอบว่ามีการใส่คำจำกัดความที่ขาดหายไปลงในไฟล์ rv32i_instructions.h ขณะที่เราดำเนินการ

วิธีการที่เราจะเพิ่มมีดังต่อไปนี้

  • add - การเพิ่มจำนวนเต็ม 32 บิต
  • and - บิตไวส์ 32 บิตและ
  • or - การดำเนินการ OR แบบบิต 32 บิต
  • sll - เลื่อนไปทางซ้ายแบบตรรกะ 32 บิต
  • sltu - 32 บิตแบบไม่ลงนามที่มีค่าน้อยกว่า
  • sra - เลื่อนไปทางขวาแบบเลขคณิต 32 บิต
  • srl - เลื่อนไปทางขวาแบบตรรกะ 32 บิต
  • sub - ลบจำนวนเต็ม 32 บิต
  • xor - การดำเนินการ XOR แบบบิต 32 บิต

หากคุณเคยทำบทแนะนำก่อนหน้านี้ คุณอาจจำได้ว่าเราได้แยกความแตกต่างระหว่างคำสั่งแบบรีจิสเตอร์กับรีจิสเตอร์กับคำสั่งแบบทันทีในโปรแกรมถอดรหัส เราไม่จำเป็นต้องทำแบบนั้นแล้วในเรื่องของฟังก์ชันทางอรรถศาสตร์ อินเทอร์เฟซตัวถูกดำเนินการจะอ่านค่าตัวถูกดำเนินการ ขึ้นอยู่กับว่าตัวถูกดำเนินการใดเป็นตัวถูกดำเนินการ ลงทะเบียน หรือทันที โดยฟังก์ชันเชิงความหมายไม่จำเป็นต้องเข้าใจตรงกันเลยว่าตัวถูกดำเนินการต้นฉบับคืออะไร

ยกเว้น sra วิธีการทั้งหมดข้างต้นจะถือเป็นการดำเนินการสำหรับค่าที่ไม่มีเครื่องหมาย 32 บิต ดังนั้นในกรณีเหล่านี้ เราจึงใช้ฟังก์ชันเทมเพลต BinaryOp ที่เราดูก่อนหน้านี้ด้วยอาร์กิวเมนต์ประเภทเทมเพลตเดียวเท่านั้น เติมเนื้อหาของฟังก์ชันใน rv32i_instructions.cc ให้สอดคล้องกัน โปรดทราบว่าระบบจะใช้เฉพาะ 5 บิตล่างของออพเพอร์แรนด์ที่ 2 ของคำสั่งเลื่อนเพื่อเลื่อนจำนวน ไม่เช่นนั้น การดำเนินการทั้งหมดจะเป็นรูปแบบ 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 ที่มีอาร์กิวเมนต์ 3 รายการ เมื่อดูที่เทมเพลต อาร์กิวเมนต์ประเภทแรกจะเป็นประเภทผลการค้นหา uint32_t ประเภทที่ 2 คือประเภทของตัวถูกดำเนินการต้นทางที่เป็น 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 ที่มี 2 ออบเจ็กต์

คำสั่ง ALU ที่มี 2 ออบเจ็กต์มีเพียง 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 ลงในการจับข้อมูล Lambda เพื่อให้พร้อมใช้งานในส่วนเนื้อหาของฟังก์ชัน Lambda แทนที่จะเป็น [](uint32_t a) { ... } ตามเดิม ควรเขียน lambda เป็น [instruction](uint32_t a) { ... } ตอนนี้คุณใช้ instruction ในส่วนเนื้อหาของ Lambda ได้แล้ว

ทำการเปลี่ยนแปลงและสร้างได้เลย คุณสามารถตรวจสอบงานของคุณเทียบกับ rv32i_instructions.cc


เพิ่มคำแนะนำในการเปลี่ยนขั้นตอนการควบคุม

คำสั่งสำหรับการเปลี่ยนแปลงขั้นตอนการควบคุมที่คุณต้องนำไปใช้จะแบ่งออกเป็นคำสั่ง Branch แบบมีเงื่อนไข (Branch ที่สั้นกว่าซึ่งจะดำเนินการหากการเปรียบเทียบเป็นจริง) และวิธีการข้ามลิงก์ ซึ่งใช้เพื่อเรียกใช้ฟังก์ชัน (และจะลบ -and-link ออกโดยการตั้งค่าการลงทะเบียนลิงก์เป็น 0 ทำให้การเขียนไม่ดำเนินการ)

เพิ่มวิธีการสาขาแบบมีเงื่อนไข

ไม่มีฟังก์ชันตัวช่วยสำหรับคำสั่ง Branch จึงมี 2 ตัวเลือก เขียนฟังก์ชันเชิงความหมายตั้งแต่ต้น หรือเขียนฟังก์ชันตัวช่วยในเครื่อง เนื่องจากเราต้องใช้คำสั่งสำหรับ Branch จำนวน 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();
  }
}

สิ่งเดียวที่แตกต่างกันไปตามวิธีการของ Branch คือเงื่อนไข Branch และประเภทข้อมูลที่มีการลงนามกับ Int 32 บิตที่ไม่มีการรับรองของตัวถูกดำเนินการแหล่งที่มา 2 ตัว ซึ่งหมายความว่าเราต้องมีพารามิเตอร์เทมเพลตสำหรับโอเปอเรนด์ต้นทาง ฟังก์ชันตัวช่วยต้องใช้อินสแตนซ์ 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; });
}

คำสั่งสาขาที่เหลือมีดังนี้

  • Beq - เงื่อนไขที่เท่ากัน
  • Bgeu - สาขาที่มากกว่าหรือเท่ากับ (ไม่มีเครื่องหมาย)
  • Blt - สาขาที่น้อยกว่า (ลงชื่อ)
  • Bltu - เงื่อนไขที่น้อยกว่า (แบบไม่ลงนาม)
  • Bne - สาขาไม่เท่ากัน

ทำการเปลี่ยนแปลงเพื่อใช้ฟังก์ชันความหมายเหล่านี้ แล้วสร้างใหม่ คุณสามารถตรวจสอบงานของคุณเทียบกับ rv32i_instructions.cc

การเขียนฟังก์ชันตัวช่วยสำหรับคำสั่งการข้ามและลิงก์นั้นไม่มีประโยชน์ เราจึงต้องเขียนฟังก์ชันเหล่านี้ขึ้นมาใหม่ เรามาเริ่มกันที่ ความหมายของการสอน

คำสั่ง jal จะนําออฟเซตจากออปเรอเรนต์ต้นทาง 0 และเพิ่มลงใน PC (ที่อยู่คำสั่ง) ปัจจุบันเพื่อคํานวณเป้าหมายการกระโดด ระบบจะเขียนเป้าหมายการกระโดดไปยังออบเจ็กต์ปลายทาง 0 ที่อยู่สำหรับคืนสินค้าคือที่อยู่ของคำสั่งตามลำดับถัดไป ซึ่งสามารถคำนวณได้โดยเพิ่มขนาดของ การสอนปัจจุบันลงในที่อยู่ ที่อยู่สำหรับคืนสินค้าจะถูกเขียนไปยัง ตัวถูกดำเนินการปลายทาง 1 อย่าลืมใส่ตัวชี้ออบเจ็กต์คำสั่งใน การจับภาพ lambda

คำสั่ง jalr จะใช้รีจิสเตอร์ฐานเป็นออปเรอเรนด์ต้นทาง 0 และออฟเซตเป็นออปเรอเรนด์ต้นทาง 1 แล้วบวกเข้าด้วยกันเพื่อคํานวณเป้าหมายการกระโดด มิเช่นนั้น คำสั่งจะเหมือนกับคำสั่ง jal

เขียนฟังก์ชันเชิงความหมาย 2 รายการและสร้างตามคำอธิบายเหล่านี้เกี่ยวกับความหมายของคำสั่ง คุณสามารถตรวจสอบงานของคุณเทียบกับ rv32i_instructions.cc


วิธีการเพิ่มการจัดเก็บหน่วยความจำ

วิธีการสำหรับร้านค้าที่เราต้องทำมี 3 อย่าง ได้แก่ ไบต์ร้านค้า (sb) คำใน Store (sh) และคำใน Store (sw) โดยวิธีการของร้านค้าจะแตกต่างจากวิธีการที่เราใช้อยู่ คือไม่ได้เขียนเป็นสถานะของผู้ประมวลผลข้อมูลภายใน แต่จะเขียนไปยังทรัพยากรของระบบ ซึ่งก็คือหน่วยความจำหลัก MPACT-Sim ไม่ถือว่าหน่วยความจำเป็นโอเปอแรนด์คำสั่ง ดังนั้นการเข้าถึงหน่วยความจำจึงต้องใช้วิธีการอื่น

คำตอบคือให้เพิ่มวิธีการเข้าถึงหน่วยความจำลงในออบเจ็กต์ ArchState ของ MPACT-Sim หรือให้ถูกกว่านั้น ให้สร้างออบเจ็กต์สถานะ 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 คือ 2 บรรทัดสุดท้าย ก่อนอื่น ระบบจะดึงข้อมูลออบเจ็กต์สถานะโดยใช้เมธอดในInstruction คลาสและ downcast<> ไปยังคลาสที่สืบทอดมาเฉพาะของ RiscV จากนั้นระบบจะเรียกใช้เมธอด Fence ของคลาส RiscVState เพื่อดำเนินการรั้ว

วิธีการสำหรับร้านค้าจะทำงานคล้ายกัน ก่อนอื่น ที่อยู่ที่มีประสิทธิภาพของการเข้าถึงหน่วยความจำจะคำนวณจากตัวถูกดำเนินการของแหล่งคำสั่งพื้นฐานและออฟเซ็ต จากนั้นค่าที่เก็บจะถูกดึงจากตัวถูกดำเนินการของแหล่งที่มาถัดไป ถัดไป ระบบจะรับออบเจ็กต์สถานะ RiscV ผ่านการเรียกเมธอด state() และ static_cast<> โดยจะเรียกใช้เมธอดที่เหมาะสม

เมธอด RiscVState ของออบเจ็กต์ StoreMemory ค่อนข้างเรียบง่าย แต่มีนัยบางอย่างที่ทำให้เราต้องระวัง ดังนี้

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

จะเห็นได้ว่าเมธอดจะใช้พารามิเตอร์ 3 รายการ ได้แก่ ตัวชี้ไปยังวิธีการของสโตร์ ที่อยู่ของร้านค้า และตัวชี้ไปยังอินสแตนซ์ DataBuffer ที่มีข้อมูลร้านค้า โปรดทราบว่าไม่จำเป็นต้องระบุขนาด อินสแตนซ์ DataBuffer เองมีเมธอด size() อย่างไรก็ตาม จะไม่มีตัวถูกดำเนินการปลายทางที่เข้าถึงได้สำหรับคำสั่ง ซึ่งนำไปใช้จัดสรรอินสแตนซ์ DataBuffer ของขนาดที่เหมาะสมได้ แต่เราต้องใช้DataBuffer factory ที่ได้มาจากเมธอด 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 จะเข้าใจและจัดการกับกรณีนี้ เพื่อให้ Use Case ที่พบบ่อยที่สุดมีความซับซ้อนน้อยที่สุด แต่ StoreMemory ไม่ได้เขียนแบบนั้น ระบบจะIncRefอินสแตนซ์ DataBuffer ขณะดำเนินการในอินสแตนซ์นั้น แล้วDecRefเมื่อดำเนินการเสร็จสิ้น อย่างไรก็ตาม หากฟังก์ชันความหมายไม่ได้ DecRef การอ้างอิงของตัวเอง ก็จะไม่มีการอ้างสิทธิ์ซ้ำ ดังนั้น บรรทัดสุดท้ายจึงต้องเป็น

  db->DecRef();

ฟังก์ชันร้านค้ามีอยู่ 3 ฟังก์ชัน และสิ่งเดียวที่แตกต่างกันคือขนาดของการเข้าถึงหน่วยความจำ ดูเหมือนว่านี่จะเป็นโอกาสที่ดีสำหรับ ฟังก์ชันตัวช่วยเทมเพลตในพื้นที่ สิ่งเดียวที่แตกต่างในฟังก์ชันร้านค้าคือประเภทของค่าร้านค้า ดังนั้นเทมเพลตจึงต้องมีค่านั้นเป็นอาร์กิวเมนต์ นอกจากนี้ คุณต้องส่งเฉพาะอินสแตนซ์ 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();
}

ไปทำฟังก์ชันอรรถศาสตร์ของ Store ให้เสร็จแล้วสร้างให้เสร็จเลย คุณตรวจสอบงานได้โดยเทียบกับ rv32i_instructions.cc


เพิ่มวิธีการโหลดหน่วยความจำ

คำแนะนำการโหลดที่จำเป็นต้องดำเนินการมีดังต่อไปนี้

  • lb - โหลดไบต์ แล้วขยายลงในคำ
  • lbu - โหลดไบต์แบบไม่ลงนาม ขยายเป็นคำแบบ 0
  • lh - โหลดครึ่งเวิร์ด ขยายขนาดลงท้ายด้วยเครื่องหมายเป็นเวิร์ด
  • lhu - โหลดคำที่ไม่มีเครื่องหมายครึ่งคำ ขยายเป็นศูนย์ในคำ
  • lw - โหลดคำ

คำสั่งโหลดเป็นคำสั่งที่ซับซ้อนที่สุดที่เราต้องทำโมเดลในบทแนะนำนี้ คำสั่งเหล่านี้คล้ายกับคำสั่งจัดเก็บตรงที่จำเป็นต้องเข้าถึงออบเจ็กต์ RiscVState แต่เพิ่มความซับซ้อนตรงที่คำสั่งโหลดแต่ละรายการจะแบ่งออกเป็นฟังก์ชันเชิงความหมาย 2 รายการแยกกัน คำสั่งแรกคล้ายกับคำสั่ง Store ตรงที่คำนวณที่อยู่ที่มีผลและเริ่มการเข้าถึงหน่วยความจำ รายการที่ 2 จะทำงานเมื่อเข้าถึงหน่วยความจำเสร็จสมบูรณ์ และเขียนข้อมูลหน่วยความจำไปยังตัวถูกดำเนินการปลายทางการลงทะเบียน

มาเริ่มกันที่การประกาศเมธอด LoadMemory ใน RiscVState

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

เมื่อเทียบกับเมธอด StoreMemory นั้น LoadMemory จะใช้พารามิเตอร์เพิ่มเติมอีก 2 รายการ ได้แก่ ตัวชี้ไปยังอินสแตนซ์ Instruction และตัวชี้ไปยังการอ้างอิงที่นับออบเจ็กต์ context วิธีแรกคือคำสั่งสำหรับย่อยที่ใช้การเขียนบันทึกการจดทะเบียน (อธิบายไว้ในบทแนะนำเกี่ยวกับตัวถอดรหัส ISA) มีการเข้าถึงโดยใช้เมธอด child() ในอินสแตนซ์ Instruction ปัจจุบัน ส่วนรายการหลังคือพอยน์เตอร์ไปยังอินสแตนซ์ของคลาสที่มาจาก ReferenceCount ซึ่งในกรณีนี้จัดเก็บอินสแตนซ์ DataBuffer ที่จะมีข้อมูลที่โหลด ออบเจ็กต์บริบทจะพร้อมใช้งานผ่านเมธอด 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;
};

คำสั่งโหลดทั้งหมดเหมือนกัน ยกเว้นขนาดข้อมูล (ไบต์ ครึ่งเวิร์ด และเวิร์ด) และค่าที่โหลดจะขยายสัญญาณหรือไม่ ส่วนคำสั่งหลังจะส่งผลต่อคำสั่ง child เท่านั้น มาสร้างฟังก์ชันตัวช่วยที่ใช้เทมเพลตสำหรับคำสั่งการโหลดหลักกัน คำสั่งนี้จะคล้ายกับวิธีการของ Store มาก เพียงแต่จะไม่เข้าถึงตัวถูกดำเนินการต้นทางเพื่อรับค่า และจะสร้างออบเจ็กต์บริบท

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 context() และแคสต์แบบคงที่เป็น LoadContext * ประการที่ 2 ระบบจะอ่านค่า (ตามประเภทข้อมูล) จากอินสแตนซ์ load-data DataBuffer ประการที่สาม ระบบจะจัดสรรอินสแตนซ์ DataBuffer ใหม่จากออบเจ็กต์ปลายทาง สุดท้าย ระบบจะเขียนค่าที่โหลดลงในอินสแตนซ์ DataBuffer ใหม่และ Submit อีกครั้ง การใช้ฟังก์ชันตัวช่วยที่ใช้เทมเพลตเป็นความคิดที่ดี

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 ซึ่งจะแสดง Command Shell แบบง่าย พิมพ์ 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] >

บทแนะนํานี้จบแล้ว เราหวังว่าข้อมูลนี้จะเป็นประโยชน์