Dalam tutorial ini, saya akan menunjukkan kepada Anda cara menulis mesin virtual Anda sendiri (VM) yang dapat menjalankan program assembler seperti
2048 (teman saya) atau
Roguelike (milik saya). Jika Anda tahu cara memprogram, tetapi ingin lebih memahami apa yang terjadi di dalam komputer dan bagaimana bahasa pemrograman bekerja, maka proyek ini adalah untuk Anda. Menulis mesin virtual Anda sendiri mungkin tampak sedikit menakutkan, tetapi saya berjanji bahwa topiknya sangat sederhana dan instruktif.
Kode terakhir adalah sekitar 250 baris dalam C. Cukup untuk mengetahui dasar-dasar C atau C ++, seperti
aritmatika biner . Setiap sistem Unix (termasuk macOS) cocok untuk membangun dan menjalankan. Beberapa Unix API digunakan untuk mengonfigurasi input dan tampilan konsol, tetapi mereka tidak penting untuk kode utama. (Implementasi dukungan Windows dihargai).
Catatan: VM ini adalah program yang kompeten . Artinya, Anda sudah membaca kode sumbernya sekarang! Setiap bagian dari kode akan ditampilkan dan dijelaskan secara rinci, sehingga Anda dapat yakin bahwa tidak ada yang hilang. Kode akhir dibuat oleh pleksus blok kode. Repositori proyek di sini .
1. Isi
- Daftar isi
- Pendahuluan
- Arsitektur LC-3
- Contoh Assembler
- Eksekusi program
- Implementasi instruksi
- Petunjuk Lembar Curang
- Prosedur Pemrosesan Interupsi
- Cheat sheet untuk rutinitas interupsi
- Unduh perangkat lunak
- Register yang Dipetakan Memori
- Fitur platform
- Startup mesin virtual
- Metode alternatif dalam C ++
2. Pendahuluan
Apa itu mesin virtual?
Mesin virtual adalah program yang bertindak seperti komputer. Ini mensimulasikan prosesor dengan beberapa komponen perangkat keras lainnya, memungkinkan Anda untuk melakukan aritmatika, membaca dari dan menulis ke memori, dan berinteraksi dengan perangkat input / output seperti komputer fisik nyata. Yang paling penting, VM memahami bahasa mesin yang dapat Anda gunakan untuk pemrograman.
Berapa banyak perangkat keras yang disimulasikan oleh VM tertentu tergantung pada tujuannya. Beberapa VM mereproduksi perilaku satu komputer tertentu. Orang tidak lagi memiliki NES, tetapi kita masih bisa bermain game untuk NES dengan mensimulasikan perangkat keras di tingkat perangkat lunak. Emulator ini harus
secara akurat menciptakan kembali setiap
detail dan setiap komponen perangkat keras utama dari perangkat asli.
VM lain tidak sesuai dengan komputer tertentu, tetapi sebagian sesuai dengan beberapa sekaligus! Ini terutama dilakukan untuk memfasilitasi pengembangan perangkat lunak. Bayangkan Anda ingin membuat program yang berjalan di banyak arsitektur komputer. Mesin virtual menyediakan platform standar yang menyediakan portabilitas. Tidak perlu menulis ulang program dengan dialek assembler yang berbeda untuk setiap arsitektur. Cukup membuat VM kecil saja di setiap bahasa. Setelah itu, program apa pun dapat ditulis hanya sekali dalam bahasa assembly mesin virtual.


Catatan: kompiler menyelesaikan masalah dengan mengkompilasi bahasa tingkat tinggi standar untuk arsitektur prosesor yang berbeda. VM menciptakan satu arsitektur CPU standar yang disimulasikan pada berbagai perangkat perangkat keras. Salah satu kelebihan dari kompiler adalah tidak ada overhead run-time seperti yang dilakukan VM. Meskipun kompiler bekerja dengan baik, menulis kompiler baru untuk banyak platform sangat sulit, sehingga VM masih berguna. Pada kenyataannya, pada level yang berbeda, baik VM dan kompiler digunakan bersama.
Java Virtual Machine (JVM) adalah contoh yang sangat sukses. JVM itu sendiri relatif berukuran sedang, cukup kecil untuk dipahami oleh seorang programmer. Ini memungkinkan Anda menulis kode untuk ribuan perangkat yang berbeda, termasuk ponsel. Setelah mengimplementasikan JVM pada perangkat baru, program Java, Kotlin atau Clojure tertulis apa pun dapat bekerja tanpa perubahan. Satu-satunya biaya hanya akan menjadi biaya overhead untuk VM itu sendiri dan
selanjutnya abstrak dari tingkat mesin. Ini biasanya kompromi yang cukup bagus.
VM tidak harus besar atau di mana-mana untuk memberikan manfaat serupa.
Video game yang lebih lama sering menggunakan VM kecil untuk membuat
sistem scripting sederhana.
VM juga berguna untuk mengisolasi program dengan aman. Salah satu aplikasi adalah pengumpulan sampah.
Tidak ada cara sepele untuk menerapkan pengumpulan sampah otomatis di atas C atau C ++, karena program tidak dapat melihat tumpukan atau variabel sendiri. Namun, VM "di luar" program yang sedang berjalan dan dapat mengamati semua
referensi ke sel memori pada stack.
Contoh lain dari perilaku ini ditunjukkan oleh
kontrak pintar Ethereum . Kontrak pintar adalah program kecil yang dijalankan oleh setiap simpul validasi di blockchain. Artinya, operator mengizinkan eksekusi pada mesin mereka dari program apa pun yang ditulis oleh orang asing, tanpa kesempatan untuk mempelajarinya terlebih dahulu. Untuk mencegah tindakan jahat, tindakan tersebut dilakukan pada
VM yang tidak memiliki akses ke sistem file, jaringan, disk, dll. Ethereum juga merupakan contoh portabilitas yang baik. Berkat VM, Anda dapat menulis kontrak pintar tanpa memperhitungkan fitur banyak platform.
3. Arsitektur LC-3
VM kami akan mensimulasikan komputer fiksi yang disebut
LC-3 . Ini populer untuk mengajar assembler siswa. Di sini, satu set perintah yang disederhanakan
dibandingkan dengan x86 , tetapi mempertahankan semua konsep dasar yang digunakan dalam CPU modern.
Pertama, Anda perlu mensimulasikan komponen perangkat keras yang diperlukan. Coba pahami apa masing-masing komponen itu, tetapi jangan khawatir jika Anda tidak yakin bagaimana itu cocok dengan gambaran besarnya. Mari kita mulai dengan membuat file dalam C. Setiap bagian kode dari bagian ini harus ditempatkan dalam lingkup global file ini.
Memori
LC-3 memiliki 65.536 sel memori (2
16 ), yang masing-masing berisi nilai 16-bit. Ini berarti bahwa ia hanya dapat menyimpan 128 KB - jauh lebih sedikit dari yang Anda terbiasa! Dalam program kami, memori ini disimpan dalam array sederhana:
uint16_t memory[UINT16_MAX];
Daftar
Register adalah slot untuk menyimpan satu nilai dalam CPU. Register seperti CPU "workbench". Agar dapat bekerja dengan sebagian data, ia harus berada di salah satu register. Tetapi karena hanya ada beberapa register, hanya sejumlah kecil data yang dapat diunduh pada waktu tertentu. Program mengatasi masalah ini dengan memuat nilai dari memori ke register, menghitung nilai ke register lain, dan kemudian menyimpan hasil akhir kembali ke memori.
Hanya ada 10 register di LC-3, masing-masing dengan 16 bit. Sebagian besar dari mereka adalah tujuan umum, tetapi beberapa ditugaskan peran.
- 8 register tujuan umum (
R0-R7
) - 1 register meja tim (
PC
) - 1 flag flag register (
COND
)
Register tujuan umum dapat digunakan untuk melakukan perhitungan perangkat lunak apa pun. Penghitung instruksi adalah integer yang tidak ditandatangani yang merupakan alamat memori dari instruksi selanjutnya yang akan dieksekusi. Bendera kondisi memberi tahu kami informasi tentang perhitungan sebelumnya.
enum { R_R0 = 0, R_R1, R_R2, R_R3, R_R4, R_R5, R_R6, R_R7, R_PC, R_COND, R_COUNT };
Seperti memori, kami akan menyimpan register dalam larik:
uint16_t reg[R_COUNT];
Set instruksi
Instruksi adalah perintah yang memberi tahu prosesor untuk melakukan semacam tugas mendasar, misalnya, menambahkan dua angka. Instruksi memiliki
opcode (kode operasi) yang menunjukkan jenis tugas yang dilakukan, serta seperangkat
parameter yang memberikan input untuk tugas yang sedang dilakukan.
Setiap
opcode mewakili satu tugas yang prosesor "tahu" bagaimana melakukan. Ada 16 opcode di LC-3. Komputer hanya dapat menghitung urutan instruksi sederhana ini. Panjang setiap instruksi adalah 16 bit, dan 4 bit kiri menyimpan kode operasi. Sisanya digunakan untuk menyimpan parameter.
Nanti kita akan membahas secara terperinci apa yang dilakukan oleh setiap instruksi. Tetapkan opcode berikut saat ini. Pastikan Anda menyimpan pesanan ini untuk mendapatkan nilai enum yang benar:
enum { OP_BR = 0, OP_ADD, OP_LD, OP_ST, OP_JSR, OP_AND, OP_LDR, OP_STR, OP_RTI, OP_NOT, OP_LDI, OP_STI, OP_JMP, OP_RES, OP_LEA, OP_TRAP };
Catatan: Arsitektur Intel x86 memiliki ratusan instruksi, sedangkan arsitektur lain seperti ARM dan LC-3 sangat sedikit. Set instruksi kecil disebut RISC , sedangkan yang lebih besar disebut CISC . Set instruksi besar, sebagai suatu peraturan, tidak menyediakan fitur fundamental baru, tetapi sering menyederhanakan penulisan kode assembler . Satu instruksi CISC dapat menggantikan beberapa instruksi RISC. Namun, prosesor CISC lebih kompleks dan mahal untuk desain dan pembuatan. Ini dan trade-off lainnya tidak memungkinkan memanggil desain "optimal" .
Bendera kondisi
Register
R_COND
menyimpan flag kondisi yang memberikan informasi tentang perhitungan terakhir yang dilakukan. Ini memungkinkan program memeriksa kondisi logis, seperti
if (x > 0) { ... }
.
Setiap prosesor memiliki banyak tanda status untuk memberi sinyal berbagai situasi. LC-3 hanya menggunakan tiga flag kondisi yang menunjukkan tanda dari perhitungan sebelumnya.
enum { FL_POS = 1 << 0, FL_ZRO = 1 << 1, FL_NEG = 1 << 2, };
Catatan: (Karakter <<
disebut operator shift kiri . (n << k)
menggeser bit n
kiri oleh k
tempat. Jadi, 1 << 2
sama dengan 4
Baca di sini jika Anda tidak terbiasa dengan konsep. Ini akan sangat penting).
Kami telah selesai mengonfigurasi komponen perangkat keras dari mesin virtual kami! Setelah menambahkan inklusi standar (lihat tautan di atas), file Anda akan terlihat seperti ini:
{Includes, 12} {Registers, 3} {Opcodes, 3} {Condition Flags, 3}
Berikut ini tautan ke bagian artikel bernomor, dari mana fragmen kode yang sesuai berasal. Untuk daftar lengkap, lihat program kerja - kira-kira. trans.4. Contoh Assembler
Sekarang, mari kita lihat program assembler LC-3 untuk mendapatkan gambaran tentang apa yang sebenarnya dilakukan oleh mesin virtual. Anda tidak perlu tahu cara memprogram dalam assembler, atau memahami semuanya di sini. Cobalah untuk mendapatkan gambaran umum tentang apa yang terjadi. Ini adalah "Hello World" sederhana:
.ORIG x3000 ; this is the address in memory where the program will be loaded LEA R0, HELLO_STR ; load the address of the HELLO_STR string into R0 PUTs ; output the string pointed to by R0 to the console HALT ; halt the program HELLO_STR .STRINGZ "Hello World!" ; store this string here in the program .END ; mark the end of the file
Seperti dalam C, program mengeksekusi satu pernyataan dari atas ke bawah. Tetapi tidak seperti C, tidak ada area bersarang
{}
atau struktur kontrol seperti
if
atau
while
; hanya daftar operator sederhana. Karena itu, jauh lebih mudah dilakukan.
Harap perhatikan bahwa nama beberapa operator sesuai dengan opcode yang telah kami tentukan sebelumnya. Kita tahu bahwa instruksinya 16 bit, tetapi setiap baris terlihat seperti jumlah karakter yang berbeda. Bagaimana ketidakcocokan seperti itu mungkin terjadi?
Ini karena kode yang kita baca ditulis dalam
bahasa assembly - dalam bentuk teks, bentuk yang dapat dibaca dan dapat ditulis. Alat, yang disebut
assembler , mengubah setiap baris teks menjadi instruksi biner 16-bit yang dipahami mesin virtual. Bentuk biner ini, yang pada dasarnya adalah array instruksi 16-bit, disebut
kode mesin dan sebenarnya dieksekusi oleh mesin virtual.
Catatan: meskipun kompiler dan assembler memainkan peran yang sama dalam pengembangan, mereka tidak sama. Assembler hanya mengkodekan apa yang ditulis programmer dalam teks, mengganti karakter dengan representasi biner mereka dan mengemasnya menjadi instruksi.
Perintah
.ORIG
dan
.STRINGZ
terlihat seperti instruksi, tetapi tidak. Ini adalah arahan assembler yang menghasilkan bagian dari kode atau data. Misalnya,
.STRINGZ
menyisipkan serangkaian karakter pada lokasi yang ditentukan dalam program biner.
Loop dan kondisi dieksekusi menggunakan pernyataan seperti goto. Berikut adalah contoh lain yang diperhitungkan menjadi 10.
AND R0, R0, 0 ; clear R0 LOOP ; label at the top of our loop ADD R0, R0, 1 ; add 1 to R0 and store back in R0 ADD R1, R0, -10 ; subtract 10 from R0 and store back in R1 BRn LOOP ; go back to LOOP if the result was negative ... ; R0 is now 10!
Catatan: tutorial ini tidak harus belajar perakitan. Tetapi jika Anda tertarik, Anda dapat menulis dan membangun program LC-3 Anda sendiri menggunakan LC-3 Tools .
5. Eksekusi program
Sekali lagi, contoh-contoh sebelumnya hanya memberikan gambaran tentang apa yang dilakukan VM. Untuk menulis VM, Anda tidak perlu memahami assembler sepenuhnya. Selama Anda mengikuti prosedur yang sesuai untuk membaca dan menjalankan instruksi, program LC-3
apa pun akan bekerja dengan benar, terlepas dari kerumitannya. Secara teori, VM bahkan dapat menjalankan browser atau sistem operasi seperti Linux!
Jika Anda berpikir mendalam, maka ini adalah ide yang sangat bagus secara filosofis. Program itu sendiri dapat menghasilkan tindakan kompleks sewenang-wenang yang tidak pernah kita harapkan dan mungkin tidak dapat dipahami. Tetapi pada saat yang sama, semua fungsinya terbatas pada kode sederhana, yang akan kami tulis! Pada saat yang sama, kami tahu segalanya dan tidak tahu sama sekali tentang cara kerja setiap program. Turing menyebutkan ide bagus ini:
โPendapat bahwa mesin tidak dapat mengejutkan seseorang dengan apa pun didasarkan, saya percaya, pada satu kesalahan, yang rawan terhadap ahli matematika dan filsuf. Maksud saya asumsi bahwa karena beberapa fakta telah menjadi milik pikiran, segera semua konsekuensi dari fakta ini akan menjadi milik pikiran. โ - Alan M. Turing
Prosedur
Berikut ini adalah deskripsi yang tepat dari prosedur penulisan:
- Unduh satu instruksi dari memori di alamat register
PC
. - Tambah register
PC
. - Lihat opcode untuk menentukan jenis instruksi apa yang harus diikuti.
- Ikuti instruksi menggunakan parameternya.
- Kembali ke langkah 1.
Anda dapat mengajukan pertanyaan: "Tetapi jika loop terus menambah penghitung tanpa adanya
if
atau
while
, apakah instruksi tidak akan berakhir?" Jawabannya adalah tidak. Seperti yang telah kami sebutkan, beberapa instruksi mirip goto mengubah aliran eksekusi dengan melompat-lompat di sekitar
PC
.
Kami memulai studi tentang proses ini sebagai contoh dari siklus utama:
int main(int argc, const char* argv[]) { {Load Arguments, 12} {Setup, 12} enum { PC_START = 0x3000 }; reg[R_PC] = PC_START; int running = 1; while (running) { uint16_t instr = mem_read(reg[R_PC]++); uint16_t op = instr >> 12; switch (op) { case OP_ADD: {ADD, 6} break; case OP_AND: {AND, 7} break; case OP_NOT: {NOT, 7} break; case OP_BR: {BR, 7} break; case OP_JMP: {JMP, 7} break; case OP_JSR: {JSR, 7} break; case OP_LD: {LD, 7} break; case OP_LDI: {LDI, 6} break; case OP_LDR: {LDR, 7} break; case OP_LEA: {LEA, 7} break; case OP_ST: {ST, 7} break; case OP_STI: {STI, 7} break; case OP_STR: {STR, 7} break; case OP_TRAP: {TRAP, 8} break; case OP_RES: case OP_RTI: default: {BAD OPCODE, 7} break; } } {Shutdown, 12} }
6. Implementasi instruksi
Sekarang tugas Anda adalah membuat implementasi yang benar untuk setiap opcode. Spesifikasi terperinci dari setiap instruksi terdapat dalam
dokumentasi proyek . Dari spesifikasi Anda perlu mengetahui cara kerja setiap instruksi dan menulis implementasi. Ini lebih mudah daripada kedengarannya. Di sini saya akan menunjukkan bagaimana menerapkan keduanya. Kode untuk sisanya dapat ditemukan di bagian selanjutnya.
ADD
Instruksi
ADD
mengambil dua angka, menambahkannya dan menyimpan hasilnya dalam register. Spesifikasi ini ada di dokumentasi pada halaman 526. Setiap instruksi
ADD
adalah sebagai berikut:
Ada dua baris dalam diagram, karena ada dua "mode" yang berbeda untuk instruksi ini. Sebelum saya menjelaskan mode, mari kita coba mencari kesamaan di antara mereka. Keduanya dimulai dengan empat bit identik
0001
. Ini adalah nilai opcode untuk
OP_ADD
. Tiga bit berikutnya ditandai
DR
untuk register keluaran. Register output adalah tempat di mana jumlah disimpan. Tiga bit berikut adalah:
SR1
. Ini adalah register yang berisi nomor pertama yang akan ditambahkan.
Jadi, kita tahu di mana harus menyimpan hasilnya, dan kita tahu angka pertama yang ditambahkan. Tetap hanya untuk mengetahui nomor kedua untuk penambahan. Di sini dua garis mulai berbeda. Perhatikan bahwa bit ke-5 adalah 0 di bagian atas dan 1. di bagian bawah. Bit ini sesuai dengan
mode langsung atau
mode register . Dalam mode register, nomor kedua disimpan dalam register, seperti yang pertama. Itu ditandai sebagai
SR2
dan terkandung dalam bit dua hingga nol. Bit 3 dan 4 tidak digunakan. Dalam assembler, itu akan ditulis seperti ini:
ADD R2 R0 R1 ; add the contents of R0 to R1 and store in R2.
Dalam mode langsung, alih-alih menambahkan konten register, nilai langsung tertanam dalam instruksi itu sendiri. Ini nyaman karena program tidak memerlukan instruksi tambahan untuk memuat nomor ini ke register dari memori. Sebaliknya, sudah ada di dalam instruksi ketika kita membutuhkannya. Imbalannya adalah hanya sejumlah kecil yang dapat disimpan di sana. Tepatnya, maksimal 2
5 = 32. Ini paling berguna untuk meningkatkan penghitung atau nilai. Di assembler, Anda dapat menulis seperti ini:
ADD R0 R0 1 ; add 1 to R0 and store back in R0
Berikut ini kutipan dari spesifikasinya:
Jika bit [5] adalah 0, maka operan sumber kedua diperoleh dari SR2. Jika bit [5] adalah 1, maka operan sumber kedua diperoleh dengan memperluas imm5 ke 16 bit. Dalam kedua kasus, operan sumber kedua ditambahkan ke konten SR1, dan hasilnya disimpan dalam DR. (hal. 526)
Ini mirip dengan apa yang kita diskusikan. Tapi apa itu "perluasan makna"? Meskipun dalam mode langsung, nilainya hanya 5 bit, perlu ditambahkan dengan angka 16-bit. 5 bit ini harus diperluas ke 16 agar sesuai dengan nomor lain. Untuk angka positif, kita dapat mengisi bit yang hilang dengan nol dan mendapatkan nilai yang sama. Namun, untuk angka negatif ini tidak berfungsi. Misalnya, โ1 dalam lima bit adalah
1 1111
. Jika Anda mengisinya dengan nol, kami mendapatkan
0000 0000 0001 1111
, yaitu 32! Memperluas nilai mencegah masalah ini dengan mengisi bit dengan angka nol untuk angka positif dan angka untuk angka negatif.
uint16_t sign_extend(uint16_t x, int bit_count) { if ((x >> (bit_count - 1)) & 1) { x |= (0xFFFF << bit_count); } return x; }
Catatan: jika Anda tertarik dengan angka negatif biner, Anda dapat membaca tentang kode tambahan . Tetapi ini tidak penting. Cukup salin kode di atas dan gunakan ketika spesifikasi mengatakan untuk memperluas nilainya.
Spesifikasi memiliki kalimat terakhir:
Kode kondisi diatur tergantung pada apakah hasilnya negatif, nol atau positif. (hal. 526)
Sebelumnya kami mendefinisikan kondisi flag enum, dan sekarang saatnya untuk menggunakan flag ini. Setiap kali nilai ditulis ke register, kita perlu memperbarui bendera untuk menunjukkan tandanya. Kami menulis fungsi untuk digunakan kembali:
void update_flags(uint16_t r) { if (reg[r] == 0) { reg[R_COND] = FL_ZRO; } else if (reg[r] >> 15) { reg[R_COND] = FL_NEG; } else { reg[R_COND] = FL_POS; } }
Sekarang kita siap menulis kode untuk
ADD
:
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t r1 = (instr >> 6) & 0x7; uint16_t imm_flag = (instr >> 5) & 0x1; if (imm_flag) { uint16_t imm5 = sign_extend(instr & 0x1F, 5); reg[r0] = reg[r1] + imm5; } else { uint16_t r2 = instr & 0x7; reg[r0] = reg[r1] + reg[r2]; } update_flags(r0); }
Bagian ini memiliki banyak informasi, jadi mari kita rangkum.
ADD
mengambil dua nilai dan menyimpannya dalam register.- Dalam mode register, nilai kedua yang akan ditambahkan adalah register.
- Dalam mode langsung, nilai kedua tertanam di 5 bit kanan instruksi.
- Nilai yang lebih pendek dari 16 bit harus diperluas.
- Setiap kali instruksi mengubah case, flag kondisi harus diperbarui.
Anda mungkin kewalahan dengan menulis 15 instruksi lagi. Namun, informasi yang diperoleh di sini dapat digunakan kembali. Sebagian besar instruksi menggunakan kombinasi ekspansi nilai, berbagai mode, dan pembaruan bendera.
LDI
LDI berarti pemuatan "tidak langsung" atau "tidak langsung" (memuat tidak langsung). Instruksi ini digunakan untuk memuat nilai dari lokasi memori ke dalam register. Spesifikasi pada halaman 532.
Beginilah tata letak binernya:
Tidak seperti
ADD
, tidak ada mode dan parameter yang lebih sedikit. Kali ini, kode operasinya adalah
1010
, yang sesuai dengan nilai enum
OP_LDI
. Sekali lagi, kita melihat
DR
tiga-bit (register keluaran) untuk menyimpan nilai yang dimuat. Bit yang tersisa ditandai sebagai
PCoffset9
. Ini adalah nilai langsung yang tertanam dalam instruksi (mirip dengan
imm5
). Karena instruksi dimuat dari memori, kita dapat menebak bahwa nomor ini adalah jenis alamat yang mengatakan dari mana nilai tersebut diambil. Spesifikasi menjelaskan lebih terinci:
Alamat tersebut dihitung dengan memperluas bit nilai [8:0]
menjadi 16 bit dan menambahkan nilai ini ke PC
diperbesar. Apa yang disimpan dalam memori di alamat ini adalah alamat data yang akan dimuat ke dalam DR
. (hal. 532)
Seperti sebelumnya, Anda perlu memperluas nilai 9-bit ini, tetapi kali ini menambahkannya ke
PC
saat ini. (Jika Anda melihat siklus eksekusi,
PC
meningkat segera setelah memuat instruksi ini). Jumlah yang dihasilkan adalah alamat lokasi di memori, dan alamat ini
berisi nilai
lain , yang merupakan alamat nilai beban.
Ini mungkin tampak seperti cara bundaran untuk membaca dari memori, tetapi itu perlu. Instruksi
LD
terbatas pada offset alamat 9 bit, sedangkan memori membutuhkan alamat 16 bit.
LDI
berguna untuk memuat nilai yang disimpan di suatu tempat di luar komputer saat ini, tetapi untuk menggunakannya, alamat lokasi akhir harus disimpan di dekatnya. Anda bisa menganggapnya sebagai variabel lokal di C, yang merupakan penunjuk ke beberapa data:
Seperti sebelumnya, setelah menulis nilai ke
DR
, flag harus diperbarui:
Kode kondisi diatur tergantung pada apakah hasilnya negatif, nol atau positif. (hal. 532)
Berikut adalah kode untuk kasus ini: (
mem_read
dibahas pada bagian selanjutnya):
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset)); update_flags(r0); }
Seperti yang saya katakan, untuk instruksi ini kami menggunakan bagian penting dari kode dan pengetahuan yang didapat sebelumnya ketika menulis
ADD
. Sama dengan petunjuk lainnya.
Sekarang Anda perlu mengimplementasikan sisa instruksi. Ikuti
spesifikasi dan gunakan kode yang sudah ditulis. Kode untuk semua instruksi diberikan di akhir artikel. Dua opcode yang disebutkan sebelumnya tidak akan dibutuhkan:
OP_RTI
dan
OP_RES
. Anda dapat mengabaikan mereka atau memberikan kesalahan jika mereka dipanggil. Setelah selesai, sebagian besar VM Anda dapat dianggap lengkap!
7. Boks sesuai instruksi
Bagian ini berisi implementasi lengkap dari instruksi yang tersisa jika Anda macet.
RTI & RES
(tidak digunakan)
abort();
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t r1 = (instr >> 6) & 0x7; uint16_t imm_flag = (instr >> 5) & 0x1; if (imm_flag) { uint16_t imm5 = sign_extend(instr & 0x1F, 5); reg[r0] = reg[r1] & imm5; } else { uint16_t r2 = instr & 0x7; reg[r0] = reg[r1] & reg[r2]; } update_flags(r0); }
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t r1 = (instr >> 6) & 0x7; reg[r0] = ~reg[r1]; update_flags(r0); }
Cabang
{ uint16_t pc_offset = sign_extend((instr) & 0x1ff, 9); uint16_t cond_flag = (instr >> 9) & 0x7; if (cond_flag & reg[R_COND]) { reg[R_PC] += pc_offset; } }
Lompat
RET
diindikasikan sebagai instruksi terpisah dalam spesifikasi, karena ini adalah perintah lain dalam assembler. Ini sebenarnya adalah kasus khusus
JMP
.
RET
terjadi setiap kali
R1
adalah 7.
{ uint16_t r1 = (instr >> 6) & 0x7; reg[R_PC] = reg[r1]; }
Langsung daftar
{ uint16_t r1 = (instr >> 6) & 0x7; uint16_t long_pc_offset = sign_extend(instr & 0x7ff, 11); uint16_t long_flag = (instr >> 11) & 1; reg[R_R7] = reg[R_PC]; if (long_flag) { reg[R_PC] += long_pc_offset; } else { reg[R_PC] = reg[r1]; } break; }
Muat
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); reg[r0] = mem_read(reg[R_PC] + pc_offset); update_flags(r0); }
Muat daftar
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t r1 = (instr >> 6) & 0x7; uint16_t offset = sign_extend(instr & 0x3F, 6); reg[r0] = mem_read(reg[r1] + offset); update_flags(r0); }
Alamat Muatan yang Efektif
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); reg[r0] = reg[R_PC] + pc_offset; update_flags(r0); }
Toko
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); mem_write(reg[R_PC] + pc_offset, reg[r0]); }
Toko tidak langsung
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); mem_write(mem_read(reg[R_PC] + pc_offset), reg[r0]); }
Daftar toko
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t r1 = (instr >> 6) & 0x7; uint16_t offset = sign_extend(instr & 0x3F, 6); mem_write(reg[r1] + offset, reg[r0]); }
8. Prosedur Penanganan Interrupt
LC-3 menyediakan beberapa rutinitas standar untuk melakukan tugas-tugas umum dan berinteraksi dengan perangkat I / O. Misalnya, ada prosedur untuk menerima input keyboard dan mengeluarkan garis ke konsol. Mereka disebut perangkap rutin, yang dapat Anda anggap sebagai sistem operasi atau API untuk LC-3. Setiap subprogram diberi kode interupsi (trap code) yang mengidentifikasinya (mirip dengan opcode).
Untuk menjalankannya, sebuah instruksi dipanggil TRAP
dengan kode dari subprogram yang diinginkan.Setel enum untuk setiap kode interupsi: enum { TRAP_GETC = 0x20, TRAP_OUT = 0x21, TRAP_PUTS = 0x22, TRAP_IN = 0x23, TRAP_PUTSP = 0x24, TRAP_HALT = 0x25 };
Anda mungkin bertanya-tanya mengapa kode interupsi tidak termasuk dalam instruksi. Ini karena mereka sebenarnya tidak menambahkan LC-3 fungsionalitas baru, tetapi hanya menyediakan cara mudah untuk menyelesaikan tugas (seperti fungsi sistem di C). Dalam simulator LC-3 resmi, kode interupsi ditulis dalam assembler . Ketika kode interupsi dipanggil, komputer pindah ke alamat kode ini. CPU menjalankan instruksi prosedur dan, setelah selesai, PC
diatur ulang ke lokasi dari mana interupsi dipanggil.: 0x3000
0x0
. , .
Tidak ada spesifikasi tentang bagaimana menerapkan rutinitas interupsi: hanya apa yang harus mereka lakukan. Dalam VM kami, kami akan bertindak sedikit berbeda dengan menuliskannya dalam C. Ketika kode interupsi dipanggil, fungsi C. Akan dipanggil. Setelah operasinya, instruksi akan dilanjutkan.Meskipun prosedur dapat ditulis dalam assembler dan komputer fisik LC-3 akan demikian, ini bukan pilihan terbaik untuk VM. Alih-alih menulis prosedur input-output primitif Anda sendiri, Anda dapat menggunakan prosedur yang tersedia di OS kami. Ini akan meningkatkan mesin virtual pada komputer kami, menyederhanakan kode dan memberikan tingkat abstraksi yang lebih tinggi untuk portabilitas.Catatan: Satu contoh spesifik adalah input keyboard. Versi assembler menggunakan loop untuk terus memeriksa input keyboard. Tetapi begitu banyak waktu prosesor terbuang sia-sia! Dengan menggunakan fungsi OS yang sesuai, program dapat tidur nyenyak sebelum sinyal input.
Di operator pilihan ganda untuk opcode, TRAP
tambahkan sakelar lain: switch (instr & 0xFF) { case TRAP_GETC: {TRAP GETC, 9} break; case TRAP_OUT: {TRAP OUT, 9} break; case TRAP_PUTS: {TRAP PUTS, 8} break; case TRAP_IN: {TRAP IN, 9} break; case TRAP_PUTSP: {TRAP PUTSP, 9} break; case TRAP_HALT: {TRAP HALT, 9} break; }
Seperti dengan instruksi, saya akan menunjukkan kepada Anda bagaimana menerapkan satu prosedur, dan melakukan sisanya sendiri.Putt
Kode interrupt PUTS
digunakan untuk mengembalikan sebuah string dengan nol terminating (mirip printf
di C). Spesifikasi pada halaman 543.Untuk menampilkan string, kita harus memberikan string interrupt untuk ditampilkan. Ini dilakukan dengan menyimpan alamat karakter pertama R0
sebelum pemrosesan dimulai.Dari spesifikasi:Tampilkan string karakter ASCII di layar konsol. Karakter terkandung dalam sel memori berturut-turut, satu karakter per sel, mulai dari alamat yang ditentukan dalam R0
. Output berakhir ketika nilai dijumpai dalam memori x0000
. (hal. 543)
Perhatikan bahwa tidak seperti string C, di sini karakter disimpan bukan dalam satu byte, tetapi di satu lokasi di memori . Lokasi memori LC-3 adalah 16 bit, sehingga setiap karakter dalam string adalah 16 bit. Untuk menampilkan ini dalam fungsi C, Anda perlu mengubah setiap nilai menjadi karakter dan mencetaknya secara terpisah. { uint16_t* c = memory + reg[R_R0]; while (*c) { putc((char)*c, stdout); ++c; } fflush(stdout); }
Tidak diperlukan lagi untuk prosedur ini. Rutin interupsi cukup mudah jika Anda tahu C. Sekarang kembali ke spesifikasi dan mengimplementasikan sisanya. Seperti halnya instruksi, kode lengkap dapat ditemukan di akhir panduan ini.9. Cheat sheet untuk rutinitas interupsi
Bagian ini berisi implementasi lengkap dari rutin interupsi yang tersisa.Entri karakter
reg[R_R0] = (uint16_t)getchar();
Output karakter
putc((char)reg[R_R0], stdout); fflush(stdout);
Permintaan input karakter
printf("Enter a character: "); reg[R_R0] = (uint16_t)getchar();
Output garis
{ uint16_t* c = memory + reg[R_R0]; while (*c) { char char1 = (*c) & 0xFF; putc(char1, stdout); char char2 = (*c) >> 8; if (char2) putc(char2, stdout); ++c; } fflush(stdout); }
Pengakhiran Program
puts("HALT"); fflush(stdout); running = 0;
10. Mengunduh program
Kami banyak berbicara tentang memuat dan menjalankan instruksi dari memori, tetapi bagaimana cara instruksi masuk ke memori secara umum? Saat mengonversi program assembler ke kode mesin, hasilnya adalah file yang berisi berbagai instruksi dan data. Itu dapat diunduh dengan hanya menyalin konten langsung ke alamat dalam memori.16 bit pertama dari file program menunjukkan alamat di memori tempat program harus dimulai. Alamat ini disebut asal . Itu harus dibaca terlebih dahulu, setelah itu sisa data dibaca ke dalam memori dari file.Berikut adalah kode untuk memuat program ke dalam memori LC-3: void read_image_file(FILE* file) { uint16_t origin; fread(&origin, sizeof(origin), 1, file); origin = swap16(origin); uint16_t max_read = UINT16_MAX - origin; uint16_t* p = memory + origin; size_t read = fread(p, sizeof(uint16_t), max_read, file); while (read-- > 0) { *p = swap16(*p); ++p; } }
Perhatikan bahwa untuk setiap nilai yang dimuat disebut swap16
. Program LC-3 ditulis dalam urutan byte langsung, tetapi kebanyakan komputer modern menggunakan urutan terbalik. Akibatnya, kita perlu membalik masing-masing yang dimuat uint16
. (Jika Anda secara tidak sengaja menggunakan komputer aneh seperti PPC , maka tidak ada yang perlu diubah). uint16_t swap16(uint16_t x) { return (x << 8) | (x >> 8); }
Catatan: Urutan byte mengacu pada bagaimana byte dari integer ditafsirkan. Dalam urutan terbalik, byte pertama adalah digit paling signifikan, dan dalam urutan terbalik, sebaliknya. Sejauh yang saya tahu, sebagian besar keputusan arbitrer. Perusahaan yang berbeda membuat keputusan yang berbeda, jadi sekarang kami memiliki implementasi yang berbeda. Untuk proyek ini, Anda tidak perlu lagi tahu apa pun tentang urutan byte.
Tambahkan juga fungsi yang nyaman untuk read_image_file
, yang mengambil jalur untuk string: int read_image(const char* image_path) { FILE* file = fopen(image_path, "rb"); if (!file) { return 0; }; read_image_file(file); fclose(file); return 1; }
11. Register yang dipetakan
Beberapa register khusus tidak tersedia dari tabel register reguler. Sebagai gantinya, alamat khusus disediakan untuk mereka dalam memori. Untuk membaca dan menulis ke register ini, Anda cukup membaca dan menulis ke memori mereka. Mereka disebut register yang dipetakan memori . Biasanya mereka digunakan untuk berinteraksi dengan perangkat perangkat keras khusus.Untuk LC-3 kami, kami perlu mengimplementasikan dua register yang dapat dipetakan. Ini adalah register status keyboard ( KBSR
) dan register data keyboard ( KBDR
). Yang pertama menunjukkan apakah tombol telah ditekan, dan yang kedua menentukan tombol mana yang ditekan.Meskipun input keyboard dapat diminta menggunakan GETC
, itu memblokir eksekusi sampai input diterima. KBSR
dan KBDR
izinkanmenginterogasi keadaan perangkat sambil terus menjalankan program, sehingga tetap responsif sambil menunggu input. enum { MR_KBSR = 0xFE00, MR_KBDR = 0xFE02 };
Register yang dipetakan mempersulit akses memori sedikit. Kita tidak bisa membaca dan menulis ke array memori secara langsung, tetapi sebaliknya harus memanggil fungsi khusus - setter dan pengambil. Setelah membaca memori dari register KBSR, rajin memeriksa keyboard dan memperbarui kedua lokasi di memori. void mem_write(uint16_t address, uint16_t val) { memory[address] = val; } uint16_t mem_read(uint16_t address) { if (address == MR_KBSR) { if (check_key()) { memory[MR_KBSR] = (1 << 15); memory[MR_KBDR] = getchar(); } else { memory[MR_KBSR] = 0; } } return memory[address]; }
Ini adalah komponen terakhir dari mesin virtual! Jika Anda telah menerapkan sisa rutinitas dan instruksi interupsi, Anda hampir siap untuk mencobanya!Segala sesuatu yang ditulis harus ditambahkan ke file C dalam urutan berikut: {Memory Mapped Registers, 11} {TRAP Codes, 8} {Memory Storage, 3} {Register Storage, 3} {Functions, 12} {Main Loop, 5}
12. Fitur Platform
Bagian ini berisi beberapa detail yang membosankan yang diperlukan untuk mengakses keyboard dan bekerja dengan benar. Tidak ada yang menarik atau informatif tentang pengoperasian mesin virtual. Rasakan copy paste gratis!Jika Anda mencoba memulai VM di sistem operasi selain Unix, seperti Windows, fungsi-fungsi ini harus diganti dengan fungsi Windows yang sesuai. uint16_t check_key() { fd_set readfds; FD_ZERO(&readfds); FD_SET(STDIN_FILENO, &readfds); struct timeval timeout; timeout.tv_sec = 0; timeout.tv_usec = 0; return select(1, &readfds, NULL, NULL, &timeout) != 0; }
Kode untuk mengekstrak jalur dari argumen program dan menampilkan contoh penggunaan jika hilang. if (argc < 2) { printf("lc3 [image-file1] ...\n"); exit(2); } for (int j = 1; j < argc; ++j) { if (!read_image(argv[j])) { printf("failed to load image: %s\n", argv[j]); exit(1); } }
Kode konfigurasi input terminal khusus unix. struct termios original_tio; void disable_input_buffering() { tcgetattr(STDIN_FILENO, &original_tio); struct termios new_tio = original_tio; new_tio.c_lflag &= ~ICANON & ~ECHO; tcsetattr(STDIN_FILENO, TCSANOW, &new_tio); } void restore_input_buffering() { tcsetattr(STDIN_FILENO, TCSANOW, &original_tio); }
Ketika program terganggu, kami ingin mengembalikan konsol ke pengaturan normal. void handle_interrupt(int signal) { restore_input_buffering(); printf("\n"); exit(-2); }
signal(SIGINT, handle_interrupt); disable_input_buffering();
restore_input_buffering();
{Sign Extend, 6} {Swap, 10} {Update Flags, 6} {Read Image File, 10} {Read Image, 10} {Check Key, 12} {Memory Access, 11} {Input Buffering, 12} {Handle Interrupt, 12}
#include <stdio.h> #include <stdlib.h> #include <stdint.h> #include <string.h> #include <signal.h> #include <unistd.h> #include <fcntl.h> #include <sys/time.h> #include <sys/types.h> #include <sys/termios.h> #include <sys/mman.h>
Startup mesin virtual
Sekarang Anda dapat membangun dan menjalankan mesin virtual LC-3!- Kompilasi program dengan kompiler favorit Anda .
- Unduh versi kompilasi 2048 atau Rogue .
- Jalankan program dengan file obj sebagai argumen:
lc3-vm path/to/2048.obj
- Mainkan di 2048!
Control the game using WASD keys. Are you on an ANSI terminal (y/n)? y +--------------------------+ | | | | | | | 2 | | | | 2 | | | | | | | +--------------------------+
Debugging
Jika program tidak bekerja dengan benar, kemungkinan besar Anda salah mengode beberapa jenis instruksi. Sulit untuk melakukan debug. Saya sarankan Anda membaca kode perakitan program pada saat yang sama - dan dengan bantuan debugger langkah demi langkah ikuti petunjuk dari mesin virtual satu per satu. Saat membaca kode, pastikan bahwa VM pergi ke instruksi yang dimaksud. Jika ketidakcocokan terjadi, Anda akan menemukan instruksi mana yang menyebabkan masalah. Baca kembali spesifikasi dan periksa kembali kode.14. Metode alternatif dalam C ++
Berikut cara canggih untuk menjalankan instruksi yang secara signifikan mengurangi ukuran kode. Ini adalah bagian yang sepenuhnya opsional.Karena C ++ mendukung generik yang kuat selama proses kompilasi, kami dapat membuat bagian dari instruksi menggunakan kompiler. Metode ini mengurangi duplikasi kode dan sebenarnya lebih dekat ke tingkat perangkat keras komputer.Idenya adalah untuk menggunakan kembali langkah-langkah umum untuk setiap instruksi. Misalnya, beberapa instruksi menggunakan pengalamatan tidak langsung atau perluasan nilai dan menambahkannya ke nilai saat ini PC
. Setuju, akan menyenangkan untuk menulis kode ini sekali untuk semua instruksi?Mempertimbangkan instruksi sebagai urutan langkah-langkah, kita melihat bahwa setiap instruksi hanyalah penataan ulang beberapa langkah yang lebih kecil. Kami akan menggunakan tanda bit untuk menunjukkan langkah mana yang harus diikuti untuk setiap instruksi. Nilai 1
dalam bit nomor instruksi menunjukkan bahwa untuk instruksi ini kompiler harus menyertakan bagian kode ini. template <unsigned op> void ins(uint16_t instr) { uint16_t r0, r1, r2, imm5, imm_flag; uint16_t pc_plus_off, base_plus_off; uint16_t opbit = (1 << op); if (0x4EEE & opbit) { r0 = (instr >> 9) & 0x7; } if (0x12E3 & opbit) { r1 = (instr >> 6) & 0x7; } if (0x0022 & opbit) { r2 = instr & 0x7; imm_flag = (instr >> 5) & 0x1; imm5 = sign_extend((instr) & 0x1F, 5); } if (0x00C0 & opbit) {
static void (*op_table[16])(uint16_t) = { ins<0>, ins<1>, ins<2>, ins<3>, ins<4>, ins<5>, ins<6>, ins<7>, NULL, ins<9>, ins<10>, ins<11>, ins<12>, NULL, ins<14>, ins<15> };
Catatan: Saya belajar tentang teknik ini dari emulator NES yang dikembangkan oleh Bisqwit . Jika Anda tertarik pada emulasi atau NES, saya sangat merekomendasikan videonya.Versi lain dari C ++ menggunakan kode yang sudah ditulis. Versi lengkap di sini . {Includes, 12} {Registers, 3} {Condition Flags, 3} {Opcodes, 3} {Memory Mapped Registers, 11} {TRAP Codes, 8} {Memory Storage, 3} {Register Storage, 3} {Functions, 12} int running = 1; {Instruction C++, 14} {Op Table, 14} int main(int argc, const char* argv[]) { {Load Arguments, 12} {Setup, 12} enum { PC_START = 0x3000 }; reg[R_PC] = PC_START; while (running) { uint16_t instr = mem_read(reg[R_PC]++); uint16_t op = instr >> 12; op_table[op](instr); } {Shutdown, 12} }