Analýza generovania AMD64 assembleru pre podmienky (if) v jazyku Go a inštrukcií vetvenia

If príkazy v jazyku Go
Najprv treba uviesť, že sme si Go vybrali preto, lebo medzi modernými jazykmi má jeho assembler najkrajšiu podobu a efektivita jeho syntaxe môže byť v porovnaní s klasickými jazykmi dokonca presvedčivá.
Teraz, keď sme v predchádzajúcej prednáške pochopili, ako funguje jednoduchý program v jazyku Go, porovnajme priamo Go a Assembly riadok po riadku.
Zdrojový kód
Najprv, rovnako ako v jazyku Go, aj moderné kompilátory vrátane GCC automaticky optimalizujú vetvenia, ktoré nemajú zmysel. Kompilátory jazyka C, ako GCC a Clang, pri štandardnej úrovni -O2 vykonávajú veľmi agresívne optimalizácie, takže éra, v ktorej programátor kompilátoru nemohol plne dôverovať, sa vlastne definitívne skončila už koncom 20. storočia.
Preto má podmienka zmysel len vtedy, keď je zadaná tak, že ju kompilátor nedokáže predvídať a nahradiť iným výrazom.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Ak túto podmienku nahradíme niečím predvídateľným, ako napríklad x = 10, ktorým možno vetvenie odstrániť,
10 // kompilátor optimalizuje kód a vetvenie odstráni.
11 // Ak to chceme vidieť priamo v assembleri, v jazyku C použijeme napríklad -O0,
12 // alebo necháme kompilátor pracovať s externou hodnotou, ktorú nedokáže predvídať,
13 // keďže táto časť sa venuje modernému programovaniu, optimalizáciu binárneho kódu v jazyku Go
14 // vypínať nebudeme.
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}
V tomto prípade kompilátor nedokáže predvídať vstup, preto sa príkaz vetvenia preloží do strojového kódu nezmenený.
Assembler
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)
Pomocou nástroja go tool nám ochotne ukáže, ktorý príkaz zodpovedá ktorým inštrukciám assemblera.
Keďže sa v tomto diele budeme venovať porovnávacím príkazom a vetveniu if, stačí sledovať niekoľko riadkov.
Inštrukcie CMPQ a JL
Inštrukcia CMPQ slúži na porovnanie dátových typov s veľkosťou 4 bajty (4 slová) a jej názov pochádza z CoMPareQuadword, odtiaľ skratka CMPQ.
Na pamäťovej adrese 0x47a84e nájdeme príkaz CMPQ os.Args+8(SB), $0x2.
V tomto prípade program porovnáva počet prijatých argumentov so šestnástkovou hodnotou 0x2 (teda jednoducho s číslom 2).
Potom sa pomocou JL zisťuje, či je počet argumentov menší ako 2 (teda ak je jediným argumentom samotný program), a podľa výsledku sa vykoná skok. Ide o skratku z Jump, Less than', teda JL.
Ak je argumentov menej ako 2, skáče sa na adresu 0x47a8b0, kde sa nachádza JGE.
Keďže sa však v tejto časti používa register AX, musíme poznať, aká hodnota je v registri uložená.
Inštrukcia MOVQ
Ďalej treba pochopiť, ako sa pomocou registra 'CX' ukladá začiatočná adresa dát a ako sa z nej po jej načítaní získavajú skutočné údaje.
Z rozsahu 0x47858-0x47863 vidno, že táto operácia prebieha postupne.
Najprv sa začiatočná adresa poľa argumentov vloží do registra CX pomocou príkazu MOVQ os.Args(SB), CX. Pritom je potrebné rozumieť typu string v jazyku Go.
Typ string v jazyku Go je štruktúra, ktorá sa skladá z dvoch 8-bajtových údajov, teda má celkovo 16 bajtov.
| struct | 8 bajtov | 8 bajtov |
|---|---|---|
| string | adresa v pamäti | dĺžka reťazca |
Vizuálne to vyzerá ako vyššie, pričom prvých 8 bajtov obsahuje začiatočnú adresu reťazca a nasledujúcich 8 bajtov jeho dĺžku.
Adresa reťazca sa teda uloží do registra AX a dĺžka reťazca do registra BX.
CALL
Aj v predchádzajúcom príspevku sme pri pohľade na funkcie z balíka runtime videli pridanú inštrukciu CALL.
Táto inštrukcia sa nachádza pred funkciami používanými v jazyku Go a znamená, že sa volá určitá funkcia; doslova teda ide o CALL. Následne sa pomocou volania CALL reťazec prevedie na celé číslo, pričom kam sa toto číslo uloží, nie je viditeľné, pretože je abstrahované vo funkcii.
Inštrukcie CMPQ a JGE
Ak sa vrátime k adrese 0x47a86c, inštrukcia porovnáva adresu reťazca s číslom 0xa (v desiatkovej sústave 10)!
To znamená, že keďže program daný argument ďalej nepoužíva, prepísal ho na mieste reťazca a vytvoril tak miesto pre celočíselnú premennú x.
Toto je podstata agresívnej optimalizácie, ktorá prebieha v jazyku Go a podobných jazykoch.
Potom sa objaví inštrukcia JGE, ktorá je skratkou z Jump, Greater or Equals. Tento príkaz teda zisťuje, či je hodnota väčšia alebo rovná porovnávanej hodnote.
Preto nejde o priamo zapísaný výraz x < 10, ale smer porovnania v tomto výraze je obrátený!
V strojovom kóde je to preto, lebo preskočiť vykonávanie vopred, keď podmienka neplatí, je intuitívnejšie a ušetrí jednu inštrukciu v porovnaní s tým, aby sa po jednom porovnaní pri splnenej podmienke ešte raz overovalo, či neplatí.
Táto optimalizácia je veľmi klasická, a preto sa na rozdiel od príkladu strconv.Atoi uvedeného vyššie objavuje často aj v kompilátoroch s oveľa nižšou úrovňou optimalizácie, takže je dobré ju poznať.
Ak sa tieto poznatky využijú, možno získať zdrojové kódy, ktoré sa síce líšia, ale na úrovni assemblera sú zhodné na 100 %.
Príklad zrkadlového kódu
Pomocou nižšie uvedeného skriptu môžeme overiť, že bash skript vytvorí dva zrkadlové zdrojové kódy na 100 % a že po vynechaní metadát, ktoré sa zakaždým menia, sa pri pohľade iba na main získa presne rovnaký assembler.
1#!/usr/bin/env bash
2
3# 1. Úplné vymazanie existujúcich zvyšných súborov a adresárov
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. Zápis zdrojového kódu pôvodnej verzie (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. Zápis zdrojového kódu zrkadlovej verzie (main_from_asm.go)
36# Dokonalá symetrická synchronizácia štruktúry operátorov, aby kompilátor použil optimalizačnú šablónu (JGE) bez zmeny
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 // Ak sa za základ zoberie 10 a zachová sa štruktúra x < 10, kompilátor
56 // použije presne rovnaký mechanizmus JGE a rozloženie blokov ako main.go.
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Samostatné zostavenie v rovnakom adresári a s rovnakým názvom súboru
67echo "[4/6] Compiling both sources inside 'test_dir'..."
68cp main.go test_dir/main.go
69cd test_dir && go build -o ../main_orig main.go && cd ..
70rm test_dir/main.go
71
72cp main_from_asm.go test_dir/main.go
73cd test_dir && go build -o ../main_asm main.go && cd ..
74
75# 5. Extrakcia čistej assembler funkcie main.main pomocou go tool objdump
76echo "[5/6] Extracting main.main assembly sections..."
77go tool objdump -s "main\.main" main_orig > orig.asm
78go tool objdump -s "main\.main" main_asm > asm.asm
79
80# Odstránenie virtuálnych adries, offsetov a textových údajov strojových bajtov
81# a filtrovanie iba polí čistej sady inštrukcií pre CPU (Opcode a Operands)
82awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
83awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
84
85# 6. Overenie štruktúry strojových inštrukcií oboch programov pomocou diff
86echo "[6/6] Verifying assembly structural integrity via diff..."
87echo "------------------------------------------------------------"
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 "------------------------------------------------------------"
Po spustení skriptu získame nasledujúce informácie.
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------------------------------------------------------------
Záver
Programovacie jazyky poskytujú veľa abstrakcií, no za nimi sa skrývajú veľmi zaujímavé a agresívne optimalizácie. Tieto poznatky sme navyše mohli využiť na vytvorenie zrkadlového kódu, ktorý sa líši v zdrojovom texte, ale má rovnaký assembler. Ak sa zaujímate o nízkoúrovňové programovanie a narazíte na proprietárny softvér napísaný v jazyku Go, môže byť priama analýza assemblera a rekonštrukcia zdrojového kódu aspoň čiastočne realizovateľná.
Nasledujúca prednáška
V budúcej hodine sa pozrieme na príkazy if a na ďalší zaujímavý príkaz, select-case.