Generowanie kodu assemblera AMD64 dla instrukcji warunkowej (if) w języku Go oraz analiza instrukcji skoku

Instrukcja If w języku Go
Najpierw powód wyboru Go: spośród nowoczesnych języków Go ma najbardziej „piękny” kod assembly, a efektywność jego składni bywa wręcz przytłaczająca nawet w porównaniu z językami klasycznymi.
Skoro na poprzednim wykładzie zrozumieliśmy, jak działa prosty program w Go, porównajmy teraz Go i Assembly linia po linii.
Kod źródłowy
Przede wszystkim, nie tylko w Go, ale także w nowoczesnych kompilatorach, w tym GCC, gałęzie, które nie mają znaczenia dla wyniku, są automatycznie optymalizowane. Kompilatory języka C, takie jak GCC i Clang, stosują bardzo agresywne optymalizacje już na poziomie -O2, będącym standardem branżowym, więc era, w której programista nie mógł w pełni ufać kompilatorowi, ostatecznie dobiegła końca już w drugiej połowie XX wieku.
Dlatego, z punktu widzenia kompilatora, warunki, które trudno przewidzieć i zastąpić inną konstrukcją, muszą być sformułowane tak, aby miały sens.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Jeśli zastąpimy to czymś przewidywalnym, co można usunąć z gałęzi, np. x = 10,
10 // kompilator zoptymalizuje kod i usunie gałąź.
11 // Dlatego, aby zobaczyć to bezpośrednio w assembly, w C używa się np. -O0,
12 // albo pozwala kompilatorowi użyć nieprzewidywalnej zewnętrznej wartości,
13 // ale ponieważ ten rozdział dotyczy nowoczesnego programowania, nie stosujemy
14 // metody wyłączania optymalizacji binarnej w Go.
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}
W tym przypadku, ponieważ kompilator nie może przewidzieć danych wejściowych, instrukcja rozgałęzienia zostaje przetłumaczona na kod maszynowy bez zmian.
Język asemblera
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)
Narzędzie go tool wskazuje bardzo przystępnie, która konstrukcja języka odpowiada któremu fragmentowi assembly.
Ponieważ w tym wykładzie uczymy się porównań i instrukcji warunkowych if, wystarczy zwrócić uwagę na kilka linii.
Instrukcje CMPQ i JL
Instrukcja CMPQ służy do porównywania typów danych o rozmiarze 4 bajtów (4 słowa), a jej nazwa pochodzi od CoMPareQuadword, skróconego do CMPQ.
Pod adresem pamięci 0x47a84e znajduje się instrukcja CMPQ os.Args+8(SB), $0x2.
W tym przypadku program porównuje liczbę przekazanych argumentów z szesnastkową wartością 0x2 (czyli po prostu 2).
Następnie, jeśli argumentów jest mniej niż 2 (czyli program sam jest jedynym argumentem), za pomocą JL wykonywany jest skok po porównaniu. Oznacza to Jump, Less than', skrócone do JL.
Jeśli w poprzednim porównaniu argumentów było mniej niż 2, skok prowadzi pod adres 0x47a8b0, gdzie znajduje się JGE.
Jednak ta instrukcja operuje na rejestrze AX, więc trzeba poznać, co dokładnie jest przechowywane w tym rejestrze.
Instrukcja MOVQ
Następnie trzeba zrozumieć, jak rejestr CX jest wykorzystywany do przechowywania adresu początkowego danych oraz jak po odczytaniu adresu wydobywane są rzeczywiste dane.
Zakres 0x47858-0x47863 pokazuje krok po kroku wykonanie tej operacji.
Najpierw adres początkowy tablicy argumentów jest wstawiany do rejestru CX za pomocą instrukcji MOVQ os.Args(SB), CX. Wtedy trzeba zrozumieć typ string w Go.
String w Go jest strukturą złożoną z dwóch 8-bajtowych wartości, czyli z 16 bajtów.
| struktura | 8 bajtów | 8 bajtów |
|---|---|---|
| string | adres pamięci | długość stringa |
Wizualnie wygląda to jak powyżej: pierwsze 8 bajtów zawiera adres początkowy stringa, a kolejne 8 bajtów zawiera jego długość.
Dlatego adres stringa zapisywany jest w rejestrze AX, a jego długość w rejestrze BX.
CALL
We wcześniejszym poście, gdy oglądaliśmy funkcje runtime, także pojawiała się instrukcja CALL.
Występuje ona przed funkcjami używanymi w Go i oznacza, że funkcja jest wywoływana, dosłownie CALL. Następnie, za pomocą wywołania funkcji, string jest konwertowany na liczbę całkowitą, przy czym to, gdzie zapisywana jest ta liczba, nie jest widoczne, ponieważ zostało ukryte w funkcji.
Instrukcje CMPQ i JGE
Wracając do wcześniejszego adresu 0x47a86c, instrukcja porównuje adres stringa z liczbą 0xa (dziesiętnie 10)!
Oznacza to, że ponieważ program nie używa już tego argumentu, nadpisano nim miejsce stringa, tworząc w ten sposób przestrzeń dla zmiennej całkowitej x.
To właśnie jest istota agresywnych optymalizacji, jakie stosuje się m.in. w języku Go.
Następnie pojawia się instrukcja JGE, będąca skrótem od Jump, Greater or Equals. Ta instrukcja sprawdza więc, czy wartość jest większa lub równa wartości porównywanej.
Zatem zamiast dokładnie x < 10 mamy odwrócony kierunek porównania!
W kodzie maszynowym opłaca się bowiem z góry pominąć blok, gdy warunek nie jest spełniony, zamiast wykonywać porównanie w przypadku spełnienia warunku, a następnie ponownie sprawdzać, czy warunek nie jest spełniony. Rozwiązanie to jest bardziej bezpośrednie i pozwala zaoszczędzić jedną instrukcję.
Ta optymalizacja jest bardzo klasyczna, dlatego w przeciwieństwie do przykładu strconv.Atoi omówionego wyżej często pojawia się nawet w kompilatorach o znacznie niższym poziomie optymalizacji, więc warto ją znać.
Dzięki zastosowaniu tej zasady można uzyskać źródła różniące się od siebie, a mimo to dające na poziomie assembly w 100% identyczny wynik.
Przykład kodu lustrzanego
Korzystając z poniższego skryptu, skrypt bash tworzy dwa w 100% lustrzane źródła, a następnie weryfikuje, że po pominięciu metadanych, które się zmieniają, sama funkcja main daje dokładnie identyczny assembly.
1#!/usr/bin/env bash
2
3# 1. Całkowite czyszczenie pozostałych plików i katalogów z poprzednich uruchomień
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. Zapis oryginalnego kodu źródłowego (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. Zapis kodu lustrzanego (main_from_asm.go)
36# Struktura operatorów jest symetrycznie dopasowana, aby kompilator użył tego samego szablonu optymalizacji (JGE)
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 // Jeśli zachowamy strukturę x < 10, używając 10 jako punktu odniesienia, kompilator
56 // przyjmie dokładnie ten sam mechanizm JGE i ten sam układ bloków co w main.go.
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Kompilacja obu źródeł w tym samym katalogu i pod tą samą nazwą pliku
67echo "[4/6] Compiling both sources inside 'test_dir'..."
68cp main.go test_dir/main.go
69cd test_dir && go build -o ../main_orig main.go && cd ..
70rm test_dir/main.go
71
72cp main_from_asm.go test_dir/main.go
73cd test_dir && go build -o ../main_asm main.go && cd ..
74
75# 5. Wyodrębnienie czystego assembly funkcji main.main za pomocą go tool objdump
76echo "[5/6] Extracting main.main assembly sections..."
77go tool objdump -s "main\.main" main_orig > orig.asm
78go tool objdump -s "main\.main" main_asm > asm.asm
79
80# Usunięcie adresów wirtualnych, przesunięć i tekstu bajtów maszynowych,
81# filtrowanie wyłącznie pól właściwych instrukcjom procesora (Opcode i Operands)
82awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
83awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
84
85# 6. Weryfikacja integralności struktury instrukcji za pomocą diff
86echo "[6/6] Verifying assembly structural integrity via diff..."
87echo "------------------------------------------------------------"
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 "------------------------------------------------------------"
Po uruchomieniu skryptu otrzymujemy następujące informacje.
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------------------------------------------------------------
Wnioski
Języki programowania oferują wiele abstrakcji, ale za nimi kryją się bardzo ciekawe i agresywne optymalizacje. Co więcej, wykorzystując tę zależność w odwrotny sposób, można stworzyć lustrzane kody, które mają różne źródła, ale identyczny assembly. Jeśli interesujesz się niskim poziomem i natrafisz na oprogramowanie własnościowe napisane w Go, dekompilacja assembly i odtworzenie źródła nie wydaje się czymś niemożliwym.
Następny wykład
Na następnym wykładzie omówimy instrukcję warunkową If oraz równie ciekawą instrukcję select-case.