GoSuda

Go-ehtolauseiden (if) AMD64-assemblykoodin generointi ja haaraohjeiden analyysi

By Lee Yunjin views ...

Go-kielen If-lause

Ensinnäkin, syy siihen, että valitsimme Go:n, on se, että Go on moderneista kielistä se, jonka assembly on "kauneinta", ja verrattuna klassisiin kieliin sen syntaksin tehokkuus on joskus jopa ylivoimaista.

Nyt, kun ymmärsimme edellisessä luennossa yksinkertaisen Go-ohjelman toimintatavan, vertaillaan Go:ta ja Assemblyä suoraan rivi riviltä.

Lähdekoodi

Ensinnäkin, Go:ssa, kuten myös GCC:n mukaan lukien modernit kääntäjät, optimoidaan automaattisesti haarautumat, joilla ei ole käyttöä. GCC:n ja Clangin kaltaiset C-kielen kääntäjät tekevät teollisuusstandardin -O2-asetuksella myös hyvin aggressiivisia optimointeja, joten aika, jolloin ohjelmoija ei voinut täysin luottaa kääntäjään, päättyi lopullisesti jo 1900-luvun loppupuolella.

Siksi kääntäjän näkökulmasta ehdon on oltava sellainen, jota kääntäjän on vaikea ennustaa ja korvata toisella rakenteella, jotta sillä on edes merkitystä.

 1package main
 2
 3import (
 4    "os"
 5    "strconv"
 6)
 7
 8func main() {
 9    // Jos tämä korvataan ennustettavalla ja haarautuman poistavalla lausekkeella, kuten x = 10,
10    // kääntäjä optimoi sen ja poistaa haarautuman.
11    // Siksi, jos tätä halutaan tarkastella suoraan assemblynä, C-kielessä käytetään esimerkiksi -O0:aa
12    // tai annetaan kääntäjän käyttää ennustamatonta ulkoista arvoa,
13    // mutta koska tämä osio käsittelee modernia ohjelmointia,
14    // Go:n binäärioptimointien poistamista ei käytetä.
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}

Tässä tapauksessa kääntäjä ei voi ennustaa syötettä, joten haarautumalause käännetään konekielelle sellaisenaan.

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)

go tool -työkalun avulla saadaan mukavasti selville, mikä lause vastaa mitäkin assembly-koodia.

Käsiteltävänä ovat vertailu ja if-haarautumalause, joten riittää, että tarkastellaan muutamia rivejä.

CMPQ-käsky & JL-käsky

CMPQ-käsky on käsky, jolla vertaillaan 4 tavun (4 sanan) tietotyyppejä, ja sen nimi tulee sanoista CoMPareQuadword, joka on lyhennetty muotoon CMPQ.

Muistiosoitteessa 0x47a84e on lause CMPQ os.Args+8(SB), $0x2. Tässä tapauksessa verrataan ohjelman vastaanottamien argumenttien määrää heksadesimaalilukuun 0x2 (eli käytännössä lukuun 2).

Tämän jälkeen, jos argumentteja on alle kaksi (eli ohjelma on ainoa argumentti), suoritetaan ehdollinen hyppy JL-käskyllä. Eli tämä on Jump, Less than' lyhennettynä JL. Edellisen vertailun perusteella, jos argumentteja oli alle kaksi, hypätään osoitteeseen 0x47a8b0, jossa on JGE. Tässä käytetään kuitenkin AX-rekisteriä, joten on tiedettävä, mikä arvo rekisterissä on.

MOVQ-käsky

Tämän jälkeen on ymmärrettävä, miten CX-rekisteriä käytetään varsinaisesti datan aloitusosoitteen tallentamiseen ja miten varsinainen data luetaan osoitteesta.

Alueella 0x47858-0x47863 tämä toimenpide suoritetaan vaiheittain.

Ensin argumenttitaulukon aloitusosoite lisätään CX-rekisteriin MOVQ os.Args(SB), CX-käskyllä. Tässä on ymmärrettävä Go:n merkkijonotyyppi.

Go:n string on rakenne, ja tämä rakenne koostuu kahdesta 8 tavun tietoalkiosta, eli yhteensä 16 tavusta.

struct8 tavua8 tavua
stringmuistiosoitemerkkijonon pituus

Visuaalisesti se näyttää yllä olevalta: ensimmäiset 8 tavua sisältävät merkkijonon aloitusosoitteen, ja seuraavat 8 tavua merkkijonon pituuden.

Siksi merkkijonon osoite tallennetaan AX-rekisteriin ja merkkijonon pituus BX-rekisteriin.

CALL

Edellisessä kirjoituksessa, kun tarkasteltiin runtime-funktioita, niiden edessä oli CALL-käsky. Tämä esiintyy Go:ssa käytettävien funktioiden edessä, ja se tarkoittaa sanatarkasti, että jokin funktio kutsutaan, eli CALL. Tämän jälkeen CALL-funktiolla muutetaan merkkijono kokonaisluvuksi, mutta sitä, mihin kokonaisluku tallennetaan, ei näy, sillä se on abstrahoitu funktion sisälle.

CMPQ-käsky & JGE-käsky

Palataan takaisin edelliseen osoitteeseen 0x47a86c: käsky vertaa merkkijonon osoitetta lukuun 0xa (kymmenen kymmenjärjestelmässä)!

Tämä tarkoittaa, että koska ohjelma ei enää käytä kyseistä argumenttia, se on ylikirjoitettu merkkijonon paikalle, jolloin kokonaislukumuuttujan x paikka on luotu.

Tämä on Go-kielessä ja muissa vastaavissa kielissä tehtävän aggressiivisen optimoinnin todellinen luonne.

Sen jälkeen esiintyy JGE-käsky, joka on lyhennys sanoista Jump, Greater or Equals. Tämä lause kysyy siis, onko verrattava arvo suurempi tai yhtä suuri kuin vertailukohde.

Näin ollen lause ei ole suoraan x < 10, vaan vertailun suunta on käännetty päinvastaiseksi verrattuna x < 10:een! Konekielessä tämä on intuitiivisempaa ja säästää yhden käskyn, koska ehdon ollessa epätosi hyppääminen ohi tapahtuu ennakoivasti. Sen sijaan, että ehdon ollessa tosi suoritettaisiin vertailu kerran ja tarkistettaisiin uudelleen, onko se epätosi, hyppy tehdään heti.

Tämä optimointi on hyvin klassinen, ja kuten yllä nähdyssä strconv.Atoi-esimerkissä, sitä esiintyy usein myös kääntäjissä, joiden optimointi on huomattavasti vaatimattomampaa, joten sen tunteminen on hyödyllistä.

Tätä periaatetta soveltamalla voidaan saada lähdekoodi, joka on erilainen, mutta joka tuottaa assemblyssä 100 % saman tuloksen.

Peilikuvakoodiesimerkki

Alla olevan skriptin avulla Bash-skripti voi luoda kaksi täysin peilikuvaa lähdekoodista (100 %) ja varmistaa, että kun tarkastellaan pelkästään main-funktiota ja jätetään pois vaihtelevat metatiedot, saadaan täsmälleen sama assembly.

 1#!/usr/bin/env bash
 2
 3# 1. Tyhjennä täysin vanhat jäännöstiedostot ja hakemistot
 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. Kirjoita alkuperäisen version lähdekoodi (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. Kirjoita peilikuvaversion lähdekoodi (main_from_asm.go)
36# Synkronoi operaattorien rakenne täydellisesti symmetriseksi, jotta kääntäjä käyttää
37# optimointimallia (JGE) sellaisenaan
38echo "[3/6] Generating main_from_asm.go..."
39cat << 'EOF' > main_from_asm.go
40package main
41
42import (
43        "os"
44        "strconv"
45)
46
47func main() {
48        if len(os.Args) < 2 {
49                return
50        }
51        x, _ := strconv.Atoi(os.Args[1])
52
53        s1 := "X is smaller than 10"
54        s2 := "X is larger or same as 10"
55
56        // Käyttämällä vertailukohtana lukua 10 ja säilyttämällä rakenteen x < 10 kääntäjä ottaa
57        // käyttöön täsmälleen saman JGE-mekanismin ja lohkojärjestyksen kuin main.go:ssa.
58        switch {
59        case x < 10:
60                println(s1)
61        default:
62                println(s2)
63        }
64}
65EOF
66
67# 4. Käännä molemmat lähdekoodit samassa hakemisto- ja tiedostonimiympäristössä
68echo "[4/6] Compiling both sources inside 'test_dir'..."
69cp main.go test_dir/main.go
70cd test_dir && go build -o ../main_orig main.go && cd ..
71rm test_dir/main.go
72
73cp main_from_asm.go test_dir/main.go
74cd test_dir && go build -o ../main_asm main.go && cd ..
75
76# 5. Pura pelkkä main.main-assembly-funktio go tool objdumpilla
77echo "[5/6] Extracting main.main assembly sections..."
78go tool objdump -s "main\.main" main_orig > orig.asm
79go tool objdump -s "main\.main" main_asm > asm.asm
80
81# Poista virtuaaliset osoitteet, offsetit ja konekielen tavudatateksti
82# ja suodata vain suoritettavien käskyjen (Opcode & Operands) kentät
83awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
84awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
85
86# 6. Varmista kahden konekielikäskyrakenteen eheys diff-vertailulla
87echo "[6/6] Verifying assembly structural integrity via diff..."
88echo "------------------------------------------------------------"
89if diff orig_pure.asm asm_pure.asm > /dev/null; then
90    echo "===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <==="
91    echo "컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다."
92else
93    echo "===> [실패] 어셈블리 명령어 구조에 차이점이 발견되었습니다. <==="
94    diff -u orig_pure.asm asm_pure.asm
95fi
96echo "------------------------------------------------------------"

Kun ajat lähdekoodit, saat seuraavanlaisen tulosteen.

 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------------------------------------------------------------

Johtopäätös

Ohjelmointikielet tarjoavat paljon abstraktiota, mutta abstraktion takana piilee mielenkiintoisia ja aggressiivisia optimointeja. Tätä periaatetta voi myös hyödyntää käänteisesti: luoda lähdekoodeja, jotka ovat erilaisia mutta joilla on sama assembly, eli peilikuvakoodia. Jos olet kiinnostunut matalan tason ohjelmoinnista ja kohtaat Go:lla kirjoitettua suljettua ohjelmistoa, assemblyn purkaminen ja analysointi lähdekoodin palauttamiseksi ei välttämättä ole mahdotonta.

Seuraava luento

Seuraavalla kerralla tutustumme If-lauseeseen ja toiseen mielenkiintoiseen rakenteeseen, select-case-lauseeseen.