C++ in der Embedded-Entwicklung: Exceptions und Assertions

Von Hartmut Schorrig *

Anbieter zum Thema

Der vierte Artikel dieser Serie zu C++ in der Embedded-Entwicklung wendet sich dem Thema des Exception handlings und den Möglichkeiten von Assertions zu. Selbst in der umgänglichen C++-Programmierung gibt es dazu einiges zu sagen, die Technik von Exceptions stellt sich aber im Embedded-Umfeld nochmal ganz anders dar.

Der Umgang mit Exceptions ('Ausnahmen') und Assertions ('Annahmen') in C++ erfordert gerade im Embedded-Umfeld genaue Aufmerksamkeit.
Der Umgang mit Exceptions ('Ausnahmen') und Assertions ('Annahmen') in C++ erfordert gerade im Embedded-Umfeld genaue Aufmerksamkeit.
(Bild: gemeinfrei / Pixabay)

Exceptionhandling ist seit Jahrzehnten in vielen Hochsprachen, nicht nur C++, auch Java, Python, C# und vielen anderen etwa gleichartig im Gebrauch. Eine der Grundideen ist es, die lästige Abfrage von möglichen Fehlerreturn-Werten in jeder Aufrufebene, wie oft in C gehandhabt, einzusparen. Sachlich gibt es eine Trennung der beiden Ebenen, die den Fehler feststellt, und die den Fehler behandeln kann. Das wird durch throw und catch eindeutig markiert. Die Zwischenebenen brauchen keinen zusätzlichen Aufwand.

Alternativen zu Exceptionhandling, C-Programmierung

Da man traditionell in C nicht mit den Möglichkeiten des Exceptionhandling rechnet, sind dort folgende Verfahren etabliert:

  • errno wird gesetzt, Routine wird beendet. Alle anderen Routinen müssen nun über die errno austesten, ob ein Fehler vorliegt. Die errno ist in C seit Anfangszeiten vorhanden und für einfache Programme eines Prozesses mit einem Thread einsetzbar. Die globale errno-Variable ist häufig nicht threadsicher.
  • error-return: Wegen der Threadunsicherheit der errno ist es insbesondere bei Betriebssystemaufrufen üblich, diese mit einem Fehleranzeige-Returnwert zu verlassen. Dieser muss also immer ausgetestet werden. Das Verfahren ist threadsicher.

Beide Verfahren haben den Nachteil, dass die Abfrage der möglichen Fehlersituation und der Rücksprung wiederum mit error-return in jeder Aufrufebene erfolgen muss, bis die Fehlersituation behandelt werden kann. Das Problem dabei ist, dass im ersten Durchgang der Programmierung oft erst einmal auf den „Gutfall“ orientiert wird – die vielen Ausnahmesituationsmöglichkeiten werden auf „später“ verschoben. Die Komplexität der Software wächst, die Ausnahmen werden nie ordentlich programmiert, weil nun Projektstress vorliegt. Kennt man dies? Mit try-catch-throw wäre das viel übersichtlicher.

Erwähnenswert, wenn auch alles andere als empfehlenswert (aber leider in der Praxis oft genug anzutreffen, ist auch die Ignoranz von Fehlern: Man kann sich durchaus auf den Standpunkt stellen, es wäre alles ausgetestet, Werte würden immer stimmen, alle Testfälle stehen auf grün.

Drei Exception-Situationen sind unterscheidbar

1. Erwartbare Exceptions: Oft werden Exceptions auch verwendet für die normal erwartbare Ausnahme, beispielsweise für „File not found“. Beim file-open kann aber keineswegs vorausgesetzt werden, dass das File vorhanden ist. Es gibt zu viele Situationen wie beispielsweise File wirklich gelöscht oder Netzwerkstecker herausgefallen. Anderes Beispiel: Man kann beim Initialisieren eines Array auf die Größenabfrage verzichten und die Schleife mit einer ArrayOutOfBoundsException beenden, in Java ist das sehr einfach. - Oder auf Nullpointer-Abfragen als erwartbare Rückgabewerte verzichten nach dem Motto, es wird schon irgendwo throwen.

Das Problem beim Debuggen mit erwartbaren Exceptions ist, dass ständig Exceptions ansprechen, die überhaupt nicht von Interesse sind. Wenn man sich mit Visual Studio an ein Tool mit „Attach to Process“ anhängt, wird man sofort konfrontiert beispielsweise mit

„Exception thrown at 0x00007FF8C20B3E49 in XXX.exe: Microsoft C++ exception: mcos::RequireStringMsgException at memory location 0x00000000043F2460.“

obwohl gar keine erkennbare Fehlersituation vorliegt. Es müssen erst einmal alle Exceptionquellen aus der Beobachtung herausgenommen werden („Exception Settings“, alles abhaken). Das ist die Praxis.

Solche Exceptions sollten vermieden werden. Eine file-open-Routine sollte sich besser mit einem null-Pointer zurückmelden wenn das File nicht vorhanden ist. Diese Tatsache muss auf der file-open-aufrufenden Ebene sowieso abgefragt werden. Man spart sich damit das wrappen mit try-catch. Zulässig sollte aber eine Exception bei file-open sein, wenn es ein nicht erwartbares Problem gibt obwohl das File vorhanden ist. Ein gelocktes File ist erwartbar und kein Sonderfall.

2. Exceptions bei erwartbaren Situationen in Sonderfällen: Dafür ist das Exceptionhandling eigentlich da und richtig angewendet. Wenn ein File erfolgreich geöffnet ist, dann ist es eine eher seltene Situation, das jemand innerhalb des kurzen Moments bis zum Schließen über das Netzkabel stolpert, was zu einer IOException führt. Würde man solche Dinge alle behandeln wollen, steigt der Programmieraufwand. Diese Exception hat eine äußere Ursache (im Beispiel schlecht verlegtes Kabel). Die Exception darf die Abarbeitung eines Moduls abbrechen, und muss nicht kleinteilig auf jeder Ebene behandelt werden.

3. Exceptions bei vollkommen unerwartbaren Situationen, nämlich einem Programmierfehler in der abgenommenen Software. Interessanterweise ist die Behandlung solcher Dinge genauso mit gleichen Verfahren (Abbruch eines Moduls, Ersatzreaktion) behandelbar wie der seltene erwartbare Störfall. Man soll nicht sagen, es gäbe keine Softwarefehler. Man soll dafür sorgen, dass bei Softwarefehlern die Situation beherrschbar bleibt.

Prinzip des Exception handlings

Etabliert hat sich folgendes Vorgehen, meist mit einer ähnlichen Schreibweise über viele Programmiersprachen:

try {  ...  AnyOperation(...); // ... Aufruf von Subroutinen auch in tiefer Schachtelung} catch (Exception exc) { //... Ersatzhandlung, wenn irgendwo tief eine Exception auftrat} finally { // ... Durchlauf dieses Blockes immer, auch wenn die Exception hier nicht abgefangen wurde.}

Den finally-Block gibt es in Java, nicht in C++. Er ist wichtig für die Behandlung von Ressourcen.

Alle Operationen dürfen eine Exception werfen ("throwen") wenn sie selbst keine Behandlung ausführen können:

AnyOperation(...) {  if(...    throw new RuntimeException(„message“);}

Wenn die Weiterarbeit in der gegebenen Situation nicht zielführend ist, wird mit einem throw die Kontrolle aus einer möglicherweise tiefen Schachtelung von Subroutinen an den catch-Block gegeben. In C++ werden alle Destructoren der zwischenliegenden Ebenen abgearbeitet, was allerdings etwas Rechenzeitaufwand bedeutet, auch wenn die Destructoren leer sind. Der Anwender braucht in den Zwischenebenen nichts extra zu programmieren. Andere Maschinencodes als die Destructoren werden nicht abgearbeitet. Im catch-Block kann man nun einfach eine Fehlermeldung absetzen und das Programm nach OK-click und Senden eines Reports beenden – oder mit einem „Plan B“, einer passenden Ersatzhandlung, im übergeordnetem Rahmen fortsetzen.

Jetzt Newsletter abonnieren

Verpassen Sie nicht unsere besten Inhalte

Mit Klick auf „Newsletter abonnieren“ erkläre ich mich mit der Verarbeitung und Nutzung meiner Daten gemäß Einwilligungserklärung (bitte aufklappen für Details) einverstanden und akzeptiere die Nutzungsbedingungen. Weitere Informationen finde ich in unserer Datenschutzerklärung.

Aufklappen für Details zu Ihrer Einwilligung

Der Anwender hat die Möglichkeit, mit dem laufenden Programm es nochmal zu probieren, eventuell mit geänderten Bedingungen, und so ohne Kenntnis des Programms selbst feststellen, woran der Fehler liegen könnte. Man stelle sich vor, Einfügen einer Grafik funktioniert nicht: „unbekannter Fehler“. Man probiert es mit einer anderen Grafik – geht. Nochmal mit der alten Grafik – geht wieder nicht. Nun erzeugt man das Grafikfile neu – geht. Es kann weitergearbeitet werden. Wiederholt sich die Fehlersituation häufiger, wird man wohl nach einem Update Umschau halten. Das wäre die Praxis bei PC-Programmen.

Exception handling bei Embedded-Geräten

In der Embedded-Branche ist es noch viel wichtiger, dass ein Gerät nicht einfach abstürzt oder der Watchdog-Reset eingreifen muss, wenn etwas nicht stimmt. Es sollte immer bedienfrei und wartbar laufen, dabei sicherheitsrelevante Dinge sicher beherrschen. Das kann man auf zwei Wegen erreichen:

  • Alle Situationen bedenken, Testen nach Plan und Sicherheitskonzept. Und doch kann eine Situation nicht vorausgeahnt worden sein.
  • Exception handling mit einer sicheren catch-Behandlung.

Auch vom Programmier- und Testaufwand kommt der zweite Punkt besser weg.

In einer Industrieroboter-Lageregelung kann der Plan B im geordnetem Stillstand liegen. Wenn ein Umrichter am Netz hängt, kann Plan B eine „Ventilsperre“ sein, oder auch ein Nachführen der erzeugten Spannung zur Außenspannung mit einem vereinfachtem Algorithmus, so dass der Umrichter am Netz bleibt und wieder leistungsmäßig hochgefahren werden kann.

Da in Embedded-Geräten häufig C eingesetzt wird oder auf eine C-angepasste Programmierung orientiert wird, wird oft statt dem Exceptionhandling entweder auf eine error-Return-Auswertung gesetzt, oder auf die Ignorierung von Fehlermöglichkeiten, die aufgrund der passfähig auf die Einsatzfälle durchgeführten Tests ausgeschlossen werden könnten.

Beim Einsatz von C++ scheitert das Exceptionhandling mit throw zumindestens in zeitkritischen Interrupts oder schnellen Threads offensichtlich an der Rechenzeit - also bleibt man bei den „altbewährten" Technologien der Fehlerabfrage oder Fehlerignoranz. In einem Folgeartikel wird der Einsatz von longjmp anstelle throw vorgestellt und empfohlen.

longjmp, der „kleine Bruder“ des throw

Man kann den longjmp als „den kleinen Bruder“ des throw bezeichnen. Er tut das Gleiche: Restaurieren einer vorher mit setjmp gespeicherten Stack-Ebene (Stackframe), um dort fortzusetzen. Die Fortsetzung wird mit dem Return-Wert des setjmp gesteuert, dieser ist 0 bei direktem Aufruf und nicht 0, entsprechend dem longjmp -Argument bei der Rückkehr aus dem longjmp. Restauriert werden auch die Registerbelegungen. Werden Variable in diesem Stackframe nach dem setjmp geändert und ist der geänderte Wert bei der Rückkehr nach longjmp wichtig, dann müssen diese Variablen allerdings mit einer volatile-Kennzeichnung aus der Registernutzung entfernt werden. Diese Hinweise finden sich auch in entsprechenden Handbüchern. Mit dem setjmp-Returnwert lässt sich die Verzweigung realisieren: Entweder Start der Bearbeitung nach TRY oder der Fehlerzweig.

Der longjmp wurde „zu C-Zeiten“ entwickelt, war in den 80-gern schon präsent.

In Diskussionsrunden in Fachkreisen findet man bezüglich longjmp eher Unkenntnis oder Ablehnung, teils mit der Bemerkung „Das wäre ja goto-Programmierung“. In den Open Group Base Specifications Issue 7, 2018 edition IEEE [8], ist dieses Kapitel tatsächlich mit „longjmp - non-local goto“ überschrieben. Auch aus dem INTERNATIONAL STANDARD ISO/IEC ISO/IEC 9899:TC3 [7] könnte man adäquates herauslesen. Anders allerdings in den Compilerbauer-Kreisen, in denen der longjmp ernst genommen wird. Dem Entgegen steht eine Aussage des ARM-Compilersupports, wonach longjmp als durchaus hoffähig bewertet wird. Die Verwendung des longjmp könnte eine probate Lösung eines Exceptionhandlings auch bei schneller Interruptverarbeitung sein. Dieser Aspekt und einige weitere Aspekte wie Stacktrace zur genaueren Fehlerlokalisierung wird im Folgeartikel behandelt.

Exceptions bei Memory-Verletzungen, Nullpointer

Das Erzeugen einer Exception bei falschen Speicherzugriffen setzt voraus, dass die Speicherzugriffe überwacht werden. Das ist beim Test einer embedded Applikation auf dem PC der Fall. Man muss bei Visual Studio die Option /EHa „asynchronous Exception Handling“ anwählen.

Ein Beispiel aus der Praxis: Eigene S-Function in Mathworks-Simulink können durchaus komplex gestaltet sein. Eigene Fehler, null-Pointer, bewirken den Absturz der gesamten Matlab-Applikation ohne auswertbaren Fehlerhinweis. Die Nutzung von C++ in S-Functions, Einkleidung in try-catch, setzen der /EHa-Option und Augabe eines Infofensters mit Simulink-Mitteln beendet zwar den aktuellen Simulationslauf, aber informiert in bester Weise über die Fehlerstelle in der eigenen S-Function. Diese Möglichkeiten sind in Simulink alle einsetzbar, aber nicht vordergründig favorisiert.

Für embedded Hardware, die den Speicher selbst nicht überwacht, gilt: Vorgetestet am PC sollten die meisten Probleme bereits erkannt und behoben sein. In wieweit sich Hardware-Traps nutzen lassen, um THROW zu erzeugen, wird im Moment noch detailliert untersucht. Die einzelnen Plattformen sind da etwas unterschiedlich. Es sieht aber gut aus.

Allgemeines zu Assertions

Assertions sind Zusicherungen. Es wird an geeigneter Stelle im Programm geprüft, ob eine Festlegung zutrifft. Beispielsweise wird standardgemäß in Java notiert:

assert( a > b);

Man kann nun Math.sqrt( a – b) berechnen, ohne nochmals die Vorbedingungen zu prüfen. Interessant am assert(cond) in Java ist, dass die Prüfung zur Laufzeit wahlweise ein- oder ausgeschaltet werden kann, mittels der Option -ea beim Start java.exe. Die Assertion (Zusicherung) wird also nicht unbedingt geprüft, aus Rechenzeit- und Performancegründen. Ihre Prüfung kann aber jederzeit aktiviert werden, im Falle der Fehlersuche und selbstverständlich bei Entwicklung und Test.

Die Assertion hat noch eine andere wichtige Wirkung: Man weiß beim Studium des Quelltextes, was vorgesehen ist, ohne im Algorithmus tiefer zu forschen und ohne jede Zeile von etwaig vorhandenen Dokumentationen lesen zu müssen. Das trifft auch insbesondere auf eine automatische Codeanalyse zu, die syntaktisch gut mit einem assert-Befehl umgehen kann.

Fehlersuche beim Integrationstest

Wenn ein Entwickler für die kleine embedded Platine zuständig ist, dieser arbeitet selbstverständlich sorgfältig und ist selten krank, dann passt alles zueinander. Wird die Leistungsfähigkeit der Controller größer oder sind mehrere Geräte vernetzt, dann kann es schon mal vorkommen, dass die eine Abteilung nicht genau verstanden hat was die andere Abteilung vermeintlich doch klar formuliert hätte. An den Schnittstellen stimmt es nicht. Jeder testet zunächst selbst in seiner Verständniswelt. Der Integrationstest kommt mit Termindruck, und es zeigen sich Probleme. Nun weiß jeder Entwickler ob seiner Schwächen und sucht die Fehler, nun endlich am Gesamtaufbau, in seinem eigenen Softwareanteil. Dort werden zwar Fehler gefunden, letztlich wird irgendwie angepasst, Hauptsache zum ersten Abnahmetermin geht es.

Mit einer Assertion, die sich passend auch auf das Timing beziehen kann (Zeitinformation auswerten, Nummerierung der Aufrufzyklen, diese in Testzellen in Telegrammen übertragen) kann nun eindeutig festgelegt und automatisch ausgewertet werden, ob an den Schnittstellen alles stimmt. Werden die Assertions gleich zu Anfang festgelegt und eingebaut, fallen Mängel bereits an der eigenen Unittest-Umgebung auf. Die Assertions sind der Ersatz für sonst ggf. verbale Festlegungen, die überlesen oder falsch verstanden werden könnten, möglicherweise nicht versionsgepflegt sind.

Assertions in C und C++

Das assert(..)-Beispiel aus Java zeigt, dass dort schon mit der Version 1.0 diese Thematik geregelt wurde. In C und C++ gibt es zwar das File <assert.h>. Das damit definierte assert(..) führt in die Tiefe von Systemlibraries. Es wird nicht (anders als in Java) eine Exception erzeugt, damit ist das assert(...) nicht auffangbar.

Man kann ein ASSERT(...) aber nun ganz einfach als Makro definieren und zum THROW(...) führen. Das ASSERT(...) kann für bestimmte Compiliereinheiten leer definiert sein (schon getestet, nicht im Fokus), für andere Compiliereinheiten aktiv. Das ASSERT kann geschickt auch als Umgehungsstrategie definiert werden. Der Autor hat diesbezüglich im emC-Framework [4] in der emC/Base/Assert_emC.h im Wesentlichen zwei Makros definiert:

ASSERT_emC(COND, TEXT, VAL1, VAL2);

Dieses Makro ist leer, erzeugt also keinerlei Maschinencode und belegt keinen Speicher für den TEXT, wenn Assertions ausgeblendet sind (Compilerschalter ASSERT_IGNORE_emC). Mit aktiven Assertions wird mit diesen Argumenten ein THROW erzeugt. Damit sind neben den standardgemäßen __FILE__ und __LINE__ einige mehr Informationen vorhanden, insbesondere Begleitwerte, die auswertbar oder logbar sind.

if( CHECK_ASSERT_emC(COND, TEXT, VAL1, VAL2)) {  ... Abarbeitung nur im Gutfall}

Damit kann ein Codeabschnitt übersprungen werden, wenn die Assertion-Bedingung nicht zutrifft. Es kann je nach Definition des Makros auch stattdessen eine Exception erzeugt werden, es kann eine Log-Meldung abgelegt werden, oder das gesamte Makro ist true, was der ausgeschalteten Assertion entspricht. Der Compiler optimiert die if-Abfrage dann heraus.

Die Namensgebung mit dem Suffix _emC soll ein Nameclash mit möglicherweise anderen ASSERT-Makros verhindern, denn es gibt in gewachsener Software viele Varianten. Man kann zusätzlich definieren:

#define ASSERT(COND) ASSERT_emC(COND, "ASSERT", 0, 0)

und hat diese möglicherweise vorhandene Variante in den Sources eingefangen. Ein assert(..) im Zusammenhang mit der <assert.h> lässt sich, abhängig von der Gestaltung dieses Systemheaders, leider nicht umdefinieren, obwohl es auch nur ein Makro ist. Von dessen Verwendung sei daher abgeraten. Nicht immer ist ein Standard für alle Fälle gut geeignet.

Literatur

[1] Erster Artikel dieser Serie: „C++ in der Embedded-Entwicklung: Embedded-Code am PC testen

[2] Zweiter Artikel dieser Serie: „C++ in der Embedded-Entwicklung: Umgang mit Heap-Daten

[3] Dritter Artikel dieser Serie: „C++ in der Embedded-Entwicklung: Keine Angst vor Templates

[4] Website emC des Verfassers

[5] Präsentation einer Exceptionhandling mit Makros wahlweise mit C++ throw und longjmp mit Rechenzeitmessungen (englisch)

[6] Darstellung Exceptionhandling mit C++ throw oder longjmp in deutsch

[7] „INTERNATIONAL STANDARD ISO/IEC ISO/IEC 9899:TC3" (C99-Standard)

[8] The Open Group Base Specifications Issue 7, 2018 edition IEEE Std 1003.1-2017 (Revision of IEEE Std 1003.1-2008) Copyright 2001-2018 IEEE and The Open Group

* Hartmut Schorrig war in den vergangenen zwei Jahrzehnten Entwicklungsingenieur bei Siemens. In den Jahren zuvor wurden in verschiedenen Forschungsinstituten und in der Wirtschaft Erfahrungen gesammelt, anfänglich in den 80-ger Jahren mit der Entwicklung eines Industrie-PCs „MC80“, damals selbstverständlich noch in Assembler. Schon mit dem Studium „Technische Kybernetik und Automatisierungstechnik“ an der TH Ilmenau wurde der Blick auf den Zusammenhang von Elektronik, Regelungstechnik und Software gerichtet. Aktuell betreibt er die IT-Plattform vishia.org.

(ID:46945979)