Stack frame dan penyelarasan

Prolog dan epilog berbasis rbp, penyelarasan stack 16 byte sebelum call, dan akibat melanggarnya.

Daftar isi

Setiap fungsi yang membutuhkan penyimpanan lokal membangun stack frame, yaitu wilayah stack miliknya sendiri selama fungsi berjalan. Seluruh pemanggilan fungsi di Linux x86-64 tunduk pada satu janji penyelarasan, yaitu rsp selalu kelipatan 16 pada setiap instruksi call. Topik ini membahas prolog dan epilog berbasis rbp, aturan penyelarasan beserta pembuktiannya, dan akibat yang nyata ketika janji itu dilanggar. Janji penyelarasan itu mengikat kedua pihak: pemanggil menata stack sebelum call, sedangkan yang dipanggil menjaganya selama berjalan.

Prolog dan epilog berbasis rbp#

Prolog standar terdiri atas dua instruksi, push rbp lalu mov rbp, rsp. Instruksi pertama menyimpan rbp milik pemanggil, instruksi kedua menyalin posisi stack saat itu ke rbp, sehingga rbp menjadi acuan tetap untuk seluruh akses lokal selama fungsi berjalan, misalnya [rbp-8] untuk variabel lokal pertama. Adapun rsp terus bergerak setiap kali ada push, pop, sub, atau call, sehingga akses melalui rsp harus mengikuti setiap pergeseran itu. Epilog membatalkan prolog dengan urutan terbalik, lalu ret mengembalikan kendali kepada pemanggil.

Acuan tetap tersebut juga dimanfaatkan perkakas di luar program itu sendiri. Setiap frame menyimpan rbp pemanggil tepat pada alamat yang ditunjuk rbp aktif, sehingga debugger dapat menyusuri rantai ini untuk berjalan mundur dari satu frame ke frame pemanggilnya. Fungsi tanpa frame pointer masih sah menurut ABI, tetapi kerangka rbp membuat penelusuran mundur dan inspeksi variabel lokal menjadi mudah.

Contoh berikut membangun frame dengan dua variabel lokal sekaligus memeriksa penyelarasan stack pada dua titik berbeda.

c utama.c
// tidak-dijalankan
#include <stdio.h>

long cek_entry(void);
long cek_setelah_prolog(void);
long jumlah_lokal(void);

int main(void) {
    printf("rsp mod 16 saat masuk fungsi        = %ld\n", cek_entry());
    printf("rsp mod 16 setelah prolog push rbp  = %ld\n", cek_setelah_prolog());
    printf("jumlah_lokal() dengan dua variabel  = %ld\n", jumlah_lokal());
    return 0;
}
nasm bingkai.asm
default rel

global cek_entry
global cek_setelah_prolog
global jumlah_lokal

section .text
cek_entry:
    mov rax, rsp        ; rsp pada instruksi pertama fungsi
    and rax, 15         ; sisa pembagian rsp oleh 16
    ret

cek_setelah_prolog:
    push rbp
    mov rbp, rsp
    mov rax, rsp        ; rsp setelah prolog
    and rax, 15
    pop rbp
    ret

jumlah_lokal:
    push rbp            ; prolog: simpan rbp pemanggil
    mov rbp, rsp        ; rbp menjadi acuan tetap frame
    sub rsp, 16         ; ruang dua variabel lokal 8 byte
    mov qword [rbp-8], 10
    mov qword [rbp-16], 32
    mov rax, [rbp-8]    ; baca variabel lokal pertama
    add rax, [rbp-16]   ; + variabel lokal kedua
    mov rsp, rbp        ; lepaskan ruang lokal
    pop rbp             ; epilog: pulihkan rbp pemanggil
    ret
bash
gcc -c utama.c -o utama.o
nasm -f elf64 bingkai.asm -o bingkai.o
gcc utama.o bingkai.o -o prog
./prog
text Keluaran
rsp mod 16 saat masuk fungsi        = 8
rsp mod 16 setelah prolog push rbp  = 0
jumlah_lokal() dengan dua variabel  = 42

Fungsi jumlah_lokal memesan 16 byte dengan sub rsp, 16, mengisi dua variabel pada [rbp-8] dan [rbp-16], menjumlahkannya, lalu memulihkan rsp dengan mov rsp, rbp sebelum pop rbp. Keluaran 42 berasal dari 10 + 32 yang tersimpan di frame, bukan dari register argumen. Gambar berikut menunjukkan posisi setiap bagian frame pada saat fungsi berjalan setelah prolognya.

argumen ke-7 dan seterusnyadidorong pemanggil sebelum callalamat kembalididorong instruksi callrbp pemanggildidorong push rbpvariabel lokalruang sub rsp, 16red zone 128 byteaman hanya untuk fungsi daunrbprsprsp kelipatan 16 pada setiap instruksi call
Tata letak stack frame standar setelah prolog

Janji penyelarasan 16 byte#

Kontrak menetapkan bahwa rsp selalu kelipatan 16 pada saat instruksi call dieksekusi. Instruksi call sendiri mendorong alamat kembali sebesar 8 byte, sehingga pada instruksi pertama fungsi nilai rsp selalu bersisa 8 terhadap 16. Angka 8 itulah yang dicetak cek_entry pada baris pertama keluaran di atas. Setelah prolog mendorong rbp, sisanya kembali menjadi 0, dan keadaan itulah yang wajib dipenuhi sebelum fungsi memanggil fungsi lain, sebagaimana dicetak oleh cek_setelah_prolog.

Alasan janjinya ada pada instruksi SSE yang memuat atau menyimpan data 16 byte dari memori. Misalnya, movaps menuntut alamat kelipatan 16 dan memicu kesalahan bila tidak. Compiler menghasilkan instruksi semacam itu untuk slot stack milik fungsi yang dipanggil dengan keyakinan bahwa stack selaras sesuai kontrak, sehingga kepatuhan pemanggil menjadi syarat berjalannya kode yang bahkan tidak ditulisnya sendiri.

Akibat stack tidak selaras#

Contoh berikut memperagakan mekanisme kegagalan secara langsung. Fungsi simpan_paket menggunakan movaps untuk menyimpan xmm0 ke slot lokalnya, dan operasi itu sah hanya bila pemanggil selaras. Pemanggil yang benar memanggilnya tanpa masalah, sedangkan panggil_salah menggeser rsp sebesar 8 byte sebelum call, sehingga stack tidak selaras pada instruksi call dan movaps di dalam simpan_paket menerima alamat yang bersisa 8.

c utama_crash.c
// tidak-dijalankan
#include <stdio.h>
#include <string.h>

int simpan_paket(void);
int panggil_salah(void);

int main(int argc, char **argv) {
    if (argc > 1 && strcmp(argv[1], "salah") == 0) {
        return panggil_salah();
    }
    printf("movaps berhasil, simpan_paket balik %d\n", simpan_paket());
    return 0;
}
nasm salah_selaras.asm
default rel

global simpan_paket
global panggil_salah

section .text
simpan_paket:
    push rbp
    mov rbp, rsp
    sub rsp, 16
    movaps [rsp], xmm0  ; butuh rsp selaras 16 byte, sesuai janji ABI
    mov eax, 1
    mov rsp, rbp
    pop rbp
    ret

panggil_salah:
    push rbp
    sub rsp, 8          ; stack kini tidak selaras saat call (rsp mod 16 = 8)
    pxor xmm0, xmm0
    call simpan_paket
    add rsp, 8
    pop rbp
    ret
bash
gcc -c utama_crash.c -o utama_crash.o
nasm -f elf64 salah_selaras.asm -o salah_selaras.o
gcc utama_crash.o salah_selaras.o -o prog-selaras
# pemanggilan dengan stack selaras
./prog-selaras
echo "exit=$?"
# pemanggilan tidak selaras: pesan shell disenyapkan, kode keluar tetap ditampilkan
sh -c './prog-selaras salah; echo "exit=$?"' 2>/dev/null
text Keluaran
movaps berhasil, simpan_paket balik 1
exit=0
exit=139

Keluaran menunjukkan panggilan selaras berjalan normal, sedangkan panggilan tak selaras diakhiri sinyal SIGSEGV yang tampak sebagai kode keluar 139. Baris Segmentation fault yang biasanya ditambahkan shell memuat nomor proses yang berbeda antar eksekusi, sehingga contoh menjalankan pemanggilan itu lewat sh -c dengan pesan shell disenyapkan dan hanya kode keluarnya yang ditampilkan. Pada program nyata, kegagalan semacam ini sering muncul jauh dari pemanggil yang salah, misalnya di dalam fungsi pustaka yang menggunakan instruksi SSE selaras pada slot stack-nya.

Karena kegagalannya melompat jauh dari sebabnya, pemeriksaan penyelarasan dilakukan di titik yang dicurigai dengan pola yang sama seperti cek_entry, yaitu mengambil sisa rsp terhadap 16 dan mengembalikannya sebagai nilai balik. Pola ini menempatkan pembuktian tepat pada batas antara pemanggil dan yang dipanggil, sehingga pihak yang melanggar janji dapat ditunjuk secara langsung.

Kesalahan umum#

Tiga kekeliruan berikut paling sering muncul pada pengelolaan frame dan penyelarasan.

  • Mendorong jumlah register genap sebelum call tanpa koreksi. Setiap push menggeser rsp sebesar 8 byte, dan karena stack bersisa 8 pada titik masuk fungsi, dua push setelah masuk fungsi, misalnya push rbp lalu push rbx, membuat rsp bersisa 8 pada instruksi call. Tambahkan sub rsp, 8 atau dorong satu register cadangan untuk mengembalikan kelipatan 16.
  • Menulis epilog yang tidak mencerminkan prolog. sub rsp, 16 pada prolog tanpa mov rsp, rbp atau add rsp, 16 pada epilog membuat ret menarik alamat kembali dari posisi yang keliru, dan program melompat ke alamat sembarangan. Pasangan mov rsp, rbp dan pop rbp dapat digantikan satu instruksi leave yang melakukan keduanya.
  • Mengandalkan red zone setelah ada panggilan fungsi. Wilayah 128 byte di bawah rsp hanya aman untuk fungsi daun, sehingga data yang dititipkan di sana hilang karena tertimpa begitu fungsi lain dipanggil.

Ringkasan#

  • Prolog push rbp dan mov rbp, rsp membangun acuan tetap frame, dan epilog memulihkannya sebelum ret.
  • rsp wajib kelipatan 16 pada setiap instruksi call, sehingga selalu bersisa 8 pada instruksi pertama fungsi dan kembali bersisa 0 setelah prolog.
  • Pelanggaran penyelarasan memicu SIGSEGV pada instruksi SSE selaras seperti movaps, sering kali jauh dari pemanggil yang salah.
  • Red zone 128 byte di bawah rsp hanya aman untuk fungsi daun yang tidak memanggil fungsi lain.

Referensi#