Calling convention pada AMD64, ARM64, dan RISCV64

Artikel ini merupakan lanjutan dari artikel Hello, World! sebelumnya yang akan memperkenalkan calling convention pada arsitektur AMD64, ARM64 dan RISCV64. Program assembly pada artikel sebelumnya sangat sederhana: tidak ada percabangan, tidak ada pemanggilan fungsi, hanya memakai syscall. Kali ini saya ingin membahas mengenai: pembuatan fungsi, percabangan, dan pemanggilan fungsi.

Calling Convention

Jika kita membuat seluruh program sendiri, tidak memanggil fungsi apapun yang lain, maka kita punya kebebasan memakai register manapun juga untuk kebutuhkan apapun. Misalnya kita ingin memanggil fungsi, parameter pertama bisa di register r0, parameter kedua di r1, dst. Atau terserah kalau mau mulai dari r5 juga boleh.

Tapi ketika kita ingin memakai library atau kode orang lain, maka kita perlu memiliki semacam standard/konvensi agar berbagai program bisa berinteroperasi. Istilah untuk ini adalah calling convention, sebuah calling convention biasanya menyatakan:

  • Bagaimana passing parameter, register mana yang dipakai (atau apakah langsung dipassing menggunakan stack)
  • Di register mana hasil kembalian fungsinya
  • Register-register mana saja yang boleh diubah di dalam subrutin/fungsi (scratch registers), atau disebut juga caller saved registers
  • Register-register mana saja yang harus disimpan (must be preserved) dalam subrutin/fungsi, atau callee saved registers

Untuk register yang harus disimpan, maksudnya: ketika keluar dari subrutin, maka nilai register tersebut harus sama dengan ketika masuk. Artinya kita boleh saja mengubah register tersebut di dalam fungsi, asalkan kita simpan dulu, entah di stack atau di tempat lain, dan sebelum kembali (return), nilai registernya dikembalikan lagi.

Sedangkan untuk register yang boleh diubah (scratch registers), sudah jelas artinya bahwa ini bisa diubah oleh subrutin manapun. Kita harus memperhatikan: jika kita memakai salah satu register tersebut, lalu memanggil subrutin lain, maka tidak dijamin nilainya akan tetap sama setelah subrutin itu selesai.

Di tulisan ini saya akan mengabaikan register floating point. Ini hanya untuk mempersingkat saja,. Contoh fungsi di artikel ini juga tidak menggunakan floating point, jadi tidak akan terlalu berguna jika dibahas sekarang.

Artikel ini tidak terlalu detail, dan untuk hal yang kompleks, sebaiknya cek lagi standard masing-masing arsitektur dan teknologi yang digunakan. Contohnya: beberapa bahasa pemrograman tidak memakai calling convention seperti yang disebutkan di sini.

Parameter Program (command line arguments)

Ketika kita menjalankan program, kita bisa memberikan parameter ke program, seperti ini:

./namaprogram param1 param2

Di dalam bahasa C, ini deklarasi main yang biasanya adalah seperti ini:

int main(int argc, char *argv[]);

Jumlah argumen ada di argc, dan isinya ada di array argv. Sebuah program setidaknya punya 1 argumen, yaitu nama program saat dijalankan.

Di Linux, jumlah parameter program akan diberikan pada nilai di alamat yang ditunjuk oleh stack pointer (register rsp di AMD64, register sp di ARM64/RISCV64). Pointer ke argumen pertama ada di nilai yang ditunjuk oleh stack pointer + 8, atau secara umum nilai pointer argumen ke n ada di stack pointer + 8*n (dengan n mulai dari 1).

Program yang akan saya tunjukkan hanya akan melakukan hal berikut:

  • mengambil argv[0] (akan selalu ada, jadi tidak perlu dicek)
  • mencari panjangnya (memakai fungsi sendiri, tidak memanggil library C strlen)
  • mencetak isi argv[0]

Fungsi strlen

Syscall write butuh jumlah karakter yang ingin diprint. Di tulisan sebelumnya karena stringnya sudah diketahui panjangnya maka saya hardcode nilainya. Sedangkan dalam kasus program yang akan kita buat ketika program dimulai kita hanya diberi null terminated string (serangkaian karakter yang diakhiri dengan byte 0) yang tidak diketahui panjangnya sebelumnya. Jadi untuk memprint, kita perlu mengimplementasikan fungsi untuk mencari panjang string.

Dalam C, fungsi ini adalah strlen. Cara kerja fungsinya sangat sederhana. Kita menggunakan counter yang akan kita tambah dengan 1 sampai menemukan karakter \0. Kira-kira seperti ini dalam C (kita asumsikan input s tidak NULL).

size_t mystrlen(const char *s) 
{
       size_t ctr = 0;
       while (*s++ != 0) {
            ctr++;
       }   
       return ctr;
}

Program itu bisa disederhanakan tanpa menggunakan aritmatika pointer. Intinya adalah kita ingin menghitung jumlah karakter sampai dengan karakter NUL (\0)

size_t my_strlen_simple(const char *s)
{
       size_t ctr = 0;
       int idx = 0;
       while (1) {
            char c = s[idx];
            if (c=='\0') {
                break;
            }
            idx++;
            ctr++;
       } 
       return ctr;
}

Kita bisa saja langsung memanggil fungsi strlen di C tapi di tulisan ini akan saya implementasikan dari awal dengan berbagai alasan:

  • Kita ingin membuat program murni assembly, tidak memakai library apapun
  • Fungsi ini sangat sederhana, hanya beberapa baris assembly
  • Fungsi ini memiliki kondisi (if) dan loop, dan mengakses memory (array of characters).

Calling convention AMD64

Di AMD64 ada dua calling convention, Microsoft dan System-V tapi saya hanya akan membahas System-V (UNIX System V) yang standardnya diikuti oleh Linux dan berbagai OS lain (misalnya FreeBSD dan macOS). Saya hanya akan membahas kasus sederhana, detailnya bisa dicek di artikel wikipedia (yang ada link ke masing-masing standard).

Nama dan urutan register di AMD64 cukup rumit dan perlu dihapal karena sejarahnya panjang. Dulu di jaman 8086 (masih 16 bit), nama registernya adalah: AX, BX, CX, DX, SI, DI, BP, SP, dan register AX, BX, CX, dan DX bisa diakses low dan high bytenya dengan: AL, AH, BL, BH, CL, CH, DL, dan DH. Ketika Intel membuat sistem 32 bit, registernya diextend menjadi: EAX, EBC, ECX, EDX, ESI, EDI, EBP, ESP dan register lama dipertahankan dan tetap bisa diakses. Ketika AMD, membuat sistem 64 bit, ini diteruskan, sekarang regsiternya menjadi RAX, RBX, RCX, dan RDX, RSI, RDI, RBP, RSP, dan semua register lama juga bisa diakses. Tapi sekarang ada tambahan register baru juga: R8, R9, R10, R11, R12, R13, R14, dan R15.

Sebagai catatan: alasan saya menuliskan assembly 64 bit saja di seri artikel ini adalah: ada banyak keanehan jika kita harus memprogram dan memikirkan arsitektur sebelum 64 bit. Contoh keanehan: dulunya isi register SI,DI,BP,SP hanya bisa diakses sebagai register 16 bit saja (tidak seperti AX, BX, CX, dan DX yang bisa diakses bagian terendah 8 bitnya melalui AL, BL, CL, dan DL), tapi sejak AMD64, semua jadi lebih konsisten dan semua bisa diakses sesuai dengan tabel di bawah ini.

x86_64 registers
Pemetaan regsiter di AMD64

Beberapa parameter pertama masuk ke register dengan urutan berikut: RDI, RSI, RDX, RCX, R8, R9. Jika ada fungsi yang parameternya banyak, maka bisa masuk ke stack dengan urutan RTL (Right to Left). Register RBX, RSP, RBP, R12, R13, R14, dan R15 nilainya tidak boleh diubah (preserved), sementara register parameter (RDI, RSI, RDX, RCX, R8, R9) boleh diubah, plus R10 dan R11 juga boleh diubah. Nilai kembalian di register RAX.

.text
.globl  _start
        .type   _start, @function
mystrlen:
        movq $0, %rax
 L1:
        movb (%rdi),%bl
        cmp $0, %bl
        jz L2
        inc %rax
        inc %rdi
        jmp  L1
L2:
        ret
_start:
        movq  8(%rsp),%rdi
        call mystrlen
        movq %rax, %rdx
        movq  8(%rsp), %rsi
        movq $1, %rax
        movq %rax, %rdi
        syscall

        movq    $60, %rax
        syscall

Untuk fungsi mystrlen implementasinya di AMD64 adalah:

  • Register rax dipakai sebagai counter. Sesuai calling convention hasil ada di rax jadi berapapun nilai terakhir register rax akan menjadi hasil kembalian fungsinya
  • Parameter ada di rdi
  • Instruksi movb (%rdi),%bl akan mengisi register BL dengan karakter dari input. Register BL adalah 8 bit terbawah dari register rbx
  • Jika nilai bl sudah 0 (dari cmp $0, bl), maka jump ke L2, yaitu label exit, di L2 ada ret yang akan kembali dari fungsi
  • Jika bl belum nol, maka nilai rax (counter) ditambah 1 dengan (inc %rax), dan kita ingin memproses karakter berikutnya, jadi rdi (input) perlu ditambah dengan 1 juga

Bagian memanggil fungsinya:

  • Kita pindahkan inputnya ke rdi, sesuai konvensi parameter pertama masuk ke rdi
  • Kita ambil outputnya dari rax (sesuai konvensi)
  • Kita pindahkan nilainya ke register yang sesuai untuk memanggil syscall

Calling convention ARM64

ARM64 memiliki calling convention sebagai berikut: x0 sampai x7 dipakai untuk parameter. Register x9-x15 bisa dipakai sembarang. Register x19 sampai x29 harus disimpan (must be preserved). Nilai kembalian di register x0 (x1-x7 juga bisa dipakai untuk bahasa yang bisa mengembalikan lebih dari 1 nilai). Register x8 khusus jika kita ingin mengembalikan struct.

.text
.align 4

.globl  _start
        .type   _start, @function

mystrlen:
        mov x1, x0
        mov x0, #0
        L0:
        ldrb w2, [x1]
        cmp x2, 0
        b.eq L1
        add x1, x1, 1
        add x0, x0, 1
        b L0
L1:
        ret

_start:
        ldr x0, [sp, 8]
        bl mystrlen
        mov x2, x0
        ldr x1, [sp, 8]

        mov x8, #64
        mov x0, #1
        svc #0

        mov x8, #93
        svc #0

Untuk fungsi mystrlen implementasinya di ARM64 adalah:

  • Input ada di x0, karena kembalian juga perlu di x0 kita punya dua opsi: mengcopy nilai input ke register lain, atau memakai register lain untuk counter, dan di akhir kita pindah ke x0, saya pilih cara pertama (dicopy ke x1)
  • Counter menggunakan temporary register x0, diisi dengan 0 menggunakan mov
  • Isi karakter saat masuk ke w2 dengan ldrb (load byte), w2 adalah versi 32 bit dari register x2
  • Register x2 dibandingkan dengan 0 (zero), dengan cmp. Lalu hasil perbandingannya dicek dengan b.eq dan jika hasilnya sama, maka keluar ke L1, dan sesuai calling convention sudah ada di x0 jadi tidak perlu dipindah.
  • Jika tidak sama dengan 0 maka instruksi berikutnya akan menambah nilai a1 dan t1 dengan menggunakan add

Bagian memanggil fungsinya:

  • Kita pindahkan inputnya ke x0, sesuai konvensi parameter pertama masuk ke x0 kita memakai ldr x0,[sp,8] untuk mendapatkan nilai yang ditunjuk oleh alamat sp+8
  • Pemanggilan fungsi dilakukan dengan bl mystrlen (branch and link)
  • Kita ambil outputnya mystrlen dari x0 (sesuai konvensi) dan dimasukkan ke x2 karena syscall membutuhkan input length ini di register x2

Calling convention RISCV64

Pada RISCV, nama register bisa berbeda tergantung dari sudut pandang arsitektur atau dari nama untuk ABI (application binary interface). Jadi register x0 punya nama lain yaitu zero, atau x5 sama dengan t0. Nama ABI ini memudahkan untuk memahami penggunaannya. Misalnya: register x4 sebaiknya digunakan untuk temporary, jadi lebih mudah kalau di assembly kita memakai nama t0 (temporary 0).

Parameter masuk di register a0 sampai a7, dan nilai kembalikan di a0 dan a1. Register t0 sampai t6 boleh dipakai untuk temporer, dan s1 sampa s11 nilainya harus sama ketika keluar dari fungsi.

.text
.align 4

.globl  _start
        .type   _start, @function

mystrlen:
	li t0, 0
L0:
	lbu t1,0(a0) 
	beq t1,zero,L1
	addi a0, a0, 1
	addi t0, t0, 1
	j L0
L1:		
	mv a0, t0	
	ret 
_start:
	ld a0,8(sp)
	jal ra,mystrlen	
	mv a2, a0
	ld a1,8(sp)
    li a7, 64
	li a0, 1
    scall

    li a7, 93
    scall

Untuk fungsi mystrlen implementasinya di RISCV64 adalah:

  • Input ada di a0, karena kembalian juga perlu di a0 kita punya dua opsi: mengcopy nilai input ke register lain, atau memakai register lain untuk counter, dan di akhir kita pindah ke a0, saya pilih cara kedua (lihat versi ARM64 untuk yang pertama)
  • Counter menggunakan temporary register t0, diisi dengan 0 menggunakan li (load immediate)
  • Isi karakter saat masuk ke t1 dengan lbu (load byte unsigned)
  • Register t1 dibandingkan dengan 0 (zero), dengan beq dan jika hasilnya sama, maka keluar ke L1, dan salin t0 ke a0 karena sesuai calling convention, nilai kembalian perlu ditaruh di a0.
  • Jika tidak sama dengan 0 maka instruksi berikutnya akan menambah nilai a1 dan t1 dengan menggunakan addi (add integer)

Bagian memanggil fungsinya:

  • Kita pindahkan inputnya ke a0, sesuai konvensi parameter pertama masuk ke a0 kita memakai ld a0,8(sp) untuk mendapatkan nilai yang ditunjuk oleh sp+8
  • Pemanggilan fungsi dilakukan dengan jal ra,mystrlen. Register ra sesuai konvensi dipakai untuk alamat kembali dari fungsi
  • Kita ambil outputnya mystrlen dari a0 (sesuai konvensi) dan dimasukkan ke a2 karena syscall membutuhkan input length ini di register a2

Link dengan kode C

Dengan mengikuti calling convention, maka kode mystrlen bisa dipanggil dari bahasa C. Beberapa perubahan perlu dilakukan pada kode di atas:

  • hapus bagian _start karena kita ingin fungsi main ada di bahasa C
  • export fungsi mystrlen agar dikenali dari C

Saya contohkan satu saja untuk RISCV64, listingnya setelah diubah menjadi seperti ini:

.text
.align 4

.globl  mystrlen
        .type   mystrlen, @function

mystrlen:
        li t0, 0
L0:
        lbu t1,0(a0)
        beq t1,zero,L1
        addi a0, a0, 1
        addi t0, t0, 1
        j L0
L1:
        mv a0, t0
        ret

Kode dalam bahasa C untuk memanggil fungsi di atas:

#include <stdlib.h>
#include <stdio.h>

extern size_t mystrlen(const char *s);

int main(int argc, char *argv[])
{
    printf("Len is: %d\n", 
    mystrlen("cintaprogramming.com"));
}

Kedua file ini bisa dikompilasi langsung:

cc test-strlen.c mystrlen.s -o test-strlen

Atau bisa juga kita jadikan dulu assemblynya menjadi file object. Biasanya jika seseorang ingin mendistribusikan kode komersial, hal ini yang dilakukan (baca juga tentang shared dan static library yang pernah saya tulis di situs ini).

cc -c mystrlen.c

Lalu setelah ““`mystrlen.o tercipta:

cc test-strlen.c mystrlen.o -o test-strlen

Assembly yang lebih efisien

Contoh yang saya berikan sebenarnya kurang efisien. Berbagai instruksi yang saya pilih adalah untuk kesederhanaan. Cara termudah belajar menulis assembly yang lebih efisien adalah: dengan compiler C. Sebelumnya saya sudah memberi contoh implementasi dalam bahasa C. Ini bisa dijadikan assembly dengan:

cc -Os -S mystrlen-c.c

Parameter -Os artinya: optimasi untuk ukuran (size). Parameter -S artinya: hasilkan kode assembly dalam namafile.s. Jika ingin optimasi untuk kecepatan kita bisa memakai -O3. Hasilnya bisa dilihat di mystrlen-c.s untuk RISCV64 :

mystrlen:
        mv      a5,a0
        li      a0,0
.L2:
        add     a4,a5,a0
        lbu     a4,0(a4)
        bne     a4,zero,.L3
        ret

Penutup

Dengan memahami berbagai register dasar, calling convention, dan beberapa instruksi dasar, sudah cukup bagi kita untuk membuat kode assembly yang sederhana. Selain itu kita juga bisa mulai membaca kode hasil compiler untuk reverse engineering.

Tinggalkan Balasan

Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib ditandai *