Aufgabe 4: Kontextwechsel
Eigentlich könnten Übersetzer machen, was sie wollen, Speicher und Register beliebig verwenden, Hauptsache das resultierende Programm funktioniert im Anschluss. Das kann allerdings dann zu einem Problem werden, wenn wir ein Unterprogramm verwenden wollen, welches von einem anderen Übersetzer erstellt wurden – vielleicht wegen einer anderen Programmiersprache. Um Interoperabilität zu gewährleisten, müssen sich die Übersetzer an eine Aufrufkonvention halten.
Zum Beispiel wer welche Register bei einem Funktionsaufruf sichern muss: Der Aufrufende muss sich um die flüchtigen Register kümmern, und die aufgerufene Funktion um die nicht-flüchtigen – sofern sie diese denn überhaupt verwendet.
Aber welche Register sind nun was? Wir sind im Long Mode auf der 64 bit x86 Architektur, für uns spielen weder Segmentregister noch Kontroll- oder Debugregister eine Rolle beim Funktionsaufruf. Und da wir uns im Betriebssystem bewegen, ignorieren wir auch die SSE Register, sondern konzentrieren uns primär auf die Allzweckregister, und berücksichtigen Statusregister und Stapel- sowie Instruktionszeiger. Nach der System V ABI für 64 bit sind diese 10 blau eingefärbten Register flüchtig definiert, auch scratch oder caller-save genannt, müssen also von der aufrufenden Funktion zuerst gesichert werden. Die anderen Register sind nicht-flüchtig, natürlich mit Ausnahme der Sonderfälle Stapel- und Instruktionszeiger. Wenn ich nun die flüchtigen Register eh bei Verwendung sichern und später wieder herstellen muss, dann würden sich diese doch eignen darin im Anschluss Daten an die aufzurufende Funktion zu übermitteln? Und das wird auch gemacht: rdi
wird beispielsweise für den ersten und rsi
für den zweiten Funktionsparameter verwendet, während der Rückgabewert in rax
geschrieben wird.
Wie sieht das nun in der Praxis aus, bei einem Funktionsaufruf mit eben zwei Parametern? Schauen wir uns dazu den Aufruf von func
mit Parameter 23
und 42
an. Der resultierende Assembler Code für den Aufruf könnte in etwa wie folgt aussehen.
Dabei verwende ich hier die Intel-Syntax, wie sie auch von unserem Netwide Assembler verwendet wird – bei dieser wird zuerst das Ziel, dann die Quelle genannt. Im Gegensatz zur AT&T-Syntax, die genau anders herum ist, aber aufgrund zusätzlicher Zeichen leicht identifiziert werden kann, wie dem Prozentzeichen vor Registernamen. Letztere wird übrigens standardmäßig beim Inlineassembly und in objdump
verwendet.
Als Erstes werden – sofern nötig – flüchtige Register gesichert, beispielsweise r9
. Anschließend werden die Parameter in die entsprechenden Register geschrieben, 0x2a
entspricht 42, dem zweiten Parameter, und wird somit in rsi
geschrieben, 0x17
für 23 als ersten Parameter in rdi
.
Der Compiler generiert, wie hier zu sehen, für kleinere Zahlen innerhalb der 32 bit Grenze, ein mov
nach esi
statt rsi
– das ist valid, da dabei automatisch die oberen 32 bit von rsi
auf 0 gesetzt werden, der Aufruf hat also die gleiche Wirkung, es werden sich durch diese Variante jedoch ein paar Bytes im Maschinencode gespart. Das gilt aber übrigens nicht für ein 16 bit mov
nach 'si', da bleiben die oberen Bits dann unverändert, werden also nicht genullt... Wieso? Historisch gewachsen. Ja, x86 ist nicht einfach.
Dann der Funktionsaufruf, welcher implizit die Rücksprungadresse – also die Adresse der nächsten Instruktion nach dem call
auf den Stack schreibt. Der Einfachheit halber nehmen wir das Label L1
stellvertretend für eine explizite Adresse.
Wir erinnern uns: Der Stack wächst auf dem x86er nach unten, in Richtung der kleineren Adressen. Dabei wird bei einem push
zuerst der Wert des Stapelzeigers rsp
um die Adressbreite, bei 64 bit eben 8 Byte – verringert und danach an die neue Adresse der Wert geschrieben. Somit zeigt rsp
immer auf das letzte hinzugefügte – und noch nicht gepop
te – Datum. Sowohl Wachstumsrichtung als auch wohin der Stackpointer zeigt ist natürlich kein Naturgesetz, sondern hier eine Designentscheidung von Intel, die auf anderen Architekturen auch anders sein kann.
Außerdem muss die aktuelle Adresse des Stackzeigers bei einem Funktionsaufruf an einer 16 Byte Grenze ausgerichtet sein, also Adresse des Stackzeigers Modulo 16 muss direkt vor dem call
0 sein. Dies schreibt uns die System V ABI vor, die 16 Byte Ausrichtung werden von den SSE Instruktionen benötigt. Bei den ursprünglichen x86 Instruktionen selbst ist dies zwar nicht zwingend notwendig, allerdings können Zugriffe auf nicht ausgerichtete Adressen deutlich langsamer sein. Deshalb diese Entscheidung, welche unter Umständen halt paar Bytes verschwendet, aber dadurch Compiler bei Funktionen nicht extra Code einbauen müssen, der erst prüft, ob die Stackadresse ausgerichtet ist und das gegebenenfalls nachholen muss.
Auf unserem Stack liegt also der Inhalt des gesicherten Registers r9
, und die Adresse von L1
– und der Stackpointer rsp
zeigt auf diesen Eintrag, wenn in die Funktion gesprungen wird. Diese sichert zuerst den alten Rahmenzeiger (Basepointer) auf den Stack, bevor sie ihn auf den Wert des aktuellen Stackpointers setzt. Danach werden nun gegebenenfalls weitere, nicht-flüchtige Register auf dem Stack gesichert – sofern sie im Nachgang denn benutzt werden. Und die eigentliche Funktion ausgeführt.
Am Ende wird der Rückgabewert, sagen wir mal 0xd
, in das Register rax
(bzw. eben eax
) kopiert. Danach werden die zu Beginn gesicherten Register mittels pop
wiederhergestellt, rbx
und der Rahmenzeiger rbp
– somit zeigt der Stackpointer wieder auf die gleiche Adresse wie zum Beginn der Funktion. Zum Zurückkehren wird nun ret
aufgerufen, was die Rücksprungadresse L1
vom Stack nimmt und den Instruktionszeiger entsprechend setzt.
wir sind nun zurück, direkt beim nächsten Befehl nach dem call
. Hier steht zum Beispiel eine Sicherung des Rückgabewertes in ein anderes Register, sagen wir mal rsi
. Nun können noch zuvor gesicherte flüchtige Register wiederhergestellt werden, wie unser r9
.
Wenn ihr nun einen entsprechenden Code im aktuellen GCC übersetzt und das Kompilat anschließend durch objdump
jagt, werdet ihr sehen, dass ich hier die Instruktionen ein wenig vereinfacht hab: Zum einen wird Dank schlaueren Übersetzern nicht mehr zwingend der Basepointer verwendet, sondern die Berechnung von Zugriffen auf dem Stack kann in vielen Fällen auch ausschließlich anhand des Stackpointers durchgeführt werden – die GCC Option -fomit-frame-pointer
, welche dies erlaubt, ist heutzutage standardmäßig aktiviert, wodurch man ein weiteres der begehrten Allzweckregister zur Verfügung hat. Zum anderen findet man die pushund
popan dieser Stelle meist nicht so wieder, stattdessen steht dort ein mov
mit indirekter Adressierung, dem Stackpointer mit Versatz, also zum Beispiel 8 Byte vor dem aktuellen Stapelzeiger. Dazu steht am Anfang der Funktion auch ein Erweitern des Stackpointers, was den Platz reserviert und auch gleich eine korrekte Ausrichtung des Stacks für die weiteren Funktionsaufrufe sicherstellen kann. Am Ende der Funktion wird der Stackzeiger dann wieder zum ursprünglichen Zustand zurückgesetzt. Das grundlegende Prinzip bleibt aber das gleiche wie bei unserem Beispiel zuvor.