StuBS
x86-ABI: Register und Stacklayout

Erinnerung: Wir gehen hier davon aus, dass der NASM-Assembler verwendet wird. Er erwartet Befehle im Format: OP Ziel, Quelle.

Für die Threadumschaltung in Software muss der Zustand des Prozessors gesichert werden, um ihn später wiederherzustellen und die Ausführung des Programms an dieser Stelle fortzusetzen. Was zum Zustand des Prozessors gehört, ist höchst abhängig von der verwendeten Architektur und muss gegebenenfalls genau in der ABI-Dokumentation recherchiert werden. Wir verwenden in Betriebssystembau die x86-Architektur und auch davon nur einen kleinen Teil, d.h. wir werden uns nur auf die wesentlichen Register konzentrieren.

Ein Application Binary Interface (ABI) im Allgemeinen ist eine Aufrufkonvention, die sowohl von der Architektur als auch vom Compiler und Programmiersprache festgelegt wird. Sie regelt, wie Parameter an Funktionen übergeben werden und wie Strukturen im Speicher abgelegt sind. Die Konvention dient als Schnittstelle sowohl zwischen separat kompiliertem Code (ggf. verschiedene Compiler), als auch zwischen händisch geschriebenen Assembler-Funktionen und generiertem Code. Besonders wichtig ist eine ABI bei der Verwendung von geteilten Bibliotheken, da diese vorkompiliert vorliegen und daher nur mit einer spezifischen ABI benutzt werden können. Auch die in unserem Fall verwendeten Object-Dateien brauchen eine ABI, da auch der Linker mit vorkompilierten Objekten arbeitet (anders als geteilte Bibliotheken, die erst zur Ladezeit in den Adressraum geladen werden). Die C-ABI hat sich weithin durchgesetzt (hauptsächlich aus historischen Gründen) und kann von vielen anderen kompilierten Sprachen aus angesprochen werden. Sie ist damit zur Standardschnittstelle zwischen verschiedenen Programmiersprachen geworden. Auch wir wollen sie im Folgenden verwenden, um zwischen Assembler- und C++-Code zu vermitteln.

Register

x86 verfügt über 8 General Purpose Register, ein Flags-Register (eflags) und den Instruction Pointer (eip). Die General Purpose Register sind die Folgenden. Sie hatten ursprünglich einen festen Verwendungszweck und haben daher entsprechende Namen. Die meisten Register lassen sich mittlerweile aber für quasi alle Instruktionen verwenden:

  • eax (Akkumulator für manche Arithmetik-Instruktionen)
  • ebx (früher Base)
  • ecd (Counter bei Schleifen)
  • edx (Datenregister)
  • esi (Source Index für String-Operationen)
  • edi (Destination Index für String-Operationen)
  • ebp (Base Pointer)
  • esp (Stack Pointer)

x86 begann als 16-Bit Mikroprozessor-Familie und verfügte zunächst über ax, bx, cx, dx, si, di, bp und sp, die jeweils 16\,Bit breit waren. Weiter konnten die Register in das untere und das obere Byte geteilt werden also bspw. in ah und al, wenn 8 Bit-Genauigkeit nötig war. Viele Instruktionen vom 8086 konnten nur mit bestimmten Registern durchgeführt werden.

Im 32-Bit (protected) Mode wurden die Register um 16 Bit erweitert, sodass ax ein Teil von eax geworden ist. Viele Instruktionen wurden derart übernommen, dass sie in der Verwendung ihrer Register freier wurden. Die Registerteilung kann auch heute noch vorgenommen werden, d.h. eax kann in ax geteilt werden, und dieses wiederum in ah und al, falls nötig.

esi und edi haben spezielle Bedeutung bei String-Operationen. esp ist für den Stack wichtig, weil der Stack-Pointer auf den letzten Eintrag des Stacks zeigt.

Flüchtige vs. nicht-flüchtige Register

In Programmen sind Aufrufe von Unterfunktionen ein üblicher Mechanismus. Bei einem Call unterscheidet man dann zwischen der aufrufenden Funktion (Aufrufer/Caller) und der aufgerufenen Funktion (Aufgerufener/Callee). Beide, Caller und Callee, brauchen Register um ihre Arbeit zu verrichten. Der Aufrufer allerdings erwartet oftmals in seinen Registern den gleichen Wert vor und nach dem Call. D.h. für ihn soll der Call in Bezug auf die Register transparent sein (eine Ausnahme ist das eax-Register, in dem der Rückgabewert übergeben wird). Idealerweise wollen wir also zwei völlig getrennte Sätze an Registern für jeden Funktionsruf.

Leider sind die Register hardwareseitig beschränkt, weswegen der übliche Weg ist, Register vor dem Call auf den Stack zu sichern und nach dem Call wiederherzustellen. Diese Sicherung kann auf Seiten des Aufrufers, also vor bzw. nach der call-Instruktion (Caller-Saved, flüchtig in Bezug auf den Aufrufer) oder auf Seiten des Aufgerufenen (Callee-Saved, nicht-flüchtig in Bezug auf den Aufrufer) passieren. Dieser würde als erstes die Registerwerte auf den Stack speichern und als letztes, vor der ret-Instruktion die Register wiederherstellen.

Die x86-ABI sieht es vor, die Register in zwei Kategorien aufzuteilen. Die flüchtigen Register sind eax, ecx, edx und eflags, alle anderen sind nicht-flüchtig. D.h.: Wenn der Aufrufer in eax, ecx, edx und eflags weiterrechnen will, muss er sie selbst wegspeichern, bei allen anderen Registern übernimmt das die aufgerufene Funktion, falls es die Register benötigt.

Parameterübergabe über den Stack

Bei x86 werden die Parameter an Funktionen über den Stack übergeben. Nehmen wir als Beispiel folgende Funktion, die zwei Parameter erwartet:

int foobar ( int a, int b ) {
// placeholder
return a+b;
}

Eine rufende Funktion legt die Parameter b und a (also in umgekehrter Reihenfolge) auf den Stack. Dann wird mithilfe der Instruktion call die Funktion aufgerufen. Dabei wird die Rücksprungadresse auf den Stack gelegt und an die Adresse gesprungen, an der die Funktion beginnt.

Die rufende Funktion könnte den Funktionsruf an foobar also folgendermaßen umsetzen:

caller:
// ...
// Annahme in eax sei a
// in ebx sei b
push ebx;
push eax;
call foobar;
// ...

Bei der ersten Instruktion von foobar sieht der Stack also folgendermaßen aus (der Stack wächst bei x86 nach unten, d.h., von hohen Adressen zu niedrigen Adressen):

0xff
...
b
a
Rücksprungadresse<- esp
...
0x00

Will die aufgerufene Funktion auf die Parameter zugreifen, müssen die Werte vom Stack geholt werden, bspw. indirekt über den Stack-Pointer. In x86-Assembler kann die mov-Instruktion einen Zeiger mit einem Offset versehen und die daraus resultierende Adresse laden. Dies kann mit dem esp gemacht werden, bspw. so:

foobar:
// esp zeigt auf die _unterste_ Stelle der Rücksprungadresse
// Adressen sind auf x86 4 Byte lang
mov eax, [esp+4]; // eax enthält nun a
mov ebx, [esp+8]; // ebx enthält nun b
// ...

Adressen auf x86 sind 4 Byte groß und der esp zeigt auf die Rücksprungadresse. Also ist esp die Rücksprungadresse, esp+4 der erste Parameter und esp+8 der zweite Parameter, wenn beide Parameter 32 Bit groß sind.

Sollen jetzt nicht-flüchtige Register verwendet werden, müssen sie von der Funktion auf den Stack gespeichert werden. Dann verschiebt sich allerdings der esp! Die flüchtigen Register wurden bereits (soweit nötig) von der rufenden Funktion gesichert.

Rückgabewerte

Rückgabewerte werden bei x86 über das Register eax übergeben. Also muss am Ende der Funktion – zum Zeitpunkt der Ausführung der ret-Anweisung – der Rückgabewert im Register eax stehen. Wenn die rufende Funktion einen Rückgabewert erwartet, wird sie dieses Register auslesen. Ist kein Rückgabewert gesetzt worden, wird der zuletzt gesetzte Wert gelesen.

Bspw. könnte der Rücksprung in der Funktion foobar so aussehen:

foobar:
// ...
add eax, ebx // eax = eax + ebc
ret

Nach der Rückkehr von foobar wird die aufrufende Funktion den Stack aufräumen:

caller:
// ...
call foobar
add esp, 8

Die Instruktionen call und ret haben Einfluss auf den Kontrollfluss. Neben dem eigentlichen Sprung in die Funktion legt call die Rücksprungadresse auf den Stack ab. ret erwartet die Rücksprungadresse als obersten Wert auf dem Stack, entfernt sie vom Stack und springt an die angegebene Position. Die nach dem call folgende Instruktion wird als nächstes ausgeführt.