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