Article

SmallSol

Panduan untuk membuat machine emulator kecil menggunakan go.

Requirements:

Panduan ini akan memandu proses pembuatan emulasi mesin untuk RISC-V RV32i.

Konsep Dasar & Permulaan

CPU RV32I hanya butuh beberapa komponen: • 32 register 32-bit (x0–x31). x0 selalu bernilai 0. • Program counter (PC), alamat instruksi berikutnya. • Memori berupa array byte, little-endian. • Siklus fetch → decode → execute, diulang terus sampai program berhenti.

Setiap instruksi RV32I berukuran tepat 32 bit dan punya 6 format (R, I, S, B, U, J). Perbedaan format hanya pada cara bit-bit immediate disusun.

Struktur Proyek

Struktur proyek akan terlihat seperti yang dibawah ini:

--go.mod
--decode.go
--cpu.go
--main.go
--test.go  // File untuk tes

Buat proyek:

mkdir SmallSol && cd SmallSol
go mod init SmallSol

Decode

Decode itu memecah instruksi ya. Ide utamanya: bungkus uint32 dalam tipe Inst lalu beri method untuk tiap field.

package main

// Opcode utama RV32I.
const (
	opLoad   = 0x03
	opFence  = 0x0F
	opImm    = 0x13
	opAUIPC  = 0x17
	opStore  = 0x23
	opReg    = 0x33
	opLUI    = 0x37
	opBranch = 0x63
	opJALR   = 0x67
	opJAL    = 0x6F
	opSystem = 0x73
)

type Inst uint32

func (i Inst) Opcode() uint32 { return uint32(i) & 0x7f }
func (i Inst) Rd() uint32     { return uint32(i>>7) & 0x1f }
func (i Inst) Funct3() uint32 { return uint32(i>>12) & 0x7 }
func (i Inst) Rs1() uint32    { return uint32(i>>15) & 0x1f }
func (i Inst) Rs2() uint32    { return uint32(i>>20) & 0x1f }
func (i Inst) Funct7() uint32 { return uint32(i >> 25) }

// Immediate. Konversi ke int32 lalu geser kanan = sign extension otomatis.

func (i Inst) ImmI() int32 { return int32(i) >> 20 }

func (i Inst) ImmS() int32 {
	return int32(i)>>25<<5 | int32(i>>7)&0x1f
}

func (i Inst) ImmB() int32 {
	return int32(i)>>31<<12 |
		int32(i>>7)&1<<11 |
		int32(i>>25)&0x3f<<5 |
		int32(i>>8)&0xf<<1
}

func (i Inst) ImmU() uint32 { return uint32(i) & 0xfffff000 }

func (i Inst) ImmJ() int32 {
	return int32(i)>>31<<20 |
		int32(i)&0xff000 |
		int32(i>>20)&1<<11 |
		int32(i>>21)&0x3ff<<1
}

CPU dan Memori (cpu.go)

Bagian 1

package main

import (
	"encoding/binary"
	"fmt"
	"os"
)

type CPU struct {
	Reg      [32]uint32
	PC       uint32
	Mem      []byte
	Halted   bool
	ExitCode int
}

func NewCPU(memSize int) *CPU {
	c := &CPU{Mem: make([]byte, memSize)}
	c.Reg[2] = uint32(memSize) // sp di ujung memori
	return c
}

func (c *CPU) setReg(rd, v uint32) {
	if rd != 0 { // x0 hardwired ke 0
		c.Reg[rd] = v
	}
}

func (c *CPU) check(addr, size uint32) error {
	if uint64(addr)+uint64(size) > uint64(len(c.Mem)) {
		return fmt.Errorf("akses memori di luar batas: 0x%08x", addr)
	}
	return nil
}

func (c *CPU) load(addr, size uint32) (uint32, error) {
	if err := c.check(addr, size); err != nil {
		return 0, err
	}
	switch size {
	case 1:
		return uint32(c.Mem[addr]), nil
	case 2:
		return uint32(binary.LittleEndian.Uint16(c.Mem[addr:])), nil
	}
	return binary.LittleEndian.Uint32(c.Mem[addr:]), nil
}

func (c *CPU) store(addr, size, v uint32) error {
	if err := c.check(addr, size); err != nil {
		return err
	}
	switch size {
	case 1:
		c.Mem[addr] = byte(v)
	case 2:
		binary.LittleEndian.PutUint16(c.Mem[addr:], uint16(v))
	default:
		binary.LittleEndian.PutUint32(c.Mem[addr:], v)
	}
	return nil
}

Bagian 2, eksekusi instruksi

func (c *CPU) Step() error {
	raw, err := c.load(c.PC, 4)
	if err != nil {
		return err
	}
	i := Inst(raw)
	next := c.PC + 4

	switch i.Opcode() {
	case opLUI:
		c.setReg(i.Rd(), i.ImmU())

	case opAUIPC:
		c.setReg(i.Rd(), c.PC+i.ImmU())

	case opJAL:
		c.setReg(i.Rd(), next) // simpan alamat kembali
		next = c.PC + uint32(i.ImmJ())

	case opJALR:
		// hitung target dulu, karena rd bisa sama dengan rs1
		target := (c.Reg[i.Rs1()] + uint32(i.ImmI())) &^ 1
		c.setReg(i.Rd(), next)
		next = target

	case opBranch:
		take, err := c.branchTaken(i)
		if err != nil {
			return err
		}
		if take {
			next = c.PC + uint32(i.ImmB())
		}

	case opLoad:
		if err := c.execLoad(i); err != nil {
			return err
		}

	case opStore:
		if err := c.execStore(i); err != nil {
			return err
		}

	case opImm:
		sra := i.Funct7() == 0x20 // hanya berarti untuk SRAI
		res := alu(i.Funct3(), c.Reg[i.Rs1()], uint32(i.ImmI()), false, sra)
		c.setReg(i.Rd(), res)

	case opReg:
		alt := i.Funct7() == 0x20 // SUB dan SRA
		res := alu(i.Funct3(), c.Reg[i.Rs1()], c.Reg[i.Rs2()], alt, alt)
		c.setReg(i.Rd(), res)

	case opFence:
		// tidak melakukan apa pun pada emulator satu-core

	case opSystem:
		switch i.ImmI() {
		case 0: // ECALL
			if err := c.ecall(); err != nil {
				return err
			}
		case 1: // EBREAK
			c.Halted = true
		default:
			return fmt.Errorf("instruksi SYSTEM tidak didukung di PC=0x%08x", c.PC)
		}

	default:
		return fmt.Errorf("opcode tidak dikenal 0x%02x di PC=0x%08x", i.Opcode(), c.PC)
	}

	c.PC = next
	return nil
}

Fungsi-fungsi pendukung:

func b2u(b bool) uint32 {
	if b {
		return 1
	}
	return 0
}

// alu dipakai bersama oleh format R dan I-ALU.
func alu(f3, a, b uint32, sub, sra bool) uint32 {
	switch f3 {
	case 0: // ADD / SUB / ADDI
		if sub {
			return a - b
		}
		return a + b
	case 1: // SLL
		return a << (b & 31)
	case 2: // SLT
		return b2u(int32(a) < int32(b))
	case 3: // SLTU
		return b2u(a < b)
	case 4: // XOR
		return a ^ b
	case 5: // SRL / SRA
		if sra {
			return uint32(int32(a) >> (b & 31))
		}
		return a >> (b & 31)
	case 6: // OR
		return a | b
	}
	return a & b // AND
}

func (c *CPU) branchTaken(i Inst) (bool, error) {
	a, b := c.Reg[i.Rs1()], c.Reg[i.Rs2()]
	switch i.Funct3() {
	case 0: // BEQ
		return a == b, nil
	case 1: // BNE
		return a != b, nil
	case 4: // BLT
		return int32(a) < int32(b), nil
	case 5: // BGE
		return int32(a) >= int32(b), nil
	case 6: // BLTU
		return a < b, nil
	case 7: // BGEU
		return a >= b, nil
	}
	return false, fmt.Errorf("funct3 branch tidak valid")
}

func (c *CPU) execLoad(i Inst) error {
	addr := c.Reg[i.Rs1()] + uint32(i.ImmI())
	var v uint32
	var err error
	switch i.Funct3() {
	case 0: // LB (sign-extend)
		v, err = c.load(addr, 1)
		v = uint32(int32(int8(v)))
	case 1: // LH (sign-extend)
		v, err = c.load(addr, 2)
		v = uint32(int32(int16(v)))
	case 2: // LW
		v, err = c.load(addr, 4)
	case 4: // LBU
		v, err = c.load(addr, 1)
	case 5: // LHU
		v, err = c.load(addr, 2)
	default:
		return fmt.Errorf("funct3 load tidak valid")
	}
	if err != nil {
		return err
	}
	c.setReg(i.Rd(), v)
	return nil
}

func (c *CPU) execStore(i Inst) error {
	addr := c.Reg[i.Rs1()] + uint32(i.ImmS())
	v := c.Reg[i.Rs2()]
	switch i.Funct3() {
	case 0: // SB
		return c.store(addr, 1, v)
	case 1: // SH
		return c.store(addr, 2, v)
	case 2: // SW
		return c.store(addr, 4, v)
	}
	return fmt.Errorf("funct3 store tidak valid")
}

Bagian 3, syscall

Program butuh cara berhenti dan mencetak teks. Kita pinjam konvensi Linux RISC-V: nomor syscall di a7 (x17), argumen di a0–a2 (x10–x12).

func (c *CPU) ecall() error {
	switch c.Reg[17] { // a7
	case 93:
		c.Halted = true
		c.ExitCode = int(int32(c.Reg[10]))
	case 64:
		buf, n := c.Reg[11], c.Reg[12]
		if err := c.check(buf, n); err != nil {
			return err
		}
		if c.Reg[10] == 1 {
			os.Stdout.Write(c.Mem[buf : buf+n])
		}
		c.Reg[10] = n // nilai kembalian: jumlah byte tertulis
	default:
		return fmt.Errorf("syscall tidak dikenal: %d", c.Reg[17])
	}
	return nil
}

Program Utama (main.go)

package main

import (
	"fmt"
	"os"
)

const memSize = 1 << 20 // 1 MiB

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "pemakaian: rv32i program.bin")
		os.Exit(2)
	}
	prog, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}

	cpu := NewCPU(memSize)
	copy(cpu.Mem, prog) // program dimuat di alamat 0, PC mulai 0

	for !cpu.Halted {
		if err := cpu.Step(); err != nil {
			fmt.Fprintf(os.Stderr, "error: %v (PC=0x%08x)\n", err, cpu.PC)
			os.Exit(1)
		}
	}
	os.Exit(cpu.ExitCode)
}

Coba tes (test.go)

Kita coba rakit program kecil yang menghitung 5+7.

package main

import (
	"encoding/binary"
	"testing"
)

func TestAdd(t *testing.T) {
	prog := []uint32{
		0x00500093, // addi x1, x0, 5
		0x00700113, // addi x2, x0, 7
		0x002081B3, // add  x3, x1, x2
		0x05d00893, // addi a7, x0, 93   (syscall exit)
		0x00018513, // addi a0, x3, 0    (a0 = x3)
		0x00000073, // ecall
	}
	cpu := NewCPU(1024)
	for n, w := range prog {
		binary.LittleEndian.PutUint32(cpu.Mem[n*4:], w)
	}
	for !cpu.Halted {
		if err := cpu.Step(); err != nil {
			t.Fatal(err)
		}
	}
	if cpu.ExitCode != 12 {
		t.Fatalf("exit code = %d, harapan 12", cpu.ExitCode)
	}
}