PIC C Tutorial – Grundlagen

In diesem Teil des PIC-C-Tutorials beschäftigen wir uns mit den wesentlichen Grundlagen der Programmierung in C. Dabei hat dieses Tutorial keinen Anspruch auf Vollständigkeit. Der Leser sollte jedoch nach dem Durcharbeiten des (gesamten) Tutorials in der Lage sein erste eigene Projekte mit PIC-Mikrocontrollern zu bewältigen und dabei die Hochsprache C als Werkzeug einzusetzen. C ist Case sensitiv! Das bedeutet es macht einen Unterschied ob ihr Buchstaben groß oder klein schreibt. Heißt eine Funktion zum Beispiel get_integer() und Ihr möchtet sie mit Get_gnteger() aufrufen, so wird das nicht funktionieren!

Des weiteren möchte ich euch dringen dazu ermahnen: 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 <xc.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 funktionieren (ohne Fehler compilierbar). 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 dringend empfehlen die Headerdatei stdint.h zu verwenden:

#include <stdint.h>

Nach der Einbindung könnt ihr dann Standard-Integer Typen 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

Deklaration

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

Typ Variablenname = Anfangswert;

Folglich schreibt man:

uint8_t speicher = 0;

Es hat sich durchgesetzt, dass Variablen immer mit einem Kleinbuchstaben beginnen. Das ist natürlich kein Zwang, jedoch ein inoffizieller Standard. Ihr werdet merken, dass nicht nur euer Code lesbarer wird, sondern auch euer Verständnis für fremden Code schneller wächst. Das bedeutet natürlich auch im Umkehrschluss, dass es auch anderen leichter fällt euren Code zu lesen (und das wiederum sollte immer in eurem Interesse sein –  Stichwort Support im Forum bei Problemen).

Somit hätten wir eine Variable des Typ uint8_t (siehe Einbindung von stdint.h oben) mit dem Namen speicher deklariert und direkt mit dem Wert 0 initialisiert. In dieser 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;  // speicher2, speicher3 sind zuvor definierte Variablen

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ürlich 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 uint8_t, da dessen Kapazität lediglich von 0  bis 255 reicht. Es gäbe keinen Fehler beim Kompilieren jedoch erzeugt die Addition in diesem Fall einen Überlauf was zu schwerwiegenden Problemen führen kann. Gegebenenfalls werdet ihr durch eine Warnung vom Compiler auf die Gefahr aufmerksam gemacht. Ein interessanter Beitrag zu diesem Theme im Focus 

Weitere Beispiele:

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

// Dieser Befehl:
speicher1 = speicher1 + 2;
// lässt sich auch kürzer schreiben:
speicher1 += 2;
// mehr dazu später im Tutorial

Speicherklassen

Es sind in C noch diverse weitere 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 Schlüsselwort extern könnt ihr Variablen für andere Module bekannt machen (unter Anwendung von Headerdateien). Ein Beispiel: Im Modul main gibt es eine globale (also in allen Modulen gültige) Variable:

uint8_t versionUID;

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

extern uint8_t versionUID;

Somit ist die globale Variable nicht nur in der C-Datei verwendbar, in der sie deklariert wurde, sondern auch in der C-Datei, welche die Headerdatei mit der extern Anweisung einbindet, denn dieses Modul weiß nun, dass es diese global gültige Variable gibt und genutzt werden kann.

Achtung  Global gültige Variablen werden von Anfängern gerne eingesetzt, da sie so schön einfach von überall aus verwendet werden können. Ihr solltet sie jedoch nur so wenig wie möglich und so häufig wie nötig nutzen, da sie zu merkwürdigem Verhalten eines Programms führen können. Die Ursachen dieser Probleme sind dann oftmals schwer zu identifizieren. Wenn möglich sollte eine globale Variable zumindest wenn möglich das Schlüsslwort static bekommen, siehe hierzu mehr im Unterkapitel zu diesem Schlüsselwort.

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 keine Optimierung an dieser Variable vornehmen.

volatile uint8_t count;

const

Durch const wird eine Variable zu einer Konstanten. Der Wert der Variable ist danach nicht mehr veränderbar! Eine mir const deklarierte Variable wird im Flash und nicht im RAM abgespeichert. Der RAM dient, wie auf dem normalen PC zum Arbeiten und ist folglich nicht für konstante Werte vorgesehen. Daher der Name Arbeitsspeicher. Da eine Konstante nicht verändert werden kann wird sie stattdessen im Flash Speicher abgelegt. So wird der wenige kostbare 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 aus.

So deklariert Ihr eine Konstante:

const uint16_t 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 innerhalb einer Funktion als static gekennzeichnet, so wird es diese Variable unverändert bei erneuten Aufruf dieser Funktion geben.

Merke  Wenn du zwingend eine globale Variable verwenden musst, diese jedoch nur innerhalb dieses Moduls genutzt wird, so stelle ihr immer das Schlüsselwort static voran. Generall gilt: Verwende so wenig globale Variablen wie möglich!
static uint8_t 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 Ablauf. 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 main Funktion mit seiner Arbeit 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 einzelne 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? Ja, allerdings würden wir es mit dem bloßen Auge nicht erkennen können, denn die LED würde so schnell blinken, dass wir es gar sehen können. Schließlich arbeitet PIC so schnell, dass unser Auge das Schalten nicht wahrnehmen kann (vorausgesetzt der Takt des PIC ist in einem üblichen MHz Bereich). Aber ohnehin ist das Programm sinnlos.  Aber 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 schaut euch das Programm einmal genau an, dann werdet ihr feststellen, dass sich eigentlich zum vorherigen Beispiel nicht viel verändert hat. Die main Funktion steht nun lediglich oberhalb den anderen Funktionen. Also das selbe oder? Falsch! Dieses Beispiel würde so nicht funktionieren. Es ist so nicht kompilierbar, weshalb der Compiler eine Fehlermeldung ausgeben würde. Diese würde ungefähr heißen: “Unbekannte Funktion: funktion_a“. Das liegt daran, dass eine Funktion immer vor ihrem Aufruf bekannt sein muss. Da im Beispiel jedoch vor dem Funktionskopf in Zeile 9 bereits ein Aufruf in zeile 3 stattfindet, kommt es zum Fehler.

Um diesem Problem zu begegnen können wir Prototypen einsetzen. Ein Prototyp macht nichts weiter als dem Compiler mitzuteilen, dass es eine zugehörige Funktion gibt, die bekannt gemacht werden soll. Somit führen Funktionsaufrufe zu dieser Funktion (dessen Funktionsrumpf jedoch erst nach dem Aufruf folgt) nicht mehr zu Compiler-Fehlern. Folgendes Beispiel würde demnach wieder funktionieren (siehe Prototypen in Zeile 1 und 2):

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 funktion_a und funktion_b mit Hilfe von Prototypen bereits vor main und der darin enthaltenen Funktionsaufrufen angemeldet haben.

Hinweis  Ein Prototyp wird zum Anmelden von Funktionen genutzt. Somit ist eine Funktion die unterhalb ihrer Funktionsaufrufe steht bereits bekannt. Ein Prototyp ist nichts anderes als der mit einem ; abgeschlossene Kopf einer Funktion:

// Prototyp zum Bekanntmachen von funktion_b
void funktion_b (void);

void funktion_a (void)
{
    // kein Problem, da durch Prototyp bereits bekannt
    funktion_b();
}

void funktion_b (void)
{
    // ...
}

Ü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 (uint8_t);

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

void funktion_a (uint8_t x)
{
    if (x == 5)
    {
        LATAbits.LATA5 = 1;
    }
    else
    {
        LATAbits.LATA5 = 0;
    }
}

Hier würde der PIC in der Zeile 5 die Funktion funktion_a aufrufen und würde den Parameter 5 mit übergeben. Da nun in der funktion_a der Übergabeparameter als uint8_t x deklariert worden ist, steht der übergebene Parameter 5 nun in x (x ist eine normale Variable und kann innerhalb der Funktion als solche genutzt werden). Jetzt wird die Funktion funktion_a abgearbeitet. In der Zeile 10 steht ein Ausdruck, den wir zwar bisher noch nicht behandelt haben (Erklärung folgt später), dessen Bedeutung wir uns aber aber evtl. denken können. if(x == 5) bedeutet, wenn die Speicherzelle oder Variable x den Wert 5 beinhaltet, dann führe aus, was in den folgenden geschweiften Klammern {} steht. Wenn nicht (oder Ansonsten) führe aus was in den Nachfolgenden geschweiften Klammern {} nach dem Schlüsselwort else steht. Da x ja nun tatsächlich den Wert 5 beinhaltet, wird die LED bei diesem Programm immer eingeschaltet werden. Würde die Variable x 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 x == 5 ist in diesem Fall eine so genannte wahre Bedingung / wahrer Ausdruck.

Im Gegensatz zu Rückgabewerten (siehe nachfolgendes Unterkapitel) können einer Funktionen mehrere Übergabeparameter übergeben werden, siehe:

void check (uint8_t a, uint8_t b)
{
    // ...
}

Rückgabewerte

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

uint8_t funktion_a (uint8_t a, uint8_t b);

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

uint8_t funktion_a (uint8_t a, uint8_t b)
{
    return (a+b)
}

Dieses Programm würde der Variable c den Summand der Zahlen 13 und 17 also 30 zuweisen. 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 übergebenen Werte (13 und 17) in die lokal gültigen Variablen a und b geschrieben. Anschließend wird die Funktion durch den Befehl return wieder verlassen. Als Rückgabewert wird (a+b) berechnet und zurück gegeben. Also wird die Zahl 30  als Ergebnis von a+b an die aufrufende Funktion zurück gegeben. Der Rückgabewert wird demnach in die Variable c geschrieben.

Während man einer Funktion mehr als nur einen Übergabewert übergeben kann, kann eine Funktion immer nur maximal einen Rückgabewert zurückgeben! Was wenn man aber mehr als nur einen Rückgabewert benötigt? Die Antwort zu dieser Frage findet ihr, wenn wir uns mit Pointern im weiteren Verlauf dieses Tutorials beschäftigen werden 😉

Ausblick

Wenn du nachdem Lesen der Grundlagen neugierig auf mehr geworden bist, dann lies doch einfach weiter. Im nächsten Artikel des PIC-C-Tutorials beschäftigen wir uns mit den “Werkzeugen”, die uns durch die Programmiersprache C bereitgestellt werden. Einen Teil davon haben wir schon in diesem Artikel gesehen als wir zum Beispiel den Wert einer Variable geprüft (x == 5) oder eine Berechnung durchgeführt haben (a+b). Hier geht es weiter: PIC C Tutorial – Werkzeug

Leave a Comment