Sisipan assembler ... dalam C #?

Maka, kisah ini dimulai dengan kebetulan tiga faktor. Saya:

  1. kebanyakan ditulis dalam C #;
  2. hanya secara kasar membayangkan bagaimana itu diatur dan bekerja;
  3. menjadi tertarik assembler.

Campuran yang tampaknya tidak bersalah ini memunculkan ide aneh: apakah mungkin untuk menggabungkan beberapa bahasa ini? Tambahkan C # kemampuan untuk melakukan insert assembler, seperti di C ++.

Jika Anda tertarik pada konsekuensi apa yang menyebabkan ini, selamat datang di kucing.



Kesulitan pertama


Bahkan pada saat itu, saya menyadari bahwa sangat tidak mungkin ada alat standar untuk memanggil kode assembler dari kode C # - ini terlalu bertentangan dengan salah satu konsep penting bahasa: keamanan memori. Setelah studi dangkal masalah (yang, antara lain, mengkonfirmasi firasat awal - "di luar kotak" tidak ada kemungkinan seperti itu), menjadi jelas bahwa selain masalah ideologis, ada masalah teknis murni: C #, seperti yang Anda tahu, dikompilasi menjadi bytecode perantara, yang selanjutnya ditafsirkan oleh mesin virtual CLR. Dan di sini kita dihadapkan dengan masalah yang sangat: di satu sisi, kompiler (selanjutnya saya maksudkan Roslyn dari Microsoft, karena ini adalah standar di bidang kompiler C #), jelas, tidak mengenali dan menerjemahkan perintah assembler dari tampilan teks ke representasi biner, yang berarti bahwa kita harus menggunakan instruksi mesin secara langsung dalam bentuk biner mereka sebagai sisipan, dan di sisi lain, mesin virtual memiliki bytecode sendiri dan tidak dapat mengenali dan menjalankan itu perintah paket yang kami tawarkan padanya.

Solusi teoritis untuk masalah ini jelas - Anda perlu memastikan bahwa kode penyisipan biner dijalankan oleh prosesor, melewati interpretasi mesin virtual. Hal paling sederhana yang terlintas dalam pikiran adalah untuk menyimpan kode biner sebagai array byte, yang kontrolnya akan ditransfer entah bagaimana pada waktu yang tepat. Dari sini tampak tugas pertama: Anda harus menemukan cara untuk mentransfer kontrol ke apa yang terkandung dalam area memori arbitrer.

Prototipe pertama: "memanggil" sebuah array


Tugas ini mungkin merupakan hambatan paling serius untuk memasukkan. Sangat mudah untuk menggunakan alat bahasa untuk mendapatkan pointer ke array kami, tetapi dalam C # world pointer hanya ada untuk data dan tidak mungkin untuk mengubahnya menjadi sebuah pointer, katakanlah, sebuah fungsi sehingga Anda dapat memanggilnya nanti (baik, atau setidaknya saya tidak tahu cara harus dilakukan).

Untungnya (atau sayangnya), tidak ada yang baru di bawah bulan dan pencarian cepat di Yandex untuk kata-kata "C #" dan "assembler inserts" membawa saya ke sebuah artikel di majalah edisi Desember 2007]] [Aker] . Setelah dengan jujur โ€‹โ€‹menyalin fungsi dari sana dan menyesuaikannya dengan kebutuhan saya, saya dapat

[DllImport("kernel32.dll")] extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect); public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code) { int i = 0; int* p = &i; p += 0x14 / 4 + 1; i = *p; fixed (byte* b = code) { *p = (int)b; uint prev; VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev); } return (void*)i; } 

Gagasan utama kode ini adalah mengganti alamat pengirim dari fungsi InvokeAsm() pada stack dengan alamat array byte yang ingin Anda transfer kontrolnya. Kemudian, setelah keluar dari fungsi, alih-alih melanjutkan eksekusi program, eksekusi kode biner kami akan dimulai.

Kami akan berurusan dengan sihir yang InvokeAsm() di InvokeAsm() lebih terinci. Pertama, kita mendeklarasikan variabel lokal, yang, tentu saja, muncul di stack, lalu kita mendapatkan alamatnya (sehingga mendapatkan alamat bagian atas stack). Selanjutnya, kita tambahkan konstanta ajaib tertentu yang diperoleh dengan menghitung dengan susah payah di debugger offset alamat pengirim relatif ke atas tumpukan, simpan alamat kembali dan tulis alamat array byte kami sebagai gantinya. Arti suci menyimpan alamat pengirim sudah jelas - kita perlu melanjutkan menjalankan program setelah penyisipan kita, yang berarti kita perlu tahu di mana harus mentransfer kontrol setelahnya. Selanjutnya adalah panggilan ke fungsi WinAPI dari perpustakaan kernel32.dll - VirtualProtect() . Diperlukan untuk mengubah atribut halaman memori di mana kode penyisipan berada. Tentu saja, ketika mengompilasi program, itu muncul di bagian data, dan halaman memori yang sesuai memiliki akses baca dan tulis. Kita juga perlu menambahkan izin untuk mengeksekusi isinya. Akhirnya, kami mengembalikan alamat pengirim nyata yang tersimpan. Tentu saja, alamat ini tidak akan dikembalikan ke kode yang disebut InvokeAsm() , karena eksekusi segera setelah return (void*)i; "Gagal" di sisipan. Namun, konvensi pemanggilan yang digunakan oleh mesin virtual (stdcall dengan optimasi dinonaktifkan dan fastcall dengan diaktifkan) berarti mengembalikan nilai melalui register EAX, mis. untuk kembali dari sisipan, kita harus mengikuti dua instruksi: push eax (kode 0x50) dan ret (kode 0xC3).

Klarifikasi
Di masa depan, kita akan berbicara tentang arsitektur x86 (atau lebih tepatnya, IA-32) - norak karena fakta bahwa pada waktu itu saya setidaknya entah bagaimana mengenalnya, tidak seperti, katakanlah, x86-64. Namun, metode transfer kontrol yang dijelaskan di atas harus berfungsi untuk kode 64-bit.

Akhirnya, Anda harus memperhatikan dua argumen yang tidak digunakan: void* firstAsmArg dan void* secondAsmArg . Mereka diperlukan untuk mentransfer data pengguna sewenang-wenang ke insert assembler. Argumen ini akan ditempatkan di tempat yang dikenal di stack (stdcall), atau, sekali lagi, di register terkenal (fastcall).

Sedikit tentang optimasi
Karena, dari sudut pandang kompiler, kode tidak mengerti apa itu, mungkin saja secara tidak sengaja membuang beberapa panggilan penting / sebaris sesuatu / tidak menyimpan beberapa argumen "tidak digunakan" / entah bagaimana mengganggu pelaksanaan rencana kami. Ini sebagian diselesaikan oleh [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] , namun, bahkan tindakan pencegahan seperti itu tidak memberikan efek yang diinginkan: misalnya, variabel lokal i , yang merupakan kunci untuk seluruh fungsi, tiba-tiba menjadi register, yang jelas-jelas merusak semuanya, . Oleh karena itu, untuk sepenuhnya menghilangkan kemungkinan ada sesuatu yang salah, Anda harus membangun perpustakaan dengan optimasi dinonaktifkan (baik menonaktifkannya di properti proyek atau menggunakan konfigurasi Debug). Akibatnya, stdcall akan digunakan, jadi di masa depan saya akan melanjutkan dari konvensi pemanggilan ini.

Perangkat tambahan


Aman lebih baik daripada tidak aman


Tentu saja, tidak ada pertanyaan tentang keamanan apa pun (dalam arti kata ini digunakan dalam C #). Namun, metode InvokeAsm() yang dijelaskan di atas beroperasi pada pointer, yang berarti bahwa itu hanya dapat dipanggil dari blok yang ditandai dengan kata kunci unsafe , yang tidak selalu nyaman - setidaknya itu memerlukan kompilasi dengan switch / tidak aman (atau tanda centang yang sesuai di properti proyek di VS). Oleh karena itu, tampaknya logis untuk menyediakan shell yang beroperasi setidaknya IntPtr (paling buruk), dan idealnya, memungkinkan pengguna untuk menentukan jenis yang akan dikirim dan dikembalikan. Nah, kedengarannya seperti generik, kita menulis generik, apa lagi yang ada, orang bertanya, untuk dibicarakan? Bahkan - ada sesuatu.

Yang paling jelas: bagaimana cara mendapatkan pointer ke argumen yang tipenya tidak diketahui? Konstruksi tipe T* ptr = &arg tidak diizinkan dalam C #, dan, secara umum, tidak sulit untuk memahami alasannya: pengguna dapat menggunakan salah satu dari jenis yang dikelola sebagai parameter tipe, penunjuk yang tidak dapat diperoleh. Solusinya bisa dengan membatasi parameter tipe unmanaged , tetapi, pertama, itu muncul hanya dalam C # 7.3, dan kedua, itu tidak memungkinkan melewati string dan array sebagai argumen, meskipun operator fixed memungkinkan mereka untuk digunakan (kita mendapatkan pointer ke yang pertama karakter atau elemen array, masing-masing). Selain itu, saya ingin memberi pengguna kesempatan untuk beroperasi termasuk jenis yang dikontrol - karena kami mulai melanggar aturan bahasa, kami akan melanggarnya sampai akhir!

Mendapatkan pointer ke objek yang dikelola dan objek dengan pointer


Dan lagi, setelah perundingan yang tidak membuahkan hasil, saya mulai mencari solusi akhir. Kali ini artikel tentang Habrรฉ membantu saya. Singkatnya, salah satu metode yang diusulkan di dalamnya adalah menulis perpustakaan bantu, dan bukan dalam C #, tetapi langsung di IL. Tugasnya adalah untuk mendorong objek (sebenarnya referensi ke objek) ke tumpukan mesin virtual, dilewatkan sebagai argumen, dan kemudian mengambil sesuatu yang lain dari tumpukan - misalnya, angka atau IntPtr . Dengan melakukan langkah-langkah yang sama dalam urutan terbalik, Anda dapat mengubah pointer (misalnya, dikembalikan dari insert assembler) menjadi objek. Metode ini baik karena semua yang terjadi jelas dan transparan. Tetapi ada kekurangannya: Saya ingin bertahan dengan sesedikit mungkin file, jadi alih-alih menulis perpustakaan terpisah, saya memutuskan untuk menyematkan kode IL di yang utama. Satu-satunya cara yang saya temukan adalah menulis metode rintisan dalam C #, membangun proyek, membongkar biner menggunakan ildasme, menulis ulang kode metode rintisan dan meletakkan semuanya kembali bersama-sama dengan ilasm. Ini adalah beberapa tindakan tambahan, dan mengingat bahwa Anda perlu melakukannya setiap kali Anda membuatnya setelah membuat perubahan pada kode ... Secara umum, saya bosan dengan hal itu dengan cepat, dan saya mulai mencari alternatif.

Tepat pada saat itu, sebuah buku yang luar biasa jatuh ke tangan saya, berkat yang saya pelajari banyak untuk diri saya sendiri - โ€œCLR via C #โ€ oleh Jeffrey Richter. Di dalamnya, di suatu tempat di sekitar bab kedua puluh, kami berbicara tentang struktur GCHandle , yang memiliki metode Alloc() yang mengambil objek dan salah satu GCHandleType enumerasi GCHandleType . Jadi, jika Anda memanggil metode ini dengan mengirimkannya objek yang diinginkan dan GCHandle.Pinned , Anda bisa mendapatkan alamat objek ini dalam memori. Selain itu, sebelum memanggil GCHandle.Free() objek sudah diperbaiki, mis. sepenuhnya dilindungi dari efek pengumpul sampah. Namun, ada masalah tertentu. Pertama-tama, GCHandle tidak membantu dengan cara apa pun untuk menyelesaikan konversi "pointer โ†’ object", hanya "object โ†’ pointer". Lebih penting lagi, untuk menggunakan GCHandleType.Pinned kelas atau struktur objek yang alamatnya ingin kita dapatkan harus memiliki [StructLayout(LayoutKind.Sequential)] , sedangkan LayoutKind.Auto digunakan secara LayoutKind.Auto . Jadi metode ini hanya cocok untuk beberapa jenis standar dan untuk jenis khusus yang awalnya dirancang dengan mempertimbangkan hal ini. Bukan metode universal yang ingin kita temukan, bukan?

Baiklah, coba lagi. Sekarang mari kita perhatikan dua fungsi tidak berdokumen, yang, bagaimanapun, didukung oleh Roslyn: __makeref() dan __refvalue() . Yang pertama mengambil objek dan mengembalikan instance dari struktur TypedReference yang menyimpan referensi ke objek dan tipenya, sedangkan yang kedua mengekstrak objek dari instance typedReference ditransmisikan. Mengapa fitur-fitur ini penting bagi kami? Karena TypedReference adalah struktur! Dalam konteks diskusi, ini berarti bahwa kita bisa mendapatkan pointer ke sana, yang, bersama-sama, akan menjadi pointer ke bidang pertama dari struktur ini. Yaitu, ia menyimpan tautan sangat ke objek yang menarik bagi kita. Kemudian, untuk mendapatkan pointer ke objek yang dikelola, kita perlu membaca nilai dengan pointer ke __makeref() akan kembali dan mengubahnya menjadi pointer. Untuk mendapatkan objek dengan pointer, Anda harus memanggil __makeref() dari objek kosong bersyarat dari tipe yang diperlukan, mendapatkan pointer ke instance TypedReference dikembalikan, menulis pointer ke objek di atasnya, dan kemudian memanggil __refvalue() . Hasilnya adalah sesuatu seperti kode ini:

 public static Tout ToInstance<Tout>(IntPtr ptr) { Tout temp = default; TypedReference tr = __makeref(temp); Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr); Tout instance = __refvalue(tr, Tout); return instance; } public static void* ToPointer<T>(ref T obj) { if (typeof(T).IsValueType) { return *(void**)&tr; } else { return **(void***)&tr; } } 

Komentar
Kembali ke tugas menulis pembungkus yang aman untuk InvokeAsm() , harus dicatat bahwa metode mendapatkan pointer menggunakan __makeref() dan __refvalue() , tidak seperti menggunakan GCHandle.Alloc(GCHandleType.Pinned) , tidak menjamin bahwa pengumpul sampah kami tidak ada di mana-mana objek tidak akan bergerak. Oleh karena itu, pembungkus harus dimulai dengan mematikan pengumpul sampah dan diakhiri dengan mengembalikan fungsinya. Solusinya agak kasar, tetapi efektif.

Bagi mereka yang tidak ingat opcodes


Jadi, kami belajar cara memanggil kode biner, belajar untuk tidak hanya memberikan nilai langsung, tetapi juga menunjuk ke apa pun sebagai argumen ... Hanya ada satu masalah. Di mana mendapatkan kode biner yang sama? Anda dapat mempersenjatai diri dengan pensil, notepad, dan tabel opcode (misalnya, yang ini ) atau mengambil hex editor dengan dukungan assembler x86 atau bahkan penerjemah penuh, tetapi semua opsi ini berarti bahwa pengguna harus menggunakan sesuatu yang lain kecuali perpustakaan. Ini bukan yang saya inginkan, jadi saya memutuskan untuk memasukkan penerjemah saya di perpustakaan, yang secara tradisional disebut SASM (kependekan dari Stack Assembler; itu tidak ada hubungannya dengan IDE ).

Penafian
Saya tidak pandai string parsing, jadi kode penerjemah ... baik, tidak sempurna, untuk sedikitnya. Selain itu, saya tidak kuat dalam ekspresi reguler, jadi mereka tidak ada di sana. Dan secara umum - pengurai berulang.

Saya mungkin tidak akan berbicara tentang proses menciptakan "keajaiban" ini - tidak ada yang menarik dalam cerita ini, tetapi saya akan menjelaskan secara singkat fitur-fitur utama. Sebagian besar instruksi x86 saat ini didukung. Instruksi coprocessor matematika untuk bekerja dengan angka floating point dan dari ekstensi (MMX, SSE, AVX) belum didukung. Dimungkinkan untuk mendeklarasikan konstanta, prosedur, variabel stack lokal, variabel global, memori yang dialokasikan selama terjemahan secara langsung dalam array dengan kode biner (jika variabel-variabel ini dinamai menggunakan label, maka nilainya juga dapat diperoleh dari C # setelah melakukan penyisipan dengan metode pemanggilan) GetBYTEVariable() , GetWORDVariable() , GetDWORDVariable() , GetAStringVariable() dan GetWStringVariable() dari objek SASMCode ), addr dan invoke makro hadir. Salah satu fitur penting adalah dukungan untuk mengimpor fungsi dari perpustakaan eksternal menggunakan extern < > lib < > construct.

asmret macro layak untuk paragraf terpisah. Dalam proses penerjemahan, terbentang dalam 11 instruksi yang membentuk epilog. Prolog ditambahkan ke awal kode yang diterjemahkan secara default. Tugas mereka adalah untuk menyimpan / mengembalikan keadaan prosesor. Selain itu, prolog menambahkan empat konstanta - $first , $second , $this dan $return . Selama penerjemahan, konstanta ini digantikan oleh alamat pada stack, di mana, masing-masing, adalah argumen pertama dan kedua dilewatkan ke insert assembler, alamat dari perintah insert pertama dan alamat pengirim.

Ringkasan


Kode itu akan mengatakan lebih dari sekadar kata-kata, dan akan aneh jika tidak membagikan hasil kerja yang cukup panjang, jadi saya mengundang semua orang yang saya tertarik ke GitHub .

Namun, jika saya mencoba untuk menggeneralisasi segala sesuatu yang telah dilakukan, maka, menurut pendapat saya, sebuah proyek yang menarik dan bahkan, sampai batas tertentu, tidak sia-sia. Sebagai contoh, algoritma identik untuk menyortir insert dalam C # dan menggunakan insert assembler berbeda dalam kecepatan lebih dari dua kali (tentu saja, lebih memilih assembler). Dalam proyek-proyek serius, tentu saja, tidak disarankan untuk menggunakan perpustakaan yang dihasilkan (efek samping yang tidak dapat diprediksi mungkin terjadi, meskipun sangat tidak mungkin), tetapi sangat mungkin untuk diri Anda sendiri.

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


All Articles