Foundationপ্রথম নীতি থেকে
LEVEL 3অ্যাডভান্সড~১৪ ঘণ্টাPython

CPU Emulator

CPU Emulator

RISC-V RV32I-এর একটা সফটওয়্যার এমুলেটর — সত্যিকারের ৩২-বিট মেশিন কোড পড়ে, ফিল্ড ডিকোড করে, রেজিস্টার ফাইল ও ফ্ল্যাট মেমরির উপর fetch-decode-execute লুপ চালিয়ে — শেষে হাতে-অ্যাসেম্বল করা একটা লুপ-প্রোগ্রাম দিয়ে হাতে-গণনার সাথে মিলিয়ে verify করা।

মাইলস্টোন

আগে যা পড়া দরকার

কেন এই প্রজেক্ট

Fetch-decode-execute লেসনে আমরা দেখেছি একটা CPU কীভাবে মেমরি থেকে একটা ইন্সট্রাকশন তুলে, তার বিট প্যাটার্ন পড়ে বুঝে নেয় “কী করতে হবে”, তারপর সেটা করে। Instruction encoding লেসনে দেখেছি সেই বিট প্যাটার্নগুলো কীভাবে সাজানো — কোন বিট opcode, কোন বিট রেজিস্টার নম্বর, কোন বিট immediate।

এই প্রজেক্টে দুটোই একসাথে জোড়া লাগবে, আর এবার কোনো toy বা ঘরে-বানানো ISA না — সরাসরি RISC-V RV32I, একটা বাস্তব, ব্যাপকভাবে ব্যবহৃত ইন্সট্রাকশন সেট আর্কিটেকচার। আপনি যে মেশিন কোড ডিকোড করবেন, ঠিক একই মেশিন কোড একটা বাস্তব RISC-V চিপেও চলে। এটাই এই প্রজেক্টের আসল শিক্ষা — ISA স্পেসিফিকেশন একটা প্রোজে ছাপা কাগজ না, এটা একটা precise, বাস্তবায়নযোগ্য চুক্তি। আপনি সেই চুক্তিটা পড়ে হুবহু মেনে একটা সফটওয়্যার মেশিন বানাবেন, আর সেটা যাচাই করার একমাত্র উপায় হলো — এটা প্রত্যাশিত ফলাফল দেয় কি না।

RV32I ও এই এমুলেটরের সীমা

RV32I পূর্ণাঙ্গ ISA-তে ৪০+ ইন্সট্রাকশন আছে (multiply/divide extension বাদেই)। এই প্রজেক্টে আমরা একটা কার্যকরী core subset বানাব যেটা দিয়ে ইতিমধ্যে লুপ, শর্ত, ফাংশন-কল — সবকিছু লেখা সম্ভব:

গ্রুপইন্সট্রাকশনফরম্যাট
Arithmetic/logic (রেজিস্টার-রেজিস্টার)ADD, SUB, AND, OR, XOR, SLTR-type
Arithmetic (রেজিস্টার-immediate)ADDII-type
MemoryLW, SWI-type (load), S-type (store)
BranchBEQ, BNEB-type
JumpJAL, JALRJ-type, I-type

U-type (LUI/AUIPC) ফরম্যাটের বিট-লেআউটও নিচে দেখানো হলো, কারণ ডিকোডার-এর সম্পূর্ণ ছবি বুঝতে ওটাও দরকার — কিন্তু ন্যূনতম execute-তালিকায় কোনো U-type ইন্সট্রাকশন নেই, তাই LUI বাস্তবায়ন নিজেকে চ্যালেঞ্জ করুন-এ রাখা হয়েছে।

রেজিস্টার ফাইল — x0-এর কোয়ার্ক

RV32I-তে ৩২টা general-purpose রেজিস্টার (x0x31), প্রতিটা ৩২-বিট। একটা নিয়ম মুখস্থ রাখার মতো গুরুত্বপূর্ণ:

x0 সবসময় শূন্য। এতে লেখা যায়, কিন্তু লেখাটা কোনো প্রভাব ফেলে না — পড়লে সবসময় 0 ফেরত আসে।

এটা কোনো accident না, ইচ্ছাকৃত ডিজাইন। এর সুবিধা:

  • ADDI x5, x0, 7 দিয়ে x5-এ constant 7 বসানো যায় — আলাদা কোনো “load-immediate” ইন্সট্রাকশন লাগে না।
  • BEQ x5, x0, target দিয়ে সরাসরি “x5 শূন্য কি না” পরীক্ষা করা যায়।
  • Compiler-এর জন্য একটা result-discard করার জায়গা পাওয়া যায় (ADD x0, x5, x6 মানে “যোগ করো, কিন্তু ফলাফল ফেলে দাও”)।

আমাদের এমুলেটরে এটা বাস্তবায়ন করার সবচেয়ে সহজ উপায় — রেজিস্টার পড়া/লেখার জন্য দুটো helper ফাংশন বানানো, রেজিস্টার অ্যারেতে সরাসরি ইনডেক্স না করে:

class CPU:
    def __init__(self, mem_size=4096):
        self.regs = [0] * 32
        self.pc = 0
        self.mem = bytearray(mem_size)
        self.halted = False

    def read_reg(self, i):
        return self.regs[i] if i != 0 else 0

    def write_reg(self, i, value):
        if i != 0:
            self.regs[i] = value & 0xFFFFFFFF   # ৩২-বিটে wrap

write_reg নিজে x0-কে সুরক্ষা দেয় (i != 0 চেক), আর read_reg নিশ্চিত করে কেউ যদি ভুলবশত x0-তে কিছু লিখেও ফেলে (উপরের চেকের কারণে যেটা আসলে সম্ভবই না), তাও পড়ার সময় শূন্যই পাওয়া যাবে। দুটো চেক একসাথে থাকাটা defensive coding-এর ভালো অভ্যাস — 8-bit ALU প্রজেক্টে register file-এর R0-সুরক্ষাতেও একই প্যাটার্ন দেখেছিলাম।

ইন্সট্রাকশন ফরম্যাট — বিট-বাই-বিট

RV32I-এর প্রতিটা ইন্সট্রাকশন ৩২ বিট, আর সব ফরম্যাটেই opcode সবসময় বিট [6:0]-এ থাকে — এইটুকু জানলেই ডিকোডার প্রথম ধাপে opcode বের করে বাকি ফিল্ড কীভাবে পড়তে হবে সেটা ঠিক করতে পারে।

R-type:  funct7[31:25] rs2[24:20] rs1[19:15] funct3[14:12] rd[11:7]  opcode[6:0]
I-type:  imm[31:20]               rs1[19:15] funct3[14:12] rd[11:7]  opcode[6:0]
S-type:  imm[31:25]    rs2[24:20] rs1[19:15] funct3[14:12] imm[11:7] opcode[6:0]
B-type:  imm[31|30:25|... ছড়ানো, নিচে বিস্তারিত ...]                opcode[6:0]
U-type:  imm[31:12]                                        rd[11:7]  opcode[6:0]
J-type:  imm[20|19:12|11|... ছড়ানো, নিচে বিস্তারিত ...]     rd[11:7]  opcode[6:0]

opcode ও funct3/funct7 মিলে ইন্সট্রাকশন বাছাই হয়:

opcodefunct3funct7ইন্সট্রাকশন
0110011 (R)0000000000ADD
0110011 (R)0000100000SUB
0110011 (R)1110000000AND
0110011 (R)1100000000OR
0110011 (R)1000000000XOR
0110011 (R)0100000000SLT
0010011 (I)000ADDI
0000011 (I)010LW
0100011 (S)010SW
1100011 (B)000BEQ
1100011 (B)001BNE
1101111 (J)JAL
1100111 (I)000JALR

খেয়াল করুন — ADD আর SUB-এর opcode আর funct3 একই; কেবল funct7 আলাদা। এটা RISC-V-এর একটা ইচ্ছাকৃত সিদ্ধান্ত: R-type-এর মধ্যেই একটা বাড়তি ৭-বিট ফিল্ড রাখা হয়েছে, যাতে যোগ আর বিয়োগের মতো “কাছাকাছি জাতের” অপারেশনগুলো একই ফরম্যাটের ভেতর বিট-ভাগাভাগি করে জায়গা বাঁচাতে পারে।

ধাপে ধাপে

১. মেমরি — flat byte array, little-endian

RV32I লিটল-এন্ডিয়ান — একটা ৩২-বিট শব্দের সবচেয়ে কম গুরুত্বপূর্ণ বাইট সবচেয়ে ছোট ঠিকানায় থাকে। Python-এর struct মডিউল দিয়ে এটা সরাসরি হ্যান্ডল করা যায়:

import struct

def fetch_word(mem, addr):
    return struct.unpack_from('\<I', mem, addr)[0]

def load_word(mem, addr):
    return struct.unpack_from('\<I', mem, addr)[0]

def store_word(mem, addr, value):
    struct.pack_into('\<I', mem, addr, value & 0xFFFFFFFF)

'\<I' মানে “little-endian, unsigned ৩২-বিট ইন্টিজার” — fetch (ইন্সট্রাকশন পড়া) আর data load দুটোতেই একই ফরম্যাট।

২. Sign-extend — সব ফরম্যাটের ভিত্তি

Immediate ফিল্ডগুলো signed — ঋণাত্মক offset (পিছনের দিকে branch, নেগেটিভ constant) বোঝাতে হবে। একটা closed-form সূত্র দিয়ে এটা এক লাইনে করা যায়, কোনো if-branch ছাড়াই:

def sign_extend(value, bits):
    sign_bit = 2 ** (bits - 1)
    return (value & (sign_bit - 1)) - (value & sign_bit)

যুক্তি: sign_bit হলো সেই নির্দিষ্ট বিট-প্রস্থের MSB (sign বিট)-এর মাস্ক। যদি সেই বিট সেট থাকে, value & sign_bit অ-শূন্য (ঠিক sign_bit-এর সমান) হয়ে যায় আর ফলাফল থেকে পুরো sign_bit বিয়োগ হয়ে যায় — এটাই two’s complement-এর সংজ্ঞা। বিট সেট না থাকলে বিয়োগ হয় শূন্য, ফলাফল অপরিবর্তিত থাকে।

যাচাই — ১২-বিট 0xFFF (সব বিট সেট) sign-extend করলে -1 হওয়া উচিত:

sign_bit = 2**11 = 2048 (0x800)
value & (sign_bit-1) = 0xFFF & 0x7FF = 0x7FF = 2047
value & sign_bit      = 0xFFF & 0x800 = 0x800 = 2048
ফলাফল = 2047 - 2048 = -1   ✓

৩. Decode — কমন ফিল্ড

সব ফরম্যাটেই opcode, rd, funct3, rs1, rs2, funct7-এর বিট-পজিশন একই — শুধু কোন ফরম্যাটে কোনটা “প্রযোজ্য” সেটা আলাদা। তাই একটা কমন এক্সট্র্যাক্টর সবার আগে লেখা যায়:

def decode_common(word):
    opcode = word & 0x7F
    rd     = (word >> 7)  & 0x1F
    funct3 = (word >> 12) & 0x7
    rs1    = (word >> 15) & 0x1F
    rs2    = (word >> 20) & 0x1F
    funct7 = (word >> 25) & 0x7F
    return opcode, rd, funct3, rs1, rs2, funct7

৪. Decode — প্রতিটা ফরম্যাটের immediate

I-type সবচেয়ে সহজ — বিট [31:20] সরাসরি একটা ১২-বিট signed সংখ্যা:

def imm_i(word):
    return sign_extend((word >> 20) & 0xFFF, 12)

S-type — immediate দুই টুকরায় ভাঙা ([31:25] আর [11:7]), কারণ rd-এর জায়গাটা store-এ লাগে না তাই সেই বিটগুলো immediate-এর নিচের অংশ ধরে রাখে:

def imm_s(word):
    hi = (word >> 25) & 0x7F   # imm[11:5]
    lo = (word >> 7)  & 0x1F   # imm[4:0]
    return sign_extend(hi * 32 + lo, 12)   # hi 5 বিট বাম দিকে শিফট, lo দিয়ে OR

hi * 32 মানে hi-কে ৫ বিট বাম দিকে শিফট করা (2**5 = 32) — বিট-পজিশন [11:5]-এ বসানোর জন্য। যেহেতু hi আর lo কখনো ওভারল্যাপ করে না, + আর bitwise | এখানে সমান কাজ করে।

B-type সবচেয়ে জটিল — RISC-V ইচ্ছাকৃতভাবে immediate-এর বিটগুলো এমনভাবে সাজিয়েছে যাতে rs1/rs2/funct3-এর পজিশন R/I/S-type-এর সাথে অপরিবর্তিত থাকে (hardware-এ কম মাক্সিং লাগে), বিনিময়ে সফটওয়্যার ডিকোডারকে একটু বেশি কাজ করতে হয়:

def imm_b(word):
    b12   = (word >> 31) & 1     # sign বিট
    b11   = (word >> 7)  & 1
    b10_5 = (word >> 25) & 0x3F
    b4_1  = (word >> 8)  & 0xF
    raw = b12 * 4096 + b11 * 2048 + b10_5 * 32 + b4_1 * 2
    return sign_extend(raw, 13)

লক্ষ্য করুন branch offset-এর সবচেয়ে নিচের বিট (bit 0) কখনো এনকোড করা হয় না — ধরে নেওয়া হয় সবসময় 0, কারণ ইন্সট্রাকশন সবসময় ২-বাইট-অ্যালাইনড। এই জন্যই b4_1 * 2 (b4_1-কে ১ বিট বাম দিকে শিফট) — সবচেয়ে নিচের বিট হিসেবে স্বয়ংক্রিয়ভাবে 0 বসে যায়।

J-type একই কৌশলে, শুধু ২১-বিট প্রশস্ত (JAL অনেক দূরে লাফাতে পারে):

def imm_j(word):
    b20    = (word >> 31) & 1
    b19_12 = (word >> 12) & 0xFF
    b11    = (word >> 20) & 1
    b10_1  = (word >> 21) & 0x3FF
    raw = b20 * 1048576 + b19_12 * 4096 + b11 * 2048 + b10_1 * 2
    return sign_extend(raw, 21)

৫. Execute — R-type (ADD/SUB/AND/OR/XOR/SLT)

def exec_r(self, rd, funct3, rs1, rs2, funct7):
    a, b = self.read_reg(rs1), self.read_reg(rs2)
    if funct3 == 0b000:
        result = (a - b) if funct7 == 0b0100000 else (a + b)
    elif funct3 == 0b111:
        result = a & b
    elif funct3 == 0b110:
        result = a | b
    elif funct3 == 0b100:
        result = a ^ b
    elif funct3 == 0b010:                              # SLT — signed compare
        result = 1 if sign_extend(b, 32) > sign_extend(a, 32) else 0
    else:
        raise NotImplementedError(f"R-type funct3={funct3:03b}")
    self.write_reg(rd, result & 0xFFFFFFFF)

SLT-এর শর্তটা লক্ষ্য করুন: rd = (rs1 \< rs2) ? 1 : 0 লেখার বদলে sign_extend(b,32) > sign_extend(a,32) লেখা হয়েছে — গাণিতিকভাবে হুবহু একই জিনিস (a \< b ঠিক তখনই সত্যি যখন b > a), শুধু তুলনাটা উল্টো দিক থেকে লেখা।

৬. Execute — ADDI, LW

def exec_i_alu(self, rd, funct3, rs1, imm):
    if funct3 == 0b000:                                 # ADDI
        self.write_reg(rd, (self.read_reg(rs1) + imm) & 0xFFFFFFFF)
    else:
        raise NotImplementedError(f"I-type-alu funct3={funct3:03b}")

def exec_load(self, rd, funct3, rs1, imm):
    addr = (self.read_reg(rs1) + imm) & 0xFFFFFFFF
    if funct3 == 0b010:                                 # LW
        self.write_reg(rd, fetch_word(self.mem, addr))
    else:
        raise NotImplementedError(f"LOAD funct3={funct3:03b}")

৭. Execute — SW

def exec_store(self, funct3, rs1, rs2, imm):
    addr = (self.read_reg(rs1) + imm) & 0xFFFFFFFF
    if funct3 == 0b010:                                 # SW
        store_word(self.mem, addr, self.read_reg(rs2))
    else:
        raise NotImplementedError(f"STORE funct3={funct3:03b}")

৮. Execute — BEQ/BNE

def exec_branch(self, funct3, rs1, rs2, imm):
    a, b = self.read_reg(rs1), self.read_reg(rs2)
    if funct3 == 0b000:      taken = (a == b)           # BEQ
    elif funct3 == 0b001:    taken = (a != b)            # BNE
    else: raise NotImplementedError(f"BRANCH funct3={funct3:03b}")
    return (self.pc + imm) & 0xFFFFFFFF if taken else None

None ফেরত মানে “branch নেওয়া হয়নি” — caller সেটা দেখে বুঝবে pc + 4-এই এগোতে হবে, উপরে ফেরত-দেওয়া অ্যাড্রেসে না।

৯. Execute — JAL/JALR

def exec_jal(self, rd, imm):
    self.write_reg(rd, self.pc + 4)                      # রিটার্ন-অ্যাড্রেস সংরক্ষণ
    return (self.pc + imm) & 0xFFFFFFFF

def exec_jalr(self, rd, rs1, imm):
    target = (self.read_reg(rs1) + imm) & 0xFFFFFFFE      # সবচেয়ে নিচের বিট শূন্য করে দেওয়া হয়
    self.write_reg(rd, self.pc + 4)
    return target

JALR-এর টার্গেট-এর LSB জোর করে শূন্য করা হচ্ছে (& 0xFFFFFFFE) — এটা স্পেকের একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ ডিটেইল, কারণ RISC-V compressed extension-এর সাথে সামঞ্জস্যের জন্য এই বিটটা ঠিকানার অংশ হিসেবে গণ্য হয় না।

১০. পুরো step() জোড়া লাগানো

def step(self):
    word = fetch_word(self.mem, self.pc)
    opcode, rd, funct3, rs1, rs2, funct7 = decode_common(word)
    next_pc = self.pc + 4                                 # ডিফল্ট — sequential এগোনো

    if opcode == 0b0110011:
        self.exec_r(rd, funct3, rs1, rs2, funct7)
    elif opcode == 0b0010011:
        self.exec_i_alu(rd, funct3, rs1, imm_i(word))
    elif opcode == 0b0000011:
        self.exec_load(rd, funct3, rs1, imm_i(word))
    elif opcode == 0b0100011:
        self.exec_store(funct3, rs1, rs2, imm_s(word))
    elif opcode == 0b1100011:
        branch_target = self.exec_branch(funct3, rs1, rs2, imm_b(word))
        if branch_target is not None:
            next_pc = branch_target
    elif opcode == 0b1101111:                             # JAL
        target = imm_j(word)
        next_pc = self.exec_jal(rd, target)
        if target == 0:                                   # অসীম self-jump = halt কনভেনশন
            self.halted = True
    elif opcode == 0b1100111:                              # JALR
        next_pc = self.exec_jalr(rd, rs1, imm_i(word))
    else:
        raise NotImplementedError(f"opcode={opcode:07b} সাপোর্ট করা হয়নি")

    self.pc = next_pc

def run(self, max_steps=10_000):
    steps = 0
    for _ in range(max_steps):
        if self.halted:
            break
        self.step()
        steps += 1
    return steps

JAL x0, 0 (অসীম self-jump, নিজেই নিজেকে টার্গেট করা) কে “halt” কনভেনশন হিসেবে ব্যবহার করা হয়েছে — ঠিক যেভাবে single-cycle CPU প্রজেক্টে JMP 8 (নিজের ঠিকানায় jump) দিয়ে প্রোগ্রাম থামানো হয়েছিল। বাস্তব RV32I-তে সাধারণত ecall-এর মাধ্যমে OS-কে জানানো হয়, কিন্তু আমাদের ন্যূনতম ইন্সট্রাকশন সেটে ecall নেই, তাই এই সহজ কনভেনশনটাই যথেষ্ট।

হাতে-অ্যাসেম্বল করা প্রোগ্রাম — লুপ দিয়ে যোগফল

লক্ষ্য: 1 + 2 + 3 + 4 + 5 = 15 গণনা করা, একটা BNE-ভিত্তিক লুপে, তারপর ফলাফল মেমরিতে স্টোর করে আবার লোড করে যাচাই করা।

assembly                     ঠিকানা     উদ্দেশ্য
────────────────────────────────────────────────────────────
addi x5, x0, 0                0x00      sum = 0
addi x6, x0, 1                0x04      i = 1
addi x7, x0, 6                0x08      limit = 6  (লুপ চলবে যতক্ষণ i != 6)
add  x5, x5, x6                0x0C      sum += i        ← loop:
addi x6, x6, 1                0x10      i += 1
bne  x6, x7, loop              0x14      if (i != 6) goto loop  (offset −8)
sw   x5, 100(x0)               0x18      mem[100] = sum
lw   x28, 100(x0)              0x1C      x28 = mem[100]   (স্মৃতি রাউন্ড-ট্রিপ যাচাই)
jal  x0, 0                     0x20      অসীম self-jump — halt

bne x6, x7, loop-এর offset কীভাবে এলো: এই ইন্সট্রাকশনের ঠিকানা 0x14, লক্ষ্য (loop:) 0x0C — তাই imm = 0x0C − 0x14 = −8

হাতে-এনকোড করা মেশিন কোড

ঠিকানাইন্সট্রাকশনopcode/ফিল্ডhex
0x00addi x5, x0, 0I-type, funct3=000, imm=00x00000293
0x04addi x6, x0, 1I-type, funct3=000, imm=10x00100313
0x08addi x7, x0, 6I-type, funct3=000, imm=60x00600393
0x0Cadd x5, x5, x6R-type, funct3=000, funct7=00x006282B3
0x10addi x6, x6, 1I-type, funct3=000, imm=10x00130313
0x14bne x6, x7, −8B-type, funct3=001, imm=−80xFE731CE3
0x18sw x5, 100(x0)S-type, funct3=010, imm=1000x06502223
0x1Clw x28, 100(x0)I-type, funct3=010, imm=1000x06402E03
0x20jal x0, 0J-type, imm=00x0000006F

program.bin-এ এই ৯টা শব্দ ক্রমানুসারে little-endian বাইট হিসেবে লিখে cpu.mem[0:36]-এ লোড করলেই এমুলেটর চালানোর জন্য প্রস্তুত।

ট্রেস — ভেরিফায়েড রান

প্রথম কয়েকটা ধাপ (setup + প্রথম লুপ-পাস):

ধাপpcইন্সট্রাকশনx5(sum)x6(i)x7
10x00addi x5,x0,0000
20x04addi x6,x0,1010
30x08addi x7,x0,6016
40x0Cadd x5,x5,x6116
50x10addi x6,x6,1126
60x14bne x6,x7,−8 (2≠6, নেওয়া হলো, pc→0x0C)126

লুপ এভাবেই আরও ৪ বার চলে (ধাপ ৭–১৭) — প্রতি পাসে add দিয়ে sum-এ i যোগ হয়, addi দিয়ে i বাড়ে, bne আবার পিছনে লাফ দেয় — যতক্ষণ না i == 6:

ধাপpcইন্সট্রাকশনx5(sum)x6(i)x7
160x0Cadd x5,x5,x6 (শেষ যোগ, i=5)1556
170x10addi x6,x6,11566
180x14bne x6,x7,−8 (6==6, নেওয়া হয়নি, pc→0x18)1566
190x18sw x5,100(x0)mem[100]=151566
200x1Clw x28,100(x0)x28=151566
210x20jal x0,0halted1566

চূড়ান্ত অবস্থা: ২১টা step() কল-এর পর x5 = 15, x6 = 6, x7 = 6, x28 = 15, mem[100] = 15 — হাতে-গণনা করা 1+2+3+4+5 = 15-এর সাথে হুবহু মেলে, আর x28-এর মান প্রমাণ করে memory round-trip (store করে আবার load করা)-ও সঠিক।

বাস্তব টুলচেইনের সাথে মেলানো

এই হাতে-এনকোডিং ভুল হলে ধরার সবচেয়ে ভালো উপায় — একটা আসল RISC-V টুলচেইন দিয়ে একই অ্যাসেম্বলি কম্পাইল করে তুলনা করা:

$ riscv64-unknown-elf-as -march=rv32i -mabi=ilp32 -o sum.o sum.s
$ riscv64-unknown-elf-objdump -d sum.o

objdump-এর আউটপুটে প্রতিটা ইন্সট্রাকশনের পাশে ঠিক যে hex encoding দেখাবে, সেটা উপরের টেবিলের সাথে বিট-বাই-বিট মিলে যাওয়া উচিত। টুলচেইন ইনস্টল করা এই লেখার জন্য জরুরি না, কিন্তু বাস্তব প্রজেক্টে এটাই সবচেয়ে নির্ভরযোগ্য “গ্রাউন্ড ট্রুথ” — নিজের ডিকোডার লজিকের বিরুদ্ধে আরেকটা স্বাধীন সোর্স।

নিজেকে চ্যালেঞ্জ করুন

  1. LUI/AUIPC (U-type) — বিট [31:12]-কে সরাসরি rd-তে বসিয়ে (LUI) বা pc-র সাথে যোগ করে (AUIPC) implement করুন — এটাই একমাত্র বাকি-থাকা ফরম্যাট
  2. SLTU, SLTIU — unsigned compare যোগ করুন, sign_extend না ব্যবহার করে সরাসরি তুলনা করে
  3. Byte/halfword মেমরি অ্যাক্সেস — LB/LH/LBU/LHU/SB/SH যোগ করুন, struct-এর '\<b'/'\<h'/'\<B'/'\<H' ফরম্যাট কোড ব্যবহার করে
  4. ইন্সট্রাকশন কাউন্টার ও ট্রেস মোড — প্রতিটা step()-এ pc, decoded mnemonic, আর যা বদলালো সেটা প্রিন্ট করে একটা objdump -d-এর মতো execution trace বানান
  5. ELF লোডার — একটা সত্যিকারের .elf ফাইল থেকে .text সেকশন বের করে সরাসরি লোড করুন, হাতে-এনকোড করা hex-এর বদলে
  6. M extension (MUL/DIV) — funct7 0000001 দিয়ে চেনা যায়, RV32I-এর সবচেয়ে সহজ এক্সটেনশন

এটা যেখানে গিয়ে মিশবে

এখানে যা শিখলেনপরে কোথায় লাগবে
Fetch-decode-execute লুপ, সফটওয়্যারে বাস্তবায়িতএই মডিউলেরই Pipeline Visualizer প্রজেক্ট — একই লজিক, কিন্তু এবার স্টেজে ভাগ করা
Bit-field ডিকোডিং (opcode/funct3/funct7/immediate)Assembly module — Toy Assembler প্রজেক্ট, উল্টো দিকের কাজ (টেক্সট → বিট)
Sign extension, two’s complementLevel 1-এর floating-point ও integer representation লেসনের সরাসরি প্রয়োগ
রেজিস্টার ফাইল, memory-mapped store/loadLevel 4 — OS-এর process memory model, ও calling convention
এই এমুলেটরের সীমাবদ্ধতা (কোনো cache, pipeline, বা branch prediction নেই)Level 3-এরই Cache Simulator ও Pipeline Visualizer প্রজেক্ট — এই একই CPU-কে ধাপে ধাপে বাস্তবসম্মত করা
ISA-কে “hardware/software contract” হিসেবে দেখাLevel 11 — Advanced Architecture-এর microarchitecture বনাম architecture আলোচনা