Start page   

Multitasking mit Protothreads und Protosockets

Einleitung

In diesem Aufsatz erkläre ich, wie man mit Hilfe von Protothreads und Protosockets eigene Multitasking fähige Programme für kleine Mikrocontroller schreibt. Viele Projekte benutzen diese Technologie, so auch mein AVR Webserver.

Multitasking bedeutet, dass ein Gerät mehrere Aufgaben gleichzeitig erledigen kann, so wie eine Fabrik mit mehreren Fließbändern unterschiedliche Produkte gleichzeitig herstellen kann. Die Fließbänder entsprechen im Programm den Threads, und die Arbeitsschritte an den Bändern entsprechen im Programm den Tasks.

Im Mikrocontroller Umfeld wird dies häufig mit einem Zustandsautomaten realisiert, da dieser wenig Speicher benötigt. Darauf aufbauend entwickelte Adam Dunkels am schwedischen Institut für Computerwissenschaften seine Protothreads.

Zustandsautomat

Zuerst möchte ich ein fiktives Beispielprogramm zeigen, dass den Inhalt einer Textdatei auf den Bildschirm aufgibt. Dieses Programm wird in den nächsten Schritten Multitasking fähig gemacht.
int main() {
    File* file=fopen("datei.txt");              // Datei öffnen

    while (!feof(file)) {                       // Solange das Dateieende nicht erreicht ist ...
        int bytesRead=fread(buffer,10,1,file);  // bis zu 10*1 Bytes lesen
        fwrite(buffer,bytesRead,1,stdout);      // die gelesenen Bytes auf dem Bildschirm ausgeben
    }

    fclose(file);                               // Datei schließen
    return 0;
}
Wenn der Computer während dessen noch etwas anderes erledigen soll, muss man entweder
  1. die while Schleife mit einem Timer aktiv unterbrechen (= präemptives Multitasking) oder
  2. sie muss so umgeschrieben werden, dass sie selbst Rechenzeit an andere Threads abgibt (= kooperatives Multitasking)
Ich will hier die Variante b) mit einem Zustandsautomaten erklären. Dieser besteht aus einer endlosen Programmschleife, in der die einzelnen Threads immer abwechselnd aufgerufen werden.

int main() {
    while(1) {
        mache ein bisschen von Thread 1;
        mache ein bisschen von Thread 2;
    }
}

Damit auch wirklich jeder Thread oft genug an die Reihe kommt, darf keiner der Threads stehen bleiben. Jeder Thread darf pro Schleifendurchlauf nur eine kleine kurze Aufgabe erledigen. Aufgaben, die lange dauern (z.B. das Warten auf eine Benutzereingabe) müssen über mehrere Schleifendurchläufe verteilt werden.

Die Thread-Funktionen des Zustandsautomaten sind so geschrieben, dass sie bei jedem Schleifendurchlauf Bescheid wissen, welchen kleinen Arbeitsschritt (Task) sie als nächstes zu tun haben. Diese Information merkt sich der Thread in seiner Zustands-Variable.

Das folgende Programmbeispiel zeigt, wie so ein Thread programmiert werden kann. Jeder case ist hier ein Arbeitsschritt (Task):

int step=0;
int step2=0;
File* file;
char buffer[10];
int bytesRead;

// Thread 1: Gib den Inhalt einer Datei aus
void print_file(char* filename) {
    switch (step) {
    
        case 0: 
            file=fopen(filename);  // Datei öffnen
            step=1;
            break;
            
        case 1:
            bytesRead=fread(buffer,10,1,file);  // lesen
            step=2;
            break;
            
        case 2:
            fwrite(buffer,bytesRead,1,stdout);  // ausgeben
            if (feof(file)) {  // Entscheiden, was als nächstes kommt
                step=3;
            }
            else {
                step=1;
            }
            break;  
              
        case 3:
            fclose(file);  // Datei schließen
            step=999;  
            break;
            
        case 999:
           // Nichts mehr tun
           break;
    }   
}

// Thread 2: Gib xXxXxX und so weiter aus
void print_x() {
    switch (step2) {
    
        case 0: 
            putchar('x');
            step2=1;
            break;
            
        case 1:
            putchar('X');
            step2=0;
            break;
    }   
}

int main() {
   while (1) {
       print_file("datei.txt");  // Thread 1 aufrufen
       print_x();                // Thread 2 aufrufen
                                 // Hier könnten noch weitere Threads stehen
   }      
}
Das Hauptprogramm besteht hier aus einer simplen Endlosschleife, in der alle Threads abwechselnd aufgerufen werden. Der print_file() Thread führt bei jedem Aufruf nur eine kleine Aktion aus, nämlich:

Wenn die Datei komplett ausgegeben wurde, macht der Thread bei allen weiteren Schleifendurchläufen einfach gar nichts mehr. Oder anders formuliert: Er gibt die Rechenzeit sofort an andere Threads ab. Der zweite Thread gibt immer abwechselnd die Buchstaben x und X aus, und zwar unendlich oft. Er endet niemals.

Angenommen, die Datei enthält genau 50 "u", dann sieht die Bildschirmausgabe so aus:

xXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXxXxXxXxXxXxXxXxXxXxXxX...
Diese Bildschirmausgabe zeigt deutlich, dass beide Threads quasi zeitgleich ablaufen.

Die Variablen "step" und "step2" sind die Zustandsvariablen der Zustandautomaten. Sie geben stets darüber Auskunft, in welchem Zustand sich die Threads befinden, bzw. welche Aufgabe (Task) sie als nächstes ausführen müssen. Anstelle der numerischen Zustände verwendet man in echten Programmen besser Enumerations, da die aussagekräftiger sind und man leichter Schritte einfügen kann.

Protothreads

Die Protothreads von Adam Dunkels sind eine Variante von Zustandsautomaten. Sie beruhen wie das obige Programmbeispiel auf dem switch Befehl. Das obige Programmbeispiel würde mit Protothreads (pt.h) so aussehen:
#include "pt.h"

File* file;
char buffer[10];
int bytesRead;

struct pt thread1;
struct pt thread2;

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);
    file=fopen(filename);
    PT_YIELD(&thread1);
    while (!eof(file)) {
        bytesRead=fread(buffer,10,1,file);
        PT_YIELD(&thread1);
        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(&thread1);
    }
    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}

PT_THREAD(print_x()) {
   PT_BEGIN(&thread2);
   while(1) {
       putchar('x');
       PT_YIELD(&thread2);
       putchar('X');
       PT_YIELD(&thread2);
   }
   PT_END(&thread2);
}

int main() {
    PT_INIT(&thread1);
    PT_INIT(&thread2);
    while (1) {
        print_file("datei.txt");
        print_x();
    }
}
Hinter der Fassade entsteht durch die Protothread-Makros letztendlich ein Programm mit switch und case Befehlen. Die Makros ermöglichen aber eine übersichtlichere Gestaltung des Quelltextes. Man kann den Quelltext leichter von oben nach unten lesen.

Im obigen Beispiel sind thread1 und thread2 die Zustands-Variablen. Hier merkt sich der Thread, an welcher Stelle er unterbrochen wurde, und wo er beim nächsten Aufruf weiter arbeiten muss.

PT_THREAD benutzt man, um eine Funktion zu deklarieren, die als Thread ausführbar ist. Das Makro legt bloß fest, welchen Datentyp der Rückgabewert der Funktion hat, nämlich ein char. Der Rückgabewert zeigt an, ob der Thread beendet ist, oder warum er sich selbst unterbrochen hat. Folgende Rückgabewerte sind in der Datei pt.h definiert:

#define PT_WAITING 0
#define PT_YIELDED 1
#define PT_EXITED  2
#define PT_ENDED   3
Die Zahlenwerte sind je nach Version unterschiedlich, deswegen sollte man in Programmen immer mit den Namen arbeiten.

PT_BEGIN benutzt man ganz am Anfang im Rumpf der Thread-Funktion. An dieser Stelle fügt das Makro den switch Befehl ein.

PT_YIELD gibt Rechenzeit an andere Threads ab, indem es eine Sprungmarke mittels case Befehl setzt und die Ausführung des Threads mit "return PT_YIELDED" unterbricht. Beim nächsten Schleifendurchlauf sorgt der "switch" Befehl dafür, dass der Thread an dieser Stelle fortgesetzt wird.

PT_YIELD_UNTIL gibt solange Rechenzeit ab, bis eine Bedingung wahr ist. Die Funktion wird mit "return PT_YIELDED" unterbochen. Im obigen Programmbeispiel ist die Bedingung einfach "0" (also immer unwahr), was dazu führt, dass der print_file Thread niemals endet.

Das ist in diesem Fall wichtig, denn ansonsten würde der Thread durch die Hauptschleife gleich wieder neu gestartet werden. Das Programm würde dann die Datei immer wieder erneut ausgeben, was hier nicht gewollt ist. Alternativ dazu hätte ich auch im Hauptprogramm den Rückgabewert des Thread abfragen können, damit er nur so lange aufgerufen wird, bis er beendet ist. Etwa so:

int main() {
    PT_INIT(&thread1);
    PT_INIT(&thread2);
    char status=0;
    while (1) {
        if (status!=PT_ENDED) {
            status=print_file("datei.txt");
        }
        print_x();
    }
}
PT_END schließt den switch Befehl ab, und beendet den Thread mit "return PT_ENDED". Dieses Makro muss immer ganz am Ende des Threads verwendet werden. Falls der Thread nach seinem Ende erneut aufgerufen wird, beginnt er wieder von Anfang an.

PT_INIT setzt die Zustandsvariable auf 0. Damit ist sichergestellt, dass die beiden Threads zunächst von Anfang an ausgeführt werden. Dieses Makro muss unbedingt außerhalb der Thread-Funktionen aufgerufen werden, und zwar bevor sie gestartet werden.

Es gibt noch weitere Makros in der Datei pt.h, die im obigen Beispielprogramm nicht vorkommen:

PT_WAIT_UNTIL wartet, bis eine Bedingung wahr ist. Das Makro fügt mittels case Befehl eine Sprungmarke ein, an der die Funktion beim nächsten Aufruf fortgesetzt wird. Die Funktion wird mit "return PT_WAITING" unterbrochen, um Rechenzeit an andere Threads abzugeben. Der Einzige Unterschied zu PT_YIELD_UNTIL ist der andere Rückgabewert.

PT_WAIT_WHILE wartet, solange eine Bedingung erfüllt ist. Das Makro fügt mittels case Befehl eine Sprungmarke ein, an der die Funktion beim nächsten Aufruf fortgesetzt wird. Die Funktion wird mit "return PT_WAITING", um Rechenzeit an andere Threads abzugeben.

PT_RESTART kann innerhalb eines Threads benutzt werden, um wieder vorn vorne zu beginnen. Das Makro setzt die Zustandsvariable auf 0 und verlässt dann die Funktion mit "return PT_WAITING". Beim nächsten Schleifendurchlauf wird sie daher wieder von Anfang an ausgeführt, so wie nach PT_INIT.

PT_EXIT kann man innerhalb eines Threads benutzen, um ihn vorzeitig abzubrechen. Die Funktion endet mit "return PT_EXITED". Falls der Thread nach seinem Ende erneut aufgerufen wird, beginnt er wieder von Anfang an.

Die Makros zum Starten von Unter-Threads erkläre ich weiter unten in dem entsprechenden Kapitel.

Einschränkungen

ProtoThreads dürfen keine switch/case Befehle verwenden, da die Protothread-Makros selbst schon switch/case Konstrukte sind. Verwenden sie stattdessen if und else, wenn nötig.

Die Werte von lokalen Variablen gehen an den Unterbrechungspunkten verloren. Lokale Variablen sind daher nur bedingt verwendbar.

In dem letzten Programmbeispiel hatte ich einige Variablen global deklariert, was dem erfahrenen Programmierer sofort auffallen sollte:

File* file;
char buffer[10];
int bytesRead;
Ohne Threads würde man stattdessen lokale Variablen etwa so nutzen:
void print_file(char* filename) {
    File* file=fopen(filename);
    while (!eof(file)) {
        char buffer[10];
        int bytesRead=fread(buffer,10,1,file);
        fwrite(buffer,bytesRead,1,stdout);
    }
    fclose(file);
}
Wir wollen die Datei aber in vielen kleinen Stückchen als Thread laden, damit andere Threads auch noch Rechenzeit ab bekommen. Wenn man einfach nur die Protothread Makros einfügt, erhält man folgenden nicht funktionierenden Quelltext:
PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    File* file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        char buffer[10];

        int bytesRead=fread(buffer,10,1,file);
        PT_YIELD(&thread1);

        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Mit PT_YIELD habe ich Unterbrechungspunkte eingefügt. An diesen Stellen wird die Funktion für eine Weile Unterbrochen, um Rechenzeit an andere Threads abzugeben. Das Unterbrechen geschieht mit return Befehlen. Aus Sicht des C Compilers ist die Funktion also beendet, und darum wird der Speicherplatz, den die lokalen Variablen belegt haben, wieder für andere Zwecke frei gegeben. Beim Fortsetzen des Threads muss man daher damit Rechnen, dass die lokalen Variablen nun zufällige Werte enthalten.

Dagegen kann man nichts tun. Einen return Befehl, der den Speicher von lokalen Variablen beschützt, gibt es nicht - jedenfalls nicht in der Programmiersprache C. Aus diesem Grund kann man lokale Variablen innerhalb von Threads nur sehr bedingt einsetzen. Das ist der Grund, warum ich die Variablen global deklariert habe.

Lokale Variablen in Protothreads richtig verwenden

Das bedeutet allerdings nicht, dass lokale Variablen grundsätzlich verboten sind. Mit Hilfe einer kleinen Änderung kann der Thread print_file zumindest einen Teil seiner Variablen lokal behalten:
File* file;

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        char buffer[10];
        int bytesRead=fread(buffer,10,1,file);
        fwrite(buffer,bytesRead,1,stdout);

        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Jetzt haben wir nur noch eine globale Variable (file). Die beiden anderen Variablen (buffer und bytesRead) sind lokal und funktionieren so auch korrekt. Denn zwischen der Stelle, wo ihre Werte gesetzt werden (beim Lesen aus der Datei) und der Stelle, wo sie ausgelesen werden (beim Schreiben auf den Bildschirm) befindet sich kein Unterbrechungspunkt mehr.

Diese Änderung hat allerdings auch einen Seiteneffekt. Erinnern sie sich, dass die Ausgabe der Datei immer durch zwei "x" unterbrochen war? Jetzt ist es nur noch ein "x":

xuuuuuuuuuuXuuuuuuuuuuxuuuuuuuuuuXuuuuuuuuuuxuuuuuuuuuuXxXxXxXxXxXxXxXxXxXxXxXxXxXxX...
Wir sehen jetzt nur noch ein "x" zwischen den "u", weil ich einen PT_YIELD zwischen dem Lesen aus der Datei und der Bildschirmausgabe entfernt habe. Folglich gibt dieser Thread nur noch halb so oft Rechenzeit an den anderen Thread (print_x) ab. Die Änderung sieht geringfügig aus, doch sie hat einen erheblichen Einfluss auf die Verteilung der Rechenzeit zwischen den Threads.

Wir können die Rechenzeit wieder wie ürsprünglich verteilen indem wir einen PT_YIELD einfügen:

File* file;

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        char buffer[10];
        int bytesRead=fread(buffer,10,1,file);
        fwrite(buffer,bytesRead,1,stdout);

        PT_YIELD(&thread1);
        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Jetzt geben wir zwischen der Ausgabe der Text-Stücke immer zweimal Zeit an den anderen Thread ab, so dass die Bildschirmausgabe fast wieder der ursprünglichen entspricht:
xuuuuuuuuuuXxuuuuuuuuuuXxuuuuuuuuuuXxuuuuuuuuuuXxuuuuuuuuuuXxXxXxXxXxXxXxXxXxXxXxXx...
Dennoch besteht hier im Vergleich zur Ursprünglichen Variante des Programms ein kleiner feiner Unterschied im zeitlichen Verhalten, der alleine schon an der veränderten Reihenfolge der großen und kleinen "x" erkennbar ist:

vorhernachher
  • Datei öffnen
  • x
  • Datei lesen (10 Bytes)
  • X
  • Datei anzeigen (10 Bytes)
  • x
  • Datei lesen (10 Bytes)
  • X
  • Datei anzeigen (10 Bytes)
  • x
  • ...
  • Datei öffnen
  • x
  • Datei lesen (10 Bytes)
  • Datei anzeigen (10 Bytes)
  • X
  • x
  • Datei lesen (10 Bytes)
  • Datei anzeigen (10 Bytes)
  • X
  • x
  • ...

Bei zeitkritischen Anwendungen, z.B. wenn ein Thread eine Maschine steuert, muss man manchmal auf solche kleinen Unterschiede achten.

Vielleicht fallen ihn spontan statische lokale Variablen als mögliche Alternative ein:

PT_THREAD(print_file(char* filename)) {
    PT_BEGIN(&thread1);

    static File* file=fopen(filename);
    PT_YIELD(&thread1);

    while (!eof(file)) {
        static char buffer[10];

        static int bytesRead=fread(buffer,10,1,file);
        PT_YIELD(&thread1);

        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(&thread1);
    }

    fclose(file);
    PT_YIELD_UNTIL(&thread1,0);
    PT_END(&thread1);
}
Das sieht vielversprechend aus und bei einem ersten Funktionstest wird es auch prima klappen. Aber einen Haken haben die statischen Variablen: sie liegen im gleichen Speicherbereich, wie globale Variablen. Und deswegen kann man diesen Thread nicht mehrmals zeitgleich ausführen. Also egal ob sie nun globale oder lokale statische Variablen verwenden - dieser Thread darf nur einmal gleichzeitig laufen, sonst überschreiben sich die beiden Ausführungen gegenseitig ihre Variablen.

Wenn sie die Hauptschleife wie folgt ändern, wird das Programm mit Sicherheit abstürzen:

int main() {
   while (1) {
       print_file("datei.txt");
       print_file("noch_eine_datei.txt");
   }  
}
Da die Threads nun zwei Dateien quasi zeitgleich lesen, brauchen Sie auch zwei file Variablen, zwei buffer und zwei readBytes Variablen. Aber die existieren eben nur einmal, weil sie entweder im globalen Bereich deklariert sind oder lokal aber statisch (was letztendlich das Gleiche ist).

Dieses Dilemma kann man lösen, indem man alle Thread-Variablen in Strukturen verpackt und dann den Funktionen als Parameter übergibt. Zum Beispiel so:

#include "pt.h"

struct thread_data {
    File* file;
    char buffer[10];
    int bytesRead;
    struct pt thread;
};

struct thread_data data1;
struct thread_data data2;
struct thread_data data3;


PT_THREAD(print_file(struct file_data* data, char* filename)) {
    PT_BEGIN(&data->thread);

    data->file=fopen(filename);
    PT_YIELD(&data->thread);

    while (!eof(file)) {
        data->bytesRead=fread(data->buffer,10,1,file);
        PT_YIELD(&data->thread);

        fwrite(data->buffer,data->bytesRead,1,stdout);
        PT_YIELD(&data->thread);
    }

    fclose(data->file);
    PT_YIELD_UNTIL(&data->thread,0);

    PT_END(&data->thread);
}

int main() {
    PT_INIT(&data1->thread);
    PT_INIT(&data2->thread);
    PT_INIT(&data3->thread);
    while (1) {
        print_file(&data1->thread, "datei_voller_u.txt");
        print_file(&data2->thread, "datei_voller_s.txt");
        print_file(&data3->thread, "datei_voller_h.txt");
    }
}
Jetzt hat jeder Thread seine eigenen Variablen, so dass sie sich nicht gegenseitig stören. Dieses Programm gibt den Inhalt von drei Dateien quasi gleichzeitig aus:
uuuuuuuuuusssssssssshhhhhhhhhhuuuuuuuuuusssssssssshhhhhhhhhhuuuuuuuuuusssssssssshhhhhhhhhh...

Verschachtelte Protothreads

Je nach dem, wieviel ein Thread zu tun hat, kann er ziemlich umfangreich werden. Wohl kaum jemand möchte allerdings Monster-Funktionen mit hunderten Zeilen von Quelltext schreiben. Ein ordentlich strukturiertes Programm teilt große Aufgaben in viele kleine auf. Das will ich anhand eines neuen Beispielprogrammes demonstrieren. Das Programm soll einen Text über eine serielle Verbindung senden. Fangen wir wieder mit einem Quelltext ohne Threads an:
void send_character(char char) {
    while (!serial_ready()) {};  // warte
    serial_write(char);
}

void send_line(char[] string) {
    int i=0;
    while (string[i]!=0) {
        send_character(string[i]);
        i++;
    }
    send_character('\n'); // Zeilenumbruch
}

void send_text() {
    send_line("Hallo");
    send_line("da bist du ja!");
    send_line("Ich habe dich vermisst.");
}

int main() {
    send_text();
}
Die fiktive Funktion serial_ready() liefert 1 (true) zurück, wenn der serielle Port bereit ist, ein Zeichen zu senden. Das kann eine Weile dauern. Die ebenfalls fiktive Funktion serial_write() übergibt einen Buchstaben an den seriellen Port. Wir wollen uns an dieser Stelle nicht mit den technischen Details des seriellen Ports beschäftigen. Es genügt, zu wissen, dass serielle Ports für jeden Buchstaben eine gewisse Zeit benötigen, ihn zu senden. Während das Programm darauf wartet, kann es andere sinnvolle Threads ausführen.

Worum es mir hier geht, sind die über mehrere Ebenen verschachtelten Funktionen:

Jetzt zeige ich, wie man dieses Programm Multitasking fähig macht. Während es darauf wartet, dass der serielle Port bereit wird, soll es "x" Zeichen auf den Bildschirm ausgeben.

#include "pt.h"

struct pt thread1a;
struct pt thread1b;
struct pt thread1c;
struct pt thread2;

int i;

// Thread 1 dritter Teil
PT_THREAD(send_character(char char)) {
    PT_BEGIN(&thread1c);
    PT_WAIT_UNTIL(&thread1c,serial_ready());
    serial_write(char);
    PT_END(&thread1c);
}

// Thread 1 zweiter Teil
PT_THREAD(send_line(char[] string)) {
    PT_BEGIN(&thread1b);
    i=0;
    while (string[i]!=0) {
      PT_SPAWN(&thread1b,&thread1c,send_character(string[i]));
      i++;
    }
    send_character('\n');
    PT_END(&thread1b);
}

// Thread 1 erster Teil
PT_THREAD(send_text()) {
    PT_BEGIN(&thread1a);
    PT_SPAWN(&thread1a,&thread1b,send_line("Hallo"));
    PT_SPAWN(&thread1a,&thread1b,send_line("da bist du ja!"));
    PT_SPAWN(&thread1a,&thread1b,send_line("Ich habe dich vermisst."));
    PT_END(&thread1a);
}

// Thread 2
PT_THREAD(print_x()) {
     PT_BEGIN(&thread2);
     while(1) {
         putchar('x');
         PT_YIELD(&thread2);
         putchar('X');
         PT_YIELD(&thread2);
     }
     PT_END(&thread2);
}

int main() {
    PT_INIT(&tread1a);
    PT_INIT(&tread2);
    while (1) {
        send_text();
        print_x();
    }
}
Den Thread print_x habe ich unverändert von dem Beispiel mit den Textdateien übernommen, dazu gibt es also nichts neues zu erklären. Interessanter ist der Thread send_text mit seinen beiden unter-Threads. Hier sehen Sie ein neues Protothread Makro:

PT_SPAWN startet einen untergeordneten Thread und wartet, bis er sich beendet. Während des Wartens gibt PT_SPAWN natürlich Rechenzeit an andere Threads ab.

In diesen Zusammenhang gibt es noch ein anderes Makro, das man alternativ verwenden kann:

PT_WAIT_THREAD führt einen untergeordneten Thread aus, bis er sich beendet. Anders als PT_SPAWN wird hier die Zustandsvariable des Threads jedoch nicht initialisiert, das muss man dann mit PT_INIT selber machen.

Bei älteren Versionen der Protohtread Makros verhalten sich PT_YIELD und PT_YIELD_UNTIL seltsam. Haupt-Threads geben mit PT_YIELD etwas Rechenzeit an andere Threads ab, um danach fortgesetzt zu werden. Aber untergeordnete Threads werden nach PT_YIELD nicht mehr forgesetzt.

Betroffen sind alle Protohtread Versionen, wo in der pt.h die folgende Zeile zu finden ist:

#define PT_SCHEDULE(f) ((f) == PT_WAITING)
Ich bin nicht sicher, ob das ein Bug oder ein Feature ist. Adam Dunkels hat dieses eigenartige Verhalten in neueren Versionen jedenfalls abgestellt. Um das Problem zu umgehen, kann man in betroffenen Versionen anstelle von PT_YIELD einfach PT_WAIT_UNTIL(&thread,1) benutzen.

Achten Sie darauf, für jeden untergeordneten Protothread eine eigene Zustandsvariable zu benutzen (thread1a, thread1b, thread1c). Ansonsten überschreiben sich die Threads gegenseitig ihren Zustand, was in der Praxis meistens zu unvollständigen Ergebnissen aufgrund von vorzeitig abgebrochenen Threads führt.

Beachten sie auch, dass ich die Variable i in den globalen Bereich verschoben haben. Das war nötig, weil zwischen dem Setzen und Lesen der Variable in send_line ein Unterbrechungspunkt liegt, nämlich PT_SPAWN.

Auch hier können Sie wieder alle Variablen des ersten threads in einer Struktur zusammenfassen und beim Aufruf der Funktionen übergeben, so wie wir das oben bei dem Programm mit den Textdateien schon gemacht haben. Die Struktur würde aus folgenden Variablen bestehen:

struct thread_data {
    struct pt thread1a;
    struct pt thread1b;
    struct pt thread1c;
    int i;
};
Das war alles zum Thema Protothreads. In den nächsten Kapiteln geht es um Netzwerk-Programmierung mit µIP und Protosockets.

µIP Applikationen

µIP ist ein kleiner TCP/IP Stack für Mikrocontroller, entwickelt von Adam Dunkels. µIP verarbeitet hereinkommende Ethernet Pakete und sendet ausgehende Pakete. Auch der Verbindungsaufbau zu anderen Computern wird von µIP abgewickelt. Da µIP mehrere Verbindungen gleichzeitig verwalten kann, müssen die µIP Applikationen Multitasking fähig sein.

Ich beziehe mich auf die µIP Version 1.0, für das Verständnis dieses Artikel genügt jedoch ein Blick in die beiden Header Dateien pt.h und psock.h. Mein AVR Webserver ist eine vollständige µIP Anwendung.

In diesem Artikel will ich nicht alle Details von µIP erklären, denn dazu hat Adam Dunkels im Reference Manual schon genug geschrieben. Wir beginnen mit dem Einstiegspunkt der Applikation, also an der Stelle, wo die Verbindung gerade aufgebaut wurde.

µIP Applikationen arbeiten Ereignisgesteuert. Es gibt folgende Ereignisse:

Nachdem µIP eine Verbindung aufgebaut hat, ruft es für jedes Ereignis auf der Verbindung die Applikation auf.

Grundgerüst einer µIP Applikation

Die Einstiegsfunktion wird durch die Definition UIP_APPCALL konfiguriert. Die hier angegebene Funktion ruft µIP bei jedem Ereignis auf.
#define UIP_APPCALL appcall

void appcall(struct application_state *app_state) {
    if (uip_closed() || uip_aborted() || uip_timedout()) {
        return;
    }
    if (uip_connected()) { 
        PSOCK_INIT(&app_state->socket, app_state->inputbuffer, sizeof(app_state->inputbuffer));
    }
    send_and_receive(app_state);
}
Wenn die Verbindung beendet wurde, macht diese Applikation gar nichts. Wenn eine neue Verbindung geöffnet wurde, initialisiert sie einen Protosocket und startet dann den Protothread send_and_receive. Bei allen anderen folgenden Ereignissen (also uip_newdata, uip_poll und uip_rexmit) setzt die Applikation den Protothread send_and_received fort.

App_state ist eine Struktur, in der die Applikation alle Variablen ablegen soll, die zwischen den Ereignissen nicht verloren gehen sollen. Für Protosockets muss die Struktur mindestens diese beiden Variablen enthalten:

struct application_state {   
    struct psock socket;     // Zustandsvariable des Protosockets
    char inputbuffer[100];   // Puffer für empfangene Daten
};

typedef struct application_state uip_tcp_appstate_t;
µIP reserviert für jede Verbindung eine Instanz dieser Struktur. Wenn Sie µIP so konfigurieren, dass es maximal 4 Verbindungen gleichzeitig bedienen kann, dann gibt es genau 4 Instanzen dieser Struktur. Diese liegen im Speicherbereich für statische Variablen.

Für jede bestehende Verbindung übergibt µIP immer die selbe Instanz dieser Struktur an die Applikation - darauf kann man sich verlassen. Sie können die Struktur nach belieben mit zusätzlichen Variablen erweitern. So ist sichergestellt, dass die Applikation Multitasking fähig ist.

Protosockets

Protosockets wurden von Adam Dunkels entwickelt, um die Programmierung von Netzwerk-Kommunikation auf Mikrocontrollern möglichst komfortabel zu machen. Sie erben (fast) alle Eigenschaften von Protothreads, und fügen weitere Funktionen zum Senden und Empfangen von Daten hinzu. Wir beginnen mit einem einfachen Protosocket:
static PT_THREAD(send_and_receive(struct application_state *app_state))
{
    PSOCK_BEGIN(&app_state->socket);
    while (1) {  

        // Sende Hallo
        PSOCK_SEND_STR(&app_state->socket,"Hallo\n"); 
 
        // Empfange bis zu einem Zeilenumbruch
        PSOCK_READTO(&app_state->socket, '\n');
                        
        // Wurde ein exit Befehl empfangen?
        if (strstr(app_state->inputbuffer,"exit")) {
            PSOCK_SEND_STR(&app_state->socket,"Tschüss\n"); 
            PSOCK_CLOSE_EXIT(&app_state->socket);
        }
    }
    PSOCK_END(&app_state->socket);
}
Bauen Sie eine Verbindung zum Mikrocontroller mit einem Telnet Programm auf. Gleich nach dem Verbindungsaufbau sendet die Applikation den Text "Hallo", dann wartet sie auf eine Eingabe. Wenn sie jetzt "exit" eingeben, beendet sich die Applikation mit der Meldung "Tschüss". Wenn sie irgend etwas anderes eintippen, wiederholt das Programm die "Hallo" Meldung:
telnet 192.168.2.100
< Hallo
> blabla
< Hallo
> exit
< Tschüss
Verbindung beendet

Funktionen von Protosockets

Protosockets senden und empfangen immer abwechselnd - gleichzeitig geht es nicht. Pro Verbindung darf man auch immer nur einen Protosocket laufen haben. In Kombination mit dem Pufferspeicher im Ethernet Controller erhält man letztendlich dennoch eine Full-Duplex Kommunikation auf dem Kabel.

Protosockets kümmern sich automatisch um die Unterscheidung der drei Events (uip_newdata, uip_poll und uip_rexmit). Sie kopieren empfangene Daten vom Ethernet Paket Puffer in den inputbuffer der Applikation. Beim Senden warten sie, bis µIP dazu bereit ist und schreiben dann die zu sendenden Daten direkt in den Ethernet Paket Puffer. Und wenn das gesendete Paket beim Empfänger nicht angekommen ist, sorgen die Protosockets dafür, dass das gleiche Paket erneut gesendet wird.

Jeder Protosocket ist gleichzeitig auch ein Protothread, wie man am obigen Programmbeispiel sehen kann.

Die folgenden Markos werden von der Protosocket Library psock.h bereitgestellt:

PSOCK_INIT Initialisiert einen Protosocket. Diese Funktion muss genau einmal nach dem Verbindungsaufbau aufgerufen werden.

PSOCK_BEGIN und PSOCK_END kennzeichnen Anfang und Ende eines Threads, der Protosockets benutzt.

PSOCK_CLOSE schließt die Verbindung, der Thread läuft aber noch weiter.

PSOCK_EXIT beendet den Thread. Vergessen Sie nicht, vor dem Ende der Applikation die Verbindung zu schließen!

PSOCK_CLOSE_EXIT schließt die Verbindung und beendet dann Thread.

Für das Senden von Daten gibt es Funktionen, die aus dem RAM lesen, und Funktionen die aus dem Programmspeicher lesen. Die Varianten für den Programmspeicher enden mit "_P", wie allgemein üblich.

PSOCK_SEND und PSOCK_SEND_P senden eine Menge Daten. Sie übergeben als Parameter einen Zeiger auf die Daten und die Anzahl in Bytes.

PSOCK_SEND_STR und PSOCK_SEND_STR_P senden einen String.

PSOCK_READBUF wartet und empfängt solange Daten, bis der inputbuffer voll ist.

PSOCK_READTO wartet und empfängt solange Daten, bis das angegebene Zeichen empfangen wurde. Wenn dabei mehr Zeichen empfangen werden, als in den Puffer passen, werden zu überschüssigen Zeichen verworfen. Gelesen wird dennoch auch beim Pufferüberlauf bis zu dem angegebenen Zeichen.

Mit PSOCK_DATALEN fragt man ab, wieviele Bytes vorher von PSOCK_READBUF oder PSOCK_READTO empfangen wurden.

Um zu warten, ohne gleichzeitig etwas senden oder empfangen zu müssen, gibt es folgende Makros:

Mit PSOCK_NEWDATA fragt man ab, ob neue Daten herein gekommen sind. Diese Daten befinden sich zunächst noch im Ethernet Paket Puffer von µIP, sie wurden noch nicht in den inputbuffer der Applikation kopiert. Diese Funktion wird in der Regel zusammen mit PSOCK_WAIT_UNTIL verwendet, um so lange zu warten, bis entweder neue Daten herein gekommen sind oder irgendeine andere Bedingung erfüllt ist.

PSOCK_WAIT_UNTIL wartet, bis eine Bedingung erfüllt ist. Alternativ kann man auch PT_WAIT_UNTIL benutzen.

Daten generieren

Ein typischer Anwendungsfall für generierte Daten ist die Erzeugung eines Textes mit sprintf. Dazu braucht man normalerweise eine temporäre Zwischen-Variable:
static PT_THREAD(send_a_number(struct application_state *app_state))
{
    PSOCK_BEGIN(&app_state->socket);
    sprintf(app_state->stringBuffer,"%i\n",1234567);
    PSOCK_SEND_STR(&app_state->socket,app_state->stringBuffer); 
    PSOCK_END(&app_state->socket);
}
Dieser Thread formatiert eine Zahl in einen String und und gibt ihn dann aus. Blöd wird es nur, wenn man einen String erzeugt, der ziemlich lang ist. Denn dann müsste man zuerst den String in einen großen (!) String-Puffer schreiben, um ihn senden zu können. Und das Senden besteht ja intern daraus, den String in den Ethernet Paket Puffer zu kopieren. Das ist ein Kopiervorgang mehr, als notwendig, weswegen die Performance nicht mehr optimal sein wird.

Es liegt also nahe, die Ausgabe von sprintf direkt in den Ethernet Paket Puffer zu schreiben, dann braucht man keine temporäre Zwischen-Variable und schneller geht es außerdem. Bei Protosockets ist dazu das PSOCK_GENERATOR_SEND Makro vorgesehen. Man wendet es so an:

struct application_state {   
    struct psock socket;     // Zustandsvariable des Protosockets
    char inputbuffer[100];   // Puffer für empfangene Daten
    
    //char strungBuffer[20]; // nicht mehr verwendet, stattdessen:
    int number;              // Eine Zahl, für generate_number
};

static unsigned short generate_number(void *state) {
    struct application_state *app_state = (struct application_state *) state; 
    int bytes=sprintf(uip_appdata, "%i\n", app_state->number);
    return bytes;
}

static PT_THREAD(send_a_number(struct application_state *app_state))
{
    PSOCK_BEGIN(&app_state->socket);
    app_state->number=1234567;
    PSOCK_GENERATOR_SEND(&app_state->socket, generate_number, app_state);
    PSOCK_END(&app_state->socket);
}
Das Makro PSOCK_GENERATOR_SEND ruft den Generator auf, der seine Ausgabe direkt in then Ethernet Paket Puffer schreibt. Falls das Paket aufgrund einer Übertragungsstörung nicht beim Empfänger ankommt, wird der Generator nochmal aufgerufen.

Der Kopf (Signatur) des Generators muss immer exakt so aussehen, wie der oben fett markierte Teil. Der Generator liefert als Rückgabewert immer die Anzahl der Bytes, die er erzeugt hat. Eingabeparameter muss man in die Struktur app_state verpacken, die der Funktion als Parameter übergeben wird.

Wieviele Bytes der Generator erzeugen darf, hängt einerseits von der Größe des Ethernet Paket Puffers ab, und auch von den Restriktionen der Netzwerk-Geräte in der Übertragungsstrecke. Die µIP Funktion uip_mss() liefert diesen Wert. Er ist typischerweise etwa 1300 Bytes und nur sehr selten kleiner als 512 Bytes.

Puffern und Wiederholen

Alle Daten, die der Ethernet Controller empfängt, landen zunächst im Ethernet Paket Puffer von µIP. In diesem Puffer wartet das empfangene Paket darauf, von der Applikation abgeholt zu werden. Die Applikation überträgt empfangene Daten in ihren inputbuffer, indem sie PSOCK_READBUF oder PSOCK_READTO aufruft.

Die Größe des inputbuffer muss nicht der Größe des Ethernet Paket Puffers entsprechen. Der inputbuffer darf durchaus größer oder kleiner sein. In beiden Fällen müssen andere Instanzen der Applikation nach dem Empfang eines Paketes stets warten, bis die adressierte Applikation alle empfangenen Bytes ausgelesen hat. Das sie tatsächlich warten, darum kümmert sich µIP automatisch. Sie brauchen dazu keine Warteschleifen Programmieren.

Was passiert, wenn der Ethernet Controller ein weiteres Paket empfängt, bevor die Applikation das vorherige Paket verarbeitet hat? Die Antwort lautet: Der Ethernet Controller Chip puffert dies ab, dazu enthält hat er auch einige Kilobytes RAM. Sollte sich im Ethernet Controller eine zu große Warteschlange aufbauen (also dessen Speicherkapazität übersteigen), gehen tatsächlich Pakte verloren. Das ist jedoch nicht weiter schlimm, denn der Sender wird sie kurze Zeit später erneut senden, so ist das im TCP/IP Protokoll vorgesehen.

Der Empfang läuft also aus Sicht der Applikation ziemlich unkompliziert ab.

Auch das Senden von Daten ist ganz einfach. Denn immer wenn die Applikation Daten sendet, wird der Thread solange blockiert, bis der Empfänger den Empfang bestätigt hat. So ist zum Einen sichergestellt, dass die Applikation nicht schneller sendet, als der Empfänger empfangen kann. Darüber hinaus sendet der Protothread automatisch Pakte erneut, wenn der Empfänger sie nach einer Weile nicht als erfolgreich empfangen bestätigt.

Da das Senden und Empfangen beides über einen einzigen gemeinsam genutzten Paket Puffer statt findet, kann die Applikation nur abwechselnd senden und empfangen. Wenn die Applikation Daten sendet, bevor sie ein empfangenes Paket ausgelesen hat, geht dieses verloren.

Also merke: Immer erst Empfangen und dann die Antwort senden. Es sei denn, man will gar nichts empfangen.

Protothreads mit Protosockets verschachteln

Protosockets sind eine Variante von Protothreads. Sie erben viele aber leider nicht alle Eigenschaften der Protothreads. Der offensichtlichste Unterschied ist, dass Protosockets keine untergeordneten Threads starten können. Es gibt weder ein PSOCK_SPAWN Makro, noch ein PSOCK_WAIT_THREAD Makro. Das hat einen guten Grund, denn es würde nicht funktionieren.

Umgekehrt können Protothread weder senden noch empfangen, das können nur Protosockets.

Daraus ergibt sich eine ziemlich starr vorgegebene Struktur für ihre Anwendung: Entweder besteht die ganze Anwendung nur aus einem einzigen Protosocket (wie die obigen Beispiele), oder sie besteht aus (eventuell verschachtelten) Protothreads, die letztendlich immer Protosockets aufrufen müssen, um übers Netzwerk zu kommunizieren. Ein vollständiges Beispiel:

#define UIP_APPCALL appcall

struct application_state {   
    struct psock socket;     // Zustandsvariable des Protosockets
    struct pt thread;        // Zustandsvariable des Threads
    char inputbuffer[100];   // Puffer für empfangene Daten
};

typedef struct application_state uip_tcp_appstate_t;

static PT_THREAD(send_hello(struct application_state *app_state))
{
    PSOCK_BEGIN(&app_state->socket);
    PSOCK_SEND_STR(&app_state->socket,"Hallo\n"); 
    PSOCK_END(&app_state->socket);
}

static PT_THREAD(receive_command(struct application_state *app_state))
{
    PSOCK_BEGIN(&app_state->socket);
    PSOCK_READTO(&app_state->socket, '\n');
    PSOCK_END(&app_state->socket);
}

static PT_THREAD(send_and_receive(struct application_state *app_state))
{
    PT_BEGIN(&app_state->thread);
    while (1) {

        // Sende "Hallo"
        PT_WAIT_THREAD(&app_state->thread,send_hello);

        // Empfange einen Befehl
        PT_WAIT_THREAD(&app_state->thread,receive_command);

        // Beende die Verbindung
        if (strstr(app_state->inputbuffer,"exit")) {
            // geht nicht: PSOCK_SEND_STR(&app_state->socket,"Tschüss\n"); 
            uip_close();
            break;
        }

    }
    PT_END(&app_state->thread);
}

void appcall(struct application_state *app_state) {
    if (uip_closed() || uip_aborted() || uip_timedout()) {
        return;
    }
    if (uip_connected()) { 
        PSOCK_INIT(&app_state->socket, app_state->inputbuffer, sizeof(app_state->inputbuffer));
    }
    send_and_receive(app_state);
}
Die Zeile, wo die "Tschüss" Meldung ausgegeben wird, habe ich auskommentiert, weil sie nicht funktioniert. Nur Protosocket Funktionen (also welche die mit PSOCK_BEGIN beginnen) können Daten senden und empfangen. Um die Meldung "Tschüss" auszugeben, müsste man also eine weitere Protosocket Funktion schreiben, so wie die Funktion send_hello.

Aus dem gleichen Grund, benutze ich in dem obigen Beispiel die µIP Funtkion uip_close anstatt PSOCK_CLOSE, um die Verbindung zu schließen. PSOCK_CLOSE darf man nur in Protosockets benutzen, das obige Programm schließt die Verbindung aber in einen Protothread.

Das war's. Nun möchte ich ihnen empfehlen, sich die Quelltexte meines AVR Webservers anzuschauen, wenn Sie noch mehr Programmierbeispiele sehen wollen. Insbesondere diese Dateien:

Die Haupt-Applikation befindet sich in inetd.c. Abhängig von der TCP Port Nummer der aktuellen Verbindung, delegiert inetd das Ereignis an eine der drei unter-Applikationen. Mein Webserver führt also drei Applikationen gleichzeitig aus.