Maka, kisah ini dimulai dengan kebetulan tiga faktor. Saya:
- kebanyakan ditulis dalam C #;
- hanya secara kasar membayangkan bagaimana itu diatur dan bekerja;
- 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).
KlarifikasiDi 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 optimasiKarena, 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; } }
KomentarKembali 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 ).
PenafianSaya 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.