Go feltételes utasítás (if) AMD64 assembly kódgenerálása és elágazási utasítások elemzése

Az if utasítás Go nyelvben
Először is, azért választottuk a Go nyelvet, mert a Go a modern nyelvek közül a legszebb assembly kódot állítja elő, és a szintaxis hatékonysága a klasszikus nyelvekkel összehasonlítva is néha egyenesen lenyűgöző.
Most, hogy az előző előadásban megértettük az egyszerű Go program működését, hasonlítsuk össze közvetlenül a Go-t és az Assembly-t soronként.
Forráskód
Először is, a Go-ban, sőt a modern fordítók, köztük a GCC is, automatikusan optimalizálják azokat az elágazásokat, amelyeknek nincs értelmük. A GCC és a Clang, mint C nyelvi fordítók, az iparági szabványnak számító -O2 szinten is nagyon agresszív optimalizálást végeznek, így a programozóknak a fordítóban való teljes bizalmatlanságának korszaka tulajdonképpen már a 20. század második felében véget ért.
Ezért a fordító szemszögéből olyan feltételeket kell megadni, amelyeket a fordító nehezen tud megjósolni és más szerkezetre átalakítani, csak így van értelme a vizsgálatnak.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Ha ezt olyan kiszámítható, és az elágazást eltávolítható dologgal oldjuk meg, mint x = 10,
10 // akkor a fordító optimalizálja, és eltávolítja az elágazást.
11 // Ezért ha ezt közvetlenül assemblyben szeretnénk megnézni, C nyelvben például a -O0 kapcsolót kell használni,
12 // vagy magának a fordítónak egy előre nem jelezhető külső értéket kell használnia,
13 // de ez a szekció a modern programozással foglalkozik, ezért a Go bináris optimalizálásának
14 // kikapcsolását nem alkalmazzuk.
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}
Ebben az esetben, mivel a fordító nem tudja előre megjósolni a bemenetet, az elágazás változatlanul gépi kódra fordul.
Assembly nyelv
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)
A go tool segítségével szépen megmutatja, hogy melyik szerkezet melyik assembly kódnak felel meg, soronként.
Mivel most az összehasonlító utasításokat és az if elágazást tanuljuk, elegendő néhány sorra figyelni.
CMPQ utasítás és JL utasítás
A CMPQ utasítás 4 bájtos (4 szavas) adattípusok összehasonlítására szolgál, nevét a CoMPareQuadword szóból rövidítették CMPQ-ra.
A 0x47a84e memóriacímen a CMPQ os.Args+8(SB), $0x2 utasítás található.
Ebben az esetben a program a kapott argumentumok számát hasonlítja össze a 16-os számrendszerbeli 0x2 értékkel (vagyis egyszerűen 2-vel).
Ezután, ha az argumentumok száma kisebb 2-nél (vagyis ha csak a program saját neve az argumentum), a JL segítségével feltételes ugrás történik. Ez a Jump, Less than rövidítése, vagyis JL.
Ha az előző összehasonlításban az argumentumok száma kisebb volt 2-nél, a program a 0x47a8b0 címre ugrik, ahol egy JGE utasítás található.
Ehhez azonban tudnunk kell, hogy az ebben a utasításban használt regiszter az AX, így ismernünk kell a regiszterben tárolt érték jelentését.
MOVQ utasítás
Ezt követően a CX regiszter segítségével tároljuk az adat kezdőcímét, és meg kell értenünk, hogyan olvassuk ki a valós adatot a címről.
A 0x47858-0x47863 tartományban ezt a műveletet lépésenként hajtja végre.
Először is, az argumentumtömb kezdőcímét a MOVQ os.Args(SB), CX utasítással helyezzük a CX regiszterbe. Ehhez meg kell értenünk a Go string típusát.
A Go string egy struktúra, amely két 8 bájtos adatból, azaz összesen 16 bájtból áll.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Vizuálisan a fenti módon ábrázolható: az első 8 bájt a string kezdőcímét, a második 8 bájt pedig a string hosszát tartalmazza.
Ezért a string címét az AX regiszterben, a hosszát pedig a BX regiszterben tároljuk.
CALL
Az előző bejegyzésben a runtime függvények vizsgálatakor is megjelent a CALL utasítás.
Ez a Go függvényei előtt áll, és egy függvény meghívását jelenti, szó szerint a CALL (hívás) értelemben. Ezután a CALL segítségével a string egész számmá alakul, és ekkor nem látszik a függvényben elrejtett absztrakció miatt, hogy az eredményt hova tárolja. A függvény absztrakciója miatt ez nem látható.
CMPQ utasítás és JGE utasítás
Visszatérve a korábbi 0x47a86c címhez, az utasítás a string címét hasonlítja össze a 0xa számmal (tízes számrendszerben 10)!
Ez azt jelenti, hogy mivel a program a továbbiakban nem használja az adott argumentumot, a string helyére írt felül, így létrehozva az egész típusú x változó helyét.
Ez az a konkrét megvalósítása a Go nyelvben is alkalmazott agresszív optimalizálásnak.
Ezután a JGE utasítás jelenik meg, amely a Jump, Greater or Equals rövidítése. Ez az utasítás tehát azt vizsgálja, hogy a összehasonlítandó érték nagyobb-e vagy egyenlő-e.
Vagyis a kód nem pontosan az x < 10 szerkezetet követi, hanem az összehasonlítás iránya fordított!
Gépi kódban ugyanis az a megoldás intuitívabb és egy utasítással kevesebbet igényel, ha a feltétel nem teljesülése esetén előre átugorjuk a blokkot, ahelyett, hogy a teljesülő ágat egyszer végrehajtva újra ellenőriznénk, hogy a feltétel nem teljesül-e.
Ez az optimalizáció nagyon klasszikus, ezért érdemes megjegyezni, mert a strconv.Atoi példától eltérően olyan fordítókban is gyakran megjelenik, amelyek optimalizálási szintje meglehetősen alacsony.
Ezen felismerés alkalmazásával tehát olyan forráskódot kapunk, amely a forráskódban különbözik, de assembly szinten 100%-ban megegyezik.
Tükörkép kód példa
Az alábbi szkript segítségével a bash szkript 100%-ban tükörképes két forráskódot hoz létre, majd az egyes buildekben változó metaadatok kivételével csak a main függvényt vizsgálva igazolható, hogy pontosan azonos assembly kód keletkezik.
1#!/usr/bin/env bash
2
3# 1. A korábbi maradék fájlok és könyvtárak teljes törlése
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. Az eredeti verzió forráskódjának létrehozása (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. A tükörképes verzió forráskódjának létrehozása (main_from_asm.go)
36# A fordító az optimalizációs sablont (JGE) változtatás nélkül használja, ha az operátorok szerkezete teljesen szimmetrikusan szinkronizált
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 // A 10-es értéket alapul véve, az x < 10 szerkezet megtartásával a fordító
56 // pontosan ugyanazt a JGE mechanizmust és blokkelrendezést alkalmazza, mint a main.go esetében.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Build végrehajtása azonos könyvtár-elérési úttal és fájlnévvel, külön-külön
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. A tiszta main.main assembly függvény kinyerése a go tool objdump segítségével
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# A virtuális címek, eltolások és a gépi kódbájtok szöveges adatait eltávolítjuk,
80# és csak a CPU által végrehajtandó tiszta utasításkészlet (Opcode és Operandusok) mezőit szűrjük ki
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. A két gépi kódú utasításstruktúra diff-ellenőrzése
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 "------------------------------------------------------------"
A szkriptet valóban lefuttatva a következő információkat kapjuk.
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------------------------------------------------------------
Következtetés
A programozási nyelvek sok absztrakciót kínálnak, de az absztrakció mögött igen érdekes és agresszív optimalizációk húzódnak meg. Ezt a tulajdonságot kihasználva olyan tükörképes kódot is létre tudtunk hozni, amelynek forráskódja eltér, de assembly kódja azonos. Ha érdekel az alacsony szintű programozás, és Go nyelven írt zárt forráskódú szoftverrel találkozunk, az assembly szétszerelésével és elemzésével a forráskód visszafejtése sem tűnik lehetetlennek.
Következő előadás
A következő alkalommal az if utasítást és a szintén érdekes select-case utasítást fogjuk megismerni.