Apa yang terjadi di balik layar C #: dasar-dasar bekerja dengan tumpukan

Saya mengusulkan untuk melihat internal yang berada di belakang garis sederhana inisialisasi objek, metode memanggil, dan melewati parameter. Dan, tentu saja, kami akan menggunakan informasi ini dalam praktiknya - kami akan mengurangi tumpukan metode pemanggilan.

Penafian


Sebelum melanjutkan cerita, saya sangat menyarankan Anda untuk membaca posting pertama tentang StructLayout , ada contoh yang akan digunakan dalam artikel ini.

Semua kode di belakang kode tingkat tinggi disajikan untuk mode debug , karena ini menunjukkan basis konseptual. Optimasi JIT adalah topik besar terpisah yang tidak akan dibahas di sini.

Saya juga ingin memperingatkan bahwa artikel ini tidak mengandung bahan yang harus digunakan dalam proyek nyata.

Teori pertama


Kode apa pun akhirnya menjadi seperangkat perintah mesin. Paling dapat dimengerti adalah perwakilan mereka dalam bentuk instruksi bahasa Assembly yang secara langsung berhubungan dengan satu (atau beberapa) instruksi mesin.


Sebelum beralih ke contoh sederhana, saya mengusulkan untuk berkenalan dengan tumpukan. Stack adalah sebagian besar memori yang digunakan, sebagai suatu peraturan, untuk menyimpan berbagai jenis data (biasanya mereka dapat disebut data temporal ). Perlu diingat juga bahwa tumpukan tumbuh menuju alamat yang lebih kecil. Itulah kemudian suatu objek ditempatkan pada stack, semakin sedikit alamat yang dimilikinya.

Sekarang mari kita lihat bagian kode berikutnya dalam bahasa Assembly (saya telah 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 perlu diperhatikan adalah EBP dan ESP mendaftar dan beroperasi bersama mereka.

Kesalahpahaman bahwa register EBP entah bagaimana terkait dengan pointer ke atas tumpukan adalah umum di antara teman-teman saya. Saya harus mengatakan bahwa itu tidak benar.

Register ESP bertanggung jawab untuk menunjuk ke atas tumpukan. Sejalan dengan itu, dengan setiap instruksi PUSH (menempatkan nilai di atas tumpukan) nilai register ESP dikurangi (tumpukan tumbuh menuju alamat yang lebih kecil), dan dengan setiap instruksi POP itu bertambah. Juga, perintah CALL menekan alamat pengirim pada stack, sehingga mengurangi nilai register ESP . Bahkan, perubahan register ESP dilakukan tidak hanya ketika instruksi ini dijalankan (misalnya, ketika panggilan interupsi dilakukan, hal yang sama terjadi dengan instruksi CALL ).

Akan mempertimbangkan StubMethod () .

Di baris pertama, konten register EBP disimpan (diletakkan di atas tumpukan). Sebelum kembali dari suatu fungsi, nilai ini akan dikembalikan.

Baris kedua menyimpan nilai saat ini dari alamat bagian atas tumpukan (nilai ESP register dipindahkan ke EBP ). Selanjutnya, kami memindahkan bagian atas tumpukan ke posisi sebanyak yang kami perlukan untuk menyimpan variabel dan parameter lokal (baris ketiga). Sesuatu seperti alokasi memori untuk semua kebutuhan lokal - stack frame . Pada saat yang sama, register EBP adalah titik awal dalam konteks panggilan saat ini. Mengalamatkan didasarkan pada nilai ini.

Semua hal di atas disebut sebagai prolog fungsi .

Setelah itu, variabel pada stack diakses melalui register EBP yang disimpan, yang menunjuk pada tempat di mana variabel dari metode ini dimulai. Berikutnya adalah inisialisasi variabel lokal.

Pengingat panggilan cepat : di .net, digunakan konvensi panggilan cepat.
Konvensi panggilan mengatur lokasi dan urutan parameter yang dilewatkan ke fungsi.
Parameter pertama dan kedua dilewatkan melalui register ECX dan EDX , masing-masing, parameter selanjutnya ditransmisikan melalui stack. (Ini untuk sistem 32-bit, seperti biasa. Dalam sistem 64-bit, empat parameter melewati register ( RCX , RDX , R8 , R9 ))

Untuk metode non-statis, parameter pertama adalah implisit dan berisi alamat instance yang disebut metode (alamat ini).

Pada baris 4 dan 5, parameter yang melewati register (2 pertama) disimpan di stack.

Selanjutnya adalah membersihkan ruang pada stack untuk variabel lokal ( stack frame ) dan menginisialisasi variabel lokal.

Perlu disebutkan bahwa hasil fungsi ada di register EAX .

Pada baris 12-16, penambahan variabel yang diinginkan terjadi. Saya menarik perhatian Anda ke baris 15. Ada nilai mengakses dengan alamat yang lebih besar dari awal tumpukan, yaitu, ke tumpukan metode sebelumnya. Sebelum memanggil, pemanggil mendorong parameter ke bagian atas tumpukan. Di sini kita membacanya. Hasil penambahan diperoleh dari register EAX dan ditempatkan di stack. Karena ini adalah nilai pengembalian StubMethod () , ia ditempatkan lagi di EAX . Tentu saja, set instruksi yang absurd seperti itu hanya ada dalam mode debug, tetapi mereka menunjukkan dengan tepat seperti apa kode kita tanpa pengoptimal pintar yang melakukan sebagian besar pekerjaan.

Pada baris 18 dan 19, EBP (metode panggilan) sebelumnya dan penunjuk ke atas tumpukan dipulihkan (saat metode tersebut dipanggil). Baris terakhir adalah kembali dari fungsi. Tentang nilai 0x4 saya akan ceritakan nanti.

Urutan perintah semacam itu 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 dimasukkan ke register ( panggilan cepat ). Selanjutnya adalah pemanggilan metode StubMethod () . Sekarang mari kita ingat instruksi RET 0x4 . Di sini, pertanyaan berikut mungkin: apa itu 0x4? Seperti yang saya sebutkan di atas, kami telah mendorong parameter dari fungsi yang dipanggil ke stack. Tapi sekarang kita tidak membutuhkannya. 0x4 menunjukkan berapa banyak byte yang perlu dibersihkan dari stack setelah pemanggilan fungsi. Karena parameternya satu, Anda perlu menghapus 4 byte.

Berikut ini gambar kasar tumpukan:



Jadi, jika kita berbalik dan melihat apa yang ada di stack tepat setelah pemanggilan metode, hal pertama yang akan kita lihat EBP , yang didorong ke stack (pada kenyataannya, ini terjadi di baris pertama dari metode saat ini). Hal selanjutnya adalah alamat pengirim. Itu menentukan tempat, di sana untuk melanjutkan eksekusi setelah fungsi kita selesai (digunakan oleh RET ). Dan tepat setelah bidang ini kita akan melihat parameter dari fungsi saat ini (mulai dari ke-3, dua parameter pertama dilewatkan melalui register). Dan di belakang mereka tumpukan metode panggilan bersembunyi!

Kolom pertama dan kedua yang disebutkan sebelumnya ( EBP dan alamat pengirim) menjelaskan offset di + 0x8 ketika kita mengakses parameter.

Sejalan dengan itu, parameter harus di atas tumpukan dalam urutan yang ditentukan sebelum panggilan fungsi. Oleh karena itu, sebelum memanggil metode, setiap parameter didorong ke stack.
Tetapi bagaimana jika mereka tidak mendorong, dan fungsinya masih akan membawa mereka?

Contoh kecil


Jadi, semua fakta di atas telah menyebabkan saya keinginan yang luar biasa untuk membaca tumpukan metode yang akan memanggil metode saya. Gagasan bahwa saya hanya berada di satu posisi dari argumen ketiga (itu akan paling dekat dengan tumpukan metode panggilan) adalah data berharga yang ingin saya terima begitu banyak, tidak membiarkan saya tidur.

Jadi, untuk membaca tumpukan metode pemanggilan, saya perlu naik sedikit lebih jauh dari parameter.

Ketika merujuk ke parameter, perhitungan alamat parameter tertentu hanya didasarkan pada kenyataan bahwa pemanggil telah mendorong semuanya ke stack.

Tapi secara implisit melewati parameter EDX (yang tertarik - artikel sebelumnya ) membuat saya berpikir bahwa kita dapat mengakali kompiler dalam beberapa kasus.

Alat yang saya gunakan untuk melakukan ini disebut StructLayoutAttribute (semua fitur ada di artikel pertama ). // Suatu hari aku akan belajar sedikit lebih dari sekedar atribut ini, aku janji

Kami menggunakan metode favorit yang sama dengan tipe referensi yang tumpang tindih.

Pada saat yang sama, jika metode yang tumpang tindih memiliki jumlah parameter yang berbeda, kompilator tidak mendorong yang diperlukan ke stack (setidaknya karena tidak tahu yang mana).
Namun, metode yang sebenarnya disebut (dengan offset yang sama dari jenis yang berbeda), berubah menjadi alamat positif relatif terhadap tumpukannya, yaitu metode yang berencana menemukan parameternya.

Tapi tidak ada yang melewati parameter dan metode mulai membaca tumpukan metode pemanggilan. Dan alamat objek (dengan properti Id, yang digunakan dalam WriteLine () ) ada di tempat, di mana parameter ketiga diharapkan.

Kode ada di 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 assembly, semuanya cukup jelas di sana, tetapi jika ada pertanyaan, saya akan mencoba menjawabnya di komentar

Saya mengerti betul bahwa contoh ini tidak dapat digunakan dalam praktik, tetapi menurut pendapat saya, ini bisa sangat berguna untuk memahami skema kerja umum.

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


All Articles