Ich möchte in diesem Artikel aufzeigen wie man ein C-Projekt modularisiert. Sobald ein Programm eine gewisse Größe übersteigt, wird es sehr schnell sehr unübersichtlich. Nun muss man sich Gedanken darüber machen, wie man sein Programm / sein Projekt möglichst so organisiert, dass man entsprechende Code-Passagen schnell wiederfindet. Zusätzlich (und das ist ein enorm wichtiger Faktor) sollte man sein Programm immer möglichst gut kommentieren / strukturieren, so dass sich auch außenstehende mit vergleichsweise geringem Aufwand einarbeiten können. Es kommt immer wieder vor, dass sich Hilfesuchende im Forum melden und nach Fehlern in ihrem Code fragen. Dieser ist dann häufig hoffnungslos unübersichtlich, so dass es allen anderen sehr schwer fällt Hilfestellung zu geben.
Modularisierung
Die Modularisierung ist der Schlüssel zum Erfolg. Unter dem Begriff Modularisierung ist sowohl das Aufteilen des Quellcodes auf mehrere Source-Dateien gemeint als auch das sinnvolle Aufteilen von Algorithmen in separate Funktionen. Es ist dem ein oder anderen vielleicht schon mal aufgefallen, dass er einen bestimmten Code mehrfach in seinem Programm geschrieben hat. In dem Fall lohnt es sich diese Abschnitte in eine Funktion auszulagern.
Funktionen nutzen
Wir schauen uns ein fiktives Beispiel an um den Nutzen von Funktionen besser zu verstehen. Wir schauen uns zunächst ein Beispiel ohne die Nutzung von Funktionen an bevor wir eine bessere Lösung sehen, die die Möglichkeit der Funktionen nutzt:
//... uint8_t a,b,c,small; //... if(a<b) { if(a<c) { small = a; } else { small = c; } } else { if(b<c) { small = b; } else { small = c; } } //... if(a<b) { if(a<c) { small = a; } else { small = c; } } else { if(b<c) { small = b; } else { small = c; } } //...
Man kann sehen, dass das Programm eine gewisse Anzahl an wiederholenden Algorithmen hat. In diesem einfachen Beispiel soll aus drei Zahlen (a,b,c) die kleinste ermittelt werden. Diese Ermittlung wird öfter im Programm benötigt. Daher bietet es sich an diesen Algorithmus in eine Funktion auszulagern, siehe:
uint8_t get_smallest(uint8_t a, uint8_t b, uint8_t c) { uint8_t ret; if(a<b) { if(a<c) { ret = a; } else { ret = c; } } else { if(b<c) { ret = b; } else { ret = c; } } } //... uint8_t a,b,c,small; //... small = get_smallest(a,b,c); //... small = get_smallest(a,b,c); //...
Es sollte nun klar sein, dass Funktionen sehr nützlich sein können um den Programmcode einerseits übersichtlicher zu machen und zum anderen um Speicher / Anweisungen einzusparen. Damit wissen wir also schon mal wie wir Funktionen nutzen können um Struktur in eine C-Datei zu bekommen. Darüber hinaus wird das Programm kleiner. Hierbei sollte bereits darauf geachtet werden, dass eine Funktion die aufgerufen wird, vor dem Aufruf bekannt gemacht worden sein muss! Das bedeutet im Klartext: Entweder wird die Funktion in der C-Datei so platziert, dass alle Aufrufe erst im späteren Verlauf der Datei auftauchen:
void function_XYZ (void) { //... } //... function_XYZ();
Oder man verwendet die Möglichkeit der sogenannten “Prototypen”. Man kann die Funktion erst mit einem Prototypen bekannt machen und sie dann überall aufrufen wo man möchte. Dabei ist die eigentliche Definition, also der Inhalt der Funktion an einer beliebigen Stelle in der Datei, siehe:
// Prototyp der Funktion void function_XYZ (void); //... function_YXZ(); // ... // Definition der Funktion void function_YXZ (void) { //... }
Alles klar, jetzt wissen wir, wie Funktionen innerhalb einer C-Datei verwendet werden können. Als nächtest schauen wir uns an, wie wir Funktionen geschickt auf mehrere C-Dateien aufteilen können und wie man die Aufteilung strukturieren kann.
Dateistruktur
Das nachfolgende Blockschaltbild soll einen ersten Überblick über ein gut strukturiertes Projekt liefern. Ganz oben steht zumindest grafisch die main-Datei, die die Endlosschleife enthält.
Übrigens: In der Endlosschleife der main-Funktion wird kein “üblicher” Programmcode geschrieben. In der Regel werden hier nur Flags abgefragt, die zum Beispiel durch einen Interrupt gesetzt werden. Dann werden entsprechende Funktionen aufgerufen, die die Aufgaben erledigen, die zu den jeweiligen Zeitpunkten notwendig sind.
Ihr seht auch, dass eine der main-Datei untergeordnete C-Datei wiederum auf eine weitere Headerdatei zugreifen kann. Was eine solche Headerdatei ist, werden wir uns nun ansehen.
Zusammenhang zwischen C- und H-Dateien
Wir haben im Blockschaltbild C- und H-Dateien gesehen und wollen nun herausfinden was es mit diesem Dateien auf sich hat. In einer C-Datei schreiben wir für gewöhnlich unser eigentliches Programm, hier werden Funktionen geschrieben. Eine Headerdatei ist im Grunde das selbe. Dem Compiler interessiert es im Grunde nicht ob es sich um eine C- oder H-Datei handelt. Wir nutzen jedoch die H-Dateien (Headerdateien) dazu Prototypen von zugehörigen C-Dateien zu sammeln.
In der Regel hat jede C-Datei eine zugehörige Headerdatei, die den selben Namen wie die C-Datei trägt (bis eben auf die Dateiendung). Während in der C-Datei die Funktionen stehen (die Definitionen), werden in der Headerdatei die Prototypen eingetragen. Nachfolgend ein einfaches Beispiel:
Hier ist schön zu sehen wo die Funktion wirklich definiert (ausgeschrieben) ist, wo sie lediglich via Prototypen der “Außenwelt” bekannt gemacht wird und wo die Funktion verwendet bzw. aufgerufen wird.
- Implementierung: lcd.c
- Bekanntmachung: lcd.h
- Nutzung / Aufruf: main.c
Damit jedoch Funktionen aus einer externern Datei aufgerufen werden können, muss immer die zugehörige Headerdatei mit dem #include Befehl eingebunden werden. In der Headerdatei sind dann wiederum alle Prototypen der zugehörigen C.Datei enthalten, so dass diese genutzt / aufgerufen werden können.
Aufbau einer Headerdatei
Ich habe es bereits erwähnt: Eine Headerdatei ist im Grunde nichts anderes wie eine normale C-Datei, wir nutzen sie nur anders. Um die abweichende Funktionalität deutlich zu machen, wird ein H statt einem C als Dateiendung verwendet. Hier ein klassicher Aufbau einer Headerdatei:
#ifndef NAME_H #define NAME_H #include <xc.h>; #include <stdint.h>; /*Prototypen, Makros, externe Variablen*/ #endif NAME_H
Bei größeren Projekten könnte es unter Umständen vorkommen, dass man versucht, Header-Dateien mehrmals einzubinden. Diese Fehlerquelle kann man mit der bedingten Kompilierung vorbeugen, siehe #ifndef , #define und #endif. Der Platzhalter NAME sollte dann den selben Namen tragen wie die aktuelle Headerdatei in der ihr euch befindet.
In den Grafiken (siehe oben) haben wir bereits gesehen, das auch Headerdateien selber auf andere Headerdateien zugreifen können bzw. teilweise müssen. Das einfachste Beispiel hierzu ist folgendes: In einer Headerdatei wollt Ihr ein Makro für einen Eingangspin festlegen:
#define TASTER PORTCbits.RC0
Diese Zeile würde der Compiler als Fehler markieren. Aber warum, es ist doch alles in Ordnung? Der Fehler liegt darin, dass der Compiler PORTC… gar nicht kennt. Denn in der Headerdatei in der dieses Makro angelegt werden soll, muss die <xc.h> Datei eingebunden werden. Und so bindet eine H-Datei eine weitere H-Datei ein.
Übrigens: Mit den spitzen Klammern werden allgemeine Headerdateien eingebunden, die nicht im direkten Zusammenhang mit einem Projekt stehen. Das sind solche, die bei der Installation des Compilers “mitkommen”, wie zum Beispiel die <xc.h> oder auch <stdint.h>.
Dateiverwaltung mit MPLABX
Wir haben nun gelernt, wie wir ein Projekt übersichtlich und strukturiert mit Hilfe der Modularisierung gestalten können. Ich möchte euch noch zeigen, wie die Dateien entsprechend in der IDE verwaltet werden können.
Auf der linken Seite ist der Reiter Projects zu sehen, der im Dateibaum die zum Projekt gehörenden Dateien darstellt. Die wichtigsten Ordner sind hier Header Files und Source Files. In den Kontext-Menüs, die mit einem Rechtsklick auf den jeweiligen Ordner geöffnet werden können, können neue Dateien erstellt oder bereits existierende hinzugefügt werden.
Übrigens: Mit der Tastenkombination <STRG> + <SHIFT> + <H> kann man ganz schnell zwischen C- und H-Datei hin und her springen. Alternativ gibt es einen kleinen C/H-Button für den Befehl. Und mit gedrückter <STRG> Taste kann man auf eine Funktion drauf klicken und gelangt automatisch zum jeweiligen Gegenstück.
Globale Variablen
Zu guter letzt wollen wir uns noch mit globalen Variablen beschäftigen und wie diese auch wirklich global, also über mehrere C-Dateien hinweg verwendet werden können. Man sollte globale Variablen nur dann verwenden, wenn es auch wirklich Sinn macht. Sie bieten nämlich ein relativ großes Fehlerpotential, wenn man nicht aufpasst.
Wie eine globale Variable angelegt werden kann, wissen wir. Die Deklaration wird einfach außerhalb jeder Funktion erledigt:
bla.c:
#include <xc.h>; #include <stdint.h>; //... uint8_t cnt = 0; //... void function_XYZ (void) { cnt++; }
Doch wie kann die Variable cnt auch außerhalb der Datei main.c verwendet (lesen/schreiben) werden? Hierzu muss sie lediglich in den anderen Dateien bekannt gemacht werden. Ich habe es mir angewöhnt, dass ich zusätzlich zu nahezu jeder C-Datei auch eine H-Datei anlege, die die entsprechenden Informationen über den Inhalt der C-Datei bereitstellt, so auch für globale Variablen.
bla.h:
#ifndef BLA_H #define BLA_h #include <xc.h> #include <stdint.h> //... extern uint8_t cnt; //... #endif
Nun kann jede beliebige C-Datei diese Headerdatei einbinden und weiß, dass die globale Variable cnt existiert und, dass sie sie benutzen kann.
Hallo Nico,
wie immer bei dir, eine sehr hilfreiche Seite. Vielen Dank dafür.
Wo werden sinnvoller Weise die Initialisierungen, Definitionen und die Konfiguration durchgeführt. In der main.c oder main.h?
Moin Micha,
die Konfiguration (also die Konfigurationsbits) kommt üblicherweise in die main.c. Globale Variablen kommen dort hin, wo sie “Sinn” machen. Geht es um die globale Uhrzeit, die überall verfügbar sein muss, dann kommt die Deklaration/Definition bspw. in die rtc.c oder ähnlich.
Bei dem letzten Beispiel hier im Artikel siehst Du die beiden Dateien blac und bla.h. Dort wurde die Variable cnt in der bla.c Datei definiert, deklariert und auch mit dem Wert null initialisiert.
In einer Headerdatei werden hingegen nie Initialisierungen (sprich Wertzuweisungen) gemacht. In Headerdateien werden nur Deklarationen mit dem Schlüsselwort extern durchgeführt.
Ich hoffe ich konnte Dir helfen
Viele Grüße, Nico