Start page   

Protothreads und Protosockets

Einleitung

In diesem Aufsatz erkläre ich, wie man mit Hilfe von Protothreads und Protosockets eigene Multithreading fähige Programme für kleine Mikrocontroller schreibt. Einige Projekte benutzen diese Technologie, so auch mein Ethernet I/O Modul.

Multithreading bedeutet, dass ein Gerät mehrere Aufgaben gleichzeitig erledigen kann. 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 Multithreading fähig gemacht.
int main() 
{
    File* file;
    char buffer[10];
    int bytesRead;

    fopen("datei.txt");                         // Datei öffnen

    while (!feof(file))                         // Solange das Dateieende nicht erreicht ist ...
    {                       
        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 Multithreading) oder
  2. sie muss so umgeschrieben werden, dass sie selbst Rechenzeit an andere Threads abgibt (= kooperatives Multithreading)
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, 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 sie als nächstes zu erledigen 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:

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

// Thread 1: Gib den Inhalt einer Datei aus

void print_file(char* filename) 
{
    static int step = 0;
    
    switch (step) 
    {
    
        case 0: 
            file=fopen(filename);    // Datei öffnen
            step=1;                  // Der nächste Schritt ist Nr. 1
            break;
            
        case 1:
            bytesRead=fread(buffer,10,1,file);  // lesen
            step=2;                             // Der nächste Schritt ist Nr. 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() 
{
    static int step = 0;
    
    switch (step) 
    {
    
        case 0: 
            putchar('x');
            step=1;
            break;
            
        case 1:
            putchar('X');
            step=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" sind die Zustandsvariablen der Zustandautomaten. Sie geben stets darüber Auskunft, in welchem Zustand sich die Threads befinden, bzw. welche Aufgabe sie als nächstes ausführen müssen. Da sie statisch sind, verlieren sie ihren Wert nicht zwischen den Funktionsaufrufen. Anstelle der numerischen Zustände verwendet man in echten Programmen besser Enumerations, da die aussagekräftiger sind und man leichter weitere 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"

struct pt thread1;
struct pt thread2;

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

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

PT_THREAD(print_x(struct pt *pt)) 
{
   PT_BEGIN(pt);
   while(1) 
   {
       putchar('x');
       PT_YIELD(pt);
       putchar('X');
       PT_YIELD(pt);
   }
   PT_END(pt);
}

int main() 
{
    PT_INIT(&thread1);
    PT_INIT(&thread2);
    while (1) 
    {
        print_file(&thread1, "datei.txt");
        print_x(&thread2);
    }
}
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(&thread1,"datei.txt");
        }
        print_x(&thread2);
    }
}
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.

Ganz wichtig: 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.

Lokale Variablen

In dem letzten Programmbeispiel hatte ich einige Variablen global deklariert, was dem erfahrenen Programmierer sofort auffallen sollte:
File* file;
char buffer[10];
int bytesRead;
Ich erkläre hier, warum ich das gemacht habe. Ohne Threads würde man normalerweise lokale Variablen nutzen:
void print_file(char* filename) 
{
    File* file;
    char buffer[10];
    int bytesRead;
    
    file=fopen(filename);
    while (!eof(file)) 
    {
        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ügen würde, ergäbe dies den folgenden nicht funktionierenden Quelltext:
PT_THREAD(print_file(struct pt *pt, char* filename)) 
{   
    File* file;
    char buffer[10];
    int bytesRead;
    
    PT_BEGIN(pt);
    
    file=fopen(filename);
    PT_YIELD(pt);

    while (!eof(file)) 
    {
        bytesRead=fread(buffer,10,1,file);
        PT_YIELD(pt);

        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(pt);
    }

    fclose(file);
    PT_YIELD_UNTIL(pt,0);
    
    PT_END(pt);
}
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 unter der Haube 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.

Lokale Variablen kann man innerhalb von Threads nur bedingt einsetzen!

Um die obige print_file() Funktion zu reparieren, müssen wir mindestens die Variable "file" in den globalen Bereich:

File* file;

PT_THREAD(print_file(struct pt *pt, char* filename)) 
{
    char buffer[10];
    int bytesRead;
    
    PT_BEGIN(pt);

    file=fopen(filename);
    PT_YIELD(pt);

    while (!eof(file)) 
    {
        bytesRead=fread(buffer,10,1,file);
        // PT_YIELD entfernt
        
        fwrite(buffer,bytesRead,1,stdout);        
        PT_YIELD(pt);
    }

    fclose(file);
    PT_YIELD_UNTIL(pt,0);
    
    PT_END(pt);
}
Da ich eine Unterbrechung entfernt habe, dürfen die beiden Variablen "buffer" und "bytesRead" lokal bleiben. Zwischen dem Schreiben und dem Lesen der Variablen gibt es nämlich keine Unterbrechung mehr.

Die Entfernung des PT_YIELD 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". Vorher:

xXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXuuuuuuuuuuxXxXxXxXxXxXxXxXxXxXxXxX...
Nachher:
xuuuuuuuuuuXuuuuuuuuuuxuuuuuuuuuuXuuuuuuuuuuxuuuuuuuuuuXxXxXxXxXxXxXxXxXxXxXxXxXxXxX...
Wir sehen jetzt nur noch ein "x" zwischen den "u". Dieser Thread gibt nur noch halb so oft Rechenzeit an den anderen Thread ab. Die Änderung sieht im Quelltext geringfügig aus, doch sie hat einen erheblichen Einfluss auf die Verteilung der Rechenzeit zwischen den Threads.

Um das ürsprüngliche Timing wieder her zu stellen, müssen wohl doch alle drei Variablen global sein:

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

PT_THREAD(print_file(struct pt *pt, char* filename)) 
{    
    PT_BEGIN(pt);

    file=fopen(filename);
    PT_YIELD(pt);

    while (!eof(file)) 
    {
        bytesRead=fread(buffer,10,1,file);
        PT_YIELD(pt);  // wieder eingefügt
        
        fwrite(buffer,bytesRead,1,stdout);        
        PT_YIELD(pt);
    }

    fclose(file);
    PT_YIELD_UNTIL(pt,0);
    
    PT_END(pt);
}
Vielleicht fallen ihnen dazu spontan statische lokale Variablen als mögliche Alternative ein:
PT_THREAD(print_file(struct pt *pt, char* filename)) 
{
    static File* file;
    static char buffer[10];
    static int bytesRead;

    PT_BEGIN(pt);
    
    file=fopen(filename);
    PT_YIELD(pt);

    while (!eof(file)) 
    {       
        bytesRead=fread(buffer,10,1,file);
        PT_YIELD(pt);
        
        fwrite(buffer,bytesRead,1,stdout);
        PT_YIELD(pt);
    }

    fclose(file);
    PT_YIELD_UNTIL(pt,0);
    
    PT_END(pt);
}
Statische Variablen innerhalb von Funktionen liegen im gleichen Speicherbereich, wie globale Varaiablen. Sie verlieren ihren Inhalt also nicht zwischen den Funktionsaufrufen. Das kann man so machen, allerdings verbirgt dieses Konstrukt eine andere Einschränkung, die ich im nächsten Kapitel erkläre.

Thread sichere Funktionen

Eine Funktion ist dann thread-sicher, wenn sie mehrmals gleichzeitig parallel ausgeführt werden kann. Bei den obigen Beispielen mit den Dateien ist das nicht der Fall, denn es gibt nur eine globale "file" Variable. Die Konsequenz ist, dass das Programm nur eine Datei gleichzeitig öffnen kann. Der folgende Test demonstriert den Fehler:
struct pt thread1;
struct pt thread2;
struct pt thread3;

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

PT_THREAD(print_file(struct pt *pt, char* filename)) 
{ ... }

int main() 
{
   while (1) 
   {
       print_file(&thread1, "datei_voller_u.txt");
       print_file(&thread2, "datei_voller_s.txt");
       print_file(&thread3, "datei_voller_h.txt");
   }  
}
Da die Threads nun drei Dateien quasi parallel (zeitlich überlappend) lesen, brauchen Sie auch mindestens drei "file" Variablen, drei "buffer" und drei "bytesRead" Variablen. Das Schlüsselwort "static" wäre keine Lösung, denn damit gäbe damit immer noch nur eine "file", eine "buffer" und eine "bytesRead" Variable.

Deswegen erstellen wir nun eine eigene Struktur, die für jeden Thread Platz für eigene Variablen bereit stellt:

#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 thread_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, "datei_voller_u.txt");
        print_file(&data2, "datei_voller_s.txt");
        print_file(&data3, "datei_voller_h.txt");
    }
}
Jetzt hat jeder Thread seine eigenen Daten, so dass sie sich nicht mehr gegenseitig stören. Die Funktion print_file() ist Thread-sicher geworden. 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 Multithreading fähig macht. Während es serielle Ausgaben macht soll es außerdem "x" Zeichen auf den Bildschirm ausgeben.

#include "pt.h"

struct pt thread1;
struct pt thread1b;
struct pt thread1c;
struct pt thread2;
int i;

// Thread 1 dritter Teil

PT_THREAD(send_character(struct pt *pt, char char)) 
{
    PT_BEGIN(pz);
    
    PT_WAIT_UNTIL(pt,serial_ready());
    serial_write(char);
    
    PT_END(pt);
}

// Thread 1 zweiter Teil

PT_THREAD(send_line(struct pt *pt, char[] string)) 
{
    PT_BEGIN(pt);
    
    i=0;
    while (string[i]!=0) 
    {
      PT_SPAWN(pt,&thread1c,send_character(string[i]));
      i++;
    }
    send_character('\n');
    
    PT_END(pt);
}

// Thread 1 (Texte seriell ausgeben)

PT_THREAD(send_text(struct pt *pt)) 
{
    PT_BEGIN(pt);
    
    PT_SPAWN(pt,&thread1b,send_line("Hallo"));
    PT_SPAWN(pt,&thread1b,send_line("da bist du ja!"));
    PT_SPAWN(pt,&thread1b,send_line("Ich habe dich vermisst."));
    
    PT_END(pt);
}

// Thread 2 (xXxXxX... ausgeben)

PT_THREAD(print_x(struct pt *pt))
 {
     PT_BEGIN(pt);
     
     while(1) 
     {
         putchar('x');
         PT_YIELD(pt);
         
         putchar('X');
         PT_YIELD(pt);
     }
     
     PT_END(pt);
}

// Hauptprogramm

int main() 
{
    PT_INIT(&thread1);
    PT_INIT(&thread2);
    while (1) 
    {
        send_text(&thread1);
        print_x(&thread2);
    }
}
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.

Achten Sie darauf, für jeden untergeordneten Protothread eine eigene Zustandsvariable zu benutzen (thread1, 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. Anstelle i global zu machen, könnte man auch hier wieder alle Variablen des Threads in einer Struktur zusammenfassen und beim Aufruf der Funktionen übergeben, so wie wir das weiter oben bei dem Programm mit den Textdateien schon gemacht habe.

Das war alles zum Thema Protothreads. In den nächsten Kapiteln geht es um Netzwerk-Programmierung mit µIP und Protosockets.

Umgehung eines Fehlers

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)
Um den Fehler zu umgehen, kann man anstelle von PT_YIELD(pt) einfach PT_WAIT_UNTIL(pt,1) benutzen. Die von mir gezeigte pt.h ist bereits korrigiert worden, dort funktioniert PT_YIELD(pt) richtig.

µ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 Multithreading 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 Ethernet I/O Modul ist eine µ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.

Application_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 globalen 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 Multithreading 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:
struct application_state 
{   
    struct psock socket;     // Zustandsvariable des Protosockets
    char inputbuffer[100];   // Puffer für empfangene Daten        
    char stringBuffer[20];   // Puffer für zu sendende Daten
};

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 sehr langen Strng generiert (zum Beispiel aus 2000 Messwerten). Denn dann bräuchte man entsprechend viel temporären Speicherplatz und müsste diesen Stückweise in den Ethernet Paket Pufferumkopieren.

Es liegt also nahe, die Ausgabe direkt in den Ethernet Paket Puffer zu schreiben, dann braucht man keinen temporären Speicher 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 stringBuffer[20]; // nicht mehr verwendet, stattdessen:
    int number;              // Eine Zahl, Eingabe 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 (uip_appdata) schreibt. Falls das Paket aufgrund einer Übertragungsstörung nicht beim Empfänger ankommt, wird der Generator automatisch nochmal aufgerufen.

Der Generators muss als Parameter einen void* akzeptieren, tatsächlich wird er aber immer mit der application_state Struktur aufgerufen, die wir selbst nach belieben gestalten dürfen. Dort kann man eingabeParameter unterbringen.

Der Generator muss als Rückgabewert immer die Anzahl der Bytes zurück liefern, die er erzeugt hat.

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 zwischen 1024 und etwa 1300 Bytes, nur sehr selten kleiner.

Puffern und Wiederholen

Alle Daten, die der Ethernet Controller empfängt, landen zunächst im Ethernet Paket Puffer (uip_appdata) von µIP. In diesem Puffer wartet das empfangene Paket darauf, von der Applikation abgeholt zu werden. Die Applikation überträgt empfangene Daten in ihren eigenen 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 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 wiederholt der Protothread gesendete Pakete automatisch, wenn der Empfänger sie nicht quittiert.

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 komplett ausgelesen hat, geht der noch nicht ausgelesene Teil 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, ähnlich der 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 Ethernet I/O Moduls 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.