Dieser Artikel ist eine Übersetzung und Ergänzung von Kapitel I: Eine Einführung in die Baugruppe.
Dieser Artikel geht von folgenden Personen aus:
Umgebung
$ go version
go version go1.10 linux/amd64
Die vom Go-Compiler ausgegebene Assembly ist eine Abstraktion und nicht der tatsächlichen Hardware zugeordnet. Der Go-Assembler übersetzt diese Pseudo-Assembler in eine Maschinensprache, die der Zielhardware entspricht.
Es könnte hilfreich sein, sich so etwas wie Java-Bytecode vorzustellen.
Der größte Vorteil einer solchen Zwischenschicht besteht darin, dass die Anpassung an neue Architekturen erleichtert wird. Weitere Informationen finden Sie unter [* Das Design des Go Assembler *] von Rob Pike (https://talks.golang.org/2016/asm.slide#1).
Das Wichtigste, was Sie über Go-Assemblys wissen müssen, ist die Tatsache, dass Go-Assemblys nicht direkt der Zielhardware entsprechen. Einige sind direkt an die Hardware gebunden, andere nicht. Dadurch muss der Assembler die Pipeline nicht mehr übergeben. Stattdessen kann der Compiler die Pseudo-Assembly verarbeiten, die diese Hardware abstrahiert, und die Anweisungsauswahl (in diesem Fall die Go-Assembly zur eigentlichen Assembly). Die Konvertierung nach) erfolgt nun teilweise nach der Codegenerierung (der Generierung der Go-Assembly durch den Generator). Als Beispiel einer Pseudoanordnung kann der MOV-Befehl der GO-Anordnung in einen "Löschen" - oder "Laden" -Befehl usw. umgewandelt werden, oder er kann abhängig von der Architektur unverändert bleiben (obwohl sich der Name ändern kann). Während gängige Architekturkonzepte wie Speicherbewegungen und Aufrufe und Rückgaben von Unterprogrammen abstrahiert werden, werden hardwarespezifische Anweisungen häufig unverändert dargestellt.
Der Go-Assembler ist ein Programm, das diese Pseudo-Assembly analysiert und in Anweisungen zur Eingabe in den Linker konvertiert.
Betrachten Sie den folgenden Code.
//go:noinline
func add(a, b int32) (int32, bool) { return a + b, true }
func main() { add(10, 32) }
// go: noinline
Direktive um eine Inline Erweiterung zu verhindern) *Lassen Sie uns diesen Code zu einer Assembly kompilieren.
$ 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
: Wird relativ zum Beginn der Befehlsoffsetfunktion dargestellt.
--TEXT "". Add
: Die Direktive TEXT
gibt an, dass das Symbol" ".add
im Abschnitt .text
enthalten ist und dass sich die folgenden Anweisungen in dieser Funktion befinden.
Die leere Zeichenfolge "" "wird zum Zeitpunkt der Verknüpfung durch den aktuellen Paketnamen ersetzt. Diesmal wird es "main.add" sein.
--(SB)
: SB
ist ein virtuell definiertes Register in der Go-Assembly, bei dem es sich um einen "Static-Base" -Zeiger handelt. Es stellt den Beginn des Programmadressraums dar.
Das "". Add (SB) "zeigt an, dass das" ". Add" -Symbol einen konstanten Versatz aufweist, der vom Linker ab dem Beginn des Adressraums berechnet wird. Mit anderen Worten, es handelt sich um eine globale Bereichsfunktion mit einer festen Adresse.
Sie können dies deutlich mit "objdump" sehen.
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
objdump Ergänzung
---j .text
Nur Textabschnitt angezeigt
-- -t
Symboltabelle anzeigen
--000000000044d980 g F .text 000000000000000f main.add
Adresse 0x44d980
hat ein globales Funktionssymbol mit dem Namen main.add
Alle benutzerdefinierten Symbole werden als Offsets von den Pseudoregistern FP (lokal) und SB (global) beschrieben. Da das Pseudoregister SB als Ursprung des Speichers betrachtet werden kann, kann das Symbol foo (SB) als Symbol betrachtet werden, das die Adresse von foo darstellt.
--NOSPLIT
: Weist den Compiler an, keine Präambel * stack-split * einzufügen, um festzustellen, ob der aktuelle Stack erweitert werden muss.
Da die Funktion "add" keine lokalen Variablen enthält und keine Stapelrahmen erfordert, muss der aktuelle Stapel nicht erweitert werden. Daher ist die Überprüfung der Stapelerweiterung bei jedem Aufruf der Funktion eine Verschwendung von CPU-Ressourcen. Der Compiler erkennt dies automatisch und setzt automatisch das NOSPLIT-Flag. Die Stapelerweiterung wird später im Abschnitt Goroutine erwähnt.
-- $ 0-16
: $ 0
steht für die Anzahl der dieser Funktion zugewiesenen Stapelrahmenbytes, 16
für die Größe des vom Aufrufer übergebenen Arguments (+ Rückgabewert). (16 Bytes mit int 32 x 3 + bool (mit 4 Bytes ausrichten))
Im allgemeinen Fall folgt auf die Größe des Stapelrahmens die Größe der Argumente, die durch ein Minuszeichen getrennt sind. (Dieses Minuszeichen stellt keine Subtraktion dar.) $ 24-8 gibt an, dass die Funktion einen 24-Byte-Stapelrahmen hat und mit einem 8-Byte-Argument aufgerufen wird, das im aufrufenden Stapelrahmen vorhanden ist. Wenn für TEXT kein NOSPLIT angegeben ist, muss die Größe des Arguments angegeben werden. Bei Assembly-Funktionen, die den Go-Prototyp verwenden, überprüft go vet, ob die Argumentgröße korrekt ist.
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
Die Anweisungen FUNCDATA und PCDATA enthalten Informationen zur Verwendung durch den GC.
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Mit der Aufrufkonvention von Go können alle Argumente unter Verwendung des vorab zugewiesenen Speicherplatzes im Stapelrahmen des Aufrufers durch den Stapel geleitet werden. Daher liegt es in der Verantwortung des Aufrufers, Argumente an den Angerufenen zu übergeben und die Stapelgröße entsprechend zu verwalten, damit der Rückgabewert des Angerufenen unter dem Anrufer zurückgegeben wird.
Der Go-Compiler generiert keine PUSH / POP-Anweisungen. Stattdessen wird der Stapel erweitert oder verkleinert, indem SP addiert oder subtrahiert wird. Dies ist ein Pseudoregister, das auf die Oberseite des Stapels zeigt.
[UPDATE: We've discussed about this matter in issue #21: about SP register.]
Mit dem Pseudowiderstand SP werden lokale Variablen und Argumente referenziert. Da SP auf den Anfang des Stapelrahmens zeigt, wird die Referenz mit einem negativen Versatz im Bereich [-framesize, 0) erstellt. z.B. x-8 (SP), y-4 (SP)
Die offizielle Dokumentation besagt, dass benutzerdefinierte Symbole durch einen Versatz vom FP-Register dargestellt werden. Dies gilt jedoch nicht für automatisch generierten Code. Moderne Go-Compiler verweisen immer auf Argumente und lokale Variablen in einem Versatz zum Stapelzeiger. Dies ermöglicht die Verwendung des FP als zusätzliches Allzweckregister auf Plattformen mit einer kleinen Anzahl von Registern, wie z. B. x86. Weitere Informationen finden Sie unter * Stapelrahmenlayout auf x86-64 *. [UPDATE: We've discussed about this matter in issue #2: Frame pointer.]
". b + 12 (SP)" und ".a + 8 (SP)" beziehen sich auf die oberen 12- bzw. 8-Byte-Adressen des Stapels. (Beachten Sie, dass sich der Stapel von der oberen Adresse zur unteren Adresse erstreckt.)
".a" und ".b" sind willkürliche Aliase, die dem Referenzort zugewiesen werden. Der Name hat keinen Einfluss auf Ihre Arbeit, ist jedoch für die Verwendung der indirekten Adressierung in virtuellen Registern unerlässlich.
Das Dokument über FP, bei dem es sich um einen Pseudo-Frame-Zeiger handelt, lautet wie folgt.
FP ist ein virtueller Frame-Zeiger zum Referenzieren von Funktionsargumenten. Der Compiler enthält den Inhalt dieses Registers und verweist auf die Argumente der Funktion auf dem Stapel als Offsets basierend auf diesem Register. Mit anderen Worten, in der 64-Bit-Architektur bezieht sich 0 (FP) auf das erste Argument der Funktion und 8 (FP) auf das zweite Argument. Um jedoch auf diese Weise auf die Argumente zugreifen zu können, müssen Sie mit einem Namen beginnen, z. B. first_arg + 0 (FP) oder second_arg + 8 (FP). (Der Versatz von FP unterscheidet sich vom Fall von SB, dh vom Versatz zum Symbol.) Der Assembler akzeptiert keine unbenannten Schriften wie 0 (FP) und 8 (FP) und erzwingt diese Namensspezifikation. Machen. Der tatsächliche Name ist für Ihre Arbeit irrelevant, wird jedoch zur Dokumentation des Argumentnamens verwendet.
Schließlich gibt es zwei wichtige Dinge.
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL
fügt zwei Long-Wörter (4 Byte lange Werte) hinzu und speichert das Ergebnis in AX
. Hier werden "AX" und "CX" hinzugefügt und das Ergebnis in "AX" gespeichert.
Das Ergebnis wird dann in "". ~ R2 + 16 (SP) "auf dem vorab zugewiesenen Stapel gespeichert, damit der Aufrufer den Rückgabewert erhält. Auch hier hat "". ~ R2 "keine Bedeutung für die Verarbeitung von Inhalten.
Da Go mehrere Rückgabewerte unterstützt, wird in diesem Beispiel die Konstante "true" auch als Rückgabewert zurückgegeben. Wie beim ersten Rückgabewert wird das Ergebnis in "" gespeichert. ~ R3 + 20 (SP) ", obwohl der Offset unterschiedlich ist.
0x0013 RET
Die letzte Pseudoanweisung "RET" besteht darin, den Go-Assembler anzuweisen, die entsprechende Anweisung einzufügen, um von der Unterroutine auf der Zielhardware zurückzukehren.
In den meisten Fällen POP die in 0 (SP)
gespeicherte Rückgabezieladresse und springe dorthin.
Der letzte Befehl im TEXT-Block muss ein Sprungbefehl sein (normalerweise mit RET). Wenn keine Sprunganweisung vorhanden ist, fügt der Linker eine Anweisung hinzu, um zu sich selbst zu springen, damit die Anweisung nicht über den TEXT-Block hinaus ausgeführt wird.
Da viele Grammatiken und Erklärungen herausgekommen sind, werde ich eine kurze Zusammenfassung schreiben.
;;Globales Funktionssymbol"".Deklarieren Sie hinzufügen(Haupt beim Verknüpfen.add)
;; stack-Fügen Sie keine geteilte Präambel ein
;;Der Stapelrahmen wird mit 0 Byte und 16 Byte übergeben
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...omitted FUNCDATA stuff...
0x0000 MOVL "".b+12(SP), AX ;;Zweites Argument vom aufrufenden Stack-Frame an AX(b)Bewegung
0x0004 MOVL "".a+8(SP), CX ;;Erstes Argument vom aufrufenden Stack-Frame an CX(a)Bewegung
0x0008 ADDL CX, AX ;; AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;;Verschieben Sie das in AX gespeicherte Additionsergebnis in den aufrufenden Stapelrahmen
0x000e MOVB $1, "".~r3+20(SP) ;;Konstante`true`Zum aufrufenden Stack-Frame
0x0013 RET ;; 0(SP)Wechseln Sie zu der in gespeicherten Zieladresse
Die Visualisierung des Inhalts des Stapels nach Abschluss der Verarbeitung von "main.add" ist wie folgt.
| +-------------------------+ <-- 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
Lassen Sie uns den Inhalt der Hauptfunktion noch einmal überprüfen.
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
Gleich wie für die Funktion "Hinzufügen". Dieses Mal sind 24 Bytes im Stapelrahmen gesichert, so dass kein Argument empfangen und kein Rückgabewert zurückgegeben wird.
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
Wiederum ermöglicht die Aufrufkonvention von Go, dass alle Funktionsargumente durch den Stapel geleitet werden.
Durch Subtrahieren von $ 24 Bytes von SP reserviert main
24 Bytes für seinen eigenen Stapelrahmen. (Beachten Sie, dass sich der Stapel nach unten erstreckt)
Verwenden Sie diese reservierten $ 24 Bytes wie folgt.
16 (SP)
-24 (SP)
) werden verwendet, um den aktuellen Wert des Rahmenzeigers BP zu speichern. Auf diese Weise können Sie den Stapel zurückspulen (folgen Sie der Funktion unter dem Aufruf), was beim Debuggen hilfreich ist. ("MOVQ BP, 16 (SP)")
--1 + 3 Bytes (12 (SP)
-16 (SP)
) sind reserviert, um den zweiten Rückgabewert der Funktion add
zu empfangen ( bool
ist 1 Byte, aber amd64
) +3 Bytes für die architektonische Ausrichtung)
--4 Bytes (8 (SP)
-12 (SP)
) sind reserviert, um den ersten Rückgabewert der Funktion add
( int32
) zu erhalten.4 (SP)
-8 (SP)
) sind für den Wert des Arguments der add
-Funktion b (int32)
reserviert
--4 Bytes (0 (SP)
-4 (SP)
) sind für den Wert des Arguments der add
-Funktion a (int32)
reserviertSchließlich berechnet "LEAQ" nach der Stapelzuweisung die neue Adresse des Rahmenzeigers und speichert sie in "BP". (BP = 16 (SP) wie in x86 lea Anweisung)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
Der Aufrufer platziert das Argument für Angerufene als 8-Byte-Quad-Wort oben im Stapel. Die platzierten Werte mögen auf den ersten Blick bedeutungslos erscheinen, aber "137438953482" ist eine Sammlung von 4-Byte "10" und "32".
$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
32 10
Die oberen 32-63 Bits von 137438953482 repräsentieren "100000 (32)" und die unteren 0-31 Bits repräsentieren "000000000000000000000000001010 (10)".
0x002b CALL "".add(SB)
Rufen Sie die Funktion add
mit der Anweisung CALL
als relativen Offset zum SB auf.
Beachten Sie, dass CALL
eine 8-Byte-Adresse als Rückgabezieladresse am oberen Rand des Stapels platziert, sodass alle SP
s, auf die in der add
-Funktion verwiesen wird, um 8 Bytes nach unten verschoben werden.
Zum Beispiel wird "". A "als" 8 (SP) "anstelle von" 0 (SP) "dargestellt.
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
Schließlich,
Und beenden Sie die Ausführung der Hauptfunktion
Ich denke, was Sie durch "Hinzufügen" und "Haupt" tun, ist ein allgemeiner Unterprogrammaufruf.
Wenn Sie sich die Assembly für Goroutine ansehen, sind Sie mit den Anweisungen für die Stapelverwaltung vertraut.
Damit wir diese Muster so schnell wie möglich verstehen können, wollen wir verstehen, was wir tun und warum wir dies tun.
Stacks
Die Anzahl der Goroutinen, die in Ihrem Go-Programm angezeigt werden, hängt von der jeweiligen Situation ab. Praktische Programme können in Millionenhöhe sein. Die Laufzeit von Go verfolgt einen konservativen Ansatz, um den Goroutine-Stack so zu sichern, dass ihm nicht der Speicher ausgeht. Anfänglich werden von der Laufzeit 2 KB Stapelspeicher für jede Goroutine zugewiesen. (Der Stapel ist tatsächlich dem Heap im Hintergrund zugeordnet.)
Wenn Goroutine ausgeführt wird, ist möglicherweise mehr Speicher erforderlich als die ursprünglich zugewiesenen 2 KB. In diesem Fall kann der Stapel zerstört werden und in andere Speicherbereiche eindringen. Um einen solchen Stapelüberlauf zu verhindern, reserviert die Laufzeit einen Stapel, der doppelt so groß ist wie zuvor, und kopiert den Inhalt des Stapels darauf, wenn Goroutine den Stapel überschreitet. Dieser Prozess heißt * Stack-Split * und ermöglicht es Ihnen, die Stapelgröße von Goroutine effizient und dynamisch zu handhaben.
Splits
Damit * stack-split * funktioniert, fügt der Compiler am Anfang und am Ende jeder Funktion einige Anweisungen ein, die zu einem Stapelüberlauf führen können, damit Sie nach einem Stapelüberlauf suchen können. Wie wir bereits gesehen haben, ist dies für Funktionen nutzlos, bei denen ein Stapelüberlauf unwahrscheinlich ist. Daher kann NOSPLIT dem Compiler mitteilen, dass keine Anweisungen zum Überprüfen eingefügt werden müssen.
Ich habe den Code für * stack-split * in der obigen Hauptfunktion weggelassen, aber schauen wir uns das jetzt an.
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
Beachten Sie, dass dieser Prolog und dieser Epilog so lange wiederholt werden, bis die Stapelgröße groß genug ist.
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
ist ein virtuelles Register, das von der Laufzeit verwaltet wird und einen Zeiger auf das aktuelle g
hat. Dies ist eine Datenstruktur, die alle Zustände von Goroutine verfolgt.
Lassen Sie uns die Definition von g
aus dem Quellcode der Laufzeit überprüfen.
type g struct {
stack stack // 16 bytes
//stackguard0 ist der Stapelzeiger, der mit Prolog verglichen werden soll
//Normalerweise ist stackgurad0 stack.lo+Wird zum StackGuard, kann aber auch zum StackPreempt werden, um die Vorabfreigabe auszulösen
//Vorkaufsrecht:Das Verhalten eines Multitasking-Computersystems, eine laufende Aufgabe vorübergehend anzuhalten
stackguard0 uintptr
stackguard1 uintptr
// ...omitted dozens of fields...
}
Da "g.stack" 16 Bytes beträgt, ist "16 (CX)" "g.stackguard0". Dies ist der von der Laufzeit verwaltete Stapelschwellenwert, der mit dem Stapelzeiger verglichen werden kann, um festzustellen, ob Goroutine den Stapelspeicherplatz belegt hat.
Der Stapel wächst in Richtung der unteren Adresse. Wenn also "SP <= stackguard0" ist, ist der Stapelspeicherplatz belegt. In diesem Fall springt der Prolog zum Epilog.
Epilogue
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
Der Vorgang des Epilog ist einfach: Rufen Sie einfach zur Laufzeit die Stack-Erweiterungsfunktion auf, um den Stack zu erweitern und zum Prologcode zurückzukehren.
Das NOP
vor dem CALL
existiert, um zu verhindern, dass der Prologcode direkt zum CALL
springt. Abhängig von der Plattform kann es notwendig sein, ziemlich tief zu erklären, daher werde ich die Erklärung weglassen, aber es ist eine übliche Praxis, einen NOP-Befehl vor den CALL-Befehl zu stellen und dorthin zu springen.
[UPDATE: We've discussed about this matter in issue #4: Clarify "nop before call" paragraph.]
Diesmal habe ich nur die Spitze des Eisbergs erklärt.
Der Stapelerweiterungsmechanismus ist zu detailliert und komplex, um hier erklärt zu werden. Wenn ich die Gelegenheit dazu hätte, hätte ich gerne ein eigenes Kapitel.
Dieses Mal habe ich versucht, Go Assembly anhand eines einfachen Beispiels zu erklären.
In den verbleibenden Kapiteln werden wir uns eingehender mit der internen Implementierung von Go befassen.
Recommended Posts