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

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.
| struct | 8 tavua | 8 tavua |
|---|---|---|
| string | muistiosoite | merkkijonon 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.