Membuat mesin arcade emulator. Bagian 3

gambar

Bagian satu dan dua .

8080 prosesor emulator


Shell emulator


Anda sekarang harus memiliki semua pengetahuan yang diperlukan untuk mulai membuat emulator prosesor 8080.

Saya akan mencoba membuat kode saya sejelas mungkin, setiap opcode diimplementasikan secara terpisah. Ketika Anda merasa nyaman dengan itu, Anda mungkin ingin menulis ulang untuk mengoptimalkan kinerja atau menggunakan kembali kode.

Untuk memulainya, saya akan membuat struktur memori yang akan berisi bidang untuk semua yang tampaknya perlu bagi saya ketika menulis disassembler. Juga akan ada tempat untuk buffer memori, yang akan menjadi RAM.

typedef struct ConditionCodes { uint8_t z:1; uint8_t s:1; uint8_t p:1; uint8_t cy:1; uint8_t ac:1; uint8_t pad:3; } ConditionCodes; typedef struct State8080 { uint8_t a; uint8_t b; uint8_t c; uint8_t d; uint8_t e; uint8_t h; uint8_t l; uint16_t sp; uint16_t pc; uint8_t *memory; struct ConditionCodes cc; uint8_t int_enable; } State8080; 

Sekarang buat prosedur dengan panggilan kesalahan yang akan mengakhiri program dengan kesalahan. Akan terlihat seperti ini:

  void UnimplementedInstruction(State8080* state) { // pc    ,     printf ("Error: Unimplemented instruction\n"); exit(1); } int Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; switch(*opcode) { case 0x00: UnimplementedInstruction(state); break; case 0x01: UnimplementedInstruction(state); break; case 0x02: UnimplementedInstruction(state); break; case 0x03: UnimplementedInstruction(state); break; case 0x04: UnimplementedInstruction(state); break; /*....*/ case 0xfe: UnimplementedInstruction(state); break; case 0xff: UnimplementedInstruction(state); break; } state->pc+=1; //  } 

Mari kita implementasikan beberapa opcodes.

  void Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; switch(*opcode) { case 0x00: break; //NOP -  ! case 0x01: //LXI B, state->c = opcode[1]; state->b = opcode[2]; state->pc += 2; //   2  break; /*....*/ case 0x41: state->b = state->c; break; //MOV B,C case 0x42: state->b = state->d; break; //MOV B,D case 0x43: state->b = state->e; break; //MOV B,E } state->pc+=1; } 

Itu dia. Untuk setiap opcode, kami mengubah status dan memori, seperti yang dilakukan perintah pada 8080 yang sebenarnya.

8080 memiliki sekitar 7 jenis, tergantung bagaimana Anda mengklasifikasikannya:

  • Transfer data
  • Aritmatika
  • Logis
  • Cabang
  • Tumpukan
  • Input-output
  • Spesial

Mari kita lihat masing-masing secara individual.

Grup aritmatika


Instruksi aritmatika banyak dari 256 opcode dari prosesor 8080, yang mencakup berbagai macam penjumlahan dan pengurangan. Sebagian besar instruksi aritmatika bekerja dengan register A dan menyimpan hasilnya dalam A. (Register A juga disebut akumulator).

Sangat menarik untuk dicatat bahwa perintah-perintah ini mempengaruhi kode kondisi. Kode negara (juga disebut bendera) diatur tergantung pada hasil dari perintah yang dieksekusi. Tidak semua perintah memengaruhi flag, dan tidak semua tim yang memengaruhi flag memengaruhi semua flag sekaligus.

Bendera 8080


Dalam prosesor 8080, bendera disebut Z, S, P, CY, dan AC.

  • Z (nol, nol) mengambil nilai 1 saat hasilnya nol
  • S (tanda) mengambil nilai 1 ketika bit 7 (bit paling signifikan, bit paling signifikan, MSB) dari perintah matematika diberikan
  • P (parity, parity) diatur ketika hasilnya genap, dan diatur ulang ketika ganjil
  • CY (carry) mengambil nilai 1 ketika, sebagai hasil dari perintah, transfer atau pinjam ke bit orde tinggi dilakukan
  • AC (bantu bantu) terutama digunakan untuk matematika BCD (desimal berkode biner). Untuk detail lebih lanjut, lihat manual, di Space Invaders flag ini tidak digunakan.

Kode negara digunakan dalam perintah cabang bersyarat, misalnya, JZ melakukan percabangan hanya jika bendera Z diatur.

Sebagian besar instruksi memiliki tiga bentuk: untuk register, untuk nilai langsung, dan untuk memori. Mari kita terapkan beberapa instruksi untuk memahami formulir mereka dan melihat seperti apa bentuk kode negara. (Perhatikan bahwa saya tidak mengimplementasikan flag transfer tambahan karena tidak digunakan. Jika saya mengimplementasikannya, saya tidak bisa mengujinya.)

Formulir Pendaftaran


Berikut adalah contoh implementasi dari dua instruksi dengan formulir register; di yang pertama, saya menggunakan kode untuk membuatnya lebih mudah dipahami, dan yang kedua, bentuk yang lebih ringkas yang melakukan hal yang sama disajikan.

  case 0x80: //ADD B { //      , //      uint16_t answer = (uint16_t) state->a + (uint16_t) state->b; //  :    , //    , //      if ((answer & 0xff) == 0) state->cc.z = 1; else state->cc.z = 0; //  :   7 , //    , //      if (answer & 0x80) state->cc.s = 1; else state->cc.s = 0; //   if (answer > 0xff) state->cc.cy = 1; else state->cc.cy = 0; //    state->cc.p = Parity( answer & 0xff); state->a = answer & 0xff; } //  ADD     case 0x81: //ADD C { uint16_t answer = (uint16_t) state->a + (uint16_t) state->c; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Saya meniru perintah matematika 8-bit dengan angka 16-bit. Ini membuatnya lebih mudah untuk melacak kasus di mana perhitungan menghasilkan carry.

Formulir untuk nilai langsung


Bentuk untuk nilai langsung hampir sama, kecuali bahwa byte setelah perintah adalah sumber yang ditambahkan. Karena "opcode" adalah penunjuk ke perintah saat ini dalam memori, opcode [1] akan langsung menjadi byte berikutnya.

  case 0xC6: //ADI  { uint16_t answer = (uint16_t) state->a + (uint16_t) opcode[1]; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Bentuk untuk memori


Dalam bentuk untuk memori, byte akan ditambahkan ke mana alamat yang disimpan dalam sepasang register HL menunjukkan.

  case 0x86: //ADD M { uint16_t offset = (state->h<<8) | (state->l); uint16_t answer = (uint16_t) state->a + state->memory[offset]; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Catatan


Instruksi aritmatika yang tersisa diimplementasikan dengan cara yang serupa. Tambahan:

  • Dalam versi yang berbeda dengan carry (ADC, ACI, SBB, SUI), sesuai dengan manual referensi, kami menggunakan bit carry dalam perhitungan.
  • INX dan DCX mempengaruhi pasangan register, perintah ini tidak mempengaruhi flag.
  • AYAH adalah perintah lain dari sepasang register, hanya memengaruhi flag carry
  • INR dan DCR tidak mempengaruhi flag carry

Grup cabang


Setelah Anda berurusan dengan kode negara, grup cabang akan menjadi cukup jelas untuk Anda. Ada dua jenis percabangan - transisi (JMP) dan panggilan (PANGGILAN). JMP hanya mengatur PC ke nilai tujuan lompat. PANGGILAN digunakan untuk rutinitas, ia menulis alamat pengirim ke tumpukan, dan kemudian menetapkan alamat tujuan PC. RET kembali dari CALL, menerima alamat dari tumpukan dan menuliskannya ke PC.

Baik JMP dan CALL hanya pergi ke alamat absolut yang dikodekan dalam byte setelah opcode.

Jmp


Perintah JMP bercabang tanpa syarat ke alamat tujuan. Ada juga perintah cabang bersyarat untuk semua kode status (kecuali untuk AC):

  • JNZ dan JZ untuk nol
  • JNC dan JC untuk migrasi
  • JPO dan JPE untuk paritas
  • JP (plus) dan JM (minus) untuk tanda

Berikut ini adalah implementasi dari beberapa di antaranya:

  case 0xc2: //JNZ  if (0 == state->cc.z) state->pc = (opcode[2] << 8) | opcode[1]; else //    state->pc += 2; break; case 0xc3: //JMP  state->pc = (opcode[2] << 8) | opcode[1]; break; 

PANGGILAN dan RET


PANGGILAN mendorong alamat instruksi ke tumpukan setelah panggilan, dan kemudian melompat ke alamat tujuan. RET menerima alamat dari tumpukan dan menyimpannya di PC. Versi bersyarat dari CALL dan RET ada untuk semua negara.

  • CZ, CNZ, RZ, RNZ untuk nol
  • CNC, CC, RNC, RC untuk transfer
  • CPO, CPE, RPO, RPE untuk paritas
  • CP, CM, RP, RM untuk tanda

  case 0xcd: //CALL  { uint16_t ret = state->pc+2; state->memory[state->sp-1] = (ret >> 8) & 0xff; state->memory[state->sp-2] = (ret & 0xff); state->sp = state->sp - 2; state->pc = (opcode[2] << 8) | opcode[1]; } break; case 0xc9: //RET state->pc = state->memory[state->sp] | (state->memory[state->sp+1] << 8); state->sp += 2; break; 

Catatan


  • Perintah PCHL tanpa syarat melompat ke alamat di sepasang register HL.
  • Saya tidak memasukkan RST yang sebelumnya dibahas dalam grup ini. Itu menulis alamat kembali ke tumpukan, dan kemudian melompat ke alamat yang telah ditentukan di bagian bawah memori.

Grup yang logis


Grup ini melakukan operasi logis (lihat posting pertama tutorial). Secara alami, mereka mirip dengan grup aritmatika di mana sebagian besar operasi bekerja dengan register A (drive), dan sebagian besar operasi mempengaruhi flag. Semua operasi dilakukan pada nilai 8-bit, dalam grup ini tidak ada perintah yang mempengaruhi pasangan register.

Operasi Boolean


DAN, ATAU, TIDAK (CMP) dan "eksklusif atau" (XOR) disebut operasi Boolean. ATAU dan DAN saya jelaskan sebelumnya. Perintah NOT (untuk prosesor 8080 itu disebut CMA, atau akumulator komplemen) hanya mengubah nilai bit - semua unit menjadi nol, dan nol menjadi yang.

Saya menganggap XOR sebagai "pengenal perbedaan". Tabel kebenarannya terlihat seperti ini:

xyHasil
000
011
101
110

AND, OR, dan XOR memiliki formulir untuk register, memori, dan nilai langsung. (CMP hanya memiliki perintah case-sensitive). Berikut ini adalah implementasi dari sepasang opcode:

  case 0x2F: //CMA (not) state->a = ~state->a //  ,  CMA     break; case 0xe6: //ANI  { uint8_t x = state->a & opcode[1]; state->cc.z = (x == 0); state->cc.s = (0x80 == (x & 0x80)); state->cc.p = parity(x, 8); state->cc.cy = 0; //  ,  ANI  CY state->a = x; state->pc++; //   } break; 

Perintah shift siklik


Perintah-perintah ini mengubah urutan bit dalam register. Pergeseran ke kanan menggerakkan mereka sedikit demi sedikit, dan pergeseran ke kiri - kiri sedikit:

(0b00010000) = 0b00001000

(0b00000001) = 0b00000010

Mereka tampaknya tidak berharga, tetapi kenyataannya tidak demikian. Mereka dapat digunakan untuk berkembang biak dan membelah dengan kekuatan dua. Ambil shift kiri sebagai contoh. 0b00000001 adalah desimal 1, dan menggesernya ke kiri menjadikannya 0b00000010 , yaitu, desimal 2. Jika kita melakukan shift lain ke kiri, kita mendapatkan 0b00000100 , yaitu 4. Pergeseran lain ke kiri, dan kita dikalikan dengan 8. Ini akan bekerja dengan semua dengan angka: 5 ( 0b00000101 ) ketika bergeser ke kiri memberi 10 ( 0b00001010 ). Pergeseran kiri lain memberi 20 ( 0b00010100 ). Pergeseran ke kanan melakukan hal yang sama, tetapi untuk pembagian.

8080 tidak memiliki perintah multiplikasi, tetapi dapat diimplementasikan menggunakan perintah ini. Jika Anda mengerti bagaimana melakukan ini, Anda akan menerima poin bonus. Suatu kali pertanyaan semacam itu diajukan kepada saya pada sebuah wawancara. (Ya, meskipun butuh beberapa menit.)

Perintah-perintah ini memutar drive secara siklis dan hanya memengaruhi flag carry. Berikut ini beberapa perintah:

  case 0x0f: //RRC { uint8_t x = state->a; state->a = ((x & 1) << 7) | (x >> 1); state->cc.cy = (1 == (x&1)); } break; case 0x1f: //RAR { uint8_t x = state->a; state->a = (state->cc.cy << 7) | (x >> 1); state->cc.cy = (1 == (x&1)); } break; 

Perbandingan


Tugas CMP dan CPI hanya mengatur bendera (untuk percabangan). Mereka melakukan ini dengan mengurangi flag, tetapi tidak menyimpan hasilnya.

  • Sama: jika dua angka sama, maka bendera Z diatur, karena pengurangan satu sama lain memberi nol.
  • Lebih besar dari: jika A lebih besar dari nilai yang dibandingkan, maka bendera CY dihapus (karena pengurangan dapat dilakukan tanpa meminjam).
  • Lebih kecil: jika A kurang dari nilai yang dibandingkan, maka flag CY diatur (karena A harus menyelesaikan pinjaman untuk menyelesaikan pengurangan).

Ada versi perintah ini untuk register, memori, dan nilai langsung. Implementasinya adalah pengurangan sederhana tanpa menyimpan hasilnya:

  case 0xfe: //CPI  { uint8_t x = state->a - opcode[1]; state->cc.z = (x == 0); state->cc.s = (0x80 == (x & 0x80)); //  ,    p -   state->cc.p = parity(x, 8); state->cc.cy = (state->a < opcode[1]); state->pc++; } break; 

CMC dan STC


Mereka melengkapi grup logis. Mereka digunakan untuk mengatur dan menghapus bendera carry.

Kelompok input-output dan perintah khusus


Perintah-perintah ini tidak dapat ditugaskan ke kategori lain mana pun. Saya akan menyebut mereka untuk kelengkapan, tetapi bagi saya tampaknya kita harus kembali kepada mereka lagi ketika kita mulai meniru perangkat keras Space Invaders.

  • EI dan DI mengaktifkan atau menonaktifkan kemampuan prosesor untuk menangani interupsi. Saya menambahkan flag interrupt_enabled ke struktur status prosesor, dan mengatur / mengatur ulang menggunakan perintah ini.
  • Tampaknya RIM dan SIM terutama digunakan untuk serial I / O. Jika Anda tertarik, Anda dapat membaca manual, tetapi perintah ini tidak digunakan di Space Invaders. Saya tidak akan meniru mereka.
  • HLT berhenti. Saya pikir kami tidak perlu meniru, tetapi Anda dapat memanggil kode Anda yang keluar (atau keluar (0)) ketika Anda melihat perintah ini.
  • IN dan OUT adalah perintah yang digunakan peralatan prosesor 8080 untuk berkomunikasi dengan peralatan eksternal. Saat kami mengimplementasikannya, tetapi mereka tidak akan melakukan apa pun selain melewatkan byte data mereka. (Nanti kita akan kembali ke mereka).
  • NOP adalah "tidak ada operasi". Salah satu aplikasi NOP adalah untuk mengontrol waktu panel (dibutuhkan empat siklus CPU untuk menjalankan).

Aplikasi NOP lainnya adalah modifikasi kode. Katakanlah kita perlu mengubah kode ROM game. Kami tidak bisa hanya menghapus opcodes yang tidak perlu, karena kami tidak ingin mengubah semua perintah CALL dan JMP (mereka akan salah jika setidaknya satu bagian dari kode bergerak). Dengan NOP kita dapat menyingkirkan kode tersebut. Menambahkan kode jauh lebih sulit! Anda dapat menambahkannya dengan menemukan ruang di suatu tempat di ROM dan mengubah perintah ke JMP.

Kelompok tumpukan


Kami telah menyelesaikan mekanisme untuk sebagian besar tim dalam kelompok tumpukan. Jika Anda melakukan pekerjaan dengan saya, maka perintah ini akan mudah diimplementasikan.

PUSH dan POP


PUSH dan POP hanya berfungsi dengan pasangan register. PUSH menulis sepasang register ke stack, dan POP mengambil 2 byte dari atas stack dan menulisnya ke sepasang register.

Ada empat opcode untuk PUSH dan POP, satu untuk masing-masing pasangan: BC, DE, HL dan PSW. PSW adalah sepasang register bendera drive dan kode status khusus. Berikut ini adalah implementasi PUSH dan POP untuk BC dan PSW. Tidak ada komentar di dalamnya - Saya tidak berpikir ada sesuatu yang sangat rumit di sini.

  case 0xc1: //POP B { state->c = state->memory[state->sp]; state->b = state->memory[state->sp+1]; state->sp += 2; } break; case 0xc5: //PUSH B { state->memory[state->sp-1] = state->b; state->memory[state->sp-2] = state->c; state->sp = state->sp - 2; } break; case 0xf1: //POP PSW { state->a = state->memory[state->sp+1]; uint8_t psw = state->memory[state->sp]; state->cc.z = (0x01 == (psw & 0x01)); state->cc.s = (0x02 == (psw & 0x02)); state->cc.p = (0x04 == (psw & 0x04)); state->cc.cy = (0x05 == (psw & 0x08)); state->cc.ac = (0x10 == (psw & 0x10)); state->sp += 2; } break; case 0xf5: //PUSH PSW { state->memory[state->sp-1] = state->a; uint8_t psw = (state->cc.z | state->cc.s << 1 | state->cc.p << 2 | state->cc.cy << 3 | state->cc.ac << 4 ); state->memory[state->sp-2] = psw; state->sp = state->sp - 2; } break; 

SPHL dan XTHL


Ada dua tim lagi dalam kelompok tumpukan - SPHL dan XTHL.

  • SPHL memindahkan HL ke SP (memaksa SP untuk mendapatkan alamat baru).
  • XTHL menukar apa yang ada di atas tumpukan dengan apa yang ada di sepasang register HL. Mengapa Anda perlu melakukan ini? Saya tidak tahu.

Sedikit lagi tentang angka biner


Saat menulis program komputer, salah satu keputusan yang perlu Anda buat adalah memilih jenis data yang digunakan untuk angka - apakah Anda ingin mereka menjadi negatif dan berapa ukuran maksimumnya. Untuk emulator CPU, kita membutuhkan tipe data yang cocok dengan tipe data CPU target.

Ditandatangani dan tidak ditandatangani


Ketika kami mulai berbicara tentang bilangan hex, kami menganggapnya tidak bertanda - yaitu, setiap digit biner dari bilangan heksadesimal memiliki nilai positif, dan masing-masing dianggap sebagai kekuatan dua (unit, dua, empat, dll.).

Kami berurusan dengan masalah penyimpanan komputer dari angka negatif. Jika Anda tahu bahwa data yang dimaksud memiliki tanda, yaitu, mereka bisa negatif, maka Anda dapat mengenali angka negatif dengan bit paling signifikan dari nomor tersebut (bit paling signifikan, MSB). Jika ukuran data adalah satu byte, maka setiap angka dengan nilai bit MSB yang diberikan adalah negatif, dan masing-masing dengan nol MSB adalah positif.

Nilai angka negatif disimpan sebagai kode tambahan. Jika kami memiliki nomor yang ditandatangani, dan MSB sama dengan satu, dan kami ingin mengetahui apa nomor ini, maka kami dapat mengonversinya sebagai berikut: melakukan biner "TIDAK" untuk angka hex, dan kemudian menambahkannya.

Misalnya, untuk bilangan hex 0x80, bit MSB diatur, yaitu negatif. Biner β€œTIDAK” dari angka 0x80 adalah 0x7f, atau desimal 127. 127 + 1 = 128. Artinya, 0x80 dalam desimal adalah -128. Contoh kedua: 0xC5. Tidak (0xC5) = 0x3A = desimal 58 +1 = desimal 59. Artinya, 0xC5 adalah desimal -59.

Yang mengejutkan dalam angka-angka dengan kode tambahan adalah kita dapat melakukan perhitungan dengan angka-angka itu seperti angka-angka yang tidak ditandatangani, dan mereka akan tetap berfungsi . Komputer tidak perlu melakukan sesuatu yang khusus dengan tanda-tanda. Saya akan menunjukkan beberapa contoh yang membuktikan hal ini.

  Contoh 1

      biner hex desimal    
       -3 0xFD 1111 1101    
    + 10 0x0A +0000 1010    
    ----- -----------    
        7 0x07 1 0000 0111    
                        ^ Ini direkam dalam carry bit

    Contoh 2    

      biner hex desimal    
      -59 0xC5 1100 0101    
    + 33 0x21 +0010 0001    
    ----- -----------    
      -26 0xE6 1110 0110 


Dalam Contoh 1, kita melihat bahwa menambahkan hasil 10 dan -3 di 7. Hasil tambahan ditransfer, sehingga bendera C. Dapat diatur dalam Contoh 2, hasil penambahan negatif, jadi kami memecahkan kode ini: Tidak (0xE6) = 0x19 = 25 + 1 = 26. 0xE6 = -26 Ledakan otak!

Jika Anda mau, baca lebih lanjut tentang kode tambahan di Wikipedia .

Tipe data


Di C, ada hubungan antara tipe data dan jumlah byte yang digunakan untuk tipe ini. Faktanya, kami hanya tertarik pada bilangan bulat. Tipe data C sekolah standar / lama adalah char, int, dan long, serta teman-teman mereka unsigned char, unsigned int, dan unsigned long. Masalahnya adalah bahwa pada platform yang berbeda dan pada kompiler yang berbeda, tipe ini dapat memiliki ukuran yang berbeda.

Oleh karena itu, yang terbaik adalah memilih tipe data untuk platform kami yang menyatakan ukuran data secara eksplisit. Jika platform Anda memiliki stdint.h, maka Anda dapat menggunakan int8_t, uint8_t, dll.

Ukuran bilangan bulat menentukan jumlah maksimum yang dapat disimpan di dalamnya. Dalam kasus bilangan bulat tak bertanda, Anda dapat menyimpan angka dari 0 hingga 255 dalam 8 bit. Jika Anda menerjemahkan ke dalam hex, maka dari 0x00 hingga 0xFF. Karena 0xFF memiliki "semua bit diatur", dan itu sesuai dengan desimal 255, sangat logis bahwa interval integer unsigned byte tunggal adalah 0-255. Interval memberi tahu kami bahwa semua ukuran bilangan bulat akan bekerja persis sama - angka sesuai dengan angka yang diperoleh saat semua bit diatur.

JenisIntervalHex
8-bit tidak ditandatangani0-2550x0-0xFF
8-bit ditandatangani-128-1270x80-0x7F
16-bit tidak ditandatangani0-655350x0-0xFFFF
Ditandatangani 16-bit-32768-327670x8000-0x7FFF
32-bit tidak ditandatangani0-42949672950x0-0xFFFFFFFFFF
32-bit ditandatangani-2147483648-21474836470x80000000-0x7FFFFFFF

Yang lebih menarik adalah bahwa -1 dalam setiap tipe data yang ditandatangani adalah angka yang memiliki semua bit yang ditetapkan (0xFF untuk byte yang ditandatangani, 0xFFFF untuk nomor 16-bit yang ditandatangani, dan 0xFFFFFFFF untuk jumlah 32-bit yang ditandatangani). Jika data dianggap tidak ditandatangani, maka untuk semua bit yang diberikan, jumlah maksimum yang mungkin untuk tipe data ini diperoleh.

Untuk meniru register prosesor, kami memilih tipe data yang sesuai dengan ukuran register ini. Mungkin bermanfaat untuk memilih jenis yang tidak ditandatangani secara default dan mengonversinya ketika Anda perlu mempertimbangkannya ditandatangani. Misalnya, kami menggunakan tipe data uint8_t untuk mewakili register 8-bit.

Petunjuk: gunakan debugger untuk mengonversi tipe data


Jika gdb diinstal pada platform Anda, maka sangat nyaman untuk menggunakannya untuk bekerja dengan angka biner. Di bawah ini saya akan menunjukkan contoh - dalam sesi yang ditunjukkan di bawah ini, baris yang dimulai dengan # adalah komentar yang saya tambahkan nanti.

# /c, gdb
(gdb) print /c 0xFD
$1 = -3 '?'

# /x, gdb hex
# "p" "print"
(gdb) p /c 0xA
$2 = 10 '\n'

# 2 " "
(gdb) p /c 0xC5
$3 = -59 '?'
(gdb) p /c 0xC5+0x21
$4 = -26 '?'

# print , gdb
(gdb) p 0x21
$9 = 33

# , gdb,
# ,
(gdb) p 0xc5
$5 = 197 #
(gdb) p /c 0xc5
$3 = -59 '?' #
(gdb) p 0xfd
$6 = 253

# ( 32- )
(gdb) p /x -3
$7 = 0xfffffffd

# 1
(gdb) print (char) 0xff
$1 = -1 '?'
# 1
(gdb) print (unsigned char) 0xff
$2 = 255 '?'


Ketika saya bekerja dengan angka hex, saya selalu melakukannya di gdb - dan itu terjadi hampir setiap hari. Jauh lebih mudah daripada membuka kalkulator programmer dengan GUI. Pada mesin Linux (dan Mac OS X), untuk memulai sesi gdb, cukup buka terminal dan masukkan "gdb". Jika Anda menggunakan Xcode pada OS X, maka setelah memulai program, Anda dapat menggunakan konsol di dalam Xcode (yang mana output printf adalah output). Di Windows, debugger gdb tersedia dari Cygwin.

Pemutusan Emulator CPU


Setelah menerima semua informasi ini, Anda siap untuk perjalanan panjang. Anda harus memutuskan bagaimana Anda menerapkan emulator - baik membuat emulasi 8080 penuh, atau hanya menerapkan perintah yang diperlukan untuk menyelesaikan permainan.

Jika Anda memutuskan untuk melakukan emulasi penuh, maka Anda akan memerlukan beberapa alat lagi. Saya akan membicarakannya di bagian selanjutnya.

Cara lain adalah meniru hanya instruksi yang digunakan oleh gim. Kami akan terus mengisi konstruksi sakelar besar yang kami buat di bagian Emulator Shell. Kami akan mengulangi proses berikut ini sampai kami memiliki satu perintah yang belum direalisasi:

  1. Luncurkan emulator dengan ROM Space Invaders
  2. Panggilan UnimplementedInstruction()keluar jika perintah tidak siap
  3. Tirulah instruksi ini
  4. Kebagian 1

Hal pertama yang saya lakukan ketika mulai menulis emulator saya adalah menambahkan kode dari disassembler saya. Jadi saya bisa menampilkan perintah yang harus dijalankan sebagai berikut:

  int Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; Disassemble8080Op(state->memory, state->pc); switch (*opcode) { case 0x00: //NOP /* ... */ } /*    */ printf("\tC=%d,P=%d,S=%d,Z=%d\n", state->cc.cy, state->cc.p, state->cc.s, state->cc.z); printf("\tA $%02x B $%02x C $%02x D $%02x E $%02x H $%02x L $%02x SP %04x\n", state->a, state->b, state->c, state->d, state->e, state->h, state->l, state->sp); } 

Saya juga menambahkan kode di akhir untuk menampilkan semua register dan flag negara.

Berita baik: untuk mempelajari program untuk 50 ribu tim, kita hanya perlu subset dari 8080 opcodes. Aku bahkan akan memberikan daftar opcode yang perlu diimplementasikan:

OpcodeTim
0x00Tidak
0x01LXI B, D16
0x05DCR B
0x06MVI B, D8
0x09Ayah b
0x0dDCR C
0x0eMVI C, D8
0x0fRrc
0x11LXI D, D16
0x13Inx d
0x19Ayah d
0x1aLDAX D
0x21LXI H, D16
0x23Inx h
0x26MVI H, D8
0x29Ayah h
0x31LXI SP, D16
0x32STA adr
0x36MVI M, D8
0x3aLda adr
0x3eMVI A, D8
0x56MOV D, M
0x5eMOV E, M
0x66MOV H, M
0x6fMOV L, A
0x77MOV M, A
0x7aMOV A, D
0x7bMOV A, E
0x7cMOV A, H
0x7eMOV A, M
0xa7ANA A
0xafXRA A
0xc1Pop b
0xc2Jnz adr
0xc3Jmp adr
0xc5PUSH B
0xc6ADI D8
0xc9Ret
0xcdHubungi adr
0xd1Pop d
0xd3OUT D8
0xd5PUSH D
0xe1Pop h
0xe5PUSH H
0xe6ANI D8
0xebXchg
0xf1POP PSW
0xf5PUSH PSW
0xfbEi
0xfeCPI D8

Ini hanya 50 instruksi, dan 10 di antaranya adalah gerakan yang diimplementasikan sepele.

Debugging


Tapi saya punya kabar buruk. Emulator Anda hampir pasti tidak akan berfungsi dengan benar, dan bug dalam kode semacam itu sangat sulit ditemukan. Jika Anda tahu perintah mana yang berperilaku buruk (misalnya, transisi atau panggilan ke kode yang tidak berarti), maka Anda dapat mencoba untuk memperbaiki kesalahan dengan memeriksa kode Anda.

Selain meneliti kode, ada cara lain untuk memperbaiki masalah - dengan membandingkan emulator Anda dengan yang benar-benar berfungsi. Kami berasumsi bahwa emulator lain selalu berfungsi dengan benar, dan semua perbedaannya adalah bug di emulator Anda. Misalnya, Anda dapat menggunakan emulator saya. Anda dapat secara manual menjalankannya secara paralel. Anda dapat menghemat waktu jika Anda mengintegrasikan kode saya ke proyek Anda untuk mendapatkan proses berikut:

  1. Buat status untuk emulator Anda
  2. Buat negara untuk milikku
  3. Untuk tim selanjutnya
  4. Memanggil emulator Anda dengan negara Anda
  5. Memanggil milikku dengan keberuntunganku
  6. Bandingkan dua negara kami
  7. Mencari kesalahan dalam perbedaan apa pun
  8. kebagian 3

Cara lain adalah dengan menggunakan situs ini secara manual . Ini adalah emulator prosesor Javascript 8080 yang bahkan menyertakan ROM Space Invaders. Inilah prosesnya:

  1. Mulai ulang emulasi Space Invaders dengan mengklik tombol Space Invaders
  2. Tekan tombol β€œRun 1” untuk menjalankan perintah.
  3. Kami menjalankan perintah berikut di emulator kami
  4. Bandingkan status prosesor dengan milik Anda
  5. Jika kondisinya sesuai, goto 2
  6. Jika kondisinya tidak cocok, maka emulasi instruksi Anda salah. Perbaiki itu, dan kemudian mulai lagi dari langkah 1.

Saya menggunakan metode ini di awal untuk men-debug emulator 8080 saya. Saya tidak akan berbohong - prosesnya bisa lama. Akibatnya, banyak masalah saya ternyata kesalahan ketik dan salin-tempel, yang setelah deteksi sangat mudah untuk diperbaiki.

Jika Anda selangkah demi selangkah menjalankan kode Anda, maka sebagian besar dari 30 ribu instruksi pertama dieksekusi dalam siklus sekitar $ 1a5f. Jika Anda melihat javascript di emulator , Anda dapat melihat bahwa kode ini menyalin data ke layar. Saya yakin kode ini sering dipanggil.

Setelah rendering layar pertama, setelah 50 ribu perintah, program macet di loop tanpa akhir ini:

  0ada LDA $20c0 0add ANA A 0ade JNZ $0ada 

Ia menunggu hingga nilai dalam memori pada $ 20c0 berubah menjadi nol. Karena kode dalam loop ini tidak benar-benar berubah $ 20c0, itu pasti sinyal dari tempat lain. Sudah waktunya untuk berbicara tentang meniru "besi" dari mesin arcade.

Sebelum kita melanjutkan ke bagian selanjutnya, pastikan emulator CPU Anda termasuk dalam loop tanpa akhir ini.

Untuk referensi, lihat sumber saya .

Emulasi 8080 penuh


Sebuah pelajaran yang sangat merugikan saya: jangan menerapkan tim yang tidak dapat Anda uji. Ini adalah aturan praktis yang baik untuk semua perangkat lunak yang sedang dikembangkan. Jika Anda tidak memeriksa tim, maka itu pasti akan rusak. Dan semakin jauh Anda menjauh dari implementasinya, semakin sulit untuk menemukan masalah.

Ada solusi lain jika Anda ingin membuat emulator 8080 penuh dan memastikannya bekerja. Saya menemukan kode untuk 8080 bernama cpudiag.asm, yang dirancang untuk menguji setiap perintah prosesor 8080.

Saya memperkenalkan Anda pada proses ini setelah yang pertama karena beberapa alasan:

  1. Saya ingin deskripsi proses ini diulang untuk prosesor lain. Saya tidak berpikir bahwa analog cpudiag.asm ada untuk semua prosesor.
  2. Seperti yang Anda lihat, prosesnya cukup melelahkan. Saya pikir seorang pemula dalam debugging kode assembler akan mengalami kesulitan besar jika langkah-langkah ini tidak terdaftar.

Begitulah cara saya menggunakan tes ini dengan emulator saya. Anda dapat menggunakannya atau menghasilkan cara yang lebih baik untuk mengintegrasikannya.

Majelis uji


Saya mencoba beberapa hal, tetapi sebagai hasilnya saya memutuskan untuk menggunakan halaman yang bagus ini . Saya menempelkan teks cpudiag.asm ke panel kiri dan build selesai tanpa masalah. Butuh beberapa saat bagi saya untuk mengetahui cara mengunduh hasil, tetapi dengan mengklik tombol "Make Beautiful Code" di kiri bawah, saya mengunduh file bernama test.bin, yang merupakan kode yang dikompilasi 8080. Saya dapat memverifikasi ini menggunakan disassembler saya.

Unduh cpudiag.asm dari cermin di situs web saya.

Unduh cpudiag.bin (kode kompilasi 8080) dari situs saya.

Mengunggah tes ke emulator saya


Alih-alih memuat penyerang. * File, saya memuat biner ini.

Kesulitan kecil muncul di sini. Pertama, ada baris dalam kode assembler sumber ORG 00100H, yaitu, itu berarti bahwa seluruh file dikompilasi dengan asumsi bahwa baris pertama kode adalah 0x100 hex. Saya belum pernah menulis kode di assembler 8080 sebelumnya, jadi saya tidak tahu apa yang dilakukan baris ini. Saya hanya perlu satu menit untuk mengetahui bahwa semua alamat cabang tidak benar dan perlu untuk memulai memori pada 0x100.

Kedua, sejak emulator saya mulai dari awal, saya harus terlebih dahulu melakukan transisi ke kode sebenarnya. Setelah memasukkan nilai hex ke dalam memori di alamat nol JMP $0100, saya berurusan dengan ini. (Atau Anda bisa menginisialisasi PC dengan nilai 0x100.)

Ketiga, saya menemukan bug dalam kode yang dikompilasi. Saya pikir alasannya adalah pemrosesan yang salah dari baris kode terakhir STACK EQU TEMPP+256, tapi saya tidak yakin. Bagaimanapun, tumpukan selama kompilasi terletak pada $ 6ad, dan beberapa PUSH pertama mulai menulis ulang kode. Saya menyarankan bahwa variabel juga harus diimbangi dengan 0x100, seperti sisa kode, jadi saya memperbaikinya dengan memasukkan "0x7" ke dalam baris kode yang menginisialisasi stack pointer.

Akhirnya, karena saya tidak mengimplementasikan DAA atau migrasi tambahan di emulator saya, saya memodifikasi kode untuk melewati pemeriksaan ini (kami hanya melewatkannya menggunakan JMP).

  ReadFileIntoMemoryAt(state, "/Users/kpmiller/Desktop/invaders/cpudiag.bin", 0x100); //  ,   JMP 0x100 state->memory[0]=0xc3; state->memory[1]=0; state->memory[2]=0x01; //Fix the stack pointer from 0x6ad to 0x7ad // this 0x06 byte 112 in the code, which is // byte 112 + 0x100 = 368 in memory state->memory[368] = 0x7; //  DAA state->memory[0x59c] = 0xc3; //JMP state->memory[0x59d] = 0xc2; state->memory[0x59e] = 0x05; 

Tes ini mencoba membuat kesimpulan


Jelas, tes ini bergantung pada bantuan dari CP / M OS. Saya menemukan bahwa CP / M memiliki beberapa kode pada $ 0005 yang mencetak pesan ke konsol, dan mengubah emulasi PANGGILAN saya untuk menangani perilaku ini. Saya tidak yakin apakah semuanya ternyata benar, tetapi itu berfungsi untuk dua pesan yang coba dicetak oleh program. Emulasi PANGGILAN saya untuk menjalankan tes ini terlihat seperti ini:

  case 0xcd: //CALL  #ifdef FOR_CPUDIAG if (5 == ((opcode[2] << 8) | opcode[1])) { if (state->c == 9) { uint16_t offset = (state->d<<8) | (state->e); char *str = &state->memory[offset+3]; // - while (*str != '$') printf("%c", *str++); printf("\n"); } else if (state->c == 2) { //    ,   ,    printf ("print char routine called\n"); } } else if (0 == ((opcode[2] << 8) | opcode[1])) { exit(0); } else #endif { uint16_t ret = state->pc+2; state->memory[state->sp-1] = (ret >> 8) & 0xff; state->memory[state->sp-2] = (ret & 0xff); state->sp = state->sp - 2; state->pc = (opcode[2] << 8) | opcode[1]; } break; 

Dengan tes ini, saya menemukan beberapa masalah di emulator saya. Saya tidak yakin yang mana dari mereka yang akan terlibat dalam permainan, tetapi jika mereka melakukannya, maka akan sangat sulit untuk menemukan mereka.

Saya melanjutkan dan mengimplementasikan semua opcodes (dengan pengecualian DAA dan teman-temannya). Saya membutuhkan 3-4 jam untuk memperbaiki masalah dalam tantangan saya dan menerapkan yang baru. Itu pasti lebih cepat daripada proses manual yang saya jelaskan di atas - sebelum saya menemukan tes ini, saya menghabiskan lebih dari 4 jam pada proses manual. Jika Anda bisa mengetahui penjelasan ini, maka saya sarankan menggunakan metode ini daripada membandingkan secara manual. Namun, mengetahui proses manual juga merupakan keterampilan yang hebat, dan jika Anda ingin meniru prosesor lain, maka Anda harus kembali ke sana.

Jika Anda tidak dapat melakukan proses ini atau tampaknya terlalu rumit, maka sudah pasti layak untuk memilih pendekatan yang dijelaskan di atas dengan dua emulator berbeda yang berjalan di dalam program Anda. Ketika beberapa juta perintah muncul dalam program dan interupsi ditambahkan, tidak mungkin membandingkan dua emulator secara manual.

Source: https://habr.com/ru/post/id418699/


All Articles