If-sætninger i Go-sproget
If-sætninger i Go-sproget
Først og fremmest skyldes vores valg af Go, at Go har den mest "smukke" assembly blandt moderne sprog, og sammenlignet med klassiske sprog er effektiviteten af dens syntaks ofte overvældende.
Da vi nu i den forrige lektion har forstået, hvordan et simpelt Go-program fungerer, skal vi straks sammenligne Go og Assembly linje for linje.
Kildekode
Som det er tilfældet i Go, optimerer moderne compiler-løsninger, herunder GCC, automatisk forgreningssætninger (branching statements), som ikke tjener noget formål. Da C-sprog-compilere som GCC og Clang også udfører meget aggressive optimeringer ved industristandarden -O2, kan man sige, at en æra, hvor programmøren ikke længere fuldt ud kan stole på compileren, endegyldigt blev fuldendt i slutningen af det 20. århundrede.
Derfor giver det kun mening at give compileren betingelser, som den ikke let kan forudsige og omdanne til en anden syntaks.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Hvis man udfylder dette med noget forudsigeligt som x = 10,
10 // hvor forgreningen kan fjernes, vil compileren optimere og fjerne forgreningen.
11 // Derfor, for at betragte dette direkte i assembly, ville man i C-sprog
12 // anvende -O0 eller lignende, eller lade compileren bruge eksterne værdier,
13 // der er uforudsigelige. Da denne sektion omhandler moderne programmering,
14 // benytter vi ikke metoder til at deaktivere Go's binære optimering.
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 tilfælde oversættes forgreningssætningen direkte til maskinkode, da compileren ikke kan forudsige inputtet.
Assembly-sprog
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 at benytte go tool får man venligt oplyst, hvilken syntaks der matcher hvilken assembly-kode.
Da vi i denne lektion skal lære om sammenligningssætninger og if-forgreningssætninger, er der et par linjer, vi skal være særligt opmærksomme på.
CMPQ-instruktion & JL-instruktion
CMPQ-instruktionen er beregnet til at sammenligne 4-byte (4-word) datatyper, og dens etymologi stammer fra CoMPare Quadword, forkortet til CMPQ.
Hvis vi ser på hukommelsesadressen 0x47a84e, indeholder den syntaksen CMPQ os.Args+8(SB), $0x2.
I dette tilfælde sammenligner programmet antallet af modtagne argumenter med den hexadecimale værdi 0x2 (hvilket blot er 2).
Derefter udføres et hop via JL efter sammenligningen for at se, om argumenterne er færre end 2 (det vil sige, hvis programmet kun er sig selv som argument). Dette er en forkortelse for Jump, Less than.
Ved den forudgående sammenligningsoperation, hvis argumentet var mindre end 2, hopper den til adressen 0x47a8b0, hvor der findes en JGE.
Da denne syntaks dog benytter AX-registret, må vi forstå, hvad den værdi, der er gemt i registret, repræsenterer.
MOVQ-instruktion
Herefter skal vi forstå, hvordan den faktiske startadresse for data gemmes ved brug af 'CX'-registret, og hvordan data ekstraheres efter læsning af adressen.
Ved at betragte området 0x47858-0x47863 udføres denne operation trin for trin.
Først indsættes startadressen for argument-arrayet i CX-registret med instruktionen MOVQ os.Args(SB), CX. Her må man forstå Go's string-type.
Go's string er en struct, og denne struct består af 16 bytes, fordelt på to 8-byte datafelter.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Hvis vi visualiserer dette, svarer det til ovenstående, hvor de første 8 bytes gemmer stringens startadresse, og de efterfølgende 8 bytes gemmer stringens længde.
Derfor gemmes stringens adresse i AX-registret, og stringens længde gemmes i BX-registret.
CALL
I det forrige indlæg, da vi betragtede runtime-funktionerne, var der også en CALL-instruktion knyttet til dem.
Dette er knyttet til funktioner, der anvendes i Go, og betyder bogstaveligt talt at kalde (call) en given funktion. Derefter konverteres stringen til et heltal ved hjælp af CALL-funktionen, men hvor heltallet gemmes, fremgår ikke direkte i abstraktionen af funktionen.
CMPQ-instruktion & JGE-instruktion
Hvis vi vender tilbage til den tidligere adresse 0x47a86c, sammenligner instruktionen adressen på stringen med tallet 0xa (10 i decimaltal)!
Dette betyder, at da programmet ikke længere benytter det pågældende argument, er pladsen for stringen blevet overskrevet for at skabe plads til heltallet x.
Dette er kernen i den aggressive optimering, der foregår i sprog som Go.
Derefter optræder instruktionen JGE, som er en forkortelse for Jump, Greater or Equals. Derfor spørger denne syntaks, om værdien er større end eller lig med sammenligningsgrundlaget.
Det betyder, at syntaksen x < 10 ikke er direkte oversat, men at sammenligningsretningen for syntaksen er vendt om!
Dette skyldes, at det i maskinkode er mere intuitivt at springe over på forhånd, når betingelsen ikke er opfyldt, frem for at udføre sammenligningen én gang, når betingelsen er opfyldt, for derefter at tjekke igen, om den ikke er opfyldt; det sparer desuden 1 instruktion.
Da denne form for optimering er meget klassisk, er det et mønster, der ofte optræder selv i compilere med en betydeligt lavere grad af optimering end i eksemplet med strconv.Atoi, så det er værd at kende til.
Ved at anvende dette punkt kan man opnå kildekode, der er 100 % identisk på assembly-niveau, selvom kilden er forskellig.
Eksempel på spejlbillede-kode
Ved at benytte nedenstående script kan man verificere, at bash-scriptet skaber to kilder, der er 100 % spejlbilleder af hinanden, og at de producerer nøjagtig samme assembly, når man ser udelukkende på main, bortset fra metadata, der ændrer sig fra gang til gang.
1#!/usr/bin/env bash
2
3# 1. Fuldstændig initialisering af eksisterende resterende filer og mapper
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. Skrivning af kildekode til original version (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. Skrivning af kildekode til spejlbillede-version (main_from_asm.go)
36# For at sikre at compileren bruger optimeringsskabelonen (JGE) direkte, synkroniseres operatorstrukturen perfekt symmetrisk
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 // Ved at tage udgangspunkt i 10 og bevare x < 10 strukturen,
56 // anvender compileren den præcis samme JGE-mekanisme og blok-placering som i main.go.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Udførelse af build i samme mappesti og filnavn-miljø
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. Udtrækning af ren main.main assembly-funktion ved brug af 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# Fjernelse af virtuel adresse, offset og maskinkode-byte-data,
80# og filtrering af kun de rene instruktionssæt (Opcode & Operands), som CPU'en skal eksekvere
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. Verificering af diff for de to maskinkode-instruktionsstrukturer
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89 echo "===> [Succes] main.main maskinkodelogikken for de to binære filer er 100 % identisk! <==="
90 echo "Vi har perfekt synkroniseret compilerens optimerings-pipeline-retningslinjer for at opnå den samme assembly."
91else
92 echo "===> [Fejl] Der blev fundet forskelle i assembly-instruktionsstrukturen. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
Ved faktisk at køre koden kan man opnå følgende 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===> [Succes] main.main maskinkodelogikken for de to binære filer er 100 % identisk! <===
9Vi har perfekt synkroniseret compilerens optimerings-pipeline-retningslinjer for at opnå den samme assembly.
10------------------------------------------------------------
Konklusion
Selvom programmeringssprog tilbyder mange abstraktioner, har vi set, at der bag abstraktionen gemmer sig meget interessante og aggressive optimeringer. Ved at udnytte dette kunne vi desuden skabe spejlbilled-kode, der har forskellig kildetekst, men identisk assembly. Hvis man har interesse for lav-niveau programmering og støder på proprietær software skrevet i Go, burde det ikke være umuligt at analysere ved at dekompilere assembly-koden for at rekonstruere kilden.
Næste lektion
I næste lektion vil vi undersøge select-case-sætningen, som har sin egen unikke charme i forhold til if-sætningen.