Analyse de la génération de code assembleur AMD64 pour les conditions (if) en Go et des instructions de branchement

Les instructions If en Go
Tout d'abord, si nous avons choisi Go, c'est parce que, parmi les langages modernes, Go offre l'assembleur le plus « élégant », et que son efficacité syntaxique peut même, par rapport aux langages classiques, se révéler écrasante.
Maintenant que nous avons compris, lors du cours précédent, le fonctionnement d'un programme Go simple, comparons immédiatement Go et Assembly ligne par ligne.
Code source
Tout d'abord, comme pour Go, même les compilateurs modernes, GCC compris, optimisent automatiquement les branchements dont l'utilisation n'a aucun sens. Les compilateurs C tels que GCC et Clang effectuent également, avec -O2, le niveau d'optimisation standard de l'industrie, des optimisations très agressives ; l'époque où le programmeur ne pouvait guère accorder sa pleine confiance au compilateur est donc, en fin de compte, en quelque sorte révolue depuis la seconde moitié du XXe siècle.
Par conséquent, pour avoir au moins un sens, il faut imposer une condition que le compilateur ait du mal à prédire et à remplacer par une autre construction.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Si l'on se contente d'une condition prévisible, telle que x = 10, dont le branchement peut être supprimé,
10 // le compilateur optimise et supprime le branchement.
11 // Ainsi, pour observer directement cela en assembleur, on utilise en C des options comme -O0,
12 // ou bien on fait en sorte que le compilateur utilise une valeur externe imprévisible,
13 // mais comme cette section traite de la programmation moderne, nous n'utiliserons pas
14 // la méthode consistant à désactiver l'optimisation binaire de 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}
Dans ce cas, comme le compilateur ne peut pas prédire l'entrée, le branchement est traduit tel quel en code machine.
Assembleur
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)
L'outil go tool indique obligeamment, une à une, les correspondances entre les instructions du code source et l'assembleur.
Comme ce que nous allons apprendre cette fois concerne les instructions de comparaison et les branchements if, il suffit de prêter attention à quelques lignes.
Instructions CMPQ et JL
L'instruction CMPQ sert à comparer des types de 4 octets (4 mots), et son étymologie est CoMPareQuadword, abrégé en CMPQ.
À l'adresse mémoire 0x47a84e, on trouve l'instruction CMPQ os.Args+8(SB), $0x2.
Dans ce cas, le programme compare le nombre d'arguments reçus avec l'hexadécimal 0x2 (c'est-à-dire simplement 2).
Ensuite, on vérifie si le nombre d'arguments est inférieur à 2 (c'est-à-dire si le programme ne possède que lui-même comme argument), et un saut est effectué via JL selon le résultat de la comparaison. Ainsi, JL est l'abréviation de Jump, Less than'.
Si, pour l'opération de comparaison précédente, le nombre d'arguments était inférieur à 2, le programme saute à l'adresse 0x47a8b0, où se trouve JGE.
Cependant, cette instruction utilise le registre AX, il faut donc connaître la nature de la valeur stockée dans ce registre.
Instruction MOVQ
Ensuite, il faut comprendre comment le registre CX sert à stocker l'adresse de début des données, et comment les données réelles sont extraites après la lecture de cette adresse.
Si l'on examine la plage 0x47858-0x47863, cette opération s'effectue par étapes.
Tout d'abord, l'adresse de début du tableau d'arguments est placée dans le registre CX au moyen de l'instruction MOVQ os.Args(SB), CX. Il faut alors comprendre le type string de Go.
Le string de Go est une structure, composée de deux données de 8 octets, soit 16 octets au total.
| struct | 8 octets | 8 octets |
|---|---|---|
| string | adresse mémoire | longueur de la chaîne |
Visuellement, cela se représente comme ci-dessus : les 8 premiers octets contiennent l'adresse de début de la chaîne, et les 8 suivants sa longueur.
Par conséquent, l'adresse de la chaîne est stockée dans le registre AX et sa longueur dans le registre BX.
CALL
Dans l'article précédent également, lorsque l'on observait les fonctions du groupe runtime, l'instruction CALL était présente.
Elle précède les fonctions utilisées en Go et signifie littéralement qu'une fonction est appelée, d'où son nom CALL. Ensuite, la fonction appelée via CALL convertit la chaîne en entier, et l'endroit où cet entier est stocké n'apparaît pas, car il est abstrait dans la fonction.
Instructions CMPQ et JGE
Revenons à l'adresse 0x47a86c mentionnée précédemment : l'instruction compare l'adresse de la chaîne au nombre 0xa (10 en décimal) !
Cela signifie que, comme le programme n'utilise plus cet argument, la variable entière x a été placée en écrasant l'emplacement de la chaîne.
Telle est la réalité de l'optimisation agressive réalisée notamment en Go.
Vient ensuite l'instruction JGE, abréviation de Jump, Greater or Equals. Ainsi, cette instruction vérifie si la valeur est supérieure ou égale à celle de comparaison.
Par conséquent, ce n'est pas la construction x < 10 telle quelle, mais la direction de la comparaison a été inversée !
En langage machine, cela est dû au fait que sauter par anticipation lorsque la condition n'est pas vérifiée est plus intuitif et permet d'économiser une instruction, par rapport à effectuer la comparaison une fois lorsque la condition est vérifiée, puis à vérifier à nouveau si elle ne l'est pas.
Cette optimisation est très classique : contrairement à l'exemple strconv.Atoi vu plus haut, il s'agit d'un schéma qui apparaît fréquemment, même dans des compilateurs dont le niveau d'optimisation est assez faible ; il est donc utile de le connaître.
Ainsi, en exploitant ce point, on peut obtenir des sources différentes mais dont l'assembleur correspond à 100 %.
Exemple de code miroir
Le script ci-dessous permet de vérifier que, après avoir généré deux sources miroirs à 100 % à l'aide d'un script Bash, et en excluant les métadonnées qui varient d'une fois à l'autre, l'assembleur obtenu pour main seul est rigoureusement identique.
1#!/usr/bin/env bash
2
3# 1. Réinitialisation complète des anciens fichiers et répertoires résiduels
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. Rédaction du code source de la version originale (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. Rédaction du code source de la version miroir (main_from_asm.go)
36# Synchronisation parfaitement symétrique de la structure des opérateurs afin que le compilateur utilise tel quel le modèle d'optimisation (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 // En prenant 10 comme référence et en conservant la structure x < 10, le compilateur
56 // adopte exactement le même mécanisme JGE et la même disposition des blocs que main.go.
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. Compilation de chaque source dans le même chemin de répertoire et avec le même nom de fichier
67echo "[4/6] Compiling both sources inside 'test_dir'..."
68cp main.go test_dir/main.go
69cd test_dir && go build -o ../main_orig main.go && cd ..
70rm test_dir/main.go
71
72cp main_from_asm.go test_dir/main.go
73cd test_dir && go build -o ../main_asm main.go && cd ..
74
75# 5. Extraction de la fonction d'assembleur main.main pure à l'aide de go tool objdump
76echo "[5/6] Extracting main.main assembly sections..."
77go tool objdump -s "main\.main" main_orig > orig.asm
78go tool objdump -s "main\.main" main_asm > asm.asm
79
80# Suppression des adresses virtuelles, des décalages et du texte des octets machine,
81# en ne filtrant que les champs de l'ensemble d'instructions pur (Opcode & Operands) que le CPU exécutera
82awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
83awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
84
85# 6. Vérification par diff de l'intégrité structurelle des deux codes machine
86echo "[6/6] Verifying assembly structural integrity via diff..."
87echo "------------------------------------------------------------"
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 "------------------------------------------------------------"
En exécutant réellement ce code source, on obtient les informations suivantes.
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------------------------------------------------------------
Conclusion
Les langages de programmation offrent de nombreuses abstractions, mais on constate que derrière ces abstractions se cachent des optimisations très intéressantes et agressives. Il a également été possible d'exploiter ce principe à rebours pour créer des codes miroirs, dont les sources diffèrent mais dont l'assembleur est identique. Si vous vous intéressez aux bas niveaux et rencontrez un logiciel propriétaire écrit en Go, il ne semble pas impossible de le désassembler et d'analyser l'assembleur afin de reconstituer le code source.
Prochain cours
Lors du prochain cours, nous étudierons les instructions If ainsi que l'instruction select-case, tout aussi intéressante.