PIC-C-Tutorial

Ich möchte Euch hier in diesem kleinen C Kurs etwas die Hochsprache C näher bringen und vor allem wie man damit PIC-Mikrocontroller programmieren kann. Ich persönlich habe damals mit Assembler angefangen, was mir beim Einstieg in C ganz gut geholfen hat. Während ich Assembler nur 1 Jahr lang in der Berufsschule gelernt habe, musste ich mir C komplett selber beibringen. Ihr werdet aber schnell feststellen, dass es nicht unmöglich ist – es macht sogar Spaß :-)

Vorbereitungen

Bevor wir loslegen können bedarf es ein paar Vorbereitungen. Zunächst einmal braucht Ihr die entsprechende Entwicklungsumgebung um das Programm für den PIC zu schreiben. Ich empfehle euch die kostenlose Software MPLABX IDE  direkt von Microchip. Sie ist zum einen kostenlos und lässt sich mit den neuen Compilern (zum Beispiel dem XC8) verbinden, welchen wir ebenfalls für das Schreiben unserer C-Programme benötigen (Download des Compilers auf der selben Webseite).  Nachdem Ihr die IDE und den Compiler (wir benötigen den XC8) installiert habt, kann es auch schon losgehen.

Erstellen eines neuen Projekts

Hierfür bitte einmal mal in diesem  Artikel nachschauen.

Erstes Beispielprogramm

Wenn Ihr gleich das erste Beispielprogramm seht, werdet Ihr euch wahrscheinlich erstmal wundern. Zu sehen sind bloß irgendwelche Buchstaben und Zeichen. Verstehen werdet Ihr vermutlich erstmal wenig. Aber lasst Euch davon nicht abschrecken. Es verhält sich ganz ähnlich wie bei Euren ersten Englisch Unterrichtsstunden, da habt Ihr auch erstmal wenig verstanden. Genauso ist es auch beim Programmieren, denn es ist auch eine Fremdsprache.

Na gut, legen wir los. Das erste Programm:

// Unser erstes Programm

/*Einbinden einer Headerdatei*/
#include <xc.h>

/*Beginn der main-Routine*/
void main (void)
{
   /*Aufruf eines Unterprogramms*/
   LED_Blinken();
}  /*Ende der Funktion*/

Wie ich oben bereits erwähnt habe wird Euch das eventuell erstmal abschrecken. Aber keine Sorge, es sieht komplizierter aus als es ist. Die Grundlagen der Programmiersprache C sind relativ einfach zu erlernen. Es ist allgemein üblich, dass Programmcodes in Englisch kommentiert werden. Ich werde aber grundsätzlich auf Deutsch zurück greifen, da man als Anfänger schon genug damit zu tun hat die Sprache C an sich zu begreifen, da braucht man nicht noch eine Erklärung in einer weiteren Fremdsprache! Wenn Ihr Euch dann irgendwann etwas geübter in der Sprache C bewegt, kann ruhig mal ein englischer Kommentar Platz finden. Aber Schritt für Schritt. Nun zurück zum Beispielprogramm. Nehmen wir das Programm einmal auseinander und schauen uns die einzelnen Befehle/Zeilen an:

// Unser erstes Programm

Das ist ein Kommentar und wird somit nicht vom Compiler(1) berücksichtigt. Durch die einleitenden zwei // wird ein Kommentar angezeigt. Alles was sich nach diesen beiden Slashs in der Zeile befindet hat keinen Einfluss auf das Programm. Sobald aber die Zeile zu Ende ist und Sie in der nächsten Zeile schreiben, ist der Kommentar zu Ende! Wenn Sie Kommentare verfassen, welche über die Länge von einer Zeile hinaus gehen, dann solltet Ihr euch einer anderen Schreibweise bedienen:

(1) Übersetzt das Programm in die Maschinensprache

/*So könnt Ihr
ruhig in mehreren Zeilen schreiben*/

Durch einen Schrägstrich mit einem folgenden Sternchen leitet Ihr ein Kommentar ein. Alles was nun bis zu dem beendenden des Kommentars mit */ geschrieben wird ist in dem Kommentar und wird nicht den Programmcode beeinflussen. Nun schauen wir uns den nächsten Teil des Programms an:

#include <xc.h>

Durch den #include Befehl können wir Funktions Bibliotheken in unser Programm einbinden und somit benutzen. Die Bibliothek des verwendenden PIC ist natürlich gezwungenermaßen erforderlich! Durch den include Befehl werden Dateien in das Programm eingebunden, welche die Dateiendung *.h haben. Also sind es Header-Dateien. Zusammengefasst: Ihr müsst immer die Bibliothek des PIC, den Sie verwenden einbinden. Anders als noch unter dem alten C18-Compiler wird unter Verwendung der XC-Compiler einfach nur noch die XC-Headerdatei eingebunden.

Die Headerdatei xc.h muss immer eingebunden werden!

Projekt Struktur

Man ist (oder besser sollte man) als Programmierer immer bemüht sein eine gewisse Struktur in seinem Projekt zu bekommen, damit es übersichtlich bleibt. Nichts ist schlimmer als eine Codewüste wo nach zwei Wochen kein Mensch mehr durchsteigt. Muss man nach einer gewissen Zeit wieder an seinen Quellcode heran treten um ihn zu Warten (elementarer Bestandteil der Softwareentwicklung) und der Code ist derart schlecht strukturiert, dass man erstmal Stunden braucht um ihn wieder zu verstehen, dann ist damit keinem geholfen. Es hätte den selben Effekt ein ganz neues Programm zu schreiben. Daher sollte man sich an gewisse Konventionen halten:

  • Den Programmcode immer gut kommentieren
  • Moularisieren (Aufteilung in Source und Header Dateien)
  • Funktionen hinreichend verwenden
  • Dokumentation verfassen (oft vernachlässigt)

Mit diesen drei Punkten kann man sich im Nachhinein viel Arbeit, Ärger und Kummer ersparen. Ich möchte besonders für die Einsteiger auf den zweiten und dritten Punkt noch einmal eingehen:

Stellt Euch folgendes Szenario vor: Ihr schreibt ein Programm indem der Analogwert einer Knopfzelle eingelesen/verarbeitet und formatiert auf einem Display dargestellt wird. An sich kein großer Programmieraufwand. Als Einsteiger (aber auch als Fortgeschrittener) könnte man hierfür die Bibliothek des Compilers verwenden. Man bindet diese in mit einem Präprozessor Befehl #include ein und es stehen einem ihre Funktionen zur Verfügung. Schon hat man eine Struktur erstellt. Es würde wenig Sinn machen den gesamten Code direkt in die C-Datei zu kopieren, denn es wird furchtbar unübersichtlich.

Funktionen verwenden um das Programm übersichtlich zu gestalten!

Oftmals bietet es sich an in der main.c auch nur die main-Funktion zu schreiben alles andere lagern wir in weitere C-Dateien aus. Somit erhalten wir maximale Übersicht und es ergeben sich mehrere Source Dateien, welche sich jeweils mit Problemen des Projekts befassen und sollten daher auch einen geeigneten Dateinamen bekommen. Zum Beispiel könnte man sich vorstellen, dass sich die Datei Uhr.c in irgend einer Weise mit der Uhrzeit befasst. In der main.c haben wir dann den vollen Überblick über den Ablauf des Programmes.

Grundsätzlich bekommt jede C-Datei eine sogenannte Header Datei zugewiesen. In dieser Headerdatei stehen wichtige Dinge, welche unter anderem auch für andere Dateien relevant sein können. Ein Beispiel: Wir haben in unserem Projekt zwei C-Dateien: DCF.c und Uhr.c. In der DCF Datei wird das DCF77 Zeitsignal eingelesen und dekodiert. Die einzelnen Zeitinformationen speichern wir in einem Vektor ab. Diese Informationen ist nun aber auch in der Uhr.c Datei notwendig. Wir können also  in der Headerdatei der Uhr.c Datei, also in der Uhr.h,  bekannt machen (Klasse: extern), dass dieser Vektor existiert und wir können ihn somit auch in der Uhr.c Datei verwenden.

Bitte achtet auf diese Strukturhinweise, damit helft Ihr nicht nur euch selber sondern auch anderen, wenn Sie euch bei Problemen unterstützen sollen.

Präprozessor

Der Präprozessor ist ein nützlicher Helfer, der uns das Schreiben von Programmen erleichtert. Wir haben den Präprozessor weiter oben schon angewendet:

Alle Codezeilen, die mit dem Zeichen “#” beginnen sind Anweisungen für den Präprozessor. Beim Kompilieren eines Programms wird zunächst der Präprozessor ausgeführt. Erst danach beginnt das eigentöiche Übersetzen des Quellcodes. Wir erinnern uns an folgende Zeile aus unsrem ersten Programm:

#include <xc.h>

Diese Zeile beginnt mit der Raute und ist folglich eine Anweisung für den Präprozessor. Hier soll er den Inhalt der Headerdatei xc.h an dieser Stelle in die Datei einbinden. Man könnte genauso gut den gesamten Inhalt der Headerdatei durch Copy&Paste an dieser Stelle einfügen. Damit wäre die Übersichtlichkeit jedoch völlig vernichtet. Der Präprozessor kann aber noch mehr…

Makros

Gerne benutzen wir den Präprozessor um Makros in unser Programm einzubauen. So lassen sich Dinge realisieren wie:

#define LED_EIN LATAbits.LATA0=1;
#define LED_AUS LATAbits.LATA0=0;
...
LED_EIN /*Anwendung des Makros*/
...
LED_AUS
...

Dies ist an sich kein C-Code doch durch den Präprozessor können wir uns nützliche Makros einfallen lassen, damit wir den Code leserlicher gestalten. Der Präprozessor sucht im Programmcode nun alle Stellen wo LED_EIN bzw. LED_AUS steht und ersetzt sie mit dem was wir oben im #define definiert haben.

Programmierstil: Makros werden üblicherweise groß geschrieben, damit man sie schnell identifizieren kann. Kleingeschrieben geht es zwar genauso man pflegt aber keinen guten Stil (herzu sei gesagt, dass habe ich erst erfahren als ich Makros schon oft verwendet habe, daher sind sie bei mir oftmals noch klein geschrieben).

Bedingtes Compilieren

Wenn wir ein Programm erstellen verwenden wir häufig kleine Codesnipsel um die Funktionalität des Programms zu prüfen. Zum Beispiel möchten wir eine LED einschalten, wenn eine if-Bedingung erfüllt wurde. Dies soll allerdings im fertigen Programm (Release) nicht mehr der Fall sein. Für solche (und noch mehr) Zwecke eignet sich die bedingte Compilierung des Präprozessors.

Hier ein Beispiel:

#define DEBUG 1
#define LEDEIN LATBbits.LATB3=1;
...

if(TEMPOVER)
{
   #if DEBUG
      LEDEIN
   #endif
   ...
}

Hier würde die LED nur dann eingeschaltet werden, wenn oben im #define eine 1 angegeben ist. Sobald die 1 durch eine 0 ersetzt wird, wird der Programmcode im Bereich zwischen #if DEBUG und #endif weder ausgeführt noch compiliert. Das bedeutet, dass dieser Code noch nicht einmal compiliert wird, wenn DEBUG nicht mit 1 definiert wurde. Somit kriegt zum Beispiel ein Kunde nichts von diesem Code mit.

Hinweis: Der Präprozessor kennt keine geschweiften Klammern, daher müssen wir die Anweisungen in dieses Muster schreiben #if … #endif

Liste aller Präprozessoranweisungen

Nachfolgend eine Aufzählung mit Anweisungen, sowie der Bedeutung, die Ihr mit dem Präprozessor verwenden könnt.

Anweisungen Bedeutung
#include Fügt einen Dateiinhalt ein z.B. eine Headerdatei
#define Definiert ein Makro oder eine symbolische Konstante
#if Verzweige in Abhängigkeit von einem Ausdruck
#undef Aufheben von Makros, d.h. ab jetzt wird makro nicht mehr ersetzt
#elif Weitere Alternative wenn #if nicht zutrifft
#else Alternative wenn #if , #ifdef oder #ifndef nicht zutreffen.
#ifdef Verzeige, wenn eine Präprozessorkonstante definiert wurde.
#ifndef Verzeige, wenn eine Präprozessorkonstante nicht definiert wurde.
#endif Abschluß für #if #ifdef oder #ifndef
#error Fehlernachricht ausgeben.
#warning Ausgabe von Warntexten ohne Abbruch des Vorgangs
#pragma Herstellerspezifische Aktionen

Für weitere Codebeispiele siehe WikiBooks 

Die Main Funktion

void main(void)

Durch diese Codezeile wird die main Funktion angeführt. Alles was nun in den beiden folgenden geschweiften Klammern { } steht, gehört zu der (main) Funktion. Dies ist ganz ähnlich wie in Assembler wo das main Programm durch die Marke main begonnen wird und durch ein Goto main abgeschlossen wird. Das void vor dem main ist der so genannte Rückgabeparameter. Man kann damit einer aufrufenden Funktion einen Wert zurück geben (dies wird aber später unter Funktionen erklärt). Das void nach dem main in Klammern ist der Übergabeparameter. Da wir in diesem Beispiel-Programm auf Über- bzw. Rückgabeparameter verzichten, wird an die stellen void geschrieben was soviel bedeutet wie, wird nicht verwendet, leer.

Die main-Funktion darf nie vergessen werden! Ist die main-Funktion nicht vorhanden, so kann der Compiler das Programm nicht übersetzen, da jedes Programm als erstes in diese Funktion springt.

Da es immer wieder vorkommt, dass Leute in Ihrem Programm eine fehlerhafte Struktur haben, hier nochmal ein allgemeiner Hinweis wie ihr euer Programm strukturieren solltet:

void main(void)
{
   /* Hier können sämtliche Initialisierungen 
      durchgeführt werden (PIC, Display, usw.) */

   while(1)
   {
      /* Hier kommt der Algorithmus/ die Algorithmen hinein,
         jedoch keine Initialisierungen! */
   }
}

C Grundlagen

C ist Case sensitiv! Das bedeutet es macht einen Unterschied ob Ihr Buchstaben groß oder klein schreibt. Heißt eine Funktion zum Beispiel getInteger und Ihr möchtet sie mit GetInteger aufrufen, so wird das nicht funktionieren!

Schreibt eure Programme übersichtlich und wenn möglich auch nur einen Befehl pro Zeile. Schaut Euch mal an wie es aussieht, wenn mehrere Befehle in eine Zeile geworfen werden:

#include <p18cxxx.h>

void main (void) {LED_Blinken(); }

Das ist schlicht und einfach unübersichtlich. Es mag bei diesem Beispiel noch nicht so dramatisch sein, doch wenn das Programm umfangreicher wird, dann verliert man sehr schnell den Überblick. Rein programmtechnisch würde die Schreibweise aber so auch arbeiten. Es ist aber einfach nicht Zweckdienlich. Und außerdem wird Euch jeder Mensch dieser Erde bei einer solchen Programmierung seine Hilfe verweigern, mit Sicherheit! Daher gewöhnen wir uns ein paar Schreibregeln an. Wir schreiben Grundsätzlich nur einen Befehl pro Zeile gefolgt von einem Kommentar. Und wenn eine Funktion eingeleitet wird, dann wird der Code zwischen den geschweiften Klammern eingerückt geschrieben, so erkennt man sofort wo eine Funktion beginnt, wo sie endet und welche Abschnitte zu ihr gehören. Was wir uns nicht aussuchen können ist, dass eine Anweisung immer mit einem Semikolon “;” beendet werden muss. Ein PIC kann nicht mehrere Operationen gleichzeitig ausführen, sondern arbeitet eine Operation nach der anderen sequenziell ab. Das ist nicht weiter schlimm, weil er dies einerseits sehr schnell macht und anderseits kann er durch Interrupts an eine andere Programmstelle springen, dort etwas abarbeiten und anschließend mit der ursprünglichen Arbeit fortfahren.

Variablen

Um überhaupt mit dem Arbeitsspeicher des PIC zu arbeiten brauchen wir Variablen in die wir Daten schreiben und bei Bedarf wieder auslesen können. Bei Variablen unterscheidet man in C zwischen verschiedenen Typen. Grundsätzlich gilt aber, dass man mit dem Speicherplatz sparsam umgehen soll. Braucht man z.B. nur eine Speicherzelle, die eine maximale Speicherkapazität von 256 haben muss, macht es keinen Sinn eine Variable mit dem Fassungsvermögen von 65536 zu verwenden! Bitte denkt daran, die Speicherkapazität in einem Mikrocontroller ist begrenzt. Anders als in einem gängigen PC verfügt ein Mikrocontroller nicht über GByte an Speicher. Wir befinden uns eher im Bereich von KByte.

Folgend die verschiedenen Typen aufgeführt:

Typ Anzahl der Bits Wertebereich
bit 1 0 bis 1
char 8 -128 bis 127
unsigned char 8 0 bis 255
signed char 8 -128 bis 127
int 16 -32768 bis 32767
unsigned int 16 0 bis 65535
short int 16 -32768 bis 32767
unsigned short int 16 0 bis 65535
short long 24 -8 388 608 bis 8 388 607
unsigned short long 24 0 bis 16 777 215
long int 32 -2 147 483 648 bis 2 147 483 647
unsigned long int 32 0 bis 4 294 967 295
float 32 1.17E-38 bis 6.80E+38
double 32 1.17E-38 bis 6.80E+38

Ich würde Euch empfehlen die Headerdatei stdint zu verwenden.

#include <stdint.h>

Nach der Einbindung könnt Ihr dann Standard-Integer verwenden. Das hat de Vorteil, das diese Typen immer gleich sind. Egal ob Ihr nun auf dem PIC oder auf einem x86 Prozessor programmiert. Es ist sofort ersichtlich um was für einen Typ es sich handelt. Oft verwendet werden folgende:

uint8_t a;    // 8 Bit, vorzeichenlos
uint16_t b;   // 16 Bit, vorzeichenlos

Variablen Deklaration

Jetzt haben wir schon die verschiedenen Variablen Typen kennen gelernt aber wie wendet man diese an? Bei der “Variablen Deklaration” wird der Variablen Typ, der Name und eventuell ein Anfangswert festgelegt. Schauen wir uns das einmal anhand eines Beispiels an:

Typ Variablenname = Anfangswert;

Folglich schreibt man:

unsigned char speicher = 0;

Programmierstil: Variablen beginnen immer mit Kleinbuchstaben!

Somit hätten wir eine Variable des Typ unsigned char mit dem Namen speicher deklariert. In diese Variable können wir nun Daten speichern. Um eine Variable zu beschreiben schreiben wir folgendes:

speicher = 100;                    //oder..
speicher = 100+100;                //oder..
speicher = speicher2 + speicher3;

Es lassen sich Variablen natürlich auch über Operationen beschreiben wie in dem Beispiel oben, dabei spielt es keine Rolle ob die Operation mit Zahlen oder anderen Variablen durchgeführt wird. Man muss natürich darauf aufpassen, dass man immer die entsprechenden Variablen-Typen benutzt. Denn eine Addition von 200+200 ergäbe 400 und wäre somit nicht geeignet für eine unsigned char. Es gäbe keinen Fehler beim Kompilieren jedoch erzeugt die Addition in diesem Fall einen Überlauf was zu schwerwiegenden Problemen führen kann. Interessanter Beitrag hierzu im Focus 

Weitere Beispiele:

speicher1 = 100;
speicher2 = 50;
speicher3 = speicher1/speicher2;
speicher4 = 2*speicher3;
//Der Inhalt von speicher4 ist 4

Speicherklassen

Es sind in C noch diverse Speicherklassen vorhanden mit denen zum Beispiel Variablen versehen werden können. Wozu das nützlich sein kann möchte ich Euch in den nächsten Abschnitten zeigen.

extern

Mit dem Keyword extern könnt Ihr Variablen in anderen Modulen bekannt machen (unter Anwendung von Headerdateien). Ein Beispiel: In der Datei main.c gibt es eine globale* Variable:

* überall gültig, sofern bekannt

unsigned char versionUID;

Die kann nun innerhalb einer Headerdatei, die in einer weiteren C-Datei eingebunden wird bekannt gemacht werden:

extern unsigned char versionUID;

Somit ist die globale Variable nicht nur in der C-Datei verwendbar, in der sie deklariert wurde, sondern auch in der C-Datei die die Headerdatei mit der extern Anweisung einbindet.

volatile

Durch das voranstellen von volatile an eine Variable, kann dem Compiler mitgeteilt werden, dass ein Zugriff von außerhalb auf dieses Element möglich sein soll. Ein Zugriff kann zum Beispiel durch eine ISR (Interrupt service routine) erfolgen. Der Compiler wird an dieser Variable keine Optimierung vornehmen.

volatile int count;

const

Durch const wird eine Variable zu einer Konstanten. Der Wert der Variable ist danach nicht mehr veränderbar!

Übrigens speichert der Compiler diese Daten im Flash und nicht im RAM! Der RAM dient, wie auf dem normalen PC zum Arbeiten. Daher der Name Arbeitsspeicher. Da eine Konstante nicht verändert werden kann wird sie im Flash Speicher abgelegt. So wird der wenige RAM Speicher nicht belastet.

Arrays können nur zu einer begrenzten Größe im RAM des PIC gespeichert werden. Der RAM ist in diverse Bereiche unterteilt. Wenn das Array die Größe eines solchen Bereiches überschreitet, so gibt der Compiler eine Fehlermeldung.

So deklariert Ihr eine Konstante:

const int freq_Hz = 32768;

static

Wird eine Variable mit dem Keyword static ausgestattet, so wird diese Variable global. Jedoch nur in dem Modul indem es initialisiert wurde: Ist eine Variable in einer Funktion als static gekennzeichnet, so wird es diese Variable unverändert bei erneuten Aufruf dieser Funktion geben.

static int x;

Funktionen

Jetzt kommen wir zu den Funktionen. Ihr werdet sie beim Programmieren immer wieder benötigen. Sie sind uns eine große Hilfe beim Strukturieren des Programms bzw. dessen Ablaufs. Schauen wir uns zunächst einmal wieder ein Beispiel an:

void Funktion_A(void)
{
  LATAbits.LATA5 = 1;
}

void Funktion_B(void)
{
  LATAbits.LATA5 = 0;
}

int main(void)
{
  Funktion_A();
  Funktion_B();
  Funktion_A();
  Funktion_B();
}

Was wir ja bereits wissen ist, dass ein PIC immer bei der Funktion main beginnt. Nun schauen wir uns an was dort als erstes auf den PIC wartet. In der Zeile 13 wartet die erste Anweisung auf den PIC, welche ihm mitteilt, dass er die Funktion Funktion_A aufrufen soll. Also wird er in die Funktion springen und landet dann bei dieser Anweisung:

LATAbits.LATA5 = 1;

Dies ist ein neuer Befehl, welchen ich hier nur ganz kurz ansprechen möchte (wird später noch genauer erklärt). Durch diesen Befehl wird das Bit 5 des PORTA auf High gesetzt. Wir nehmen an, dass dort evtl. einer Treiberschaltung (Transistor als Schalter) mit einer LED sitzt. Also würde die LED in der Funktion_A eingeschaltet werden. Nachdem der PIC die Funktion_A komplett abgearbeitet hat (in diesem Fall lediglich eine Anweisung) kehrt er dorthin zurück von wo aus er die Funktion aufgerufen hat und geht einen Schritt vorwärts. Also würde er nun Zeile 14 aufrufen, was zur Folge hätte, dass die LED ausgeschaltet wird. Und so weiter… Und so fort… Fazit: Die LED würde also blinken? Jein, denn die LED würde so schnell blinken, dass wir es gar nicht wahrnehmen würden, denn der PIC arbeitet so schnell, dass unser Auge das Schalten nicht wahrnehmen kann. Aber ohnehin ist das Programm sinnlos, es dient ja nur dem Zweck, dass wir es als Lehrbeispiel verwenden können.

Ein weiteres Beispiel:

int main(void)
{
  Funktion_A();
  Funktion_B();
  Funktion_A();
  Funktion_B();
}

void Funktion_A(void)
{
  LATAbits.LATA5 = 1;
}
void Funktion_B(void)
{
  LATAbits.LATA5 = 0;
}

Nun schauen Sie sich das Programm einmal genau an, dann werden Sie feststellen, dass sich eigentlich zum vorherigen Beispiel nicht viel verändert hat. Es steht jetzt lediglich die main Funktion über den anderen Funktionen. Also das selbe oder? Falsch! Dieses Beispiel würde so nicht funktionieren und der Compiler würde eine Fehlermeldung ausgeben, welche ungefähr heißen würde: “Unbekannte Funktion: Funktion_A“. Das liegt daran, dass eine Funktion immer vor der main Funktion stehen muss oder die Funktion, wenn sie unter der main Funktion steht einmalig angemeldet (Prototypen schreiben) werden muss. Folgendes Beispiel würde wieder funktionieren:

void Funktion_A(void);
void Funktion_B(void);

int main(void)
{
  Funktion_A();
  Funktion_B();
  Funktion_A();
  Funktion_B();
}

void Funktion_A(void)
{
  LATAbits.LATA5 = 1;
}

void Funktion_B(void)
{
  LATAbits.LATA5 = 0;
}

Dieses Programm würde funktionieren, da wir die Funktionen vor main angemeldet haben. Dies geschieht einfach indem man die Funktionsköpfe einmal aufführt. Man nennt das auch Prototypen.

Übergabeparameter

Als nächstes geht es um die “void”, die wir bisher nicht angesprochen haben. Funktionen haben in C noch einen kleinen aber feinen Vorteil, denn man kann ihnen Parameter übergeben. Das bedeutet, dass wenn man eine Funktion aufruft ihr einen Wert mitgeben kann und wenn die Funktion zu Ende ist, kann somit ein Wert zurück gegeben werden. Sehen wir uns dazu mal ein Beispiel an:

void Funktion_A(void);
void Funktion_B(void);

int main(void)
{
  Funktion_A(5);
}

void Funktion_A(unsigned char A)
{
  if (A==5)
  {
    LATAbits.LATA5 = 1;
  }
  else
  {
    LATAbits.LATA5 = 0;
  }
}

Hier würde der PIC in der Zeile 6 die Funktion Funktion_A aufrufen und würde den Wert “5” mit übergeben. Da nun in der Funktion_A der Übergabeparameter als “unsigned char A” deklariert wurden ist, steht die “5” nun in “A” (“A” ist nun auch eine normale Variable und kann innerhalb der Funktion als solche genutzt werden). Jetzt wird die Funktion Funktion_A abgearbeitet und beginnt also in der Zeile 11, wo etwas steht, was wir noch nicht kennen (Erklärung folgt) aber uns evtl. erschließen können. Es steht “if(Wenn) (A==5)”, dass bedeutet, wenn die Speicherzelle oder Variable “A” den Wert “5” beinhaltet, dann führe aus, was in den folgenden geschweiften Klammern {} steht… else(Ansonsten), wenn nicht (oder Ansonsten) führe aus was in den Nachfolgenden geschweiften Klammern {} steht. Da “A” ja nun den Wert “5” tatsächlich beinhaltet würde die LED bei diesem Programm immer eingeschaltet sein. Würde die Variable “A” einen von “5” unterschiedlichen Wert annehmen (im Beispielprogramm nicht möglich), würde die else Routine durchlaufen werden und die LED wäre aus. Das (A==5) ist eine so genannte Bedingung, wenn diese Bedingung wahr ist, wird die if-Routine ausgeführt.

Merke: 5 == 5 ist eine wahre Bedingung, während 5 == 4 eine falsche Bedingung ist. Und ganz wichtig: Nicht “==” mit “=” verwechseln! 

Rückgabeparameter

Nun kann man natürlich nicht nur einen Wert übergeben, sondern kann auch Werte zurückgeben. Das sind dann die so genannten Rückgabeparameter und sind im Prinzip nichts groß anderes. Schauen wir uns hierzu doch wieder ein Beispiel an:

void Funktion_A(void);
void Funktion_B(void);

unsigned char c;

int main(void)
{
  c = Funktion_A(13,17);
}

unsigned char Funktion_A(unsigned char A, unsigned char B)
{
  return(A+B)
}

Dieses Programm würde der Variable c den Summand der Zahlen 13 und 17 zuweisen (30). Dies würde folgendermaßen ablaufen: Das Programm will der Variable c einen Wert zuweisen, findet dann jedoch einen Aufruf zu einer Funktion vor. In dieser Funktion werden nun die ihr übergebenden Werte (13 und 17) in die Variablen (A und B) geschrieben. Anschließend wird die Funktion durch den Befehl return wieder verlassen mit dem Rückgabewert (A+B). Also wird die “30” zurück gegeben (Die “30” steht nun in de Variablen c!

Vielleicht stellt Ihr euch gerade die Frage was man machen kann, wenn man mal mehr als einen Rückgabewert benötigt. Dafür müssen wir uns erst mit Zeigern beschäftigen. Also noch etwas Geduld.

Mathematische Operationen

Wir haben ja im Laufe dieses Tutorials bereits Variablen kennen gelernt und im Umgang mit Mikrocontrollern dreht sich im Endeffekt ja alles um Zahlen, daher bietet uns die Sprache C eine Reihe an Rechenoperationen an, die wir benutzen können um einen Ausdruck zu bilden. Ein Ausdruck ist eine Operanden (also den Zahlen) und Operatoren (Rechenzeichen: Plus Minus, …). Es stehen folgende Operationen zur Verfügung:

Arithmetische Operationen

Symbol          Beschreibung 
+               Addition 
-               Subtraktion 
*               Multiplikation 
/               Division 
%               Modulo

Bis auf die Rechenart “Modulo” sollten eigentlich alle anderen Arten klar sein. Die Rechenoperation Modulo teilt eine Zahl durch eine andere Zahl und gibt des Rest aus. Hier ein Beispiel:

A = 11%5  11/5=2 (Rest=1) 
A würde den Wert "1" annehmen

B = 5%3  5/3=1 (Rest=2) 
B würde den Wert "2" annehmen

Logische Operationen

Symbol         Funktion 
&&             UND 
||             ODER 
!              NICHT

Diese Operationen beziehen sich auf die ganze Variable! Nicht Bitweise!

// Beispiele für logische Operationen
A = 10; B = 20; C = 0;
if( (A == 10) && (B < 20) || (C < 5) )
{
/* Wird ausgeführt wenn A den Wert
10 hat UND B kleiner als 20 ist
ODER wenn C kleiner als 5 ist */
}

Relationale Operationen

Ich denke zu den folgenden Operationen brauche ich nicht viel zu sagen. Wobei eine Sache vielleicht erwähnt werden sollte:

Vergleiche zwischen Operanden sollten immer nur dann durchgeführt werden, wenn die Operanden vom selben Typ sind. Vor allem aber nicht signed und unsigned vergleichen. Das kann ähnlich wie das Überlaufen von Variablen zu Problemen führen!

Symbol         Beschreibung 
>              größer 
>=             größer oder gleich 
<              kleiner 
<=             kleiner oder gleich 
==             gleich 
!=             ungleich

 Bitweise Logische Funktionen

Symbol         Operation 
&              bitweises UND 
|              bitweises ODER 
^              bitweises Exklusiv-ODER 
~              Einerkomplement 
>>             bitweises Verschieben nach rechts 
<<             bitweises Verschieben nach link

Hinweis: Die Verschiebeoperationen >> und << sind das selbe wie eine Division bzw. eine Multiplikation mit 2.

Diese Operationen beziehen sich auf jedes einzelne Bit einer Variable! Und so arbeiten sie:

A='10101010' , B='01010101' --> A&B='00000000'
A='10101010' , B='10101010' --> A&B='10101010'

Inkrement / Dekrement

Selbstverständlich sind auch die gängigen Operationen wie das Inkrementieren (+1) oder Dekrementieren (-1) einer Variablen möglich. Man unterscheidet verschiedene Schreibweisen:

A = A+1; A++; ++A;
// A wird bei allen Anweisungen um 1 erhöht
B = B-1; B--; --B;
// B wird bei allen um 1 vermindert

Verarbeitungsreihenfolge

Eine ganz wichtige Regel bei der Verwendung von Operationen ist zu wissen in welcher Reihenfolge diese ausgeführt werden, wenn denn mehrere in einer Zeile/Anweisung stehen. Hier eine Übersicht:

Rang Operation Reihenfolge
1 () [] -> . von links nach rechts
2 ! ~ ++ — + – * & (type) sizeof von rechts nach links
3 * / % von links nach rechts
4 + – von links nach rechts
5 << >> von links nach rechts
6 < <= > >= von links nach rechts
7 == != von links nach rechts
8 & von links nach rechts
9 ^ von links nach rechts
10 | von links nach rechts
11 && von links nach rechts
12 || von links nach rechts
13 ?: von rechts nach links
14 = += -= *= /= %= &= ^= |= <<= >>= von rechts nach links
15 , von links nach rechts

Bei den Operatoren +, -, * und & im Rang 2 handelt es sich um die unären Varianten (positives und negatives Vorzeichen, Verweis- und Adressoperator).

Bitmanipulation

Oftmals ist es notwendig in einem Byte nur ein einzelnes Bit zu verändern. Stellt Euch zum Beispiel vor, dass Ihr in euremProgramm viele Flags habt, die das auftreten eines wichtigen Ereignisses wieder spiegeln. Dann ist es auf dem PC vielleicht “in Ordnung” dafür 8 einzelne Variablen zu definieren doch auf dem Mikrocontroller müssen wir Speicher bewusst arbeiten. Also versuchen wir das effizienter zu lösen. Wir benötigen für 8 Flags nicht 8 Variablen sondern lediglich 1 Variable des Typs char (auch wenn wir keine Buchstaben hier hinein speichern). Dies ist möglich indem wir jedes Bit des Bytes (eine char Variable = 8 Bit = 1 Byte) als einzelnes Flag betrachten. Zum Beispiel ist das Bit 0 für das Auftreten eines Alarms und Bit 1 für eine Schwellwerterkennung …

Wir schaffen uns also eine Variable vom Typ char und initialisieren sie mit null:

unsigned char flags = 0;

Das auftreten eines Alarms (zum Beispiel wenn Bit 0 == 1), können wir nun wie folgt prüfen:

if(flags&0x01) //Wenn Bit 0 eine 1 ist, dann..
{
   // ...
}

Diese if-Anweisung wird nur durchlaufen, wenn das Bit 0 der Variable flags eine 1 ist (siehe Logische Operationen). Die 1 an dieser Stelle muss natürlich auch irgendwie entstehen und zwar ohne die anderen Bits dabei zu verändern und das macht man so:

// Bit 0 setzen, alle anderen nicht ändern
flags = flags | 0x01;
// Verkürzte Schreibweise
flags |= 0x01;

In analogie dazu können wir folgendermaßen ein bestimmtes Bit löschen (hier Bit 0):

// Bit 0 löschen, alle anderen nicht ändern
flags=flags & 0xFE;
// Verkürzte Schreibweise(n)
flags &= 0xFE;
flags &= ~0x01;

Vektoren / Arrays und Strings

Vektoren / Arrays

Wir erinnern uns, dass wir Variablen deklarieren können und in diesen genau einen Wert speichern können. Doch was macht man, wenn man mal mehrere Werte automatisiert speichern will? Dafür benutzen wir sogenannte Vektoren oder mehrdimensional Arrays. Ein Vektor besteht aus einer bestimmten Anzahl von Variablen mit dem selben Typ. Sehen wir uns einmal an wie ein Vektor deklariert werden kann.

Das Muster sieht so aus:

Typ vektorname [Anzahl] = {1, 10, 4, ...};
Int vektor[5] = {0, 3, 4, 9, 10};

Das direkte Zuweisen von Werten ist nur optional!

Somit hätten wir unseren ersten Vektor erstellt. Dieses besteht nun aus fünf Integer Variablen (0-4) mit dem Namen Vektor. Beschreiben können wir den Vektor wie folgt:

vektor[0] = 50;
//Somit steht eine "50" in der ersten Variable
vektor[1] = 50+50;
//Somit steht eine "100" in der zweiten Variable

Ihr seht schon man kann mit dieser Schreibweise Platz sparen, doch bis hier hin würde das auch nicht viel bringen, doch wenn wir uns den nächsten Schritt anschauen, werden wir schnell den Vorteil eines Vektors erkennen:

unsigned char i;
int vektor[5];

for (i=0;i<5;i++)
{
  vektor[i]=5*i;
}

In diesem Beispielprogramm wird in der Zeile 1 erst einmal eine Variable mit dem Namen i deklariert. Gefolgt von einer Deklaration für einen Vektor Namens vektor mit 5 Speicherplätzen*. Dann kommt in der Zeile 4 etwas, das wir bisher noch nicht kennen. Das soll uns aber erst einmal nicht weiter stören (wird später erklärt). Man spricht von einer for-Schleife, diese wird solange wiederholt, bis der Ausdruck i<5 (hier) nicht mehr wahr ist! Durch die Initialisierungsanweisung i=0 wird die Variable i zu Beginn auf 0 gesetzt. Die Schleifenanweisung i++ wird nach jeder durchlaufenden Schleife ausgeführt. Wir errinern uns, dass i++; gleich der Erhöhung von i um 1 ist. Somit wird also die Variable von i hoch gezählt. Also würde diese Schleife genau 5 mal durchlaufen werden. Die for-Schleife wird wie erwähnt noch einmal näher erklärt. Wir gehen also davon aus, dass i seinen Wert nach jeder Schleife um 1 erhöht. So würde sich folgender Inhalt in dem Vektor ergeben:

Wir zählen die “0” immer mit, daher hat ein Vektor mit [5] auch 5 Speicherplätze ( 0-4 ).

Durchgang 0 --> vektor[0] Inhalt: 0
Durchgang 1 --> vektor[1] Inhalt: 5
Durchgang 2 --> vektor[2] Inhalt: 10
Durchgang 3 --> vektor[3] Inhalt: 15
Durchgang 4 --> vektor[4] Inhalt: 20

Und genau dadurch lassen sich später Datenbanken leichter und vor allem mit weniger Schreibarbeit erstellen, als wenn wir jeder Variablen einen Wert einzeln zuordnen müssen und somit auch für jede Variable eine eigene Rechnung haben müssten. Ein Vektor wird zwar ähnlich wie eine Variable deklariert muss aber immer mit einem Index angesprochen werden. Wir haben diesen Index bereits verwendet. In unserem Beispiel war es die Variable “i”. Folglich gilt:

vektor_A = vektor_B;
//Diese Operation kann nicht funktionieren!
for (i=0; i<5; i++) {vektor_A[i] = vektor_B[i];}  //So würde es funktionieren!

Mehrdimensionale Vektoren bzw. Arrays

Bisher haben wir nur über die eindimensionalen Vektoren gesprochen es gibt aber auch noch mehrdimensionale Vektoren. Diese bezeichnet man dann als Arrays. Sie können sich mehrdimensionale Arrays wie eine Tabelle vorstellen.

unsigned char Array_A[5];
//Eindimensional = 5 Zeilen
unsigned char Array_A[5][5];
//Zweidimensional = 5 Zeilen und 5 Spalte

Man kann Arrays auch direkt bei der Deklaration Werte zu weisen. Dies würde wie folgt aussehen:

unsigned char Array_ED [5] = {0,1,2,3,4};
unsigned char Array_MD [4][4] = {0,1,...,14,15};

Strings

Strings sind im Grunde genommen nichts neues. Es sind Vektoren, welche aus dem Typ char gebildet werden und werden dafür benutzt Text zu speichern. Wenn wir einen String deklarieren…

char text[10];

…können in diesem String (nur) 9 Zeichen stehen, nicht 10! Denn es muss immer Platz für das Abschluss-Zeichen die 0 sein. Der Text könnte ja auch bloß 5 Zeichen lang sein und wie sollte der PIC dann wissen wo Schluss ist? Somit wird immer eine abschließende 0 angehängt um das Ende des Textes zu markieren.

Bedingungen

Bedingungen sind sehr wichtig für die Programmsteuerung. Mit ihnen entsteht eigentlich erst ein logischer Zusammenhang. Wir können mit den if/else Bedingungen sehr genaue Steuerung erarbeiten. Wir können mit den Bedingungen Werte überprüfen und in Abhängigkeit Operationen durchführen, Texte ausgeben oder es ggf. nicht machen. Schauen wir uns einmal ein Beispiel an:

int Batterie_Volt;
Batterie_Volt = 500;

if (Batterie_Volt < 1000)
{
  LCD_Write_String("Warnung Akku");
}

Bei dieser if Bedingung würd in Zeile 5 geprüft werden ob der Wert in der Variable Batterie_Volt kleiner ist als “1000”. Wenn diese Aussage wahr ist, also Batterie_Volt kleiner ist als 1000, würde der if Anweisungsblock {} ausgeführt werden. Nun ist dieses Beispiel sinnlos, da wir die Variable Batterie_Volt ja oben immer mit “500” beschreiben. Es würde also immer die if-Funktion ausgeführt werden. (Es ist ja nur ein Beispiel!) Angenommen die Variable nimmt nun einen Wert an, der größer ist als “1000”, dann würde die Warnung nicht gelöscht werden und die if-Funktion würde nicht mehr ausgeführt. Also müssen wir das Programm bearbeiten:

int Batterie_Volt;
Batterie_Volt = 500;

if (Batterie_Volt<1000)
{
  LCD_Write_String("Warnung Batt");
}
else
{
  LCD_Write_String("Batt OK");
}

Nun würde, wenn sich der Wert von Batterie_Volt ändert und größer 1000 wird, die else-Funktion einschalten und durcharbeitet werden. Dadurch hätten wir eine zweiseitige Bedingung. Nur mit der if Abfrage allein, wäre es eine einseitige Abfrage. Wir wissen, dass die Variable Batterie_Volt in diesem Programm immer den Wert “500” behalten wird, es dient ja nur um die Funktionen if, else zu verstehen. Später in richtigen Programmen könnte die Variable zum Beispiel mit dem gemessenen Analogwert (umgewandelt in digital durch den Analog-Digital-Umsetzter des PIC) gefüllt werden und somit wäre das Programm eine Art Batterie-Wächter.

Eine if-Bedingung hat also immer mindestens folgende Ausführung: (einseitig)

if (Ausdruck)
{
   // Anweisungen
}

Eine if-Bedingung kann folgende Ausführung haben (zweiseitig):

if ( X )
{
   // Anweisungen Y
}
else
{
   // Anweisungen Z
}

Vereinfacht gesagt: Wenn X, dann Y ansonsten Z

Folgende Ausdrücke können für die Bedingung benutzt werden:

x == y   logischer Vergleich auf Gleichheit 
x != y   ... Vergleich auf Ungleichheit 
x <  y   ... Vergleich auf "kleiner" 
x <= y   ... Vergleich auf "kleiner oder gleich" 
x >  y   ... Vergleich auf "größer"  
x >= y   ... Vergleich auf "größer oder gleich"

Es können natürlich auch mehrere Bedingungen verknüpfen werden

x && y   wahr, wenn x wahr und y wahr sind 
x || y   wahr, wenn x wahr und/oder y wahr 
!x       wahr, wenn x nicht wahr ist

Ich denke die einzelnen Vergleichsoperatoren dürften klar sein. Für die Bedingungs-Verknüpfung möchte ich hier nochmal ein Beispiel zeiegn, damit das Prinzip klar wird:

if ( (A < B) && (A > 80) || (A == 11) )
{
  // Anweisungen
}

Diese if-Funktion würde nur ausgeführt werden, wenn A kleiner ist als B und A größer ist als 80 oder A gleich 11 ist. So lassen sich mehrere Bedingungen in einer zusammenfassen.

Switch Case

Es wird Euch öfter mal passieren, dass Variablen auf viele verschiedene Zahlenwerte überprüft werden müssen. Nun könnt Ihr das natürlich mit diversen if-Funktionen erledigen, wesentlich eleganter geht dies allerdings mit der Switch-Case-Funktion. Sehen wir uns auch dazu ein Beispiel an:

void Read_ADC(unsigned char channel)
{
  switch(channel)
    {
    case 0:
      ADCON0bits.CHS0=0;
      ADCON0bits.CHS1=0;
      ADCON0bits.CHS2=0;
      ADCON0bits.CHS3=0;
      break;
    case 1:
      ADCON0bits.CHS0=1;
      ADCON0bits.CHS1=0;
      ADCON0bits.CHS2=0;
      ADCON0bits.CHS3=0;
      break;
    case 2:
      ADCON0bits.CHS0=0;
      ADCON0bits.CHS1=1;
      ADCON0bits.CHS2=0;
      ADCON0bits.CHS3=0;
      break;
    default:
      break;
    }
}

Stellt Euch vor, der PIC bewegt sich in main Funktion und bekommt nun die Anweisung in die Funktion Read_ADC zu springen. Bei dieser Anweisung würde der PIC einen Übergabeparameter mitgeben. Wir gehen davon aus, dass der Übergabewert eine “1” ist. Beim erreichen der Funktion wird diese “1” zunächst einmal in die Variable channel geschrieben. Jetzt startet der Anweisungsblock wo direkt die Switch-Case Anweisung wartet. Hier steht nun switch(channel), dass heißt, der PIC würde nun (1 in channel) zu dem Case “1” springen und da arbeiten. Wichtig ist an dieser Stelle auch das break; Denn wenn diese Anweisung am Ende einer case Anweisung nicht stehen würde, dann würden alle anderen Case Anweisungen da drunter auch noch abgearbeitet werden (es sei denn das ist gewollt). Das break kann bei der letzten Case-Anweisung natürlich entfallen, denn da drunter kommt ja ohnehin nichts mehr.

Schleifen

Jetzt haben wir ja schon eine ganze Menge angeschaut aber es gibt selbstverständlich noch mehr. Schleifen! Sie werden immer dann benötigt, wenn ein Anweisungsblock {} mehrfach wiederholt werden soll/muss. Hier mal direkt ein Beispiel:

 while-Schleife

while(1)
{
   // Anweisungen
}

Dieses Beispiel zeigt die Endlos-Schleife, sie muss in jedem C Programm (für µC) vorhanden sein, denn ein PIC arbeitet seinen Programmcode immer durch, wieder und wieder. Eine while Schleife wird solange durchlaufen, wie ihr Ausdruck “wahr” (unterschiedlich zu 0) ist. Da in der Klammer eine 1 steht, ist der Ausdruck immer 1 (=wahr) und die Schleife wird nie verlassen. Es sei denn, die Betriebsspannung wird entfernt. Nachfolgend die Grundschreibweise für eine While-Schleife:

while (Ausdruck)
{
   // Anweisungen
}

Man kann die Anweisung natürlich auch anders gestalten, sodass sich bedingte Schleifen ergeben. Zum Beispiel:

while (i <= 10)
{
   // Anweisungen
}

Diese while Schleife würde solange wiederholt werden, wie der Wert von “i” kleiner/gleich 10. Anders ausgedrückt, die while Schleife würde beendet werden, wenn “i” den Wert 10 überschreitet. Man kann die Bedingung im while Kopf, genau wie bei den if-Funktionen, beliebig erweitern.

for-Schleife

Die for-Schleife haben wir oben in Beispielen schon angesprochen. Ich möchte sie hier aber dennoch noch einmal genau erklären. Eine for-Schleife ist ähnlich wie eine while-Schleife nur dass man im Schleifen-Kopf mehr einbringen kann. Dazu schauen wir uns wieder ein Beispielcode an:

unsigned char i;
int array_01[5];

for (i = 0; i < 5; i++)
{
  array_01[i]=5*i;
}

In einer for-Schleife gibt es immer eine Variablen Initialisierung eine Abbruchbedingung und eine Schleifenanweisung. In dem obigen Beispiel ist die Variablen Initialisierung das: i=0; Somit ist festgelegt, dass die Variable “i” zu Beginn den Inhalt “0” hat. Die Abbruchbedingung ist: i<5; Also sobald diese Bedingung nicht wahr ist, wird die For-Schleife beendet. Die Abbruchbedingung wird immer beim Start überprüft. Zum Schluss noch die Variablen Veränderung, diese ist in dem Beispiel: i++; Also wird nach jeder durchlaufenden Schleife die Avriable “i” um 1 erhöht (inkrementiert). Somit können wir sagen, diese Schleife würde 5 mal durchlaufen werden.

do While-Schleife

Die do while Schleife ist wie eine ganz normale while Schleife, bloß dass diese mindestens einmal durchlaufen wird. Am Ende der Schleife wird dann die Bedingung überprüft, wenn dieser Ausdurck von 0 verschieden ist (wahr), dann wird die Schleife wiederholt, ist der Wert 0 (falsch), dann wird die Schleife beendet!

do
{
} while(i <= 10);

Weitere Steuerungen (break, continue, …)

break Anweisung

Die break Anweisung beendet einen gerade laufenden Programmblock. Hinweis: Es wird die Schleife komplett beendet! Siehe hierzu auch das Beispiel bei Switch Case.

continue Anweisung

Ist ähnlich wie die Break Anweisung. Wenn in einer Schleife die continue Anweisung erscheint, wird der Rest der Schleife nicht mehr durchlaufen sondern der PIC springt sofort zu Schleifenabfrage vor. Also wird nicht wie bei der Break Anweisung die Schleife ganz beendet sondern nur der aktuelle Durchlauf!

return Anweisung

Dir return Anweisung wird hauptsächlich dafür verwendet, wenn Werte aus einer Funktion zurück gegeben werden sollen.

Zeiger (Pointer)

Allgemeines

Ein Zeiger (engl. Pointer) ist eine Variable, welche auf eine Stelle im Speicher zeigt. Die Zeiger haben zu den Vektoren (Arrays) in C eine besondere Verwandtschaft so lässt sich ihre Syntax gegenseitig verwenden. Man benutzt Zeiger zum Beispiel dann, wenn man in einer Funktion eine große Menge an Daten verarbeiten muss, welche hinterher auch z.B. in der main Funktion wieder zur Verfügung stehen müssen. Jetzt hat man mit den Zeigern eine sehr elegante Methode die Verwendung von globalen Variablen zu vermeiden. Man nennt dieses Verfahren dann call by reference, was soviel bedeutet wie: Funktionsaufruf mit einer entsprechenden (Start)Adresse. Man könnte sich vorstellen, dass in einer Funktion ein Datensatz nach dem Alphabet sortiert werden muss. Es steht z.B. ein Vektor mit 500 Elementen zur Verfügung, welche in sich wiederum 20 Elemente für Vornamen, 20 für Nachnamen, 20 für Matrikelnummer,… enthält. Nun wäre es nicht sinnvoll diesen Vektor global zu deklarieren. Er wird lokal in main deklariert und der Funktion, welche sortieren soll, wird lediglich die Adresse des Vektors übergeben. Die Funktion kann nun direkt auf den Inhalt zugreifen und die Variablen verändern ohne, dass der gesamte Inhalt an die Funktion übergeben werden musste (call by value). Aber selbst bei dieser beschriebenen Version gibt es noch Schwächen. Noch eleganter wäre es die 500 Elemente nur mit einer Indizierung zu sortieren, so müssten die ganzen folge Informationen nicht mit verschoben werden, was eine Menge an Rechenzeit spart.

Deklarierung

Ein Zeiger wird wie folgt deklariert:

unsigned char *pText;

Hierbei gilt es zu beachten, dass ein Zeiger immer vom selben Datentyp sein muss, wie das Element auf das er zeigt!

Initialisierung

Um mit einem Zeiger arbeiten zu können muss dem Zeiger eine Adresse zugewiesen werden. Wir deklarieren zunächst eine char Variable indem wir ein Zeichen speichern. Anschließend wird die Adresse dieser Variable in den Zeiger geschrieben. Der Zeiger zeigt nun auf die Adresse im Speicher an dem der Inhalt von der Variable Text steht:

unsigned char text = 'H';
unsigned char *pText = &text;

In der ersten Zeile wird eine Variable des Typ char angelegt und mit dem Wert ‘H’ beschrieben. In der zweiten Zeile folgt ein Zeiger, welche auf den Datentyp char zeigt. Dieser wird auch gleich mit der Adresse der Variable text gefüllt. Das Kaufmanns-Und “&” ist der Adressoperator.

Wir halten also fest, dass der Operator * (Stern) einen Zeiger ankündigt. Ein Zeiger lässt sich auch, wie andere Variablen, direkt bei der Deklarierung initialisieren. Wie das geht seht ihr hier.

Arbeiten mit Zeigern

Wir können mit dem Zeiger nun auch direkt den Wert der Variable ausgeben oder bearbeiten. Durch das Zeichen * vor dem Namen wird angegeben, dass man tatsächlich das erhält, was in der Speicherzelle steht auf die der Zeiger zeigt. Ohne das * wird die Adresse des Zeigers geändert auf die er zeigen soll:

pText = &text; // Adresszuweisung
*pText = 'A'; // Wertzuweisung

Nach der zweiten Zeile steht in der Variable text nicht mehr das ‘H’ sondern ein ‘A’, da wir über den * Operator auf den Inhalt der Speicherzelle zugreifen auf das der Zeiger zeigt.

Vektoren und Zeiger

Wie im Deutschen üblich beschreibt der vordere Teil eines Wortes nur das eigentliche. So ist das folgende (Zeigervektor) eigentlich ein Vektor, welcher aber aus Zeigern besteht. Hier ein kleines Beispiel eines Zeigervektors:

unsigned char *pText[5];

Wir haben nun einen Vektor mit 5 Elementen (0-4) gebildet, welche jeweils ein Zeiger auf den Datentyp unsinged char sind. Ein Zeiger besteht grundsätzlich aus vier Byte, egal auf was er zeigt! Das ist, wenn man mal etwas genauer darüber nachdenkt, auch logisch, da die Adresse nicht länger als 4 Byte ist auch wenn das Element, welches an dieser Stelle steht die nachfolgenden 8 Byte belegt! Mit dem Zeigervektor kann nun wie gewohnt gearbeitet werden, wie wir es von Vektoren kennen:

// Anlegen von Strings
char a[] = "Meier";
char b[] = "Schulze";
char c[] = "Musterman";

// Anlegen eines Zeigervektor
char *pName[3];

// Zuweisung von Adressen
pName[0] = &a[0];
pName[1] = &b[0];
pName[2] = &c[0];

Die Adresszuweisungen könnte man auch anders schreiben, da der Name eines Vektors die Startadresse von diesem ist!

// Zuweisung von Adressen (alternativ)
pName[0] = a;
pName[1] = b;
pName[2] = c;

Weitere Beispiele zum Umgang mit Zeigern:

// Anlegen von zwei Variablen
int a = 4, b;

// Ein Vektor mit 6 Variablen
int z[6];

// Zeiger auf Typ int
int *pInt;

// Jetzt zeigt pInt auf a
pInt = &a;

// Jetzt hat b den Wert 4
b = *pInt;

// Jetzt hat a den Wert 7
*pInt = 7;

// Nun zeigt pInt auf z[2]
pInt = &z[2];

// Jetzt hat z[2] den Wert 9
*pInt = 9;

// Und jetzt zeigt pInt auf z[3]
pInt++;

Erläuterung zu Zeile 26: Da der Compiler aus Zeile 3 weiß, dass es sich um einen Zeiger auf den Datentyp Integer handelt, wird er automatisch die Anzahl der Bytes entsprechend im Adressbereich weiter gehen um auf die nächste Integer Variable zu zeigen. Daher ist es so wichtig, dass der Compiler weiß um welchen Datentypen es sich handelt!

Strukturen mit struct

In C werden Strukturen dafür verwendet verschiedene Elemente (meistens unterschiedlicher) Datentypen zusammenzufassen. Vorstellbar wäre zum Beispiel eine Struktur, welche in einem Projekt diverse Informationen für den Anwender bereit hält wie: Uhrzeit, Datum, Wochentag, Temperatur…

Deklaration

Im folgenden seht ihr ein Beispiel einer einfachen Struktur, welche drei Elemente eines Daums (Tag, Monat und Jahr) zu einer Struktur bündelt. Zur direkten Ausgabe ist der Monat als Text gespeichert:

struct sDatum
{
   int Tag;
   char Monat[10];
   int Jahr;
};

Eine Struktur erstellt Ihr mit dem Schlüsselwort struct. Danach wird der ausgewählte Name für diese Struktur geschrieben. Damit es ersichtlich ist, dass es sich hierbei um eine Struktur handelt bietet es sich an vor den Namen ein kleines “s” zu setzten. In die geschweiften Klammern kommen die Elemente hinein, welche durch die Struktur zusammen gefasst werden sollen. Der Speicherbedarf dieser Struktur läge bei (2+10+2) 14 Byte auf einem System mit 8 Bit Datenbreite (PIC10-PIC18).

Ein struct ist übrigens ein kleiner Wink in Richtung C++ bzw. objektorientierter Programmierung.

Üblicherweise wird die deklariert bzw. definiert man eine Struktur in einer Headerdatei. Die Deklaration an sich belegt noch keinen Speicher. Sie ist quasi nur der Stempel. Eine erzeugte bwz. später definierte Struktur ist dann sozusagen das Abbild des Stempels und benötigt Speicher.

Definition

Mit der Deklaration der Variablen haben wir nun die Möglichkeit geschaffen eine solche Struktur zu erstellen (definieren). Im folgenden ein Beispiel wie eine Struktur nun definiert werden kann:

struct sDatum Datum;

Die Initialisierung erfolgt ahnlich wie bei Vektoren mit geschweiften Klammern hinter der Definition:

struct sDatum Datum = {15, "August", 2012};

Die Deklaration und Definition kann aber auch kombiniert werden, so folgt:

struct sDatum
{
        int Tag;
        char Monat [10];
        int Jahr;
} Datum;

Es können auch Zeiger auf Strukturen und Vektoren von Strukturen gebildet werden:

struct sDatum Fussball[7], *pDatum;

Anwendung

Um mit einer erstellten Struktur arbeiten zu können muss man wissen wie man nun auf die einzelnen Elemente einer Struktur zugreifen kann. Nun, das ist recht einfach hier wird der Punkt Operator verwendet “.”. Hier ein kleines Beispiel:

Datum.Tag = 13;

Und schon ist der Variable Tag in der Struktur Datum der Wert 13 zugewiesen worden. Ein Unterschied zu Vektoren besteht darin, dass man sie in einem einfachen Rutsch (komplett) kopieren kann:

struct sDatum original, kopie;
// ...
kopie = original;

Jetzt stellt euch vor Ihr erstellt die Struktur für euer Projekt in der Funktion main. In eurer Struktur können sich beliebig viele Elemente befinden hier gibt es keine Begrenzung. Nun habt ihr euer Projekt schön auf verschiedene Funktionen aufgeteilt benötigt aber in den Funktionen ebenfalls die Struktur, da zum Beispiel eine Datum Funktion das aktuelle Datum in dieser Struktur abspeichern soll. Die Lösung hierfür lautet call by reference. Ihr könnt nun dieser Funktion, welche Information in eurer Struktur abspeichern soll , die Adresse der in main erstellten Struktur übergeben indem ihr den Adressoperator verwendet. Hier ein Beispiel:

void main (void)
{
   struct sDatum info;

   // ...
   getDatum(&info);
   // ..
}

Nun hat die Funktion getDatum die Adresse der Struktur erhalten, sie weiß also wo im Speicher sich der Inhalt der Struktur aus main befindet. Der große Vorteil ist hierbei, dass man keine Rückgabewerte braucht (was ja an sich schon Unsinn ist, da eine Funktion nur einen Rückgabewert besitzen kann. Man müsste die Funktion also mehrfach aufrufen um alle Daten zu bekommen. Das am besten sofort wieder vergessen!). Ein weiterer Vorteil ist die Geschwindigkeit, denn es wird nun un getDatum mit den original Speicherzellen aus main gearbeitet und es ist nicht notwendig eine Kopie der gesamten Struktur zu erstellen. Dadurch wird das Programm schneller.

Wie aber greifen wir jetzt auf die Inhalte in der Funktion getDatum auf die Struktur zu, da wir ja nun nicht mehr die Struktur selber sondern “nur” einen Zeiger auf diese haben. Nun hierfür gibt es zwei Möglichkeiten wobei nur eine wirklich Verwendung findet: Man kann nun entweder in der Zeigerdenkweise bleiben und es so machen:

void getDatum (struct sDatum *pInfo)
{
   (*pInfo).Tag=11;
}

Oder aber und das ist die übliche Methode: Man verwendet den Pfeil Operator, welcher genau das selbe bewirkt:

void getDatum (struct sDatum *pInfo)
{
   pInfo->Tag=11;
}
Erklärung zum Funktionskopf
Wer es noch nicht ganz verstanden hat warum der Funktionskopf von dem Beispiel zuvor so aussieht, dem sei hier kurz geholfen. Schauen wir uns die Funktionsdeklaration noch einmal an:
void getDatum (struct sDatum *pInfo)

Nun wir möchten der Funktion die Adresse der Struktur aus main übergeben. Nun ist es ja üblich der Funktion im Kopf mitzuteilen welche Daten die Funktion bekommen wird. Und in diesem Fall ist es eine Adresse auf den Datentyp struct Datum.

Eigene Datentypen mit typedef

Mit der Anweisung typedef ist es möglich eigene Datentypen zu erstellen. Zunächst ein Beispiel:

struct sDatum
{
        int Tag;
        char Monat [10];
        int Jahr;
} Datum;

typedef struct sDatum tDatum;

Hier wurde nun ein eigener Datentyp erschaffen. Hinter diesem Datentypen tDatum (Das kleine “t” soll hier wieder der Anschaulichkeit dienen, signalisiert einen eigenen Datentypen) verbirgt sich die Struktur aus einem Kapitel zuvor. Nun lässt sich eine Struktur Beispielsweise einfach erstellen als vorher:

tDatum info;

Man könnte auch direkt beim Definieren einer Struktur den Datentypen erstellen. Wie das geht seht ihr hier:

typedef struct
{
   int Tag;
   char Monat [10];
   int Jahr;
} tDatum;

Aufzählungen mit enum

Mit der Anweisung enum hat man die Möglichkeit in einer Variablen eine begrenzte Anzahl an Möglichkeiten zu speichern. Man könnte sich ja zum Beispiel vorstellen man möchte eine Variable haben, welche einem die Jahreszeit angibt. So benötigt diese Variable vier verschiedene Zustände (Frühling, Sommer, Herbst und Winter). Natürlich wäre es hier auch einfach möglich eine unsigned char Variable zu verwenden aber man möchte sein Programm ja doch so gestalten, dass man die maximale Übersichtlichkeit erhält. Zu besseren Lesbarkeit könnte man auch mit#define arbeiten:

#define FRUEHL 0
#define SOMMER 1
#define HERBST 2
#define WINTER 3

Für solche Aufzählungen bietet C einen besonderen Datentypen: enum:

enum eJahreszeit
{
   Fruehl,
   Sommer,
   Herbst,
   Winter
};

enum eJahreszeit Jahreszeit;

Hier hat Fruehl den Wert 0, Sommer 1,… Man kann die Reihenfolge auch beeinflussen:

enum eJahreszeit
{
   Fruehl=1,
   Sommer,
   Herbst=6,
   Winter
};

Somit hätte Fruehl den Wert 1, Sommer 2, dann geht es bei Herbst mit 6 weiter und folglich hat Winter die 7.

Anwendung

Hier ein kleines Beispiel wie man mit enum arbeiten kann:

enum eJahreszeit
{
   Fruehl,
   Sommer,
   Herbst,
   Winter
};

enum eJahreszeit Jahreszeit;

//...

Jahreszeit = Sommer;

if(Jahreeszeit == Sommer)
{
   // ...
}

Speziell für PIC

Konfiguration

Selbstverständlich müssen in C genauso wie in Assembler (ASM) die Konfiguration für den PIC bestimmt werden. Wenn dies vergessen wird, wird der PIC nichts machen! Nun unterscheidet sich die Einstellung der einzelnen Einheiten etwas. Hier ein beispiel wie Ihr in C, die Konfiguration für einen PIC vornehmen könnt:

#pragma config FOSC=HS   // Taktbereich
#pragma config PWRT=ON   // Power on timer
#pragma config BOR=OFF   // Brown out reset
#pragma config WDT=OFF   // Watchdog timer
#pragma config LVP=OFF   // Low voltage progr.
#pragma config MCLRE=ON  // Master clear reset

Durch die Einleitung #pragma weiß der PIC, dass es sich um Konfigurationsinformationen handelt. Natürlich könnt Ihr die Konfigurationsbits, wie in Assembler, auch manuell einstellen.

So werden die Konfigurationsbits mit MPLABX erstellt:

MPLABX_Configbits

Eingänge / Ausgänge

Hier ein Beispiel, wie Ihr in der Sprache C Zustände von Port Pins einlesen und wie Ihr Pins (Ausgang) setzten oder löschen können:

// Ausgang löschen
LATAbits.LATA4 = 0;

// Ausgang setzten
LATBbits.LATB0 = 1;

// Wahl Ausgang(0)/Eingang(1)
TRISB = 0x80;

// Abfrage eines Ports (Eingang)
if (PORTB == 0xFF);

// Abfrage eines einzelnen Pins
if (PORTBbits.RB0 == 0);
Anmerkung zum Beispiel
TRISB = 0x80;

Hier wurde nun das Bit 7 des Port-B als Eingang und die restlichen Bits als Ausgang definiert, denn 0x80 ist Hexadezimal und steht für Binär: 1000 0000

Also Schreibregel beim Setzten/Löschen von Ausgängen:

LATXbits.LXY = Z;
X: Hier wird der Port eingetragen (A, B,..).
Y: Hier kommt das Bit hinein (0-7) des Ports.
Z: Der Zustand den der Port-Pin annehmen soll.

Dabei darf man natürlich nicht vergessen die TRIS Bits dementsprechend einzustellen! Wir erinnern uns: Eine “1” im TRIS Register schaltet den jeweiligen Port-Pin als Eingang, eine “0” als Ausgang. Im obigen Beispiel seht Ihr ebenfalls wie Ihr die TRIS-Register beschreiben könnt.

Schreibregel beim Lesen von Eingängen:

if (PORTXbits.RXY == 0) {...} else {...}
X: Hier wird der Port eingetragen (A, B,..).
Y: Pin eintragen der gelesen werden soll (0-7)

Dies ist ein Beispiel, wie man in Abhängigkeit vom Zustand eines Einganges den Programmablauf steuern kann!

Wann PORT und wann LAT?

Ein weiterer wichtiger Punkt ist zu wissen, wann man mit PORT und wann mit LAT abeiten muss. Ganz kurz und knapp: LAT bei Ausgängen und PORT bei Eingängen.

Wer sich noch etwas mehr mit den Vorteilen der LAT-Register beschäften möchte kann mal >> hier rein schauen. Sehr interessant!

Interrupts

PIC Mikrocontroller unterscheiden zwei Interrupt Prioritäten. High und low. Wenn Ihr einen Interrupt programmiert habt und ein entsprechendes Event diesen auslöst, so wird der PIC in die so genannte Interrupt-Service-Routine springen. Im Grunde genommen ist das eine Art Funktion. Nur wird diese nicht vom Programmierer aufgerufen sondern vom PIC selber wenn ein entsprechendes Ereignis eintritt.

int tick_count;

void interrupt tc_int(void)
{
   if (TMR0IE && TMR0IF)
   {
      TMR0IF=0;
      ++tick_count;
      return;
   }
   // ...
}

/* Beziehungsweise ein Interrupt mit niedriger Priorität */

void interrupt low_priority tc_clr(void)
{
   if (TMR1IE && TMR1IF)
   {
      TMR1IF=0;
      tick_count = 0;
      return;
   }
   // ...
}

Häufige Fehler

Hier entsteht eine Sammlung von Fragen mit Antworten, welche sehr häufig gestellt werden. Außerdem wird eine Liste mit Fehlern erstellt, welche immer wieder vorkommen.

GLEICH = ist nicht gleich GLEICH ==

Ein Fehler der immer wieder gern gemacht wird ist, dass man bei einem Vergleich das falsche Zeichen wählt. Ein einfaches geschriebenes “=” Zeichen ist dafür da um z.B. Variablen einen Wert zu zuweisen aber nicht als Vergleichsoperator, dies würde einen Syntax Fehler nach sich ziehen und das Compilieren beenden. Siehe:

Falsch

if (A=B) {..} else {..}

Richtig

if (A==B) {..} else {..}

Funktion aufrufen

Wenn man eine Funktion aufrufen möchte die Assembler Leute kennen dies besser unter dem Namen Unterprogramm, dann sollte man doch auf die korrekte Schreibweise achten.(Am besten gleich den nächsten Punkt in diesem Kapitel lesen!) Siehe:

Falsch

void Funktion();
Funktion()
Funktion;

Richtig

Funktion();

Funktion anmelden

Dieser Fehler wird sehr häufig gemacht und dann wundert man sich warum beim Compilieren so ein seltsamer Fehler kommt. Bevor man eine Funktion innerhalb main-Routine aufrufen kann, muss man sie vorher angemeldet haben. Dafür gibt es zwei Möglichkeiten: Entweder man schreibt die Funktion oberhalb main-Routine oder aber man meldet sie vorher an. Siehe:

Falsch

void main (void)
{
  FunktionA();
}

void FunktionA (void) {..}

Richtig

void FunktionA (void) {..}
void main (void)
{
  FunktionA();
}

Oder aber man meldet Sie vorher an:

Richtig

void FunktionA (void);

void main (void)
{
  FunktionA();
}

void FunktionA (void) {..}

Schlusswort

Ich hoffe dieser kleine C Kurs hat Euch etwas geholfen mit PIC Mikrocontrollern und der Verwendung der Sprache C umzugehen. Sollten Ihr dennoch Fragen haben zögert nicht diese im  Forum zu stellen!

10 Comments


  1. Hi!

    Also ich muss dir jetzt auch ein ganz großes Kompliment aussprechen!

    Ich finde deinen 18 & C Kurs hier und deine komplette Seite soooowas von super! :-)

    Seit ein paar Monaten surf ich mehrmals die Woche drauf rum und die ersten Erfolge lassen bereits für sich sprechen.

    Leider gibt es fast keine deutschen Quellen für PIC Programmierung und finde es klasse das du dir soviel Mühe gemacht hast so eine erstklassige Seite zu gestalten.

    Vielen lieben Dank dafür!

    Reply

  2. Hallo Nico.
    Super genial erklärt. Ich habe etliche C Bücher durchgelesen, aber nichts ist so gut beschrieben wie hier.

    Vielen Dank

    Gruss Remo

    Reply

    1. Hallo,

      Danke für das Kompliment. Es freut mich, dass ich Dir mit dem Tutorial helfen konnte 😉

      Viele Grüße
      Nico


  3. Grüße und vielen Dank für die Website!
    Wird gleich mal in die Favoriten aufgenommen…
    Dann kann es demnächst losgehen mit dem Programmieren :)

    Reply

  4. Hi,
    muss das nicht auch *pinfo in der zuweisung sein?

    void getDatum (struct sDatum *pinfo)
    {
    (*info).Tag=11;
    }

    Reply

    1. Danke für den Hinweis – ich habe es korrigiert.


  5. Hallo Nico,
    danke für dieses super Tutorial! Es hat mir schon des öfteren sehr geholfen.
    Viele Grüße Sören

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *