GoSuda

Análise das instruções de desvio e da geração de código assembly AMD64 para a instrução condicional if em Go

By Lee Yunjin views ...

Instruções if na Linguagem Go

Primeiramente, a razão pela qual escolhemos Go é que, entre as linguagens modernas, Go é aquela cujo assembly é mais "belo", e há momentos em que a eficiência de sua sintaxe é, na verdade, superior à das linguagens clássicas.

Agora que entendemos, na aula anterior, o funcionamento de um programa simples em Go, vamos comparar Go e Assembly linha por linha.

Código-fonte

Antes de tudo, tanto em Go quanto em compiladores modernos, incluindo o GCC, desvios sem utilidade são otimizados automaticamente. Compiladores de C como GCC e Clang também realizam otimizações muito agressivas com -O2, que é o padrão da indústria; assim, a era em que é difícil confiar totalmente no compilador já ficou para trás desde o final do século XX.

Portanto, do ponto de vista do compilador, uma condição que não possa ser facilmente prevista e substituída por outra construção só tem significado se for fornecida de forma adequada.

 1package main
 2
 3import (
 4    "os"
 5    "strconv"
 6)
 7
 8func main() {
 9    // Se isto for substituído por algo previsível e que pode eliminar o desvio, como x = 10,
10    // o compilador otimiza e elimina o desvio.
11    // Assim, para observar o assembly diretamente, em C usa-se algo como -O0,
12    // ou faz-se com que o compilador use um valor externo imprevisível;
13    // 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}

Nesse caso, como o compilador não consegue prever a entrada, o desvio é traduzido diretamente para código de máquina.

Linguagem 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)

A ferramenta go tool indica, de forma conveniente, qual construção corresponde a qual trecho de assembly, uma por uma.

Como o que aprenderemos agora são as comparações e os desvios if, basta prestar atenção a algumas linhas.

Instruções CMPQ & JL

A instrução CMPQ é usada para comparar valores de 4 bytes (4 palavras), e seu nome vem de CoMPareQuadword, abreviado como CMPQ.

No endereço de memória 0x47a84e, encontra-se a construção CMPQ os.Args+8(SB), $0x2. Nesse caso, o programa compara o número de argumentos recebidos com 0x2 em hexadecimal (ou seja, 2).

Em seguida, para verificar se o número de argumentos é menor que 2 (isto é, se o programa não recebeu nenhum argumento além de si mesmo), realiza-se um desvio condicional com JL. Ou seja, JL é a abreviação de Jump, Less than. Se, na comparação anterior, o número de argumentos for menor que 2, o programa salta para o endereço 0x47a8b0, onde há uma instrução JGE. No entanto, como essa construção utiliza o registrador AX, é necessário saber o que está armazenado nele.

Instrução MOVQ

Em seguida, é preciso entender como o registrador CX é usado para armazenar o endereço inicial dos dados e como os dados reais são extraídos após a leitura desse endereço.

Observando o intervalo 0x47858-0x47863, essa operação é realizada em etapas.

Primeiramente, o endereço inicial do array de argumentos é carregado no registrador CX pela instrução MOVQ os.Args(SB), CX. Nesse momento, é necessário compreender o tipo string de Go.

A string em Go é uma estrutura, composta por dois valores de 8 bytes, totalizando 16 bytes.

struct8 byte8 byte
stringmem addressstring length

Visualmente, essa estrutura é como mostrada 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

Nas postagens anteriores, ao observarmos funções do pacote runtime, havia a instrução CALL. Ela é usada antes das funções utilizadas em Go e significa, literalmente, chamar uma função. Em seguida, a função CALL é utilizada para converter a string em um inteiro, e não é visível, abstraído pela função, onde esse inteiro é armazenado.

Instruções CMPQ & JGE

Voltando ao endereço 0x47a86c, a instrução está comparando o endereço da string com o número 0xa (em decimal, 10)!

Isso significa que, como o programa não utiliza mais esse argumento, sobrescreve-se a posição da string para criar o espaço da variável inteira x.

Essa é a essência da otimização agressiva realizada em linguagens como Go.

Em seguida, aparece a instrução JGE, que é a abreviação de Jump, Greater or Equals. Assim, essa construção verifica se o valor é maior ou igual ao valor de comparação.

Portanto, não é exatamente a construção x < 10, mas sim uma forma em que a direção da comparação foi invertida! Isso ocorre porque, em código de máquina, pular antecipadamente quando a condição não é satisfeita é mais direto e economiza uma instrução em comparação com executar a comparação quando a condição é satisfeita e depois verificar novamente se ela não é satisfeita.

Essa otimização é muito clássica; por isso, ao contrário do exemplo strconv.Atoi visto acima, é um padrão que aparece com frequência mesmo em compiladores com um nível de otimização bastante baixo, valendo a pena conhecê-lo.

Assim, aplicando esse princípio, é possível obter código-fonte diferente que produz assembly 100% idêntico no nível das instruções.

Exemplo de código espelhado

Com o script abaixo, o Bash gera dois códigos-fonte 100% espelhados e, desconsiderando os metadados que variam a cada compilação, é possível verificar que, ao observar apenas main, obtém-se assembly exatamente idêntico.

 1#!/usr/bin/env bash
 2
 3# 1. Inicialização completa de arquivos e diretórios residuais 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. Criação 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. Criação do código-fonte da versão espelhada (main_from_asm.go)
36# Sincroniza simetricamente a estrutura dos operadores para que o compilador adote o mesmo modelo 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        // Ao manter a estrutura x < 10 tomando 10 como referência, o compilador
56        // adota exatamente o mesmo mecanismo JGE e a mesma disposição de blocos do main.go.
57        if x < 10 {
58                println(s1)
59        } else {
60                println(s2)
61        }
62}
63EOF
64
65# 4. Compilação de ambas as fontes com o mesmo caminho de diretório e nome de arquivo
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. Extração das seções de assembly de main.main com 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# Remove endereços virtuais, deslocamentos e dados de bytes de máquina,
80# filtrando apenas os campos de instruções puras (Opcode & Operands) que a CPU executa
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ção da integridade estrutural das instruções por diff
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
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 "------------------------------------------------------------"

Ao executar o script de fato, obtém-se a seguinte informação.

 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

As linguagens de programação oferecem muitas abstrações, mas, por trás dessas abstrações, existem otimizações muito interessantes e agressivas. Além disso, explorando esse aspecto ao contrário, também foi possível criar códigos espelhados que têm fontes diferentes, mas assembly idêntico. Se você se interessar por baixo nível e se deparar com software proprietário escrito em Go, parece que não é impossível desmontar o assembly, analisá-lo diretamente e recuperar o código-fonte.

Próxima aula

Na próxima aula, veremos o select-case, outra construção interessante, além do if.