Analyse av AMD64-assemblykodegenerering og forgreningsinstruksjoner for Go-betingelsessetninger (if)

If-setningen i Go
Først er grunnen til at vi valgte Go at Go, blant moderne språk, har den mest «vakre» assemblyen, og at effektiviteten i syntaksen til tider faktisk er overveldende, selv sammenlignet med klassiske språk.
Nå som vi forsto hvordan et enkelt Go-program fungerer i forrige forelesning, skal vi sammenligne Go og Assembly linje for linje.
Kildekode
For det første optimaliserer moderne kompilatorer, inkludert de i Go og til og med GCC, automatisk grener som ikke har noen nytte. GCC og Clang, som er C-kompilatorer, utfører svært aggressiv optimalisering under bransjestandarden -O2, så tiden da programmerere ikke fullt ut kunne stole på kompilatoren, er endelig blitt en saga blott fra slutten av 1900-tallet.
Derfor får en betingelse først mening i det minste når den er vanskelig for kompilatoren å forutsi og erstatte med en annen konstruksjon.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Hvis man erstatter dette med noe forutsigbart og som kan fjerne grenen, slik som x = 10,
10 // optimaliserer kompilatoren det og fjerner grenen.
11 // For å se slikt direkte i assembly i C må man derfor bruke f.eks. -O0,
12 // eller la kompilatoren bruke en ytre verdi som den ikke kan forutsi,
13 // men siden denne delen handler om moderne programmering, brukes ikke
14 // metoden der man slår av Go sin binæroptimalisering.
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 inndataen, så grenen oversettes uendret til maskinkode.
Assemblyspråk
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)
Ved hjelp av go tool får man den hjelpsomme informasjonen om hvilken syntaks som tilsvarer hvilken assembly, linje for linje.
Siden vi denne gangen skal lære om sammenligninger og if-grener, er det nok å se på noen få linjer.
CMPQ-instruksjonen og JL-instruksjonen
CMPQ er en instruksjon for å sammenligne datatyper på 4 byte (4 ord), og navnet kommer fra CoMPareQuadword, forkortet til CMPQ.
Hvis man ser på minneadressen 0x47a84e, finnes setningen CMPQ os.Args+8(SB), $0x2.
I dette tilfellet sammenligner programmet antall argumenter det har mottatt med heksadesimalt 0x2 (altså bare 2).
Deretter sjekkes det om antall argumenter er mindre enn 2 (altså om programmet selv er det eneste argumentet), og et hopp utføres via JL etter sammenligningen. Altså står dette for Jump, Less than', forkortet til JL.
Hvis argumentantallet var mindre enn 2 i den foregående sammenligningen, hoppes det til adressen 0x47a8b0, og der finnes JGE.
Men siden denne setningen bruker registeret AX, må man vite hva verdien lagret i registeret er.
MOVQ-instruksjonen
Deretter må man forstå hvordan registeret 'CX' brukes til å lagre startadressen til dataene, og hvordan de faktiske dataene hentes ut etter at adressen er lest.
Når man ser på området 0x47858-0x47863, utføres denne operasjonen trinn for trinn.
Først settes startadressen til argumentarrayen inn i registeret CX med instruksjonen MOVQ os.Args(SB), CX. Her må man forstå Gos strengtype.
En string i Go er en struct, og denne structen består av to datatyper på 8 byte, altså totalt 16 byte.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | minneadresse | strenglengde |
Visuelt kan dette fremstilles som over: de første 8 byte inneholder strengens startadresse, og de siste 8 byte inneholder strengens lengde.
Derfor lagres strengens adresse i registeret AX, og strengens lengde i registeret BX.
CALL
Også i forrige innlegg, når vi så på funksjonene i runtime-pakken, var instruksjonen CALL knyttet til dem.
Den står foran funksjonene som brukes i Go, og betyr bokstavelig talt at en bestemt funksjon kalles, altså CALL.
Deretter brukes en CALL til å konvertere strengen til et heltall, men hvor heltallet lagres, er skjult i funksjonen og er ikke synlig.
CMPQ-instruksjonen og JGE-instruksjonen
Hvis vi går tilbake til adressen 0x47a86c fra før, sammenligner instruksjonen strengens adresse med tallet 0xa (10 i desimal)!
Det betyr at programmet ikke lenger bruker det aktuelle argumentet, og derfor har overskrevet strengens plass for å lage plass til den heltallsvariable x.
Dette er essensen av den aggressive optimaliseringen som utføres i blant annet Go.
Deretter dukker instruksjonen JGE opp, som er forkortelsen for Jump, Greater or Equals. Derfor spør denne setningen om verdien er større enn eller lik sammenligningsverdien.
Derfor er det ikke slik at setningen er x < 10 akkurat som den står, men med x < 10 er sammenligningsretningen i setningen snudd!
Dette er fordi det i maskinkode er mer intuitivt å hoppe over på forhånd når betingelsen ikke stemmer, enn å utføre en sammenligning når betingelsen stemmer og deretter sjekke om den ikke stemmer, og det sparer én instruksjon.
Denne optimaliseringen er svært klassisk, og i motsetning til strconv.Atoi-eksemplet ovenfor er det et mønster som ofte dukker opp selv i kompilatorer med betydelig lavere optimaliseringsnivå, så det er nyttig å kjenne til.
Ved å utnytte dette kan man derfor få kildekode som er forskjellig, men som gir 100% identisk assembly.
Speilkode-eksempel
Ved hjelp av skriptet nedenfor kan man verifisere at bash-skriptet lager to 100% speilvendte kildefiler, og at man, når man kun ser på main og ser bort fra metadata som varierer fra gang til gang, får nøyaktig samme assembly.
1#!/usr/bin/env bash
2
3# 1. Fullstendig tilbakestilling av eksisterende rester av 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# Synkroniser operatorstrukturen fullstendig symmetrisk, slik at kompilatoren bruker optimaliseringsmalen (JGE) uendret
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 // 10를 기준으로 삼아 x < 10 구조를 유지하면 컴파일러는
56 // main.go와 정확히 동일한 JGE 매커니즘 및 블록 배치를 채택합니다.
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Bygg hver av dem i samme katalogbane 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 ..
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. Trekk ut den rene assembly-funksjonen main.main ved hjelp av 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# Fjern den virtuelle adressen, offsetten og teksten med maskinkodebytes,
81# og filtrer ut kun feltene for den rene instruksjonssettet (Opcode & Operands) som CPU-en skal utføre
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 til maskinkodeinstruksjonene med 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 "------------------------------------------------------------"
Når man faktisk kjører kildekoden, får man informasjonen nedenfor.
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 mange abstraksjoner, men bak abstraksjonene skjuler det seg svært interessante og aggressive optimaliseringer. Ved å utnytte dette kunne vi også lage speilvendt kode, der kildekoden er forskjellig, men assemblyen er identisk. Hvis man er interessert i lavnivå og støter på proprietær programvare skrevet i Go, er det kanskje ikke umulig å gjenopprette kildekoden ved å dekompilere og analysere assemblyen direkte.
Neste forelesning
Neste gang skal vi se nærmere på if-setningen og på select-case-setningen, som også er interessant.