Generarea codului de asamblare AMD64 pentru instrucțiunea condițională if în Go și analiza instrucțiunilor de salt

Instrucțiunea If în limbajul Go
Mai întâi, motivul pentru care am ales Go este că, dintre limbajele moderne, Go are cel mai „frumos" assembly, iar eficiența sintaxei sale este uneori chiar copleșitoare în comparație cu limbajele clasice.
Acum, după ce am înțeles în lecția anterioară modul de funcționare al unui program Go simplu, să comparăm direct Go cu Assembly, linie cu linie.
Codul sursă
În primul rând, nu doar Go, ci chiar și compilatoarele moderne, inclusiv GCC, optimizează automat ramificațiile care nu au niciun rost. GCC, Clang și alte compilatoare pentru limbajul C aplică optimizări foarte agresive la -O2, standardul din industrie, așa că perioada în care programatorii puteau avea încredere deplină în compilator s-a încheiat, practic, încă de la sfârșitul secolului XX.
Prin urmare, din punctul de vedere al compilatorului, trebuie să oferim condiții pe care acesta nu le poate prevedea și transforma cu ușurință în altă structură, pentru ca ramificația să aibă cu adevărat sens.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Dacă am înlocui aceasta cu ceva previzibil și care poate fi eliminat, precum x = 10,
10 // compilatorul ar optimiza și ar elimina ramificația.
11 // Prin urmare, pentru a vedea direct un astfel de caz în assembly, în limbajul C s-ar folosi -O0 sau
12 // compilatorul ar primi o valoare externă imprevizibilă,
13 // dar, deoarece această secțiune tratează programarea modernă,
14 // nu vom folosi metoda de dezactivare a optimizărilor binare din 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}
În acest caz, deoarece compilatorul nu poate prevedea intrarea, instrucțiunea de ramificație este tradusă în cod mașină exact așa cum este.
Limbaj de asamblare
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)
Unealta go tool ne arată, în mod util, ce instrucțiune assembly corespunde fiecărei structuri din cod, una câte una.
Ceea ce vom învăța acum sunt comparațiile și instrucțiunile de ramificație if, așa că trebuie să urmărim doar câteva linii.
Instrucțiunile CMPQ și JL
Instrucțiunea CMPQ este folosită pentru a compara tipuri de date de 4 octeți (4 cuvinte), iar numele provine de la CoMPareQuadword, prescurtat la CMPQ.
La adresa de memorie 0x47a84e găsim instrucțiunea CMPQ os.Args+8(SB), $0x2.
În acest caz, programul compară numărul de argumente primite cu 0x2 în hexazecimal (adică doar 2).
Apoi, dacă argumentele sunt mai puține decât 2 (adică programul are doar propriul nume ca argument), se efectuează un salt prin JL, adică se execută saltul după comparație. Aceasta este prescurtarea pentru Jump, Less than.
Dacă, pentru comparația anterioară, argumentele erau mai puține decât 2, se sare la adresa 0x47a8b0, unde se află JGE.
Totuși, în această secțiune se folosește registrul AX, deci trebuie să cunoaștem ce valoare este stocată în registru.
Instrucțiunea MOVQ
Urmează să vedem cum se folosește registrul CX pentru a stoca adresa de început a datelor și cum sunt extrase datele reale după citirea adresei.
Intervalul 0x47858-0x47863 arată că operația se execută pas cu pas.
Mai întâi, adresa de început a matricei de argumente este introdusă în registrul CX prin instrucțiunea MOVQ os.Args(SB), CX. Aici trebuie să înțelegem tipul string din Go.
Un string din Go este o structură, iar această structură este formată din 2 valori de 8 octeți, deci are 16 octeți în total.
| struct | 8 octeți | 8 octeți |
|---|---|---|
| string | adresa de memorie | lungimea șirului |
Din punct de vedere vizual, arată ca mai sus: primii 8 octeți conțin adresa de început a șirului, iar următorii 8 octeți conțin lungimea șirului.
Prin urmare, adresa șirului este stocată în registrul AX, iar lungimea șirului în registrul BX.
CALL
În postarea anterioară, când am privit funcțiile din pachetul runtime, apărea deja instrucțiunea CALL.
Aceasta precedă funcțiile folosite în Go și înseamnă literal că se apelează o funcție. Apoi, prin funcția apelată, șirul este convertit într-un număr întreg, dar unde este stocat acel număr nu se vede, deoarece este abstractizat în interiorul funcției.
Instrucțiunile CMPQ și JGE
Revenind la adresa de mai devreme 0x47a86c, instrucțiunea compară adresa șirului cu numărul 0xa (zecimal 10)!
Aceasta înseamnă că, deoarece programul nu mai folosește argumentul respectiv, s-a suprascris locația șirului cu variabila întreagă x, creându-se astfel spațiul pentru ea.
Aceasta este esența optimizării agresive realizate în limbaje precum Go.
Apoi apare instrucțiunea JGE, care este prescurtarea pentru Jump, Greater or Equals. Prin urmare, această instrucțiune verifică dacă valoarea este mai mare sau egală cu cea de comparație.
Astfel, nu avem exact structura x < 10, ci direcția comparației este inversată față de x < 10!
La nivel de cod mașină, acest lucru este mai intuitiv: când condiția nu este adevărată, se sare anticipat peste bloc, iar aceasta economisește o instrucțiune comparativ cu executarea comparației când condiția este adevărată și apoi verificarea din nou dacă aceasta nu este adevărată.
Această optimizare este foarte clasică și apare frecvent chiar și în compilatoarele cu optimizare mult mai slabă, comparativ cu exemplul strconv.Atoi de mai sus, așa că merită reținută.
Astfel, aplicând aceste principii, putem obține surse diferite care, la nivel de assembly, sunt 100% identice.
Exemplu de cod în oglindă
Folosind scriptul de mai jos, scriptul bash generează două surse în oglindă 100% și, eliminând metadatele care se schimbă de fiecare dată, se poate verifica faptul că, atunci când se analizează doar main, se obține exact același assembly.
1#!/usr/bin/env bash
2
3# 1. Ștergerea completă a fișierelor și directoarelor reziduale existente
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. Scrierea codului sursă al versiunii originale (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. Scrierea codului sursă al versiunii în oglindă (main_from_asm.go)
36# Sincronizarea simetrică completă a structurii operatorilor, astfel încât compilatorul să folosească direct același model de optimizare (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 // Dacă păstrăm structura x < 10 luând 10 drept referință, compilatorul
56 // va adopta exact același mecanism JGE și aceeași plasare a blocurilor ca în main.go.
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Compilarea ambelor surse în același director și sub același nume de fișier
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. Extragerea secțiunilor assembly ale main.main folosind 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# Eliminarea adreselor virtuale, a offset-urilor și a textului cu octeții mașinii,
81# filtrând doar câmpurile pure ale instrucțiunilor CPU (Opcode & 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. Verificarea integrității structurale a instrucțiunilor mașină prin 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 "------------------------------------------------------------"
Când rulați efectiv sursele, puteți obține informațiile de mai jos.
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------------------------------------------------------------
Concluzie
Limbajele de programare oferă multe abstractizări, dar în spatele acestora se ascund optimizări foarte interesante și agresive. De asemenea, valorificând această particularitate, am putut crea și cod în oglindă, cu surse diferite, dar cu assembly identic. Dacă vă interesează nivelul scăzut și întâlniți software proprietar scris în Go, nu este imposibil să dezasamblați assembly-ul și să analizați codul sursă pentru a-l reconstrui.
Lecția următoare
Data viitoare vom studia instrucțiunea If și o altă temă interesantă, instrucțiunea select-case.