Analys av AMD64-assemblerkodgenerering och grenningsinstruktioner för Go-villkorssatsen (if)

If-satser i Go-språket
Först och främst är anledningen till att vi valde Go att Go, bland moderna språk, har den mest "vackra" assemblerkoden, och jämfört med klassiska språk är dess syntaktiska effektivitet snarare överväldigande i vissa fall.
Nu när vi i föregående lektion har förstått hur ett enkelt Go-program fungerar, låt oss direkt jämföra Go och Assembly rad för rad.
Källkod
Först och främst, inte bara i Go utan även i moderna kompilatorer, inklusive GCC, optimeras grenar som saknar betydelse automatiskt. GCC och Clang, som är C-kompilatorer, utför mycket aggressiva optimeringar vid -O2, som är branschstandarden, så tiden då programmerare knappast kunde lita helt på kompilatorn fullbordades i själva verket redan under senare delen av 1900-talet.
Därför måste villkoret vara sådant att kompilatorn inte lätt kan förutse det och ersätta det med en annan syntax, för att det ens ska ha någon 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 som x = 10, som kan tas bort utan att påverka grenen,
10 // kommer kompilatorn att optimera bort grenen.
11 // Därför, för att själv se detta i assembler, använder man i C till exempel -O0,
12 // eller låter programmet använda ett externt värde som kompilatorn inte kan förutse.
13 // Eftersom detta avsnitt handlar om modern programmering använder vi inte
14 // metoden att stänga av Gos binäroptimering.
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 detta fall kan kompilatorn inte förutse indata, så grenen översätts som den är till maskinkod.
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)
Med hjälp av go tool visar verktyget vänligt nog vilken assemblerkod som motsvarar varje syntaxkonstruktion, en för en.
Eftersom det vi ska lära oss nu är jämförelser och if-grenar behöver vi bara uppmärksamma några rader.
CMPQ-instruktionen och JL-instruktionen
Instruktionen CMPQ är till för att jämföra värden av 4 byte (4 ord), och dess etymologi är CoMPareQuadword, förkortat till CMPQ.
Om vi tittar på minnesadressen 0x47a84e finns satsen CMPQ os.Args+8(SB), $0x2.
I detta fall jämför programmet antalet argument som tagits emot med det hexadecimala talet 0x2 (alltså just 2).
Därefter utför JL ett villkorligt hopp beroende på om antalet argument är mindre än 2 (det vill säga om programmet endast har sitt eget namn som argument). JL är en förkortning av Jump, Less than.
Om antalet argument var mindre än 2 hoppar programmet till adressen 0x47a8b0, där det finns en JGE. Men i denna sats används registret AX, så vi måste förstå vilket värde som lagrats i registret.
MOVQ-instruktionen
Därefter måste vi, med hjälp av registret CX, förstå hur startadressen för datat lagras, och hur den faktiska datan extraheras efter att adressen lästs.
Intervallet 0x47858-0x47863 visar hur denna operation utförs steg för steg.
Först placeras startadressen för argumentarrayen i registret CX med instruktionen MOVQ os.Args(SB), CX. Här behöver vi förstå Gos strängtyp.
En string i Go är en struktur, och denna struktur består av två 8-byte-värden, totalt 16 byte.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Visuellt ser det ut så som ovan: de första 8 byten innehåller strängens startadress, och de sista 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 tidigare inlägg, när vi tittade på funktioner i runtime, fanns instruktionen CALL framför dem.
Den används före funktioner i Go och betyder bokstavligen att en viss funktion anropas, därav namnet CALL. Därefter används en CALL-funktion för att konvertera strängen till ett heltal, och var detta heltal lagras syns inte, eftersom det är abstraherat bort i funktionen.
CMPQ-instruktionen och JGE-instruktionen
Tillbaka till adressen 0x47a86c ser vi att instruktionen jämför strängens adress med det numeriska värdet 0xa (decimalt 10)!
Detta innebär att programmet inte längre använder argumentet, så det har skrivit över strängens position för att skapa utrymme för den heltalsvariabel x.
Detta är den konkreta formen av den aggressiva optimering som sker i språk som Go.
Därefter dyker instruktionen JGE upp, en förkortning av Jump, Greater or Equals. Denna sats frågar alltså om värdet är större än eller lika med jämförelsevärdet.
Således är riktningen i jämförelsen omvänd jämfört med satsen x < 10, trots att den ursprungliga satsen är x < 10!
Detta beror på att det i maskinkod är mer intuitivt att hoppa över direkt när villkoret inte uppfylls, och det sparar en instruktion jämfört med att utföra jämförelsen en gång när villkoret uppfylls och sedan kontrollera igen om det inte stämmer.
Denna optimering är mycket klassisk, och till skillnad från exemplet strconv.Atoi ovan förekommer mönstret ofta även i kompilatorer med betydligt lägre optimeringsnivå, så det är bra att känna till.
Genom att tillämpa denna princip kan vi därför få källkod som skiljer sig åt men ger assemblerkod som är 100 % identisk.
Exempel på spegelkod
Med hjälp av skriptet nedan kan ett bash-skript skapa två källkodsfiler som är 100 % speglade, och sedan verifiera att man, när man bara betraktar main och bortser från metadata som varierar från gång till gång, får exakt identisk assemblerkod.
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ällkoden 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ällkoden för spegelversionen (main_from_asm.go)
36# Synkronisera operatorstrukturen perfekt symmetrisk så att kompilatorn använder mallen för optimering (JGE) som den är
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 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Bygg båda källkoderna i samma katalog och med samma filnamn
66echo "[4/6] Compiling both sources inside 'test_dir'..."
67cp main.go test_dir/main.go
68cd test_dir && go build -o ../main_orig main.go && cd ..
69
70rm test_dir/main.go
71cp main_from_asm.go test_dir/main.go
72cd test_dir && go build -o ../main_asm main.go && cd ..
73
74# 5. Extrahera den rena assemblerkoden för funktionen main.main med go tool objdump
75echo "[5/6] Extracting main.main assembly sections..."
76go tool objdump -s "main\.main" main_orig > orig.asm
77go tool objdump -s "main\.main" main_asm > asm.asm
78
79# Ta bort virtuella adresser, offset och textdata för maskinkodsbyte
80# och filtrera endast de instruktioner som processorn kör (Opcode & Operands)
81awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
82awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
83
84# 6. Verifiera strukturen hos maskinkodsinstruktionerna med diff
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
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 faktiskt kör skriptet får man 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 dessa abstraktioner döljer sig mycket intressanta och aggressiva optimeringar. Genom att utnyttja detta omvänt kunde vi också skapa spegelkod som skiljer sig i källkod men ger identisk assemblerkod. Om man är intresserad av lågnivåprogrammering och stöter på proprietär programvara skriven i Go, verkar det inte vara omöjligt att分解 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 ett annat intressant ämne vid sidan av if-satsen.