GoSuda

L'istruzione If nel linguaggio Go

By Lee Yunjin
views ...

Istruzioni If nel linguaggio Go

In primo luogo, abbiamo scelto Go perché, tra i linguaggi moderni, presenta un assembly tra i più "eleganti" e, anche se confrontato con linguaggi classici, l'efficienza della sua sintassi risulta talvolta schiacciante.

Ora che abbiamo compreso il funzionamento di un semplice programma in Go nella lezione precedente, passiamo immediatamente a confrontare Go e l'Assembly riga per riga.

Codice sorgente

Innanzitutto, come avviene in Go, persino i compilatori moderni, incluso GCC, ottimizzano automaticamente le istruzioni di salto (branch) che non hanno motivo di esistere. I compilatori C come GCC e Clang applicano ottimizzazioni molto aggressive allo standard di settore -O2; pertanto, l'era in cui il programmatore non può fidarsi ciecamente del compilatore è giunta a compimento già dalla fine del XX secolo.

Di conseguenza, è necessario fornire condizioni che il compilatore non possa facilmente prevedere e trasformare in altre istruzioni affinché il codice mantenga una sua valenza.

 1package main
 2
 3import (
 4    "os"
 5    "strconv"
 6)
 7
 8func main() {
 9    // Se si utilizzano valori prevedibili come x = 10, che permettono di eliminare il salto,
10    // il compilatore ottimizzerà il codice rimuovendo la diramazione.
11    // Pertanto, per osservare direttamente tali strutture in Assembly,
12    // nel linguaggio C si utilizzerebbe -O0, oppure si farebbe uso di valori esterni imprevedibili.
13    // Poiché questa sezione tratta la programmazione moderna, non disabiliteremo
14    // l'ottimizzazione dei binari di 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}

In questo caso, poiché il compilatore non può prevedere l'input, l'istruzione di salto viene tradotta fedelmente in linguaggio macchina.

Linguaggio Assembly

 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)

Utilizzando il go tool, è possibile vedere in modo trasparente quale istruzione corrisponde a quale codice Assembly.

Poiché in questa lezione ci concentriamo sulle istruzioni di confronto e sulle diramazioni if, presteremo attenzione solo ad alcune righe.

Istruzione CMPQ & Istruzione JL

L'istruzione CMPQ serve a confrontare tipi di dati da 4 byte (4 word); il termine deriva da CoMPare Quadword, abbreviato appunto in CMPQ.

Osservando l'indirizzo di memoria 0x47a84e, si nota l'istruzione CMPQ os.Args+8(SB), $0x2. In questo caso, il programma confronta il numero di argomenti ricevuti in input con l'esadecimale 0x2 (ovvero 2).

Successivamente, tramite JL, viene eseguito un salto se il numero di argomenti è inferiore a 2 (ovvero se il programma riceve solo se stesso come argomento). Il nome deriva da Jump, Less than. Se, rispetto all'operazione di confronto precedente, l'argomento era minore di 2, si salta all'indirizzo 0x47a8b0, dove è presente una JGE. Tuttavia, poiché in questa istruzione viene utilizzato il registro AX, è necessario comprendere la natura del valore memorizzato.

Istruzione MOVQ

In seguito, utilizzando il registro 'CX', viene memorizzato l'indirizzo iniziale del dato, ed è necessario capire come vengano estratti i dati effettivi dopo la lettura dell'indirizzo.

Osservando l'intervallo 0x47858-0x47863, si nota che l'operazione viene eseguita per gradi.

Innanzitutto, l'indirizzo di inizio dell'array di argomenti viene inserito nel registro CX tramite l'istruzione MOVQ os.Args(SB), CX. A questo punto è necessario comprendere il tipo stringa in Go.

La string in Go è una struttura (struct) composta da 16 byte, suddivisi in due dati da 8 byte ciascuno.

struct8 byte8 byte
stringmem addressstring length

Rappresentandolo visivamente come sopra, i primi 8 byte contengono l'indirizzo iniziale della stringa, mentre gli ultimi 8 byte contengono la lunghezza della stringa.

Di conseguenza, l'indirizzo della stringa viene salvato nel registro AX e la lunghezza della stringa nel registro BX.

CALL

Anche nei post precedenti, osservando le funzioni di tipo runtime, era presente l'istruzione CALL. Questa precede le funzioni utilizzate in Go e, come suggerisce il nome, significa chiamare una determinata funzione. Successivamente, la funzione CALL viene utilizzata per convertire la stringa in un intero, ma dove venga salvato l'intero non appare chiaramente nell'astrazione della funzione.

Istruzione CMPQ & Istruzione JGE

Tornando all'indirizzo 0x47a86c, l'istruzione sta confrontando l'indirizzo della stringa con il numero 0xa (10 in decimale)!

Ciò significa che, poiché l'argomento non viene più utilizzato all'interno del programma, la posizione della stringa è stata sovrascritta per creare lo spazio per la variabile intera x.

Questa è l'essenza dell'ottimizzazione aggressiva praticata nel linguaggio Go.

Successivamente, appare l'istruzione JGE, ovvero Jump, Greater or Equals. Pertanto, questa istruzione verifica se il valore è maggiore o uguale rispetto al termine di confronto.

Dunque, non viene eseguita la struttura x < 10 letterale, ma la direzione del confronto è invertita rispetto a x < 10! Questo accade perché, in linguaggio macchina, saltare preventivamente quando la condizione non è soddisfatta è più intuitivo e permette di risparmiare un'istruzione rispetto all'eseguire il confronto quando la condizione è soddisfatta e verificare nuovamente se non lo è.

Poiché tale ottimizzazione è molto classica, a differenza dell'esempio strconv.Atoi visto sopra, è un pattern che appare frequentemente anche in compilatori con livelli di ottimizzazione piuttosto bassi, ed è bene conoscerlo.

Pertanto, applicando questo principio, è possibile ottenere un codice che, pur essendo diverso a livello sorgente, risulta identico al 100% a livello di assembly.

Esempio di codice speculare

Utilizzando lo script sottostante, è possibile verificare che lo script Bash crei due sorgenti speculari al 100% che, una volta analizzati (escludendo i metadati che variano di volta in volta), producono un assembly esattamente identico quando si esamina solo la funzione main.

 1#!/usr/bin/env bash
 2
 3# 1. Inizializzazione completa dei file e delle directory residue
 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. Scrittura del codice sorgente 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. Scrittura del codice sorgente versione speculare (main_from_asm.go)
36# Sincronizzazione simmetrica perfetta della struttura degli operatori affinché il compilatore utilizzi il template di ottimizzazione (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        // Mantenendo la struttura x < 10 basata su 10, il compilatore 
56        // adotterà esattamente lo stesso meccanismo JGE e la stessa disposizione dei blocchi di main.go.
57        if x < 10 {
58                println(s1)
59        } else {
60                println(s2)
61        }
62}
63EOF
64
65# 4. Compilazione in un ambiente con percorso di directory e nome file identici
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. Estrazione della funzione assembly pura main.main utilizzando 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# Rimozione degli indirizzi virtuali, offset e dati binari in formato testo, 
80# filtrando solo il set di istruzioni puro (Opcode & Operands) che la CPU eseguirà
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. Verifica diff della struttura delle istruzioni macchina
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89    echo "===> [Successo] La logica macchina di main.main dei due binari coincide al 100%! <==="
90    echo "Abbiamo sincronizzato perfettamente le linee guida della pipeline di ottimizzazione del compilatore ottenendo lo stesso assembly."
91else
92    echo "===> [Fallimento] Sono state riscontrate differenze nella struttura delle istruzioni assembly. <==="
93    diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"

Eseguendo il codice, è possibile ottenere le seguenti informazioni.

 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===> [Successo] La logica macchina di main.main dei due binari coincide al 100%! <===
 9Abbiamo sincronizzato perfettamente le linee guida della pipeline di ottimizzazione del compilatore ottenendo lo stesso assembly.
10------------------------------------------------------------

Conclusione

Abbiamo appreso che, sebbene i linguaggi di programmazione offrano numerose astrazioni, dietro di esse si celano ottimizzazioni estremamente interessanti e aggressive. Inoltre, sfruttando questi aspetti, è stato possibile creare codici speculari con sorgenti diversi ma con un assembly identico. Qualora foste interessati al basso livello e doveste imbattervi in software proprietari scritti in Go, analizzarne direttamente l'assembly per tentarne il ripristino del codice sorgente non è un'impresa impossibile.

Lezione successiva

Nella prossima lezione, esploreremo l'istruzione select-case, che presenta peculiarità altrettanto interessanti rispetto all'istruzione If.