Cet article est une traduction et un ajout de Chapitre I: A Primer on Go Assembly.
Cet article suppose les personnes suivantes:
environnement
$ go version
go version go1.10 linux/amd64
La sortie de l'assembly par le compilateur Go est une abstraction et n'est pas mappée au matériel réel. L'assembleur Go traduit ce pseudo-assemblage dans un langage machine qui correspond au matériel cible.
Il peut être utile d'imaginer quelque chose comme du code d'octet Java.
Le plus grand avantage d'avoir une telle couche intermédiaire est qu'elle facilite l'adaptation aux nouvelles architectures. Pour plus d'informations, consultez [* The Design of the Go Assembler *] de Rob Pike (https://talks.golang.org/2016/asm.slide#1).
La chose la plus importante à savoir sur les assemblages Go est le fait que les assemblages Go ne correspondent pas directement au matériel cible. Certains sont directement liés au matériel, mais d'autres ne le sont pas. Cela élimine le besoin de l'assembleur Pass sur le pipeline, au lieu de cela, le compilateur peut gérer le pseudo-assemblage qui fait abstraction de ce matériel et la sélection d'instruction (dans ce cas, l'assembly Go à l'assembly réel). La conversion en) se fait désormais en partie après la génération du code (génération par le générateur de l'assembly Go). À titre d'exemple de pseudo-assemblage, l'instruction MOV de l'assemblage GO peut être convertie en instruction "clear" ou "load", etc., ou elle peut rester telle quelle (bien que le nom puisse changer) en fonction de l'architecture. Alors que les concepts architecturaux courants tels que le mouvement de la mémoire et les appels et retours de sous-programmes sont abstraits, les instructions spécifiques au matériel sont souvent représentées telles quelles.
L'assembleur Go est un programme qui analyse ce pseudo-assemblage et le convertit en instructions d'entrée dans l'éditeur de liens.
Considérez le code suivant.
//go:noinline
func add(a, b int32) (int32, bool) { return a + b, true }
func main() { add(10, 32) }
// go: noinline
pour empêcher l'expansion en ligne) *Compilons ce code dans un assembly.
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
0x0013 RET
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
0x002b PCDATA $0, $0
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
Dissecting add
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
--0x0000
: Représenté par rapport au début de la fonction de décalage de l'instruction.
--TEXT "". Add
: La directive TEXT
indique que le symbole" ".add
est contenu dans la section .text
et que les instructions suivantes sont le contenu de cette fonction.
La chaîne vide " "
est remplacée par le nom du package actuel au moment de la liaison. Cette fois, ce sera «main.add».
--(SB)
: SB
est un registre virtuellement défini dans l'assemblage Go, qui est un pointeur "Static-Base". Il représente le début de l'espace d'adressage du programme.
Le "" ". Add (SB) indique que le symbole "" ". Add
est à un décalage constant calculé par l'éditeur de liens depuis le début de l'espace d'adressage. En d'autres termes, il s'agit d'une fonction de portée globale avec une adresse fixe.
Vous pouvez le voir clairement avec objdump
.
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
supplément objdump
---j .text
Section de texte uniquement affichée
-- -t
Afficher la table des symboles
--000000000044d980 g F .text 000000000000000f main.add
Address 0x44d980
a un symbole de fonction global nommé main.add
Tous les symboles définis par l'utilisateur sont décrits comme des décalages par rapport aux pseudo-registres FP (local) et SB (global). Puisque le pseudo-registre SB peut être considéré comme l'origine de la mémoire, le symbole foo (SB) peut être considéré comme un symbole représentant l'adresse de foo.
--NOSPLIT
: indique au compilateur de ne pas insérer de préambule * stack-split * pour voir si la pile actuelle doit être développée.
Puisque la fonction add
n'a pas de variables locales et ne nécessite pas de cadres de pile, il n'est pas nécessaire d'étendre la pile actuelle, donc vérifier l'extension de pile à chaque fois que la fonction est appelée est un gaspillage de ressources CPU. Le compilateur le saura automatiquement et définira automatiquement cet indicateur NOSPLIT
. L'expansion de la pile est mentionnée plus loin dans la section Goroutine.
-- $ 0-16
: $ 0
représente le nombre d'octets de trame de pile alloués à cette fonction, 16
représente la taille de l'argument (+ valeur de retour) passé par l'appelant. (16 octets avec int 32 x 3 + booléen (aligné sur 4 octets))
Dans le cas général, la taille du frame de pile est suivie de la taille des arguments séparés par un signe moins. (Ce signe moins ne représente pas une soustraction) $ 24-8 indique que la fonction a un cadre de pile de 24 octets et sera appelée avec un argument de 8 octets qui existe dans le cadre de pile appelant. Lorsque NOSPLIT n'est pas spécifié pour TEXT, la taille de l'argument doit être spécifiée. Pour les fonctions d'assemblage qui utilisent le prototype Go, un vétérinaire vérifie si la taille de l'argument est correcte.
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
Les directives FUNCDATA et PCDATA contiennent des informations destinées à être utilisées par le GC.
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
La convention d'appel de Go permet à tous les arguments d'être passés à travers la pile en utilisant un espace pré-alloué dans le cadre de pile de l'appelant. Par conséquent, il est de la responsabilité de l'appelant de transmettre des arguments à l'appelé et de gérer la taille de la pile de manière appropriée afin que la valeur de retour de l'appelé soit renvoyée sous l'appelant.
Le compilateur Go ne génère pas d'instructions PUSH / POP. Au lieu de cela, la pile est étendue ou contractée en ajoutant ou en soustrayant SP, qui est un pseudo-registre qui pointe vers le haut de la pile.
[UPDATE: We've discussed about this matter in issue #21: about SP register.]
La pseudo-résistance SP est utilisée pour référencer des variables locales et des arguments. Puisque SP pointe vers le début de la trame de pile, la référence est faite en utilisant un décalage négatif dans la plage [-framesize, 0). par exemple x-8 (SP), y-4 (SP)
La documentation officielle indique que les symboles définis par l'utilisateur sont représentés par des décalages du registre FP, mais ce n'est pas le cas pour le code généré automatiquement. Les compilateurs Modern Go font toujours référence aux arguments et aux variables locales à un décalage par rapport au pointeur de pile. Cela permet au FP d'être utilisé comme registre à usage général supplémentaire sur les plates-formes avec un petit nombre de registres, comme x86. Voir * Disposition du cadre de la pile sur x86-64 * pour plus d'informations. [UPDATE: We've discussed about this matter in issue #2: Frame pointer.]
"". b + 12 (SP) "et" "" .a + 8 (SP) "se réfèrent respectivement aux adresses des 12 et 8 octets supérieurs de la pile. (Notez que la pile s'étend de l'adresse supérieure à l'adresse inférieure)
«.a» et «.b» sont des alias arbitraires donnés à l'emplacement de référence. Le nom n'affecte pas ce que vous faites, mais il est essentiel pour utiliser l'adressage indirect sur les registres virtuels.
Le document sur FP, qui est un pseudo pointeur de trame, dit ce qui suit.
FP est un pointeur de trame virtuel pour référencer des arguments de fonction. Le compilateur contient le contenu de ce registre et référence les arguments de la fonction sur la pile comme des décalages basés sur ce registre. En d'autres termes, dans l'architecture 64 bits, 0 (FP) fait référence au premier argument de la fonction et 8 (FP) fait référence au deuxième argument. Cependant, pour accéder aux arguments de cette façon, vous devez commencer par un nom, tel que first_arg + 0 (FP) ou second_arg + 8 (FP). (Le décalage de FP est différent du cas de SB, ce qui signifie le décalage du symbole.) L'assembleur n'accepte pas l'écriture sans nom telle que 0 (FP) et 8 (FP) et force cette spécification de nom. Faire. Le nom réel n'est pas pertinent pour ce que vous faites, mais il est utilisé pour documenter le nom de l'argument.
Enfin, il y a deux choses importantes.
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL
ajoute deux mots longs (valeurs de 4 octets) et stocke le résultat dans AX
. Ici, «AX» et «CX» sont ajoutés et le résultat est stocké dans «AX».
Le résultat est ensuite stocké dans "" ". ~ R2 + 16 (SP)` sur la pile pré-allouée pour que l'appelant reçoive la valeur de retour. Là encore, «" ". ~ R2» n'a aucune signification en termes de traitement du contenu.
Go prend en charge plusieurs valeurs de retour, donc dans cet exemple, la constante «true» est également renvoyée en tant que valeur de retour.
Comme pour la première valeur de retour, le résultat est stocké dans " ". ~ R3 + 20 (SP)
, bien que le décalage soit différent.
0x0013 RET
La pseudo-instruction finale «RET» est de demander à l'assembleur Go d'insérer l'instruction appropriée pour revenir du sous-programme sur le matériel ciblé.
Dans la plupart des cas, POP l'adresse de destination de retour stockée dans 0 (SP)
et y saute.
La dernière instruction du bloc TEXT doit être une instruction de saut (généralement avec RET) S'il n'y a pas d'instruction de saut, l'éditeur de liens ajoute une instruction pour sauter sur lui-même afin qu'il n'exécute pas l'instruction au-delà du bloc TEXT.
Comme beaucoup de grammaire et d'explications ont été publiées, j'écrirai un bref résumé.
;;Symbole de fonction globale"".Déclarer ajouter(Principal lors de la liaison.add)
;; stack-Ne pas insérer de préambule divisé
;;La trame de pile est passée à 0 octet, argument de 16 octets
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...omitted FUNCDATA stuff...
0x0000 MOVL "".b+12(SP), AX ;;Deuxième argument à AX à partir du cadre de pile appelant(b)Bouge toi
0x0004 MOVL "".a+8(SP), CX ;;Premier argument du frame de pile appelant à CX(a)Bouge toi
0x0008 ADDL CX, AX ;; AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;;Déplacer le résultat de l'addition stocké dans AX vers le frame de pile appelant
0x000e MOVB $1, "".~r3+20(SP) ;;constant`true`Vers le cadre de pile appelant
0x0013 RET ;; 0(SP)Aller à l'adresse de destination de retour stockée dans
La visualisation du contenu de la pile lorsque le traitement de «main.add» est terminé est la suivante.
| +-------------------------+ <-- 32(SP)
| | |
G | | |
R | | |
O | | main.main's saved |
W | | frame-pointer (BP) |
S | |-------------------------| <-- 24(SP)
| | [alignment] |
D | | "".~r3 (bool) = 1/true | <-- 21(SP)
O | |-------------------------| <-- 20(SP)
W | | |
N | | "".~r2 (int32) = 42 |
W | |-------------------------| <-- 16(SP)
A | | |
R | | "".b (int32) = 32 |
D | |-------------------------| <-- 12(SP)
S | | |
| | "".a (int32) = 10 |
| |-------------------------| <-- 8(SP)
| | |
| | |
| | |
\ | / | return address to |
\|/ | main.main + 0x30 |
- +-------------------------+ <-- 0(SP) (TOP OF STACK)
(diagram made with https://textik.com)
Dissecting main
Revoyons le contenu de la fonction main
.
func main() { add(10, 32) }
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0
Identique à la fonction add
. Cette fois, 24 octets sont sécurisés dans la trame de pile afin qu'aucun argument ne soit reçu et qu'aucune valeur de retour ne soit renvoyée.
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
Encore une fois, la convention d'appel de Go passe tous les arguments de fonction à travers la pile.
En soustrayant $ 24 octets de SP, main
réserve 24 octets pour sa propre trame de pile. (Notez que la pile s'étend vers le bas)
Utilisez ces 24 octets réservés comme suit.
--8 octets (16 (SP)
-24 (SP)
) sont utilisés pour stocker la valeur actuelle du pointeur de trame BP. Cela vous permet de rembobiner la pile (suivez la fonction sous l'appel), ce qui est utile lors du débogage. (MOVQ BP, 16 (SP)
)
-1 + 3 octets (12 (SP)
-16 (SP)
) est réservé pour recevoir la deuxième valeur de retour de la fonction add
(bool
vaut 1 octet mais amd64
+3 octets pour l'alignement architectural)
--4 octets (8 (SP)
-12 (SP)
) sont réservés pour recevoir la première valeur de retour de la fonction add
(int32
)
--4 octets (4 (SP)
-8 (SP)
) sont réservés pour la valeur de l'argument de la fonctionadd`` b (int32)
--4 octets (0 (SP)
-4 (SP)
) sont réservés pour la valeur de l'argument de la fonction add
a (int32)
Enfin, après l'allocation de la pile, «LEAQ» calcule la nouvelle adresse du pointeur de trame et la stocke dans «BP». (BP = 16 (SP) comme dans l'instruction x86 lea)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
L'appelant place l'argument de l'appelé sous la forme d'un mot quadruple de 8 octets en haut de la pile. À première vue, les valeurs placées peuvent sembler dénuées de sens, mais "137438953482" est une collection de 4 octets "10" et "32".
$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
32 10
Les 32-63 bits supérieurs de 137438953482 représentent «100000 (32)» et les bits inférieurs 0-31 représentent «00000000000000000000000000001010 (10)».
0x002b CALL "".add(SB)
Appelez la fonction add
avec l'instruction CALL
comme décalage relatif par rapport au SB.
Notez que «CALL» place une adresse de 8 octets en haut de la pile comme adresse de destination de retour, de sorte que tous les «SP» référencés dans la fonction «add» sont décalés de 8 octets vers le bas. Par exemple, "" ". A` est représenté par" 8 (SP) "au lieu de" 0 (SP) ".
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
Finalement,
MOVQ 16 (SP), BP
ADDQ $ 24, SP
Et terminer l'exécution de la fonction principale
Je pense que ce que vous faites via add
et main
est un appel de sous-programme général.
Si vous regardez l'assemblage pour Goroutine, vous serez familiarisé avec les instructions de gestion de pile.
Pour vous aider à comprendre ces modèles le plus rapidement possible, comprenons ce que nous faisons et pourquoi nous le faisons.
Stacks
Le nombre de Goroutines qui apparaissent dans votre programme Go dépend de la situation. Les programmes pratiques peuvent se chiffrer en millions. Le runtime de Go adopte une approche conservatrice pour sécuriser la pile Goroutine afin qu'elle ne manque pas de mémoire. Au départ, 2 Ko d'espace de pile sont alloués par le runtime pour tout Goroutine. (La pile est en fait allouée au tas en arrière-plan)
Lorsque Goroutine s'exécute, il peut nécessiter plus de mémoire que les 2 Ko initialement alloués. Dans ce cas, il peut détruire la pile et envahir d'autres zones de mémoire. Pour éviter un tel débordement de pile, le runtime réserve une pile deux fois plus grande qu'auparavant et y copie le contenu de la pile lorsque Goroutine est sur le point de dépasser la pile. Ce processus s'appelle * stack-split * et vous permet de gérer la taille de la pile de Goroutine de manière efficace et dynamique.
Splits
Pour que * stack-split * fonctionne, le compilateur insère des instructions au début et à la fin de chaque fonction qui peuvent provoquer un débordement de pile pour vous permettre de vérifier un débordement de pile.
Comme nous l'avons vu précédemment, cela est inutile pour les fonctions où le débordement de pile est peu probable, donc NOSPLIT
peut dire au compilateur qu'il n'est pas nécessaire d'insérer des instructions pour vérifier.
J'ai omis le code pour * stack-split * dans la fonction main
ci-dessus, mais regardons-le maintenant.
0x0000 TEXT "".main(SB), $24-0
;; stack-split prologue
0x0000 MOVQ (TLS), CX
0x0009 CMPQ SP, 16(CX)
0x000d JLS 58
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; stack-split epilogue
0x003a NOP
;; ...omitted PCDATA stuff...
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
--Au début de la fonction (prologue), Goroutine vérifie si la pile est épuisée, auquel cas il saute à la fin de la fonction (épilogue). --A la fin de la fonction (épilogue), il déclenche le processus d'expansion de la pile, après quoi il revient au début de la fonction (prologue).
Notez que ce prologue et cet épilogue continueront à tourner jusqu'à ce que la taille de la pile soit suffisamment grande.
Prologue
0x0000 MOVQ (TLS), CX ;; store current *g in CX
0x0009 CMPQ SP, 16(CX) ;; compare SP and g.stackguard0
0x000d JLS 58 ;; jumps to 0x3a if SP <= g.stackguard0
TLS
est un registre virtuel géré par le runtime qui a un pointeur vers le g
actuel. Il s'agit d'une structure de données qui retrace tous les états de Goroutine.
Vérifions la définition de g
à partir du code source du runtime.
type g struct {
stack stack // 16 bytes
//stackguard0 est le pointeur de pile à comparer avec Prologue
//Normalement, stackgurad0 est stack.lo+Devient un StackGuard, mais peut aussi être un StackPreempt pour déclencher la préemption
//Préemption:Le comportement d'un système informatique multitâche pour suspendre temporairement une tâche en cours d'exécution
stackguard0 uintptr
stackguard1 uintptr
// ...omitted dozens of fields...
}
Puisque «g.stack» est de 16 octets, «16 (CX)» est «g.stackguard0». Il s'agit du seuil de pile géré par le runtime, qui peut être comparé au pointeur de pile pour voir si Goroutine a utilisé l'espace de pile.
La pile se développe vers l'adresse inférieure, donc si SP <= stackguard0
, l'espace de pile est épuisé. Dans ce cas, le prologue passe à l'épilogue.
Epilogue
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
Le processus d'épilogue est simple: il suffit d'appeler la fonction d'extension de pile au moment de l'exécution pour étendre la pile et revenir au code du prologue.
Le "NOP" avant le "CALL" existe pour empêcher le code du prologue de sauter directement au "CALL". Selon la plate-forme, il peut être nécessaire d'expliquer assez profondément, donc je vais omettre l'explication, mais c'est une pratique courante de mettre une instruction NOP avant l'instruction CALL et d'y sauter. [UPDATE: We've discussed about this matter in issue #4: Clarify "nop before call" paragraph.]
Cette fois, je n'ai expliqué que la pointe de l'iceberg.
Le mécanisme d'extension de la pile est trop détaillé et complexe pour être expliqué ici, alors j'aimerais avoir un chapitre dédié si j'en ai l'occasion.
Cette fois, j'ai essayé d'expliquer Go Assembly en utilisant un exemple simple.
Nous approfondirons la mise en œuvre interne de Go dans les chapitres restants.
Recommended Posts