Saya sarankan melihat segala sesuatu yang berdiri di belakang garis sederhana objek inisialisasi, metode memanggil, dan melewati parameter. Yah, tentu saja, menggunakan informasi ini dalam praktik mengurangi tumpukan metode panggilan.
Penafian
Sebelum memulai cerita, saya sangat menyarankan Anda membaca posting pertama tentang
StructLayout , karena ada contoh yang akan digunakan dalam artikel ini.
Semua kode di belakang level yang lebih tinggi disajikan untuk mode
debugging , dialah yang menunjukkan dasar konseptual. Juga, semua hal di atas dipertimbangkan untuk platform 32-bit. Optimasi JIT adalah topik terpisah dan besar yang tidak akan dipertimbangkan di sini.
Saya juga ingin memperingatkan bahwa artikel ini tidak mengandung bahan yang harus digunakan dalam proyek nyata.
Mulailah dengan teori
Kode apa pun akhirnya menjadi seperangkat perintah mesin. Yang paling dapat dimengerti adalah perwakilan mereka dalam bentuk instruksi bahasa Assembly yang berhubungan langsung dengan satu (atau beberapa) instruksi mesin.
Sebelum beralih ke contoh sederhana, saya sarankan Anda membiasakan diri dengan apa itu tumpukan perangkat lunak.
Tumpukan perangkat lunak pada dasarnya adalah sepotong memori yang digunakan, sebagai suatu peraturan, untuk menyimpan berbagai jenis data (sebagai suatu peraturan, mereka dapat disebut
data sementara ). Perlu diingat juga bahwa tumpukan tumbuh menuju alamat yang lebih rendah. Artinya, nanti objek didorong ke stack, semakin sedikit alamatnya.
Sekarang mari kita lihat bagian kode berikutnya dalam bahasa Assembler (saya menghilangkan beberapa panggilan yang melekat dalam mode debug).
C #:
public class StubClass { public static int StubMethod(int fromEcx, int fromEdx, int fromStack) { int local = 5; return local + fromEcx + fromEdx + fromStack; } public static void CallingMethod() { int local1 = 7, local2 = 8, local3 = 9; int result = StubMethod(local1, local2, local3); } }
ASM:
StubClass.StubMethod(Int32, Int32, Int32) 1: push ebp 2: mov ebp, esp 3: sub esp, 0x10 4: mov [ebp-0x4], ecx 5: mov [ebp-0x8], edx 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x10], edx 10: nop 11: mov dword [ebp-0xc], 0x5 12: mov eax, [ebp-0xc] 13: add eax, [ebp-0x4] 14: add eax, [ebp-0x8] 15: add eax, [ebp+0x8] 16: mov [ebp-0x10], eax 17: mov eax, [ebp-0x10] 18: mov esp, ebp 19: pop ebp 20: ret 0x4 StubClass.CallingMethod() 1: push ebp 2: mov ebp, esp 3: sub esp, 0x14 4: xor eax, eax 5: mov [ebp-0x14], eax 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x8], edx 10: xor edx, edx 11: mov [ebp-0x4], edx 12: xor edx, edx 13: mov [ebp-0x10], edx 14: nop 15: mov dword [ebp-0x4], 0x7 16: mov dword [ebp-0x8], 0x8 17: mov dword [ebp-0xc], 0x9 18: push dword [ebp-0xc] 19: mov ecx, [ebp-0x4] 20: mov edx, [ebp-0x8] 21: call StubClass.StubMethod(Int32, Int32, Int32) 22: mov [ebp-0x14], eax 23: mov eax, [ebp-0x14] 24: mov [ebp-0x10], eax 25: nop 26: mov esp, ebp 27: pop ebp 28: ret
Hal pertama yang harus Anda perhatikan adalah register dan operasi
EBP dan
ESP bersama mereka.
Kesalahpahaman di antara teman-teman saya adalah bahwa register
EBP entah bagaimana terkait dengan pointer ke atas tumpukan. Saya harus mengatakan bahwa ini tidak benar.
Register
ESP bertanggung jawab untuk penunjuk ke atas tumpukan. Dengan demikian, dengan setiap
perintah PUSH (ini menempatkan nilai di atas tumpukan), nilai register ini dikurangi (tumpukan tumbuh ke alamat yang lebih rendah), dan dengan setiap operasi
POP , ia bertambah. Perintah
CALL juga mendorong alamat pengirim ke stack, sehingga mengurangi nilai register
ESP juga. Bahkan, mengubah register
ESP tidak hanya dilakukan ketika instruksi ini dijalankan (misalnya, ketika panggilan interupsi dieksekusi, hal yang sama terjadi ketika instruksi
CALL dijalankan).
Pertimbangkan StubMethod.
Pada baris pertama, isi register
EBP disimpan (didorong ke stack). Sebelum kembali dari fungsi, nilai ini akan dikembalikan.
Baris kedua menyimpan nilai saat ini dari bagian atas alamat stack (nilai register
ESP dimasukkan dalam
EBP ). Dalam hal ini, register
EBP adalah semacam nol dalam konteks panggilan saat ini. Pengalamatan dilakukan relatif terhadap itu. Selanjutnya, kami memindahkan bagian atas tumpukan ke posisi sebanyak yang kami perlukan untuk menyimpan variabel dan parameter lokal (baris ketiga). Sesuatu seperti mengalokasikan memori untuk semua kebutuhan lokal.
Semua hal di atas disebut fungsi prolog.
Setelah itu, akses ke variabel pada stack terjadi melalui
EBP yang disimpan, yang menunjukkan tempat di mana variabel dari metode khusus ini dimulai.
Berikutnya adalah inisialisasi variabel lokal.
Pengingat tentang
panggilan cepat : .net asli menggunakan
konvensi pemanggilan panggilan cepat.
Perjanjian tersebut mengatur lokasi dan urutan parameter yang dilewatkan ke fungsi.
Dengan
fastcall, parameter pertama dan kedua dilewatkan melalui register
ECX dan
EDX , dan parameter selanjutnya dilewatkan melalui stack.
Untuk metode non-statis, parameter pertama implisit dan berisi alamat objek tempat metode dipanggil (alamat ini).
Pada baris 4 dan 5, parameter yang dikirim melalui register (2 pertama) disimpan di stack.
Selanjutnya adalah membersihkan ruang stack untuk variabel lokal dan menginisialisasi variabel lokal.
Perlu diingat bahwa hasil dari fungsi ada di register
EAX .
Pada baris 12-16, variabel yang diperlukan ditambahkan. Saya menarik perhatian Anda ke saluran 15. Ada panggilan ke alamat, lebih dari awal tumpukan, yaitu, ke tumpukan metode sebelumnya. Sebelum memanggil, metode panggilan mendorong parameter ke atas tumpukan. Di sini kita membacanya. Hasil penambahan diambil dari register
EAX dan didorong ke stack. Karena ini adalah nilai kembali StubMethod, maka ia ditempatkan lagi di
EAX . Tentu saja, set instruksi yang absurd semacam itu hanya melekat pada mode debug, tetapi mereka menunjukkan bagaimana kode kita terlihat tanpa pengoptimal yang pintar yang melakukan sebagian besar pekerjaan.
Baris 18 dan 19 mengembalikan
EBP sebelumnya (metode pemanggilan) dan penunjuk ke bagian atas tumpukan (saat metode itu dipanggil).
Baris terakhir kembali. Tentang nilai 0x4 saya akan katakan sedikit lebih rendah.
Urutan perintah ini disebut epilog fungsi.
Sekarang mari kita lihat CallingMethod. Mari kita langsung ke baris 18. Di sini kita meletakkan parameter ketiga di atas tumpukan. Harap dicatat bahwa kami melakukan ini menggunakan instruksi
PUSH , yaitu nilai
ESP dikurangi. 2 parameter lainnya ditempatkan di register (
panggilan cepat ). Berikutnya adalah panggilan ke metode StubMethod. Sekarang ingat instruksi
RET 0x4 . Pertanyaan berikut mungkin di sini: apa itu 0x4? Seperti yang saya sebutkan di atas, kami mendorong parameter fungsi yang dipanggil ke stack. Tapi sekarang kita tidak membutuhkannya. 0x4 menunjukkan bahwa byte harus dihapus dari stack setelah pemanggilan fungsi. Karena ada satu parameter, Anda perlu menghapus 4 byte.
Berikut adalah contoh gambar tumpukan:

Jadi, jika kita berbalik dan melihat apa yang ada di belakang tumpukan segera setelah pemanggilan metode, hal pertama yang akan kita lihat adalah
EBP didorong ke tumpukan (pada kenyataannya, ini terjadi pada baris pertama dari metode saat ini). Selanjutnya, akan ada alamat pengirim yang mengatakan di mana eksekusi akan dilanjutkan (digunakan oleh instruksi
RET ). Dan melalui bidang-bidang ini kita akan melihat parameter sendiri dari fungsi saat ini (Mulai dari ke-3, parameter dikirimkan melalui register sebelumnya). Dan di belakang mereka ada tumpukan metode panggilan itu sendiri!
Bidang pertama dan kedua yang disebutkan menjelaskan offset pada + 0x8 saat merujuk ke parameter.
Dengan demikian, parameter harus terletak di bagian atas tumpukan dalam urutan yang ditentukan secara ketat ketika fungsi dipanggil. Oleh karena itu, sebelum memanggil metode, setiap parameter didorong ke stack.
Tetapi bagaimana jika Anda tidak mendorong mereka, dan fungsinya masih akan menerimanya?
Contoh kecil
Jadi, semua fakta yang disebutkan di atas membuat saya memiliki keinginan yang tak tertahankan untuk membaca tumpukan metode yang akan memanggil fungsi saya. Pikiran yang secara harfiah dalam satu posisi dari argumen ketiga (akan paling dekat dengan tumpukan metode pemanggilan) adalah data berharga yang sangat ingin saya dapatkan, tidak membiarkan saya tidur.
Jadi, untuk membaca tumpukan metode pemanggilan, saya perlu sedikit lebih jauh dari parameter.
Ketika mengacu pada parameter, perhitungan alamat parameter hanya didasarkan pada fakta bahwa metode pemanggilan mendorong mereka semua ke stack.
Tetapi implisit melewati parameter
EDX (siapa yang peduli -
artikel terakhir ) menunjukkan bahwa kita dapat mengecoh kompilator dalam beberapa kasus.
Alat yang saya lakukan ini disebut StructLayoutAttribute (fitur dalam
artikel pertama ). // Suatu hari aku akan belajar sesuatu selain atribut ini, aku janji.
Kami menggunakan semua teknik favorit yang sama dengan tipe referensi.
Pada saat yang sama, jika metode yang tumpang tindih memiliki jumlah parameter yang berbeda, kita mendapatkan bahwa kompiler tidak akan mendorong yang kita butuhkan ke stack (seperti imajiner, karena tidak tahu yang mana).
Namun, metode yang sebenarnya disebut (dengan offset yang sama dari jenis lain) membahas alamat plus relatif terhadap tumpukannya, yaitu metode yang berencana menemukan parameternya.
Tetapi di sana dia tidak menemukan mereka dan mulai membaca tumpukan metode pemanggilan.
Kode Spoiler using System; using System.Runtime.InteropServices; namespace Magic { public class StubClass { public StubClass(int id) { Id = id; } public int Id; } [StructLayout(LayoutKind.Explicit)] public class CustomStructWithLayout { [FieldOffset(0)] public Test1 Test1; [FieldOffset(0)] public Test2 Test2; } public class Test1 { public virtual void Useless(int skipFastcall1, int skipFastcall2, StubClass adressOnStack) { adressOnStack.Id = 189; } } public class Test2 { public virtual int Useless() { return 888; } } class Program { static void Main() { Test2 objectWithLayout = new CustomStructWithLayout { Test2 = new Test2(), Test1 = new Test1() }.Test2; StubClass adressOnStack = new StubClass(3); objectWithLayout.Useless(); Console.WriteLine($"MAGIC - {adressOnStack.Id}"); // MAGIC - 189 } } }
Saya tidak akan memberikan kode bahasa assembler, semuanya cukup jelas di sana, tetapi jika Anda memiliki pertanyaan, saya akan mencoba menjawabnya di komentar
Saya sangat memahami bahwa contoh ini tidak dapat digunakan dalam praktik, tetapi menurut pendapat saya, ini bisa sangat berguna untuk memahami skema kerja umum.