Prosedur adalah blok kode yang dipanggil dari satu tempat dan diharapkan kembali ke tempat yang sama. Agar kembalinya tepat sasaran, pasangan call dan ret bekerja bersama stack. Topik ini membahas cara call mendorong alamat kembali sebelum melompat, cara ret menariknya untuk pulang, perpindahan kendali yang bersarang hingga beberapa tingkat, serta pembacaan keduanya di debugger.
Mekanisme call dan ret#
call melakukan dua hal dalam satu instruksi, yaitu mendorong alamat instruksi berikutnya ke stack, lalu menulis alamat prosedur tujuan ke rip. Alamat yang didorong itu disebut alamat kembali, karena ret kelak menggunakannya untuk pulang. ret pada bentuk yang paling umum tidak membawa operan, hanya menarik satu qword dari puncak stack dan menulisnya ke rip, sehingga eksekusi berlanjut tepat setelah call di pemanggil.
Alamat tujuan call hadir dalam dua bentuk yang umum. Bentuk relatif tidak menuliskan alamat lengkap, melainkan selisih antara alamat tujuan dan alamat instruksi berikutnya, sehingga encoding-nya tetap sama bila seluruh program ditempatkan pada alamat lain. Bentuk tidak langsung mengambil alamat tujuan dari register atau memori, misalnya call rax pada pemanggilan melalui pointer fungsi. ret juga memiliki bentuk yang membawa operan, yaitu ret imm16, yang menambahkan bilangan tertentu pada rsp setelah alamat kembali ditarik, dan bentuk itu digunakan oleh konvensi pemanggilan yang menugaskan pembersihan argumen stack kepada prosedur yang dipanggil.
Kedua instruksi itu saling melengkapi. Setiap call menambah satu alamat kembali di stack dan setiap ret menghapus satu. Selama stack tetap seimbang, urutan pulang otomatis benar meskipun pemanggilan bersarang hingga beberapa tingkat, karena nilai yang terakhir masuk selalu pulang lebih dahulu. Push manual di tengah prosedur dapat menggeser puncak stack, dan efeknya terhadap ret dibahas pada bagian kesalahan umum.
Prosedur dua tingkat dan alamat kembali#
Program berikut memanggil prosedur_a, dan prosedur_a memanggil prosedur_b. Nilai 5 ditetapkan di prosedur_b, bertambah 3 di prosedur_a, dan menjadi kode keluar 8 di _start.
; panggil.asm
; Perpindahan kendali bersarang dengan call dan ret.
; prosedur_a memanggil prosedur_b, lalu setiap ret
; mengembalikan kendali kepada pemanggilnya.
default rel
section .text
global _start
_start:
call prosedur_a ; alamat kembali dari _start didorong
mov edi, eax ; berjalan kembali setelah prosedur_a selesai
mov eax, 60
syscall
prosedur_a:
call prosedur_b ; alamat kembali dari prosedur_a didorong
add eax, 3 ; 5 + 3 menjadi 8
ret ; pulang ke _start
prosedur_b:
mov eax, 5 ; nilai balikan
ret ; pulang ke prosedur_a# rakit, tautkan, dan jalankan
nasm -f elf64 panggil.asm -o panggil.o && ld panggil.o -o panggil && ./panggil; echo "exit=$?"
# masuk ke prosedur_b, baca dua alamat kembali, lalu ikuti ret
gdb -batch -ex 'break prosedur_b' -ex 'run' -ex 'p/x *(unsigned long *) $rsp' -ex 'p/x *(unsigned long *) ($rsp + 8)' -ex 'x/9i _start' -ex 'finish' -ex 'info registers rip' ./panggilexit=8
Breakpoint 1 at 0x401017
Breakpoint 1, 0x0000000000401017 in prosedur_b ()
$1 = 0x401013
$2 = 0x401005
0x401000 <_start>: call 0x40100e <prosedur_a>
0x401005 <_start+5>: mov %eax,%edi
0x401007 <_start+7>: mov $0x3c,%eax
0x40100c <_start+12>: syscall
0x40100e <prosedur_a>: call 0x401017 <prosedur_b>
0x401013 <prosedur_a+5>: add $0x3,%eax
0x401016 <prosedur_a+8>: ret
=> 0x401017 <prosedur_b>: mov $0x5,%eax
0x40101c <prosedur_b+5>: ret
0x0000000000401013 in prosedur_a ()
rip 0x401013 0x401013 <prosedur_a+5>Dua baris pertama keluaran debugger memuat dua alamat kembali sekaligus. Nilai $1 = 0x401013 adalah alamat kembali milik call prosedur_b, dan peta disassembly di bawahnya memperlihatkan alamat itu menunjuk add $0x3,%eax, yaitu instruksi tepat setelah call tersebut di dalam prosedur_a. Nilai $2 = 0x401005 adalah alamat kembali milik call prosedur_a, yang menunjuk mov %eax,%edi di dalam _start. Keduanya tertata sesuai LIFO, yaitu alamat milik pemanggilan yang lebih dalam berada di puncak stack.
Perintah finish menjalankan sisa prosedur_b, dan kendali mendarat di 0x401013, persis alamat kembali pertama, yang juga tampil pada baris rip. Inilah bukti bahwa ret menarik puncak stack ke rip. Setelah prosedur_a selesai, ret miliknya menarik 0x401005 dan kendali pulang ke _start. Alamat absolutnya dapat berbeda antar mesin, tetapi hubungannya tetap, yaitu setiap alamat kembali selalu menunjuk instruksi tepat setelah call pemanggilnya.
Kesalahan umum#
Dua kesalahan paling merusak pada penggunaan call dan ret menyangkut kondisi stack pada saat ret dijalankan.
Stack tidak seimbang saat ret. Satu push di dalam prosedur tanpa pop pendamping menggeser puncak stack 8 byte, sehingga ret menarik nilai yang salah sebagai alamat kembali.
prosedur_rusak:
push rax ; didorong, tetapi tidak pernah ditarik
ret ; memakai isi rax sebagai alamat kembaliPerbaikannya adalah memastikan setiap push di dalam prosedur memiliki pop pendamping, atau puncak stack dikembalikan ke posisi semula sebelum ret dijalankan. Kesalahan kedua adalah ret yang terlupa. Prosedur yang tidak diakhiri ret tidak berhenti, melainkan melanjutkan eksekusi ke label berikutnya secara berurutan. Hasilnya kadang tampak benar karena label berikutnya memang jalur yang diinginkan, tetapi kendali akhirnya mendarat di instruksi yang seharusnya tidak dijalankan. Perbaikannya adalah memastikan setiap jalur yang selesai di dalam prosedur diakhiri ret.
Fondasi bagi setiap pemanggilan fungsi#
Mekanisme call dan ret hanya mengatur perpindahan kendalinya, sedangkan keputusan lain ditetapkan oleh konvensi pemanggilan, yaitu register mana yang boleh ditimpa oleh prosedur, di mana argumen diletakkan, dan di mana nilai balikan diserahkan. Apa pun konvensinya, mesin hanya menjalankan dua instruksi yang sama, yaitu call yang mendorong alamat kembali dan ret yang menariknya.
Setiap pemanggilan fungsi pada program C yang dikompilasi berujung pada pasangan ini, dan setiap tingkat pemanggilan menambah satu alamat kembali di stack. Itulah sebabnya stack yang rusak di satu prosedur dapat memutus jalur pulang seluruh pemanggilan di atasnya. Label prosedur pun hanyalah nama untuk satu alamat, sehingga pada tingkat mesin tidak ada pembeda lain antara prosedur dan kode biasa selain kesepakatan tentang cara masuk dan cara pulang.
Ringkasan#
- call mendorong alamat instruksi berikutnya ke stack, lalu melompat ke prosedur tujuan, dan ret menarik alamat itu ke rip untuk pulang.
- Pemanggilan bersarang menumpuk alamat kembali sesuai LIFO, sehingga prosedur terdalam selalu pulang lebih dahulu.
- Alamat kembali dapat dibaca di debugger melalui isi puncak stack dan dipetakan ke instruksinya melalui disassembly.
- Stack yang tidak seimbang membuat ret menarik alamat yang salah, dan errornya baru terlihat saat program berjalan.
Referensi#
- Intel 64 and IA-32 Architectures Software Developer Manuals — Intel — Volume 1 menjelaskan CALL dan RET, termasuk bentuk near yang dipakai di sini.
- System V Application Binary Interface, AMD64 Architecture — x86-64 psABI maintainers — Menetapkan ketentuan pemanggilan yang berdiri di atas mekanisme call dan ret ini.