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

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 executado | Dados a serem armazenados | Seçõ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.