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.
Daftar Isi
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.
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 dirax
jadi berapapun nilai terakhir registerrax
akan menjadi hasil kembalian fungsinya - Parameter ada di
rdi
- Instruksi
movb (%rdi),%bl
akan mengisi registerBL
dengan karakter dari input. RegisterBL
adalah 8 bit terbawah dari registerrbx
- Jika nilai
bl
sudah 0 (daricmp $0, bl
), maka jump ke L2, yaitu label exit, di L2 adaret
yang akan kembali dari fungsi - Jika
bl
belum nol, maka nilairax
(counter) ditambah 1 dengan (inc %rax
), dan kita ingin memproses karakter berikutnya, jadirdi
(input) perlu ditambah dengan 1 juga
Bagian memanggil fungsinya:
- Kita pindahkan inputnya ke
rdi
, sesuai konvensi parameter pertama masuk kerdi
- 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 dix0
kita punya dua opsi: mengcopy nilai input ke register lain, atau memakai register lain untuk counter, dan di akhir kita pindah kex0
, saya pilih cara pertama (dicopy kex1
) - Counter menggunakan temporary register
x0
, diisi dengan0
menggunakanmov
- Isi karakter saat masuk ke
w2
denganldrb
(load byte),w2
adalah versi 32 bit dari registerx2
- Register
x2
dibandingkan dengan 0 (zero
), dengancmp
. Lalu hasil perbandingannya dicek denganb.eq
dan jika hasilnya sama, maka keluar ke L1, dan sesuai calling convention sudah ada dix0
jadi tidak perlu dipindah. - Jika tidak sama dengan
0
maka instruksi berikutnya akan menambah nilai a1 dan t1 dengan menggunakanadd
Bagian memanggil fungsinya:
- Kita pindahkan inputnya ke
x0
, sesuai konvensi parameter pertama masuk kex0
kita memakaildr x0,[sp,8]
untuk mendapatkan nilai yang ditunjuk oleh alamatsp+8
- Pemanggilan fungsi dilakukan dengan
bl mystrlen
(branch and link) - Kita ambil outputnya
mystrlen
darix0
(sesuai konvensi) dan dimasukkan kex2
karena syscall membutuhkan input length ini di registerx2
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 dia0
kita punya dua opsi: mengcopy nilai input ke register lain, atau memakai register lain untuk counter, dan di akhir kita pindah kea0
, saya pilih cara kedua (lihat versi ARM64 untuk yang pertama) - Counter menggunakan temporary register
t0
, diisi dengan0
menggunakanli
(load immediate) - Isi karakter saat masuk ke
t1
denganlbu
(load byte unsigned) - Register
t1
dibandingkan dengan 0 (zero
), denganbeq
dan jika hasilnya sama, maka keluar ke L1, dan salint0
kea0
karena sesuai calling convention, nilai kembalian perlu ditaruh dia0
. - Jika tidak sama dengan
0
maka instruksi berikutnya akan menambah nilai a1 dan t1 dengan menggunakanaddi
(add integer)
Bagian memanggil fungsinya:
- Kita pindahkan inputnya ke
a0
, sesuai konvensi parameter pertama masuk kea0
kita memakaild a0,8(sp)
untuk mendapatkan nilai yang ditunjuk oleh sp+8 - Pemanggilan fungsi dilakukan dengan
jal ra,mystrlen
. Registerra
sesuai konvensi dipakai untuk alamat kembali dari fungsi - Kita ambil outputnya
mystrlen
daria0
(sesuai konvensi) dan dimasukkan kea2
karena syscall membutuhkan input length ini di registera2
Link dengan kode C
Dengan mengikuti calling convention, maka kode mystrl
en 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.