Include-Files (C)

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Include-Files in C/C++ enthalten typischerweise Informationen, die im Rahmen der Kompilierung verschiedener anderer Quelltexte (mehrfach) benötigt werden.

Wer bisher hauptsächlich kleinere (Assembler-) Programme für Mikrocontroller realisiert hat und sich nun langsam an größere Projekte heranwagt und deshalb auf C umsteigt, ist gut beraten, die Möglichkeiten sinnvoll einzusetzen, die Include-Files in C bieten. Dabei gilt es allerdings auch, einige Fallgruben zu vermeiden - mehr dazu in diesem Artikel.

Verwendung von Include-Files

Die Verwendung von Include-Files in C und C++ führt in der Regel zu besser strukturierten und damit besser wartbaren Programmen. Indem bestimmte, zentrale Informationen nur ein einziges Mal hinterlegt werden, fällt insbesondere bei Änderungen weniger Aufwand an.

Der übliche Suffix für Include-Files ist ".h", manchmal auch ".hpp" (für C++), und die Verwendung eines Include-Files ist sehr einfach:

 #include "xyz.h"

Wird der Dateiname in spitze Klammern gesetzt, dann sucht der Präprozessor die Datei nicht im aktuellen Verzeichnis, sondern im Standard-Include-Pfad des Compilers:

 #include <io.h>

Dabei sind auch relative Verzeichnisangaben erlaubt:

 #include <avr/timer.h>

Welches Problem lösen Include-Files?

C und C++ sind insofern sehr einfache Sprachen, als der Compiler sich den Quelltext nur ein einziges mal von oben nach unten durchliest. Insbesondere sieht der Compiler auch nicht nach links oder rechts in andere Dateien und er benutzt auch keine Informationen, die er durch die Compililierung anderer Quellcode Dateien gewonnen hat. All diese Einschränkungen haben nur den einen Zweck, dafür zu sorgen, dass jede Quellcode-Datei immer unabhängig von allen anderen Code-Dateien, die zum selben Projekt gehören, compilierbar ist. So eine Datei nennt man auch eine Übersetzungseinheit, weil sie für sich übersetzbar, also compilierbar, ist.

Allerdings folgen daraus auch ein paar Einschränkungen. Die wichtigste davon ist: Man kann nur verwenden, was in dieser Übersetzungseinheit an vorhergehender Stelle deklariert wurde Die zweit wichtigste davon ist: Jede Übersetzungseinheit muss in sich geschlossen sein, also alle benötigten Informationen beinhalten

Insbesonders letzteres kann in der Praxis zu Problemen führen.

Angenommen ein komplettes Projekt besteht aus 2 Übersetzungseinheiten, a.c und b.c. In b.c sind dabei Funktionen enthalten, die auf einer speziellen Datenstruktur operieren.

b.c

struct DateTime {
  unsigned int  Year;
  unsigned char Month;
  unsigned char Day;
};

void InitDate( struct DateTime* Date,
               unsigned char Day, unsigned char Month, unsigned int Year )
{
  Date->Year  = Year;
  Date->Month = Month;
  Date->Day   = Day;
}

und diese Funktionalität soll in a.c benutzt werden

a.c

struct DateTime {
  unsigned int  Year;
  unsigned char Month;
  unsigned char Day;
};

void InitDate( struct DateTime* Date, unsigned char Day, unsigned char Month, unsigned int Year );

int main(void)
{
  struct DateTime myDate;

  InitDate( &myDate, 22, 3, 63 );
}

dann fällt sofort auf, dass die Deklaration der Struktur DateTime sowohl in b.c als auch in a.c enthalten ist. Das muss auch so sein! Denn damit der Compiler in a.c die Variable myDate erzeugen kann, muss er wissen, wieviel Speicherplatz dafür benötigt wird. Und um dies herauszufinden muss er wiederum den Aufbau der Struktur kennen. Auch wenn diese Informationen in b.c enthalten sind, so reicht dies nicht, dann wenn a.c compiliert wird, steht die Information aus b.c nicht zur Verfügung (Der Compiler sieht nicht nach links oder rechts).

Auf der anderen Seite muss die Strukturdeklaration aber auch in b.c enthalten sein. Denn wenn in der Funktion InitDate an die Elemente der Struktur zugewiesen wird, muss der Compiler ebenfalls den Aufbau der Struktur kennen.

Nun ist es aber unschön, unpraktisch und letzten Endes auch fehleranfällig, in zwei Übersetzungseinheiten dieselbe Information zu duplizieren. Es besteht immer die Gefahr, dass bei einer Änderung an der Struktur dieselbe innerhalb eine Übersetzungseinheit vergessen wird, und dann haben verschiedene Programmteile unterschiedliche Vorstellungen davon, wie die Datenstruktur aufgebaut ist. Redundanz ist hier unerwünscht.

Die Lösung aus diesem Dilemma besteht darin, dass dieser gemeinsame Code-Anteil in eine sog. Header-Datei ausgelagert wird und danach in beide Übersetzungseinheiten in Form eines #include wieder hereingezogen wird.

DateTime.h:

struct DateTime {
  unsigned int  Year;
  unsigned char Month;
  unsigned char Day;
};

void InitDate( struct DateTime* Date, unsigned char Day, unsigned char Month, unsigned int Year );

b.c:

#include "DateTime.h"

void InitDate( struct DateTime* Date,
               unsigned char Day, unsigned char Month, unsigned int Year )
{
  Date->Year  = Year;
  Date->Month = Month;
  Date->Day   = Day;
}

a.c:

#include "DateTime.h"

int main(void)
{
  struct DateTime myDate;

  InitDate( &myDate, 22, 3, 63 );
}

Jetzt gibt es nur noch eine Datei, nämlich DateTime.h, in der die Strukturdeklaration aufgeführt ist. Bei einer Änderung wird auch nur diese eine Datei geändert. Durch die Verwendung dieser Datei in a.c und b.c ist damit sichergestellt, dass die Deklaration immer in beiden Übersetzungseinheiten übereinstimmt. Es muss lediglich dafür gesorgt werden, dass bei einer Änderung in DateTime.h sowohl a.c als auch b.c neu compiliert werden.

Probleme bei Include-Files

In Include-Files werden mitunter Informationen aus anderen Include-Files benötigt und es ist an der Verwendungsstelle eines Include-Files oft nicht das Wissen über die möglicherweise sehr komplexen Abhängigkeiten vorhanden. Nachfolgend wird ausgehend von einer typischen Problemstellung eine Standardtechnik erläutert, die hilft, das Wissen um Abhängigkeiten an der Verwendungsstelle überflüssig zu machen:

Angenommen, es existieren zwei Datenstrukturen für die es die Typdefinitionen s1_t und s2_t gibt, die jeweils in entsprechenden Include-Files hinterlegt sind, also s1.h:

/* Definition der Datenstruktur s1 */
struct s1 {
    ...
};
typedef struct s1 s1_t;
...
/* Funktions-Deklarationen */
void foo(s1_t);
...

Die Datei s2.h sieht so aus:

/* Definition der Datenstruktur s2 */
struct s2 {
    ...
};
typedef struct s2 s2_t;
...
/* Funktions-Deklarationen */
s2_t *bar();
...

Eine bestimmte Applikation benötigt nun beide Struktur- und Funktions-Definitionen und inkludiert entsprechend beide Dateien, es gibt also eine kompilierbare Datei (z. B. main.c) die wie folgt aussieht:

#include "s1.h"
#include "s2.h"
...
int main(void) {
    s1_t a;
    s2_t *b;
    ...
    foo(a);
    b = bar();
    ...
}

So weit, so gut.

Nun kommt es eines Tages zu einer Änderung, die darauf hinausläuft, dass die erste Struktur die zweite als Element enthält. Das heißt die Datei s1.h sieht nun wie folgt aus:

struct s1 {
    s2_t x;
    ...
}
...
/* Funktions-Deklarationen */
void foo(s2_t);
...

Daraufhin wird sich main.c(!) nicht mehr kompilieren lassen, da bei der Verarbeitung von s1.h (noch) nicht bekannt ist, worum es sich um bei s2_t handelt. (Es könnte ja auch einfach nur ein Tippfehler sein!). Erinnern sie sich: Der Compiler liest den Quelltext von oben nach unten durch und durch die Reihenfolge der #include sieht er die Strukturdeklaration von s1_t noch bevor s2_t deklaraiert wurde.

Da es in der Praxis einen immensen Pflegeaufwand auslösen kann, wenn Änderungen in Include-Files Änderungen in vielen weiteren Dateien erfordern (dem Wesen nach soll ja eine Include-Dateien eine Information zentral für viele andere Dateien bereitstellen), muss nach einem Ausweg gesucht werden.

Dieser könnte so aussehen, dass man "vorsorglich" die Beschreibung der verwendeten Datenstruktur in s1.h inkludiert:

#include "s2.h"
struct s1 {
    s2_t x;
    ...
};
...
/* Funktions-Deklarationen */
void foo(s2_t);
...


Inkludiert das Hauptprogramm lediglich s1.h, wäre nun alles in Ordnung, wenn aber beide Dateien inkludiert werden, beschwert sich der Compiler über die doppelte Definition der Datentypen. (Die doppelte Deklaration von Funktionen ist kein Fehler, wenn sie übereinstimmend erfolgt.)

Problemlösung mit Makros

Die übliche und - fast - perfekte Lösung des Problems besteht darin, den eigentlichen Inhalt eines Include-Files vor einer zweiten Verarbeitung durch eine bedingte Kompilierung zu schützen. Bei der bedingten Kompilierung handelt es sich ebenfalls um ein Feature des C-Präprozessors und es wird hier wie folgt auf die Datei s2.h angewendet:

#ifndef S2_h
#define S2_h
struct s2 {
    ...
};
...
/* Funktions-Deklarationen */
s2_t *bar();
...
#endif

Da das beschriebene Problem in größeren Programmsystemen auch in Bezug auf die andere Struktur auftreten könnte, sollte man die Datei s1.h vorsorglich mit einem ähnlichen Schutz ausstatten:

#ifndef S1_h
#define S1_h
#include "s2.h"
struct s1 {
    s2_t x;
    ...
};
...
/* Funktions-Deklarationen */
void foo(s2_t);
...
#endif

Der nette Nebeneffekt ist, dass es damit auch keine Rolle spielt, in welcher Reihenfolge beide Dateien in main.c inkludiert werden.

Zusammenfassung der Regeln

Kochrezeptartig kann man beim Schreiben und Verwenden von Include-Files auch einfach die folgenden Regeln anwenden:

  • Wird die externe Schnittstelle (Datenstrukturen, Funktions-Deklarationen) eines Moduls XYZ in einem Include-File xyz.h beschrieben, so sollte ein bestimmter Makroname (z. B. XYZ_h) für die Steuerung der tatsächlichen Verarbeitung des Include-Files reserviert werden.[1]
  • Der Include-File selbst testet (und definiert anschließend) diesen Makro, um so eine doppelte Verarbeitung zu vermeiden.
  • Wird in einer Kompilierung das Modul XYZ verwendet (= eine seiner Datenstrukturen oder Funktionen), wird auch der Include-File xyz.h in diese Kompilierung eingeschlossen.
  • Verwenden die externen Schnittstellen (was im Header steht) von Modul XYZ ein weiteres Modul UVW, so wird dessen Include-File uvw.h im Include-File xyz.h eingeschlossen.
  • Verwendet ein Modul XYZ in der Implementierung (c-File) ein weiteres Modul UVW, so wird dessen Include-File uvw.h nur im Source-File xyz.c eingeschlossen.

Die beschriebenen Regeln funktionieren zufriedenstellend und entlasten vor allem Programm-Code, welcher Include-Files lediglich einschließt, vom Wissen über komplexe Zusammenhänge zwischen einzelnen Modulen. Ferner spielt die Reihenfolge, in der man Include-Files verwendet, keine Rolle und man kann sie beliebig gruppieren und ordnen, so wie es am übersichtlichsten ist. [2]

Gegenseitige Bezugnahme

Eine Ausnahme von der allgemeinen Regel liegt vor, wenn sich zwei Datenstrukturen gegenseitig verwenden (was nur über Zeiger der Fall sein kann). Alle wie auch immer gearteten Versuche, diese beiden Strukturdefinitionen und Funktionsprototypen in zwei verschiedene Include-Files aufzuteilen, also z. B. s1.h

#ifndef S1_h
#define S1_h
#include "s2.h"
struct s1 {
    struct s2 *px;
    ...
};
typedef struct s1 s1_t;
...
s2_t *bar(s1_t);
#endif

und s2.h

#ifndef S2_h
#define S2_h
#include "s1.h"
struct s2 {
    struct s1 *yp;
    ....
};
...
void foo(s2_t);
#endif

werden scheitern - mit und ohne dem beschriebenen Schutz vor doppelter Verarbeitung!

Die pragmatische Lösung ist hier, alles in einem gemeinsamen Include-File s1_s2.h zu hinterlegen:

#ifndef S1_S2_h
#define S1_S2_h
struct s1 {
    struct s2 *px;
    ....
};
typedef struct s1 s1_t;

struct s2 {
    struct s1 *yp;
    ....
};
typedef struct s2 s2_t;
...
void foo(s2_t);
s2_t *bar(s1_t);
#endif

Das ist insofern sinnvoll, als ein Programm, welches die eine Struktur kennen muss, stets auch die andere benötigt. Die Aufteilung in zwei Include-Files würde also keinen echten Vorteil bringen.

Eine Ausnahme kann lediglich gemacht werden, wenn auf die jeweils andere Struktur ausschließlich über Zeiger[3] zugegriffen wird, dann sind auch zwei Include-Files möglich, nämlich s1.h

#ifndef S1_h
#define S1_h
struct s2; /* Vorausdeklaration */
struct s1 {
    struct s2 *px;
    ...
};
typedef struct s1 s1_t;
...
struct s2 *bar(s1_t);

und s2.h

#ifndef S2_h
#define S2_h
struct s1; /* Vorausdeklaration */
struct s2 {
    struct s1 *py;
    ...
};
typedef struct s2 s2_t;
...
void bar(struct s1 *);

Anstatt den jeweils anderen Include-File einzuschließen, sind nun die oben gezeigten Vorausdeklarationen vorzumehmen. (Allerdings sind damit die Typdefinitionen s1_t und s2_t nicht verfügbar, die Bezugnahme auf die jeweils andere Struktur kann nur über struct s1 und struct s2 erfolgen.)

Eine weitere Möglichkeit besteht darin, die Vorausdeklaration in der selben Headerdatei vorzunehmen, aber außerhalb des Include-Guards.

Damit wird s1.h zu

// struct s1; obsolet
typedef struct s1 s1_t;
#ifndef S1_h
#define S1_h
struct s1 {
    struct s2 *px;
    ...
};
...
struct s2 *bar(s1_t);

und s2.h

typedef struct s2 s2_t;
#ifndef S2_h
#define S2_h
struct s2 {
    struct s1 *py;
    ...
};
...
void bar(struct s1 *);

Weblinks

Anmerkungen

  1. Die genaue Beziehung zwischen dem Makro-Namen und dem Modul- (oder Struktur-) Namen ist dabei nicht so bedeutend. Die übliche Konvention, solche "Steuer-Makros" mit "_h" oder "_H" zu beenden, soll nur helfen, "zufällige" Kollissionen mit Makros zu vermeiden, die einen anderen Zweck haben.
  2. Eine alphabetische Sortierung ist z. B. hilfreich, um bei einem Kompilierfehler schnell überprüfen zu können, ob vielleicht nur ein bestimmter Include-File vergessen wurde.
  3. Bei der Verwendung von C++ gilt das zusätzlich für die Bezugnahme über Referenzen.