Structure d'exécution des langages managés et analyse de l'assembleur AMD64 des binaires Go

Qu'est-ce qu'un langage managé ?
Un langage managé est un langage qui, contrairement aux langages non managés, c'est-à-dire ceux dont le programme exécute essentiellement la logique écrite par le programmeur sans s'en écarter beaucoup, exécute dans son runtime le GC, les optimisations à l'exécution, les green threads, la gestion de la concurrence, etc., afin que l'utilisateur n'ait pas à effectuer lui-même une gestion bas niveau dangereuse. Pour ce type de langage, il présente l'avantage de permettre de se concentrer uniquement sur la logique métier et de se plonger pleinement dans le développement ; en revanche, le comportement réel du programme peut différer de l'intuition du programmeur, si bien qu'un réglage fin du runtime est parfois nécessaire. Tout d'abord, parmi les langages managés, examinons le langage Go, qui est le plus fidèle à une philosophie minimaliste et dont l'assembleur est transparent.
Structure binaire du langage Go
| .text | .data | .gopclntab, .typelink, etc. |
|---|---|---|
| Code machine à exécuter | Données à stocker | Sections du runtime du langage |
Le langage Go ne traduit pas en code machine de manière strictement 1:1 ce que l'utilisateur écrit ; la logique de la section .text est donc étroitement liée à la section du runtime du langage. De plus, des fonctions que l'utilisateur n'a pas écrites, comme runtime.printnl(), sont ajoutées à l'assembleur de la section .text. Grâce à cette insertion automatique de code, le langage Go aide le développeur à s'affranchir de la gestion manuelle.
Examen de la seule fonction main en Go
Commençons par écrire un exemple simple, main.go, et observons main sur une machine AMD64.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Ensuite, on compile avec la commande suivante.
1go build main.go
2
Go prend en charge go tool pour faciliter le débogage bas niveau. Pour n'afficher que l'assembleur de la fonction main du paquet main, on saisit la commande suivante.
1go tool objdump -s "main\.main" ./main
2
Assembleur
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
- Le registre de bloc de contrôle de goroutine (R14) contient la valeur de garde de pile ; CMPQ compare cette valeur à le pointeur de pile courant (SP) afin de vérifier si l'espace de la trame de pile de la goroutine courante est suffisant. Si ce n'est pas le cas, un saut (JBE) est effectué vers l'adresse 0x468f95, point d'entrée de l'extension de pile.
- PUSHQ BP empile l'ancien pointeur de base afin de le sauvegarder.
- Le pointeur de base (BP) reçoit une copie du pointeur de pile courant (SP), ce qui fixe le repère de pile au début de la fonction.
- Ensuite, un espace de variables locales de 16 octets est alloué (SUBQ $0x10, SP), et NOPL est utilisé pour insérer une instruction vide afin d'effectuer l'alignement sur le cache du CPU.
- Pour synchroniser la sortie standard des chaînes internes du runtime Go, runtime.printlock(SB) est appelée afin de verrouiller.
- L'instruction LEAQ place dans AX, premier paramètre selon la convention ABI de Go, l'adresse de début de la chaîne constante ("Hello World").
- Ensuite, la valeur représentant la longueur de la chaîne est placée dans BX, second registre de paramètre (MOVL $0xb, BX, soit 11 en décimal).
- runtime.printstring(SB) est appelée pour afficher dans la console à partir des informations transmises dans AX (adresse des données) et BX (longueur).
- runtime.printnl(SB) est appelée pour gérer le retour à la ligne.
- La sortie étant terminée, le verrou est libéré par runtime.printunlock(SB).
- ADDQ $0x10, SP restaure la mémoire de pile de 16 octets allouée précédemment.
- POPQ BP restaure l'ancien pointeur de base.
- RET restitue le contrôle au point d'appel de la fonction.
- Si la vérification initiale de la pile a révélé un espace insuffisant, runtime.morestack_noctxt.abi0(SB), située à l'adresse 0x468f95, est appelée afin d'étendre dynamiquement la pile du runtime, comme il convient à un langage managé.
- Une fois l'extension de pile terminée, l'exécution reprend (JMP) au point d'entrée de main.main(SB). Comme on le voit, l'assembleur de la logique métier est assez clair et n'est accompagné que d'une fine couche de gestion par le runtime.
Cas sans optimisation
La forme ci-dessus résulte de l'inlining automatique, par le compilateur Go, des deux fonctions séparées. Toutefois, à des fins d'apprentissage, nous allons empêcher l'inlining de sayHello. Pour cela, on compile le code avec l'option suivante.
1go build -gcflags="-l" main.go
2
En affichant le résultat dans le shell, on constate la présence d'assembleur redondant.
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
Lorsque l'inlining est désactivé, des instructions MOVQ sont insérées pour recharger les valeurs dans 0x20(SP), etc., afin de préserver les paramètres (AX, BX) conformément à la convention d'appel de la fonction. Ainsi, il est confirmé que ce que le compilateur optimise, ce sont ces opérations inutiles de déplacement en mémoire et ces surcoûts d'appel.
Prochaine fois
La prochaine fois, nous traiterons les instructions if et switch en Go. Si le temps le permet, nous analyserons également les sections du runtime de Go.