Geschrieben von 0-checka am 19.08.2004, 18:32

In diesem Tutorial findet ihr Anmerkungen zum Programmieren von portablem Quellcode zwischen verschiedenen Compilern und Betriebsystemen (hauptsächlich Linux und Win32) bzw. Plattformen.

1. Standard C
1.1. Warum funktioniert "Hello World" überall?
1.2. Der Knackpunkt

2. Das Umgehen von Portabilitätsproblemen
2.1. Compiler
2.2. Variablentyp "bool"
2.3. Sockets
2.4. Threads
2.5. 64-Bit Variablen
2.6. Verzeichnisse und Partitionen
2.7. errno, GetLastError() und WSAGetLastError()
2.8. Systemlogs
2.9. Registry und INI bzw. CONF Dateien
2.10. Totalabsturz: Grafik
2.11. Windows ist Windows ist Windows

3. Abschliessende Zusammenfassung

1. Standard C
-----------------
Die Geschichte von C kurz erzählt: 1972 entwickelte Dennis Ritchie
von den AT&T Bell Laboratories diese Programmiersprache im
Zusammenhang mit seiner Arbeit an dem Betriebssystem Unix. Dabei bauten grosse Teile von C auf der Programmiersprache BCPL auf. Die grosse Verbreitung von C lässt sich dadurch erklären, das AT&T nach 1978 seine Unix Version an Universitäten auslieferte und Teil des Programmpaketes dieser Version war der von Ritchie und Kerningham (Ritchie hatte in der Zwischenzeit Verstärkung erhalten) entwickelte C-Compiler. Dazu kam als grosser Schub das Buch "The C Programming Language" (DER Klassiker unter den Lehrbüchern für Programmierung), das die beiden Entwickler des Compilers geschrieben hatten und zur selben Zeit veröffentlichten.
Warum "Standard" C? Nach einer Weile wurden C Compiler für andere Betriebssysteme als das AT&T Unix von verschiedenen Herstellern herausgegeben. Es kam zu immer mehr Inkompatibilitäten zwischen den C-Codes, die die verschiedenen Compiler akzeptierten bis eines Tages ANSI (ungefähr das Gleiche wie DIN in Deutschland, aber eine eigenständige Regierungsorganisation der USA - http://www.ansi.org/) sich der Sache annahm und im Jahr 1987 ANSI-C bzw. Standard-C einführte.

1.1. Warum funktioniert "Hello World" überall?
----------------------------------------------------------
In ihrem Buch zeigten Kernighan und Ritchie ein Beispielprogramm, das seitdem als Synonym für das erste Beispielprogramm in Lehrbüchern für Programmierung gilt. Dies ist der Quelltext (nicht in der Originalversion, sondern in der ANSI-C Version):

§§§§ - beginn - §§§§
#include <stdio.h>

int main(void)
{
printf("hello, world\n");
return 0;
}
§§§§ - ende - §§§§

(die Originalversion unterscheidet sich in Zeile 3: "main()" statt "int main(void)" und dem fehlenden "return 0;")

Jeder Compiler, der ANSI-C unterstützt, egal auf welchem Betriebssystem und egal auf welcher Hardwareplattform, wird eine ausführbare Datei erstellen, die das Gewünschte ausgibt und sich dann beendet. Das genau das passiert, wird in der ANSI-C Definition gefordert und der Hersteller des Compilers ist verantwortlich dafür, das umzusetzen. Wie genau er das macht, ist seine Sache. Es können vom Betriebssystem bereitgestellte Interrupts sein, C-Bibliotheken, in denen Funktionen für grundlegende Dinge bereitgestellt werden oder der Compiler Hersteller fummelt sich selbst etwas zusammen. Wichtig ist nur das Endergebnis. Wir sehen also nicht, was zwischendurch passiert, ähnlich wie bei einem Auto. Ob das Lenkrad über Zahnräder auf die Achse zugreift und die Räder einschlägt oder ob unsere Lenkbewegung durch elektrische Signale übertragen wird, wissen wir nicht und im Grunde interessiert es uns auch nicht. Wir wollen einfach nur dem Reh auf der Strasse ausweichen.

1.2. Der Knackpunkt
-------------------------
Eigentlich können wir nun ja froh sein, egal auf welchem Betriebssystem wir ein Programm in C schreiben, es läuft überall gleich. Oder nicht? Die Antwort ist einfach: Eigentlich schon. Nur das Wort "Eigentlich" stört in der Antwort und leider hat es eine Berechtigung in dem Satz. Es gibt zwei Gründe, warum die Frage mit "Nein" beantwortet werden könnte. Der erste ist, das nicht alle Hersteller der ANSI-C Definition treu geblieben sind. Der zweite Grund ist, das es Funktionalitäten gibt, die nur auf bestimmten Betriebssystemen/Hardwareplattformen vorhanden sind oder deren Aufbau sich von Betriebssystem/Plattform zu Betriebssystem/Plattform grundlegend unterscheidet. Um portablen Code zu schreiben muss man also die Compiler in Hinsicht auf Einhaltung der ANSI-C Definition und die unterschiedliche Handhabung durch die Betriebssysteme/Plattformen von den Dingen, die man implementieren will kennen.

2. Das Umgehen von Portabilitätsproblemen
-------------------------------------------------------
Da es viele Unterschiede gibt, die alle auch noch verschiedene Ursachen bzw. Auswirkungen haben, gibt es kein Patentrezept zum Verhindern von Portabilitätsproblemen. Bei den meisten Dingen reichen jedoch typedef oder #define Anweisungen, um das Problem zu beheben. Als Beispiel soll hier der NULL-Zeiger angeführt werden. Der NULL-Zeiger wird von den meisten Compilern dazu benutzt, eine Addresse als ungültig zu deklarieren und dadurch Zugriff auf eine ungültige Speicheraddresse zu verhindern. Die einfachste Version sieht so aus:
§§§§ - beginn - §§§§
#ifndef NULL
#define NULL 0
#endif
§§§§ - ende - §§§§

Diese Version birgt durch das Benutzen einer Präprozessor Anweisung allerdings eine Gefahr:
§§§§ - beginn - §§§§
int i;
i = NULL;
§§§§ - ende - §§§§

Dabei haben wir doch NULL zur Verwendung mit Zeigern eingeführt... Das Problem kann allerdings auf diese Weise gelöst werden:
§§§§ - beginn - §§§§
const void * NULL = 0;
§§§§ - ende - §§§§

Der Vorteil bei der Lösung mit const liegt klar auf der Hand: Es kann nicht mehr passieren, das aus Versehen einer "normalen" Variable einfach NULL zugewiesen wird. Der Nachteil liegt allerdings auch auf der Hand: Bei jeder Zuweisung von NULL muss ein typecast gemacht werden, damit der Compiler keine Fehler ausspuckt (ausser der Compiler macht automatische typecasts im Falle von Zuweisung eines void Zeigers).
Was die bessere Lösung ist, muss jeder für sich entscheiden. Viele bevorzugen die erste Variante, da es nicht schlimm ist, einer Variable 0 zuzuweisen, wenn der Fehler bei Ablauf des Programmes relativ einfach rauszufinden ist. Programmierer, die an sehr grossen Projekten arbeiten, bevorzugen meist die zweite Variante, da entdeckte Fehler zur Kompilierungszeit wirtschaftlich gesehen weit günstiger kommen als solche, die erst zur Programmlaufzeit entdeckt werden.
Wie ihr also seht, kann man viele Inkompatibilitäten nicht nur relativ einfach sondern auch auf verschiedene Art und Weise lösen.

2.1. Compiler
----------------
Grundsätzlich ist zu sagen, das man sich bei Compilern die Handbücher und Hilfen sehr genau durchlesen sollte. Alle guten Dokumentationen zu Compilern markieren Dinge, die nur speziell für diesen Compiler zutreffen. Man sollte sich auch bei ANSI-C Funktionen nicht darauf verlassen, das sich die Compilerhersteller wirklich an die Definitionen gehalten haben. Ein Beispiel ist zum Beispiel fopen(), eine der wichtigsten Funktionen. Manche Compilerhersteller haben im zweiten Parameter noch mögliche andere Bedeutungen untergebracht, die spezifisch für Compiler und/oder Betriebssystem sind. Daher gilt vor allem für Anfänger in diesem Bereich: Auch bei ANSI-C Funktionen ist im Handbuch nach besonderen Abhängigkeiten zu schauen und mit anderen Zielplattformen/-betriebssystemen/-compilern zu vergleichen.
Die meisten Compilerhersteller halten sich an die Direktive, vom Betriebssystem abhängige Funktionen und Schlüsselwörter mit einem Unterstrich und vom Compiler abhängige Funktionen und Schlüsselwörter mit zwei Unterstrichen beginnen zu lassen. Man sollte sich allerdings nicht darauf verlassen.

2.2. Variablentyp "bool"
------------------------------
Es gibt Compiler, die den Variablentypen "bool" unterstützen, andere wiederum tun das nicht. Diejenigen, die ihn unterstützen, lassen als Werte nur false (== 0) und true (==1 oder != 0) zu. Will man nun auf einer Plattform bzw. Betriebssytem einen Compiler benutzen, der "bool" unterstützt, während der Compiler für die/das andere Plattform/Betriebssystem das nicht tut, kommt man nicht umhin, typedef oder #define Anweisungen zur Lösung des Problems zu nutzen.

§§§§ - beginn - §§§§
#ifndef bool
#define bool int
#define true 1
#define false 0
#endif
§§§§ - oder - §§§§
typedef int bool
const bool true 1
const bool false 0
§§§§ - ende - §§§§

2.3. Sockets
---------------
Die grössten Abweichungen zu der ursprünglichen Definition der Berkerly Sockets findet man bei MS VC++. Dort gibt es nicht nur einige andere #define Anweisungen und Funktionen, die anders arbeiten, sondern das grundlegende Konzept der Socketbehandlung ist unter Windows anders als zum Beispiel unter Linux. Unter Linux kann man Sockets genauso wie Dateien öffnen, lesen und beschreiben (auch mit den selben Funktionen), während unter Windows Sockets ein in sich abgeschlossenes E/A Konzept ist, das mit den anderen nicht kompatibel ist. Daher hat Windows auch eine Menge von speziellen Funktionen, die nur auf Sockets zugeschnitten sind. Die ANSI-C Funktionen für Sockets funktionieren zwar unter Windows auch, sollten jedoch nur mit Vorsicht und am besten nur im blockierenden Modus verwendet werden. Ansonsten empfiehlt es sich die Windows eigenen Funktionen (beginnen mit WSA) zu benutzen. Im Falle der Sockets kommt man nicht umhin, grössere Teile in Betriebssystem abhängige #define Blöcke einzuschliessen und in Funktionen auszulagern. Einige Sachen (z.B. WSAStartup()) lassen sich jedoch relativ einfach umgehen:
§§§§ - beginn - §§§§
#define WIN32 1
#define LINUX 2
#define SOLARIS 3

#define OS LINUX

#if OS == LINUX
#define WSAStartup()
#elif OS == SOLARIS
#define WSAStartup()
#endif
§§§§ - ende - §§§§

(WSAStartup() ist eine Funktion, die unter Windows die System-DLL zum Ausführen von Socketoperationen lädt und vor allen Socket bezogenen Aufrufen als erste aufgerufen werden muss).
Meine persönliche Empfehlung ist, auf beiden Zielplattformen viel Erfahrung in der Socket Programmierung zu sammeln, um die Unterschiede leichter zu erkennen.

2.4. Threads
----------------
Threads sind ein Konzept, einen Prozess (ein Programm) quasi gleichzeitig mehrere Dinge auf einmal tuen zu lassen. Am weitesten ausgeprägt ist dieses Konzept unter Win32, während Linux an der Verteilung von Aufgaben zur quasi gleichzeitigen Bearbeitung durch Prozesse festhält. Unix bietet (zumindest in den meisten neueren Versionen) beide Möglichkeiten an. Das Thema Threads ist an sich schon relativ komplex (Threadsteuerung, Threadsynchronisation, Datenaustausch etc.) und jedes Betriebssystem bietet völlig andere Lösungswege an. Daher kann hier nur empfohlen werden, alle Thread relevanten Programmteile aus den eigentlichen Funktionen auszulagern und diese entweder durch #define Anweisungen für die einzelnen Betriebssysteme voneinander zu trennen oder in gänzlich verschiedene Funktionen auszulagern. Es wäre auch möglich, alle Funktionen, die mit Threads etwas zu tun haben in jeweils einer eigenen Version für die Betriebssysteme zu schreiben, allerdings erschwert man sich dann später bei einer eventuellen Fehlersuche oder Programmänderungen die Arbeit.

2.5. 64-Bit Variablen
-------------------------
In einer der neueren ANSI-C Definitionen ist der Variablentyp "(unsigned) long long int" aufgenommen worden. Dieser erstellt eine 64-Bit Variable. Der GNU Compiler gcc unterstützt diesen Variablentyp schon längere Zeit, während andere Compiler diesen (noch) nicht unterstützen. Von denen, die ihn nicht unterstützen, gibt es zwei Sorten: Solche, die gar keine 64-Bit Variablen unterstützen und solche, die 64-Bit Variablen unterstützen aber nicht auf die (inzwischen) ANSI-C konforme Weise. Die Compiler, die 64-Bit Variablen überhaupt nicht unterstützen, sind hier einfach mal ausgeklammert. Ein Blick ins Handbuch des Compilers sollte über eine mögliche Unterstützung und deren Art Aufschluss geben, ansonsten probiert man es einfach mal aus. Als Beispiele werden hier mal der MS VC++ 6.0 und der gcc Compiler herangezogen. VC++ stellt den Variablentypen (unsigned) __int64 zur Verfügung, unterstützt jedoch nicht (unsigned) long long int. gcc unterstützt (unsigned) long long int. Um einen Quellcode für beide Compiler nutzbar zu machen, muss man wieder mit Präprozessoranweisungen arbeiten:
§§§§ - beginn - §§§§
#define MSVC 1
#define GCC 2

#define DEST_COMP 1

#if DEST_COMP == 1
#define ULONG64 (unsigned __int64)
#define LONG64 __int64
#elif DEST_COMP == 2
#define ULONG64 (unsigned long long int)
#define LONG64 (long long int)
#else
#error "Nicht unterstuetzter Compiler!"
#endif
§§§§ - ende - §§§§

Bei diesem Beispiel ist im #if Konstrukt auch eine Überprüfung eingebaut, die DEST_COMP auf einen gültigen Wert überprüft. Dies sollte man auf jeden Fall immer machen, um Seiteneffekte durch andere inkludierte Headerdateien auszuschliessen.

2.6. Verzeichnisse und Partitionen
-------------------------------------------
Es gibt zwei Unterschiede zwischen Windows und Linux bezogen auf das Aussehen einer Verzeichnisangabe oder eines vollen Dateinamens:
1. Windows benutzt Bachslashs, Linux Slashs
2. Windows kann mehrere Wurzelverzeichnisse haben (c:\, d:\, e:\), Linux hat nur eines.

Das erste Problem ist relativ simpel gelöst, man muss nur bei allem, wo volle Dateinamen oder Verzeichnisangaben vorkommen könnten nach Übergabe an eine eigene Funktion alles in Slashs oder in Backslashs (was einem lieber ist) umwandeln und vor Aufruf einer Systemfunktion das ganze Betriebssystemabhängig (per #define Anweisung) wieder rückgängig machen.
Bei dem zweiten Problem wird es etwas haariger, wenn Dateien/Verzeichnisse ganz woanders liegen als dort, wo der Prozess ausgeführt wird. Das Wechseln eines Verzeichnisses unter Linux ist relativ simpel, da sich alles eh auf die gleiche Partition bezieht (so wird es uns zumindest vorgegaukelt), während bei Windows auch das Laufwerk ein völlig anderes sein kann. In diesem Fall hilft nur wieder die Trennung zwischen den Betriebssystemen per #if Anweisung und bei Linux dann einfach Aufruf von chdir() und bei Windows von chdir() und chdrive().
Wenn man anfängt, die Angabe von Datei- und Verzeichnisnamen zu bearbeiten, um Portabilität zu erreichen, sollte man dabei nicht andere Möglichkeiten vergessen, z.B. die UNC-Pfadangabe (\\rechnername\freigabe\verzeichnis\dateiname).

2.7. errno, GetLastError() und WSAGetLastError()
--------------------------------------------------------------
Unter Unix und Linux gibt es die globale Variable errno (int), in der viele Funktionen Fehlercodes speichern. Diese Variable gibt es auch unter Win32, allerdings speichern nicht alle Funktionen ihre Fehlercodes da drin. Die nicht dort gespeicherten Fehlercodes kann man per GetLastError() holen. Um das Ganze aber noch etwas kompilzierter zu machen, wird noch eine Funktion von Win32 bereitgestellt, mit der man sich die Fehlercodes der Socketfunktionen holen kann. Diese heisst WSAGetLastError(). Wie geht man nun vor, um portablen Code zu schreiben? Unter Linux ist es uns egal, von welcher Funktion ein Fehlercode gespeichert wird, alle werden in errno gespeichert. Also müssen wir uns erst auf Windows konzentrieren:
§§§§ - beginn - §§§§
#if OS == WIN32
#define ERRNO_NORMAL errno
#define ERRNO_FUNC GetLastError()
#define ERRNO_WSAFUNC WSAGetLastError()
#endif
§§§§ - ende - §§§§

Und da Linux das Ganze relativ egal ist, machen wir das dort so:
§§§§ - beginn - §§§§
#if OS == LINUX
#define ERRNO_NORMAL errno
#define ERRNO_FUNC errno
#define ERRNO_WSAFUNC errno
#endif
§§§§ - ende - §§§§

Eigentlich relativ einfach... oder? Was ist bei Funktionen, die unter Linux etwas in errno speichern, während sie es unter Win32 in einer der Funktion zu übergebenden Speicheraddresse speichern? Und lauten die Fehlermeldungen nicht eh völlig unterschiedlich unter Linux und unter Windows? Das Beispiel kann hier leider nicht vollständig aufgeführt werden, aber eines ist bei der Überprüfung der Fehlercodes sicher: Für jedes Betriebssystem müssen die Fehlercodes in eigene Fehlercodes umgewandelt werden oder die Auswertung eines Fehlercodes muss per #if Anweisung für jedes Betriebssystem einzeln geschrieben werden. Das ist zwar ein Haufen Arbeit, aber man muss das Ganze nur einmal machen, wenn man die entsprechenden Teile so auslagert, das man sie in anderen Projekten wieder benutzen kann (Bibliothek oder reiner Quellcode). Zu der Frage, ob die Fehlercodes unterschiedlich sind: Ja. Zwar versucht MS genau wie Linux die selben Fehlercodes zu nutzen wie BSD sie vormals definiert hat, doch im Laufe der Zeit ist hier und dort doch einiges auseinander gelaufen, ganz abgesehen davon, das manche Fehler einfach spezifisch für ein Betriebssystem sind.

2.8. Systemlogs
--------------------
Unter Win32 kann man (zumindest bei den auf NT basierenden Versionen) Nachrichten in die Ereignisanzeige schreiben. Normalerweise kann das jedes Programm, es wird jedoch empfohlen, das nur Dienste, Treiber oder andere Programme ohne Benutzerinteraktion dort reinschreiben. Das selbe Konzept mit der selben Empfehlung gibt es auch unter Linux unter dem Namen syslog. Da diese beiden Logkonzepte völlig unterschiedlich aufgebaut sind, muss man das Logging in eine eigene Funktion auslagern und per #if Anweisungen Betriebssystem abhängig machen. Dazu müssen auch für die defines des Logging Levels eigene gemeinsame defines erstellt werden. Was wichtig ist und man nicht vergessen sollte ist, das unter Win32 (NT based) das Erstellen einer MSG DLL und die zugehörigen Einträge in der Registry nicht vergessen werden dürfen. Während WinNT das Fehlen einer solchen DLL noch relativ locker nimmt und die eigentliche Nachricht mit einem vorhergehenden Hinweis auf das Fehlen der DLL anzeigt, zeigt Win2003 die Nachricht gar nicht mehr an.

2.9. Registry und INI bzw. CONF Dateien
---------------------------------------------------
Obwohl es unter Win32 üblich und sinnvoll ist, Programmkonfigurationsdaten in die Registry zu schreiben, sollte man sich überlegen, welchen Aufwand bei portablem Code das mit sich führt. Für schnell runter Geahcktes empfiehlt sich also das Benutzen von Konfigurationsdateien im INI Format. Dabei sollte man allerdings drauf achten, das Windows/DOS Zeilenumbrüche mit \r\n definiert, während Linux mit \n zufrieden ist. Möchte man es sauber machen (z.B. bei einer professionellen Anwendung), muss man in den sauren Apfel beissen und das ganze für Win32 Registry basiert und für Linux Datei basiert machen. Macht man es allerdings geschickt, macht man sich auch diese Arbeit nur ein einziges Mal.

2.10. Totalabsturz: Grafik
--------------------------------
Sollte es sich bei der implementierten Anwendung um eine Anwendung mit GUI handeln, wird man mit reinem C Schiffbruch erleiden. Man muss die Teile, die mit der Grafik zu tun haben von den eigentlichen Programmteilen getrennt halten. Wenn man die eigentlichen Programmteile (mit den bisherigen Tips *g*) portabel gehalten hat, ist man fertig mit der Portabilität. Weiter geht es, zumindest mit C, nicht mehr. Es bleiben jetzt zwei Möglichkeiten fortzufahren: Mit TCL/Tk den C Code einbinden und eine grafische Oberfläche erstellen (voll portabel, da es TCL/Tk auch für Windows gibt), wobei der Nachteil der Performanceverlust auf Windows ist. Als zweite Möglichkeit bleibt einem, für Windows in C die Oberfläche zu programmieren und für Linux mit TCL/Tk. Dabei ist die Portabilität natürlich eingeschränkt, aber man hat ein Maximum an Performance auf beiden Systemen. Wie die Entscheidung ausfällt, muss jeder für sich wissen.

2.11. Windows ist Windows ist Windows
-------------------------------------------------
Jeder, der viel unter Win32 programmiert, weiss schon jetzt, auf was ich hinaus will. Es geht um die vielen verschiedenen Versionen von Windows. Was Win98 kann ist für WinNT noch lange nicht selbstverständlich, von WinCE will ich hier gar nicht erst anfangen. Es lohnt sich also, die MSDN zu ordern, wenn man auf vielen verschiedenen Windowsversionen programmiert. Und danach heisst es, viele #defines machen, wenn man Pech hat.

3. Abschliessende Zusammenfassung
-----------------------------------------------
Wer sich mit portierbarer Programmierung beschäftigt und das unbedingt in C machen will, sollte viel Zeit mitbringen. Für "Quick'n'Dirty"-Programme reichen eigentlich Programmiersprachen wie Java völlig aus. Die bringen auch gute Grafikroutinen mit. Wer allerdings Wert auf höchste Performance legt, kommt an C bzw. C++ nicht vorbei. Das dabei die Grafik gegebenfalls etwas auf der Strecke bleibt oder nicht portabel wird ist dabei leider unvermeidlich. Im Grunde gilt es bei portablem Code nur eine Sache mehr zu beachten als beim normalem Programmieren: Das Nachschlagen, wie was wann funktioniert muss schon vor dem Erstellen eines Konzeptes und auf jeden Fall vor dem Schreiben der betreffenden Teile geschehen. Sonst wird man oft darauf stossen, das man Zeit verschwendet hat mit Dingen, die man wieder löschen muss.
Ich hoffe, der erste Einblick in diese Thematik hat dem ambitionierten Programmierer den Umfang der zu beachtenden Sachen deutlich gemacht. Über Rückmeldungen würde ich mich natürlich freuen.
Das Original findet ihr unter http://0-checka.de/port_win_lx.txt

Bewertung Anzahl
6
90,0 %
9 Bewertungen
4
10,0 %
1 Bewertungen