Menulis emulator mesin arcade adalah proyek pendidikan yang hebat, dan dalam tutorial ini kita akan melihat sangat detail pada seluruh proses pengembangan. Ingin benar-benar mendapatkan prosesor Anda? Kemudian membuat emulator adalah cara terbaik untuk mempelajarinya.
Anda akan membutuhkan pengetahuan tentang C, serta pengetahuan assembler. Jika Anda tidak tahu bahasa assembly, maka menulis emulator adalah cara terbaik untuk mempelajarinya. Anda juga perlu menguasai matematika heksadesimal (juga dikenal sebagai basis 16 atau hanya "hex"). Saya akan berbicara tentang topik ini.
Saya memutuskan untuk memilih emulator untuk mesin Space Invaders, yang menggunakan prosesor 8080. Game ini dan prosesor ini sangat populer, karena di Internet Anda dapat menemukan banyak informasi tentang mereka. Anda akan membutuhkannya untuk menyelesaikan proyek.
Seluruh kode sumber tutorial diunggah ke
github . Jika Anda belum menguasai bekerja dengan git, maka pada halaman github ada tombol "Unduh ZIP" yang memungkinkan Anda untuk mengunduh arsip dengan semua kode.
Pengantar angka biner dan heksadesimal
Dalam matematika "biasa", sistem bilangan desimal digunakan. Setiap digit angka dapat memiliki nilai dari nol hingga sembilan, dan ketika kami melebihi 9, kami menambahkan satu ke nomor di digit berikutnya dan mulai lagi dari nol. Ini semua sangat sederhana dan mudah, dan Anda mungkin tidak pernah memikirkannya.
Anda mungkin tahu atau mendengar bahwa komputer bekerja dengan data biner. Geeks komputer memanggil basis-10 desimal matematika, dan basis panggilan biner-2. Dalam notasi biner, setiap digit angka hanya dapat memiliki dua nilai, nol atau satu. Dalam kode biner, penghitungannya adalah sebagai berikut: 0, 1, 10, 11, 100, 101, 110, 111, 1000. Ini bukan angka desimal, jadi Anda tidak dapat memanggilnya "nol, satu, sepuluh, sebelas, seratus, seratus satu". Mereka diucapkan sebagai "nol, satu, satu-nol, satu-satu, satu-nol-nol", dll. Saya jarang membaca angka-angka biner dengan suara keras, tetapi jika perlu, Anda harus menunjukkan dengan jelas sistem angka yang digunakan. Sepuluh, sebelas dan seratus tidak memiliki arti dalam notasi biner.
Dalam notasi desimal, angka memiliki digit berikut: unit, puluhan, ratusan, ribuan, puluhan ribu, dll. Dalam sistem biner, angka-angka berikut: unit, deuces, merangkak, delapan, dll.
Dalam ilmu komputer, nilai setiap bit biner disebut bit. 8 bit membentuk satu byte.Dalam istilah biner, serangkaian angka dengan cepat menjadi sangat panjang. Untuk mewakili angka desimal 20.000 dalam bentuk biner, diperlukan 16 digit: 0b100111000100000. Untuk memperbaiki masalah ini, akan lebih mudah untuk menggunakan sistem angka heksadesimal, juga dikenal sebagai base-16 (atau hex). Di base-16, setiap digit berisi 16 nilai. Untuk nilai dari nol hingga sembilan, karakter yang sama digunakan seperti pada basis-10, tetapi untuk 6 nilai yang tersisa, pergantian digunakan dalam bentuk 6 huruf pertama dari alfabet, dari A hingga F.
Akun dalam sistem heksadesimal dilakukan sebagai berikut: 0 1 2 3 4 5 6 7 8 9 ABCDEF 10 11 12, dll. Dalam heksadesimal, puluhan, ratusan dan seterusnya tidak memiliki arti yang sama seperti dalam desimal, sehingga orang mengucapkan angka secara terpisah. Misalnya, $ A57 diucapkan dengan keras sebagai "A-five-seven." Untuk kejelasan, Anda juga dapat menambahkan hex, misalnya, "A-five-tujuh-hex." Dalam sistem bilangan heksadesimal, setara dengan angka desimal 20.000 adalah $ 4E20 - bentuk yang jauh lebih ringkas dibandingkan dengan 16 bit dari sistem biner.
Saya pikir sistem heksadesimal dipilih karena konversi yang sangat alami dari biner ke heksadesimal dan sebaliknya. Setiap digit hex sesuai dengan 4 bit (4 bit) dari nomor biner yang sama.
2 digit hex terdiri dari satu byte (8 bit). Satu digit heksadesimal dapat disebut nibble, dan beberapa orang bahkan menuliskannya melalui y sebagai βnybbleβ.
Setiap digit hex adalah 4 digit biner |
---|
Hex | A | 5 | 7 |
Biner | 1010 | 0101 | 0111 |
Saat menulis kode C, diyakini bahwa angka tersebut adalah desimal (basis-10), kecuali jika ditandai sebaliknya. Untuk memberi tahu kompiler C bahwa angka tersebut adalah biner, kami menambahkan angka nol dan huruf b dalam huruf kecil, seperti ini:
0b1101101
. Angka heksadesimal dapat ditulis dalam kode C dengan menambahkan di awal nol dan x dalam huruf kecil:
0xA57
. Beberapa bahasa assembly menggunakan tanda dolar $:
$A57
untuk menunjukkan nomor hex.
Jika Anda memikirkannya, hubungan antara angka-angka biner, heksadesimal, dan desimal cukup jelas, tetapi bagi insinyur pertama, yang telah memikirkan hal ini sebelum penemuan komputer, ini seharusnya menjadi momen wawasan.
Mengerti semua ini? Bagus
Pengantar singkat untuk prosesor
Jika sudah mengetahui hal ini, Anda dapat melewati bagian ini dengan aman.Central processing unit (CPU) adalah mesin yang dirancang untuk menjalankan program. Blok dasar CPU adalah register dan instruksi. Sebagai pengembang perangkat lunak, Anda dapat memperlakukan register ini sebagai variabel. Dalam prosesor 8080 kami, di antara register lain, ada register 8-bit yang disebut A, B, C, D, dan E. Register ini dapat diartikan sebagai kode C berikut:
unsigned char A, B, C, D, E;
Semua prosesor juga memiliki program penghitung (Program Counter, PC). Anda bisa menganggapnya sebagai pointer.
unsigned char* pc;
Untuk CPU, program adalah urutan angka heksadesimal. Setiap instruksi bahasa rakitan di 8080 sesuai dengan 1-3 byte dalam program. Untuk mengetahui perintah mana yang sesuai dengan nomor mana, manual prosesor (atau informasi lain tentang prosesor 8080 dari Internet) berguna.
Nama-nama perintah (instruksi) seringkali mnemonik dari operasi yang dilakukan oleh perintah-perintah ini. Mnemonic untuk memuat di 8080 adalah MOV (bergerak), dan ADD digunakan untuk melakukan penambahan.
Contohnya
Nilai memori saat ini ditunjukkan oleh penghitung instruksi adalah 0x79. Ini sesuai dengan instruksi
MOV A,C
prosesor 8080. Kode rakitan ini dalam kode C terlihat seperti
A=C;
.
Jika bukan nilai di PC akan menjadi 0x80, maka prosesor akan menjalankan
ADD B
Dalam C, ini terkait dengan string
A = A + B;
.
Daftar lengkap instruksi 8080 prosesor dapat ditemukan di
sini . Untuk mengimplementasikan emulator kami, kami akan menggunakan informasi ini.
Pengaturan waktu
Dalam CPU, pelaksanaan setiap instruksi membutuhkan sejumlah waktu (timing) tertentu, yang diukur dalam siklus. Dalam prosesor modern, informasi ini mungkin sulit diperoleh, karena timing tergantung pada banyak aspek yang berbeda. Tetapi dalam prosesor yang lebih tua seperti 8080, timingnya konstan dan informasi ini sering diberikan oleh produsen prosesor. Sebagai contoh, instruksi transfer dari register ke register MOV membutuhkan 1 siklus.
Informasi pengaturan waktu berguna untuk menulis kode efisien dalam prosesor. Seorang programmer dapat berusaha menghindari instruksi yang membutuhkan banyak siklus untuk diselesaikan.
Yang lebih penting bagi kami adalah bahwa kami akan menggunakan informasi pengaturan waktu untuk meniru prosesor. Agar gim bekerja dengan cara yang sama seperti pada aslinya, instruksi harus dijalankan dengan kecepatan yang benar. Beberapa emulator berusaha keras dalam hal ini, tetapi ketika kita sampai pada ini, kita harus memutuskan akurasi apa yang ingin kita dapatkan.
Operasi logis
Sebelum menutup topik angka biner dan heksadesimal, kita harus berbicara tentang operasi logis. Anda mungkin sudah terbiasa menggunakan logika dalam kode Anda, misalnya, dalam konstruksi seperti
if ((conditionA) and (conditionB))
. Dalam program yang bekerja secara langsung dengan perangkat keras, Anda sering harus memanipulasi bit angka individual.
DAN operasi
Berikut ini semua hasil yang mungkin dari operasi AND (AND) (tabel kebenaran) antara dua angka bit tunggal.
Hasil AND sama dengan unity hanya ketika kedua nilai sama dengan unity. Ketika kita menggabungkan dua angka dengan operasi AND, AND untuk setiap bit dari satu angka adalah AND dengan bit yang sesuai dari angka lainnya. Hasilnya disimpan dalam bit nomor tujuan ini. Mungkin lebih baik hanya dengan melihat contoh:
| biner | hex |
sumber x | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 | $ 6 miliar |
sumber y | 1 | 1 | 0 | 1 | 0 | 0 | 1 | 0 | $ D2 |
x DAN y | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | $ 42 |
Dalam C, operasi AND logis adalah ampersand sederhana "&".
Operasi OR (OR)
Operasi ATAU bekerja dengan cara yang sama. Satu-satunya perbedaan adalah bahwa hasilnya akan sama dengan satu jika setidaknya satu dari nilai x atau y sama dengan satu.
| biner | hex |
sumber x | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 | $ 6 miliar |
sumber y | 1 | 1 | 0 | 1 | 0 | 0 | 1 | 0 | $ D2 |
x ATAU y | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 1 | $ Fb |
Di C, operasi ATAU logis ditunjukkan oleh bilah vertikal "|".
Mengapa ini penting?
Di banyak prosesor yang lebih tua, dan terutama di mesin arcade, gim seringkali membutuhkan bekerja dengan hanya satu bit dari jumlahnya. Seringkali ada kode yang serupa:
char *buttons_ptr = (char *)0x2043; char buttons = *buttons_ptr; if (buttons & 0x4) HandleLeftButton(); char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led | 0x40;
Dalam Contoh 1, alamat $ 2043 yang dialokasikan dalam memori adalah alamat tombol pada panel kontrol. Kode ini membaca dan merespons tombol yang ditekan. (Tentu saja, di Space Invaders kode ini akan menggunakan bahasa assembly!)
Dalam Contoh 2, permainan ingin menyalakan indikator LED, yang terletak di bit 6 dari alamat $ 2089 yang dialokasikan dalam memori. Kode harus membaca nilai yang ada, hanya mengubah satu bit, dan menulisnya kembali.
Dalam contoh 3, Anda harus mematikan indikator dari contoh 2, sehingga kode harus mengatur ulang bit 6 dari alamat $ 2089. Ini dapat dilakukan dengan melakukan operasi DAN untuk byte kontrol indikator dengan nilai yang hanya bit 6 adalah nol. Jadi kita hanya akan mempengaruhi 6, meninggalkan bit yang tersisa tidak berubah.
Ini biasanya disebut "topeng." Dalam C, mask biasanya ditulis menggunakan operator NOT, dilambangkan dengan tilde ("~"). Karena itu, alih-alih menulis
0xBF
, saya hanya menulis
~0x40
dan mendapatkan nomor yang sama, tetapi tanpa banyak usaha.
Pengantar bahasa assembly
Jika Anda membaca tutorial ini, Anda mungkin akrab dengan pemrograman komputer, misalnya, dalam Java atau Python. Bahasa-bahasa ini memungkinkan Anda untuk melakukan banyak pekerjaan hanya dalam beberapa baris kode. Kode dianggap ditulis dengan cerdik jika bekerja sebanyak mungkin dalam beberapa baris sebanyak mungkin, bahkan mungkin menggunakan fungsi pustaka built-in. Bahasa semacam itu disebut "bahasa tingkat tinggi."
Sebaliknya, dalam bahasa assembly, tidak ada fitur bawaan yang menyelamatkan jiwa, dan banyak baris kode sederhana mungkin diperlukan untuk menyelesaikan tugas-tugas sederhana. Bahasa assembly dianggap bahasa tingkat rendah. Di dalamnya, Anda harus terbiasa berpikir dengan gaya "urutan langkah apa yang harus diambil untuk menyelesaikan tugas ini?"
Hal terpenting yang perlu Anda ketahui tentang bahasa assembler adalah bahwa setiap baris diterjemahkan ke dalam satu perintah prosesor.
Pertimbangkan konstruksi seperti itu dari bahasa C:
int a = b + 100;
Dalam bahasa assembly, tugas ini harus dilakukan dalam urutan berikut:
- Masukkan alamat variabel B ke dalam register 1
- Muat konten alamat memori ini ke register 2
- Tambahkan nilai langsung 0x64 untuk mendaftar 2
- Masukkan alamat variabel A ke dalam register 1
- Tulis isi register 2 ke alamat yang tersimpan di register 1
Dalam kode, akan terlihat seperti ini:
lea a1, #$1000 ; a lea a2, #$1008 ; b move.l d0,(a2) add.l d0, #$64 mov (a1),d0
Perlu diperhatikan hal berikut:
- Dalam bahasa tingkat tinggi, kompiler memutuskan di mana menempatkan variabel dalam memori. Saat menulis kode dalam assembler, Anda sendiri bertanggung jawab untuk setiap alamat memori yang akan Anda gunakan.
- Dalam sebagian besar bahasa assembly, tanda kurung berarti "memori di alamat ini."
- Dalam sebagian besar bahasa assembler, # menunjukkan angka aljabar, juga disebut nilai langsung. Misalnya, pada baris 1 dari contoh di atas, kode sebenarnya menulis nilai # 0x1000 untuk mendaftarkan a1. Jika kode itu tampak seperti
move.l a1, ($1000)
, maka a1 akan menerima isi memori di alamat 0x1000. - Setiap prosesor memiliki bahasa rakitan sendiri, dan kode porting dari satu prosesor ke prosesor lain mungkin sulit.
- Ini bukan bahasa rakitan prosesor nyata, saya datang dengan itu sebagai contoh.
Namun, ada satu hal yang sama antara programmer pintar tingkat tinggi dan penyihir assembler. Programmer assembler menganggapnya suatu kehormatan untuk menyelesaikan tugas seefisien mungkin dan meminimalkan jumlah instruksi yang digunakan. Kode untuk mesin arcade biasanya sangat dioptimalkan dan semua jus diperas dari setiap byte dan siklus ekstra.
Tumpukan
Mari kita bicara sedikit tentang bahasa assembly. Dalam program komputer yang agak rumit dalam assembler subrutin digunakan. Sebagian besar CPU memiliki struktur yang disebut stack.
Bayangkan tumpukan dalam bentuk tumpukan. Jika kami perlu menyimpan nomor, kami meletakkannya di bagian atas tumpukan. Ketika kita perlu membawanya kembali, kita mengambilnya dari atas tumpukan. Pemrogram assembler memanggil nomor yang muncul di tumpukan "push," dan muncul itu disebut "pop."
Katakanlah program saya perlu memanggil subrutin. Saya dapat menulis kode serupa:
0x1000 move.l (sp), d0 ; d0 0x1004 add.l sp, #4 ; 0x1008 move.l (sp), d1 ; d1 0x1010 add.l sp, #4 ; .. 0x1014 move.l (sp), a0 0x1018 add.l sp, #4 0x101C move.l (sp), a1 0x1020 add.l sp, #4 0x1024 move.l (sp), #0x1030 ; 0x1028 add.l sp, #4 0x102C jmp #0x2040 ; - 0x2040 0x1030 move.l a1, (sp) ; 0x1034 sub.l sp, #4 ; 0x1038 move.l a0, (sp) ; 0x103c sub.l sp, #4 ..
Kode yang ditunjukkan di atas mendorong nilai d0, d1, a0 dan a1 ke stack. Sebagian besar prosesor menggunakan penunjuk tumpukan. Ini bisa berupa register biasa, dengan konvensi yang digunakan sebagai stack pointer, atau register khusus dengan fungsi untuk instruksi tertentu.
Pada prosesor dalam seri 68K, penunjuk tumpukan hanya ditentukan oleh konvensi, atau register biasa. Dalam prosesor 8080 kami, register SP adalah register khusus. Ini memiliki perintah PUSH dan POP yang menulis dan pop dari tumpukan hanya dalam satu perintah.
Dalam proyek emulator kami, kami tidak akan menulis kode dari awal. Tetapi jika Anda perlu menganalisis program dalam bahasa assembly, maka ada baiknya belajar mengenali konstruksi semacam itu.
Bahasa tingkat tinggi
Saat menulis program dalam bahasa tingkat tinggi, semua operasi menyimpan dan memulihkan register dilakukan dengan setiap panggilan fungsi. Kami tidak memikirkannya, karena kompilator berurusan dengan mereka. Panggilan fungsi dalam bahasa tingkat tinggi dapat menghabiskan banyak memori dan waktu prosesor.
Pernahkah Anda mengalami program mogok saat memanggil subrutin dalam loop tak terbatas? Ini bisa terjadi karena setiap panggilan fungsi mendorong nilai register ke stack, dan pada titik tertentu memori kehabisan stack. (Jika tumpukan tumbuh terlalu besar, ini disebut stack overflow, atau stack overflow.)
Anda mungkin pernah mendengar tentang fungsi sebaris. Mereka menghindari menyimpan dan mengembalikan register dengan memasukkan kode rutin dalam fungsi panggilan. Kode menjadi lebih besar, tetapi berkat ini, beberapa perintah dan operasi baca / tulis ke memori disimpan.
Hubungi Konvensi
Saat menulis program assembler yang hanya memanggil kode Anda, Anda dapat memutuskan sendiri bagaimana rutinitas akan berkomunikasi satu sama lain. Misalnya, bagaimana saya kembali ke fungsi panggilan setelah rutinitas selesai? Salah satu caranya adalah dengan menulis alamat pengirim ke register tertentu. Yang lain adalah menempatkan alamat kembali di atas tumpukan. Sangat sering, keputusan tergantung pada apa yang didukung prosesor. 8080 memiliki perintah CALL yang mendorong alamat pengirim suatu fungsi ke stack. Mungkin Anda akan menggunakan perintah 8080 ini untuk mengimplementasikan panggilan subrutin.
Satu keputusan lagi perlu dibuat. Apakah pelestarian register merupakan tanggung jawab fungsi panggilan atau subrutin? Dalam contoh di atas, register disimpan oleh fungsi panggilan. Tetapi bagaimana jika kita memiliki 32 register? Menyimpan dan memulihkan 32 register ketika rutin hanya menggunakan sebagian kecil dari mereka akan membuang-buang waktu.
Pertukaran mungkin merupakan pendekatan campuran. Misalkan kita memilih kebijakan di mana rutin dapat menggunakan register r10-r32 tanpa menyimpan kontennya, tetapi tidak dapat menghancurkan r1-r9. Dalam situasi yang serupa, fungsi panggilan mengetahui yang berikut:
- Ketika kembali dari suatu fungsi, isi r1-r9 akan tetap tidak berubah
- Saya tidak bisa bergantung pada isi r10-r32
- Jika saya membutuhkan nilai di r10-r32 setelah memanggil subrutin, maka sebelum memanggilnya saya perlu menyimpannya di suatu tempat
Demikian pula, setiap rutin tahu yang berikut:
- Saya dapat menghancurkan r10-r32
- Jika saya ingin menggunakan r1-r9, maka saya perlu menyimpan konten dan mengembalikannya sebelum kembali ke fungsi yang memanggil saya
Abi
Pada kebanyakan platform modern, kebijakan semacam itu dibuat oleh para insinyur dan diterbitkan dalam dokumen yang disebut ABI (Application Binary Interface). Berkat dokumen ini, pembuat kompiler tahu cara mengkompilasi kode yang dapat memanggil kode yang dikompilasi oleh kompiler lain. Jika Anda ingin menulis kode assembler yang dapat berfungsi dalam lingkungan seperti itu, maka Anda perlu mengetahui ABI dan menulis kode sesuai dengannya.
Mengetahui ABI juga membantu dalam kode debug ketika Anda tidak memiliki akses ke sumber. ABI menentukan lokasi parameter untuk fungsi, jadi ketika mempertimbangkan subprogram apa pun, Anda dapat memeriksa alamat ini untuk memahami apa yang diteruskan ke fungsi.
Kembali ke emulator
Sebagian besar kode perakitan yang ditulis tangan, terutama untuk prosesor dan game arcade yang lebih lama, tidak mengikuti ABI. Program disusun dan mungkin tidak memiliki banyak rutinitas. Setiap rutin menyimpan dan mengembalikan register hanya dalam keadaan darurat.
Jika Anda ingin memahami apa yang dilakukan program, alangkah baiknya memulai dengan menandai alamat yang ditargetkan untuk perintah CALL.