GoSuda

If-Anweisungen in der Go-Sprache

By Lee Yunjin
views ...

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.

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