Bab 5: Memahami dasar assembly
Bahasa assembly sebenarnya sangat sederhana, tapi butuh pengetahuan mengenai arsitektur komputer yang spesifik. Bab ini akan memperkenalkan dasar arsitektur komputer secara sederhana, dan konsep bahasa assembly yang berlaku untuk berbagai arsitektur komputer. Di bab-bab berikutnya baru akan dibahas assembly spesifik untuk arsitektur tertentu.
Dengan memahami dasar assembly di bagian ini, apapun arsitekturnya bisa dipelajari dengan cepat.
Memori komputer
CPU mengeksekusi perintah yang ada di memori komputer. Memori hanyalah sederetan sel berisi angka-angka. Tiap sel memiliki jumlah bit yang sama. Karena hampir semua komputer saat ini memakai 8 bit, anggap saja selalu 8 bit. Tiap sel juga memiliki alamat, dan alamat disusun dalam urutan menaik.
Ketika komputer menyala pertama kali, isi memori ini tidak terdefinisi/acak/bisa berapa saja.
Tentunya bisa dituliskan vertikal juga (sama saja, tergantung lebih mudah mana)
Dalam kebanyakan listing assembly dan hexdump, alamat akan menggunakan notasi heksadesimal dengan jumlah digit yang sama (jika 4 digit, sisanya 4 digit, jika 8 digit sisanya 8 digit, dst). Isi memori juga dalam heksadesimal dan biasaya dua digit heksadesimal.
Jadi Anda akan melihat sesuatu seperti ini (anggap saja memori sudah diinisialisasi)
Supaya lebih ringkas, saya akan menggunakan listing sederhana seperti ini untuk menuliskan isi memori
0000 01
0001 02
0003 03
0004 04
0005 04
..
03FF 00
0400 D1
Endianness
Jika kita hanya berurusan dengan 1 byte, tidak ada masalah, tapi bagaimana jika kita ingin menyimpan angka yang lebih besar dari 1 byte (misalnya 1000, atau 0x03E8). Kita bisa menyimpannya dalam 2 sel memori 03
dan E8
. Mana yang sebaiknya disimpan lebih dulu? E8
atau 03
? Jika kita simpan 03
lalu E8
maka kita memakai sistem yang namanya Big Endian, dan jika kita simpan dulu E8
lalu 03
, maka kita memakai sistem Little Endian.
Atau dengan ilustrasi yang lebih baik dari Wikipedia:
Big Endian
Little Endian
Masalah endiannes ini nanti penting ketika membahas soal assembly. Supaya jelas, kita akan menyatakan MSB (most significant bit/byte) dan LSB (least significant byte/bit) ketika membahas sesuatu. MSB adalah byte yang "paling berarti". Misalnya dalam angka 9001, digit MSB adalah yang paling (angka 9), dan LSB sebaliknya (dalam contoh ini angka 1).
Eksekusi instruksi
Untuk saat ini: anggaplah dengan suatu cara memori telah terisi dengan instruksi. Secara praktis isi ini bisa berasal dari EPROM atau sumber lain. Sekarang anggap CPU bisa mulai mengeksekusi instruksi. Tiap CPU bisa memiliki "alamat mulai" yang berbeda, tapi biasanya dari mulai alamat memori pertama (0
).
Tergantung designnya, CPU bisa saja mengambil jumlah byte yang tetap (misalnya 4 byte) lalu mengeksekusi instruksi tersebut (contoh yang seperti ini: ARM), bisa juga mengambil jumlah byte yang bervariasi, misalnya setelah mengambil 1 byte diputuskan bahwa diperlukan 4 byte lagi (contohnya Intel X86). Dengan hanya melihat angka-angka di memori -- tanpa tahu apa arsitekturnya -- kita tidak tahu apa yang akan dieksekusi oleh CPU.
Lalu setelah mengeksekusi satu instruksi, apa instruksi berikutnya yang harus dijalankan? Biasanya ini adalah instruksi berikutnya, kecuali jika instruksi saat ini adalah JUMP (melompat) ke alamat instruksi yang lain. CPU perlu "mengingat" informasi ini.
Sebenarnya bisa saja informasi alamat instruksi ini disimpan di memori juga, misalnya alamat 0
selalu berisi alamat instruksi yang sedang dieksekusi, tapi biasanya informasi ini disimpan di register.
Register
Register sifatnya seperti memori, sama-sama menyimpan data, tapi register merupakan bagian dari CPU itu sendiri. Jika memori diakses berdasarkan alamat maka register biasanya diberi nama. Contohnya: instruction register (IP) atau program counter (PC) adalah register yang berisi alamat instruksi saat ini. Akan ada beberapa register lain yang akan dibahas.
Perlu dicatat bahwa ada CPU yang memetakan register sebagai memori, jadi register bisa diakses seperti memori, tapi untuk saat ini asumsikan saja bahwa memori dan register adalah dua hal yang berbeda.
Instruksi dan Operand
Tiap CPU memiliki jumlah instruksi yang berbeda, dari belasan sampai ratusan. Instruksi bisa sangat sederhana, misalnya HALT
(atau HLT
) yang menghentikan prosessor. Instruksi sederhana seperti itu tidak butuh info tambahan apa-apa. Sebagian besar instruksi butuh operand
, yaitu sesuatu untuk dioperasikan. Misalnya jika ada instruksi ADD
(tambah), maka apa yang perlu ditambahkan? hasilnya disimpan di mana?
Kita bisa mencoba merancang sebuah instruksi, misalnya kita punya instruksi 'ADD' seperti ini:
ADD 03,01,02
Dengan arti: tambahkan isi memori di alamat 1 dengan isi memori di alamat 2 lalu simpan hasilnya di alamat 3. Alasan mengapa 03
diletakkan di kiri di contoh ini (dan di berbagai assembly yang sebenarnya) adalah karena di berbagai bahasa pemrograman, hasil ada di kiri, seperti ini:
C = A + B
Kita bisa menggunakan byte berapa saja untuk penjumlahan, saya ambil contoh memakai 0A
, byte yang dihasilkan mungkin seperti ini:
0A 03 01 02
Andaikan memori kita maksimum hanya 256 byte (00
sampai FF
) itu sudah cukup, bagaimana jika memori kita 16 bit (0000
sampai FFFF
)? kita perlu menuliskan 00
juga:
0A 00 03 00 01 00 02
Bagaimana jika 32 bit, atau 64 bit? instruksi add dari memori ke memori bisa sangat panjang byte codenya. Biasanya sebuah CPU akan memiliki satu atau lebih register serba guna (general purpose register). Melakukan berbagai komputasi dengan register akan lebih mudah, kita hanya perlu instruksi untuk:
- Menyalin data dari memori ke register (biasanya istilahnya adalah
load
) - Menyalin data dari register ke memori (biasanya istilahnya adalah
store
) - Instruksi untuk mengeset nilai register
- Instruksi untuk mengoperasikan register
Contoh: kita bisa memiliki instruksi
LOAD R1, ADDR
Dan kita bisa merancang kode mesinnya 01 XX AA BB
untuk menyalin memori AA BB
ke register XX
. Lalu kita bisa membuat juga kebalikannya:
STORE ADDR, R1
Dan kita gunakan format serupa, tapi dengan kode 02
, sehingga menjadi 02 XX AA BB
.
Perhatikan: urutan kiri dan kanan berbeda di beberapa listing assembly dan mungkin kelihatan tidak konsisten, ini tergantung disassemblernya.
Sebagai contoh, di Intel ada instruksi untuk mengeset register dengan instruksi mov
. Di syntax Intel:
mov eax, 1
Sementara di Syntax AT&T:
movl 1, %eax
Di mesin, instruksinya sama dengan urutan byte yang sama, tapi ketika ditampilkan ke user (dan ketika kita menulis kode assembly jika nanti ingin patching), sintaksnya tergantung tool yang kita gunakan.
Supaya lebih lengkap, buat juga instruksi untuk mengeset nilai register
SET R1, VALUE
Dengan format 03 XX AA BB
. Sekarang kita bisa membuat instruksi untuk menambahkan dua register, misalnya
ADD RZZ, RXX, RYY
Dengan format 0A ZZ XX YY
yang akan menambahkan isi register XX dan YY lalu menyimpannya di ZZ. Sehingga sekarang instruksi penjumlahan sebelumnya bisa seperti ini:
000 LOAD R1, 01 | 01 01 00 01
004 LOAD R2, 02 | 01 02 00 02
008 ADD R3, R2, R1 | 0A 03 02 01
00C STORE ADDR, R3 | 02 03 00 03
Instruksinya jadi lebih banyak, tapi masing-masing instruksi lebih sederhana. Secara hardware, operasi terhadap register lebih cepat dibandingkan mengakses memori. Perhatikan bahwa tiap instruksi terdiri dari 4 byte, jadi alamat memori berikutnya adalah 4 byte dari sebelumnya.
Sekarang sedikit tambahan optimasi: sebenarnya jika memakai register, kita tidak perlu target, kita bisa membuat instruksi ADD A, B
yang artinya:
A = A + B
Ingat bahwa memori sudah disalin ke register A
, jadi tidak akan mempengaruhi isi memori. Jadi kita bisa sederhanakan di atas menjadi:
LOAD R1, 01
LOAD R2, 02
ADD R2, R1
STORE R2, 03
Ukuran register
Saat ini kebanyakan komputer melakukan komputasi dengan register 32 atau 64 bit. Mengapa 8 bit (atau 4 bit) tidak cukup? Sebenarnya komputasi apapun bisa dilakukan dengan register 8 bit, tapi untuk kebanyakan operasi akan butuh beberapa instruksi.
Contohnya: jika kita ingin menyimpan koordinat di layar dengan resolusi 1920x1080 (resolusi HD) akan dibutuhkan minimal 16 bit (2 sel memori) kali 16 bit (2 sel memori). Dulu ketika layar monitor masih dihitung dengan baris dan kolom, 8 bit sudah cukup untuk menyatakaan koordinat layar.
Dalam penjumlahan yang diajarkan di sekolah dasar, kita melakukan seperti ini:
12
8
--
20
2 + 8 = 0 -> carry 1
1 + 0 + carry = 2
Hal yang sama juga terjadi ketika kita melakukan penjumlahan lebih dari besar register. Jika besar register 8 bit dan ingin melakukan penjumlahan 16 bit, maka kita perlu menambahkan dulu byte pertama, lalu menambahkan byte kedua dan menambahkan juga carry dari operasi pertama. Di assembly, jika kita punya register R0 (MSB) /R1 (LSB) yang berisi bilangan pertama dan R2 (MSB) /R3 (LSB) yang berisi bilangan kedua:
ADD R1, R3
ADC R0, R2 #add with carry
Tentunya carry bit ini harus disimpan di suatu tempat, di berbagai arsitektur ini biasanya disimpan dalam sebuah register khusus yang bernama flag register atau status register.
Register Flag/Status
Register ini adalah register khusus yang biasanya diubah oleh suatu operasi, misalnya operasi penjumlahkan yang menghasilkan carry (atau borrow untuk pengurangan), operasi perkalian yang menghasilkan overflow, dsb. Register ini juga biasanya dibaca oleh instruksi khusus, terutama instruksi conditional jump yang akan dibahas di struktur kontrol.
Beberapa flag yang umum adalah:
- carry yang akan diset jika operasi sebelumnya menghasilkan carry ataupun borrow
- zero yang akan diset jika operasi sebelumnya menghasilkan bilangan nol
- overflow yang akan diset jika operasi sebelumnya menghasilkan overflow
- sign jika operasi sebelumnya menghasilkan angka negatif
Stack dan Stack Pointer
Dalam pelajaran pemrograman diajarkan bahwa stack adalah struktur data Last In First Out (LIFO). Hampir semua arsitektur memiliki stack
, tapi sangat sederhana. Stack hanyalah sebuah memori yang dipakai untuk menyimpan data lokal, dan juga dipakai untuk pemanggilan fungsi.
Lokasi memori untuk stack ditunjuk oleh register khusus yang bernama stack pointer (SP). Di kebanyakan arsitektur tidak ada restriksi khusus untuk register ini, dan bisa kita set nilainya menjadi berapa saja. Dua buah instruksi yang berhubungan dengan stack yang selalu ada adalah push
untuk menyimpan data di stack dan pop
untuk mengambil data dari stack.
Struktur Kontrol
Instruksi assembly sangat sederhana, jadi tidak ada konstruksi seperti while
, for
, dsb, semua akan diterjemahkan menjadi jump dan conditional jump.
Seperti namanya, jump akan melompat ke suatu alamat tertentu, alamat ini bisa alamat yang absolut, relatif, atau bisa ditentukan dari register.
JUMP 1000 # eksekusi program akan berlanjut ke alamat 1000
JUMP R0 # eksekusi program akan berlanjut ke alamat yang ada di register R0
RJUMP 100 # relative jump, eksekusi program akan berlanjut ke alamat saat ini + 100
Relative jump ini berguna ketika membuat Position Independent Code (PIE). Ketika menulis listing assembly, kita tidak menuliskan alamat numerik, tapi menggunakan label, assembler yang akan menghitung dan mengubah label menjadi angka numerik.
JUMP label_a
#instruksi lain
label_a:
#instruksi
Instruksi conditional jump hanya akan dieksekusi jika sebuah kondisi dipenuhi, dan kondisi ini biasanya bergantung pada satu atau lebih bit di flag register. Contoh paling sederhana adalah seperti ini:
SUB R1, R2 # kurangi R1 dengan R2
JZ # Jump if previous result was zero (R1==R2)
Ada beberapa varian untuk kata jump, misalnya branch atau link. Singkatan untuk kondisinya juga berbeda, misalnya untuk jump if zero (JZ) bisa menjadi: jump if equal (JE) atau branch if equal (beq/breq). Untuk saat ini Anda tidak perlu tahu detail lengkapnya, tiap arsitektur dan program assembler bisa punya singkatan sendiri, jadi silakan dihapalkan untuk masing-masing arsitektur yang ingin Anda dalami.
Ketika melihat visualisasi Control Flow Graph (CFG) kita bisa melihat bentuk instruksi IF dan LOOP. Ini adalah dasar dari semua semua graph yang ada.
Struktur IF
Struktur IF dalam C seperti ini:
if (kondisi) {
aksi1();
}
aksi2();
Adalah seperti ini dalam assembly (Jcc di sini adalah singkatan untuk jump if condition, dan JNcc untuk jump if not condition)
Atau diagram dalam disassembler visual:
Perhatikan, misalnya kita ingin melakukan perbandingan (a==b)
seperti ini:
if (a==b) {
aksi1();
}
aksi2();
Dalam assembly kondisinya dibalik:
SUB R1, R2 # anggap R1 berisi a dan R2 berisi b
JNE l_aksi_2 # jump if not equal
#aksi 1
l_aksi_2:
#aksi 2
Jadi di sini: jika a!=b
, maka langsung ke aksi 2, dan jika a==b
maka lakukan aksi 1 lalu aksi 2.
Untuk instruksi if else
, dengan struktur seperti ini
if (kondisi) {
aksi1();
} else {
aksi2();
}
aksi3();
Akan diterjemahkan
Dalam disassembler visual, strukturnya:
Berikut ini contoh real dari radare, kode C yang dicompile adalah:
#include <stdio.h>
int main(int argc, char *argv[])
{
if (argc==2) {
printf("two\n");
} else {
printf("not two\n");
}
}
Jika kita compile kode C tersebut untuk target AMD64, maka hasilnya adalah
Dan jika kita compile ke target AVR, hasilnya adalah
Yang bisa disimpulkan adalah: apapun arsitektur assemblynya, control flow graph akan serupa. Perkecualian hanya ada untuk prosessor yang memiliki instruksi conditional action. Misalnya di arsitektur x86 (pentium ke atas) ada instruksi cmov
(conditional move). Misalnya instruksi ini:
cmp eax, ebx
cmovz edx, 0
Yang berarti: bandingkan eax
dengan ebx
, jika sama, set edx
menjadi 0. Instruksi semacam ini tidak terlihat di control flow graph.
Kondisi jamak (Compound condition)
Bentuk kondisi jamak, seperti (a>b || b<c)
yang dievaluasi secara short circuit
, akan diterjemahkan menjadi beberapa if
yang masing-masing memiliki satu kondisi. Yang dimaksud short circuit adalah: evaluasi akan dihentikan sampai cukup diketahui true atau false, tidak perlu seluruhnya, misalnya:
if (check_1() || check_2()) {
aksi(1);
}
Jika check_1()
sudah mengembalikan true
, maka check_2
tidak perlu dipanggil. Ini penting karena check_2
bisa berisi kode yang panjang, rumit, dan butuh waktu lama (misalnya mengakses database).
ilustrasi OR dalam C dengan 3 kondisi:
if (a>b || b<c || c<d) {
aksi(1);
}
aksi2();
//bisa diubah menjadi
if (a>b) goto act_1;
if (b<c) goto act_1;
if (c>=d) goto done;
act_1:
aksi1();
done:
aksi2();
Tentu saja ini bisa bertingkat banyak (A or B or C or D ...), tapi secara umum bentuknya seperti sama:
Sedangkan untuk AND
if (a>b && b<c && c<d) {
aksi(1);
}
aksi2();
//bisa diubah menjadi
if (a<=b) goto done;
if (b>=c) goto done;
if (c>=d) goto done;
aksi1();
done:
aksi2();
Struktur Loop
Pada dasarnya semua loop bisa diubah menjadi IF dan GOTO/JUMP, ini mudah diilustrasikan dalam bahasa C. Saya memilih C karena C mendukung statement goto
(bahasa turunan C seperti Java tidak mendukung goto).
Contoh translasi pernyataan while
menjadi if/goto.
while (a==b) {
aksi1();
}
aksi2();
//dapat diubah menjadi
start_loop:
if (a!=b) goto done;
aksi1();
goto start_loop;
done:
aksi2();
Dalam assembly:
Dalam disassembler visual, strukturnya:
Tentunya instruksi do { } while(cond)
hanyalah varian dari bentuk di atas. Aksi dilakukan dulu, baru ada pemeriksaan kondisi di akhir. Kondisi tidak perlu dibalik.
do {
aksi1();
} while (a==b);
aksi2();
//dapat diubah menjadi
start_loop:
aksi1();
if (a==b) goto start_loop;
aksi2();
Loop for
sebenarnya hanyalah gabungan dari inisialisasi dengan loop while:
for (i=0; i < 10; i++) {
aksi();
}
aksi2();
//dapat diubah menjadi
i = 0; //inisialisasi
while (i<10) {
aksi();
i++; //aksi ekstra
}
aksi(2);
//karena while bisa diubah menjadi if/goto, maka
//loop for di atas bisa menjadi
i = 0;
start_loop:
if (i>=10) goto done;
aksi();
i++; //aksi ekstra
goto start_loop;
done:
aksi2();
Loop dengan exit/break
Kita bisa menghentikan loop di tengah dengan menggunakan instruksi break
di C. Dalam graph akan terlihat ada jump ke luar dari loop.
Instruksi assembly yang umum
Di berbagai arsitektur ada banyak instruksi assembly yang serupa, walaupun mungkin namanya berbeda. Memahami beberapa instruksi dasar yang umum di berbagai arsitektur akan mempercepat pemahaman kita terhadap arsitektur baru.
Aritmatika
Operasi yang umum adalah: penjumlahan (ADD), penjumlahan dengan carry (ADC), pengurangan (SUB), pengurangan dengan borrow (SBB). Penambahan dan pengurangan dengan 1 (INCrement dan DECrement) juga biasanya dimiliki oleh sebuah prosessor.
Instruksi perkalian dan pembagian hanya dimiliki oleh prosessor yang kompleks, kebanyakan microcontroller tidak memiliki instruksi seperti ini. Beberapa microcontroller memiliki MULtiply tapi tidak memiliki DIVide. Instruksi DIV umumnya sekaligus menghitung hasil pembagian dan sisinya.
Operasi shift left dan right dapat digunakan untuk mengalikan atau membagi kelipatan 2 pangkat N. Kadang ada versi arithmetic shift-nya yang akan memperhatikan bit sign.
Operasi negatif (NEG) dilakukan pada signed integer untuk membalik signnya.
Operasi bit
Operasi bit merupakan instruksi yang paling banyak tersedia (salah satu alasannya adalah karena pada implementasi hardware ini tidak memakan banyak gate). Beberapa operasi yang umum adalah:
- AND
- OR
- NOT
- XOR, ini digunakan untuk meng-nolkan register (XOR R, R hasilnya 0)
- Shift Left dan Shift Right, biasanya ada versi Logical shift
- Clear dan Set bit, walaupun ini bisa dilakukan dengan OR dan AND
Arsitektur Harvard vs Von Neumann
Dalam Arsitektur Harvard, bus untuk kode dan data dipisahkan, dan dalam praktiknya ini dilakukan dengan menggunakan memori terpisah. Dalam arsitektur Von Neumann hanya ada satu bus untuk instruksi dan data.
Secara praktis dalam arsitektur Von Neumann, data bisa diperlakukan seperti kode dan bisa dieksekusi, praktisnya adalah interpreter yang memakai teknologi JIT (just in time compiler), yang "mengkompilasi" kode dalam memori dan menjalankan kode tersebut.
Copyright © 2009-2018 Yohanes Nugroho