Die neue Generation der Intel-CPUs bringt mit ihrer 64-Bit-Architektur eine Reihe interessanter technischer Neuerungen mit sich. Dieser Beitrag wirft einen Blick in die Stapel- und Registerverwaltung der IA-64-CPU.
| Ein kurzer Rückblick auf x86-Konventionen | |
| Wichtige IA-64-Register |
Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:
![]()
Angesichts der Informationsflut über das Microsoft .NET verliert man leicht aus den Augen, dass auch noch ein weiterer wichtiger Entwicklungsschritt auf Windows zu kommt. Ich meine natürlich die 64-Bit-Programmierung auf den IA-64-CPUs von Intel. In meinen nächsten beiden Kolumnen möchte ich näher auf die Parameterübergabe beim IA-64 eingehen, auf die Stapelrahmen und die Rückgabewerte. Aber keine Sorge, falls Sie noch keine Zeit hatten, sich mit der IA-64-Architektur zu beschäftigen. Um meinen Ausführungen folgen zu können, brauchen Sie die Architektur nicht gut zu kennen. Was Sie für diesen Artikel unbedingt wissen müssen, werde ich nebenbei auch erzählen.
Sollte ich Sie neugierig gemacht haben, so finden Sie die vollständige Dokumentation für den IA-64 auf der Website von Intel (http://www.intel.com/technology/magazine/computing/sw06001.pdf). Allerdings kann sich der Weg durch die sehr formale und präzise Definition des CPU-Verhaltens, wie sie die Dokumentation bietet, sehr zäh gestalten. So werden zum Beispiel Informationen, die eigentlich nur für die hartgesottenen Betriebssystementwickler wichtig sind, neben Grundlagenwissen präsentiert, das jedem geläufig sein sollte. Gelegentlich lässt sich nur schwer erkennen, worauf es eigentlich ankommt. Ich möchte Ihnen im Folgenden eine vereinfachte Darstellung der wichtigsten Fakten geben, die ich mir bei der Portierung des Kerns vom Compuware BoundsChecker auf das 64-Bit-Windows angeeignet habe.
Bevor ich auf Details vom IA-64 eingehe, scheint mir ein kurzer Rückblick auf die Parameterübergabe und die Stapelrahmen der x86-CPUs (Pentium) angebracht zu sein. Ich bin der Meinung, dass sich der IA-64 leichter verstehen lässt, wenn man ihn mit den bekannten Mechanismen der x86-Architektur vergleichen kann. Wer sich gut mit der x86-Programmierung auskennt, kann natürlich direkt zum nächsten Abschnitt springen.
In der x86-Welt werden Parameter im Wesentlichen über den Stapel übergeben. Wenn Sie sich den Assemblercode ansehen, den der Compiler für den Aufruf einer Funktion mit Parametern generiert, dann werden Sie im Normalfall für jedes übergebene Argument einen PUSH-Befehl vorfinden (sofern der Compiler keine speziellen Optimierungen vornimmt). Mit dem PUSH gelangen die aktuellen Argumente auf den Stapel. Sobald alle Funktionsargumente auf dem Stapel liegen, übergibt ein CALL-Befehl die Kontrolle an die Zielfunktion.
In manchen Fällen werden die Argumente nicht mit Hilfe des Stapels übergeben, sondern in den Registern. Welche Register dabei eingesetzt werden, hängt vom Compiler und von der Aufrufkonvention ab. Die fastcall-Konvention ist ein Beispiel für eine Aufrufkonvention, die Register einsetzt. Da der x86 aber nur relativ wenige Allzweckregister hat, werden für jede gegebene Funktion höchstens zwei Argumente in Registern übergeben. Außerdem gelten für die Parameterübergabe in Registern noch weitere Beschränkungen. Gleitkommawerte werden zum Beispiel so gut wie nie in den Allzweckregistern der CPU übergeben.
Für den Zugriff auf die Parameter und auf lokale Variablen braucht der Code einen Stapelrahmen. Der traditionelle x86-Stapelrahmen wird mit dem Register EBP eingerichtet, der auf den Rahmen verweist. Es ist üblich, den Stapelrahmen bereits am Anfang einer Funktion einzurichten, zum Beispiel mit folgender Codesequenz:
PUSH EBP MOV EBP,ESP SUB ESP, XX
Anschließend sieht der Stapelrahmen aus, wie in Bild B1 gezeigt. Wichtig ist nun der Punkt, dass die verschiedenen Teile des Stapelrahmens unter der Angabe ihrer relativen Positionen in Bezug auf das EBP-Register zugänglich sind. So ist unter EBP+0 zum Beispiel die Adresse des vorigen Stapelrahmens zu finden. Unter EBP+4 liegt die Rücksprungadresse der Funktion (also die Adresse des nächsten Befehls im Aufrufer, der auf den CALL der aktuell aufgerufenen Funktion folgt). Wie Sie später noch selbst feststellen können, ist dieser Umstand eine Betonung wert: In der x86-Architektur legt der CALL-Befehl die erforderliche Rücksprungadresse im Speicher ab - genauer gesagt, auf dem Stapel.

B1 Der Stapelrahmen in der bekannten x86-Architektur.
Die Parameter der Funktion sind von EBP aus gesehen an positiven Offsets zu finden. Der erste Parameter liegt an der Position EBP+8. Die Offsetwerte aller weiteren Parameter sind größer als acht und zudem Vielfache von vier (zum Beispiel 0xC, 0x10, 0x14 und so weiter). Sofern die Funktion lokale Variablen hat, sind diese an negativen Offsets von EBP zu finden, zum Beispiel bei EBP-0x10.
Regeln sind natürlich da, um übertreten zu werden. Kein Naturgesetz schreibt vor, dass eine x86-Funktion unbedingt einen Stapelrahmen wie den gerade beschriebenen haben muss. Im optimierten Code ist der EBP-Rahmen meistens verschwunden und die Zugriffe auf Parameter und lokale Variablen erfolgen mit positiven Offsets auf den Stapelzeiger ESP bezogen. Außerdem können lokale Variablen auch in Registern gehalten werden, müssen also nicht zwangsläufig auf dem Stapel liegen. Auch hier setzt die geringe Zahl an Allzweckregistern der Unterbringung der lokalen Variablen in Registern wieder enge Grenzen. Daher kann man sagen, dass die Parameter und die lokalen Variablen in der x86-Architektur zum Stapel tendieren. Das ist für den späteren Vergleich mit der IA-64-Architektur wichtig.
Zum Schluss möchte ich noch darauf eingehen, wie die Funktionsergebnisse an die Aufrufer übergeben werden. Der typische Integerwert, der in vier oder weniger Bytes passt, wird im Register EAX übergeben. Ein 8-Byte-Integer wird meistens im Registerpaar EDX:EAX übergeben, wobei EDX die oberen 32 Bits aufnimmt. Fließkommazahlen werden auf der Spitze des Fließkommaregisterstapels übergeben.
CPUs vom Typ IA-64 haben eine erstaunlich große Zahl von Registern. Da es hier aber nur um das Verständnis der Funktionsaufrufe, Stapelrahmen und Rückgabewerte geht, können Sie die meisten Register ignorieren und sich auf eine kleine Teilmenge dieser Register beschränken. So hat der IA-64 zum Beispiel 128 Allzweckregister, jedes 64 Bit breit. Diese Register lassen sich von der Konzeption her mit den Allzweckregistern vom x86 vergleichen, zum Beispiel mit EAX. Die Namen der Allzweckregister vom IA-64 beginnen mit einem r, gefolgt von der Registernummer. Somit ist r0 das erste dieser Allzweckregister und r127 das letzte.
Die ersten 32 Allzweckregister (r0-r31) sind statisch. Damit ist gemeint, dass der Code, der sich auf eines dieser Register bezieht, auch im Silizium immer genau das angesprochene Register benutzt. Das gilt auch für alle x86-Register, die in diesem Sinne ebenfalls als statisch eingestuft werden können.
Einige der statischen Register haben vordefinierte Aufgabenbereiche und werden daher im Allgemeinen anders angesprochen als über ihre r-Namen. Die beiden wichtigsten Register aus dieser Gruppe sind der globale Zeiger und der Stapelzeiger. Das Register r12 wird als Stapelzeiger benutzt und daher auch sp-Register genannt. Der globale Zeiger liegt im Register r1, das deswegen auch das gp-Register genannt wird. Wozu der globale Zeiger gebraucht wird, habe ich schon in meiner Kolumne in Heft 2/2001 beschrieben und möchte es daher an dieser Stelle nicht wiederholen.
Neben den 32 statischen Allzweckregistern hat der IA-64 auch 96 dynamische Allzweckregister (Bild B2). Dynamisch bedeutet in diesem Zusammenhang, dass ein gegebener Registername nicht immer dasselbe Register der CPU anspricht. So wird einem Register r34 in der einen Funktion höchstwahrscheinlich ein völlig anderes CPU-Register zugewiesen als dem Register r34 einer anderen Funktion.
B2 Die Allzweckregister des IA-64.
Verwirrt? Nun, das ist völlig in Ordnung. Ich habe auch einige Zeit gebraucht, um mich in die dynamische Zuweisung der Register und die Umbenennung der Register hineinzudenken. Allerdings ist die dynamische Umbenennung der Register ein wesentlicher Schritt auf dem Weg zu der Leistung, die man sich von einem IA-64 wünscht. Wenn Sie den Assemblercode eines IA-64 verstehen und lesen möchten (zum Beispiel in einem Debugger), lässt sich dieses Thema auch nicht vermeiden.
Einige Fragen drängen sich geradezu auf, zum Beispiel: "Wenn ich nun in einem dieser Register einen Wert ablege und sich der Name des Registers ändert - wie komme ich dann später an den gespeicherten Wert?" An dieser Stelle hilft der Hinweis weiter, dass sich die Verknüpfung eines Registernamens mit einem bestimmten CPU-Register innerhalb derselben Funktion nicht ändert. Register werden nur umbenannt, wenn die Kontrolle von einer Funktion an eine andere übergeht (Der IA-64 benennt Register unter bestimmten Umständen zwar auch in derselben Funktion um, aber das ist ein weiterführendes Thema, das wir vorläufig ignorieren können.).
Was ist der Sinn der dynamischen Register? Nun, im IA-64 dreht sich alles um die Geschwindigkeit. Häufig benutzte Werte aus dem Speicher herauszunehmen und in Registern abzulegen, ist eine Möglichkeit, den Ablauf der Berechnungen zu beschleunigen. Wenn ein Parameter auf dem Stapel übergeben wird und der fragliche Speicherabschnitt nicht im Cache der CPU steht, wird die CPU einige Dutzend Taktzyklen verschwenden, bis der Wert endlich vom langsamen Hauptspeicher eingelesen wurde. Im Gegensatz dazu ist der Zugriff auf ein Register immer in einem einzigen Taktzyklus durchführbar.
Dynamische Register gibt es deswegen, damit jede Funktion mit ihrem eigenen Registersatz aus bis zu 96 Registern arbeiten kann. Innerhalb einer Funktion sind die Register r32 bis r127 im Wesentlichen für die aktuelle Funktion reserviert. In den meisten Fällen werden sich alle lokalen Variablen und die Parameterwerte der Funktion in diesen 96 Registern unterbringen lassen. Natürlich braucht die Funktion nicht alle 96 Register zu benutzen, aber verfügbar sind sie jedenfalls.
Nun stellt sich natürlich die Frage, was eigentlich geschieht, wenn die aktuelle Funktion eine weitere Funktion aufruft. Wie werden die Werte in den dynamischen Registern der Elternfunktion gesichert? Nun, die seltsame Wahrheit ist, dass die dynamischen Register nicht explizit gesichert zu werden brauchen. Seltsam, aber wahr. Der große Zauberer hinter diesem Sachverhalt ist die Register Stack Engine, eine Besonderheit der IA-64-Architektur. Diese Registerstapelmaschine kann ziemlich verwirrend sein, wenn man sich nicht gerade zu den CPU-Freaks zählt. Daher möchte ich dieses Thema erst einmal auf später verschieben. Im Moment ist nur wichtig, dass jede Funktion die Register r32 bis r127 Dank der Registerstapelmaschine im Prinzip als ihr höchstpersönliches Privateigentum betrachten kann.
Neben den 128 Allzweckregistern hat der IA-64 auch noch 128 Fließkommaregister zu bieten, die auf die Namen f0 bis f127 hören (vermutlich hat jemand viel Geld dafür erhalten, sich diese Namen auszudenken). Die Fließkommaregister sind jeweils 82 Bit lang und können somit auch ein long double von C++ aufnehmen. Wie bei den Integerregistern haben auch einige Fließkommaregister einen vordefinierten Aufgabenbereich. So wird das Register r0 zum Beispiel immer auf 0.0 gesetzt, während das Register f1 immer die Zahl 1.0 enthält.
Die letzte Registergruppe, die Sie kennen sollten, sind die Sprungregister (branch register). Der IA-64 definiert acht Sprungregister mit den Namen b0 bis b7. Diese 64-Bit-Register enthalten die Adressen von bestimmten Codeabschnitten, zu denen die CPU springen könnte. Auf dem IA-64 haben solche "Verlagerungen des Kontrollflusses" immer die Form einer "Verzweigung" (branch). Der Befehl br.call entspricht dem CALL vom x86. Der Befehl br.ret entspricht dem x86-RET und ein einfacher br-Befehl wirkt wie der JMP vom x86 (Der Einfachheit halber werde ich bei der Bezeichnung "Sprung" bleiben.).
Im Gegensatz zum x86 kann der IA-64 nicht einfach eine beliebige Adresse anspringen oder aufrufen, die in ein Allzweckregister geladen wird oder irgendwo im Speicher liegt. Statt dessen muss die betreffende Adresse in eines der Sprungregister geladen werden, bevor ein passender Sprungbefehl ausgeführt wird. Das benutzte Sprungregister wird im fraglichen Sprungbefehl als Argument angegeben. Es gibt zwar auch Sprünge, die ohne Sprungregister möglich sind, aber diese Sprünge erfolgen dann immer auf Adressen, die sich auf den Befehlszeiger beziehen.
Das war's schon in diesem kurzen Beitrag. Im nächsten Heft möchte ich auf die Einzelheiten der Prozedurrahmen und der Konventionen zur Parameterübergabe eingehen. Anschließend möchte ich darauf eingehen, was bei der Rückkehr zum Aufrufer geschieht. Zum Abschluss dieser kleinen Artikelserie möchte ich dann noch auf die Funktionsweise der Registerstapelmaschine eingehen (die Register Stack Engine).