GoSuda

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

By Lee Yunjin views ...

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.

struct8 bajtov8 bajtov
stringadresa v pamätidĺž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.