GoSuda

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

By Lee Yunjin views ...

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.

struct8 octeți8 octeți
stringadresa de memorielungimea ș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.