Analyse av AMD64-assemblerkodgenerering og grenintstruksjoner for betingede setninger (if) i Go

If-setninger i Go-språket
Først og fremst valgte vi Go fordi Go er det moderne språket der assembly er "vakrest" blant alle, og fordi effektiviteten i syntaksen til tider er overveldende selv sammenlignet med klassiske språk.
Nå som vi forsto hvordan et enkelt Go-program fungerer i forrige forelesning, la oss sammenligne Go og Assembly linje for linje.
Kildekode
Først og fremst optimaliserer moderne kompilatorer, inkludert GCC, automatisk grener som ikke har noen mening, og dette gjelder ikke bare Go. GCC og Clang, som er C-kompilatorer, utfører svært aggressive optimaliseringer ved den industristandarde -O2, så tiden da programmerere vanskelig kunne stole fullt på kompilatoren ble i realiteten avsluttet allerede på slutten av 1900-tallet.
Derfor må man gi betingelser som kompilatoren vanskelig kan forutsi og omskrive til andre setninger, for at de i det minste skal få en mening.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Hvis vi erstatter dette med noe forutsigbart og fjernbart, slik som x = 10,
10 // vil kompilatoren optimalisere og fjerne grenen.
11 // Derfor, for å se dette direkte i assembly, må man i C bruke noe som -O0,
12 // eller la kompilatoren bruke en ikke-forutsigbar ekstern verdi,
13 // men siden denne seksjonen handler om moderne programmering, bruker vi
14 // ikke metoden å slå av Gos binære optimalisering.
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}
I dette tilfellet kan ikke kompilatoren forutsi input, så if-setningen oversettes direkte til maskinkode.
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-verktøyet forteller deg heldigvis nøyaktig hvilken setning som tilsvarer hvilken assembly-instruksjon.
Det vi skal lære nå er sammenligningssetninger og if-grener, så det er nok å se på noen få linjer.
CMPQ-instruksjonen og JL-instruksjonen
CMPQ-instruksjonen er en instruksjon for å sammenligne 4-byte (4-ord) datatyper, og navnet er forkortet fra CoMPareQuadword til CMPQ.
Hvis vi ser på minneadressen 0x47a84e, finnes setningen CMPQ os.Args+8(SB), $0x2.
I dette tilfellet sammenligner programmet antall mottatte argumenter med det heksadesimale tallet 0x2 (altså bare 2).
Deretter utfører programmet et betinget hopp med JL dersom antall argumenter er mindre enn 2 (altså hvis programmet kun har sitt eget navn som argument). JL er forkortelse for Jump, Less than.
Ved sammenligningen, hvis antall argumenter var mindre enn 2, hopper programmet til adressen 0x47a8b0, der det finnes en JGE-instruksjon.
Men fordi instruksjonen her bruker AX-registeret, må vi vite hvilken verdi som er lagret i registeret.
MOVQ-instruksjonen
Deretter må vi forstå hvordan programmet bruker CX-registeret til å lagre startadressen til dataene, og hvordan det henter ut de faktiske dataene etter at adressen er lest.
Se på adresseområdet 0x47858-0x47863, der operasjonen utføres trinn for trinn.
Først settes startadressen til argumentarrayen inn i CX-registeret med instruksjonen MOVQ os.Args(SB), CX. Her må vi forstå Gos strengtype.
En string i Go er en struct, og denne structen består av to 8-byte-verdier, altså totalt 16 byte.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Visuelt ser det ut som over: de første 8 bytene inneholder startadressen til strengen, og de neste 8 bytene inneholder lengden på strengen.
Derfor lagres strengens adresse i AX-registeret og strengens lengde i BX-registeret.
CALL
I forrige innlegg, når vi så på runtime-funksjoner, var det en instruksjon som het CALL foran dem.
Den står foran funksjonene som brukes i Go, og betyr bokstavelig talt at en funksjon kalles. Deretter brukes en CALL-funksjon til å konvertere strengen til et heltall, og her er det ikke synlig hvor heltallet lagres, fordi det er abstrahert bort i funksjonen.
CMPQ-instruksjonen og JGE-instruksjonen
Hvis vi går tilbake til adressen 0x47a86c, sammenligner instruksjonen strengens adresse med tallet 0xa (desimalt 10)!
Dette betyr at siden programmet ikke bruker det opprinnelige argumentet lenger, er det overskrevet på strengens plassering for å lage en heltallsvariabel x.
Dette er den konkrete formen for den aggressive optimaliseringen som skjer i språk som Go.
Deretter dukker instruksjonen JGE opp, som er forkortelse for Jump, Greater or Equals. Denne instruksjonen spør om verdien er større eller lik den man sammenligner med.
Altså er ikke setningen akkurat x < 10, men sammenligningsretningen er snudd!
Årsaken er at det i maskinkode er mer direkte å hoppe over kodeblokken straks vilkåret ikke er oppfylt, enn å utføre sammenligningen én gang når vilkåret er oppfylt og deretter sjekke på nytt om det ikke er oppfylt, og dette sparer én instruksjon.
Denne optimaliseringen er svært klassisk, og derfor er den et mønster som ofte forekommer også i kompilatorer med langt lavere optimaliseringsnivå enn eksempelet strconv.Atoi ovenfor, så det er lurt å kjenne til den.
Ved å utnytte dette kan vi derfor få kildekoder som er forskjellige, men som gir 100 % identisk assembly.
Eksempel på speilkode
Med skriptet nedenfor kan et bash-skript lage to kildekoder som er 100 % speilvendte, og deretter verifisere at de gir nøyaktig identisk assembly for main, når metadata som varierer fra gang til gang er holdt utenfor.
1#!/usr/bin/env bash
2
3# 1. Fullstendig tilbakestilling av eventuelle gjenværende filer og kataloger
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. Skriv kildekoden for originalversjonen (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. Skriv kildekoden for speilversjonen (main_from_asm.go)
36# Strukturen av operatorene er gjort helt symmetrisk, slik at kompilatoren bruker
37# den samme optimaliseringsmalen (JGE) direkte
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 // Når 10 brukes som referanse og strukturen x < 10 beholdes, velger kompilatoren
57 // nøyaktig samme JGE-mekanisme og blokkplassering som i main.go.
58 if x < 10 {
59 println(s1)
60 } else {
61 println(s2)
62 }
63}
64EOF
65
66# 4. Bygg begge kildene i samme katalog og med samme filnavn
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 ..
70
71rm test_dir/main.go
72cp main_from_asm.go test_dir/main.go
73cd test_dir && go build -o ../main_asm main.go && cd ..
74
75# 5. Bruk go tool objdump til å hente ut ren main.main-assembly
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# Fjern virtuelle adresser, offsets og rådata for maskinkodebytes,
81# og behold bare de rene instruksjonene (Opcode og 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. Verifiser strukturen av maskinkodeinstruksjonene med diff
86echo "[6/6] Verifying assembly structural integrity via diff..."
87echo "------------------------------------------------------------"
88
89if diff orig_pure.asm asm_pure.asm > /dev/null; then
90 echo "===> [성공] To binærfilers main.main-maskinlogikk er 100 % identisk! <==="
91 echo "Ved å synkronisere kompilatorens optimaliseringsretningslinjer fullt ut, fikk vi identisk assembly."
92else
93 echo "===> [실패] Det ble funnet forskjeller i strukturen til assembly-instruksjonene. <==="
94 diff -u orig_pure.asm asm_pure.asm
95fi
96echo "------------------------------------------------------------"
Når du faktisk kjører kildekoden, får du informasjon som ligner på denne:
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------------------------------------------------------------
Konklusjon
Programmeringsspråk tilbyr mye abstraksjon, men bak abstraksjonen skjuler det seg svært interessante og aggressive optimaliseringer. Ved å utnytte dette kunne vi også lage speilkode med ulike kildekoder men identisk assembly. Hvis du er interessert i lavnivå, og møter proprietær programvare skrevet i Go, er det kanskje ikke umulig å demontere assembly og rekonstruere kildekoden.
Neste forelesning
I neste time skal vi se på select-case-setningen, som også er en interessant fortsettelse av if-setninger.