Abstürze abfangen

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.
  • 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.

 1__try
 2{
 3  int* ptr = NULL;
 4  printf("%d", *ptr);
 5}
 6__except(EXCEPTION_EXECUTE_HANDLER)
 7{
 8  printf("Exception caught");
 9}

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 und SIGFPE 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 lokale jmp_buf Variable angelegt und per setjmp 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 per longjmp 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.