Jadi kita semua sudah mengkompilasi program sebelumnya, tapi tahukah Anda bagaimana komputer Anda membagi dan menyimpan bagian-bagian berbeda dari program tersebut? Bersabarlah, hal seperti ini membuatku kewalahan pada awalnya. Ayo masuk.

Program yang dikompilasi dibagi menjadi lima segmen: teks, data, bss, heap, dan stack.

Segmen teks adalah tempat instruksi bahasa mesin dari program berada. Ketika suatu program mulai dieksekusi, RIP (register yang menunjuk ke instruksi yang sedang dijalankan), diatur ke instruksi bahasa mesin pertama di segmen teks. Prosesor kemudian mengikuti loop eksekusi saat menjalankan instruksi:

  1. Membaca instruksi yang ditunjuk RIP
  2. Menambahkan panjang byte instruksi ke RIP
  3. Menjalankan instruksi yang dibaca pada langkah 1
  4. Kembali ke langkah 1

Anda tidak dapat menulis ke segmen teks memori. Upaya apa pun untuk melakukan hal ini akan mengakibatkan program terhenti. Segmen memori teks berukuran tetap.

Bagian data digunakan untuk menyimpan variabel global dan statis yang diinisialisasi.

Bagian bss digunakan untuk menyimpan variabel global dan statis yang tidak diinisialisasi.

Kedua bagian memori ini dapat ditulis meskipun ukurannya tetap. Variabel global dan statis dapat bertahan — apa pun konteks fungsinya — karena disimpan dalam segmen memorinya sendiri.

Bagian heap memori berada langsung di bawah kendali pemrogram. Di C, pemrogram dapat menggunakan fungsi malloc() untuk mengalokasikan memori secara dinamis pada heap. Tumpukan tersebut tidak berukuran tetap dan dapat bertambah besar atau kecil. Pertumbuhan heap “bergerak ke bawah menuju alamat memori yang lebih tinggi” (Hacking: The Art of Exploitation, Jon Erickson). Mari kita ilustrasikan hal ini dengan program yang membuat alokasi memori berikutnya pada heap:

Mari kita jalankan program untuk melihat alamat memori:

Alamat pertama yang dicetak adalah 0x21c5010 dan alamat kedua di bawahnya (secara visual) pada 0x21c51b0 (alamat yang lebih tinggi).

Bagian tumpukan memori digunakan untuk menyimpan variabel fungsi lokal dan konteks selama pemanggilan fungsi. Tumpukan tersebut tidak berukuran tetap. Saat suatu fungsi dipanggil, “fungsi tersebut akan memiliki kumpulan variabel yang diteruskannya sendiri, dan kode fungsi tersebut akan berada di lokasi memori yang berbeda di segmen teks. Karena konteks dan RIP harus berubah ketika suatu fungsi dipanggil, tumpukan digunakan untuk mengingat semua variabel yang diteruskan, lokasi RIP harus kembali setelah fungsi selesai, dan semua variabel lokal yang digunakan oleh fungsi tersebut,” (Erikson). Informasi ini disimpan pada bingkai tumpukan. Tumpukan (tumpukan memori) terdiri dari banyak bingkai tumpukan yang berbeda. Berlawanan dengan heap, tumpukan tumbuh ke atas (secara visual) menuju alamat memori yang lebih rendah.

Karena tumpukan dan heap keduanya bersifat dinamis, artinya ukurannya berubah bergantung pada berapa banyak memori yang digunakan pemrogram, masuk akal jika keduanya tumbuh dalam arah yang berlawanan satu sama lain. Ini “meminimalkan ruang yang terbuang, memungkinkan tumpukan menjadi lebih besar jika tumpukannya kecil dan sebaliknya,” (Erickson).

Register RSP menyimpan alamat akhir tumpukan. Penting untuk dicatat bahwa register RSP dimanipulasi secara implisit oleh beberapa instruksi CPU: PUSH, POP, CALL RET, dll. Inilah sebabnya kita tidak melihat instruksi perakitan yang mengatur ulang nilai register RSP setelah instruksi CALL.

Ketika suatu fungsi dipanggil, beberapa hal dimasukkan ke dalam frame tumpukan: register RBP (juga dikenal sebagai framer pointer (FP)) yang “digunakan untuk mereferensikan variabel fungsi lokal dalam frame tumpukan saat ini, […] parameter untuk fungsi, variabel lokalnya, dan dua penunjuk yang diperlukan untuk mengembalikan semuanya seperti semula: penunjuk bingkai tersimpan (SFP) dan alamat pengirim. SFP digunakan untuk mengembalikan EBP ke nilai sebelumnya, dan alamat pengirim digunakan untuk memulihkan RIP ke instruksi berikutnya yang ditemukan setelah pemanggilan fungsi,” (Erickson). Register RBP hanya dimanipulasi secara eksplisit.

Mari kita ilustrasikan apa yang terjadi pada stack ketika kita menjalankan fungsi sederhana:

Mari kita bongkar fungsi main():

Anda dapat melihat bahwa setiap parameter untuk test_function() dimasukkan ke dalam tumpukan. Ketika instruksi CALL dijalankan, “alamat pengirim dimasukkan ke dalam tumpukan dan aliran eksekusi melompat ke awal test_function() pada 0x40055d,” (Erickson). Alamat pengirim dalam hal ini adalah instruksi yang mengikuti instruksi CALL. Setelah test_function() kembali, RIP akan menunjuk ke 0x4005be.

Mari kita bongkar test_function():

Perhatikan bahwa RBP didorong ke tumpukan. Ini disebut sebagai penunjuk bingkai tersimpan (SFP) dan kemudian digunakan untuk mengembalikan RBP ke bingkai sebelumnya. RSP kemudian disalin ke RBP untuk mengatur penunjuk bingkai baru. Hal ini masuk akal karena RBP digunakan untuk merujuk pada variabel fungsi lokal dalam bingkai tumpukan saat ini. Perhatikan juga bahwa RBP dimanipulasi secara eksplisit. Terakhir, kita melihat bahwa 40 dikurangi dari nilai RSP, ini untuk menghemat memori untuk buffer dan flag variabel lokal.

Kita dapat mengamati bagaimana perubahan tumpukan menggunakan GDB. Pertama mari tambahkan beberapa breakpoint:

Mari beralih ke break point berikutnya:

Anda dapat melihat bahwa register RSP, RBP, dan RIP berpindah ke ruang alamat yang lebih rendah. Kami juga melihat bahwa perbedaan antara RSP dan RBP adalah 40 yang masuk akal karena instruksi “SUB RSP, 0X40” yang kami lihat di pembongkaran test_functions.

Pada akhirnya, bingkai tumpukan terlihat seperti ini:

Setelah test_function() berakhir, bingkai tumpukan akan dikeluarkan dari tumpukan dan RIP disetel ke alamat pengirim sehingga program dapat terus dijalankan. RBP juga disetel ke nilai penunjuk bingkai yang disimpan sehingga dapat mereferensikan variabel lokal di bingkai tumpukan sebelumnya. Siklus frame tumpukan yang dibuat dan dihentikan ini berlanjut hingga program selesai dijalankan.