Dr. Hartmut Schorrig, www.vishia.org 2020-06-06

1. Prinzip

Siehe auch Realisierungsbeschreibung emC - Stacktrace, ThreadContext and Exception handling

Das Exceptionhandling mit try-catch-throw ist in allen Objektorientierten Programmiersprachen etabliert. Es trennt besser als eine Fehleranzeige im Returnwert oder eine Verwendung einer zentralen errno Variablen (altes Konzept in C) den Normalablauf vom Ausnahmeablaufe:

Folgend das Schema, wie es in Java und ähnlich in C++ verwendet wird:

try {
  Programmcode des Normalablaufes
}
catch (Exceptiontyp exc) {
  Ausnahmebehandlung bestimmter Exception-Ursachen
}
catch (ExceptionTyp2 exc) {
  andere Exception-Ursache
}
finally {
  Optionaler Block der in jedem Fall abgearbeitet wird
  auch wenn eine Exception weitergereicht wird.
}
//
irgendeineRoutine(....) {
  ....
  if(Situation ist nicht zu klären) {
    throw new Exceptiontyp(argumente);
  }
}

Die aufrufenden Ebenen einer Routine mit throw brauchen sich nicht um die Ausnahmebehandlung zu kümmern. Es wird nur der Normalzweig programmiert. Das erspart erheblichen Aufwand, denn die möglichen Arten der Ausnahmen können umfangreich sein. Lediglich die Routine, die Ausnahmen auffangen kann, hat den catch-Block; und die Routine, die die Ausnahme feststellt, ruft das throw auf, was direkt zum entsprechenden catch führt. Damit ist die Struktur der Software wesentlich verbessert, durch Einteilung in Ausnahmebehandlung und Normalzweig.

Exceptionhandling kann so betrieben werden, dass die normale Ausnahme bereits als Exception behandelt wird. Beispielsweise gibt es in Java die FileNotFoundException beim Öffnen eines Files zum Lesen oder Schreiben. Dass ein File nicht vorhanden ist, sollte aber erwartbar sein, daher kann ein open() auch null oder false zurückliefern und somit in der Normalbehandlung abgefangen werden. Mittlerweile ist es weitgehend Konsens, nur dann Exceptions zu erzeugen, wenn wirkliche Ausnahmen vorliegen. Das ist jedenfalls gegeben etwa bei ArrayIndexOutOfBoundsException ebenfalls aus dem Java-Bereich. Es ist auch gegeben, wenn eine Statemachine einen nicht erwarteten State oder einen undefinierten Wert für den State aufweist, oder ein default-Zweig in einem ansonsten vollständigen switch-case-Anweisungsblock. Eine Exception wert ist auch ein Zustand eines Reglers in der Überlaufbegrenzung, wenn die Ausregelung erwartet wird, beispielsweise als Folge eines falschen Sensorwertes.

Konsequent kann man argumentieren, dass eine Exception nur dann vorliegt wenn ein nicht vorhergesehener Programmzustand vorliegt. Das ist eigentlich nur gegeben bei einem bisher nicht erkanntem Softwarefehler. Nun ist die Argumentation "Software braucht keine Exception weil sie für die Auslieferung vollständig getestet ist" eigentlich nicht haltbar. Denn: Eine Software kann nur falsifiziert werden, nur der Fehler kann nachgewiesen werden. Es kann nicht nachgewiesen werden dass eine Software vollkommen fehlerfrei ist. Auch wenn das Einige unter uns nicht wahrhaben wollen.

Folglich kann das Excpetion Handling tatsächlich auf die nicht erwarteten Softwarefehler, sprich Zustände, die so nicht erwartet werden, reduziert werden. Aber genau dafür ist es extrem wichtig. Niemand möchte für überhaupt nicht absehbare Fälle Sonderbehandlungen einbauen. Aber ein Fehlerzustand ist gut erkennbar, schon einfach an nicht definierten Statevariablen-Belegungen, einem default im case oder nicht konsistenten Daten die so nicht auftreten dürften. Das Exceptionhandling führt dann dazu, dass es trotz Fehler in möglichst bester Weise weitergeht. Ein Embeddded Control darf nicht einfach stehenbleiben, es sitzt kein Bediener davor.

2. Situation im klassichen C

Ursprünglich wurde mit der globalen Variablen errno die Möglichkeit geschaffen, eine Fehleranzeige aus einer gerufenen Routine unaufwändig anzuzeigen. Wenn errno jedoch nicht threadlokal wirkt sondern tatsächlich eine globale Variable ist, dies ist oft der Fall, dann ist dieses Konzept in Multithread-Anwendungen vollkommen unbrauchbar.

Daher wird häufig, insbesondere bei Betriebssystemroutinen, der return-Wert eines Aufrufs zur Fehleranzeigen benutzt. Dieser muss folglich nach jedem Aufruf ausgetestet werden und ein entsprechender Sonderzweig programmiert werden.

Es gibt seit den Anfangszeiten von C den sogenannten longjmp, der sehr wahrscheinlich schon ursprünglich für Ausnahmebehandlungen vorgesehen war und heute teils auch (noch) dafür verwendet wird. Jedoch ist diese longjmp-Möglichkeit offensichtlich zuwenig allgemein bekannt. Jedenfalls begegnet man ihr wenig. Die Ausführungen im C99-Standard "INTERNATIONAL STANDARD ©ISO/IEC ISO/IEC 9899:TC3", chapter 7.13 www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf sind nicht so hilfreich, dass man diese Anwendung erkennen kann, insbesondere fehlt im Beispiel auf S. 245 die Verwendung des Rückgabewertes des setjmp(buf), der allerdings dort zuvor exakt erklärt wurde. Eine kürzere Erklärung liefert https://pubs.opengroup.org/onlinepubs/9699919799/functions/longjmp.html innerhalb der C-Standard-Präsentation https://pubs.opengroup.org/onlinepubs/9699919799/. In diesem Dokument wird der longjmp aber etwas irreführend mit 'longjmp - non-local goto' überschrieben, siehe auch Kapitel 5.2: longjmp hat nichts mit goto zu tun.

3. Nachteile bei Anwendung von try-throw-catch in C++ im Embedded Bereich

Problem 1 ist natürlich, dass man sich flächendeckend für C++ entschieden haben muss. Man findet zwar bei einigen Compilern die Möglichkeit der Anwahl, dass auch C-Routinen eingeordnet werden können auf dem Weg vom throw zum catch (bei Texax Instruments Code Composer Studio die Compileroption --extern_c_can_throw), das try und catch ist dennoch nur der C++-Compilierung vorbehalten.

Das mehr entscheidende Kriterium ist allerdings, dass für den Weg von throw in C++ zum catch-Block nicht vernachlässigbare Aufwändungen erbracht werden müssen für jeden Operations-Aufruf. Einerseits muss der Weg zurückverfolgbar sein zum catch, andererseits folgt es der Logik von Constructoren und Destructoren, dass von den Instanzen, die in den Zwischenebenen des Aufrufs im Stack angelegt wurden, die Destructoren gerufen werden. Denn: Die Instanzen sind danach nicht mehr vorhanden. Auch wenn die Destructoren leer sind, erfordert die Organisation des Aufrufs Aufwand. Dieser Aufwand ist freilich nur vohanden im throw-Fall, der Aufwand im Stackframe zur Auffindung eines catch ist immer notwendig, weil eine beliebig tief gerufene Routine ein throw enthalten könnte (man denke an dynamisches Binden) oder auch eine asynchrone Exception bei einem Speicherfehler und dergleichen ausgelöst von einer Trap-Behandlung auftreten könnte.

Alles in allem ist dieser Aufwand im schnellen Realtime eher nicht erwünscht.

Das Handbuch "__TMS320C28x Optimizing C/C Compiler v20.2.0.LTS__" von Texas Instruments: link:https://www.ti.com/lit/ug/spru514t/spru514t.pdf[] erwähnt dazu im Kapitel 6.7 C Exception Handling (S. 114):

Using the --exceptions option causes the compiler to insert exception handling code. This code will increase the size of the program and execution time, even if no exceptions are thrown.

----------------------------------------------------------------------------

In einer Stellungnahme der ARM Germany GmbH, die dem Verfasser als email vorliegt, wird unter Anderem das Fehlen der Überprüfung von Schutzverletzungen im embedded Bereich (im Vergleich zur PC-Programmierung) aufgeführt:

Das Hauptaugenmerk liegt sicherlich auf der Betriebssicherheit. …​ Vor allem die dynamische Speicherverwaltung birgt hier viele neue Risiken oder aber auch durch Standards nicht erlaubte Möglichkeiten. Dagegen steht eine sehr kleine Gruppe von C++ Entwicklern in unserer Kundenbasis und eine entsprechend geringe Nachfrage.

----------------------------------------------------------------------------

In dieser Mail wird die Aufgabe des Exceptionhandling, bei Schutzverletzungen (Zugriff auf falsche Speicherbereiche) zu wirken, angesprochen. Das ist ein generelles Problem, bei PC-Prozessoren mit dem Memory Management gut gelöst, aber für Embedded faktisch nirgends present. Allerdings ist die MMU im Wesentliche dazu da, bei Fehler in einem Prozess nicht das gesamte Betriebssystem lahmzulegen. Im Embedded Bereich gibt es die Unterteilung in gekapselte Prozesse eher nicht.

Die Aussage zur dynamischen Speicherverwaltung weist eher auf ein Mismatch zwischen dem C++-Standard, der eher für PC gedacht ist, und den Erfordernissen bei Embedded hin, so zumindestens meine Interpretation.

Diese beiden Aussagen sollten die Situation aus Sicht eines Compilerbauers prägnant wiederspiegelt.

4. Muss deshalb auf Exceptionhandling verzichtet werden

Die klare Antwort sollte NEIN sein, nur auf C++ try-throw-catch muss im Embedded Bereich wohl verzichtet werden, und auf die Nutzung von Destructoren im C++, nicht aber auf das Exceptionhandling als solches. Wenn man es kennt aus der PC-Programmierung, dann weiß man die Vorteile zu schätzen. Lediglich aus dem klassichem C-Bereich gibt es wohl wenig entsprechende Erfahrungen.

4.1. Vergleich: Control-Abgabe mit Watchdog und Reset

Es gibt ein bekanntes Verfahren im Embedded-Bereich: Wenn ein Controller nicht mehr funktioniert, insbesondere ein zyklischer Interrupt nicht mehr abgearbeitet wird oder eine nicht kontrollierbare Fehlersituation vorliegt, dann wird ein Watchdog-Timer nicht mehr re-triggered. Mit dessen Ablauf wird dann direkt hardwareseitig ein Reset des Controllers ausgelöst. Man geht dabei von der Annahme aus, dass mit dem Neuanlauf Zustände wieder korrekt initialisiert werden und so eine Weiterarbeit mit temporärem Kontroll- und Datenverlust möglich ist. Der dazu passende bekannter Spruch "Ein neues boot tut gut" ist selbst aus dem PC-Bereich bekannt.

Ein solches Watchdog-Reset sollte nur erfolgen, wenn die Situation nicht mehr softwareseitig abgefangen werden kann oder wenn die Auswirkungen des Neuanlaufs weniger kritisch sind. Man bedenke, die Controller arbeitet mit extern ablaufenden physikalischen Dingen zusammen. Wenn ein Controller für die Zündzeitpunkte eines Motors neu anläuft und innerhalb weniger Millisekunden wieder arbeitet, dann fällt für vielleicht 5 Kolbenbewegungen die Zündung aus, was schonmal verträglich ist wenn es nicht stark wiederholt passiert.

Dieses Verfahren ist eher geeignet für kleine Prozessorlösungen, die tatsächlich auch wieder schnell anlaufen.

4.2. Situation ist im eigenem Umfeld nicht mehr beherrschbar, wohl aber beim Aufrufer

Es gibt einen möglicherweise bekannten Kinderspruch "Ich weiß nicht weiter - bist du gescheiter?". Dies umschreibt prägnant eine Situation: Man muss nicht mit komplexen Überlegungen gepaart mit den entsprechend dafür notwendigen Daten in einer Operation alle Situationen beherrschen. Es ist besser "das Handtuch zu werfen" was man direkt mit throw übersetzen kann. Die Kontrolle wird damit an die Operation abgegeben, die mit einem catch erklärt, dass sie eine Fallback-Lösung oder einen "Plan B" hat.

Angenommen eine Auswertung eines Messwertes führt in einer tieferen Aufrufebene zu keiner Aussage, weil der Sensor defekt ist. Im catch-Zweig wird dann auf einen anderen Sensor umgeschaltet, der vielleicht ungenauere Werte liefert aber den Prozess weiter arbeiten lässt oder gegebenenfalls ein geordnetes Herunterfahren des zugehörigen äußeren physikalischen Prozesses bewirkt.

Nur bei kleinen Prozessoren mit geringen Resourcen ist das harte Watchdog-Reset die einzig sich anbietende Möglichkeit.

4.3. Was ist mit dem Aufwand pro Stackframe bei C++ try-catch

Die obigen Ausführungen führen zur Überlegung, dass Exceptionhandling die einfachste und beste Möglichkeit der Fehlerbehandlung ist.

Sollte man nun den notwendigen Aufwand an Rechenzeit für die Einrichtung der Daten für die Organisation des Weges von einem throw zum catch, wie er in C++ notwendig ist, akzeptieren? Im Sinne dessen dass einen höhere Leistungsfähigkeit der Prozessoren dies ermögliche? Die Beobachtungen der Haltungen der Embedded Programmierer deuten nicht in diese Richtung. Denn: Wozu sollte man einen Aufwand treiben, der "weh tut" für eine Sache die man sowieso nicht bräuche. Also wird wieder der althergebrachte Stil der Fehleranzeige über den Returnwert "für die wenigen Fälle" favorisiert. Das Problem dabei ist, dass die Einsicht, was alles passieren kann an Fehlermöglichkeiten, erst mit der Implementierung der Details wächst. Dann ist aber die falsche Grundentscheidung bereits getroffen.

Wie viele Dinge auch im tatsächlichen Leben ist hier eine Akzeptanz nur zu Erreichen, wenn es diese zum "Nulltarif" gibt.

  • Man ist ja zunächst der Meinung dass man das Exceptionhandling gar nicht bräuche.

  • Mit der steigenden Leistungsfähigkeit der Prozessoren wachsen eher die Aufgaben, was der Prozessor ausführen soll. Kürzere Abtastzeiten bedeuten eine präzisere Regelung. Zusatzzeitaufwände für etwas was man zunächst nicht braucht, stören immer.

  • Die Optimierung im Embedded Bereich geht meist nicht in die höhrere Leistungsfähigkeit sondern in Richtung des niedrigeren Energieverbrauchs, oder in Richtung niedriger Stückkosten.

  • Wenn schon ein leistungsfähigerer Prozessor, dann gibt es eine Reihe von Datenauswertungen, Optimierungsberechnungen und dergleichen, die man nun endlich mit unterbringen kann.

5. Exceptionhandling zum quasi Nulltarif

5.1. Einsatz von longjmp auch in C++

Das Exceptionhandling mit longjmp ist gleichsam verwendbar wie das C++ try-throw-catch. Lediglich die Destructoren der Zwischenebenen werden nicht aufgerufen. Ein Aufwand entsteht nur für das TRY (Einrichten des set_jmp, geschachtelte longjmps verwalten) und beim THROW (Aufbereiten des Exception-Objektes, longjmp ausführen). Der Grundaufwand an Rechenzeit entsteht also nur in der einen Ebene, in der man bewusst das TRY formuliert. Das THROW braucht seine Rechenzeit, nur wenn die Situation auftritt. Es sind keine dynamischen Objekte notwendig, die ebenfalls im Embedded Bereich ein Problem darstellen.

5.2. longjmp hat nichts mit goto zu tun

In https://pubs.opengroup.org/onlinepubs/9699919799/functions/longjmp.html ist dieses Kapitel mit 'longjmp - non-local goto' überschrieben. Nun wird im C99-Standard nachlesbar beispielsweise in "INTERNATIONAL STANDARD ©ISO/IEC ISO/IEC 9899:TC3" www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf das goto beispielsweise im Kapitel "6.8.6 Jump statements" S. 136 gleichberechtigt zu continue, break und return behandelt, was auf return im speziellen eher unpassend erscheint. Es müsste dann auch der Funktionsaufruf in dieser Kategorie erscheinen, das Gegenstück zu return. Auch beispielsweise in while-Schleifen auf Seite 133 wird goto mit einer Selbstverständlichkeit benutzt, die formal sprachlich zwar richtig sein möge, aber eigentlich in der Programmierpraxis so nicht hingehört. Es ist allgemeiner Konsens bereits seit der Einführung der Strukturierten Programmierung vor 60 Jahren (Algol 60 war die erste strukturierte Programmiersprache), dass goto-frei zu programmieren ist. Man hat das goto wahrscheinlich in den C-Sprachumfang aufgenommen, weil in der damaligen Programmierwelt (1970) Fortran noch eine große Rolle gespielt hat und man die Möglichkeit, die damals schon umfangreich vorhandenen Fortran-Libraries einfacher übernehmen zu können, eröffnen wollte. Goto ist nicht erwünscht, nicht üblich und auch durch viele Richtlinien wie beispielsweise MISRA-C-2004 Regel 14.4 "The goto statement shall not be used" ausgeschlossen.

Eine setjmp/longjmp-Konstruktion ist ein spezieller Mechanismus für das Wechseln aus einer gerufenen Ebene in eine vorher bestimmte rufende Ebene, die insbesondere für den Rücksprung als Ausnahmebehandlung geeignet ist und wahrscheinlich (Nachweis ist hier im Moment nicht erbracht) genau aus diesem Grund in die Sprache C aufgenommen wurde. Die Prinzipien eines Excpetion handling waren damals zwar noch nicht Programmierpraxis, wohl aber bereits beschrieben (TODO diese Schriften heraussuchen). Dieser Mechanismus ist fortgeführt im C++ try-catch-throw und ist aus Compilerbausicht damit direkt vergleichbar. Beides sind Spezialmechanismen, die für das jeweilige Zielsystem bereitgestellt werden müssen, also nicht von einer allgemeinen Library erbracht werden können. Es müssen speziellen Umstände der Gestaltung des Stackframes für den jeweiligen Zielprozessor bekannt sein. Folgend ist die longjmp-Realisierung für die Texas Instruments Prozessor-Serie TMS320C2000 gezeigt (Quelle: Code Composer Studio Version 10.0, File ' ti-cgt-c2000_20.2.0.LTS/lib/src/setjmpfpu32.asm`):

****************************************************************************
*    C++ syntax:    void longjmp(jmp_buf env, int returnvalue)
*
*    Description: Restore the context contained in the jump buffer.
*                 This causes an apparent "2nd return" from the
*                 setjmp invocation which built the "env" buffer.
*
*    Return:      This return appears to return "returnvalue", which must
*                 be non-zero.
*
****************************************************************************
_longjmp:	.asmfunc stack_usage(4)
  CMPB	AL,#0		; ensure that returnvalue will be non-zero
  B L1,NEQ		    ; if (returnvalue == 0) return 1
  MOVB	AL,#1		;
L1:
  MOVL    XAR1, *XAR4++
  PUSH    XAR1          ; put new return address on stack
  POP	RPC              ; pop new return address
  MOVZ	AR1,*XAR4++	     ; set SP to value stored in env
  MOV	SP,AR1
  MOVZ AR1,*XAR4++        ; Ignore alignment hole.
  MOVL    XAR1,*XAR4++    ; restore register that compiler conventions
  MOVL    XAR2,*XAR4++    ; require to be restored on function return
  MOVL    XAR3,*XAR4++
  MOV32   R4H, *XAR4++
  MOV32   R5H, *XAR4++
  MOV32   R6H, *XAR4++
  MOV32   R7H, *XAR4
  LRETR			; return
.endasmfunc

Das try-catch-throw ist insoweit geändert, dass nicht mehr ein global auffindbares Objekt als Datenspeicher genutzt wird (eine jmp_buf-Instanz) sondern die Daten im Stackframe selbst organisiert sind, außerdem werden auf dem Rückweg vom throw zum catch (entspricht dem longjmp selbst) alle Destruktoren der aufrufenden Ebene ausgeführt. Damit muss dieser Mechanismus noch stärker compiler-immanent sein, ist aber sonst ein adäquates Konzept.

Der longjmp entspringt keinesfalls einer goto-Denkweise. Dieser Mechanismus ist strukturiert: Die Unterscheidung aufgrund des Rückgabewertes von setjmp(…​) wird meist, auch nach C-Standard-Empfehlungen, für eine if-Struktur verwendet. Man kann diesem Mechanismus selbst eine frühe Objektorientierung zusprechend: Die Instanz des zugehörigen jmp_buf ist das Object, tatsächlich referenziert, obwohl aufgrund der Makro-Realisierung die Referenz auf jmp_buf nicht direkt als solche erscheint. Das setjmp ist eine Methode auf diese Struktur als setter, longjmp ist eine Methode, die nach den Regeln des Exceptionhandling wieder zur setjmp-Ebene zurückführt.

5.3. longjmp und Registervariable

Folgendes Konstrukt ist für Exceptionhandling typisch:

{
 FILE* volatile file = null;
 TRY {
   //.. do anything may thrown
   file = fopen("name");
   //.. do anything may thrown
 }_TRY
 FINALLY {
   if(file !=null) {
     flose(file);
   }
 }
 END_TRY;
}

In diesem Fall kommt es auf das FINALLY an, das jedenfalls dafür sorgen soll, dass ein geöffneter File auch wieder geschlossen ist.

Was ist, wenn der Compiler die Variable file aus Optimierungsgründen in ein Register schiebt? Das Register wird als Ausführungskontext mit in den jump_buf geladen, und also bei einer Exception über longjmp() wieder restauriert, mit null belegt entsprechend dem Zustand vor dem setjmp. Die Information, dass der file geöffnet wurde, geht verloren. Das ist fatal. Diesen Effekt muss man kennen. Wenn alle Variable die zwischen TRY und _TRY verändert werden, als volatile bezeichnet werden, dann optimiert der Compiler diese nicht in Register. Es sind Stackvariable, die ihren Wert beibehalten, so wie gesetzt. Der Stackinhalt wird nicht in den jump_buf geschrieben, er steht so wie er ist sowieso im Stack und bleibt dort erhalten.

Diese Besonderheit ist zu beachten bei Einsatz des folgend vorgestellten Systems.

5.4. ThreadContext-Daten

Was man braucht ist ein Bereich threadlokaler Daten (ThreadContext). Wichtig ist, dass ein TRY-THROW-CATCH Konstrukt beispielsweise in einem Hardwareinterrupt (schnellste Möglichkeit bei kurzen Zykluszeiten) unabhängig von einem TRY-THROW-CATCH in einem Programmteil in der mainloop oder in einem Thread eines Multitreading-Systems abläuft. Man darf daher nicht einfach eine globale Speicherstelle für das jmp_buf-Objekt nutzen, der einfachste Weg, sondern dies in den ThreadContext legen. Der ThreadContext ist für schnelle Interruptzeitschalen unaufwändig zu realisieren. Es genügt pro Interrupt ein statischer Speicher, der über einen globalen Zeiger referenziert wird. Bei Eintritt in den Interrupt wird die bisherige Referenz lokal gespeichert und die neu gültige Referenz gesetzt, und beim Austritt wieder restauriert. Das geht, da es keine präemptive Verdrängung gibt. Bei einem Multithread-Betriebssystem könnte diese Aktion vom Scheduler genauso ausgeführt werden, ist aber häufig nicht vorgesehen. Man muss dann mit leicht höherem Aufwand über die Thread-ID auf den Speicherbereich referenzieren

5.5. Verzicht auf Destructoren

Arbeitet man mit C++, dann muss man nicht dem Programmstil folgen, wesentliche Dinge in Constructoren und Destructoren unterzubringen. Im Vergleich mit Java: Dort gibt es keine Destructoren. Im Constructor legt man üblicherweise zwar Speicher für als Composite referenzierte Daten an, für die man keinen Destructor braucht da es den Garbage Collector gibt. Aber genau dies braucht man im Embedded Bereich eher nicht, da dynamische Daten zur Laufzeit Probleme hervorrufen. Mit anderen Worten: Library-Funktionen, die im Constructor Daten im Heap anlegen und daher den Destructor brauchen um die Daten wieder zu löschen, sind für den Embedded Bereich sowieso nicht geeignet.

Verbleibt das Pattern, im Constructor etwa einen File zu öffnen um ihne im Destructor wieder zu schließen. Dieses Pattern ist in Java nicht nur eben deshalb nicht gebräuchlich weil es den Destructor nicht gibt, sondern auch weil die Tatsache des file-open und -close im Programmablauf besser erkennbar ist. Dass insbesondere beim File-open in Java die dazu notwendige Instanz java.io.FileReader oder dergleichen mit einem Constructor angelegt wird, widerspricht dieser Überlegung nicht. Denn, die File-open-Aktion ist der Aufruf des new FileReader(…​) als solche Operation.

Es ist also eine Grundsatzentscheidung, die Destructoren in C++ leer zu lassen wenn man das Exceptionhandling zum Nulltarif mit longjmp einsetzen möchte. Diese Entscheidung bringt außer der Abkehr von einem für PC-Applikationen verbreiteten Stil keine Nachteile, wie oben dargestellt.

Wichtig ist in diesem Zusammenhang das finally, im Beispiel aus Java:

try {
 open a resource;
 doSomething which may be thrown;
}
finally {
 close the resource;
}

In diesem Fall gibt es keinen catch-Block, die Excpetion wird weitergereicht. Aber das finally dieser Ebene wird jedenfalls aufgerufen und enthält die notwendigen Nachbehandlungen.

6. Flexibilität mit Makros

Eine direkte Programmierung des longjmp für Exceptionhandling in den User-Sources manifestiert dies als Entscheidung. Sollen die gleichen Quellen für reine C++ Anwendungen mit genügend Rechenzeitreserve eingesetzt werden oder auch nur auf dem PC getestet werden, und es wird aus anderen Gründen für C++ try-throw-catch entschieden, insbesondere für Erkennung von memory-Exceptions (asynchron), dann müsste man umprogrammieren oder mehrere #ifdef -Blöcke vorsehen.

Für diese Dinge gibt es in C/++ die Makros, die in Headerfiles definiert werden. Je nachdem welche Header eingezogen werden, ändert die Implementierungsfunktionalität ohne die Quellen ändern zu müssen.

Mehr noch, es ist möglich, eine Applikation unter PC-Bedingungen in C++ zu testen, dabei das C++-native try-throw-catch zu verwenden, um die unveränderten Quellen in einem Zielsystem unter schnellen Realtime-Bedingungen mit longjmp zu implementieren, oder in der ausgetesteten Form dann ohne Excpetionhandling laufen zu lassen.

Die Makros in ausgetesteter Form, siehe Stacktrace, ThreadContext and Exception handling sind dann wie folgt verwendbar:

TRY {
  ...Normalablauf
}_TRY
CATCH(Exception exc) {
  ...Ausnahmebehandlung
}
FINALLY {
  ...Behandlung auch nach Ausnahme
}
END_TRY
...
subroutine(...)
  if(Ausnahmesituation) {
    THROW(Exception-Daten)
  }
}

Dieses Muster wird je nach Einsatz umgesetzt in C++ try-throw-catch, longjmp oder auch eine Behandlung ohne Rücksprung. Im letzten Fall wird mit dem THROW lediglich eine Fehlermeldung abgelegt, die Abarbeitung muss mit den Statements nach dem THROW gesichert forggesetzt werden. Der CATCH-Block wird dann am Ende des TRY-Blocks betreteten, wenn der Normalablauf dorthin gelangt und der Fehler gespeichert wurde.

7. Ablauf ohne Exception

Mit den selben Makros kann auch eine Arbeit ohne Exception im Zielsystem ausgeführt werden. Dies ist ursprünglich nur als Notlösung entstanden, weil einige Embedded Compiler den longjmp-Mechanismus leider nicht korrekt implementieren, offensichtlich haben zu wenig Anwender danach gefragt. Aber diese Variante kann durchaus sinnvoll sein. In diesem Fall läuft es nach einem THROW weiter. In diesem Programmzweig muss dann dafür gesorgt werden, dass es keine unkalkulierten Nebeneffekte gibt. Es gibt eine Fehleranzeige, durch Ablegen einer Message in einem Fehlerspeicher, der irgendwann manuell ausgelesen wird, und gegebenfalls falsche Daten, weil die Zustände eben nicht stimmen. Aber es gibt keine "Absturz", das System läuft weiter. Das ist eine Variante der THROW-Implementierung, die in Stacktrace, ThreadContext and Exception handling beschrieben ist und so in den emC-Sources implementiert ist:

void anyRoutine(...) {
 .....
 if(errorstate detected) {
   THROW(Exception, message, values);
   correct data for a proper usage .....
 }

8. Stacktrace

Ein Stacktrace wie er beispielsweise als Call-Stack-Anzeige im Debugger bekannt ist, ist für eine Fehlerursachenforschung in Logfiles abgelegt exterm hilfreich. Im Stacktrace ist erkennbar, in welchem Kontext die throw-auslösende Routine gerufen wurde.

Der Stacktrace ist aber genau die Ursache für einen erhöhten Rechenzeitaufwand pro Subroutinenaufruf, den man im Normalfall nicht haben möchte ('Null-Tarif').

Folglich ist es angeraten, Stacktraceeinträge nur dann zu compilieren, wenn

  • es sich um einen Algorithmustest auf dem PC handelt, bei dem die Rechenzeit eine untergeordnete Rolle spielt und der Stacktrace insbesondere deshalb wichtig ist, da in der Phase der Algorithmenentwicklung noch Exceptions erwartbar sind.

  • in Programmteilen in einer langsameren Abtastzeit, bei denen ebenfalls Exceptions eher erwartbar sind, diese Einträge zeitlich nicht störend sind.

Folglich muss pro Übersetzungseinheit entschieden werden können, ob mit oder ohne Stacktraceeinträge gearbeitet werden soll.

Daher wird der Stacktrace ebenfalls als Makro erzeugt und darf, muss nicht in jeder Aufrufebene geführt werden:

void anyRoutine(...) {
  STACKTRC_ENTRY("anyRoutine");
  ...
  STACKTRC_LEAVE;
}

Bei einem aktivierten Stacktrace wird in der emC-Realisierung im ThreadContext ein Arrayelement mit der Referenz auf den angegebenen Text und FILE und LINE erzeugt. Wird in einer Aufrufebene dieses STACKTRC…​ Makro nicht benutzt, dannn fehlt diese Aufrufebene im angezeigtem Stacktrace, mehr passiert nicht. Es gibt damit keinen Zwang, jede Ebene im Stacktrace zu verzeichnen.

9. Umfrage

Die Umfrage ⇒doodle, Tip: im neuen Tab öffnen ist anonym für die Benutzer. Ich kann die eingegebenen Namen sehen. Bitte Nickname vergeben wenn gewünscht.

Die Umfrage enthält die Entscheidungen:

  • Exception handling sollte so wie in C++ vorgesehen und für PC-Anwendungen bewährt auch im Embedded Bereich verwendet werden.

  • Exception handling ist gut. Die nativen C++-Lösungen sind aber für Embedded weniger geeignet. Konzept wie im Artikel nutzen

  • Keine Makros! Wenn die Entscheidung für longjmp gefallen ist, dann bitte direkt programmieren.

  • Man braucht kein Exceptionhandling wenn ordentlich getestet ist. Für die erwartbaren Restfehler genügen die aus C bekannten Verfahren

  • longjmp ist mir nicht so geläufig, um dort zuzustimmen muss ich mir das Konzept noch genauer ansehen

Dieser Artikel wird mit den Ergebnissen der Umfrage fortgesetzt, wenn diese vorliegen.

Zusätzlich ist in einer zweiten Umfrage ⇒doodle, Tip: im neuen Tab öffnen noch auswählbar zum Thema dynamischer Speicher zur Laufzeit, ebenfalls anonym für die Nutzer:

  • Auch im Embedded Bereich solte new und delete verwendet werden, es gibt viele C++- Library-Funktionen, die dies so handhaben. Der Speicher ist ausreichend. Das Argument des Fragmentierens ist nicht wirklich relevant.

  • Dynamischer Speicher zur Runtime sollte nur für Speziallösungen verwendet und ansonsten vermieden werden. Zur startup-Zeit ist dynamischer Speicher geeignet.

  • Man sollte im Embedded-Bereich nur mit statischen Daten hantieren, das ist ausreichend, man weiß genau wo die Daten liegen.

Zu dieser Umfrage gibt es einen extra Erklärungs-Artikel: DynMemRuntime_de.html