Generazione del codice assembly AMD64 per le istruzioni condizionali (if) in Go e analisi delle istruzioni di salto

Istruzioni If in Go
Innanzitutto, la ragione per cui abbiamo scelto Go è che, tra i linguaggi moderni, è quello il cui assembly risulta più "elegante", e che la sua sintassi è talvolta persino più efficiente di quella dei linguaggi classici.
Ora che, nella lezione precedente, abbiamo compreso il funzionamento di un semplice programma Go, confrontiamo Go e Assembly riga per riga.
Codice sorgente
Anzitutto, come accade in Go, anche i moderni compilatori, GCC incluso, ottimizzano automaticamente i rami che non hanno alcuno scopo. Anche i compilatori del linguaggio C, come GCC e Clang, applicano ottimizzazioni molto aggressive già con -O2, lo standard del settore; pertanto, l'epoca in cui il programmatore difficilmente poteva fidarsi completamente del compilatore si è conclusa, di fatto, già dalla fine del XX secolo.
Di conseguenza, per avere almeno un significato, la condizione deve essere tale da rendere difficile, dal punto di vista del compilatore, la previsione e la sostituzione con un'altra istruzione.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Se si sostituisse questo con un'espressione prevedibile e in grado di eliminare il ramo, come x = 10,
10 // il compilatore ottimizzerebbe il codice eliminando il ramo.
11 // Pertanto, per osservare direttamente questo comportamento in assembly, in C si usa -O0 o simili,
12 // oppure si fa in modo che il compilatore usi un valore esterno imprevedibile;
13 // poiché questa sezione tratta la programmazione moderna, non useremo
14 // il metodo di disattivare l'ottimizzazione binaria 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, il ramo viene tradotto in linguaggio macchina così com'è.
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)
Lo strumento go tool, con gentilezza, indica una per una quale istruzione sorgente corrisponda a quale codice assembly.
Poiché in questa occasione studieremo le espressioni di confronto e le istruzioni di ramificazione if, è sufficiente prestare attenzione ad alcune righe.
Istruzioni CMPQ e JL
L'istruzione CMPQ serve a confrontare tipi di dato di 4 byte (4 word) e deriva dall'abbreviazione di CoMPareQuadword, cioè CMPQ.
All'indirizzo di memoria 0x47a84e si trova l'istruzione CMPQ os.Args+8(SB), $0x2.
In questo caso, il programma confronta il numero di argomenti ricevuti con il valore esadecimale 0x2 (cioè semplicemente 2).
Successivamente, se il numero di argomenti è minore di 2 (cioè se il programma riceve soltanto se stesso come argomento), viene eseguito un salto condizionale tramite JL. Quindi questa istruzione è l'abbreviazione di Jump, Less than, cioè JL.
Se, in base al confronto precedente, gli argomenti erano minori di 2, il programma salta all'indirizzo 0x47a8b0, dove si trova un'istruzione JGE.
Tuttavia, in questa sequenza viene utilizzato il registro AX, quindi è necessario conoscere il significato del valore memorizzato nel registro.
Istruzione MOVQ
Successivamente, occorre capire come, utilizzando il registro CX, venga memorizzato l'indirizzo iniziale dei dati, e come vengano poi estratti i dati reali dopo la lettura dell'indirizzo.
Osservando l'intervallo 0x47858-0x47863, questa operazione viene eseguita per fasi.
Anzitutto, l'indirizzo iniziale dell'array degli argomenti viene inserito nel registro CX tramite l'istruzione MOVQ os.Args(SB), CX. A questo punto è necessario comprendere il tipo stringa di Go.
La string di Go è una struttura, composta da due dati di 8 byte, per un totale di 16 byte.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Visivamente, la struttura è quella mostrata sopra: i primi 8 byte contengono l'indirizzo iniziale della stringa, mentre gli ultimi 8 byte contengono la lunghezza della stringa.
Pertanto, l'indirizzo della stringa viene memorizzato nel registro AX e la sua lunghezza nel registro BX.
CALL
Anche nel post precedente, quando si osservavano le funzioni del gruppo runtime, comparivano istruzioni precedute da CALL.
Questa istruzione precede le funzioni utilizzate in Go e, come suggerisce il nome, significa letteralmente chiamare una funzione. Successivamente, tramite la chiamata a funzione, la stringa viene convertita in un intero, ma dove venga memorizzato tale intero non è visibile, poiché è astratto all'interno della funzione.
Istruzioni CMPQ e JGE
Tornando all'indirizzo 0x47a86c, l'istruzione confronta l'indirizzo della stringa con il numero 0xa (decimale 10)!
Ciò significa che, poiché il programma non utilizza più quell'argomento, il valore è stato sovrascritto nella posizione della stringa, creando così lo spazio per la variabile intera x.
Questa è la realtà dell'ottimizzazione aggressiva che avviene in linguaggi come Go.
Successivamente compare l'istruzione JGE, abbreviazione di Jump, Greater or Equals. Quindi questa sequenza verifica se il valore è maggiore o uguale rispetto al termine di confronto.
Pertanto, anziché la forma originale x < 10, si ha una condizione in cui la direzione del confronto risulta invertita rispetto a x < 10!
Ciò avviene perché, a livello di linguaggio macchina, saltare in anticipo quando la condizione non è soddisfatta è più diretto e fa risparmiare un'istruzione, rispetto all'esecuzione del confronto nel caso in cui la condizione sia soddisfatta, seguita dalla verifica che essa non sia soddisfatta.
Questo tipo di ottimizzazione è molto classico e, a differenza dell'esempio strconv.Atoi visto sopra, compare frequentemente anche nei compilatori con un livello di ottimizzazione piuttosto basso; è quindi utile conoscerlo.
Pertanto, applicando questo principio, è possibile ottenere sorgenti diverse che producono assembly identici al 100%.
Esempio di codice speculare
Utilizzando lo script seguente, lo script bash genera due sorgenti speculari al 100%, e, escludendo i metadati che di volta in volta variano, è possibile verificare che considerando solo main si ottiene un assembly esattamente identico.
1#!/usr/bin/env bash
2
3# 1. Inizializzazione completa dei residui e delle directory precedenti
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 della versione 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 della versione speculare (main_from_asm.go)
36# Mantenendo la struttura del confronto x < 10, il compilatore adotta
37# esattamente lo stesso meccanismo JGE e la stessa disposizione dei blocchi di main.go
38echo "[3/6] Generating main_from_asm.go..."
39cat << 'EOF' > main_from_asm.go
40package main
41
42import (
43 "os"
44 "strconv"
45)
46
47func main() {
48 if len(os.Args) < 2 {
49 return
50 }
51 x, _ := strconv.Atoi(os.Args[1])
52
53 s1 := "X is smaller than 10"
54 s2 := "X is larger or same as 10"
55
56 // Prendendo 10 come riferimento e mantenendo la struttura x < 10, il compilatore
57 // adotta esattamente lo stesso meccanismo JGE e la stessa disposizione dei blocchi di main.go.
58 switch {
59 case x < 10:
60 println(s1)
61 default:
62 println(s2)
63 }
64}
65EOF
66
67# 4. Compilazione di entrambi i sorgenti nello stesso percorso di directory e nome file
68echo "[4/6] Compiling both sources inside 'test_dir'..."
69cp main.go test_dir/main.go
70cd test_dir && go build -o ../main_orig main.go && cd ..
71rm test_dir/main.go
72
73cp main_from_asm.go test_dir/main.go
74cd test_dir && go build -o ../main_asm main.go && cd ..
75
76# 5. Estrazione della sezione assembly della funzione main.main tramite go tool objdump
77echo "[5/6] Extracting main.main assembly sections..."
78go tool objdump -s "main\.main" main_orig > orig.asm
79go tool objdump -s "main\.main" main_asm > asm.asm
80
81# Rimozione degli indirizzi virtuali, degli offset e dei dati dei byte macchina,
82# filtrando solo il set di istruzioni eseguite dalla CPU (Opcode e Operandi)
83awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
84awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
85
86# 6. Verifica della struttura delle istruzioni macchina tramite diff
87echo "[6/6] Verifying assembly structural integrity via diff..."
88echo "------------------------------------------------------------"
89if diff orig_pure.asm asm_pure.asm > /dev/null; then
90 echo "===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <==="
91 echo "컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다."
92else
93 echo "===> [실패] 어셈블리 명령어 구조에 차이점이 발견되었습니다. <==="
94 diff -u orig_pure.asm asm_pure.asm
95fi
96echo "------------------------------------------------------------"
Eseguendo effettivamente il codice, si ottengono 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===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <===
9컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다.
10------------------------------------------------------------
Conclusione
I linguaggi di programmazione offrono molte astrazioni, ma dietro di esse si nascondono ottimizzazioni davvero interessanti e aggressive. Inoltre, sfruttando questo aspetto in senso inverso, è stato possibile creare codice speculare: sorgenti diversi che producono lo stesso assembly. Se si è interessati al basso livello e ci si imbatte in software proprietario scritto in Go, scomporre l'assembly per analizzarlo e recuperare il sorgente non sembra un'impresa impossibile.
Prossima lezione
Nella prossima lezione esploreremo, oltre alle istruzioni If, anche l'istruzione select-case, che offre un altro motivo di interesse.