- Dalam proses menulis ulang emulator CPU untuk Time Travel Debugging dengan C++, x86/amd64 perlu menangani perilaku yang sama secara berbeda bergantung pada encoding, prefix, dan mode eksekusi
- Ada banyak representasi alternatif yang memengaruhi performa dan debugging, seperti encoding satu byte
CCuntukint 3, bentuk singkatADD EAX, imm, dan prefix REX yang tidak berdampak - Instruksi
INC/DEC,CMPXCHG8B/CMPXCHG16B, serta shift/rotate mudah berujung pada bug emulator karena cara penanganan flag-nya tidak intuitif - Shift count tidak diterapkan apa adanya sesuai ukuran operand, melainkan di-mask; karena itu
shr eax,20htidak membuat register 32-bit menjadi 0, tetapi mempertahankan nilainya - Segment masih digunakan untuk akses TEB di Windows 32-bit dan 64-bit, dan perbedaan makna
FS/GSserta cara penentuan base berdampak langsung pada implementasi disassembler dan emulator
Aturan detail x86 yang terungkap saat menulis ulang emulator TTD
- Salah satu komponen Time Travel Debugging adalah emulator CPU yang merekam seluruh eksekusi proses pada tingkat instruksi
- Emulator pada versi pertama, iDNA, hampir seluruhnya ditulis dalam assembly sehingga cepat, tetapi sulit dipelihara dan diperluas
- Pada versi kedua, bagian emulasi dan kemudian sebagian besar bagian lain ditulis ulang dalam C++, dengan tujuan mempertahankan sebagian besar performa versi assembly sambil memiliki basis kode yang lebih mudah dikelola
- Untuk membuat emulator CPU, detail perilaku CPU harus dicocokkan tanpa terlewat; bahkan aturan yang akrab bagi orang berpengalaman dengan x86 pun harus diverifikasi ulang saat diimplementasikan secara nyata
Encoding x86 yang mengekspresikan instruksi yang sama dengan berbagai cara
- x86 dapat mengekspresikan instruksi yang sama sebagai beberapa rangkaian byte
int 3dapat di-encode sebagaiCD 03, tetapi juga dapat di-encode sebagaiCCsatu byte- Karena digunakan sebagai breakpoint perangkat lunak, breakpoint dapat dipasang bahkan pada posisi instruksi di akhir halaman memori ketika halaman berikutnya tidak dipetakan
- Ada juga encoding alternatif untuk mempersingkat kasus umum
add eax, immdapat direpresentasikan secara singkat seperti05cccccccc- Untuk menambahkan nilai yang sama ke
ECX, diperlukan 1 byte tambahan, seperti81c1cccccccc
EAXdisebut “Accumulator register” bukan sekadar karena konvensi, tetapi karena benar-benar menimbulkan perbedaan encoding; instruksi pendek dapat mengurangi data yang harus dipindahkan dari memori utama dan penggunaan cache instruksi, sehingga bisa menguntungkan performa- Compiler dapat memanfaatkan encoding pendek seperti ini jika memungkinkan
Prefix dan batas panjang instruksi 15 byte
- Instruksi x86 dapat memiliki byte prefix yang mengubah perilakunya
- Prefix REX yang umum dipakai pada kode 64-bit digunakan untuk mengakses rentang register yang lebih luas daripada kode 32-bit
- CPU juga menerima prefix REX yang tidak berdampak
4004ccadalah bentuk dengan byte REX di depanadd al,0CCh8-bit, tetapi dalam kasus ini REX tidak memiliki efek apa pun- CPU tetap dapat mengeksekusi dua prefix REX yang dipasang berurutan, dan beberapa disassembler, termasuk WinDbg, dapat menjadi bingung karenanya
- Pada CPU yang kompatibel dengan x86, panjang instruksi saat ini memiliki batas keras 15 byte
- Instruksi yang melebihi 15 byte dianggap sebagai instruksi tidak valid dan memicu exception
- CPU lama juga memiliki batasan lain untuk prefix, dan beberapa prefix seperti
LOCKmemiliki syarat penggunaan yang lebih ketat
Interpretasi yang berubah tergantung ukuran alamat dan mode
- Prefix address override dapat membuat mode 64-bit merujuk ke alamat 32-bit
488d0424adalahlea rax,[rsp]67488d0424menjadilea rax,[esp]karena prefix0x67
- Pada kode 32-bit, address override mengubah mode alamat menjadi alamat 16-bit
- Rangkaian byte yang sama pun memerlukan pengetahuan tentang ukuran operand default dan ukuran alamat dari code segment agar dapat di-disassemble atau diinterpretasikan dengan benar
8b0424dalam mode 32-bit adalahmov eax,dword ptr [esp]8b0424dalam mode 64-bit adalahmov eax,dword ptr [rsp]
- Rentang
40~4Fyang digunakan untukINC regdanDEC regdi x86 dipakai sebagai byte prefix REX di x64- Dalam mode 32-bit,
48 03 04 24diinterpretasikan sebagai dua instruksi:dec eaxdanadd eax,dword ptr [esp] - Dalam mode 64-bit,
48030424diinterpretasikan sebagai satu instruksi:add rax,qword ptr [rsp]
- Dalam mode 32-bit,
- Perancang AMD64 menggunakan ruang encoding luas milik
INC/DECsebagai prefix baru untuk memperluas set register dalam mode 64-bit, sementara instruksi tersebut sudah memiliki encoding lain yang mendukung register maupun memori
Jebakan mode 64-bit pada INC reg di WinDbg
- WinDbg selalu meng-assemble instruksi seolah-olah dalam mode 32-bit, sehingga jika mencoba meng-assemble
INC regpada kode 64-bit, hasilnya bisa berbeda dari yang dimaksudkan - Dalam contoh,
inc eaxbukan menjadi instruksi increment yang sebenarnya, melainkan berubah menjadi prefix REX yang tidak berguna yang memodifikasi instruksi berikutnya - Akibatnya, rangkaian byte tersebut diinterpretasikan bukan sebagai
inc, melainkan sebagai prefix sebelum instruksijmp
Pengecualian dalam perilaku flag
INC EAXtampak mirip denganADD EAX, 1, tetapi tidak sepenuhnya samaADDmemperbarui carry flagINCtidak memperbarui carry flag
- Saat mengimplementasikan emulator TTD, perbedaan ini awalnya diimplementasikan secara keliru, lalu ditemukan melalui unit test
- Sebagian besar operasi aritmetika dan logika menetapkan overflow, sign, zero, auxiliary carry, parity, dan carry flag
CMPXCHGjuga menetapkan flag seperti itu, tetapiCMPXCHG8BdanCMPXCHG16Bhanya mengubah zero flag- Beberapa instruksi membiarkan sebagian flag dalam keadaan tidak terdefinisi
- Instruksi shift dan rotate membiarkan overflow flag tidak terdefinisi jika shift amount lebih besar dari 1
- Perilaku aktual flag yang tidak terdefinisi berkaitan dengan implementasi internal operasi shift dan dapat berbeda antar-arsitektur
- Ada cerita bahwa CPU keluarga Atom melakukan bit shift di ALU dengan cara yang lebih murah dan lebih lambat sehingga nilai flag yang tidak terdefinisi menjadi berbeda, tetapi hal ini tidak diuji langsung
Masking count pada instruksi shift
66c1e810adalahshr ax,10h, yang menggeserAXke kanan sebanyak 16 bit- Karena
AXadalah register 16-bit, hasilnya menjadi 0
- Karena
c1e820adalahshr eax,20h, dan di permukaan tampak seperti instruksi yang menggeserEAXke kanan sebanyak 32 bit- Namun kenyataannya, nilai
EAXtidak berubah- Berdasarkan Intel SDM, count di-mask dengan
1Fh, sehingga hanya 5 bit terbawah dari rotasi yang digunakan - Jika memakai prefix
REX.W, mask menjadi3Fh, sehingga nilai shift maksimum menjadi 63 bit
- Berdasarkan Intel SDM, count di-mask dengan
- Perilaku ini pernah benar-benar menjadi masalah dalam pertanyaan wawancara Microsoft tentang “semua cara untuk meng-clear register 32-bit dengan satu instruksi”
- Pewawancara mengira hal itu bisa dilakukan dengan shift, tetapi jawabannya adalah tidak bisa untuk register 32-bit
Segment yang tetap hidup dalam kode 32-bit dan 64-bit
- Memori segment mungkin terlihat seperti peninggalan kode 16-bit, tetapi tetap memiliki efek nyata dalam kode 32-bit dan 64-bit
- Sebagian besar OS menggunakan model memori yang hampir flat dan menaruh segment base address pada 0, sehingga biasanya tidak terlalu disadari
- Dalam mode 64-bit, CPU selalu memperlakukan segment base
CS,DS,ES, danSSsebagai 0
- Dalam mode 64-bit, CPU selalu memperlakukan segment base
- Sebagai pengecualian, thread local storage menggunakan extra segment register seperti
FSatauGS - Sebagai koreksi, base segment
FS/GSjuga dapat dibaca oleh kode non-privileged menggunakan instruksirdfsbase,wrfsbase,rdgsbase, danwrgsbase- Instruksi-instruksi ini tersedia sejak Ivy Bridge, yaitu sejak 2012
Akses TEB Windows dan FS/GS
- Di Windows,
FSdanGSdigunakan untuk merujuk ke TEB (Thread Execution Block) - Struktur TEB memiliki pointer self yang menunjuk ke flat address pada awal struktur, dan alamat ini juga merupakan base dari segment terkait
- Pada proses 32-bit, TEB berada di
FSGetLastErrormengambilTEB.NtTib.Selfdarifs:[00000018h], lalu membacaLastErrorValuedari[eax+34h]
- Pada proses 64-bit, TEB berada di
GSGetLastErrormembaca pointer darigs:[30h], lalu mengambil nilainya dari[rax+68h]
- Proses 32-bit yang berjalan di OS 64-bit memiliki TEB 32-bit dan TEB 64-bit sekaligus, dan ada konteks berguna ketika perlu mengakses keduanya, seperti kode WOW 64-bit yang berjalan di dalam proses 32-bit
Cara menentukan segment base juga berbeda menurut mode
- Pengaturan CPU yang menentukan base address
FSdanGSberbeda antara mode 32-bit dan mode 64-bit - Dalam mode 32-bit, nilai sebenarnya dari segment register merujuk ke segment descriptor yang didefinisikan di Global Descriptor Table dan Local Descriptor Table
- Dalam mode 64-bit, base dikendalikan oleh dua MSR
FS Base,IA32_FS_BASEdalam Intel SDMGS Base,IA32_GS_BASEdalam Intel SDM
- Karena struktur ini, dalam mode 64-bit nilai register aktual
FSdanGSitu sendiri tidak penting- Yang penting adalah prefix segment override
- Saat men-debug proses 32-bit di WinDbg, nilai register
FSdapat digunakan untuk men-dump isi “FS segment” - Pada proses 64-bit, cara yang sama tidak bekerja; yang bermakna adalah prefix segment override, bukan nilai segment
Pelajaran praktis bagi implementor emulator
- Membuat emulator x86 mengharuskan penanganan detail perilaku nyata CPU seperti encoding instruksi, prefix, flag, shift count, dan segment
- Banyak dari aturan ini hampir tidak berguna untuk penulisan kode biasa, tetapi menjadi kebutuhan langsung dalam implementasi emulator
- Banyak hal dipelajari melalui trial and error serta mentoring, dan turut diperkenalkan juga Darek Mihocka, yang memiliki pengalaman panjang dengan emulator, beserta emulators.com
- Jika tertarik pada optimasi x86 dan perilaku low-level, materi di situs web Agner Fog berguna
1 komentar
Komentar Hacker News
Sebagai tambahan, BSF/BSR juga punya keanehan. Intel SDM mengatakan jika input-nya 0, nilai tujuan tidak terdefinisi, tetapi AMD mendokumentasikan bahwa dalam kasus itu tujuan tidak diubah
Namun glibc begitu saja memakai fakta tak terdokumentasi bahwa di Intel pun tujuan tidak diubah [1]. Karena ini, saya butuh cukup lama untuk menemukan sumber masalah di binary translator saya
Selain itu, TZCNT/LZCNT adalah encoding BSF/BSR dengan prefiks F3, tetapi pada prosesor lama yang tidak mendukung ekstensi ini, prefiks tersebut diam-diam diabaikan. Jadi kode yang sama berperilaku berbeda tergantung CPU, tetapi setidaknya hal ini terdokumentasi
Dalam encoding, orang sering mengeluhkan prefiks, tetapi secara pribadi menurut saya itu bukan yang terburuk. Itu sudah dikenal luas dan sampai tingkat tertentu juga terdokumentasi. Ada keanehan yang lebih buruk. Misalnya bit ekstensi REX/VEX/EVEX.RXB diabaikan jika tidak berlaku pada targetnya, tetapi pada register mask k0-k7 akan memicu #UD. Namun jika register dienkode di ModRM.rm, bit ekstensi itu kembali diabaikan
APX menaikkan level keanehan satu tingkat lagi. Prefiks REX2 bisa meng-encode register serbaguna r16-r31, tetapi tidak bisa xmm16-xmm31, dan prefiks EVEX memiliki beberapa layout tergantung opcode; bit ekstensi yang dipakai untuk register juga berbeda tergantung jenis registernya. Register XMM memakai X3:B3:rm dan V4:X3:idx, sedangkan register serbaguna memakai B4:B3:rm dan X4:X3:idx. Sudah 1 tahun berlalu dan saya masih belum menyelesaikan decoder APX, jadi saya tidak bisa memberikan daftar lengkapnya
[1]: https://sourceware.org/bugzilla/show_bug.cgi?id=31748
Untuk EVEX, saya berencana mempertahankan bit mentah sampai opcode dibaca dan kelas EVEX diidentifikasi. Artinya, rencananya bit itu dipertahankan sampai sebelum immediate, mungkin sampai tahap sebelum ModRM
Decoder saya sebagian besar berbasis tabel di manual, dan kodenya umumnya cukup baik. Indentasinya tidak berlebihan dan sebagian besar tahap dipisahkan atau mudah dikenali. Karena output-nya adalah kode JIT, tidak perlu sangat efisien, dan lebih baik mudah dibaca. Ini juga bukan tempat sebagian besar waktu dihabiskan
Meski begitu, ada beberapa kasus ketika manual salah atau tidak menceritakan semuanya. Tabelnya juga sudah bertahun-tahun tidak diperbarui, misalnya tidak ada instruksi register K. Ke depannya sepertinya akan ada lebih banyak pekerjaan manual
Situasinya sedikit dijelaskan di komentar paling atas: https://github.com/qemu/qemu/blob/59084feb256c617063e0dbe7e6...
Seperti disebutkan di atas, masih ada beberapa instruksi yang ditangani kode lama, terutama BT/BTS/BTR/BTC. Kodenya sudah ditulis, tetapi belum digabungkan
Namun ada prefiks 0x66 yang mengalihkan mode 16-bit dan 32-bit. Jika ini diterapkan pada BSWAP EAX, terjadi hal aneh yang tidak terdefinisi
Pada beberapa arsitektur CPU, seperti perbedaan Intel dan AMD, prefiksnya sekadar diabaikan; pada yang lain terjadi perilaku yang saya sebut “swap internal”. Misalnya, dari empat byte yang tersimpan di EAX, byte nomor 1 dan 2 saling bertukar
0x11223344 menjadi 0x11332244
Dulu x86 adalah moat Intel, tetapi sekarang tampak seperti beban mimpi buruk yang harus terus dipikul
Memang ada fungsi bernama clz(), tetapi jika LZCNT hanyalah BSR dengan semantik input 0 yang berbeda, biaya menambahkan pengurangan ekstra dalam implementasi demi kompatibilitas rasanya kecil saja
Jika program yang sama juga dijalankan pada CPU yang diemulasikan dan statusnya diverifikasi sama pada setiap instruksi, kita bisa menguji apakah emulator benar-benar meniru hardware secara sempurna
Orang yang keren. Menulis assembly terasa sederhana, dan saya juga suka estetika susunan vertikalnya
Pengalaman saya yang sedikit mendekati pekerjaan seperti OP hanyalah ketika mencoba membuat teman yang menulis JS memahami stack, lalu bersama-sama membuat VM mini dengan ISA kecil: https://gist.github.com/darighost/2d880fe27510e0c90f75680bfe...
Kami bisa saja menggali lebih dalam dan saya berharap kami melakukannya, tetapi mungkin itu akan menyimpang dari tujuan pendidikan semula. Saya harus menghubungi teman itu untuk menanyakan apakah ia masih ingin belajar bersama. Ia menghasilkan banyak uang dari pengembangan web yang keren sehingga tidak punya waktu untuk mendalami, sedangkan saya pengangguran sehingga punya waktu dan energi yang nyaris seperti lautan tak terbatas, jadi tidak mudah
Justine Tunney dan emulatornya juga layak dilihat. https://justine.lol/blinkenlights/
Dokumentasinya memberi tur yang sangat bagus tentang bagaimana CPU bekerja
Diskusi sebelumnya ada di sini: https://news.ycombinator.com/item?id=34636699
Sulit dipercaya sudah 16 bulan berlalu. Waktu benar-benar cepat
Saya sangat tidak setuju dengan pendapat bahwa “menulis emulator CPU adalah cara terbaik untuk benar-benar memahami cara kerja CPU”
Cara terbaik adalah membuat CPU pada level gate, seperti yang dilakukan di kelas ilmu komputer yang bagus. Proyek membuat ARM yang diperkecil dari nol dulu benar-benar menyenangkan
Membuat emulator CPU modern adalah tantangan yang lebih mudah dijangkau, dan meski hanya sebagian yang berfungsi, efek edukatifnya tetap besar
Kebanyakan software engineer punya pemahaman yang tidak lengkap tentang data hazard, cache invalidation, dan pipeline stall
Namun begitu mulai membuat CPU seperti itu untuk pekerjaan bermakna yang ingin dipakai, detail-detail itu mulai terasa kurang menarik. Pada titik itu, kompleksitas perilaku dan spesifikasi menjadi lebih menarik, dan pendekatan emulator lebih mudah ditangani serta bisa mencakup lebih banyak jenis perilaku
Saya sudah menulis emulator cepat untuk sekitar selusin arsitektur yang bukan mainan, dan juga beberapa penerjemah JIT, tetapi x86 masih membuat saya PTSD. Saya belum pernah melihat arsitektur sekotor ini. Memang ada sejarah dan alasannya, tetapi tetap saja parah
Baru-baru ini, sebagai proyek sampingan, saya mengimplementasikan sebagian besar decoder x86-64 [1], dan cukup terkejut bahwa sekarang semuanya menjadi lebih rumit. Untuk tujuan saya, Sandpile.org [2] sangat berguna
[1] Tepatnya versi x86-64 dari disfilter milik Fabian Giesen yang saya buat untuk proyek sampingan lain yang belum dipublikasikan: https://gist.github.com/lifthrasiir/df47509caac2f065032ef72e...
[2] https://sandpile.org/
Disassembler 68k yang saya buat di universitas terasa seperti momen ketika Neo berkata, “Saya tahu kungfu.” Itu adalah mata rantai yang hilang yang membuat saya bisa bernalar tentang kode dari bahasa tingkat tinggi sampai transistor, lalu kembali lagi
Kalau menulis emulator penuh, rasanya efeknya bisa satu orde magnitudo lebih besar. Tulisan yang bagus
Sepertinya ingatan saya salah. Saya ingat varian salsa20 dan kode mesin awalnya ada di cryp.to, tetapi situs Dan Bernstein adalah https://cr.yp.to/
Saat dulu di startup kami meninjau enkripsi data tersimpan, enkripsi streaming, dan sebagainya, halaman Dan memiliki banyak implementasi berdasarkan chipset dan instruction set target. Itu di-cross-compile dari representasi assembler miliknya
Menarik menggunakan VM dan melihat instruction set apa saja yang didukung pada awal hingga pertengahan 2000-an. Saat pengujian, kadang muncul masalah karena VM mengklaim mendukung sesuatu, tetapi implementasinya belum sepenuhnya mendukung
Menarik melihat banyak pembicaraan di sini tentang betapa menyakitkannya assembly x86 dibandingkan RISC. Saya mengalami masalah yang justru sebaliknya ketika memisahkan kembali kode menjadi object file
Untuk kegunaan ini, analisis x86 benar-benar mudah, sedangkan MIPS seperti mimpi buruk. Ini terutama karena yang saya pedulikan adalah referensi ke kode dan data. x86 punya konstanta immediate seukuran pointer, sedangkan MIPS punya pasangan relokasi HI16/LO16 yang berkelindan dengan graf penggunaan register, alur kode, dan instruksi branch delay, sehingga menimbulkan berbagai macam masalah
Bukan berarti saya sedang memuji x86
Hal terbesar yang dipelajari saat membandingkan assembly x86 dan C adalah bahwa signed/unsigned bukan lagi tipe, melainkan properti dari operasi
Akan bagus kalau bisa memanfaatkan flag, dan di beberapa arsitektur seperti PPC atau armv7 itu lebih mudah, tetapi di x86 flag terlalu mudah ditimpa sehingga sangat sulit memanfaatkan nilainya