Analisis Pembangkitan Kode Assembly AMD64 dan Instruksi Percabangan untuk Kondisi (if) pada Go

If Statement dalam Bahasa Go
Pertama-tama, alasan kami memilih Go adalah karena di antara bahasa modern, Go adalah bahasa yang assembly-nya paling "indah", dan bahkan jika dibandingkan dengan bahasa klasik, efisiensi sintaksnya kadang justru sangat unggul.
Sekarang setelah kita memahami cara kerja program Go sederhana pada pelajaran sebelumnya, mari kita langsung membandingkan Go dan Assembly baris demi baris.
Kode Sumber
Pertama-tama, tidak hanya di Go, bahkan compiler modern termasuk GCC secara otomatis mengoptimasi cabang yang tidak memiliki arti penggunaan. Compiler bahasa C seperti GCC dan Clang juga melakukan optimasi yang sangat agresif pada -O2, yang merupakan standar industri, sehingga era di mana programmer sulit mempercayai compiler sepenuhnya akhirnya sudah berakhir sejak akhir abad ke-20.
Oleh karena itu, agar memiliki makna, kondisi yang diberikan harus sulit diprediksi dan diubah menjadi sintaks lain oleh compiler.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Jika ini diisi dengan sesuatu yang dapat diprediksi dan cabangnya dapat dihapus, seperti x = 10,
10 // compiler akan mengoptimasinya dan menghapus cabangnya.
11 // Oleh karena itu, untuk melihat assembly-nya secara langsung, di C digunakan opsi seperti -O0,
12 // atau compiler dibuat menggunakan nilai eksternal yang tidak dapat diprediksi,
13 // tetapi karena bagian ini membahas pemrograman modern, metode mematikan
14 // optimasi biner Go tidak digunakan.
15 if len(os.Args) < 2 {
16 return
17 }
18 x, _ := strconv.Atoi(os.Args[1])
19
20 if x < 10 {
21 println("X is smaller than 10")
22 } else {
23 println("X is larger or same as 10")
24 }
25}
Dalam kasus ini, karena input tidak dapat diprediksi oleh compiler, pernyataan cabang tetap diterjemahkan ke dalam kode mesin apa adanya.
Assembly
1TEXT main.main(SB) /home/yjlee/introduction-to-golang/learn-golang/if-and-switch/golang-if/main.go
2 main.go:8 0x47a840 493b6610 CMPQ SP, 0x10(R14)
3 main.go:8 0x47a844 7670 JBE 0x47a8b6
4 main.go:8 0x47a846 55 PUSHQ BP
5 main.go:8 0x47a847 4889e5 MOVQ SP, BP
6 main.go:8 0x47a84a 4883ec10 SUBQ $0x10, SP
7 main.go:15 0x47a84e 48833d12fb0a0002 CMPQ os.Args+8(SB), $0x2
8 main.go:15 0x47a856 7c58 JL 0x47a8b0
9 main.go:15 0x47a858 488b0d01fb0a00 MOVQ os.Args(SB), CX
10 main.go:18 0x47a85f 488b4110 MOVQ 0x10(CX), AX
11 main.go:18 0x47a863 488b5918 MOVQ 0x18(CX), BX
12 main.go:18 0x47a867 e834e8ffff CALL strconv.Atoi(SB)
13 main.go:20 0x47a86c 4883f80a CMPQ AX, $0xa
14 main.go:20 0x47a870 7d1d JGE 0x47a88f
15 main.go:21 0x47a872 e809befbff CALL runtime.printlock(SB)
16 main.go:21 0x47a877 488d0519f50100 LEAQ 0x1f519(IP), AX
17 main.go:21 0x47a87e bb15000000 MOVL $0x15, BX
18 main.go:21 0x47a883 e878c6fbff CALL runtime.printstring(SB)
19 main.go:21 0x47a888 e853befbff CALL runtime.printunlock(SB)
20 main.go:21 0x47a88d eb1b JMP 0x47a8aa
21 main.go:23 0x47a88f e8ecbdfbff CALL runtime.printlock(SB)
22 main.go:23 0x47a894 488d05c8040200 LEAQ 0x204c8(IP), AX
23 main.go:23 0x47a89b bb1a000000 MOVL $0x1a, BX
24 main.go:23 0x47a8a0 e85bc6fbff CALL runtime.printstring(SB)
25 main.go:23 0x47a8a5 e836befbff CALL runtime.printunlock(SB)
26 main.go:25 0x47a8aa 4883c410 ADDQ $0x10, SP
27 main.go:25 0x47a8ae 5d POPQ BP
28 main.go:25 0x47a8af c3 RET
29 main.go:16 0x47a8b0 4883c410 ADDQ $0x10, SP
30 main.go:16 0x47a8b4 5d POPQ BP
31 main.go:16 0x47a8b5 c3 RET
32 main.go:8 0x47a8b6 e845f0feff CALL runtime.morestack_noctxt.abi0(SB)
33 main.go:8 0x47a8bb eb83 JMP main.main(SB)
Dengan menggunakan go tool, kita dapat mengetahui secara rinci sintaks mana yang berpasangan dengan assembly mana.
Karena yang akan kita pelajari kali ini adalah perbandingan dan pernyataan cabang if, kita cukup memperhatikan beberapa baris saja.
Instruksi CMPQ & Instruksi JL
Instruksi CMPQ adalah instruksi untuk membandingkan tipe data 4 byte (4 word), dan asal katanya dari CoMPareQuadword yang disingkat menjadi CMPQ.
Jika kita melihat alamat memori 0x47a84e, terdapat sintaks CMPQ os.Args+8(SB), $0x2.
Dalam kasus ini, program membandingkan jumlah argumen yang diterima dengan heksadesimal 0x2 (yaitu hanya 2).
Setelah itu, untuk memeriksa apakah argumen kurang dari 2 (yaitu jika program itu sendiri adalah satu-satunya argumen), dilakukan lompatan setelah perbandingan melalui JL. Artinya, ini adalah singkatan dari Jump, Less than, sehingga menjadi JL.
Jika berdasarkan operasi perbandingan sebelumnya argumen kurang dari 2, program melompat ke alamat 0x47a8b0, dan di sana terdapat JGE.
Namun, instruksi ini menggunakan register AX, sehingga kita perlu mengetahui identitas nilai yang tersimpan di register tersebut.
Instruksi MOVQ
Setelah ini, kita perlu memahami cara menyimpan alamat awal data menggunakan register CX secara langsung, serta bagaimana data sebenarnya diekstrak setelah alamat tersebut dibaca.
Pada rentang 0x47858-0x47863, operasi ini dilakukan secara bertahap.
Pertama, alamat awal array argumen dimasukkan ke register CX dengan instruksi MOVQ os.Args(SB), CX. Pada tahap ini, kita harus memahami tipe string di Go.
string di Go adalah struktur, dan struktur ini terdiri dari 2 data berukuran 8 byte, sehingga totalnya 16 byte.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Secara visual, strukturnya seperti di atas: 8 byte pertama menyimpan alamat awal string, dan 8 byte berikutnya menyimpan panjang string.
Oleh karena itu, alamat string disimpan di register AX, dan panjang string disimpan di register BX.
CALL
Pada postingan sebelumnya, ketika kita melihat fungsi-fungsi di kelompok runtime, terdapat instruksi bernama CALL.
Instruksi ini dilekatkan pada fungsi-fungsi yang digunakan di Go, dan berarti memanggil suatu fungsi, sehingga memang benar-benar disebut CALL. Setelah itu, fungsi CALL digunakan untuk mengonversi string menjadi bilangan bulat, dan pada tahap ini tidak terlihat dengan jelas di mana bilangan bulat tersebut disimpan karena telah diabstraksikan dalam fungsi.
Instruksi CMPQ & Instruksi JGE
Kembali ke alamat 0x47a86c tadi, instruksi tersebut membandingkan alamat string dengan angka 0xa (desimal 10)!
Artinya, karena argumen tersebut tidak digunakan lagi di dalam program, nilai di lokasi string ditimpa untuk membentuk ruang variabel integer x.
Inilah wujud sebenarnya dari optimasi agresif yang dilakukan pada bahasa seperti Go.
Selanjutnya muncul instruksi JGE, yang merupakan singkatan dari Jump, Greater or Equals. Dengan demikian, instruksi ini memeriksa apakah nilai lebih besar atau sama dengan nilai pembandingnya.
Maka, bukan x < 10 seperti yang tertulis, melainkan arah perbandingan dalam sintaks ini terbalik!
Hal ini dilakukan karena dalam kode mesin, melompati lebih awal ketika kondisi tidak terpenuhi lebih intuitif dan menghemat 1 instruksi, dibandingkan dengan melakukan perbandingan satu kali ketika kondisi terpenuhi lalu memeriksa kembali apakah kondisi tidak terpenuhi.
Optimasi semacam ini sangat klasik, sehingga pola ini sering muncul bahkan pada compiler dengan tingkat optimasi yang cukup rendah, berbeda dengan contoh strconv.Atoi yang telah kita lihat sebelumnya, sehingga akan bermanfaat jika Anda memahaminya.
Dengan demikian, dengan menerapkan prinsip ini, kita dapat memperoleh sumber yang berbeda tetapi menghasilkan assembly yang 100% identik.
Contoh Kode Cermin
Dengan menggunakan skrip berikut, skrip bash akan membuat dua sumber yang 100% cermin, lalu memverifikasi bahwa setelah menghilangkan metadata yang berubah-ubah, hanya bagian main yang menghasilkan assembly yang persis sama.
1#!/usr/bin/env bash
2
3# 1. Inisialisasi penuh untuk sisa file dan direktori lama
4echo "[1/6] Cleaning up old artifacts..."
5rm -rf test_dir main_orig main_asm orig.asm asm.asm orig_pure.asm asm_pure.asm
6mkdir -p test_dir
7
8# 2. Menulis kode sumber versi asli (main.go)
9echo "[2/6] Generating main.go..."
10cat << 'EOF' > main.go
11package main
12
13import (
14 "os"
15 "strconv"
16)
17
18func main() {
19 if len(os.Args) < 2 {
20 return
21 }
22 x, _ := strconv.Atoi(os.Args[1])
23
24 s1 := "X is smaller than 10"
25 s2 := "X is larger or same as 10"
26
27 if x < 10 {
28 println(s1)
29 } else {
30 println(s2)
31 }
32}
33EOF
34
35# 3. Menulis kode sumber versi cermin (main_from_asm.go)
36# Struktur operator disinkronkan simetris sempurna agar compiler menggunakan template optimasi (JGE) yang sama
37echo "[3/6] Generating main_from_asm.go..."
38cat << 'EOF' > main_from_asm.go
39package main
40
41import (
42 "os"
43 "strconv"
44)
45
46func main() {
47 if len(os.Args) < 2 {
48 return
49 }
50 x, _ := strconv.Atoi(os.Args[1])
51
52 s1 := "X is smaller than 10"
53 s2 := "X is larger or same as 10"
54
55 // Dengan mempertahankan struktur x < 10 yang berpatokan pada 10, compiler
56 // akan menggunakan mekanisme JGE dan penempatan blok yang persis sama dengan main.go.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Melakukan build di masing-masing sumber dalam direktori dan nama file yang sama
66echo "[4/6] Compiling both sources inside 'test_dir'..."
67cp main.go test_dir/main.go
68cd test_dir && go build -o ../main_orig main.go && cd ..
69
70rm test_dir/main.go
71cp main_from_asm.go test_dir/main.go
72cd test_dir && go build -o ../main_asm main.go && cd ..
73
74# 5. Mengekstrak bagian assembly fungsi main.main murni menggunakan go tool objdump
75echo "[5/6] Extracting main.main assembly sections..."
76go tool objdump -s "main\.main" main_orig > orig.asm
77go tool objdump -s "main\.main" main_asm > asm.asm
78
79# Menyaring hanya field instruksi murni (Opcode & Operands) yang akan dieksekusi CPU,
80# dengan menghapus alamat virtual, offset, dan teks data byte mesin
81awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
82awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
83
84# 6. Verifikasi integritas struktur instruksi mesin melalui diff
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89 echo "===> [์ฑ๊ณต] ๋ ๋ฐ์ด๋๋ฆฌ์ main.main ๊ธฐ๊ณ์ด ๋ก์ง์ด 100% ์ผ์นํฉ๋๋ค! <==="
90 echo "์ปดํ์ผ๋ฌ์ ์ต์ ํ ํ์ดํ๋ผ์ธ ๊ฐ์ด๋๋ผ์ธ์ ์๋ฒฝํ๊ฒ ๋๊ธฐํํ์ฌ ๋์ผํ ์ด์
๋ธ๋ฆฌ๋ฅผ ์ป์์ต๋๋ค."
91else
92 echo "===> [์คํจ] ์ด์
๋ธ๋ฆฌ ๋ช
๋ น์ด ๊ตฌ์กฐ์ ์ฐจ์ด์ ์ด ๋ฐ๊ฒฌ๋์์ต๋๋ค. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
Ketika skrip ini benar-benar dijalankan, kita akan memperoleh informasi seperti berikut.
1[1/6] Cleaning up old artifacts...
2[2/6] Generating main.go...
3[3/6] Generating main_from_asm.go...
4[4/6] Compiling both sources inside 'test_dir'...
5[5/6] Extracting main.main assembly sections...
6[6/6] Verifying assembly structural integrity via diff...
7------------------------------------------------------------
8===> [์ฑ๊ณต] ๋ ๋ฐ์ด๋๋ฆฌ์ main.main ๊ธฐ๊ณ์ด ๋ก์ง์ด 100% ์ผ์นํฉ๋๋ค! <===
9์ปดํ์ผ๋ฌ์ ์ต์ ํ ํ์ดํ๋ผ์ธ ๊ฐ์ด๋๋ผ์ธ์ ์๋ฒฝํ๊ฒ ๋๊ธฐํํ์ฌ ๋์ผํ ์ด์
๋ธ๋ฆฌ๋ฅผ ์ป์์ต๋๋ค.
10------------------------------------------------------------
Kesimpulan
Bahasa pemrograman menyediakan banyak abstraksi, tetapi di balik abstraksi tersebut terdapat optimasi yang sangat menarik dan agresif. Dengan memanfaatkan hal ini secara terbalik, kita juga dapat membuat kode cermin yang sumbernya berbeda tetapi assembly-nya sama. Jika Anda tertarik pada level rendah dan menemukan perangkat lunak proprietary yang ditulis dengan Go, membongkar assembly-nya secara langsung untuk memulihkan sumbernya tampaknya bukan hal yang mustahil.
Pelajaran Berikutnya
Pada pelajaran berikutnya, kita akan mempelajari pernyataan select-case yang juga menarik, selain If.