Einfache Entwurfsmuster in C

In der objektorientierten Programmierung sind die sogenannten "Entwurfsmuster" nicht mehr wegzudenken. Sie bedeuten Abstraktion und Vereinfachung. Das hört sich schön an und wäre eine tolle Sache für die Embedded-Welt mit ihrem bodenständigen C. Nun, so weit weg ist das nicht...

Die Entwurfsmuster (englisch "design pattern") sind eine sehr hilfreiche Sache bei der objektorientierten Programmierung. Nur kommt man in der Regel im Embedded-Bereich nicht um das gute alte C herum. Es gibt zwar auch Ansätze die Objektorientierung mit C zu bewerkstelligen, aber mit Lösungen wie GObject sieht das am Ende komplizierter aus, als die Ersparnis durch die Muster selbst. ;-)

Auch die Verwendung von C++ als echte objektorientierte Sprache ist für die Programmierung von Mikrocontrollern nicht so einfach und mit vielen Fallstricken gespickt. Aus diesem Grund bleibe ich hier bei reinem C.

In diesem C-Postit möchte ich eine einfache Lösung für das Entwurfsmuster "Adapter" zeigen. Es basiert auf den bei C-Einsteigern zu gehassten Zeigern (engl. "pointer"). Um noch eins oben drauf zu packen, werden hier sogar (Trommelwirbel) Zeiger auf Funktionen benutzt. Ihr bekommt ein nervöses Zucken im Augenwinkel? Entspannt euch, so schlimm wird es nicht. ;-)

Welche Aufgabe gilt es zu lösen?

Um ein konkretes Beispiel zum Arbeiten zu haben, gehe ich von folgendem fiktiven (und stark vereinfachten) Fall aus. Im Embedded-Bereich kommen Anbindungen von Mikrocontrollern an FPGAs sehr häufig vor. Die Art der Anbindungen kann allerdings variieren. FPGAs können entweder an den Adress- und Datenbus angebunden sein, oder aber einfach und schnell serielle Busse wie den I2C-Bus oder den SPI-Bus benutzen.

In unserem Beispiel soll eine universelle, projektunabhängige Library an unterschiedliche Anbindungen anpassbar sein. Die Library soll nicht mit dem Projekt gebaut werden, sodass eine Lösung mit bedingter Kompilierung ausfällt. Die projektspezifische Anbindung an den FPGA muss also der Library zur Laufzeit mitgeteilt werden. Diese Trennung ist wichtig, da so die projektunabhängige Library und der Source des Projektes getrennt voneinander verwaltet werden können. Das könnte sogar soweit gehen, dass die Library unabhängig von der zugrunde liegenden Plattform implementiert werden kann. Doch das würde den Rahmen des Postit sprengen.

Die eigentliche Funktionalität der FPGA-Library ist hier nicht relevant. Diese fiktive Library dient nur als Beispiel für die Umsetzung der abstrakten Anbindung mittels des "Adapter"-Entwurfsmusters. Konkret geht es um folgendes:

  • die Library muss Register eines FPGAs lesen und schreiben können.
  • es müssen Fehler an die Fehlerverwaltung des Projekts weitergereicht werden können.
  • es müssen optional asynchrone Benachrichtigungen (englisch "notification") verarbeitet werden.

Die zur Verfügung zu stellende Schnittstelle

Das hier entworfene Adapter-Muster soll das Lesen und Schreiben von Registern eines FPGAs implementieren. Diese beiden Funktionen sind das elementare Interface. Die Register basieren auf Bytewerten. Ohne näher auf Details einzugehen, können mehrere Register referenziert werden. Dies erfolgt über eine Registernummer, die ebenfalls als Byte übergeben wird.

Zur Handhabung von Fehlern kann optional ein error handler angegeben werden. Dieser bekommt nur den Fehlercode als Byte übergeben.

Als letztes gibt es noch einen ebenfalls optionalen notification handler. Dieser passt zwar nur bedingt zum Entwurfsmuster "Adapter", aber für ein eigenes Muster vom Typ "Beobachter" ist es doch zu wenig. Dieser Handler soll asynchrone Benachrichtigungen aufnehmen, wie sie zum Beispiel bei Interrupts auftreten können.

Die nachfolgenden Deklarationen zeigen die Schnittstelle in Form eines C-Schnippels. Die Funktionsprototypen sehen bereits so aus, wie wir sie später benötigen.

/* die Basisfunktionalität */
void write_register (uint8_t reg_nr, uint8_t val);
uint8_t read_register (uint8_t reg_nr);

/* Anbindung an die Fehlerverwaltung */
enum { E_OK=0, E_BASIC, E_NOTIFY };
void error_handler (uint8_t error_nr);

/* Anbindung der Benachrichtigungen */
void notification_handler (uint8_t event, uint16_t ev_parm);

Was sind Zeiger auf Funktionen?

Ich beginne mal mit einem kurzen Rückblick. Die Variablen eines Programms werden im RAM des Mikrocontrollers gespeichert. Diese exakte Position im RAM kennt der Compiler (eigentlich ist es der Linker, der die Adresse kennt, aber das sind Feinheiten). Anhand des Namens der Variablen kann man aber im C-Source auf diese Speicherstelle zugreifen. Die Adresse im RAM wird hier dann als Zeiger (engl. "pointer") bezeichnet. Der "Pointer" zeigt somit quasi auf diese Position.

In C allokiert man eine Variable durch die Angabe des Datentyps und des Namens. Dies muss am Anfang eines Gültigkeitsbereichs (engl. "scope") erfolgen (also hinter einer öffnenden geschweiften Klammer). Will man anstelle des kompletten Datentyp nur einen Zeiger verwenden, so muss man dies bei der Deklaration durch ein dem Namen vorangestelltes Sternchen festlegen. In dem Fall wird dann nur Speicherplatz für eine Adresse allokiert. Die Adresse einer Variablen bekommt man, in dem man vor dem Namen ein "Ampersant" (das &, auch Et-Zeichen oder kaufmännisches Und genannt) stellt. Dadurch erhält man die Referenz (engl. reference) auf eine Variable. Der Name des Zeigers (im Beispiel pointer) steht also für eine Adresse. Zum Zugriff auf die Variable, die sich hinter der Adresse verbirgt, muss man dann wieder ein Sternchen voranstellen (*pointer). Man spricht dann vom "Dereferenzieren" (engl. dereferencing). Das Ganze sieht dann so aus:

int variable=0;         // allocate an integer
int *pointer;           // pointer to integer
pointer = &variable;    // store reference to 'variable'
*pointer = 3;           // use (dereference) pointer
...                     // 'variable' now has value 3

Will man dieses Spielchen mit Funktionen spielen, dann sieht das allerdings ganz anders aus. Stellt man ein Sternchen vor den Namen einer Funktion, bezieht sich dieses auf den Rückgabewert. Was also muss man tun?

Im Grunde ist es ganz einfach, auch wenn es im ersten Moment ungewohnt aussieht. Man schreibt einfach den Prototyp der Funktion auf, packt den Namen dann in runde Klammern und fügt innerhalb der Klammern das Sternchen vor den Namen. Die Adresse der Funktion bekommt man ohne das Ampersant, einfach durch Angabe des Namens jedoch ohne die Parameterliste anzugeben. Und weil das jetzt schon alles anders war, wird auch die Dereferenzierung (also hier der Aufruf mittels des Zeigers) ohne Sternchen gemacht. Es wird einfach der Namen mit der Parameterliste verwendet.

int function ( int parameter );            // a function
int (*pointer_to_function_t) (int param);  // pointer to this type of funct.
pointer_to_function_t foo;                 // the pointer itself
foo = function;                            // assign address to 'function'
foo(1);                                    // use (dereference) pointer

Wir bauen uns ein Entwurfsmuster

Aus der anfangs definierten Aufgabenstellung einer fiktiven Library-Anbindung ergeben sich die folgenden zu definierenden Funktionszeiger. Die Deklarationen arbeiten mit typedef um einen Datentyp zu erhalten.

Die Basisfunktionalität erfordert das Lesen und Schreiben von Registerwerten. Die notwendigen Datentypen der Funktionszeiger für das Lesen und das Schreiben sehen wie folgt aus:

typedef void (*write_register_t) (uint8_t reg_nr, uint8_t val);
typedef uint8_t (*read_register_t) (uint8_t reg_nr);

Die Anbindung an die Fehlerverwaltung erfordert in unserem Fall nur einen Datentyp:

typedef void (*error_handler_t) (uint8_t error_nr);

Zu guter Letzt fehlt noch die Anbindung der rudimentären Benachrichtigungen. Auch hier gibt es nur einen Datentyp.

typedef void (*notification_handler_t) (uint8_t event, uint16_t ev_parm);

Da die komplette Schnittstelle an die Library übergeben werden soll, machen vier einzelne Zeiger keinen Sinn. Aus dem Grund werden die Funktionszeiger in eine eigene Struktur adapter_t verpackt. Diese soll alle aktiven Zeiger aufnehmen.

typedef struct
{
    read_register_t cb_read;
    write_register_t cb_write;
    error_handler_t cb_error;
    notification_handler_t cb_notify;
} adapter_t;

Die Anwendung aus Projektsicht

Die Anwendung muss die Funktionen, die in adapter_t als Funktionszeiger enthalten sind, implementieren. Hierzu müssen die Funktionen die Parameterliste und die Rückgabewerte übernehmen -- die Signatur muss übereinstimmen. Der Name der Funktion spielt hier keine Rolle. Optionale Funktionen muss man nicht implementieren.

void spi_write (uint8_t reg_nr, uint8_t val)
{ ... }

uint8_t spi_read (uint8_t reg_nr)
{ ... }

Hat man die Funktionen implementiert, müssen die Zeiger auf die Funktionen in einer Struktur adapter_t eingetragen werden. Ungenutzte Zeiger (optionaler Funktionen) müssen unbedingt auf 0 gesetzt werden!

adapter_t spi_binding;
spi_binding.cb_read = spi_read;
spi_binding.cb_write = spi_write;
spi_binding.cb_error = 0;
spi_binding.cb_notify = 0;

Hat man die Struktur mit dem Adapter ""gefüllt"", dann kann man sie der Library übergeben. In unserem fiktiven Fall könnte das so aussehen:

fpga_init_library(&fpga);
fpga_register_adapter(&fpga,&spi_binding);

Die Library hat nun alle Informationen, um die projektabhängige Schnittstelle ansprechen zu können. Die Anwendung kann nun die Funktionen der Library benutzen.

fpga_do_something(&fpga);
fpga_do_something(&fpga);
fpga_simulate_notify(&fpga);

Die Anwendung aus Sicht der Library

Die andere Seite des Musters steckt in der Library. Dieser Abschnitt kümmert sich um die Nutzung der in adapter_t übergebenen Funktionszeiger. Damit die Library mit den übergebenen Zeigern arbeiten kann, muss man diese für jeden FPGA speichern. In der Praxis würde man für jeden FPGA auch noch zusätzliche Informationen wie SPI Chip Select oder Basisadresse auf dem Adressbus speichern.

typedef struct
{
    adapter_t binding;
    bool setup_valid;
    //...
} fpga_t;

Die Registrierung des Adapters

In der Demo wird die Registrierung in der Struktur fpga_t realisiert, in der die Funktionszeiger als adapter_t einfach eingebettet sind. Die Funktion fpga_init_library initialisiert die übergebene Struktur und die Prüfung mit der Übernahme erfolgt dann in fpga_register_adapter. Die fiktive Library geht davon aus, dass die Funktionszeiger cb_read, cb_write und cb_error gültig sind. Hierzu prüft fpga_register_adapter die übergebenen Funktionszeiger und setzt die boolesch Variable setup_valid entsprechend.

Die Fehlerbehandlung ist in der Demo optional. Hierfür wird ein kleiner Trick verwendet. Wenn der Funktionszeiger von der Anwendung nicht übergeben wird, dann wird als Zeiger die Adresse eines lokalen Fehlerbehandlung eingetragen. So spart man sich das Abprüfen des Funktionszeigers cb_error vor jedem Aufruf.

Verwendung der Basisfunktionen

Die Verwendung des so konfigurierten Adapters ist denkbar einfach. Gezeigt wird das im Beispielcode in den Funktionen fpga_do_something und fpga_simulate_notify. Jede Funktion der Library bekommt als Referenz des FPGA-Bausteins einen Zeiger auf fpga_t übergeben. Die Funktion muss nur noch prüfen, ob der Zeiger ungleich 0 ist und dass der Setup erfolgt ist. Danach können die Funktionszeiger einfach benutzt werden.

val = fpga->binding.cb_read(1);
if ( val == 128 )
    fpga->binding.cb_error(E_BASIC);
fpga->binding.cb_write(2,val&0x03);

Das obige Schnipsel zeigt, wie das FPGA-Register #1 gelesen wird. Ist das höchste Bit des Bytes gesetzt, wird ein Fehler erzeugt. Danach wird das gelesene Byte maskiert und in das Register #2 des FPGAs geschrieben. Nicht sehr sinnvoll, aber es zeigt die Verwendung. ;-)

Generieren einer Benachrichtigung

Benachrichtigungen sind asynchrone Ereignisse die nicht als Rückgabewert einer Funktion zur Verfügung stehen. Das könnte zum Beispiel bei einem Interrupt der Fall sein, der zu einem beliebigen Zeitpunkt auftreten kann. Diese Ereignisse kann man dann über einen Funktionszeiger der Anwendung mitteilen.

if ( !fpga->binding.cb_notify )
    return;
printf("fpga_simulate_notify()\n");
val = fpga->binding.cb_read(1);
fpga->binding.cb_notify(EV_DEMO,val);

In diesem Beispiel hier ist die Verwendung des notification handlers cb_notify optional. Aus dem Grund muss vor der Verwendung geprüft werden, ob der Funktionszeiger gültig ist (auch wenn setup_valid gesetzt ist). Der Aufruf erfolgt dann wie gehabt. In diesem Fall bekommt die Benachrichtigungsfunktion den Grund der Benachrichtigung als EV_DEMO mitgeteilt.

Die Demo

Die kompilierbare Demo enthält alle hier angesprochenen Funktionen. Zudem wird in dem Beispiel auch noch die Anwendung der Fehler- und Benachrichtigungsanbindung gezeigt. In dem Beispiel werden zwei FPGAs mit unterschiedlicher Anbindung verwendet, sodass der Grund des ganzen Aufwandes klarer werden sollte.

Demo -- adapter pattern in C
prepare MCU binding...
fpga_register_adapter()
prepare SPI binding...
fpga_register_adapter()
use MCU for FPGA1...
fpga_do_something()
mcu_read: reg=0x01
mcu_write: reg=0x02 val=0x03
fpga_do_something()
mcu_read: reg=0x01
local_error_handler: error #2
mcu_write: reg=0x02 val=0x00
fpga_simulate_notify()
mcu_read: reg=0x01
local_notification_handler: event=0 parm=0x0081
use SPI for FPGA2...
fpga_do_something()
spi_read: reg=0x01
spi_write: reg=0x02 val=0x03
fpga_do_something()
spi_read: reg=0x01
local_error_handler: error #2
spi_write: reg=0x02 val=0x00

Fazit

Das C-Postit ist dieses mal etwas länger geworden. Die Anwendung ist aber relativ einfach und die Notwendigkeit sollte durch das Beispiel klar werden. Das Muster zeigt, dass man auch mit C einiges Umsetzen kann, was man beim ersten Kontakt mit C in der Schule oder im Studium gar nicht erwartet hätte.

Viel Spaß beim Umsetzen der Lösung in eigenen Anwendungen. ;-)

Comments