Membangun dengan Titik Pemeriksaan

Membangun bertahap dengan setiap tahap diuji terhadap program nyata sebelum maju.

Daftar isi

Membangun emulator, yaitu program yang meniru kerja sebuah prosesor, jarang selesai dalam satu tahap tunggal. Kesalahan yang tertinggal di tahap awal menular ke semua tahap sesudahnya karena fitur berikutnya dibangun di atas fitur yang dianggap sudah benar. Topik ini menetapkan disiplin pengerjaan proyek untuk seluruh jalur, yaitu membangun sedikit, menguji dengan program nyata, lalu maju hanya setelah tahap saat ini lulus. Sebagai bahan latihan, topik ini memperkenalkan Mesin Mini, sebuah mesin uji kecil berisi sepuluh instruksi, dan menjalankan titik pemeriksaan pertamanya sampai tuntas.

Mengapa setiap tahap harus lulus dahulu#

Titik pemeriksaan (checkpoint) adalah program nyata yang harus lulus pada kode terkini sebelum satu tahap dinyatakan selesai. Kata kuncinya adalah program nyata, bukan uji coba sesaat lewat baris perintah. Sebuah program menyusun beberapa fitur menjadi satu rangkaian kerja, sehingga program yang berjalan benar membuktikan bahwa fitur-fitur itu juga bekerja bersama. Uji coba sesaat hanya membuktikan satu instruksi berjalan pada kondisi yang dipilih sendiri, dan kondisi seperti itu sering lebih ramah daripada keadaan sungguhan.

Alasan kedua menyangkut biaya pencarian error. Apabila tahap awal sudah lulus dan error baru muncul di tahap berikutnya, sumber masalah hampir pasti berada pada kode yang baru ditulis, bukan pada ratusan baris sebelumnya. Semakin lambat sebuah error ditemukan, semakin banyak kode yang harus ditinjau ulang. Gerbang lulus pada setiap tahap menjaga wilayah pencarian itu tetap kecil.

Alasan ketiga menyangkut regression, yaitu keadaan ketika fitur yang tadinya lulus menjadi rusak akibat perubahan di tempat lain. Daftar titik pemeriksaan yang telah lulus berubah menjadi rangkaian uji yang dapat diulang kapan saja. Setiap kali kode berubah, seluruh titik pemeriksaan lama dijalankan ulang, sehingga regression tertangkap pada menit yang sama, bukan berminggu-minggu kemudian.

Mesin Mini dan tabel decode-nya#

Seluruh latihan proyek pada jalur ini memakai satu mesin uji yang sama agar setiap tabel titik pemeriksaan menunjuk objek yang nyata. Mesin Mini adalah prosesor fiktif yang sengaja dibuat kecil: 32 register 32 bit, 256 kata memori, dan sepuluh instruksi. Nama instruksinya mengikuti kebiasaan RISC-V, yaitu addi, add, sub, lw, sw, beq, bne, j, ecall, dan halt, sehingga kebiasaan yang dibangun di sini dapat dibawa ke mesin sungguhan. Register zero selalu bernilai nol, sp menunjuk tumpukan, ra menampung alamat kembali, sedangkan a0 sampai a7 menjadi register argumen. Instruksi ecall memanggil layanan mesin dengan nomor layanan pada a7, yaitu layanan 1 untuk mencetak bilangan bertanda pada a0 dan layanan 10 untuk mengakhiri program.

Inti mesinnya adalah tabel decode yang menerjemahkan mnemonic, yaitu singkatan nama instruksi seperti addi, menjadi fungsi eksekusinya. Pada Mesin Mini, tabel itu berupa kamus Python yang memetakan setiap mnemonic ke fungsi penanganannya, sehingga menambah instruksi berarti menambah satu baris kamus dan satu fungsi. Program uji ditulis sebagai daftar string assembly di dalam kode, tanpa berkas masukan, supaya pengujian bersifat deterministik dan dapat diulang siapa pun dengan keluaran yang persis sama.

python emulator.py
#!/usr/bin/env python3
"""emulator.py — Mesin Mini, emulator 10 instruksi untuk proyek arsitektur.

Mesin Mini adalah prosesor fiktif yang sengaja dibuat kecil supaya seluruh
perilakunya dapat dipahami dan diuji: 32 register 32 bit, 256 kata memori,
dan sepuluh instruksi. Program ditulis sebagai daftar string assembly di
dalam kode Python, satu string per instruksi, tanpa berkas masukan.

Pemakaian sebagai pustaka:
    from emulator import Mesin
    Mesin(["addi a0, zero, 7", "halt"]).jalankan()
"""

MASK32 = 0xFFFFFFFF

# Nama lain register: selain x0-x31, beberapa register punya nama peran,
# mengikuti kebiasaan RISC-V (ra = x1, sp = x2, a0-a7 = x10-x17).
ALIAS = {
    "zero": 0, "ra": 1, "sp": 2,
    "a0": 10, "a1": 11, "a2": 12, "a3": 13, "a4": 14, "a5": 15, "a6": 16, "a7": 17,
}


def ke_int32(nilai):
    """Bungkus bilangan menjadi bilangan bertanda 32 bit (komplemen dua)."""
    nilai &= MASK32
    if nilai >= 0x80000000:
        nilai -= 0x100000000
    return nilai


def nomor_register(nama):
    """Terjemahkan nama register (x5, a0, sp) menjadi nomornya 0-31."""
    nama = nama.strip()
    if nama in ALIAS:
        return ALIAS[nama]
    if nama.startswith("x") and nama[1:].isdigit():
        nomor = int(nama[1:])
        if 0 <= nomor <= 31:
            return nomor
    raise ValueError(f"register tidak dikenal: {nama}")


def siapkan_program(program):
    """Lepaskan label dari daftar instruksi dan catat alamat tiap label.

    Label ditulis di awal baris dan diakhiri titik dua, misalnya
    "ulang: addi a0, a0, 1". Alamat sebuah label adalah nomor instruksi
    yang mengikutinya. Label di akhir program menunjuk ke satu alamat
    setelah instruksi terakhir, sehingga lompatan ke sana mengakhiri
    eksekusi.
    """
    instruksi = []
    label = {}
    for baris in program:
        baris = baris.strip()
        while ":" in baris:
            nama, sisa = baris.split(":", 1)
            label[nama.strip()] = len(instruksi)
            baris = sisa.strip()
        if baris:
            instruksi.append(baris)
    return instruksi, label


def eksekusi_addi(mesin, ops):
    """addi rd, rs, imm : rd = rs + imm."""
    rd, rs, imm = ops
    mesin.tulis(rd, mesin.baca(rs) + int(imm))


def eksekusi_add(mesin, ops):
    """add rd, rs, rt : rd = rs + rt."""
    rd, rs, rt = ops
    mesin.tulis(rd, mesin.baca(rs) + mesin.baca(rt))


def eksekusi_sub(mesin, ops):
    """sub rd, rs, rt : rd = rs - rt."""
    rd, rs, rt = ops
    mesin.tulis(rd, mesin.baca(rs) - mesin.baca(rt))


def eksekusi_lw(mesin, ops):
    """lw rd, off(rs) : rd = memori[rs + off]."""
    rd, token = ops
    mesin.tulis(rd, mesin.mem[mesin.alamat_memori(token)])


def eksekusi_sw(mesin, ops):
    """sw rs, off(rs1) : memori[rs1 + off] = rs."""
    rs, token = ops
    mesin.mem[mesin.alamat_memori(token)] = mesin.baca(rs)


def eksekusi_beq(mesin, ops):
    """beq rs, rt, target : lompat bila rs == rt."""
    rs, rt, target = ops
    if mesin.baca(rs) == mesin.baca(rt):
        mesin.pc = mesin.alamat_lompatan(target)


def eksekusi_bne(mesin, ops):
    """bne rs, rt, target : lompat bila rs != rt."""
    rs, rt, target = ops
    if mesin.baca(rs) != mesin.baca(rt):
        mesin.pc = mesin.alamat_lompatan(target)


def eksekusi_j(mesin, ops):
    """j target : lompat tanpa syarat."""
    mesin.pc = mesin.alamat_lompatan(ops[0])


def eksekusi_ecall(mesin, ops):
    """ecall : layanan sistem, nomor layanannya berada pada a7."""
    layanan = mesin.baca("a7")
    if layanan == 1:
        print(mesin.baca("a0"))   # layanan 1: cetak bilangan bertanda pada a0
    elif layanan == 10:
        mesin.berjalan = False    # layanan 10: akhiri program
    else:
        raise ValueError(f"layanan ecall tidak dikenal: {layanan}")


def eksekusi_halt(mesin, ops):
    """halt : hentikan eksekusi."""
    mesin.berjalan = False


# Tabel decode: setiap mnemonic dipetakan langsung ke fungsi eksekusinya.
DECODE = {
    "addi": eksekusi_addi,
    "add": eksekusi_add,
    "sub": eksekusi_sub,
    "lw": eksekusi_lw,
    "sw": eksekusi_sw,
    "beq": eksekusi_beq,
    "bne": eksekusi_bne,
    "j": eksekusi_j,
    "ecall": eksekusi_ecall,
    "halt": eksekusi_halt,
}


class Mesin:
    """Mesin Mini: 32 register, 256 kata memori, dan satu program assembly."""

    def __init__(self, program, batas_langkah=1000000):
        instruksi, label = siapkan_program(program)
        self.program = instruksi
        self.label = label
        self.regs = [0] * 32
        self.mem = [0] * 256
        self.pc = 0
        self.berjalan = True
        self.batas_langkah = batas_langkah

    # ---------- akses state ----------

    def baca(self, nama):
        """Baca nilai register menurut namanya."""
        return self.regs[nomor_register(nama)]

    def tulis(self, nama, nilai):
        """Tulis nilai ke register dengan pembungkusan 32 bit; x0 selalu nol."""
        nomor = nomor_register(nama)
        if nomor != 0:
            self.regs[nomor] = ke_int32(nilai)

    def alamat_memori(self, token):
        """Uraikan operand offset(register) menjadi alamat memori."""
        offset, reg = token.split("(", 1)
        alamat = self.baca(reg.rstrip(")")) + int(offset)
        if not 0 <= alamat < len(self.mem):
            raise IndexError(f"alamat memori di luar jangkauan: {alamat}")
        return alamat

    def alamat_lompatan(self, token):
        """Terjemahkan label menjadi alamat, atau terima angka alamat langsung."""
        token = token.strip()
        if token in self.label:
            return self.label[token]
        return int(token)

    # ---------- siklus mesin ----------

    def langkah(self):
        """Eksekusi satu instruksi pada pc."""
        if not 0 <= self.pc < len(self.program):
            self.berjalan = False   # pc keluar dari program: berhenti
            return
        baris = self.program[self.pc]
        self.pc += 1
        bagian = baris.replace(",", " ").split()
        mnemonik, operand = bagian[0], bagian[1:]
        if mnemonik not in DECODE:
            raise ValueError(f"instruksi tidak dikenal: {mnemonik}")
        DECODE[mnemonik](self, operand)

    def jalankan(self):
        """Jalankan program sampai berhenti atau batas langkah terlampaui."""
        hitung = 0
        while self.berjalan:
            self.langkah()
            hitung += 1
            if hitung > self.batas_langkah:
                raise RuntimeError("batas langkah terlampaui, periksa perulangan tak berujung")
        return self

Dua perilaku pada kode di atas dibuat tegas dengan sengaja. Penulisan ke zero diabaikan, mengikuti kebiasaan RISC-V, sehingga program yang keliru menimpanya tidak merusak keadaan mesin. Lompatan ke label yang berada di ujung program dan pc yang keluar dari batas program sama-sama mengakhiri eksekusi, sedangkan batas langkah menahan perulangan yang tak berujung agar pengujian tidak pernah menggantung.

Empat tingkat kerangka uji#

Kerangka uji disusun bertingkat menurut ketergantungan fitur, bukan menurut urutan penulisan kode. Tingkat pertama menguji aritmetika murni, yaitu addi, add, dan sub, tanpa memori dan tanpa lompatan. Tingkat kedua menambahkan jalur data memori melalui instruksi load/store, yaitu lw dan sw, termasuk beberapa alamat sekaligus dan offset negatif. Tingkat ketiga menyalakan kendali aliran lewat beq, bne, dan j, lengkap dengan cabang maju, cabang mundur, dan perulangan. Tingkat keempat menuntut disiplin fungsi dan stack, yaitu area memori yang dikelola lewat sp, dengan argumen dan hasil disimpan di sekitar badan rutin.

Urutan ini bukan formalitas. Aritmetika yang keliru merusak hasil perulangan, dan perulangan yang keliru merusak rutin yang memakai tumpukan, sehingga satu tingkat hanya layak diberi beban setelah tingkat di bawahnya lulus. Tabel berikut merangkum kerangka itu beserta program ujinya dan tanda lulusnya.

TahapFitur yang diujiProgram ujiTanda lulus
1Aritmetika murnihitung 7 + 5, lalu 5 - 912 dan -4 tercetak melalui ecall
2Load dan storesimpan dua nilai di alamat berbeda, ambil kembalikedua nilai kembali utuh
3Cabang dan perulanganjumlahkan 1 sampai 5 dengan bne mundurjumlah akhir 15
4Fungsi dan stackdorong argumen, rutin menggandakan, ambil hasilhasil 14 dan sp kembali semula

Titik pemeriksaan pertama#

Kerangka di atas kini diuji pada titik pemeriksaan pertama, yang mencakup tingkat 1 dan tingkat 2 sekaligus karena keduanya sudah tersedia pada Mesin Mini. Program uji bernama uji-titik-1.py menghitung tiga nilai yang dicetak melalui ecall, lalu seluruh cetakan ditangkap dan dibandingkan dengan daftar harapan bernama HARAPAN. Kecocokan penuh mencetak baris keputusan LULUS, sedangkan kegagalan mencetak daftar harapannya dan mengakhiri skrip dengan kode keluar 1.

Kode keluar dan pencarian modul layak diperhatikan. Dua baris sys.path.insert mendaftarkan folder berkas ini dan folder kerja sebagai tempat pencarian modul, sehingga from emulator import Mesin berhasil baik saat skrip dijalankan dari folder topik maupun dari salinannya. Perintahnya satu baris saja, yaitu python3 uji-titik-1.py.

python uji-titik-1.py
#!/usr/bin/env python3
"""uji-titik-1.py — titik pemeriksaan pertama Mesin Mini.

Tahap ini menguji aritmetika dan load/store. Program uji didefinisikan
eksplisit sebagai daftar instruksi, mesin dijalankan, lalu seluruh bilangan
yang dicetak lewat ecall dibandingkan dengan daftar harapan.
"""

import io
import sys
from contextlib import redirect_stdout
from pathlib import Path

# emulator.py berada di samping berkas ini, atau di direktori kerja saat
# skrip dijalankan dari salinannya
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path.cwd()))

from emulator import Mesin

# Program uji tahap 1: aritmetika, memori, dan bilangan negatif.
PROGRAM = [
    "mulai: addi a7, zero, 1",  # layanan 1: cetak bilangan pada a0
    "addi   a0, zero, 7",       # a0 = 7
    "addi   a1, zero, 5",       # a1 = 5
    "add    a0, a0, a1",        # a0 = 7 + 5 = 12
    "ecall",                    # cetak 12
    "addi   sp, zero, 100",     # tunjuk alamat kerja data di memori
    "sw     a0, 0(sp)",         # simpan 12 ke memori
    "lw     a1, 0(sp)",         # baca kembali 12 dari memori
    "addi   a2, zero, 30",
    "add    a0, a1, a2",        # 12 + 30 = 42
    "ecall",                    # cetak 42
    "addi   a3, zero, 9",
    "addi   a4, zero, 5",
    "sub    a0, a4, a3",        # 5 - 9 = -4
    "ecall",                    # cetak -4
    "halt",
]

HARAPAN = [12, 42, -4]


def main():
    penyangga = io.StringIO()
    with redirect_stdout(penyangga):
        Mesin(PROGRAM).jalankan()
    cetakan = [int(baris) for baris in penyangga.getvalue().split()]
    for nilai in cetakan:
        print(nilai)
    if cetakan == HARAPAN:
        print("TITIK PEMERIKSAAN 1 LULUS")
        return 0
    print(f"TITIK PEMERIKSAAN 1 GAGAL, harapan {HARAPAN}")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
text Keluaran
12
42
-4
TITIK PEMERIKSAAN 1 LULUS

Keluaran itu adalah gerbangnya. Selama baris keputusan belum menyatakan LULUS, tahap berikutnya tidak boleh dimulai, dan setiap perubahan pada emulator.py mewajibkan eksekusi ulang berkas ini. Kebiasaan kecil tersebut menjaga setiap tahap berikutnya berdiri di atas dasar yang sudah terbukti.

Kesalahan umum#

  • Maju tanpa menunggu lulus. Penyebabnya lazimnya tenggat waktu, dan akibatnya wilayah pencarian error melebar ke seluruh kode yang belum teruji. Perlakukan baris keputusan LULUS sebagai syarat mutlak sebelum menulis fitur berikutnya.
  • Mengandalkan mata saja. Keluaran yang dilihat tanpa pembanding otomatis membuat nilai yang hampir benar lolos tanpa sengaja. Simpan daftar harapan seperti HARAPAN dan bandingkan seluruh cetakan secara mekanis.
  • Memperbaiki dua fitur sekaligus. Apabila dua perubahan masuk sebelum uji diulang, sumber regression tidak dapat ditunjuk. Terapkan satu perubahan, jalankan ulang seluruh titik pemeriksaan, baru melanjutkan.

Ringkasan#

  • Titik pemeriksaan adalah program nyata yang wajib lulus pada kode terkini sebelum tahap berikutnya dimulai, dan seluruh titik lama diulang setiap perubahan untuk menangkap regression.
  • Kerangka uji bertingkat mengikuti ketergantungan fitur: aritmetika murni, load/store, cabang dan perulangan, lalu fungsi dan stack.
  • Mesin Mini berisi sepuluh instruksi dengan tabel decode berupa kamus Python, dan program uji didefinisikan eksplisit di dalam kode agar keluarannya deterministik.
  • Keluaran ecall ditangkap, dibandingkan dengan daftar harapan, lalu ditutup dengan baris keputusan LULUS atau GAGAL beserta kode keluar.

Referensi#