Instrução If na linguagem Go
Instruções If na linguagem Go
Primeiramente, optamos pela linguagem Go devido ao fato de que, entre as linguagens modernas, ela possui o assembly mais "elegante" e, mesmo quando comparada a linguagens clássicas, sua eficiência sintática é frequentemente avassaladora.
Agora que compreendemos o funcionamento básico de um programa em Go na aula anterior, passemos imediatamente a comparar, linha por linha, o código em Go com o seu respectivo Assembly.
Código-fonte
De início, embora seja uma prática comum em Go, até mesmo compiladores modernos, incluindo o GCC, otimizam automaticamente instruções de desvio (branching) que não possuem utilidade prática. Compiladores de C, como o GCC e o Clang, realizam otimizações extremamente agressivas no padrão da indústria -O2; portanto, a era em que o programador não podia confiar plenamente no compilador foi, de fato, consolidada desde o final do século XX.
Consequentemente, a instrução só adquire significado se fornecermos condições que sejam difíceis para o compilador prever e converter em outras estruturas.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Se preenchermos isto com algo previsível como x = 10, o que permitiria
10 // a remoção do desvio, o compilador irá otimizar e eliminar a ramificação.
11 // Portanto, para observar isso diretamente em assembly, em C utilizaríamos
12 // -O0 ou faríamos com que o compilador utilizasse valores externos imprevisíveis.
13 // Como esta seção aborda a programação moderna, não utilizaremos
14 // métodos para desativar a otimização de binários em 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, a instrução de desvio é traduzida literalmente para linguagem 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)
Ao utilizar a go tool, somos gentilmente informados sobre a correspondência entre cada instrução e o código assembly gerado.
Como o foco desta lição são as instruções de comparação e de desvio if, devemos atentar para algumas linhas específicas.
Instrução CMPQ & Instrução JL
A instrução CMPQ é utilizada para comparar tipos de dados de 4 bytes (4 words), sendo derivada de CoMPare Quadword.
Observando o endereço de memória 0x47a84e, encontramos a instrução CMPQ os.Args+8(SB), $0x2.
Neste caso, o programa compara o número de argumentos recebidos com o valor hexadecimal 0x2 (ou seja, 2).
Em seguida, através da instrução JL, o programa realiza um desvio caso o número de argumentos seja menor que 2 (ou seja, se o programa for o único argumento). Isto deriva de Jump, Less than.
Se o número de argumentos for menor que 2 na comparação anterior, ele salta para o endereço 0x47a8b0, onde encontramos a instrução JGE.
Entretanto, como esta instrução utiliza o registrador AX, precisamos compreender a natureza do valor armazenado nele.
Instrução MOVQ
A seguir, devemos entender como o endereço inicial dos dados é armazenado utilizando o registrador CX e como os dados reais são extraídos após a leitura do endereço.
Ao analisar o intervalo 0x47858-0x47863, observamos a execução gradual desta operação.
Primeiro, o endereço inicial do array de argumentos é inserido no registrador CX por meio da instrução MOVQ os.Args(SB), CX. Neste ponto, é necessário compreender o tipo string em Go.
O tipo string em Go é uma estrutura (struct) composta por 16 bytes, divididos em dois campos de 8 bytes cada.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Representando visualmente, os primeiros 8 bytes contêm o endereço inicial da string, e os 8 bytes subsequentes contêm o comprimento da string.
Portanto, o endereço da string é armazenado no registrador AX e o comprimento da string no registrador BX.
CALL
Nas postagens anteriores, ao observarmos as funções da runtime, a instrução CALL já estava presente.
Esta instrução precede as funções utilizadas em Go e significa, literalmente, chamar uma determinada função. Posteriormente, a função CALL é utilizada para converter a string em um número inteiro, embora o local de armazenamento desse inteiro não seja visível, pois está abstraído pela função.
Instrução CMPQ & Instrução JGE
Retornando ao endereço 0x47a86c, a instrução compara o endereço da string com o valor numérico 0xa (10 em decimal)!
Isso significa que, como o argumento não é mais utilizado dentro do programa, o compilador sobrescreveu o local da string para criar o espaço da variável inteira x.
Esta é a essência da otimização agressiva realizada em linguagens como Go.
Em seguida, surge a instrução JGE, que é a abreviação de Jump, Greater or Equals. Portanto, esta instrução verifica se o valor é maior ou igual ao termo de comparação.
Assim, não se trata da instrução x < 10 literalmente, mas sim de uma inversão na direção da comparação!
Isso ocorre porque, em linguagem de máquina, pular preventivamente quando a condição não é atendida é mais intuitivo e economiza uma instrução do que realizar a comparação quando a condição é atendida e verificar novamente se ela não é verdadeira.
Essa otimização é clássica e, ao contrário do exemplo de strconv.Atoi visto acima, é um padrão frequente até em compiladores com níveis de otimização bastante baixos, sendo, portanto, importante conhecer.
Dessa forma, aplicando esses princípios, é possível obter códigos que, embora diferentes na origem, são 100% idênticos em nível de assembly.
Exemplo de código espelho
Ao utilizar o script abaixo, é possível verificar que o script Bash gera dois códigos-fonte que são "espelhos" um do outro e, ao analisar apenas o main (excluindo metadados variáveis), obtém-se um assembly exatamente idêntico.
1#!/usr/bin/env bash
2
3# 1. Inicialização completa de arquivos residuais e diretórios
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 da versão original do código-fonte (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 da versão espelho do código-fonte (main_from_asm.go)
36# Sincronização simétrica da estrutura de operadores para que o compilador use o template 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 tomar 10 como base e manter a estrutura x < 10, o compilador
56 // adota exatamente o mesmo mecanismo JGE e disposição de blocos que em main.go.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Compilação de cada um mantendo 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 da função assembly puramente main.main usando 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# Remoção de endereços virtuais, offsets e dados binários de máquina para
80# filtrar apenas o conjunto de instruções puras (Opcode & Operands) que a CPU executará
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 de diff da estrutura de instruções de máquina
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89 echo "===> [Sucesso] A lógica em linguagem de máquina de main.main nos dois binários é 100% idêntica! <==="
90 echo "Sincronizamos perfeitamente as diretrizes do pipeline de otimização do compilador para obter o mesmo assembly."
91else
92 echo "===> [Falha] Diferenças foram encontradas na estrutura das instruções de assembly. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
Ao executar o código, obtêm-se as seguintes informações:
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===> [Sucesso] A lógica em linguagem de máquina de main.main nos dois binários é 100% idêntica! <===
9Sincronizamos perfeitamente as diretrizes do pipeline de otimização do compilador para obter o mesmo assembly.
10------------------------------------------------------------
Conclusão
Pudemos observar que, embora as linguagens de programação ofereçam diversas abstrações, por trás delas escondem-se otimizações fascinantes e agressivas. Além disso, aproveitando esses pontos, foi possível criar códigos que funcionam como espelhos: fontes diferentes, porém com assembly idêntico. Se você tiver interesse em baixo nível e se deparar com softwares proprietários escritos em Go, recuperar o código-fonte através da desmontagem e análise direta do assembly não é uma tarefa impossível.
Próxima aula
Na próxima aula, exploraremos a instrução select-case, que possui um interesse particular, assim como a instrução If.