Systemnahe Programmierung
Willemers Informatik-Ecke
Ein Ziel bei der Entwicklung von C++ war es, C so erweitern, dass neue Programmierkonzepte unterstützt werden, aber die Effizienz und Leistungsfähigkeit von C nicht verloren geht. Eine der besonderen Fähigkeiten von C ist der Umgang mit systemnahen Strukturen. Immerhin wurde C dazu entwickelt, um das Betriebssystem UNIX damit zu schreiben. Diese Fähigkeiten sind in C++ dementsprechend erhalten geblieben.

Bit-Operatoren

Bits haben nur zwei Zustände: an oder aus, eins oder null. So kann man ein Bit nur setzen oder löschen. Wenn aber zwei Bits miteinander verknüpft werden, dann gibt es die gleichen Operationen UND und ODER wie bei booleschen Werten. Im Unterschied zu booleschen Werten werden Bits normalerweise zu Bytes zusammengepackt. Darum muss das UND für Bits auf jedes Bit eines Bytes oder gar einer ganzen long-Variablen wirken. Darum unterscheidet sich auch der logische UND-Operator (&&) vom binären UND (&).

Anwendung

Dass mehrere Bits in einem längeren Wort zusammengepackt sind, finden Sie einmal auf Hardware-Ebene, wenn Sie auf Controller und deren Register zugreifen. Damit können Sie Peripheriegeräte steuern oder ihre Zustände abfragen. Aber auch in mancher API werden mehrere Flags (Flags sind Optionen, die nur zwei Zustände haben können.) so in einer Variablen zusammengefasst, dass sie nur einen Parameter beanspruchen. In beiden Fällen ist es notwendig, dass Sie auf einzelne Bits eines Bytes zugreifen können.

Die Bit-Operationen haben die gleichen Namen und die gleichen Funktionen wie die booleschen Operationen, die bei Abfragen und Schleifen vorgestellt wurden. Die Bit-Operationen müssen allerdings auf einzelne Bits zugreifen können und erhalten deshalb in C/C++ auch einen etwas anderen Operator. Die folgende Tabelle zeigt diese Operatoren im Überblick.

[Bit-Operatoren]
Operator Funktion Wirkung
& AND 1, wenn alle Operanden 1 sind
| OR 1, wenn mindestens ein Operand 1 ist
~ (Tilde) NOT kippt jedes Bit
^ XOR 1, wenn genau einer der beiden Operanden 1 ist

Wenn zwei Werte mit Bit-Operatoren verknüpft werden, geschieht dies auf binärer Ebene. Um die Ergebnisse zu verstehen, müssen auch die beteiligten Werte binär dargestellt werden. Dazu eignen sich natürlich nur ganzzahlige Typen. In den folgenden Beispielen werden Variablen vom Typ unsigned char mit acht Bits verwendet.

AND als Maske

Die AND-Funktion kann dazu verwendet werden, um bestimmte Bits aus einem Byte herauszufiltern. Man spricht in diesem Zusammenhang von einer Maske. Da die AND-Funktion nur dann 1 ergibt, wenn beide Operanden 1 sind, wird der Bereich des ersten Operanden herausgefiltert, der beim zweiten auf 1 steht. Beispiel:

~   01101110
AND 00111100
------------
    00101100

Hier werden die vier mittleren Bits maskiert. Auf ähnliche Weise ist es möglich, mit Hilfe der AND-Funktion ein einzelnes Bit in einem Byte zu löschen:

~   00101101
AND 11110111
------------
    00100101

Hier wurde das vierte Bit von rechts des ersten Operanden gelöscht. Dazu muss die Maske aus lauter Einsen bestehen und nur im vierten Bit von rechts eine 0 aufweisen.

Bits abfragen

Sie können die AND-Verknüpfung auch verwenden, wenn Sie den Zustand eines einzelnen Bits abfragen wollen. Wenn Sie das fünfte Bit von rechts prüfen wollen, maskieren Sie es mit $2^4$, also 16. Im Programm schreiben Sie:

if (RegisterInhalt & 16)

Wenn das fünfte Bit 0 ist, dann ist der Gesamtausdruck auch 0, also falsch. Ist das fünfte Bit gesetzt, ergibt die Maskierung mit 16 den Wert 16. Da dieser nicht 0 und damit nicht falsch ist, muss er wahr sein.

XOR kippt Bits

Soll das Bit umgeschaltet werden, wird die Operation XOR verwendet. Sie ist eins, wenn genau einer von beiden Operanden eins ist. Das zu kippende Bit muss in der Maske gesetzt sein, alle anderen müssen auf 0 stehen.

~   00101101
XOR 00001000
------------
    00100101

OR setzt Bits

Soll ein bestimmtes Bit gesetzt werden, wird die OR-Funktion verwendet. Auch hier ist im zweiten Operanden nur das Bit gesetzt, das im ersten Operanden gesetzt werden soll. Alle anderen Werte bleiben durch eine 0 unverändert.

~   00100101
OR  00001000
------------
    00101101

Der OR-Operator wird beispielsweise bei den Datei-Operationen verwendet. Dort wurden beim Öffnen einer Datei die Zugriffsarten mit einem senkrechten Strich, also mit einem binären Oder, verknüpft:

fstream f(..., ios::out|ios::binary|ios::in);

Das deutet darauf hin, dass die Konstanten ios::out, ios::binary und ios::in binär kodiert sind. Beispielsweise könnte folgende Kodierung vorliegen:

[Beispielkodierung]
Konstante dezimal binär Bedeutung
ios::in 1 0%00000001 Datei zum Lesen öffnen
ios::out 2 0%00000010 Datei zum Schreiben öffnen
ios::binary 4 0%00000100 Datei ist keine Textdatei
ios::trunc 8 0%00001000 Datei wird beim Öffnen geleert
ios::app 16 0%00010000 Geschriebene Daten ans Ende anhängen

Wollen Sie nun eine binäre Datei zum Lesen und Schreiben so öffnen, dass geschriebene Daten immer an das Ende angehängt werden, verknüpfen Sie die Konstanten mit ODER und übergeben diesen Wert als Parameter:

ios::in|ios::out|ios::binary|ios::app

Durch die Oder-Verknüpfung entsteht eine Zahl, die widerspiegelt, welche Optionen eingeschaltet sind und welche nicht.

ios::in      0%00000001
ios::out     0%00000010
ios::binary  0%00000100
ios::app     0%00010000
ODER:        0%00010111

Mit einem Byte können Sie so bereits acht Optionen transportieren. Wenn Sie eine long-Variable verwenden, können es bis zu 32 Optionen sein.

Shift-Operatoren

Die doppelten Kleiner- und Größer-Zeichen werden in C++ meist im Zusammenhang mit der Ein- und Ausgabe als Umleitungsoperator verwendet. Bereits in C werden sie zum bitweisen Schieben ganzzahliger Werte verwendet.

Beispiel

Das folgene Beispiel zeigt, wie Shift-Operatoren im Programm eingesetzt werden.

wert = 24 >> 1;

Die Operation bewirkt, dass die Zahl 24 binär um 1 Bit nach rechts verschoben wird. Das Ergebnis dieser Operation ist 12. Dazu betrachten wir 24 zunächst in Binärdarstellung:

2410 = 24 + 23 = 000110002

Wird 00011000 bitweise um eins nach rechts verschoben, ergibt sich der Wert 00001100. Das ist in Dezimaldarstellung die Zahl 12:

000011002 = 23 + 22 = 810 + 410 = 1210

Der Wert hinter dem Shift-Operator gibt an, um wie viele Bits der Wert geschoben wird. Wie Sie oben gesehen haben, werden die frei werdenden Bits links mit Nullen aufgefüllt.

Der Operator << schiebt die Bits nach links, arbeitet ansonsten genau wie der Operator >>. Auch hier werden die frei werdenden Bits mit Nullen aufgefüllt.

wert = 12 << 2;

Das Ergebnis würde 48 sein. Hier wird um zwei Bitstellen geschoben. Das Schieben um ein Bit ergibt 24. Ein weiteres Schieben verdoppelt das Ergebnis wiederum auf 48. Das Schieben um eine Stelle nach links entspricht also einer Multiplikation mit 2, ist aber durch den Prozessor wesentlich effizienter zu realisieren als eine Multiplikation. So liegt es nahe, Multiplikationen und Divisionen mit Zweierpotenzen in zeitkritischen Umgebungen mit Hilfe des Shift-Operators durchzuführen. Einige Compiler führen diese Optimierung allerdings selbst durch, so dass Sie dadurch vielleicht keinen Zeitgewinn erreichen.

Zugriff auf Hardware-Adressen

Zeiger auf Register

Bisher wurde der Blick auf den Inhalt einer Zeigervariablen immer vermieden. In der systemnahen Programmierung ist der Wert, den ein Zeiger enthält, durchaus interessant. Beispielsweise liegt das Statusregister der ersten Druckerschnittstelle bei einem gängigen Intel-PC an der Adresse 0x379. Für das normale Anwendungsprogramm ist diese Adresse unerreichbar. Wenn Sie aber eines Tages einen Treiber schreiben sollen, ist dies die Adresse, an der Sie erfahren können, ob der Drucker bereit ist, weitere Aufträge entgegenzunehmen. Der Zugriff auf die Adresse ist denkbar einfach: Sie definieren eine Zeigervariable und weisen ihr die Adresse direkt zu.

[Zugriff auf den Druckerstatus]

unsigned char *DruckerStatusRegister;
unsigned char;

DruckerStatusRegister = 0x379;
Status = *DruckerStatusRegister;
if (Status & 0%10000000) 
{
    // Drucker ist frei
}
else 
{
    // Drucker ist BUSY
}

Register auslesen

Anschließend können Sie in der Variablen Status ablesen, welchen Inhalt das Statusregister der LPT1 hat. Durch die Maskierung des höchsten Bits können Sie beispielsweise feststellen, welchen Zustand die BUSY-Leitung der Schnittstelle hat.

Bit-Strukturen

Um auf bitweise codierte Daten zuzugreifen, kann eine Struktur definiert werden, deren Elemente mit ihrer Bitbreite definiert sind. Solche Codierungen finden sich häufig im Bereich von Controllern oder Statusmeldungen, die in Registern abgelegt sind.

struct tRegister
{
    unsigned int Target:3;
    unsigned int reserved1:1;
    unsigned int busy:1;
    unsigned int conditionmet:1;
    unsigned int check:1;
    unsigned int reserved2:1;
} StatusByte;

Dieses Statusbyte modelliert das Register eines Controllers. Es enthält in den höchsten drei Bits das Target. Es folgt ein reserviertes Bit. Danach folgen je ein Bit für busy, condition met und check. Das niedrigstwertige Bit ist wieder reserviert. Tatsächlich wird für diese Struktur nur ein Byte benötigt. Das Ziel ist aber weniger das platzsparende Abspeichern, sondern das Abbilden von Registerstrukturen.

Sie können auf die Bestandteile der Bitstruktur wie bei einer üblichen Struktur zugreifen:

StatusByte.Target = 5;

Portabilität und der Präprozessor

Die systemnahe Programmierung hat immer den Nachteil, dass die Programme nur auf dem System laufen, für das sie geschrieben sind. Mit Hilfe des Präprozessors ist es möglich, Programmteile abzugrenzen, die nur auf einem bestimmten System laufen. Es ist sogar möglich, Alternativen für die verschiedenen Systeme nebeneinander zu stellen.

Der Präprozessor ist eine Compilerstufe, die vor der eigentlichen Übersetzung durch den Quelltext geht und ihn vorübersetzt. Der Präprozessor ist darauf spezialisiert, textuelle Ersetzungen durchzuführen.

Bedingte Kompilation: #if

#ifdef

Mit #if, #else und #endif können in Abhängigkeit von einer Bedingung Übersetzungen durchgeführt werden. Die Bedingung defined wird beim Übersetzen systemabhängiger Besonderheiten eingesetzt. Dabei wird ausgenutzt, dass die Compiler diverse Makros vordefinieren, an denen man erkennen kann, in welcher Umgebung das Programm übersetzt wird. So definieren die meisten UNIX-Compiler das Makro __unix__. Da #if defined so oft verwendet wird, gibt es dafür das Kürzel #ifdef. Im folgenden Beispiel wird der Systemaufruf fork(), der nur unter UNIX verfügbar ist, nur übersetzt, wenn die Zielmaschine auch unter UNIX läuft.

#ifdef __unix__
     fork();
#endif

Auch Besonderheiten von C++ können Sie auf diese Weise ausgrenzen. In der Datei stdlib.h ist das Makro __cplusplus definiert, wenn es sich um einen C++-Compiler handelt.

#ifdef __cplusplus
  template <class T> T min(T a, T b) { return a<b?a:b; }
#else
  #define  min(a,b) a>b?b:a
#endif
Die Compiler-Hersteller codieren ihre Compiler-Versionen in Makros. So verwendet der Borland C++ Builder das Makro __BCPLUSPLUS__, um seine Versionsnummer zu kodieren. Das kann ausgenutzt werden, wenn Mechanismen verwendet werden sollen, die nur in neueren Versionen verfügbar sind.

#if __BCPLUSPLUS__ >= 0x530
     // läuft unter C++ Builder ab Version 3.0
#endif

Sie können diesen Mechanismus auch verwenden, um kurzfristig Code auszugrenzen, der aber später vielleicht wieder gebraucht wird. Dann prüfen Sie auf einen Namen, der vermutlich nicht definiert ist. Im Beispiel wird das Wort HUHU verwendet.

#ifdef HUHU
     // Hier steht Experimentalcode, der so lange 
     // ausgeblendet ist, bis jemand HUHU definiert.
     /* mit vielen Kommentaren */
#endif

Es gibt auch eine Anweisung #ifndef, deren Block nur dann übersetzt wird, wenn ein Name nicht definiert ist. Den Befehl #ifndef finden Sie häufiger in Header-Dateien, um einen Schutz gegen doppeltes Einbinden zu realisieren. Dazu wird ein Name für jede Header-Datei verwandt. Es bietet sich dabei an, den Namen der Header-Datei zu verwenden. Wenn dieser Name noch nicht definiert wurde, wird der Block betreten. Dort wird als Erstes genau dieser Name definiert, so dass beim zweiten Einbinden dieser Datei der Inhalt nicht mehr gelesen wird.

#ifndef HEADERFILE_H
#define HEADERFILE_H
  // Hier ist der Code der Header-Datei     
#endif // als letzte Zeile der Header-Datei