L'istruzione If nel linguaggio Go
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.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string 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.