C++ in der Embedded-Entwicklung: Exceptions in schneller Echtzeitverarbeitung
Im Vorgängerartikel wurde die Möglichkeit des Einsatzes des longjmp für die Wiederherstellung des Stackframe der TRY-Ebene erwähnt - in Fachkreisen umstritten doch bei Compilerbauern ernstgenommen. Der fünfte Artikel dieser Serie zu C++ widmet sich dem Exceptionhandling, auch möglich mit longjmp, insbesondere bei Embedded-Geräten.

Etabliert hat sich folgendes Vorgehen, meist mit einer ähnlichen Schreibweise über viele Programmiersprachen. Im folgenden wird die in diesem Artikel vorgestellte Lösung nach [5] präsentiert:
TRY {
...
AnyOperation(...);
... Aufruf von Subroutinen auch in tiefer Schachtelung
}_TRY
CATCH(Exception, exc) {
... Ersatzhandlung, wenn irgendwo tief eine Exception auftrat
}
FINALLY {
... Durchlauf dieses Blockes immer, auch wenn die Exception hier nicht abgefangen wurde.
}
END_TRY
Alle Operationen dürfen eine Exception werfen ("throwen") wenn sie selbst keine Behandlung ausführen können:
AnyOperation(...) {
if(...
THROW(Exception, "message", val1, val2);
}
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. Der Anwender braucht in den Zwischenebenen nichts extra zu programmieren. Im CATCH-Block kann man eine adäquate Ersatzhandlung "Plan B" ausführen.
Exceptionhandling bei Embedded-Geräten
In der Embedded-Branche ist es noch viel wichtiger, dass ein Gerät nicht einfach crashed 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.
- Exceptionhandling 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.
In [4] wurden die bisher üblichen Verfahren und deren Nachteile erwähnt, error-Return oder Fehlerignoranz. Mit dem bewährten TRY-CATCH_THROW Prinzip geht es besser.
Ein kleines Rechenzeitproblem mit C++ throw bei Embedded-Prozessoren
Prozessoren für den embedded Einsatz sind meist viel weniger leistungsfähig als PC-Prozessoren. Das liegt daran, dass andere Kriterien wie Stromverbrauch, Gehäuseformen, Kühlung und der Preis eine Rolle spielen. Dennoch ist die Leistungsfähigkeit ausreichend. Es soll hier von kleinen Prozessoren „poor controller“ und mittelmäßig leistungsfähigen Controllern ausgegangen werden. Zu letzteren gehört beispielsweise ein TMS320F28379D von Texas Instruments. Dieser kann problemlos in einer 50 µs Zeitscheibe (im Interrupt) eine komplexe Regelung berechnen, mit einer Floating-Point-Unit on Board, einem spezifischen CLA „Control Law Accelerator“-Spezialprozessor, das Ganze zwei mal auf dem Chip. Da ist schon einiges möglich. Die Notwendigkeit einer solchen Abtastzeit soll hier nicht hinterfragt werden, man zielt für bestimmte Anwendungen selbst noch auf Implementierungen in einem FPGA, weil 50 µs oder 20 µs Abtasttakt mit einem Prozessor zu langsam sind. Aber die Prozessoren schaffen einiges.
An genau dem oben genannten Prozessor wurden Untersuchungen ausgeführt, wie lange ein Exceptionhandling benötigt [5]. Für die Organisation try-catch ist ein Grundaufwand von 1.44 µs notwendig, durchaus in die 50 µs Zeitscheibe zuordenbar. Der Aufruf von Subroutinen braucht keine zusätzliche Zeit, anders als gegebenenfalls erwartet. Aber: Kommt es zu einem throw, dann benötigt dieser 117 µs bei nur einer Stackebene, 269 µs beim Test mit 9 Zwischenlevel (Stack Frames), was nicht tragbar ist. Der Prozessor lief bei dieser Messung nur mit dem halben Takt (100 MHz), dieser leistet genügend für den 50 µs-Regelungsalgorithmus. Man setzt den Takt oft herunter, damit die Versorgungsleistung für das Kärtchen sinkt. Doch auch beim maximalen Takt von 200 MHz ist das zu viel. Die zyklische Bearbeitung verträgt keine Ausreißer, aus physikalischen Gründen.
Die Rechenzeit verglichen mit der Abarbeitung auf einem PC-Prozessor führt zu folgender Überlegung: Bei PC-Anwendungen kommt es meist auf die Gesamtgeschwindigkeit des Datendurchsatzes an. Einzelne seltene Ausreißer im µs oder gar Millisekundenbereich spielen keine Rolle. Die Rechenleistung ist um den Faktor 10..20 höher als bei diesem mittelleistungsfähigen Controller. Gemessen wurden allerdings an einem PC mit 3 GHz Takt für 10000 Schleifen mit throw etwa 7..10 Sekunden, also 700 µs (!) pro throw im Releasemode mit Optimierung /O2 für „Maximize Speed“ in einem Visual Studio 2015-Projekt. Im Debugmode ohne Optimierung etwas mehr (10..12 Sekunden). Die Vergleichsrechenzeiten für den kompletten Algorithmus lagen im erwarteten Rahmen, bei manueller Zeitnahme überhaupt nicht auffällig bei 10000 Durchläufen. Das bestärkt die Vermutung, dass das Exceptionhandling bei dem Texas Instruments Prozessor gar nicht unoptimal ist.
Die gleiche erwartete Funktionalität, Abbruch mit THROW und Fortsetzen mit einem Errorcode bei CATCH, kann allerdings auch mit einem longjmp erreicht werden. Im Testprogramm nach [5] wurden Makros verwendet, die entweder C++ throw oder longjmp aufrufen. Ergebnis: Die longjmp-Lösung benötigt 1.2 µs anstatt 117 µs beim TI-Prozessor! Ein Vergleich auf dem PC bringt ähnliche Ergebnisse, longjmp ist bei 10.000 Durchläufen manuell nicht messbar.
longjmp ?
Man kann den longjmp als „den kleinen Bruder“ des throw bezeichnen. Er tut das Gleiche: Restaurieren einer vorher mit setjmp gespeicherten Stackebene (Stackframe), um dort fortzusetzen. Die Fortsetzung wird mit dem Returnwert 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 und war in den 80ger Jahren 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 [8] ist dieses Kapitel tatsächlich mit „longjmp - non-local goto“ überschrieben. Auch aus [7] könnte man adäquates herauslesen. Anders allerdings in den Compilerbauer-Kreisen, in denen der longjmp ernst genommen wird. Dem Autor liegt eine Email vom ARM-Compilersupport vor, aus der das folgende Zitat stammt:
After discussing this issue in quite a bit of detail with colleagues and our engineering team, we have the following information:
The functions setjmp and longjmp are not forbidden/deprecated, they are part of the C++ standard.
The C++ standard [1] specifies restrictions that apply to longjmp, for example, quoting C++20 [csetjmp.syn]/2
"The function signature longjmp(jmp_buf jbuf, int val) has more restricted behavior in this document. A setjmp/longjmp call pair has undefined behavior if replacing the setjmp and longjmp by catch and throw would invoke any non-trivial destructors for any objects with automatic storage duration. A call to setjmp or longjmp has undefined behavior if invoked in a suspension context of a coroutine"
Regarding the paper you are writing, C++ exceptions (try/catch) can be implemented using setjmp/longjmp. Both LLVM [2] and GCC support this. Furthermore, GCC can configured (at build time) to use SJLJ exceptions on any platform [3] (see --enable-sjlj-exceptions). As you're probably aware, Arm Compiler 6 is based on LLVM.
Der C++ Standard in dem Zitat ist hier unter [10] notiert, [2] im Zitat ist hier [12].
Es sieht also ganz danach aus, dass die Verwendung des Pärchens setjmp/longjmp im Embedded Bereich der Schlüssel für den Einsatz des Exceptionhandling auch bei schnellen Algorithmen sein könnte oder sollte. Folgt man dem Text, kann man mit entsprechenden Compileroptionen throw programmieren und die Routinen für longjmp aufrufen, vom Compiler so erzeugt. Dazu liegen dem Autor im Moment noch keine Erfahrungen vor. Die Email ist vom Datum 2020-09-21.
Bedeutung der Destructoren in C++ vs. finally und longjmp
longjmp ignoriert Destruktoren, die bei throw aus jedem Stackframe abgearbeitet werden. Was hat es damit auf sich?
In C++ insbesondere in vielen Libraries hat sich der Stil durchgesetzt, in den Constructoren Ressourcen zu öffnen oder Speicher zu allokieren, in den Destruktoren werden die Ressourcen dann wieder geschlossen. Da die Destruktoren bei einem throw jedenfalls durchlaufen werden, ist das die C++-Lösung für die gleiche Problematik, die in Java mit finally gelöst wird.
Nun ist in der embedded Programmierpraxis der Einsatz vieler C++-Libraries schon deshalb nicht zu empfehlen, weil die Orientierung auf dynamische Daten zur Laufzeit nicht adäquat ist [2]. In diesem Punkt sind sich viele embedded Entwickler einig. Folglich ist die Bedeutung der Destruktoren nicht so wesentlich. Wenn man überhaupt C++ einsetzt, können diese auch leer gelassen werden.
Die Makros aus [5] sehen ein FINALLY vor. Diese Makros funktionieren auch mit C++ throw. Man kann also wie folgt vorgehen: Eigene Algorithmen, die aufgrund des neu eingeführten Exception Handlings ein Problem mit dem Schließen oder Freigeben von Ressourcen in Zwischenschichten haben, fügen dort einen Zwischenblock ein:
volatile Type* myResource = null;
TRY {
....
myResource = operationMayThrown
....
} TRY_
FINALLY {
if(myResource !=null) { ... release it
} END_TRY
Wird mit setjmp/longjmp gearbeitet, dann wird das FINALLY mit dem else-Block des setjmp erreicht, nachdem die CATCH-Typen getestet wurden, hier nicht vorgesehen. Der FINALLY-Block wird in jedem Fall abgearbeitet. Im END_TRY-Makro steckt eine Weiterleitung der Exception über einen longjmp auf den übergeordneten setjmp-Block. Rechenaufwand wenige µs je nach Prozessorleistung, nicht für die schnellste Abtastscheibe aber schon geeignet bei < 1ms. Wird mit C++ throw gearbeitet, dann funktioniert das genauso. Das catch(...) ist im TRY_-Makro enthalten. Im END_TRY-Makro steckt für diesen Fall wieder ein throw.
Man kann also unabhängig von der Entscheidung für longjmp oder throw jedenfalls einen sicheren Algorithmus bauen, der nicht auf Destruktoren setzt. Man kann sogar die selbige Source im selben Zielsystem mit longjmp für die schnellen Interrupts und mit throw für Teile, die irgendwo Destruktoren brauchen, einsetzen. Man muss nur zweimal compilieren mit verschiedenen Compilerschaltern und diese zwei Object-Module beim Linken unterscheiden. Das gelingt einfach, wenn auf dem Zielprozessor zwei unabhängige Ladefiles platziert werden können.
Stacktrace und Threadcontext
Üblich ist, dass als Begleitwerte einer Logmeldung __FILE__ und __LINE__ über den C/++-Compiler als textuelle und numerische Information hinzugefügt werden. Man weiß damit, an welcher Stelle ein THROW oder ASSERT passiert ist, doch man kennt die Aufrufumgebung nicht. Wenn Subroutinen mehrfach genutzt werden, insbesondere bei Systemaufrufen, kann diese Information aber essentiell für die Fehlerverfolgung sein.
In Java gibt es standardgemäß den Stacktrace. Dieser wird mitgeführt, auch im Normallauf. Es benötigt dazu nur einen Zeiger auf vorbereitete Informationen pro Subroutine (Name, File und Line), der Zeiger wird im Stack selbst bei bekanntem Stackframe-Aufbau gespeichert. Will man dies in C/++ nachbauen, dann scheitert man am doch unterschiedlichen Stackframe-Aufbau einzelner Plattformen und Compiler. Es ist aber möglich, dies in einem Array mit Stack-Struktur („last in first out“) zu speichern, der Aufwand ist gering. Das muss threadsicher erfolgen, dafür braucht es den ThreadContext.
Der ThreadContext ist ein Speicherbereich, der thread-lokale Dinge speichert. In der Lösung nach [5] ist dort der Zeiger auf den aktuellen setjmp-Buffer gespeichert, denn diese Information soll nicht über Aufrufebenen der Stackframes mit geschleppt werden. Eine globale Zelle dafür ist fatal. Auch das Stacktrace-Array gehört hier hinein. Die Umschaltung des passenden ThreadContext ist für ein interruptorientiertes System (Main-Loop und mehrere Hardwareinterrupts) ein sehr einfach und zeitschnell lösbares Problem. Es bedarf nur der statischen Definition der ThreadContext-Bereiche für die Main-Loop und für die Interrupts, die dieses System nutzen (nicht für alle ggf. kurzen Interrupts). Ein globaler Zeiger referenziert den aktuellen ThreadContext. Ein Rahmen für einen Interrupt sieht dann wie folgt aus:
__interrupt void intrCtrl() {
//
ThreadContext_emC_s* thCxtRestore = thCxtAppl_g.currThCxt;
thCxtAppl_g.currThCxt = &thCxtAppl_g.thCxtIntrStep1;
STACKTRC_ROOT_ENTRY("intrCtrl");
//
//... the statements of the Interrupt
//
STACKTRC_LEAVE;
thCxtAppl_g.currThCxt = thCxtRestore;
//... special statements for the target to leave the interrupt
}
Dieser Grundaufwand ist selbst für einen schnellen 50µs-Interrupt kein Problem. Das Makro STACKTRC_ROOT_ENTRY(...) speichert den Stand des SP-Registers hier als „Top of Stack“ im ThreadContext, um für Assertions und Debug die Stackbelastung erfragen zu können. Ob der Aufwand der Aufzeichnung der Stacktrace-Entries erfolgen soll oder nicht, hängt von der Gestaltung des Makros ab. Die Informationen sind nicht zwingend erforderlich. Der übergebene Text für STACKTRC_ENTRY, der Funktionsname, benötigt wenn nicht verwendet auch keinen Platz. Ansonsten ist für Stringliterale im const-Flash-Speicher meist noch genügend Platz.
Für ein Multithread-Betriebssystem mit preemptiven Tasks muss sich das RTOS um die ThreadContext-Pointerverwaltung kümmern. Der ThreadContext selbst wird pro Thread unabhängig vom RTOS beim Erzeugen des Threads angelegt. Entweder im RTOS ist eine entsprechend Möglichkeit vorgesehen, bestimmte vorzubereitende Pointer zu nutzen, die threadspezifisch abfragbar sind, oder es muss über die ThreadID und eine kleine Verwaltungsroutine der aktuelle ThreadContext ermittelt werden. Letzteres ist im MS-Windows notwendig, essentiell für die Simulation von embedded Software auf dem PC. Die notwendige Zusatzrechenzeit ist kaum auffällig. Lösungen dafür sind in [5] enthalten.
:quality(80)/p7i.vogel.de/wcms/64/b1/64b1e1ec390d7dd1654ba3ed279a611c/92551022.jpeg)
C++ in der Embedded-Entwicklung: Exceptions und Assertions
:quality(80)/p7i.vogel.de/wcms/37/5f/375fa2a90b5eec6a909aabcac16ce443/92241765.jpeg)
C++ in der Embedded-Entwicklung: Keine Angst vor Templates
:quality(80)/p7i.vogel.de/wcms/a7/35/a735f5b7609b4d08c55785ffd662b277/90763318.jpeg)
C++ in der Embedded-Entwicklung: Umgang mit Heap-Daten
:quality(80)/p7i.vogel.de/wcms/62/60/6260547c6166be61135ca1efa5eb89ab/89197575.jpeg)
C++ in der Embedded-Entwicklung: Embedded-Code am PC testen
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] Vierter Artikel dieser Serie: „C++ in der Embedded-Entwicklung: Exceptions und Assertions
[6] Darstellung Exceptionhandling mit C++ throw oder longjmp in deutsch
[7] Website emC des Verfassers
[8] „INTERNATIONAL STANDARD ISO/IEC ISO/IEC 9899:TC3" (C99-Standard)
[9] 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
[10] Git-Archive für C++-Standard Dokumente
[11] LLVM-Compiler-Infrastructur mit Anmerkungen zu longjmp
[12] Hinweise zur Option –enable-sjlj-exceptions im gcc
[13] Webpage des LLVM-Compiler-Konzepts
[14] Wikipedia zu LLVM
* 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:46999044)