Hello, World! di Linux dalam Assembly AMD64, ARM64, dan RISCV64

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 ketima masuk ke Windows 95/98, masih sangat mudah membuat crash dengan program DOS sederhana
  • Tool yang ada juga terbatas, misalnya saya membuat assembly mode grafik, maka debugger tidak bisa jalan ketika program berjalan
  • Arsitektur x86 memang lebih rumit dibandingkan arsitektur lain, karena masalah sejarah (ingin 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.

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 syscall write
  • Exit dari program dengan memanggil syscall exit

Syscall write 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 dengan rax. 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 dengan rip (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 memakai ecall, 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=none 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, pengetahuanassembly 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.

Leave a comment

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

Situs ini menggunakan Akismet untuk mengurangi spam. Pelajari bagaimana data komentar Anda diproses.