Go-villkorssatsen (if) i AMD64-assemblerkodgenerering och analys av greninstruktioner

If-satser i Go
Först och främst har vi valt Go eftersom Go, bland moderna språk, har de mest "vackra" assemblerkoderna, och dess syntax är ibland till och med överlägsen klassiska språk när det gäller effektivitet.
Nu när du förstår hur ett enkelt Go-program fungerar från föregående lektion, låt oss jämföra Go och Assembly rad för rad.
Källkod
Först av allt, inte bara i Go utan även i moderna kompilatorer, inklusive GCC, optimeras grenar som saknar betydelse automatiskt. GCC och Clang, C-kompilatorer, utför mycket aggressiva optimeringar vid -O2, som är industristandarden, så tiden då programmerare har svårt att fullt ut lita på kompilatorn fullbordades i själva verket redan under den senare delen av 1900-talet.
Därför måste villkoret ges på ett sätt som kompilatorn inte lätt kan förutsäga och ersätta med en annan syntax, annars har det ingen verklig betydelse.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Om man här använder något förutsägbart, till exempel x = 10, som kan tas bort från grenen,
10 // så optimerar kompilatorn bort grenen.
11 // För att se assemblerkoden direkt i C används därför till exempel -O0,
12 // eller så låter man kompilatorn använda ett externt värde som den inte kan förutsäga.
13 // Eftersom detta avsnitt handlar om modern programmering använder vi inte
14 // metoden att stänga av Gos binäroptimeringar.
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 det här fallet kan kompilatorn inte förutsäga indata, så if-satsen översätts till maskinkod precis som den är.
Assemblerkod
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)
Verktyget go tool visar mycket vänligt vilken syntax som motsvarar vilken assemblerkod, rad för rad.
Eftersom det vi ska lära oss nu är jämförelsesatser och if-grenar behöver vi bara uppmärksamma några rader.
CMPQ-instruktionen och JL-instruktionen
CMPQ-instruktionen är en instruktion för att jämföra 4-byte (4-word) datatyper, och dess ursprung är CoMPareQuadword, förkortat till CMPQ.
Vid minnesadressen 0x47a84e finns satsen CMPQ os.Args+8(SB), $0x2.
I det här fallet jämför programmet antalet argument som tagits emot med det hexadecimala talet 0x2 (det vill säga bara 2).
Därefter utför JL ett hopp baserat på jämförelsen, om antalet argument är mindre än 2 (det vill säga om programmet endast har sig självt som argument). JL är förkortningen av Jump, Less than.
Om antalet argument var mindre än 2 hoppar programmet till adressen 0x47a8b0, där det finns en JGE. Men den instruktion som används här är registret AX, så vi måste känna till vilket värde som är lagrat i registret.
MOVQ-instruktionen
Därefter måste vi förstå hur startadressen för datat lagras i registret CX, hur data läses från den adressen och hur det faktiska datat extraheras.
Intervallet 0x47858-0x47863 utför denna operation steg för steg.
Först placeras startadressen för argumentarrayen i registret CX med instruktionen MOVQ os.Args(SB), CX. Här måste vi förstå hur Gos strängtyp fungerar.
En string i Go är en struktur, och denna struktur består av två 8-byte-datavärden, totalt 16 byte.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Visuellt ser det ut som ovan: de första 8 byten innehåller strängens startadress och de andra 8 byten innehåller strängens längd.
Därför lagras strängens adress i registret AX och strängens längd i registret BX.
CALL
I det föregående inlägget, när vi tittade på runtime-funktioner, fanns instruktionen CALL framför dem.
Den används före funktioner i Go, och betyder att en viss funktion anropas, vilket är precis vad CALL innebär. Därefter används en CALL-funktion för att omvandla strängen till ett heltal, och var heltalet lagras är abstraherat bort i funktionen och syns inte.
CMPQ-instruktionen och JGE-instruktionen
Om vi återvänder till adressen 0x47a86c från tidigare, jämför instruktionen strängens adress med talet 0xa (tio i decimal)!
Detta innebär att eftersom argumentet inte längre används i programmet, skrev man över strängens position för att skapa en plats för den heltalsvariabla x.
Detta är den verkliga bilden av den aggressiva optimering som sker i språk som Go.
Därefter kommer instruktionen JGE, som är förkortningen av Jump, Greater or Equals. Den här satsen frågar därför om värdet är större än eller lika med jämförelsevärdet.
Alltså är jämförelseriktningen omvänd jämfört med satsen x < 10, så den är inte exakt x < 10, utan jämförelsens riktning är vänd!
Detta beror på att det på maskinkodsnivå är mer intuitivt och sparar en instruktion att hoppa över förväg när villkoret inte stämmer, jämfört med att utföra jämförelsen en gång när villkoret stämmer och sedan kontrollera igen om det inte stämmer.
Eftersom denna optimering är mycket klassisk förekommer mönstret ofta även i kompilatorer med betydligt lägre optimeringsnivå än exemplet strconv.Atoi ovan, så det är bra att känna till.
Genom att tillämpa dessa principer kan man alltså få källkod som skiljer sig åt men ger identisk assemblerkod på 100 %.
Exempel på spegelkod
Med hjälp av skriptet nedan kan man, med ett Bash-skript, skapa två källkodsvarianter som är 100 % spegelvända och sedan verifiera att exakt samma assemblerkod erhålls för main, med undantag för metadata som varierar från gång till gång.
1#!/usr/bin/env bash
2
3# 1. Fullständig återställning av kvarvarande filer och 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 källkod för den ursprungliga versionen (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 källkod för spegelversionen (main_from_asm.go)
36# Strukturen av operatorerna synkroniseras helt symmetriskt så att kompilatorn använder samma optimeringsmall (JGE)
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 // Om man utgår från 10 och behåller strukturen x < 10 använder kompilatorn
56 // exakt samma JGE-mekanism och blockplacering som i main.go.
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Bygg båda källkoderna i samma katalog och med samma filnamn
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. Extrahera den rena assemblerfunktionen main.main med 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# Ta bort virtuella adresser, offset och text med maskinkodsbyte
81# och filtrera endast de rena instruktionsfälten (Opcode & Operands) som CPU:n kör
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. Verifiera strukturen hos de två maskinkodsinstruktionerna 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 du kör källkoden i praktiken kan du få följande information.
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------------------------------------------------------------
Slutsats
Programmeringsspråk erbjuder många abstraktioner, men bakom abstraktionerna döljer sig mycket intressanta och aggressiva optimeringar. Genom att utnyttja detta kunde vi även skapa spegelbildskod som har olika källkod men identisk assemblerkod. Om du är intresserad av lågnivåprogrammering och stöter på proprietär mjukvara skriven i Go, verkar det inte vara omöjligt att bryta ner assemblerkoden och analysera den för att återskapa källkoden.
Nästa lektion
I nästa lektion ska vi titta på select-case-satsen, som är ännu en rolig del, tillsammans med if-satsen.