Friedrich-Alexander-Universität Erlangen-Nürnberg  /   Technische Fakultät  /   Department Informatik
C++ Einf├╝hrung f├╝r Betriebssytembau

Dies ist bei weitem keine vollst├Ąndige Einf├╝hrung in C++, sondern ausschlie├člich eine Behandlung einzelner Themenkomplexe, die f├╝r dieses Fach von Bedeutung sind. Die Gebiete werden nicht nur in ihrer Verwendung erl├Ąutert, sondern auch in ihrer technischen Auswirkung. Die einzelnen Mechanismen sollen also entmystifiziert werden. Dies wird anhand von C++-Codebeispielen, Terminaloutputs und Assemblerlistings geschehen.

Eine gute Quelle f├╝r ein umfassenderes C++ Tutorial ist cplusplus.com.

Objekt vs. Pointer vs. Referenz

Jede Variablendefinition reserviert ein St├╝ck Speicher und gibt diesem Speicher einen Namen und einen Datentypen. ├ťber den Namen kann der Speicher im Programm angesprochen werden. Der Datentyp gibt an, wie dieser zu interpretieren ist. Dazu wollen wir uns anschauen, was der Compiler (g++ -m32 -fno-PIE test.c -o test) aus folgendem St├╝ck Code macht:

int ganzzahl;
double fliesskomma;
int main(void) {
ganzzahl += 1;
fliesskomma += 1;
return 0;
}

Zun├Ąchst k├Ânnen wir uns mit dem Tool nm ansehen, welche globalen Objekte mit welchen Adressen und Gr├Â├čen angelegt wurden (... zeigt eine Auslassung an):

$ nm --numeric-sort -S test
...
0000052d 0000001b T main
...
00002020 00000008 B fliesskomma
00002028 00000004 B ganzzahl
...

Aus dieser Ausgabe sehen wir, dass die beiden Variablen im BSS-Segment (B) angelegt wurden, und 4 bzw. 8 Byte gro├č sind. Die grundlegende Eigenschaft des BSS-Segments ist es, dass der Speicher der Variablen beim Programmstart mit 0 initialisiert wird. Das passiert, weil beides Variablen sind und bei der Definition keinen Wert zugewiesen bekommen haben. Wir sehen also, dass der Name ganzzahl einen Speicherbereich bezeichnet, der an 0x2028 liegt und 4 Byte gro├č ist. Allerdings haben wir im fertig ├╝bersetzten Programm keinerlei Informationen mehr dar├╝ber, von welchem Datentyp dieser Speicher ist. Diese Information wurde nur vom Compiler benutzt, um den passenden Bin├Ąrcode zu erzeugen. Dies ist eine der Stellen, an der zu Tage tritt, dass C und C++ keine typsichere Sprache ist, da man den Speicher, der als fliesskomma bekannt ist, auch v├Âllig anders verwenden k├Ânnte. Schauen wir uns nun den erzeugten Assembler an:

$ objdump -d test
...
000052d <main>:
52d: 83 05 28 20 00 00 01 addl $0x1,0x2028 # Addiere Konstante zur Speicherstelle
534: d9 e8 fld1 # Lege eine 1 auf float-stack
536: dc 05 20 20 00 00 faddl 0x2020 # Addiere Speicherstelle auf float-stack
53c: dd 1d 20 20 00 00 fstpl 0x2020 # Speichere top-of(float-stack) in Speicherzelle
542: b8 00 00 00 00 mov $0x0,%eax # Kopiere Konstante zum Register
547: c3 ret

Wie wir sehen, steht an der Adresse 0x52d, die wir schon aus der nm-Ausgabe kennen, der Code der Funktion main. Hier sind unsere drei Zeilen relativ gut zu erkennen. Zun├Ąchst inkrementieren wir ganzzahl um 1, ohne den Umweg ├╝ber ein Register zu nehmen. Dann passiert dasselbe Spiel mit der Flie├čkommazahl unter Zuhilfename der x86-Flie├čkommaeinheit, die einen eigenen Stack von Flie├čkommazahlen verwaltet. Anschlie├čend schieben wir den Wert 0 in das Register eax, in dem der Aufrufer von main den R├╝ckgabewert erwartet, und kehren mittels ret zur├╝ck.

Meistens reicht es jedoch nicht, Objekte immer bei ihrem Namen zu nennen, da dies doch sehr unflexibel ist. In unserem Beispiel sind die Adressen unserer beiden Variablen beispielsweise direkt in den Maschinencode eingewoben. Viel sch├Âner w├Ąre es doch, wenn wir eine Indirektion einbauen w├╝rden und einer Funktion einen Namen ├╝bergeben, anstatt das Objekt selbst. Dies ist mittels einem Zeiger/Pointer m├Âglich:

void inkrement(int * zahl) {
*zahl = *zahl + 1;
}

Hier ├╝bergeben wir der Funktion einen Zeiger auf einen Speicherbereich, der einen Integer beinhaltet. Immer wenn wir auf den Speicher zugreifen wollen, m├╝ssen wir den Zeiger dereferenzieren (*zahl), um den eigentlichen Wert zu erhalten. Schauen wir uns nun den Assembler an:

00000548 <_Z9inkrementPi>:
548: 8b 44 24 04 mov 0x4(%esp),%eax # Lade das erste Argument nach %eax
54c: 83 00 01 addl $0x1,(%eax) # Addiere 1 auf die Speicherzelle auf %eax zeigt
54f: c3 ret

Zun├Ąchst f├Ąllt auf, dass unsere Funktion einen sehr komischen Namen hat. Dies liegt am C++-Name-Mangling, bei dem die Parametertypen (in diesem Fall also void und int *) in den Namen der Funktion einkodiert werden. Im ersten Befehl laden wir den Pointer zahl vom Stack, auf dem die Argumente ├╝bergeben werden, in das Register %eax. Die n├Ąchste Zeile verwendet die Adressierungsart ÔÇ×Register-IndirektÔÇť ((%eax)), um auf die dahinterliegende Speicherstelle zuzugreifen und diese zu inkrementieren. Bei diesem Beispiel hat der Aufrufer der Funktion Speicherplatz auf dem Stack angelegt, der innerhalb der Funktion unter dem Namen zahl bekannt ist und als int * interpretiert wird. W├╝rden wir also unsere Funktion mit inkrement(&ganzzahl) aufrufen, so h├Ątte das Register %eax in unserem Beispiel den numerischen Wert 0x2020.

Allerdings haben Pointer keine Garantien dar├╝ber, ob sie gerade auf ein valides/vorhandenes St├╝ck Speicher zeigen oder ob ihr Wert v├Âlliger Bogus ist. Ein h├Ąufig verwendete Art, einen Fehler anzuzeigen, ist es beispielsweise, den Nullpointer zur├╝ckzugeben. Dieser hat auch tats├Ąchlich den numerischen Wert 0 (daher der Name).

In den meisten Umgebungen f├╝hrt das Dereferenzieren des Nullpointers zu einer Ausnahme. In unserem Fall ist dies allerdings nicht so, da wir das Betriebssystem sind und die Maschine zun├Ąchst nicht dazu konfiguriert haben, den Wert 0 als einen invaliden Zeiger zu behandeln.

C++ bietet noch eine weitere M├Âglichkeit, Objekte zu adressieren: Referenzen. Eine Referenz ist zweiter Name bzw. ein Alias f├╝r ein Objekt. Dies bedeutet auch, dass sich hinter einer Referenz immer ein valides Objekt befindet.

Compiler implementieren das Sprachkonzept ÔÇ×ReferenzÔÇť meist auf der Basis von Pointern. Ihnen steht allerdings frei, in manchen Situationen andere M├Âglichkeiten zu verwenden.

void inkrement_ref(int &zahl) {
zahl = zahl + 1;
}

Wie wir sehen, m├╝ssen wir die Referenz, die durch das & angezeigt wird, nicht dereferenzieren, da sie ein Alias des referenzierten Objekts ist. Der Name zahl tut so, als w├Ąre es direkt das Objekt, auf das die Referenz zeigt. Wenn wir den dazu geh├Ârigen Assembler betrachten, sehen wir, dass der Compiler in diesem Fall die Abbildung auf Pointer gew├Ąhlt hat und exakt der gleiche Assembler entsteht.

00000550 <_Z13inkrement_refRi>:
550: 8b 44 24 04 mov 0x4(%esp),%eax
554: 83 00 01 addl $0x1,(%eax)
557: c3 ret

struct, class und Methoden

Nun ist C++ daf├╝r bekannt, dass es (auch) objektorientierte Programmierung erlaubt. Der erste Schritt in diese Richtung ist bereits in C unternommen worden, n├Ąmlich die M├Âglichkeit, mehrere Variablen unterschiedlichen Typs in einem Verbunddatentyp zu organisieren. Mit der folgenden Strukturdefinition kann man Objekte vom Typen foobar erzeugen und herumreichen (zum Beispiel per Pointer oder per Referenz).

struct foobar {
int foo;
double bar;
};

Zus├Ątzlich dazu kann man in C++ nun Methoden innerhalb einer Struktur definieren, die auf den Daten der Struktur arbeiten. Dabei werden die in der Funktion verwendeten Namen zun├Ąchst in der umgebenden Struktur gesucht, bevor der Compiler annimmt, dass es sich um eine globale Variable handelt.

struct foobar {
int foo;
double bar;
int calc(int x);
};
int foobar::calc(int x) {
return (foo * 23) + x;
}

In unserem Beispiel sehen wir, wie zun├Ąchst eine Methode calc deklariert wird und danach au├čerhalb der C++-Klasse definiert (mit Leben gef├╝llt) wird. Es funktioniert auch, dass man eine Methode direkt in einer Klasse/Struktur definiert, allerdings ist die Trennung von Interface und Implementierung in vielen F├Ąllen sch├Âner. Die Methode calc() greift auf das Feld foo zu. Dies bedeutet, dass die Methode, die wir hier sehen, irgendwie einen impliziten Parameter haben muss, der auf ein konkretes Objekt zeigt, in dessen Kontext calc() ausgef├╝hrt wird. Dieser implizite Parameter ist unter dem Namen this verf├╝gbar und ist ein Pointer auf das Objekt (foobar *). Dies bedeutet auch, dass this->foo eine explizite Form ist, um auf foo zuzugreifen. Schauen wir uns nun den erzeugten Assembler an:

0000055a <_ZN6foobar4calcEi>:
55a: 8b 44 24 04 mov 0x4(%esp),%eax # Impliziter Paramter: this
55e: 6b 00 17 imul $0x17,(%eax),%eax # Multiplikation mit 23
561: 03 44 24 08 add 0x8(%esp),%eax # Expliziter Paramter: x
565: c3 ret

Im Assembler sehen wir hier, dass die gesamte Abstraktion, die durch die C++-Objektorientierung eingef├╝hrt worden ist, (in diesem Fall) vollst├Ąndig zusammenf├Ąllt. Der this-Pointer wird einfach als erstes Argument im Assembler ├╝bergeben und alle anderen Parameter (x == 0x8(%esp)) rutschen um eins nach hinten. In diesem Fall w├Ąre dies ├Ąquivalent zu einer Funktionssignatur int calc(foobar * this, int x). Was wir au├čerdem sehen k├Ânnen, ist, dass der Zugriff auf foo eine direkte Dereferenzierung des this-Pointers ist: (%eax).

Allerdings ist die Kopplung von Code und Daten nicht das einzige Merkmal objektorientierter Programmierung. Oft ist damit auch die M├Âglichkeit zur Vererbung gemeint. Das bedeutet, dass eine Klasse von einer anderen Klasse erben kann und diese um Daten und Funktionalit├Ąt erweitert.

struct foobar_premium : public foobar {
bool is_premium() {
foo -= 1;
return true;
};
};

Hier sehen wir eine Klasse foobar_premium, die von foobar ÔÇ×publicÔÇť erbt. Dadurch k├Ânnen ihre Methoden auf die geerbten Felder zugreifen und neue Funktionalit├Ąt anbieten. Dies ist sehr ├Ąhnlich zu den Mechanismen in Java, mit einigen wichtigen Unterschieden:

  • Methoden sind per default nicht virtual (was ├Ąquivalent zu java final ist). Es wird daher nicht automatisch ÔÇ×weitervererbtÔÇť.
  • Mehrfachvererbung ist m├Âglich.
  • Objekte in C++ k├Ânnen sowohl auf dem Stack als auch auf dem Heap angelegt werden.
  • Die Sichtbarkeit der Vererbung kann einstellt werden (Man kann mit private alles erben, aber anderer Code sieht davon nichts).

Nun stellt sich die Frage, wieso hier immer von Klassen geredet wird, wo doch im Code st├Ąndig struct steht. Der interessierte Studierende wei├č doch, dass es auch class gibt. Der Punkt ist, dass die beiden Schl├╝sselw├Ârter beinahe ├Ąquivalent sind. Der einzige Unterschied ist, dass in structs alle Felder per default public sind und in einer class private.

Speicherverwaltung in Betriebssytembau

In C/C++ gibt es prinzipiell drei Bereiche, in denen Daten abgelegt werden k├Ânnen:

  1. Globale Variablen. Diese werden im Daten- bzw. dem BSS-Segment angelegt und werden vor dem Aufruf der main()-Funktion initialisiert. Gibt es keine initiale Zuweisung, so wird der Speicher einfach mit Nullen gef├╝llt.
  2. Lokale Variablen auf dem Stack. Diese sind nur innerhalb einer Funktion g├╝ltig und werden auf dem Laufzeitstack angelegt. Dies erkl├Ąrt auch, wieso die angelegten Objekte ihre G├╝ltigkeit nach der R├╝ckkehr der Funktion verlieren, da der Funktionsrahmen wieder abgebaut wird.
  3. Objekte im Heap. In einer Umgebung mit einer vollst├Ąndigen libc hat man die Funktion malloc(), um zun├Ąchst typlosen Speicher (void *) vom Heap zu bekommen. Dieser Teil der libc baut dabei heutzutage intern auf den brk() & mmap() Systemaufrufen auf. In C++ wird malloc() selten direkt benutzt, sondern ├╝ber das Keyword new.

Da wir beim Betriebssystembau keine libc haben, m├╝ssen wir zun├Ąchst auf den Komfort dynamischer Speicherverwaltung verzichten. Daher sind nur die Varianten 1 und 2 f├╝r uns relevant (in BST schreiben wir uns unsere eigene Speicherverwaltung).

Besonders bei den lokalen Variablen gibt es allerdings h├Ąufig gemachte Fehler. So wird zum einen gerne vergessen, dass der Stack endlich (4096 Byte) ist und gro├če Objekte zu einem Stackoverflow f├╝hren.

struct huge_object {
char buffer[5000];
};
void foo() {
huge_object barfoo;
}

Dieser Stackoverflow f├╝hrt allerdings nicht dazu, dass das Betriebssystem abst├╝rzt, sondern zu einer Speicherkorruption, welche meistens recht schwierig zu finden ist. Man schreibt einfach ├╝ber den Stack hinaus in den Bereich der globalen Variablen.

Der andere gern gemachte Fehler ist, die Lebensdauer von lokalen Objekten nicht zu beachten. So wird zum Beispiel gerne die Adresse einer lokalen Variable zur├╝ckgegeben, was ebenfalls zu einer Speicherkorruption f├╝hrt. Meistens findet der Compiler diese fehlerhafte Verwendung lokaler Variablen, allerdings nicht in allen F├Ąllen. Daher ist Vorsicht geboten!

object_t * foo() {
object_t ret;
ret.foo = 80;
return &ret;
}

Bitoperationen und Bitfelder

Beim Betriebssystembau hat man h├Ąufig direkt mit der Hardware zu tun. Die Designer dieser Hardware versuchen ihrerseits die Schaltkreise m├Âglichst effizient zu gestalten. Dies f├╝hrt dazu, dass h├Ąufig mehrere Bits an Informationen, die nicht direkt zusammengeh├Âren, in einem Speicherwort zusammengepfercht sind. Ein Beispiel, das in der Aufgabe 1 auftaucht, ist der Speicher der CGA-Graphikkarte. Dort wird jedes Zeichen auf dem Bildschirm (80x25 Zeichen) von 2 Bytes dargestellt. Das erste der beiden Bytes ist dabei das dargestellte Zeichen (in ASCII), das zweite Byte ist die Konfiguration f├╝r Vordergrund- und Hintergrundfarbe.

Bit76543210
BedeutungBlinkenHintergrundfarbeVordergrundfarbe

Nun kann man solche Attributbytes mittels der Bitoperationen, die C und C++ bereitstellen, zusammenbauen. Dabei gibt es das bin├Ąre UND (&), das bin├Ąre ODER (|), das bin├Ąre NICHT (~), das bin├Ąre XOR (^) und die beiden Shift-Operationen (<<, >>). Eine einfache Option f├╝r eine Wrapperfunktion w├Ąre also:

char make_attribute(char foreground, char background, char Blink) {
foreground &= 0xf; // 0000 1111
background &= 0x7; // 0000 0111
Blink &= 1; // 0000 0001
background <<= 4; // 0000 0XXX -> 0XXX 0000
Blink <<= 7; // 0000 000X -> X000 0000
return foreground | background | Blink; // Bbbb ffff
}

In dieser Funktion werden zuerst die Variablen mit einer Maske verundet, um alle Bits, die au├čerhalb des Wertebereich des jeweiligen Attributs liegen, zu beschneiden. Danach werden die Bits innerhalb von background und Blink mittels eines Shifts nach links noch an die passende Stelle verschoben und in der Returnanweisung mittels eines bin├Ąren ODERs kombiniert.

Zu diesen Operationen gibt es noch diverse Idiome, die man verwendet um einzelne Bits oder eine Menge von Bits zu l├Âschen, zu setzen oder auf ihren Wert zu pr├╝fen. Diese funktionieren meist ├╝ber ein Bitmaske, in der alle Bits, f├╝r die wir uns interessieren, gesetzt sind:

int MASKE = 0x11011;
int value;
// Alle Bits aus der Maske l├Âschen
value &= ~MASKE;
// Alle Bits aus der Maske setzen
value |= MASKE;
// Das Bit 13 unbedingt setzen
value |= (1 << 13);
// Pr├╝fen, ob ein Bit der Maske gesetzt ist
if ((value & MASKE) != 0) {...}
// Pr├╝fen, ob zwei Werte respektive der Maske gleich sind
if ((value1 & MASKE) == (value2 & MASKE)) {...}

Diese Art, die einzelnen Bits zu manipulieren, ist f├╝r kleinere Anwendungen gut genug. Allerdings neigen Programme, die allzu heftig davon Gebrauch machen, dazu, v├Âllig unlesbar und voll von magischen Konstanten zu sein. In C++ (und C) gibt es daher noch die M├Âglichkeit, Bitfelder zu verwenden. Diese weisen den Compiler an, einzelne Bits eines Speicherbereiches unter einem Namen bekannt zu machen.

struct CGA_Attr {
char foreground : 4;
char background : 3;
char blink : 1;
} __attribute__((packed));

Hierbei ist der oberste Eintrag bei den g├Ąngigen Compilern niederwertig (= n├Ąher bei Bit 0). Das zus├Ątzliche Attribut packed weist den Compiler an, keine Padding-Bits mehr zwischen den Feldern zu lassen (also zus├Ątzliche unbenutzte Bits einzubauen, die z.B. durch besseres Alignment die Performance steigern k├Ânnen). Verwendet man nun diese Struktur, wird unsere Funktion vom Eingang deutlich einfacher. Wir k├Ânnen sie sogar in den Konstruktor packen.

struct CGA_Attr {
char foreground : 4;
char background : 3;
char blink : 1;
CGA_Attr(char fg, char bg, char B)
: foreground(fg), background(bg), blink(B) {}
} __attribute__((packed));

Im Beispiel wird mittels einer Initialisierungsliste den Feldern direkt ein Wert zugewiesen. Dies erlaubt es dem Compiler, besseren Code f├╝r diese Zuweisungen zu erzeugen.

Was bedeutet volatile?

Vor Jahren habe ich in einem Mikrokontroller-Forum auf die Frage, wieso ein Programm, das Interrupts verwendet, nicht funktioniert, die Gegenfrage gelesen: "Hast du schon ├╝berall volatile hingeschrieben?`.

Das ist nat├╝rlich nicht die richtige Antwort. Denn volatile ist nicht das magische Schl├╝sselwort, mit dem der Compiler Wettlaufsituationen verhindert. Seine Bedeutung versteht man, wenn man verinnerlicht hat, dass der Compiler nicht f├╝r jeden Zugriff auf eine Variable aus dem Speicher lie├čt. Da der Speicher langsam und die Register deutlich schneller sind, ist es von gro├čem Vorteil, eine Variable aus dem Speicher zu lesen, alle n├Âtigen Modifikationen durchzuf├╝hren und dann erst zur├╝ckzuschreiben. Beispiel:

mov foo, %eax
add $1, %eax
shl $3, %eax
orl $17, %eax
mov %eax, foo

In dem Beispiel lebt Variable foo zun├Ąchst im Speicher und f├╝r die mittleren drei Instruktionen im Register, bevor sie wieder zur├╝ckgeschrieben wird. Man muss quasi gedanklich den Geist einer Variable (wo ist der Wert aktuell) von ihrer aktuellen Speicherstelle trennen.

Das Keyword volatile verbietet nun dem Compiler diese Optimierung. F├╝r jedes Auftreten einer volatile Variable muss der Compiler den Wert aus dem Speicher lesen und das Ergebnis zur├╝ckschreiben.

Operator├╝berladung

Eines der meist gehassten und am h├Ąufigst verteidigten Konzepte neben den C++-Templates ist die M├Âglichkeit, Operatoren zu ├╝berladen. Dies kann dazu f├╝hren, dass ein unschuldig aussehender Code a + b beliebig kaputte Dinge im Hintergrund machen kann. Und weil andere damit unvern├╝nftige Dinge machen, ist es wichtig, dass ihr auch wisst, wie man unvern├╝nftige Dinge damit tut.

Prinzipiell ist Operator├╝berladung sehr einfach. F├╝r den Operator OP, den man gerne auf seinen Datentypen anpassen will, definiert man einfach eine Funktion, die den magischen Namen operatorOP hat.

bool operator==(const &foo a, const &foo b) {
return a.id == b.id;
}

Damit definieren wir einen Operator, der die Gleichheit zweier selbst definierter Datentypen regelt. Da es noch andere (eingebaute) Operatorimplementierungen gibt, entscheiden die normalen C++-├ťberladungsregeln, welche Implementierung zur Anwendung kommt. Zus├Ątzlich zu frei lebenden Operatoren kann man auch Operatoren innerhalb einer Klassendefinition mit einem Argument weniger als Methoden definieren:

class foo {
int id;
public:
bool operator==(const &foo other) {
return this->id == other.id;
}
};

In diesem Fall bekommt der Operator die linke Seite durch das implizite this-Argument. Da der Operator als Methode definiert wurde, kann er auf die privaten Felder von foo zugreifen, was mit einem frei schwebenden Operator nicht m├Âglich w├Ąre.

Eine besonders eigenwillige Verwendung einer Operator├╝berladung wurde f├╝r die C++-Standardbibliothek f├╝r die Ein- und Ausgabe verwendet. Hier wird der <<-Operator ├╝berladen, um Zahlen und Strings gleicherma├čen in einem Ausgabekanal zu schicken.

std::cout << 12 << "Hallo Welt" << &main << std::endl;

Hierbei gibt jeder operator<<-Aufruf eine Referenz auf den Ausgabekanal zur├╝ck:

Stream & operator<<(Stream& out, int x) {...; return out;}
Stream & operator<<(Stream& out, std::string x) {...; return out;}
Stream & operator<<(Stream& out, void *x) {...; return out;}

Virtual auf technischer Ebene

Eine Besonderheit von Java ist es, dass jede Methode einer Klasse per default zun├Ąchst virtual ist. Dies bedeutet, dass der Aufruf der Methode dynamisch dispatched wird. Die andere Art den Dispatch eines Methodenaufrufes durchzuf├╝hren, ist der statische Dispatch. Wir wollen uns zun├Ąchst (in C++) anschauen, wo der Unterschied im Verhalten von virtuellem und statischem Dispatch liegt.

Object *obj = AbstractObjectFactory.make_object();
obj->method();

In diesem kurzen Codeabschnitt erzeugen wir ein Objekt, dass von einem Object * gehalten werden kann. Dies bedeutet, dass es entweder direkt ein Object ist oder ein Objekt einer abgeleiteten Klasse, die von Object erbt. Ob nun das Objekt, auf das obj zeigt, ein Object oder ein DerivedObject ist, bestimmt seinen dynamischen Typ. Die Variable, welche die Referenz auf das Objekt h├Ąlt, bestimmt ihren statischen Typ. Das hei├čt, dass in unserem Beispiel *obj sicher den statischen Typen Object hat, aber der dynamische Typ aus dem Code nicht direkt ablesbar ist.

Der Unterschied zwischen dynamischen und statischen Dispatch liegt darin, ob der dynamische oder der statische Typ herangezogen wird, um die Methode auszuw├Ąhlen. Falls unsere ->method() statisch dispatched wird, kann der Compiler direkt eine call Instruktion generieren.

mov %eax,(%esp)
call 8048926 <_ZN6Object6methodEv>

Zuerst wird der Pointer auf das Objekt auf den Stack gelegt und dann der Funktionsk├Ârper direkt mit einem Aufruf angesprungen. Auch hier sehen wir, dass der Funktionsname gemangled ist.

Soll nun ein dynamischer Dispatch an die Stelle des statischen Dispatches treten, muss unser Programm irgendwie herausfinden k├Ânnen, von welchem dynamischen Typen das referenzierte Objekt ist. Denn auf diesen kommt es beim dynamischen Dispatch ja an. Dazu muss das Wissen ├╝ber den dynamischen Typen des Objekts ├╝berhaupt einmal irgendwo gespeichert werden. Denn normalerweise, wenn es keine einzige virtuelle Methode gibt, ist die Abbildung von C++ Klassen und Strukturen auf den Speicher direkt. Dazu ein Beispiel, wie unser Object aussehen k├Ânnte:

class Object {
int a;
int b;
int c;
public:
void method();
};

In diesem Fall wird method() statisch dispatched und es besteht keinerlei Notwendigkeit den dynamischen Typen solcher Objekte zu kennen, wieso also daf├╝r wertvolle Bytes ausgeben. Daher spart sich C++ in diesem Fall den Speicher, um den dynamischen Typen zu vermerken und belegt f├╝r unser Objekt genau die 12 Bytes (3 * 4 Bytes f├╝r jeden Integer), die verwendet werden. Dabei werden die Felder der Klasse von oben nach unten aufsteigend in den Speicher gelegt. In diesem Fall ist daher:

Object * obj = ...;
char *memory = (char*) obj;
obj->a == *(int*)(memory + 0);
obj->b == *(int*)(memory + 4);
obj->c == *(int*)(memory + 8);

Ebenfalls k├Ânnte man, wenn man glaubt zu verstehen, was der Compiler tut, ein Objekt direkt aus einem Pointer auf ein St├╝ck Speicher casten. Im Beispiel wird auch sichtbar, dass unser Beispiel auf einer Litte-Endian Maschine kompiliert wurde.

char memory[] = {12, 0, 0, 0, // a == 12
23, 0, 0, 0, // b == 23
45, 0, 0, 0, // c == 45
};
Object *obj = (Object *)memory;

Um Informationen ├╝ber den dynamischen Typen hinzuzuf├╝gen, m├╝ssen nun im Speicher des Objekts zus├Ątzliche Informationen untergebracht werden. Die meisten Compiler f├╝gen dazu vor dem ersten Element der Klasse einen Virtual Function Table Pointer hinzu. Bei allen Objekten des gleichen dynamischen Typs zeigt dieser Pointer auf die gleiche virtuelle Funktionstabelle.

class Object {
int a;
int b;
int c;
public:
virtual void method();
};
int main() {
Object *obj = new Object();
// Breakpoint
obj->method();
}

Dieses Beispiel nun in einer GDB-Session am angegeben Breakpoint.

Breakpoint 1, main () at test.c:22
22 obj->method();
# Das Objekt an sich inspizieren:
(gdb) p obj
$1 = (Object *) 0x80f1ce0
(gdb) p *obj
$2 = {_vptr.Object = 0x80b7f2c <vtable for Object+8>, a = 0, b = 0, c = 0}
(gdb) x /4x obj
0x80f1ce0: 0x080b7f2c 0x00000000 0x00000000 0x00000000
# Die dazu geh├Ârige VTable:
(gdb) info vtbl obj
vtable for 'Object' @ 0x80b7f2c (subobject @ 0x80f1ce0):
[0]: 0x8048926 <Object::method()>
(gdb) x/a 0x80b7f2c
0x80b7f2c <_ZTV6Object+8>: 0x8048926 <Object::method()>

Wir sehen also, dass das eingef├╝gte Feld auf ein Objekt mit dem Namen <vtable for Object+8> zeigt, welches vom Compiler erzeugt wurde. In dieser VTable ist ein Pointer auf den Funktionsk├Ârper von Object::method() gespeichert. Da der VTable-Pointer direkt vom dynamischen Typen abh├Ąngt, kann ├╝ber diese Funktionstabelle der dynamische Dispatch durchgef├╝hrt werden. F├╝r unseren Methodenaufruf sieht der dazu passende Assembler so aus (Object * in %eax):

mov (%eax),%edx # VTable-Pointer in %edx
push %eax # this Pointer auf den Stack
call *(%edx) # Dereferenziere den VTable-Pointer ohne Offset (Slot 0)
# und mache einen indirekten Aufruf

Jede virtuelle Methode, die wir definieren, bekommt also einen Slot in der virtuellen Funktionstabelle. Um noch genauer zu betrachten, was der Compiler in die VTable baut, k├Ânnen wir den Compiler mit g++ -fdump-class-hierarchy aufrufen und die Ausgabe betrachten:

Vtable for Object
Object::_ZTV6Object: 3 entries
0 (int (*)(...))0
4 (int (*)(...))(& _ZTI6Object)
8 (int (*)(...))Object::method
Class Object
size=16 align=4
base size=16 base align=4
Object (0x0x7f6b48574060) 0
vptr=((& Object::_ZTV6Object) + 8)

Wir sehen aus der Ausgabe, dass die VTable eigentlich 8 Byte weiter vorne anf├Ąngt als der vptr, der am Ende in unseren Objekten gespeichert wird. Zus├Ątzlich zu unseren Methodenpointern wird noch ein Pointer auf die Run-Time-Type-Information (RTTI) gespeichert.

All dieses Wissen zusammengenommen k├Ânnen wir also auch mal ein Object und die dazu passende VTable zusammenfaken:

void my_method(Object *obj) {
printf("X: %d %d %d\n", obj->a, obj->b, obj->c);
}
int main() {
int vtable[] = {
0, 0, // Keine RTTI
(int) &my_method
};
int memory[] = {
(int) vtable + 8,
1, 2, 3
};
Object *obj = (Object *)memory;
}

Als Ausgabe erhalten wir f├╝r dieses Programm (f├╝r -m32, 32 Bit): X: 1 2 3.

Was wir aus dieser technischen Betrachtung von virtuellen Funktionstabellen lernen, ist:

  • Der statische Dispatch ist umsonst.
  • Der dynamische Dispatch ist ein indirekter Call ├╝ber einen VTable Pointer.
  • C++ zahlt diese Kosten nur, wenn die Funktionalit├Ąt explizit gefordert wurde.

Links:

Vererbung auf technischer Ebene

Wir haben bereits gelernt, wie in C++ Verbunddatentypen (struct, union, class) im Speicher abgelegt werden. Bei einfache Datenstrukturen (POD) werden die Felder (von oben nach unten) hintereinander in den Speicher gelegt (von den niedrigen Adressen zu den hohen Adressen. Und wir haben gelernt, wie Klassen, die eine virtual-Methode enthalten, um einen VTable-Pointer erweitert werden.

Wie sieht es aber nun mit Vererbung aus? Dazu ein Beispiel, welches wir mit clang -Xclang -fdump-record-layouts ├╝bersetzen:

class Base {
int a;
int b;
};
class Derived : public Base {
int cc;
};
int main() {
Base x;
Derived yy;
}

Aus der Ausgabe bekommen wir heraus, dass die Klasse Base ganz regul├Ąr in den Speicher abgelegt wird. Hierbei ist die Linke Spalte der Offset vom Pointer zum Objekt:

0 | class Base
0 | int a
4 | int b
| [sizeof=8, dsize=8, align=4,
| nvsize=8, nvalign=4]

Die beiden Integer sind also einfach hintereinander in den Speicher gelegt. Wenn wir uns die Klasse Derived anschauen, dann sehen wir, dass dieses Speicherabbild in den Anfang des Derived-Objektes eingebaut wird und dadurch der Pointer zur Variable cc einen Offset von 8 Byte aufweist.

0 | class Derived
0 | class Base (base)
0 | int a
4 | int b
8 | int cc
| [sizeof=12, dsize=12, align=4,
| nvsize=12, nvalign=4]

Der Trick bei dieser Art der Einbettung ist nun, dass man den Pointer, der auf ein Derived-Objekt zeigt, ohne Probleme wie ein Objekt vom Typen Base verwenden kann, da alle Felder an der gleichen Stelle sind. Dieselben Regel gelten, wenn wir eine virtual-Methode zur Base hinzuf├╝gen. In diesem Fall kann jedes Derived-Objekt das Vtable-Pointer-Feld der prim├Ąren Basisklasse verwenden, um auf die eigene Vtable zu zeigen:

0 | class Derived
0 | class Base (primary base)
0 | (Base vtable pointer)
8 | int a
12 | int b
16 | int cc
| [sizeof=24, dsize=20, align=8,
| nvsize=20, nvalign=8]

Solange man nur Einfachvererbung hat, ist dies im Grunde alles, was man ├╝ber Memory Layouts von Klassen wissen muss. In dem Moment, wo man Mehrfachvererbung hat, wird das Ganze eine Ecke komplizierter. Dazu ein Beispiel ohne virtual:

class Base {
int a;
int b;
};
class Base2 {
int c;
int d;
};
class Derived : public Base, public Base2 {
int xx;
};

Das Speicherlayout der abgeleiteten Klasse sieht dann folgenderma├čen aus:

0 | class Derived
0 | class Base (base)
0 | int a
4 | int b
8 | class Base2 (base)
8 | int c
12 | int d
16 | int xx
| [sizeof=20, dsize=20, align=4, nvsize=20, nvalign=4]

Auff├Ąllig ist, dass wiederum die Speicherlayouts der geerbten Klassen 1:1 in der Derived-Klasse zu finden sind. Allerdings besteht nun das Problem, dass wir den Pointer, der auf den Anfang zeigt, nicht mehr einfach verwenden k├Ânnen, um eine Funktion zu f├╝ttern, die ein Base2 Speicherlayout erwartet. In dem Fall muss also eine Anpassung des this-Pointers erfolgen. Um dies zu illustrieren, schauen wir uns den Assembler der Funktion Base2 * foo(Derived * x) {return x;} an:

00000000 <_Z3fooP7Derived>:
mov 0x4(%esp),%ecx # Lade das erste Argument nach %ecx
lea 0x8(%ecx),%eax # %eax = %ecx + 8 (Offset zu Base2)
test %ecx,%ecx # Teste, ob %ecx == 0
cmove %ecx,%eax # Falls ja, setze %eax auf 0
ret # Return %eax

Hier sehen wir, dass f├╝r die Konvertierung der beiden Pointer ein Offset von 8 aufaddiert wird. Dies f├╝hrt dazu, dass aus einen Derived-Pointer ein Base2-Pointer wird. Es bedeutet aber auch, dass der Pointer nun mitten in das Objekt zeigt. Die Sequenz test; cmove verhindert, dass aus einem Nullpointer ein Pointer mit dem Wert 8 wird.

Diese automatische Offsetanpassung ist auch der Grund, wieso man in C++ mehrere Cast-Operationen hat, die diese Offsetanpassung vornehmen (oder halt nicht). Wen man nun noch virtual ins Spiel bringt, wird die Geschichte noch etwas komplizierter. Zu beidem verweisen wir aber auf die Links zu diesem Kapitel.