__near, __far, wherever you are

Während Kate rebelliert, Leo stirbt und Céline von “near” und “far” singt (siehe Titanic), überlege ich gerade, wo ich überall “spezielle” Pointer-Attribute unterbringen muss.

Na … da blutet fast das Herz des Ozeans …


Vorgeschichte

Nachdem alle modernen Systeme mit Windows oder Linux ausgestattet sind und mit einem Flat-Memory-Modell arbeiten, versteht heute keiner mehr, wenn ich mich zu folgender Aussage hinreißen lasse:

Pointer ist nicht gleich Pointer.

In C++ finden viele typedefs statt, um Pointer zu kapseln. z.B.: typedef X* X_ptr;
Das ist eigentlich ineffizient, weil X* viel kürzer als X_ptr zu schreiben ist.

Doch früher … viel früher gab es mal mehrere Implementierungen der gleichen Sache abhängig von gewünschten Datenmodell, nämlich: typedef X __near* X_ptr; oder typedef X __far* X_ptr;
Und schon macht es wieder total Sinn, dass man mit dem richtigen X_ptr arbeitet.

Segmentierung

X86-16-bit und DOS arbeiteten mit Segment:Offset Adressierung. Das Datensegment-Register wurde auf einen 64-KByte großen Block gesetzt und man adressierte die Daten über einen relativen Offset.

Wenn ein Programm mit 64 KByte auskam, konnte man ein “kleineres” Speichermodell wählen. Das Segment-Register wurde einmal global gesetzt und alle Adressierung liefen nur über den Offset.
Das nennt man dann einen near Pointer, und der ist nur 16-Bit groß, also klein und damit effizient.

Kommt man 64 KByte nicht aus, kann man weitere 64-KByte Blöcke allokieren und das Datensegment einfach auf den Block zeigen lassen, den man haben möchte. Solche Pointer sind dann 32-Bit breit (weil Segment + Offset) und die werden eben als far Pointer bezeichnet.

Das gewählte Speichermodell entscheidet also, ob
sizeof(void*) == 2 oder sizeof(void*) == 4 gilt. Das könnte dem Programmierer jetzt theoretisch egal sein und er könnte mit dem far Modell immer alles erreichen, doch wenn stets das Segment-Register bei Datenzugriffen neu ausgerichtet werden muss, dann läuft das Programm plötzlich nur noch halb so schnell.

Also Augen auf bei der Wahl des Speichermodells!

Ausnahmen von der Regel

Das GATE Projekt ist leider zu groß, als dass es mit near recht weit kommen würde. Kleine Konsolen-Apps würden sich zwar ausgehen, aber ich mache eben nichts kleines.
Ich nutze daher das large Modell, in dem Code und Daten mehrere Segmente nutzen dürfen, damit sind sowohl Code, als auch Daten-Pointer vom Typ far.

Schließlich geht es mir bei der DOS-Portierung des Projektes nicht um Hocheffizienz sondern um den Beweis, dass man auch heutige Programme noch für DOS kompilieren kann, wenn man sie “richtig” aufsetzt.

Allerdings sind mir einige gebrochene Unit-Tests im Projekt aufgefallen, bei denen ich Pointer für andere Zwecke einsetze, und einer davon heißt “Alignment”.

Weder der C noch C++ Standard garantieren, dass Pointer einem bestimmten Alignment folgen, doch die Praxis lehrte mich:

All Datentypen haben im Zweifelsfall mindestens die Ausrichtung des Pointer-Typs, weil der “am besten” an das Speichermodell angepasst ist.

Soll heißen,
void func(char x, int y);
hat die gleiche ABI-Signatur wie: void func(void* x, void* y);

Denn alle Datentypen kleiner void* werden auf die Größe von void* aufgepumpt, weil void* mit hoher Wahrscheinlichkeit genau die Größe eines normalen CPU Allzweckregisters hat.

Generische Funktionsaufrufe

Im gate/functions Modul werden beliebige C-Funktionsaufrufe über einen Dispatcher geleitet und der arbeitete bisher nach der Idee, dass alle Parameter einer Funktion durch void* repräsentierbar sind (mit ein paar Sonderfällen, die aus mehreren void* Typen zusammengesetzt sein können.)

Das Problem ist, dass void* im Large-Modell 4 Bytes breit ist, doch das Default-Alignment ist auf 16-bit Systemen eben nur 16-bit breit.
Ein void foo(int, int) belegt 4 Bytes (2x int16) am Stack, doch ein
void bar(void*, void*) hätte wegen far bereits 8 Bytes (2x int32) belegt und stimmt nicht mehr mit dem Original überein.

Mein Unit-Test Fehler entstand also durch eine Verschiebung des Speichers.

Lösung: 16-bit Parameter nutzen, anstatt von void *

Nun musste also auch ich ein typedef für generische Parameter einsetzen und eben dieses typedef sieht unter DOS nun anders aus, als unter anderen Betriebssystemen.

1#if defined(GATE_COMPILER_WATCOM)
2  typedef short fparam_t;
3#else
4  typedef void* fparam_t;
5#endif

Kaum war die Anpassung vorgenommen, funktionierten auch die Tests wieder erfolgreich.

Andere near/far Pointer erzeugen

Wenn man unter DOS direkt auf die Hardware zugreifen möchte, dann funktioniert das unter anderem auch über feste Speicheradressen.
Ein Beispiel wäre A0000 des VGA Standards.

Pointer zu solchen Segmenten sind ebenso als __far Pointer deklariert. Sie werden über Hilfsmakros wie MK_FP explizit erzeugt:

1static unsigned char __far * vga_screen; 
2vga_screen = (unsigned char __far*)MK_FP(0xA000,0x0000); 

Fazit

Ein weiteres Mysterium ist gelöst und eine notwendige Codeanpassung wurde vorgenommen. Anstatt vieler void* Parameter wurde nun ein neuer Typ angelegt, den man an einer Stelle ändern kann.
So kann man auf jeder Plattform den passenden nativen Datentypen über Compiler-spezifische Makros ermitteln.

Ich werde vorerst mit dem LARGE Modell weiterarbeiten, doch es tut sich nun die Frage auf, ob es auch möglich wäre die kleineren Modelle einzusetzen. Spätestens dann müsste ich weitere Anpassungen in vermutlich vielen Modulen vornehmen, denn dann wird die Unterscheidung zwischen near und far explizit umgesetzt werden müssen.