Kami menulis mesin virtual kami sendiri

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


  1. Daftar isi
  2. Pendahuluan
  3. Arsitektur LC-3
  4. Contoh Assembler
  5. Eksekusi program
  6. Implementasi instruksi
  7. Petunjuk Lembar Curang
  8. Prosedur Pemrosesan Interupsi
  9. Cheat sheet untuk rutinitas interupsi
  10. Unduh perangkat lunak
  11. Register yang Dipetakan Memori
  12. Fitur platform
  13. Startup mesin virtual
  14. 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:

/* 65536 locations */ 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, /* program counter */ 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, /* branch */ OP_ADD, /* add */ OP_LD, /* load */ OP_ST, /* store */ OP_JSR, /* jump register */ OP_AND, /* bitwise and */ OP_LDR, /* load register */ OP_STR, /* store register */ OP_RTI, /* unused */ OP_NOT, /* bitwise not */ OP_LDI, /* load indirect */ OP_STI, /* store indirect */ OP_JMP, /* jump */ OP_RES, /* reserved (unused) */ OP_LEA, /* load effective address */ OP_TRAP /* execute 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, /* P */ FL_ZRO = 1 << 1, /* Z */ FL_NEG = 1 << 2, /* N */ }; 

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:

  1. Unduh satu instruksi dari memori di alamat register PC .
  2. Tambah register PC .
  3. Lihat opcode untuk menentukan jenis instruksi apa yang harus diikuti.
  4. Ikuti instruksi menggunakan parameternya.
  5. 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} /* set the PC to starting position */ /* 0x3000 is the default */ enum { PC_START = 0x3000 }; reg[R_PC] = PC_START; int running = 1; while (running) { /* FETCH */ 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) /* a 1 in the left-most bit indicates negative */ { reg[R_COND] = FL_NEG; } else { reg[R_COND] = FL_POS; } } 

Sekarang kita siap menulis kode untuk ADD :

 { /* destination register (DR) */ uint16_t r0 = (instr >> 9) & 0x7; /* first operand (SR1) */ uint16_t r1 = (instr >> 6) & 0x7; /* whether we are in immediate mode */ 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:

 // the value of far_data is an address // of course far_data itself (the location in memory containing the address) has an address char* far_data = "apple"; // In memory it may be layed out like this: // Address Label Value // 0x123: far_data = 0x456 // ... // 0x456: string = 'a' // if PC was at 0x100 // LDI R0 0x023 // would load 'a' into R0 

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):

 { /* destination register (DR) */ uint16_t r0 = (instr >> 9) & 0x7; /* PCoffset 9*/ uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); /* add pc_offset to the current PC, look at that memory location to get the final address */ 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(); 

Sedikit "dan"


 { 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); } 

Bitwise tidak


 { 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.

 { /* Also handles RET */ 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; /* JSR */ } else { reg[R_PC] = reg[r1]; /* JSRR */ } 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 TRAPdengan kode dari subprogram yang diinginkan.



Setel enum untuk setiap kode interupsi:

 enum { TRAP_GETC = 0x20, /* get character from keyboard */ TRAP_OUT = 0x21, /* output a character */ TRAP_PUTS = 0x22, /* output a word string */ TRAP_IN = 0x23, /* input a string */ TRAP_PUTSP = 0x24, /* output a byte string */ TRAP_HALT = 0x25 /* halt the program */ }; 

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, PCdiatur 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, TRAPtambahkan 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 PUTSdigunakan untuk mengembalikan sebuah string dengan nol terminating (mirip printfdi C). Spesifikasi pada halaman 543.

Untuk menampilkan string, kita harus memberikan string interrupt untuk ditampilkan. Ini dilakukan dengan menyimpan alamat karakter pertama R0sebelum 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.

 { /* one char per word */ 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


 /* read a single ASCII char */ 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


 { /* one char per byte (two bytes per word) here we need to swap back to big endian format */ 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) { /* the origin tells us where in memory to place the image */ uint16_t origin; fread(&origin, sizeof(origin), 1, file); origin = swap16(origin); /* we know the maximum file size so we only need one fread */ uint16_t max_read = UINT16_MAX - origin; uint16_t* p = memory + origin; size_t read = fread(p, sizeof(uint16_t), max_read, file); /* swap to little endian */ 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. KBSRdan KBDRizinkanmenginterogasi keadaan perangkat sambil terus menjalankan program, sehingga tetap responsif sambil menunggu input.

 enum { MR_KBSR = 0xFE00, /* keyboard status */ MR_KBDR = 0xFE02 /* keyboard data */ }; 

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) { /* show usage string */ 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!

  1. Kompilasi program dengan kompiler favorit Anda .
  2. Unduh versi kompilasi 2048 atau Rogue .
  3. Jalankan program dengan file obj sebagai argumen:
    lc3-vm path/to/2048.obj
  4. 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 1dalam 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) { // Base + offset base_plus_off = reg[r1] + sign_extend(instr & 0x3f, 6); } if (0x4C0D & opbit) { // Indirect address pc_plus_off = reg[R_PC] + sign_extend(instr & 0x1ff, 9); } if (0x0001 & opbit) { // BR uint16_t cond = (instr >> 9) & 0x7; if (cond & reg[R_COND]) { reg[R_PC] = pc_plus_off; } } if (0x0002 & opbit) // ADD { if (imm_flag) { reg[r0] = reg[r1] + imm5; } else { reg[r0] = reg[r1] + reg[r2]; } } if (0x0020 & opbit) // AND { if (imm_flag) { reg[r0] = reg[r1] & imm5; } else { reg[r0] = reg[r1] & reg[r2]; } } if (0x0200 & opbit) { reg[r0] = ~reg[r1]; } // NOT if (0x1000 & opbit) { reg[R_PC] = reg[r1]; } // JMP if (0x0010 & opbit) // JSR { uint16_t long_flag = (instr >> 11) & 1; pc_plus_off = reg[R_PC] + sign_extend(instr & 0x7ff, 11); reg[R_R7] = reg[R_PC]; if (long_flag) { reg[R_PC] = pc_plus_off; } else { reg[R_PC] = reg[r1]; } } if (0x0004 & opbit) { reg[r0] = mem_read(pc_plus_off); } // LD if (0x0400 & opbit) { reg[r0] = mem_read(mem_read(pc_plus_off)); } // LDI if (0x0040 & opbit) { reg[r0] = mem_read(base_plus_off); } // LDR if (0x4000 & opbit) { reg[r0] = pc_plus_off; } // LEA if (0x0008 & opbit) { mem_write(pc_plus_off, reg[r0]); } // ST if (0x0800 & opbit) { mem_write(mem_read(pc_plus_off), reg[r0]); } // STI if (0x0080 & opbit) { mem_write(base_plus_off, reg[r0]); } // STR if (0x8000 & opbit) // TRAP { {TRAP, 8} } //if (0x0100 & opbit) { } // RTI if (0x4666 & opbit) { update_flags(r0); } } 

 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} } 

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


All Articles