Geração de código assembly AMD64 para condicionais (if) em Go e análise de instruções de desvio

Instruções If na Linguagem Go
Primeiramente, a razão pela qual escolhemos Go é que, entre as linguagens modernas, ela é aquela cujo assembly é, por vezes, o mais "elegante", e a eficiência de sua sintaxe chega a ser esmagadora mesmo em comparação com linguagens clássicas.
Agora que, na aula anterior, compreendemos o modo de funcionamento de um programa simples em Go, vamos comparar Go e Assembly linha por linha.
Código-fonte
Antes de tudo, assim como em Go, até mesmo compiladores modernos, incluindo o GCC, otimizam automaticamente desvios condicionais que não têm utilidade. Compiladores de C como GCC e Clang também realizam otimizações bastante agressivas com -O2, o padrão da indústria; assim, a época em que o programador dificilmente podia confiar totalmente no compilador terminou de vez no final do século XX.
Portanto, do ponto de vista do compilador, para que a condição faça sentido é preciso impor condições que ele não consiga prever facilmente nem substituir por outra construção.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Se isto for substituído por algo previsível e que permita remover o desvio, como x = 10,
10 // o compilador otimiza e remove o desvio.
11 // Por isso, para observar esse assembly diretamente, em C costuma-se usar -O0 ou,
12 // o que é ainda mais simples, fazer o compilador utilizar um valor externo imprevisível,
13 // mas como esta seção trata de programação moderna, não usaremos
14 // o método de desativar a otimização binária do 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}
Neste caso, como o compilador não consegue prever a entrada, o desvio condicional é traduzido diretamente para linguagem de máquina.
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)
Usando o go tool, obtemos de forma gentil a correspondência de cada construção com cada instrução de assembly.
Como o que aprenderemos desta vez são as comparações e os desvios if, basta observar algumas linhas.
Instruções CMPQ e JL
A instrução CMPQ é utilizada para comparar valores do tipo de 4 bytes (4 words), e seu étimo vem de CoMPareQuadword, abreviado como CMPQ.
No endereço de memória 0x47a84e, encontramos a construção CMPQ os.Args+8(SB), $0x2.
Neste caso, o programa compara o número de argumentos recebidos com o hexadecimal 0x2 (ou seja, simplesmente 2).
Em seguida, para verificar se o número de argumentos é menor que 2 (isto é, se o programa possui apenas a si mesmo como argumento), realiza-se um salto condicional por meio de JL. Ou seja, trata-se de Jump, abreviação de Less than, resultando em JL.
Se, no resultado da comparação anterior, o número de argumentos for menor que 2, o salto ocorre para o endereço 0x47a8b0, onde há um JGE.
Entretanto, esta construção utiliza o registrador AX, portanto é preciso saber qual é o valor armazenado nele.
Instrução MOVQ
Na sequência, é preciso entender como o registrador 'CX' é usado para armazenar o endereço inicial dos dados e como se pretende extrair os dados reais após a leitura desse endereço.
Observando o intervalo 0x47858-0x47863, vemos que essa operação é realizada em etapas.
Primeiro, o endereço inicial do array de argumentos é inserido no registrador CX pela instrução MOVQ os.Args(SB), CX. Nesse momento, é preciso compreender o tipo string do Go.
Em Go, string é uma struct, e essa struct é composta por dois dados de 8 bytes, totalizando 16 bytes.
| struct | 8 bytes | 8 bytes |
|---|---|---|
| string | endereço de memória | comprimento da string |
Visualmente, a estrutura é como acima: os primeiros 8 bytes armazenam o endereço inicial da string, e os 8 bytes seguintes armazenam o comprimento da string.
Assim, o endereço da string é armazenado no registrador AX, e o comprimento da string no registrador BX.
CALL
Nos posts anteriores, ao observarmos funções da família runtime, a instrução CALL aparecia junto delas.
Ela é utilizada antes das funções empregadas em Go e significa que uma função está sendo chamada, daí o nome CALL. Em seguida, a função CALL é utilizada para converter a string em um inteiro, mas onde esse inteiro é armazenado não fica visível, pois fica abstraído dentro da função.
CMPQ e JGE
Voltando ao endereço anterior 0x47a86c, a instrução compara o endereço da string com o número 0xa (em decimal, 10)!
Isso significa que, como o programa não utiliza mais aquele argumento, a posição da string foi sobrescrita para criar o espaço da variável inteira x.
Esta é a essência da otimização agressiva realizada em linguagens como Go.
Depois, aparece a instrução JGE, abreviação de Jump, Greater or Equals. Portanto, esta construção pergunta se o valor é maior ou igual ao valor de comparação.
Assim, em vez de x < 10 exatamente como escrito, a direção da comparação está invertida: a ordem da comparação foi trocada!
Isso ocorre porque, em linguagem de máquina, desviar antecipadamente quando a condição não é atendida é mais direto do que executar a comparação uma vez quando a condição é atendida e depois verificar novamente se ela não é atendida, e assim economiza uma instrução.
Esse tipo de otimização é bastante clássico; por isso, diferentemente do exemplo strconv.Atoi visto acima, ele aparece com frequência mesmo em compiladores com otimização bem mais modesta, valendo a pena conhecê-lo.
Assim, aplicando esse princípio, é possível obter um código-fonte diferente que resulta em um assembly 100% idêntico.
Exemplo de código espelhado
Usando o script abaixo, o script bash gera dois códigos-fonte espelhados 100% e, em seguida, verifica que, desconsiderando os metadados que mudam a cada execução, ao considerar apenas main, obtém-se um assembly exatamente idêntico.
1#!/usr/bin/env bash
2
3# 1. Limpeza completa dos arquivos e diretórios residuais de execuções anteriores
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. Escrita do código-fonte da versão original (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. Escrita do código-fonte da versão espelhada (main_from_asm.go)
36# Mantendo a estrutura simétrica dos operadores para que o compilador adote exatamente o mesmo padrão de otimização (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 // Usando 10 como referência, se mantivermos a estrutura x < 10, o compilador
56 // adota exatamente o mesmo mecanismo JGE e a mesma disposição de blocos de main.go
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Compilação das duas fontes no mesmo caminho de diretório e com o mesmo nome de arquivo
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. Extração da função assembly pura main.main usando 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# Remoção dos valores de endereço virtual, deslocamento e texto dos bytes de máquina,
81# filtrando apenas os campos do conjunto de instruções puras (Opcode & Operands) que a CPU executa
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. Verificação da integridade estrutural das instruções via 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 "------------------------------------------------------------"
Executando o código na prática, obtemos as informações abaixo.
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------------------------------------------------------------
Conclusão
Linguagens de programação oferecem muitas abstrações, mas por trás delas escondem-se otimizações muito interessantes e agressivas. Além disso, explorando esse ponto ao contrário, também foi possível criar códigos espelhados, com fontes diferentes mas assembly idêntico. Se você se interessa por baixo nível e um dia encontrar software proprietário escrito em Go, desmontar o assembly e analisá-lo para recuperar o código-fonte não parece ser algo impossível.
Próxima aula
Na próxima vez, veremos a instrução If e outra construção igualmente interessante, a instrução select-case.