If-Anweisungen in der Go-Sprache
If-Anweisungen in der Programmiersprache Go
Zunächst haben wir uns für Go entschieden, da Go unter den modernen Sprachen über den „schönsten“ Assemblercode verfügt und die Effizienz seiner Syntax im Vergleich zu klassischen Sprachen oft überwältigend ist.
Nachdem wir im vorherigen Kapitel die Funktionsweise eines einfachen Go-Programms verstanden haben, werden wir nun Go und Assembler zeilenweise miteinander vergleichen.
Quellcode
Zunächst ist anzumerken, dass moderne Compiler, einschließlich GCC, wie auch Go, Verzweigungen, die keine funktionale Bedeutung haben, automatisch optimieren. Da C-Compiler wie GCC und Clang selbst beim Industriestandard -O2 sehr aggressive Optimierungen vornehmen, ist das Zeitalter, in dem ein Programmierer dem Compiler nicht mehr blind vertrauen kann, seit dem späten 20. Jahrhundert endgültig angebrochen.
Daher ist es nur sinnvoll, dem Compiler Bedingungen vorzugeben, bei denen eine Vorhersage und eine daraus resultierende Umwandlung in andere Konstrukte für ihn schwierig sind.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Wenn man dies mit einem vorhersehbaren Wert wie x = 10 ersetzt,
10 // bei dem die Verzweigung entfernt werden kann,
11 // optimiert der Compiler die Verzweigung weg.
12 // Um dies direkt in Assembler zu betrachten, müsste man in C
13 // beispielsweise -O0 verwenden oder den Compiler dazu zwingen,
14 // unvorhersehbare externe Werte zu verwenden.
15 // Da dieser Abschnitt moderne Programmierung behandelt,
16 // werden wir die Optimierung der Go-Binärdateien nicht deaktivieren.
17 if len(os.Args) < 2 {
18 return
19 }
20 x, _ := strconv.Atoi(os.Args[1])
21
22 if x < 10 {
23 println("X is smaller than 10")
24 } else {
25 println("X is larger or same as 10")
26 }
27}
In diesem Fall wird die Verzweigung eins zu eins in Maschinencode übersetzt, da der Compiler die Eingabe nicht vorhersagen kann.
Assemblersprache
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)
Mithilfe des go tool lässt sich freundlicherweise genau nachvollziehen, welche Anweisung welchem Assemblercode entspricht.
Da wir uns in dieser Lektion mit Vergleichsoperatoren und If-Verzweigungen beschäftigen, sollten wir einige Zeilen besonders beachten.
CMPQ-Befehl & JL-Befehl
Der CMPQ-Befehl dient dem Vergleich von 4-Byte-Datentypen (Quadwords); der Name leitet sich von CoMPareQuadword ab und wurde zu CMPQ verkürzt.
Betrachtet man die Speicheradresse 0x47a84e, so findet man dort den Befehl CMPQ os.Args+8(SB), $0x2.
In diesem Fall vergleicht das Programm die Anzahl der empfangenen Argumente mit dem Hexadezimalwert 0x2 (also schlicht der Zahl 2).
Anschließend wird mittels JL geprüft, ob die Anzahl der Argumente kleiner als 2 ist (d. h., ob das Programm nur mit sich selbst als Argument aufgerufen wurde), und ein Sprung ausgeführt. Dies steht für Jump, Less than.
Wenn das Argument bei der vorangegangenen Vergleichsoperation kleiner als 2 war, springt das Programm zur Adresse 0x47a8b0, an der sich ein JGE befindet.
Da in dieser Anweisung jedoch das AX-Register verwendet wird, müssen wir den Ursprung des dort gespeicherten Wertes identifizieren.
MOVQ-Befehl
Im nächsten Schritt müssen wir verstehen, wie das CX-Register genutzt wird, um die Anfangsadresse der Daten zu speichern, und wie die tatsächlichen Daten nach dem Lesen der Adresse extrahiert werden.
Der Bereich von 0x47858 bis 0x47863 führt diese Operation schrittweise aus.
Zuerst wird die Anfangsadresse des Argument-Arrays mit dem Befehl MOVQ os.Args(SB), CX in das CX-Register geladen. An dieser Stelle muss der String-Typ von Go verstanden werden.
Ein string in Go ist eine Struktur, die aus zwei 8-Byte-Datenfeldern besteht und somit 16 Byte groß ist.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Visuell dargestellt sieht dies wie oben aus; die ersten 8 Byte speichern die Startadresse des Strings, die hinteren 8 Byte die Länge des Strings.
Dementsprechend wird die Adresse des Strings im AX-Register und die Länge des Strings im BX-Register gespeichert.
CALL
Wie wir bereits in früheren Beiträgen bei den Funktionen des runtime-Pakets gesehen haben, taucht dort der Befehl CALL auf.
Dieser steht vor den in Go verwendeten Funktionen und bedeutet wörtlich, dass eine Funktion aufgerufen wird. Anschließend wird die CALL-Funktion verwendet, um den String in eine Ganzzahl umzuwandeln; dabei ist innerhalb der Funktion nicht unmittelbar ersichtlich, wo die Ganzzahl gespeichert wird.
CMPQ-Befehl & JGE-Befehl
Wenn wir zur Adresse 0x47a86c zurückkehren, sehen wir, dass der Befehl die Adresse des Strings mit der Zahl 0xa (dezimal 10) vergleicht!
Dies bedeutet, dass das Programm das Argument nicht mehr benötigt und daher den Speicherplatz des Strings überschrieben hat, um die Ganzzahlvariable x zu erzeugen.
Dies ist die Realität aggressiver Optimierungen, wie sie in Sprachen wie Go durchgeführt werden.
Danach folgt der Befehl JGE, eine Abkürzung für Jump, Greater or Equals. Dieser Befehl prüft also, ob der Wert größer oder gleich dem Vergleichsobjekt ist.
Folglich ist die Anweisung nicht identisch mit x < 10, sondern die Vergleichsrichtung des Ausdrucks ist umgekehrt!
Dies liegt daran, dass es in der Maschinensprache intuitiver ist, bei Nichtübereinstimmung der Bedingung vorzeitig zu springen, anstatt den Vergleich für den Fall, dass die Bedingung erfüllt ist, einmal durchzuführen und danach erneut zu prüfen, ob sie nicht doch nicht erfüllt ist. Dies spart zudem einen Befehl ein.
Da diese Optimierung sehr klassisch ist, tritt sie im Gegensatz zum obigen strconv.Atoi-Beispiel häufig auch bei Compilern mit deutlich geringerem Optimierungsgrad auf; es ist daher nützlich, dieses Muster zu kennen.
Durch die Anwendung dieser Erkenntnisse kann man Quellcodes erzeugen, die zwar unterschiedlich aussehen, aber auf Assemblerebene zu 100 % identisch sind.
Beispiel für spiegelbildlichen Code
Mit dem folgenden Skript kann verifiziert werden, dass zwei spiegelbildliche Quellcodes nach dem Entfernen der variablen Metadaten beim Vergleich der main-Funktionen exakt den gleichen Assemblercode erzeugen.
1#!/usr/bin/env bash
2
3# 1. Vollständige Initialisierung bestehender Dateien und Verzeichnisse
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. Erstellung des Quellcodes der Originalversion (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. Erstellung des spiegelbildlichen Quellcodes (main_from_asm.go)
36# Synchronisierung der Operatorenstruktur, damit der Compiler die Optimierungsvorlage (JGE) exakt übernimmt
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 // Durch die Beibehaltung der x < 10 Struktur mit 10 als Referenzwert
56 // übernimmt der Compiler denselben JGE-Mechanismus und dieselbe Blockanordnung wie in main.go.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Kompilierung in identischer Verzeichnisumgebung und Dateinamen
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. Extraktion der reinen main.main Assembler-Funktion mittels 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# Entfernung von virtuellen Adressen, Offsets und Maschinencode-Byte-Daten,
80# um nur den reinen Befehlssatz (Opcode & Operands) für die CPU zu filtern
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. Überprüfung der Struktur der beiden Maschinencode-Befehle mittels 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 "===> [Erfolg] Die Maschinencode-Logik von main.main beider Binärdateien stimmt zu 100 % überein! <==="
90 echo "Die Optimierungs-Pipeline des Compilers wurde perfekt synchronisiert, um denselben Assemblercode zu erhalten."
91else
92 echo "===> [Fehler] Es wurden Unterschiede in der Struktur der Assemblerbefehle gefunden. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
Führt man den Quellcode tatsächlich aus, erhält man folgende Informationen:
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===> [Erfolg] Die Maschinencode-Logik von main.main beider Binärdateien stimmt zu 100 % überein! <===
9Die Optimierungs-Pipeline des Compilers wurde perfekt synchronisiert, um denselben Assemblercode zu erhalten.
10------------------------------------------------------------
Fazit
Programmiersprachen bieten viele Abstraktionen, doch hinter diesen Abstraktionen verbergen sich sehr interessante und aggressive Optimierungen. Zudem konnten wir durch die Ausnutzung dieser Eigenschaften spiegelbildlichen Code erzeugen, der zwar unterschiedliche Quelltexte aufweist, aber zu identischem Assemblercode führt. Sollten Sie Interesse an Low-Level-Details haben und auf proprietäre Software in Go stoßen, scheint es keineswegs unmöglich, den Assemblercode manuell zu dekompilieren und den Quellcode zu rekonstruieren.
Nächste Lektion
In der nächsten Lektion werden wir uns mit der select-case-Anweisung befassen, die neben der If-Anweisung weitere interessante Aspekte bietet.