GoSuda

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

By Lee Yunjin views ...

Instructions conditionnelles if en Go

Tout d'abord, si nous avons choisi Go, c'est parce que, parmi les langages modernes, c'est celui dont l'assembleur est le plus « élégant », et que son efficacité syntaxique dépasse parfois nettement celle des langages classiques.

Maintenant que nous avons compris, lors du cours précédent, le fonctionnement d'un programme Go simple, comparons directement Go et l'assembleur ligne par ligne.

Code source

Tout d'abord, comme pour Go, les compilateurs modernes, y compris GCC, optimisent automatiquement les branchements qui n'ont aucune utilité. Les compilateurs C tels que GCC et Clang effectuent eux aussi des optimisations très agressives avec -O2, qui est le standard de l'industrie ; on peut dire que l'époque où il était difficile pour les programmeurs de faire pleinement confiance au compilateur s'est finalement achevée dès la fin du XXe siècle.

Par conséquent, du point de vue du compilateur, une condition ne présente un sens que si elle est difficile à prévoir et à remplacer par une autre instruction.

 1package main
 2
 3import (
 4    "os"
 5    "strconv"
 6)
 7
 8func main() {
 9    // Si l'on utilise quelque chose de prévisible et dont le branchement peut être supprimé, comme x = 10,
10    // le compilateur optimise et supprime le branchement.
11    // Ainsi, pour observer directement l'assembleur dans ce cas, 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    // Cette section traite de la programmation moderne, donc nous n'utilisons pas la méthode
14    // 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évoir l'entrée, l'instruction de branchement est traduite telle quelle 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 aimablement quelle instruction assembleur correspond à chaque instruction du code source.

Comme ce que nous allons apprendre concerne les comparaisons et les branchements if, il suffit de prêter attention à quelques lignes.

Instructions CMPQ et JL

L'instruction CMPQ est destinée à comparer des valeurs de type 4 octets (4 mots), et son nom vient de 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 la valeur hexadécimale 0x2 (c'est-à-dire simplement 2).

Ensuite, pour savoir si le nombre d'arguments est inférieur à 2 (c'est-à-dire si le programme ne reçoit que son propre nom comme argument), un saut conditionnel est effectué grâce à JL. Il s'agit de l'abréviation de Jump, Less than. Si, selon le résultat de cette comparaison, le nombre d'arguments est inférieur à 2, le programme saute à l'adresse 0x47a8b0, où se trouve une instruction 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, puis comment les données réelles sont extraites à partir de cette adresse.

Les adresses de la plage 0x47858-0x47863 réalisent cette opération par étapes.

Tout d'abord, l'adresse de début du tableau d'arguments est placée dans le registre CX par 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 éléments de 8 octets, soit 16 octets au total.

struct8 byte8 byte
stringmem addressstring length

Visuellement, cela se présente comme ci-dessus : les 8 premiers octets contiennent l'adresse de début de la chaîne, et les 8 suivants contiennent sa longueur.

Ainsi, l'adresse de la chaîne est stockée dans le registre AX, et sa longueur dans le registre BX.

CALL

Dans les articles précédents, lorsque nous examinions les fonctions de la famille runtime, les instructions étaient également précédées de CALL. Cela s'applique aux fonctions utilisées en Go ; CALL signifie littéralement « appeler une fonction ». Ensuite, la fonction appelée convertit la chaîne en entier, mais l'endroit où cet entier est stocké n'est pas visible, 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 avec le nombre 0xa (10 en décimal) !

Cela signifie que, comme le programme n'utilise plus cet argument, la valeur a été écrasée à l'emplacement de la chaîne afin de créer l'emplacement de la variable entière x.

C'est là la réalité de l'optimisation agressive réalisée dans des langages comme Go.

Vient ensuite l'instruction JGE, abréviation de Jump, Greater or Equals. Cette instruction teste donc si la valeur est supérieure ou égale à la valeur de comparaison.

Ainsi, cette instruction ne correspond pas exactement à x < 10 : le sens de la comparaison est inversé ! En langage machine, sauter immédiatement lorsque la condition n'est pas vérifiée est plus direct que d'exécuter la comparaison une première fois lorsque la condition est vérifiée, puis de vérifier à nouveau qu'elle n'est pas vérifiée ; cela permet de gagner une instruction.

Cette optimisation est très classique : elle apparaît fréquemment, même dans des compilateurs dont le niveau d'optimisation est assez faible, contrairement à l'exemple strconv.Atoi vu précédemment. Il est donc utile de la connaître.

Ainsi, en appliquant ce principe, on peut obtenir des sources différentes qui produisent un assembleur identique à 100 %.

Exemple de code miroir

Le script suivant permet de générer deux sources exactement miroir à 100 %, puis de vérifier, en excluant les métadonnées qui varient selon les cas, que l'assembleur obtenu pour main est strictement identique.

 1#!/usr/bin/env bash
 2
 3# 1. Réinitialisation complète des 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. Écriture 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. Écriture du code source de la version miroir (main_from_asm.go)
36# Synchronisation symétrique complète de la structure des opérateurs pour que le compilateur
37# reprenne le modèle d'optimisation (JGE) tel quel
38echo "[3/6] Generating main_from_asm.go..."
39cat << 'EOF' > main_from_asm.go
40package main
41
42import (
43        "os"
44        "strconv"
45)
46
47func main() {
48        if len(os.Args) < 2 {
49                return
50        }
51        x, _ := strconv.Atoi(os.Args[1])
52
53        s1 := "X is smaller than 10"
54        s2 := "X is larger or same as 10"
55
56        // En prenant 10 comme référence et en conservant la structure x < 10, le compilateur
57        // adopte exactement le même mécanisme JGE et la même disposition des blocs que main.go.
58        if x < 10 {
59                println(s1)
60        } else {
61                println(s2)
62        }
63}
64EOF
65
66# 4. Compilation des deux sources dans un environnement identique (même répertoire et 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 ..
70
71rm test_dir/main.go
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 section d'assembleur main.main pure avec 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# pour ne filtrer que le jeu d'instructions réellement exécuté par le CPU (Opcode & Operands)
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 de l'intégrité structurelle des instructions machine par diff
86echo "[6/6] Verifying assembly structural integrity via diff..."
87echo "------------------------------------------------------------"
88
89if diff orig_pure.asm asm_pure.asm > /dev/null; then
90    echo "===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <==="
91    echo "컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다."
92else
93    echo "===> [실패] 어셈블리 명령어 구조에 차이점이 발견되었습니다. <==="
94    diff -u orig_pure.asm asm_pure.asm
95fi
96echo "------------------------------------------------------------"

En exécutant réellement le script, 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 derrière ces abstractions se cachent des optimisations très intéressantes et agressives. En exploitant cette particularité, nous avons également pu créer des codes miroirs qui diffèrent par leur source mais produisent un assembleur identique. Si vous vous intéressez aux bas niveaux et que vous rencontrez un logiciel propriétaire écrit en Go, il ne paraît pas impossible de le désassembler pour analyser l'assembleur et reconstituer le code source.

Cours suivant

Lors du prochain cours, nous examinerons, outre l'instruction if, l'instruction select-case, qui présente également un intérêt certain.