GoSuda

Análise da estrutura de tempo de execução de linguagens gerenciadas e de binários Go em assembly AMD64

By Lee Yunjin views ...

O que é uma linguagem gerenciada?

Uma linguagem gerenciada é aquela que, diferentemente das linguagens não gerenciadas, ou seja, linguagens que apenas executam a lógica escrita pelo programador sem desviar muito dela, executa no runtime tarefas como GC, otimizações em tempo de execução, green threads e tratamento de concorrência, eliminando a necessidade de o usuário realizar gerenciamento de baixo nível arriscado. Nesse tipo de linguagem, há a vantagem de poder concentrar-se na lógica de negócio e mergulhar no desenvolvimento; por outro lado, o comportamento real do programa pode diferir da intuição do programador, o que, por vezes, exige ajustes finos de runtime. Primeiramente, entre as linguagens gerenciadas, examinaremos a linguagem Go, que segue com maior fidelidade a filosofia minimalista e cujo assembly é transparente.

Estrutura binária da linguagem Go

.text.data.gopclntab, .typelink etc.
Código de máquina a ser executadoDados a serem armazenadosSeções do runtime da linguagem

Como a linguagem Go não traduz o código escrito pelo usuário para código de máquina de forma 1:1, a lógica da seção .text está intimamente relacionada às seções do runtime da linguagem. Além disso, funções que o usuário não escreveu, como runtime.printnl(), são adicionadas ao assembly da seção .text. Por meio dessa inserção automática de código, a linguagem Go auxilia o desenvolvedor a se libertar do gerenciamento manual.

Observando apenas a função main em Go

Inicialmente, vamos escrever um código-fonte de exemplo simples, main.go, e observar a função main em uma máquina AMD64.

 1package main
 2
 3func sayHello(msg string) {
 4    println(msg)
 5}
 6
 7func main() {
 8    sayHello("Hello World")
 9}
10

Em seguida, realiza-se a compilação com o seguinte comando.

1go build main.go
2

Para facilitar a depuração de baixo nível, o Go oferece suporte ao go tool. Para visualizar apenas o assembly correspondente à função main do pacote main, insere-se o seguinte comando.

1go tool objdump -s "main\.main" ./main
2

Assembly

 1TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
 2  main.go:7             0x468f60                493b6610                CMPQ SP, 0x10(R14)
 3  main.go:7             0x468f64                762f                    JBE 0x468f95
 4  main.go:7             0x468f66                55                      PUSHQ BP
 5  main.go:7             0x468f67                4889e5                  MOVQ SP, BP
 6  main.go:7             0x468f6a                4883ec10                SUBQ $0x10, SP
 7  main.go:8             0x468f6e                90                      NOPL
 8  main.go:4             0x468f6f                e8cca3fcff              CALL runtime.printlock(SB)
 9  main.go:4             0x468f74                488d05da290100          LEAQ 0x129da(IP), AX
10  main.go:4             0x468f7b                bb0b000000              MOVL $0xb, BX
11  main.go:4             0x468f80                e83bacfcff              CALL runtime.printstring(SB)
12  main.go:4             0x468f85                e8f6a5fcff              CALL runtime.printnl(SB)
13  main.go:4             0x468f8a                e811a4fcff              CALL runtime.printunlock(SB)
14  main.go:9             0x468f8f                4883c410                ADDQ $0x10, SP
15  main.go:9             0x468f93                5d                      POPQ BP
16  main.go:9             0x468f94                c3                      RET
17  main.go:7             0x468f95                e8e6afffff              CALL runtime.morestack_noctxt.abi0(SB)
18  main.go:7             0x468f9a                ebc4                    JMP main.main(SB)
19
  • Compara-se, com CMPQ, o valor do guarda de pilha presente no registrador de bloco de controle de goroutine (R14) com o ponteiro de pilha atual (SP), a fim de verificar se há espaço suficiente para o frame de pilha da goroutine; caso não haja, realiza-se um salto (JBE) para o endereço 0x468f95, o Entrypoint responsável pela expansão da pilha.
  • O ponteiro de base anterior é salvo na pilha por meio de PUSHQ BP.
  • O ponteiro de base (BP) recebe uma cópia do ponteiro de pilha atual (SP), fixando o ponto de referência da pilha no início da função.
  • Em seguida, aloca-se um espaço de variáveis locais de 16 bytes (SUBQ $0x10, SP) e preenche-se com NOPL uma instrução virtual para realizar o alinhamento no cache da CPU.
  • Para sincronizar a saída padrão de strings internas do runtime do Go, chama-se runtime.printlock(SB), adquirindo o lock.
  • Com a instrução LEAQ, armazena-se no registrador AX, que segundo a convenção ABI do Go é usado como primeiro parâmetro, o endereço inicial da string constante ("Hello World").
  • Em seguida, armazena-se no segundo registrador de parâmetro, BX, o valor que representa o comprimento da string (MOVL $0xb, BX, ou seja, 11 em decimal).
  • Chama-se runtime.printstring(SB), que imprime no console com base nas informações recebidas em AX (endereço dos dados) e BX (comprimento).
  • Para tratar a quebra de linha, chama-se runtime.printnl(SB).
  • Como a saída foi concluída, libera-se o lock por meio de runtime.printunlock(SB).
  • Restaura-se a memória de pilha de 16 bytes alocada por meio de ADDQ $0x10, SP.
  • Restaura-se o ponteiro de base anterior com POPQ BP.
  • Por meio de RET, devolve-se o controle ao ponto em que a função foi chamada.
  • Caso o espaço tenha sido insuficiente na verificação inicial da pilha, chama-se runtime.morestack_noctxt.abi0(SB) no endereço 0x468f95, expandindo dinamicamente a pilha do runtime, como convém a uma linguagem gerenciada.
  • Concluída a expansão da pilha, retorna-se (JMP) ao ponto de entrada de main.main(SB). Como se observa, o assembly da lógica de negócio é bastante claro e apenas acrescido de um gerenciamento leve do runtime.

Quando não há otimização

A forma acima é o resultado de o compilador do Go ter aplicado automaticamente inlining às duas funções separadas. Entretanto, para fins de estudo, faremos com que sayHello não seja inlined neste caso. Para isso, compila-se o código-fonte com a seguinte flag.

1go build -gcflags="-l" main.go
2

Ao exibir o resultado no shell, nota-se a presença de assembly duplicado.

 1yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
 2
 3go tool objdump -s "main\.sayHello" ./main
 4TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
 5  main.go:3             0x468f60                493b6610               CMPQ SP, 0x10(R14)
 6  main.go:3             0x468f64                7636                   JBE 0x468f9c
 7  main.go:3             0x468f66                55                     PUSHQ BP
 8  main.go:3             0x468f67                4889e5                 MOVQ SP, BP
 9  main.go:3             0x468f6a                4883ec10               SUBQ $0x10, SP
10  main.go:5             0x468f6e                4889442420             MOVQ AX, 0x20(SP)
11  main.go:5             0x468f73                48895c2428             MOVQ BX, 0x28(SP)
12  main.go:4             0x468f78                e8c3a3fcff             CALL runtime.printlock(SB)
13  main.go:4             0x468f7d                488b442420             MOVQ 0x20(SP), AX
14  main.go:4             0x468f82                488b5c2428             MOVQ 0x28(SP), BX
15  main.go:4             0x468f87                e834acfcff             CALL runtime.printstring(SB)
16  main.go:4             0x468f8c                e8efa5fcff             CALL runtime.printnl(SB)
17  main.go:4             0x468f91                e80aa4fcff             CALL runtime.printunlock(SB)
18  main.go:5             0x468f96                4883c410               ADDQ $0x10, SP
19  main.go:5             0x468f9a                5d                     POPQ BP
20  main.go:5             0x468f9b                c3                     RET
21  main.go:3             0x468f9c                4889442408             MOVQ AX, 0x8(SP)
22  main.go:3             0x468fa1                48895c2410             MOVQ BX, 0x10(SP)
23  main.go:3             0x468fa6                e8d5afffff             CALL runtime.morestack_noctxt.abi0(SB)
24  main.go:3             0x468fab                488b442408             MOVQ 0x8(SP), AX
25  main.go:3             0x468fb0                488b5c2410             MOVQ 0x10(SP), BX
26  main.go:3             0x468fb5                eba9                   JMP main.sayHello(SB)
27

Ao desativar o inlining, são inseridas operações MOVQ que recarregam os parâmetros (AX, BX) em deslocamentos da pilha, como 0x20(SP), para preservá-los conforme a convenção de chamada de funções. Ou seja, confirma-se que a otimização do compilador visa justamente essas operações desnecessárias de movimentação de memória e o overhead de chamadas de função.

Próxima aula

Na próxima aula, trataremos das instruções if e switch na linguagem Go. Se houver tempo no futuro, também pretendemos analisar as seções do runtime do Go.