Tadinya saya ingin menulis tentang arsitektur RISC-V, dan memulai dengan membuat Hello, World!, tapi setelah diingat lagi, saya belum pernah membahas assembly di berbagai arsitektur lain. Jadi di tulisan ini saya ingin membuat program Hello World
di Linux untuk arsitektur 64 bit: x86/64 disebut juga AMD64, ARM64 dan RISCV64.
Dulu waktu mulai mengenal assembly, saya menganggap ini susah. Tapi setelah diingat lagi, semuanya karena keterbatasan teknologi yang saya pakai saat itu:
- Saya memakai DOS, jika salah memprogram assembly maka komputer bisa restart atau hang dengan mudah, bahkan ketika masuk ke Windows 95/98, masih sangat mudah membuat crash dengan program DOS sederhana
- Tool yang ada juga terbatas, misalnya saya membuat program assembly mode grafik di DOS, maka debugger tidak bisa jalan ketika program berjalan
- Arsitektur x86 memang lebih rumit dibandingkan arsitektur lain, karena masalah sejarah (arsitektur ini berusaha kompatibel dengan versi sebelumnya)
Dengan sistem operasi modern seperti Linux dan tools yang ada saat ini, belajar assembly berbagai arsitektur sudah semakin mudah:
- Hardware dan sistem operasi mendukung memory protection, tidak mudah membuat crash
- Sistem multi window memudahkan memprogram grafik dan menjalankan debugger di Window lain. Selain itu jika ingin memprogram grafik fullscreen juga bisa dilakukan via remote SSH
- Ternyata berbagai arsitektur lain lebih sederhana daripada x86
Saya memakai HoneyComb LX untuk target ARM dan Nezha SBC untuk target RISCV64.
Daftar Isi
Instruction Set Architecture
Sekarang setelah melihat assembly di berbagai arsitektur, saya bisa menyatakan bahwa menulis assembly itu mudah, tapi menulis assembly yang efisien/cepat sangat sulit.
Untuk memprogram dalam assembly untuk suatu arsitektur, kita perlu paham dulu Instruction Set Architecture (ISA)-nya. Ini merupakan model abstrak komputer. Sebuah dokumen ISA bisa sangat panjang, di awal minimal kita perlu tahu:
- apa register yang ada
- apa instruksi-instruksi dasar yang ada
Jika kita ingin membuat program yang rumit, multithread, maka kita mulai perlu memahami aspek ISA yang lebih rumit, misalnya tentang memory consistency, atomic instruction, dsb.
Ketika kita memprogram assembly, instruksi sederhana yang saya maksud hanyalah:
- membaca/menulis data dari/ke memori/register
- mengoperasikan register (tambah, kurang, kali, bagi, dsb)
- memahami jika ada flag/status yang berubah karena instruksi tertentu
- memahami jump dan call
Jika kita memprogram tanpa sistem operasi (misalnya memprogram microcontroller), kita perlu tahu bagaimana melakukan input/output. Jika memprogram dengan sistem operasi, kita perlu tahu bagaimana memanggil API sistem operasi. Karena artikel ini hanya membahas Linux, maka saya fokuskan ke Linux saja.
Syscall
Di Linux, untuk memanggil fungsi di kernel, kita bisa menggunakan syscall. Untuk setiap fungsi di kernel yang bisa diakses oleh program (misalnya exit
, write
, socket
) ada nomor syscall-nya. Tiap arsitektur memiliki penomoran syscall yang berbeda (misalnya di AMD64 exit adalah nomor 60, sementara di ARM64 nomor 93).
Bagaimana cara mencari nomor syscall ini? kita bisa membaca source code Linux atau bisa melihat tabel yang sudah ada di Internet. Jika kita punya akses ke OS target, nomornya ada di /usr/include/asm-generic/unistd.h
.
Karena kita memanggil fungsi kernel Linux, maka kita perlu tahu juga sedikit dasar API Linux (atau POSIX). Misalnya bahwa I/O dilakukan dengan file descriptor, dan file descriptor 0 mengacu stdin, 1 mengacu stdout, dan 2 mengacu ke stderr.
Sebuah syscall perlu dipanggil dengan instruksi khusus, misalnya syscall
di AMD64, svc #0
di ARM64, dan scall
di RISCV. Ketika instruksi dieksekusi, maka akan terjadi perpindahan dari user mode ke kernel mode.
Tiap arsitektur yang didukung Linux memiliki calling convention syscall yang berbeda juga. Calling convention ini adalah standard yang menyatakan: input harus dimasukkan ke register mana, dan outputnya ke register mana. Bagian ini akan saya contohkan di tiap arsitektur yang saya bahas.
Semua program yang akan dibuat hanya akan melakukan hal ini:
- Mencetak string
Hello World
, dengan memanggil syscallwrite
- Exit dari program dengan memanggil syscall
exit
Syscall wri
te memiliki 3 parameter: file descriptor (saya akan selalu memakai 1 untuk stdout), string yang akan diprint, dan jumlah karakter. Saya akan memakai Hello, World
! saja ditambah enter jadi jumlah karakternya ada 14.
Syscall exit
memiliki 1 parameter, yaitu status code. Tapi karena tidak penting untuk contoh ini, saya akan exit dengan status code berdasarkan nilai register terakhir.
Sebagai catatan: berbagai OS lain memakai cara yang berbeda dan calling convention yang berbeda untuk memangil syscall di sistem operasi. Misalnya macOS di ARM64 memakai svc 0x80
dengan nomor syscall di register r8
.
Assembler
Untuk contoh di tulisan ini, saya memakai assember dari GNU. Tapi saya tidak akan langsung memanggil program GNU assembler-nya (as
), saya akan memanggil gcc
yang otomatis akan memanggil assembler (as
), linker (ld
). Secara umum semua program akan diassemble dan dilink dengan cara yang sama:
cc -nostdlib namafile.s
Bisa dilihat nanti bahwa ukuran filenya ternyata masih cukup besar. Akan saya bahas bagaimana memperkecil ukuran filenya di akhir.
AMD64
AMD64 menggunakan register berikut ini untuk syscall:
- rax berisi nomor syscall
- parameter (secara berurutan): rdi, rsi, rdx, r10, r8, r9
Arsitektur AMD64 ini karena menjaga kompatibilitas dengan arsitektur sebelumnya, maka memiliki keanehan dalam nama registernya. Nanti bisa dilihat bahwa arsitektur lain, penamaan registernya lebih konsisten.
Syntax assembly yang saya pakai adalah AT&T. Perbandingan dengan Syntax Intel bisa dilihat di Wikipedia.
.text
.hello:
.string "Hello, World!\n"
.globl _start
.type _start, @function
_start:
movq $1, %rax
movq %rax, %rdi
leaq .hello(%rip), %rsi
movq $14, %rdx
syscall
movq $60, %rax
syscall
Akan ada beberapa hal yang sama dengan contoh program lain di arsitektur lain:
- bagian
.text
menyatakan bahwa berikut ini adalah bagian section kode program. - Untuk menyederhanakan: saya meletakkan string
Hello, World
! di dalam section yang sama dengan program. Biasanya ini diletakkan di section khusus (misalnya.rodata
) .string
adalah cara mendefinisikan data string di GNU assembler.globl _start
menyatakan bahwa sesuatu bernama_start
dibuat global. Nama ini adalah nama standard yang dipakai oleh GCC. Bisa saja kita memakai nama lain, tapi nanti harus disesuaikan di command line..type _start, @function
untuk memberi tahu bahwa sesuatu bernama_start
adalah sebuah fungsi
Sekarang untuk yang spesifik x86:
- Mengisikan nilai literal 64 bit dilakukan dengan
movq $BILANGAN, %REGISTER
- Kebetulan syscall write adalah syscall nomor 1, dan file descriptor juga 1, jadi kita bisa mengisi register
rdi
denganrax
. Ini contoh assembly yang efisien: baris pertama ketika diassemble akan menjadi 7 byte, sedangkan baris kedua hanya butuh 3 byte. - Untuk mendapatkan alamat string yang akan diprint, saya memakai
leaq
denganrip
(register pointer instruksi saat ini) - Syscall dipanggil dengan instruksi
syscall
ARM64
Jika dilihat instruksi ARM64 ini, ada satu hal penting sebelum .globl
yaitu .align 4
ini supaya dilakukan alignment agar instruksi berada di lokasi memori kelipatan 4. Ini tidak diperlukan di versi AMD64 (walaupun kalau ditambahkan juga tidak apa-apa).
Calling convention syscall di Linux ARM64 adalah:
- Nomor syscall di register
x8
- parameter ada di register:
x0
,x1
,x2
,x3
,x4,
x5
. Nama registernya lebih konsisten dari AMD64
.text
.hello:
.string "Hello, World!\n"
.align 4
.globl _start
.type _start, @function
_start:
mov x8, #64
mov x0, #1
adr x1, .hello
mov x2, #14
svc #0
mov x8, #93
svc #0
Untuk ARM64, nomor syscall untuk write
adalah 64, jadi saya tidak bisa mengcopy nilainya ke register berikutnya.
- Mengisi register dilakukan dengan
mov REGISTER, NILAI
- Mendapatkan alamat string dengan instruksi
adr
- Syscall dipanggil dengan
svc #0
RISCV64
Bagian awal versi RISCV64 sama dengan ARM64, perlu alignment 4 byte. Syscall write memiliki nomor 64 dan exit 93, ini kebetulan sama dengan ARM64.
Calling convention:
- Nomor syscall di register
a7
- Parameter di register:
a0
,a1
,a2
,a3
,a4
,a5
,a6
. Mirip dengan ARM4 - Syscall dipanggil dengan
scall
.text
.hello:
.string "Hello, World!\n"
.align 4
.globl _start
.type _start, @function
_start:
li a7, 64
li a0, 1
lla a1, .hello
li a2, 14
scall
li a7, 93
scall
Mengenai assemblynya:
- Register bisa diisi dengan
li
(load immediate) - Alamat string bisa didapatkan dengan
lla
- Syscall dipanggil dengan
scall
(atau boleh juga memakaiecall
, akan diassemble menjadi instruksi yang sama)
Membuat binary program sangat kecil
Program yang dihasilkan dari assembly yang sangat kecil masih beberapa kilobyte. Ini bisa diperkecil:
cc -s -nostdlib -static -Wl,--build-id=none filename.s
Flag berikut ini sama untuk semua arsitektur:
-s
strip binary, agar semua informasi debug dihapus (jangan gunakan-s
jika ingin mendebug filenya)-nostdlib
kita tidak memakai library standard, karena kita memanggil I/O langsung ke kernel dengan syscall-static
kita tidak ingin binary ini dilink dinamik dengan library apapun-Wl,--build-id=non
e untuk menghapus build id, agar ukuran akhir executable lebih kecil
Di AMD64, kita perlu menambah satu lagi parameter:
cc -s -nostdlib -static -Wl,--build-id=none -z max-page-size=0x04 filename.s
Khusus untuk AMD64, tanpa -z max-page-size=0x04
maka hasilnya akan beberapa puluh KB karena ada padding byte nol.
Hasilnya di komputer saya:
- AMD64: 440 byte
- ARM64: 384 byte
- RISV64: 576 byte
Perlu diperhatikan bahwa ukuran sudah termasuk header file ELF, bukan hanya instruksi assemblynya. Instruksi assemblynya sendiri semuanya hanya beberapa puluh byte saja.
Penutup
Walau mungkin Anda akan jarang menggunakannya, pengetahuan assembly berguna untuk:
- Melakukan reverse engineering
- Memahami crash report
- Memahami interoperasi antar bahasa
Masih banyak topik seputar assembly yang bisa dibahas, semoga perkenalan ini bisa membuat Anda tertarik untuk belajar assembly di berbagai arsitektur.
One thought on “Hello, World! di Linux dalam Assembly AMD64, ARM64, dan RISCV64”