Abstürze abfangen
« | 19 Mar 2022 | »Das Schlimmste, was passieren kann, ist, wenn eine Fremdbibliothek in einem internen Aufruf eine Schutzverletzung (Segmentation Fault) produziert und den eigenen Prozess dann gleich mit in den Abgrund reißt.
Das Debugging wird dann sehr erschwert.
Doch es gibt Hoffnung: Man kann auch SegFaults abfangen und im Idealfall einen Crash verhindern.
Das größte Problem von SegFaults oder Division durch Null sind das sofortige Beenden des Auslöserprozesses. Es wird nichts mehr in ein Log geschrieben und man kann keinen geordneten Shutdown durchführen.
Doch auch Hardware-Ausnahmen wie fehlerhafte Speicherzugriffe oder Rechenfehler können im Usermode abgefangen werden.
Und zwar sind das Fehler wie z.B.:
- Zugriff auf ungültigem Speicher
- Null-Pointer
- Freigegebener Speicher
- Ungültige Instruktion
- Eine CPU-Anweisung, die der aktuelle Prozessor nicht kennt (weil zu alt)
- Eine privilegierte Anweisung, die nur der OS-Kernel, aber kein Nutzerprogramm ausführen darf
- Mathematische Fehler
- Division durch
0
- Rechenoperationen in Zahlenbereichen außerhalb der unterstützten Grenzen.
- Division durch
- Interne Fehler des Betriebssystems
- Ungültige Handles and Deskriptoren
Windows Structured Exception Handling
SEH (Structured Exception Handling) definiert eine Schnittstelle, über die ein Programm einen Codepfad registrieren kann, der ausgeführt wird, wenn ein kritisches Problem auftritt.
Microsofts Visual C/C++ nutzt eine Erweiterung des C-Standards und führt
die Schlüsselworte __try
und __except
ein, womit man SEH-Exception
ähnlich wie C++ Exceptions im Code einbetten kann.
Der __try
Block registriert am Anfang den Filter-Code innerhalb von
__except()
im Thread Environment Block (TEB) und das OS ruft bei einem
kritischen Fehler dann genau den Code auf.
Wenn der Rückgabewert EXCEPTION_EXECUTE_HANDLER
ist, wird der nachfolgende
Codeblock ausgeführt.
Es ist aber auch erlaubt EXCEPTION_CONTINUE_SEARCH
zurückzugeben und dann
sucht das System in der Liste der registrierten Exception-Handler den
nächsten aus und springt in dessen Callstack-Zweig, wenn dieser dann
EXCEPTION_EXECUTE_HANDLER
zurückgibt.
Deshalb wird in __except
meist eine “Filterfunktion” aufgerufen, die mit
GetExceptionCode()
auswerten (und regieren) kann.
1static DWORD eval_exception(DWORD exception_code) 2{ 3 switch(exception_code) 4 { 5 case EXCEPTION_ACCESS_VIOLATION: 6 case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: 7 case EXCEPTION_DATATYPE_MISALIGNMENT: 8 case EXCEPTION_IN_PAGE_ERROR: 9 /* we handle only memory errors */ 10 return EXCEPTION_EXECUTE_HANDLER; 11 default: 12 /* other errors shall be handled by someone else */ 13 return EXCEPTION_CONTINUE_SEARCH; 14 } 15} 16 17__try 18{ 19 ... 20} 21__except(eval_exception(GetExceptionCode())) 22{ 23 printf("This is a memory-related error"); 24}
Alle Exception-Codes sind auf der GetExceptionCode() Seite dokumentiert.
POSIX Signale und longjmp
Auf POSIX Systemen unterbricht der Kernel einen korrumpierten Thread und sendet ihm ein Signal. Diese kann dann in einem vorher registrierten Signal-Handler behandelt werden. Ist ein solcher Handler nicht registriert, wird der Prozess beendet.
Das Problem ist, dass nach dem Verlassen des Signal-Handlers der Prozess ebenso beendet wird, weil nach dem Signal zum korrupten Code zurückgesprungen werden müsste.
Es gibt aber einen Ausweg, und der lautet: setjmp() / longjmp()
.
Hat man sich nämlich selbst darum gekümmert, den Callstack zu sichern,
dann kann im Signal-Handler per longjmp
zu diesem sicheren Ort
zurückgesprungen werden.
Und das funktioniert sogar bei den Signalen SIGSEGV
(Speicherverletzung),
SIGILL
(Falsche CPU Anweisung) oder SIGFPE
(Division durch 0
und andere
Rechenfehler).
Ein Flexibilitätsproblem ist jedoch, dass man Signal-Handler in der Regel
Prozess-global setzt. Man kann also nicht in Thread A
den Signal-Handler
x()
einrichten und in Thread B
dann y()
aufrufen lassen.
Meine Strategie ist daher folgende:
- Es wird je ein globaler Signal-Handler für die kritischen Signale
SIGSEGV
,SIGILL
undSIGFPE
am Programmstart gesetzt. - Es wird auch ein globaler Schlüssel für eine Thread-local-storage Eintrag
erzeugt (
pthread_key_create
). - Vor einem kritischen Bereich (also wie beim Start von
__try
) wird eine lokalejmp_buf
Variable angelegt und persetjmp
initialisiert. - Nun wird ein Pointer zum
jmp_buf
in den Thread-local-storage mit dem zuvor registriertem Schlüssel gesetzt. - Tritt eines der obigen kritischen Signale auf, kann der gesetzte Signal-Handler
den Pointer zum
jmp_buf
wieder auslesen und perlongjmp
zum gesicherten Stack zurückspringen.
1/* crash_guard_sample.c */ 2/* gcc ./crash_guard_sample.c -lpthread -o ./crash_guard_sample */ 3#include <stdio.h> 4#include <pthread.h> 5#include <setjmp.h> 6#include <signal.h> 7 8static pthread_key_t saved_context_key; 9 10typedef struct 11{ 12 jmp_buf anchor; 13} saved_context_t; 14 15static void crash_handler(int signum) 16{ 17 saved_context_t* ptr_context = 18 (saved_context_t*)pthread_getspecific(saved_context_key); 19 20 switch(signum) 21 { 22 case SIGSEGV: 23 longjmp(ptr_context->anchor, 1); 24 case SIGILL: 25 longjmp(ptr_context->anchor, 2); 26 case SIGFPE: 27 longjmp(ptr_context->anchor, 3); 28 } 29 /* unhandled error */ 30 return; 31} 32 33void crashing_function() 34{ 35 int* volatile ptr = NULL; 36 *ptr = 42; 37} 38 39void run() 40{ 41 saved_context_t saved_context; 42 int crash_status; 43 pthread_setspecific(saved_context_key, &saved_context); 44 crash_status = setjmp(saved_context.anchor); 45 if(crash_status == 0) 46 { 47 /* try block: */ 48 printf("Before critical code\n"); 49 crashing_function(); 50 printf("Nothing bad happened\n"); 51 } 52 else 53 { 54 /* except/catch block */ 55 printf("Critical error #%d caught\n", crash_status); 56 } 57} 58 59int main() 60{ 61 printf("main() begin\n"); 62 pthread_key_create(&saved_context_key, NULL); 63 signal(SIGSEGV, &crash_handler); 64 signal(SIGILL, &crash_handler); 65 signal(SIGFPE, &crash_handler); 66 67 run(); 68 printf("main() end\n"); 69}
Und streng genommen sollte man nicht setjmp / longjmp
einsetzen, sondern
sigsetjmp / siglongjmp
, die auch die Signalmaske sichern und
wiederherstellen, ansonsten landet man bei undefiniertem Verhalten, wenn man
den Signalhandler per longjmp
verlässt.
Ich hatte damit zwar keine Probleme, doch nur weil etwas funktioniert, heißt
es nicht, dass es damit richtig ist.
Fazit
Im BLOG-CLASSROOM habe ich das ganze mal etwas vollständiger als Crash-Guard Bibliothek eingefügt. Und im GATE Framework sind die beschriebenen Techniken auch eingearbeitet.
Mein Interesse für diese Thematik begann vor Jahren mit einer Software in der ein Plugin immer wieder rätselhafte Abstürze auslöste und mit solchen Crashguards konnte ich das Problem auf ein ganz bestimmtes Submodul eingrenzen und letztendlich lösen.