GoSuda

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

By Lee Yunjin views ...

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.

struct8 byte8 byte
stringmem addressstring 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.