इस ट्यूटोरियल का मकसद ये है:
- जानें कि निर्देश सिमैंटिक लागू करने के लिए, सिमैंटिक फ़ंक्शन का इस्तेमाल कैसे किया जाता है.
- जानें कि सेमैनटिक फ़ंक्शन, आईएसए डिकोडर के ब्यौरे से कैसे जुड़े हैं.
- RiscV RV32I निर्देशों के लिए निर्देश सिमैंटिक फ़ंक्शन लिखें.
- छोटा "नमस्ते दुनिया" एक्ज़ीक्यूटेबल चलाकर, फ़ाइनल सिम्युलेटर की जांच करें.
सिमेंटिक फ़ंक्शन के बारे में खास जानकारी
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 ऑपरेंड निर्देशों जैसे, 32-बिट add
वाले निर्देश के लिए सिमैंटिक फ़ंक्शन लिखने का बुनियादी तरीका यहां बताया गया है:
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}
, और 4x4 कलेक्शन के लिए {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);
आखिर में, आखिरी स्टेटमेंट, डेटा बफ़र को सिम्युलेटर को सबमिट करता है, ताकि निर्देश को डिकोड करने और डेस्टिनेशन ऑपरेंड वेक्टर को पॉप्युलेट करने के बाद, टारगेट मशीन स्टेटस (इस मामले में रजिस्टर) की नई वैल्यू के तौर पर इस्तेमाल किया जा सके.
यह फ़ंक्शन काफ़ी छोटा है, लेकिन इसमें कुछ बोइलरप्लेट कोड है. यह कोड, एक के बाद एक निर्देश लागू करते समय बार-बार दिखता है. इसके अलावा, इससे निर्देश के असली सेमेटिक्स को छिपाया जा सकता है. ज़्यादातर निर्देशों के सिमैंटिक फ़ंक्शन को लिखना और भी आसान बनाना हो, इसके लिए instruction_helpers.h में कई टेंप्लेट वाले helper फ़ंक्शन दिए गए हैं. ये हेल्पर एक, दो या तीन सोर्स ऑपरेंड, और एक डेस्टिनेशन ऑपरेंड के साथ निर्देशों के बॉयलरप्लेट कोड छिपा देते हैं. आइए, दो ऑपरेंड वाले हेल्पर फ़ंक्शन के बारे में जानते हैं:
// 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 में देखी जा सकती हैं.
जैसा कि आप देख सकते हैं, तीन तरह के लागू करने के तरीके हैं. यह इस बात पर निर्भर करता है कि सोर्स ऑपरेंड टाइप, डेस्टिनेशन के जैसे हैं या नहीं, डेस्टिनेशन, सोर्स से अलग है या नहीं या ये दोनों अलग-अलग हैं. फ़ंक्शन का हर वर्शन, निर्देश इंस्टेंस के साथ-साथ कॉल किए जा सकने वाले फ़ंक्शन (जिसमें 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>...
इस दौरान कोई फ़ाइल जनरेट नहीं होती. इसलिए, यह सिर्फ़ एक ड्राई रन है, ताकि यह पक्का किया जा सके कि सब कुछ ठीक है.
तीन ऑपरेंड वाले एएलयू निर्देश जोड़ना
अब आइए, कुछ सामान्य, तीन ऑपरेंड वाले एएलयू निर्देशों के लिए, सेमेटिक फ़ंक्शन जोड़ते हैं. फ़ाइल rv32i_instructions.cc
खोलें और पक्का करें कि फ़ाइल rv32i_instructions.h
में, ज़रूरी परिभाषाएं जोड़ दी गई हों.
हम ये निर्देश जोड़ेंगे:
add
- 32-बिट पूर्णांक जोड़ना.and
- 32-बिट बिट के अनुसार और.or
- 32-बिट बिटवाइज़ या.sll
- 32-बिट लॉजिकल शिफ़्ट बाईं ओर.sltu
- 32-बिट बिना हस्ताक्षर वाला, कम से कम सेट करें.sra
- 32-बिट अरिथमेटिक राइट शिफ़्ट.srl
- 32-बिट लॉजिकल राइट शिफ़्ट.sub
- 32-बिट इंटिजर घटाना.xor
- 32-बिट बिटवाइज़ एक्सओआर.
अगर आपने पिछले ट्यूटोरियल देखे हैं, तो आपको याद होगा कि हमने डिकोडर में रजिस्टर-रजिस्टर निर्देशों और रजिस्टर-इमीडिएट निर्देशों के बीच अंतर किया था. सेमेंटिक फ़ंक्शन के लिए, अब हमें ऐसा करने की ज़रूरत नहीं है. ऑपरेंड इंटरफ़ेस किसी भी ऑपरेंड के मान को रजिस्टर करेगा या तुरंत पढ़ेंगे. इसका मतलब है कि सिमैंटिक फ़ंक्शन इस बात पर पूरी तरह से निर्भर है कि काम का सोर्स ऑपरेंड असल में क्या है.
sra
को छोड़कर, ऊपर दिए गए सभी निर्देशों को 32-बिट की बिना साइन वाली वैल्यू पर काम करने वाले निर्देशों के तौर पर माना जा सकता है. इसलिए, इनके लिए हम BinaryOp
टेंप्लेट फ़ंक्शन का इस्तेमाल कर सकते हैं. हमने इस फ़ंक्शन को पहले ही सिर्फ़ एक टेंप्लेट टाइप आर्ग्युमेंट के साथ देखा है. इसके हिसाब से, फ़ंक्शन के मुख्य हिस्सों को rv32i_instructions.cc
में भरें. ध्यान दें कि 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
है और इस मामले में uint32_t
, सोर्स ऑपरेंड 1 का टाइप है. इससे 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 निर्देश होते हैं: lui
और auipc
. पहला निर्देश, पहले से शिफ़्ट किए गए सोर्स ऑपरेंड को सीधे डेस्टिनेशन में कॉपी करता है. बाद वाला, निर्देश के पते को डेस्टिनेशन में लिखने से पहले, उसे तुरंत में जोड़ता है. निर्देश के पते को Instruction
ऑब्जेक्ट के address()
तरीके से ऐक्सेस किया जा सकता है.
सिर्फ़ एक सोर्स ऑपरेंड होने की वजह से, हम 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
के सिमैंटिक फ़ंक्शन में एक मामूली समस्या आती है, क्योंकि आपको Instruction
इंस्टेंस में address()
तरीके को ऐक्सेस करने की ज़रूरत होती है. इसका जवाब है कि लैम्ब्डा कैप्चर में instruction
जोड़ें, ताकि इसे लैम्ब्डा फ़ंक्शन बॉडी में इस्तेमाल किया जा सके. पहले की तरह [](uint32_t a) { ...
}
के बजाय, लैम्ब्डा को [instruction](uint32_t a) { ... }
लिखा जाना चाहिए.
अब instruction
का इस्तेमाल, Lambda बॉडी में किया जा सकता है.
आगे बढ़ें और बदलाव करें और बिल्ड बनाएं. rv32i_instructions.cc के मुताबिक अपने काम की जांच की जा सकती है.
कंट्रोल फ़्लो में बदलाव करने के निर्देश जोड़ना
कंट्रोल फ़्लो में बदलाव करने के निर्देश, जो आपको लागू करने हैं उन्हें कंडिशनल ब्रांच के निर्देशों (अगर तुलना सही है, तो छोटी ब्रांच वे दिखाई जाती हैं) और जंप-ऐंड-लिंक निर्देशों में बांटा गया है, जिनका इस्तेमाल फ़ंक्शन कॉल को लागू करने के लिए किया जाता है (लिंक रजिस्टर को शून्य पर सेट करने से -और-लिंक को हटा दिया जाता है, जिससे वे कोई कार्रवाई नहीं करते).
शर्त के हिसाब से ब्रैंच के निर्देश जोड़ना
ब्रांच में निर्देश देने के लिए कोई हेल्पर फ़ंक्शन नहीं है, इसलिए इसके दो विकल्प हैं. सिमैंटिक फ़ंक्शन को शुरू से लिखें या लोकल हेल्पर फ़ंक्शन लिखें. हमें छह ब्रैंच निर्देश लागू करने हैं, इसलिए दूसरा विकल्प बेहतर है. ऐसा करने से पहले, आइए शुरुआत से ही शाखा निर्देश के सेमेटिक फ़ंक्शन को लागू करने के बारे में जानें.
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 बिट इंट का डेटा टाइप अलग-अलग होते हैं. इसका मतलब है कि सोर्स ऑपरेंड के लिए हमारे पास एक टेंप्लेट पैरामीटर
होना चाहिए. हेल्पर फ़ंक्शन में 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 - branch not equal.
इन सेमेंटिक फ़ंक्शन को लागू करने के लिए, बदलाव करें और फिर से बनाएं. rv32i_instructions.cc के हिसाब से, अपने काम की जांच की जा सकती है.
जंप-ऐंड-लिंक निर्देश जोड़ना
सीधे जाने और लिंक करने से जुड़े निर्देशों के लिए हेल्पर फ़ंक्शन लिखने का कोई मतलब नहीं है, इसलिए हमें इन्हें शुरू से लिखना होगा. आइए, सबसे पहले उनके निर्देशों के सेमेटिक्स के बारे में जानें.
jal
निर्देश, सोर्स ऑपरेंड 0 से ऑफ़सेट लेता है और उसे जंप टारगेट का हिसाब लगाने के लिए, मौजूदा पीसी (निर्देश का पता) में जोड़ता है. जंप टारगेट को डेस्टिनेशन ऑपरेंड 0 में लिखा जाता है. सामान लौटाने का पता, क्रम से अगले निर्देश का पता होता है. इसका हिसाब लगाने के लिए, मौजूदा निर्देश का
साइज़ उसके पते में जोड़ा जा सकता है. सामान लौटाने का पता, डेस्टिनेशन ऑपरेंड 1 में लिखा जाता है. Lambda कैप्चर में,
निर्देश ऑब्जेक्ट पॉइंटर को शामिल करना न भूलें.
jalr
निर्देश, बेस रजिस्टर को सोर्स ऑपरेंड 0 और ऑफ़सेट को सोर्स ऑपरेंड 1 के तौर पर लेता है. साथ ही, जंप टारगेट को कैलकुलेट करने के लिए उन्हें एक साथ जोड़ता है.
ऐसा न होने पर, यह jal
निर्देश के जैसा होगा.
निर्देश सिमेंटिक्स के इन ब्यौरों के आधार पर, इन दो सिमेंटिक फ़ंक्शन लिखें और बिल्ड बनाएं. rv32i_instructions.cc के हिसाब से, अपने काम की जांच की जा सकती है.
मेमोरी स्टोर के लिए निर्देश जोड़ें
स्टोर के लिए हमें तीन निर्देश लागू करने होंगे: स्टोर बाइट
(sb
), स्टोर हाफ़वर्ड (sh
), और स्टोर वर्ड (sw
). स्टोर के निर्देश हमारे अब तक लागू किए गए निर्देशों से अलग हैं, क्योंकि ये स्थानीय प्रोसेसर स्टेट के लिए नहीं लिखे गए हैं. इसके बजाय, वे सिस्टम रिसॉर्स - मुख्य मेमोरी
पर लिखते हैं. MPACT-Sim, मेमोरी को निर्देश ऑपरेंड के तौर पर इस्तेमाल नहीं करता है.
इसलिए, मेमोरी के ऐक्सेस को किसी दूसरे तरीके से किया जाना चाहिए.
इसका जवाब है कि MPACT-Sim ArchState
ऑब्जेक्ट में, मेमोरी ऐक्सेस करने के तरीके जोड़ें या ArchState
से लिया गया नया RiscV स्टेटस ऑब्जेक्ट बनाएं, जहां इसे जोड़ा जा सकता है. 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
निर्देश के सिमैंटिक फ़ंक्शन का मुख्य हिस्सा, आखिरी दो लाइनें हैं. सबसे पहले, स्टेट ऑब्जेक्ट को Instruction
क्लास में किसी तरीके का इस्तेमाल करके और RiscV पर आधारित खास डिराइव्ड क्लास में downcast<>
को फ़ेच किया जाता है. इसके बाद, बाड़ लगाने के लिए, RiscVState
क्लास के Fence
तरीके का इस्तेमाल किया जाता है.
स्टोर के निर्देश इसी तरह काम करेंगे. सबसे पहले, मेमोरी ऐक्सेस के असरदार पते का हिसाब, बेस और ऑफ़सेट निर्देश के सोर्स ऑपरेंड से लगाया जाता है. इसके बाद, सेव की जाने वाली वैल्यू को अगले सोर्स ऑपरेंड से फ़ेच किया जाता है. इसके बाद, state()
और
static_cast<>
के ज़रिए RiscV स्टेटस ऑब्जेक्ट को हासिल किया जाता है और सही तरीका कॉल किया जाता है.
RiscVState
ऑब्जेक्ट StoreMemory
का तरीका अपेक्षाकृत आसान है. हालांकि, इसके कुछ असर हैं जिनके बारे में हमें पता होना चाहिए:
void StoreMemory(const Instruction *inst, uint64_t address, DataBuffer *db);
जैसा कि हम देख सकते हैं, इस तरीके में तीन पैरामीटर होते हैं. पहला, स्टोर के निर्देश का पॉइंटर, दूसरा स्टोर का पता, और तीसरा स्टोर का डेटा शामिल करने वाले DataBuffer
इंस्टेंस का पॉइंटर. ध्यान दें कि साइज़ की ज़रूरत नहीं है, DataBuffer
इंस्टेंस में ही size()
तरीका होता है. हालांकि, निर्देश के लिए ऐसा कोई डेस्टिनेशन ऑपरेंड नहीं है जिसे ऐक्सेस किया जा सके. इसका इस्तेमाल, सही साइज़ के DataBuffer
इंस्टेंस को असाइन करने के लिए किया जा सकता है. इसके बजाय, हमें DataBuffer
की ऐसी फ़ैक्ट्री का इस्तेमाल करना होगा जो Instruction
इंस्टेंस में db_factory()
तरीके से मिली है. फ़ैक्ट्री में 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();
इसमें तीन स्टोर फ़ंक्शन होते हैं. हालांकि, इसमें सिर्फ़ एक चीज़ अलग है, वह है मेमोरी के ऐक्सेस का साइज़. ऐसा लगता है कि यह किसी दूसरे लोकल टेंप्लेट वाले हेल्पर फ़ंक्शन के लिए एक बेहतरीन अवसर है. स्टोर फ़ंक्शन में सिर्फ़ स्टोर वैल्यू अलग तरह की है, इसलिए टेंप्लेट में इसे एक तर्क के रूप में रखना चाहिए.
इसके अलावा, सिर्फ़ 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
ऑब्जेक्ट को ऐक्सेस करना ज़रूरी होता है. हालांकि, इनमें एक और जटिलता होती है. हर लोड निर्देश को दो अलग-अलग सेमेंटिक फ़ंक्शन में बांटा जाता है. पहला निर्देश, स्टोर निर्देश जैसा ही होता है. इसमें असरदार पता कैलकुलेट किया जाता है और मेमोरी ऐक्सेस की प्रोसेस शुरू की जाती है. दूसरा प्रोग्राम तब चलाया जाता है, जब मेमोरी का ऐक्सेस पूरा हो जाता है और रजिस्टर किए गए डेस्टिनेशन ऑपरेटिंग पर मेमोरी डेटा लिखता है.
चलिए, RiscVState
में LoadMemory
तरीके का एलान करने वाले फ़ॉर्म पर नज़र डालते हैं:
void LoadMemory(const Instruction *inst, uint64_t address, DataBuffer *db,
Instruction *child_inst, ReferenceCount *context);
StoreMemory
तरीके की तुलना में, LoadMemory
दो और पैरामीटर लेता है: Instruction
इंस्टेंस का पॉइंटर और रेफ़रंस की गिनती वाले context
ऑब्जेक्ट का पॉइंटर. पहला निर्देश, चाइल्ड निर्देश है, जो रजिस्टर में डेटा को फिर से लिखने की सुविधा को लागू करता है. इस सुविधा के बारे में आईएसए डिकोडर ट्यूटोरियल में बताया गया है. इसे मौजूदा Instruction
इंस्टेंस में, child()
तरीके का इस्तेमाल करके ऐक्सेस किया गया है.
बाद वाला, क्लास के ऐसे इंस्टेंस के लिए पॉइंटर है जो ReferenceCount
से लिया जाता है. इस मामले में यह DataBuffer
इंस्टेंस सेव करता है, जिसमें लोड किया गया डेटा शामिल होगा. कॉन्टेक्स्ट ऑब्जेक्ट, Instruction
ऑब्जेक्ट में context()
तरीके से उपलब्ध होता है. हालांकि, ज़्यादातर निर्देशों के लिए इसे 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
ऑब्जेक्ट में सेव भी किया जाता है.
चाइल्ड निर्देश के सेमैंटिक फ़ंक्शन सभी एक जैसे होते हैं. सबसे पहले, Instruction
के context()
तरीके को कॉल करके LoadContext
को पाया जाता है और फिर LoadContext *
में स्टैटिक-कास्ट किया जाता है. दूसरा, (डेटा टाइप के मुताबिक) वैल्यू, लोड-डेटा 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_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] >
यह ट्यूटोरियल यहीं खत्म होता है. हमें उम्मीद है कि इससे आपको मदद मिली होगी.