Objektorientierte Programmierung

Mehr Komfort mit C++

02.06.2008 | Autor / Redakteur: Steffen A. Mork* / Martina Hafner

*Diplominformatiker Steffen A. Mork ist als Systemarchitekt und Coach bei itemis tätig. Er verfügt über Erfahrungen im Application-Server- und Datenbank-Umfeld, insbesondere bei Performance-Analyse und-Optimierung. Derzeit entwickelt er eine Modellbahnsteuerung aus Mikrocontrollern, die über CAN-Bus miteinander gekoppelt sind. Kontakt: Steffen.Mork@itemis.de
*Diplominformatiker Steffen A. Mork ist als Systemarchitekt und Coach bei itemis tätig. Er verfügt über Erfahrungen im Application-Server- und Datenbank-Umfeld, insbesondere bei Performance-Analyse und-Optimierung. Derzeit entwickelt er eine Modellbahnsteuerung aus Mikrocontrollern, die über CAN-Bus miteinander gekoppelt sind. Kontakt: Steffen.Mork@itemis.de

Obwohl mit C++ schon seit über 20 Jahren entwickelt wird, hält diese Sprache nur langsam in die Mikrocontroller-Programmierung Einzug. Sie enthält aber viele Sprachkonstrukte die die Qualität der Firmware erheblich verbessern kann und dem Entwickler die Arbeit vereinfacht. Ein Grund für die zögerliche Nutzung liegt im höheren Ressourcen-Bedarf. Viele moderne Mikrocontroller haben diese Hürde aber nicht mehr.

C++ ist eine objektorientierte Programmiersprache. Im Wesentlichen bedeutet das, dass Objekte in Form von Klassen eingeführt werden. Diese Klassen lassen sich instanziieren und bieten die Möglichkeit, Fähigkeiten eines Objektes so zu kapseln, dass die Funktionsweise nach außen nicht unbedingt sichtbar sein muss („Black Box“). Ein weiteres wichtiges Konzept ist die Polymorphie. Darüber lassen sich die Eigenschaften anderer Klassen vererben. Beides zusammen lässt sich gut zur Definition von Schnittstellen verwenden. Durch diese Schnittstellen wird die Wiederverwendbarkeit gefördert.

C bietet diese Möglichkeiten nur eingeschränkt. Kapselung lässt sich nur innerhalb eines Moduls erreichen. Hier gibt es die Auswahl zwischen static (verborgen) und extern (nach außen sichtbar). Die Polymorphie lässt sich hier nur über Function Pointer realisieren, die von Hand initialisiert werden müssen. Ein praktisches Beispiel dafür, wenn auch nicht aus dem Embedded-Umfeld, ist der Linux-Kernel. Viele Schnittstellen z.B. zu den unterschiedlichen File-Systemen sind über Strukturen aus Funktion-Pointern implementiert.

Speicherverwaltung

In C gibt es die Möglichkeit, in Speicherbereichen entweder Strukturen als Instanzvariable zu belegen, oder Speicherressourcen typlos über malloc() zu belegen und über free() freizugeben. Unter C++ gibt es ferner die Möglichkeit, über new() und delete() Klassen zu instanziieren. In Embedded-Umfeld wird diese Möglichkeit nur sehr eingeschränkt verwendet, da die Verwaltung der Speicherinformation nicht unerheblich Ressourcen belegt. Dies ist in erster Linie RAM. Moderne Betriebssysteme haben diese Beschränkung nicht und sind dafür gedacht, universell für unterschiedliche Aufgaben einsetzbar zu sein.

Im Gegensatz dazu haben Mikrocontroller meist nur eine oder wenige klar definierte Aufgaben, die meist zyklisch abgearbeitet werden. Dadurch können Speicherbereiche beim Starten des Mikrocontrollers einmal statisch belegt werden. Es muss also nicht auf evtl. weitere Prozesse Rücksicht genommen werden. Werden die Aufgaben komplexer wie z.B. bei einer Zentralsteuerung, können sich zur Laufzeit die Anforderungen dynamisch ändern. Solche Aufgaben werden typischerweise auch für größere Mikrocontroller entwickelt, so dass eine Speicherverwaltung zur Schonung der Ressourcen durchaus Sinn macht.

Unter C gibt es das Pärchen malloc() und free(), die typfrei, also als void *, den geforderten Speicher liefern und wieder freigeben. Bei der Zuweisung muss also ein Typecast durchgeführt werden, um eine Compiler-Warnung zu vermeiden. Unter C++ werden stattdessen new() und free() eingesetzt. Der Vorteil ist, dass der Speicher typisiert zurückgegeben wird. Dadurch muss kein Typecast erfolgen. Ein weiterer Vorteil ist, dass unter C++ damit Klassen instanziiert werden können, welche sog. Konstruktoren und Destruktoren enthalten können.

Konstruktoren und Destruktoren

Eng mit der Speicherverwaltung ist die Verwendung von Konstruktoren und Destruktoren gekoppelt. Bei einer Instanziierung einer Klasse wird automatisch der sog. Konstruktor aufgerufen. Durch diesen kann eine Klasse initialisiert werden. Vor dem Freigeben des Speichers mit „delete“ wird der Destruktor aufgerufen, wodurch eine Freigabe von belegten Ressourcen erfolgen kann. Durch dieses Konstrukt kann einfach eine Ressourcenverwaltung implementiert werden.

Das Schöne ist, dass das auch bei lokalen Variableninstanzen funktioniert. So kann innerhalb eines Scopes eine Klasse instanziiert werden, initialisiert sich und kann verwendet werden. Beim Verlassen des Scopes wird vorher der Destruktor aufgerufen und es können belegte Ressourcen freigegeben werden. Dies geschieht unabhängig davon, wie der Scope verlassen wird. Das kann ein „return“ sein, aber auch ein „break“ oder eine Exception.

Ein sehr schönes Beispiel ist das Verwalten von kritischen Bereichen, bei denen der Interrupt gesperrt werden muss. Es wäre fatal, würde man beim Verlassen dieses Bereichs den Interrupt nicht wieder erlauben. Zu diesem Zweck kann man eine Klasse definieren, die sich im Konstruktur den Interrupt-Status merkt und danach den Interrupt sperrt und im Destruktor den alten Status wieder herstellt. Ein weiteres Beispiel wäre ein Chip-Select-Mechanismus nach gleichem Prinzip.

Polymorphie

Bild 1: Beispiel für Polymorphie: Die abstrakte Klasse IODriver wird von den beiden Klassen UartDriver und CanDriver abgeleitet.
Bild 1: Beispiel für Polymorphie: Die abstrakte Klasse IODriver wird von den beiden Klassen UartDriver und CanDriver abgeleitet.

Mit Polymorphie ist die Fähigkeit gemeint, dass ein Objekt vielgestaltig auftreten kann. Dies wird mit der Vererbung erreicht. Durch die sog. Basisklasse lässt sich das Verhalten eines Objektes definieren. Die Ableitung davon implementiert das Verhalten je nach Umfeld.

Ein praktisches Beispiel ist, dass in vielen Mikrocontrollern unterschiedliche Kommunikationsmöglichkeiten angeboten werden. Man kann über UART, TWI, SPI, usw. kommunizieren. Alle Schnittstellen haben gemeinsam, dass Daten gesendet und empfangen werden können. Für die Basisklasse „IODriver“ werden also die Methoden init(), read() und write() gebraucht. Wie die Kommunikation mit der Hardware tatsächlich abläuft, wird durch die Ableitung bestimmt. Zwei abgeleitete Klassen könnten „UartDriver“ oder „CanDriver“ heißen. Diese wissen, wie die Hardware angesteuert wird. Nach außen hin ist dieses „Wissen“ gekapselt („Black Box“).

Da die Basisklasse selbst als solche nur eine abstrakte Definition ist, macht es auch Sinn, diese als abstrakt zu markieren. Dazu dient das Konstrukt der sog. „Pure virtual function“. Es wird eine Methode in der Basisklasse deklariert, darf oder soll dort aber nicht definiert werden. Es muss also eine Implementierung in einer der Ableitungen definiert werden. Dies wiederum führt dazu, dass der C++-Compiler schon feststellen kann, dass eine abstrakte Klasse nicht mit „new“ instanziiert werden kann, sondern nur vollständig abgeleitete Klassen.

C++ bietet auch die Möglichkeit des sog. Operator-Überladens. Hierin besteht die Möglichkeit, gewöhnliche Operatoren wie Addition, Logikoperatoren, Zuweisung etc. abzuleiten. Im Bereich der Vektorrechnung wird dies gerne gemacht. Inwieweit es im Embedded-Bereich Sinn macht, bleibt jedem selbst überlassen.

Templates

Mit Templates können Klassen typisiert werden. Anwendung findet dies bei sog. Container-Klassen. Sie verwalten eine Menge von Objekten, z.B. Listen. Die Funktionsweise, um z.B. Integer-Zahlen in einer Liste zu verwalten ist gleich der, wie Strukturen verwaltet werden. Wenn man eine Listen-Klasse über Templates typisiert, wird nicht der Typ direkt festgelegt, sondern für den Typ ein Platzhalter eingesetzt, der zur Compile-Zeit den geforderten Typ einsetzt.

Im Embedded-Bereich werden häufig Ring Buffer eingesetzt, um schnell einkommende Daten bis zur Verarbeitung zwischenspeichern zu können. Es werden häufig mehrere Ring Buffer für unterschiedliche Datentypen eingesetzt. Hier kann man die Implementierung des Ring Buffers über Templates sowohl typisieren, als auch dimensionieren. Der Vorteil ist, dass zur Compile-Zeit eine Typüberprüfung stattfinden kann. Dies wiederum führt zu höherer Code-Qualität. Da nur einmal eine Implementierung entwickelt wird, erlaubt dies eine bestmögliche Wiederverwendung von Code.

Diese Headerdatei hat eine Ringbuffer-Implementierung als Template-Klasse. Diese kann mit beliebigen Typen und beliebiger Ringgröße definiert werden. Die ersten beiden Methoden sind der Konstruktur und Destruktor. Die weiteren Methoden schieben ein Element in den Buffer, holen das erste, entnehmen das erste und prüfen auf Überlauf.
Diese Headerdatei hat eine Ringbuffer-Implementierung als Template-Klasse. Diese kann mit beliebigen Typen und beliebiger Ringgröße definiert werden. Die ersten beiden Methoden sind der Konstruktur und Destruktor. Die weiteren Methoden schieben ein Element in den Buffer, holen das erste, entnehmen das erste und prüfen auf Überlauf.

Sieht man sich die Code-Beispiele an (siehe auch Download im Linkkasten am Ende des Beitrags), fällt auf, dass das C++-Beispiel leichter zu lesen ist. Der Algorithmus wird nicht wie beim C-Beispiel durch Pointer-Arithmetik überlagert. Die Nutzung der allgemein gültigen C-Variante ist zumindest für native Datentypen wie „int“ oder „float“ umständlicher, da erst eine Variable mit dem Wert belegt werden muss, bevor sie in den Ring Buffer hinzugefügt werden kann. Um eine Platz sparende Variante zu implementieren, muss eine auf diesen nativen Datentyp angepasste Version des Ring Buffers implementiert werden. Dies ist nur durch Kopieren des Quellcodes möglich, was langfristig zu Lasten der Wartbarkeit geht.

Die Speicherbelastung geht nicht über das gewöhnliche Maß hinaus, das durch Klassen sowieso gebraucht wird. Zu erwähnen bleibt, dass für jeden verwendeten Typen eigener Code erzeugt und kompiliert werden muss. Dies erledigt der Compiler intern, ohne dass der Entwickler davon etwas merkt.

Exceptions

Mit Exception Handling kann man den Programmfluss durch außergewöhnliche Ereignisse beeinflussen. Es empfiehlt sich, dieses für die Fehlerbehandlung einzusetzen. Sobald ein Fehler auftritt, soll der Programmfluss unterbrochen werden – es wird eine Ausnahme geworfen - und in der Aufrufhierarchie soweit zurückgesprungen werden, dass dort eine geeignete Fehlerbehandlung durchgeführt werden kann. Letztgenannter Vorgang heißt „catchen“. Wie der Name schon sagt, handelt es sich hier um Ausnahmen. Das heißt, dass das Werfen von Exceptions auch nur in Ausnahmefällen, also z.B. bei Fehlern, durchgeführt werden sollte. Es sollte z.B. nicht dazu verwendet werden, einen Zustandswechsel im Zustandsautomaten herbeizuführen.

Durch Exceptions wird der Maschinencode vergrößert. Auch wenn keine Exception geworfen wird, muss darauf überprüft werden, sodass somit die Laufzeit vergrößert wird. Es kommt häufig vor, dass eine spezielle Library zum Code gelinkt werden muss, um Exception Handling nutzen zu können. Diese ist aber für viele Mikrocontroller nicht implementiert.

Unter C lässt sich ein solches Verhalten nur durch setjmp() und longjmp() nachbilden. Durch setjmp() wird eine Rücksprungmarke definiert, die durch longjmp() angesprungen wird. Diese Variante ist jedoch sehr unübersichtlich und nicht intuitiv erfassbar, sodass sie selbst bei PC-Applikationen fast nie zum Einsatz kommt.

 

Kurzglossar

 

Expertentipp

Kommentar zu diesem Artikel abgeben

Schreiben Sie uns hier Ihre Meinung ...
(nicht registrierter User)

Kommentar abschicken
copyright

Dieser Beitrag ist urheberrechtlich geschützt. Sie wollen ihn für Ihre Zwecke verwenden? Infos finden Sie unter www.mycontentfactory.de (ID: 255213 / Software-Implementierung)