Segmentation Fault in C: den Absturz reproduzieren und die Ursache belegen
Die Adresse im Absturzbericht ist ein Symptom. Die Ursache liegt meist Sekunden oder Minuten früher, an einer Stelle, an der niemand hingesehen hat.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Warum die Absturzstelle so selten die Fehlerstelle ist
Ein Zugriff über das Ende eines Puffers hinaus schreibt in fremden Speicher. Liegt dort noch ein Bereich, den der Prozess besitzt, merkt niemand etwas: Das Programm läuft weiter, und der überschriebene Wert wird erst später gelesen. Der Absturz passiert dann in einer Funktion, die mit dem Fehler nichts zu tun hat, oft tief in der Speicherverwaltung der Standardbibliothek. Wer an dieser Stelle zu suchen anfängt, sucht am falschen Ort, und die naheliegende Reaktion, den Aufruf dort mit einer zusätzlichen Prüfung abzusichern, verschiebt den Absturz nur an die nächste Stelle.
Dazu kommt, dass viele dieser Fehler undefiniertes Verhalten auslösen. Undefiniert heißt: Der Compiler darf annehmen, dass der Fall nicht eintritt, und darauf aufbauend optimieren. Genau deshalb verschwindet ein Absturz manchmal, wenn jemand eine Ausgabezeile einfügt oder von der optimierten Fassung auf die Debug-Fassung wechselt. Das ist kein Hinweis darauf, dass der Fehler weg ist, sondern darauf, dass ihr eine Klasse von Fehlern habt, deren Sichtbarkeit vom Übersetzungslauf abhängt.
Der dritte Punkt ist die Reproduzierbarkeit. Ein Absturz, der nur beim Kunden, nur unter Last oder nur alle paar Tage auftritt, lässt sich nicht am Schreibtisch untersuchen, solange niemand ihn einfangen kann. Deshalb ist die erste Arbeit nicht die Fehlersuche, sondern die Beweissicherung: Core Dumps einschalten, Eingaben festhalten, Protokolle aufheben. Wer das nach dem ersten Absturz erledigt, hat beim zweiten alles beisammen.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Sechs Absturzbilder und was dahintersteckt
Diese sechs Fälle decken den größten Teil dessen ab, was in C-Projekten tatsächlich auf dem Tisch landet. Entscheidend ist jedes Mal, das Symptom nicht mit der Ursache zu verwechseln.
Symptom
Das Programm bricht mitten in free oder malloc ab, obwohl an dieser Stelle nichts Auffälliges steht.
Ursache
Der Heap ist beschädigt. Ein früherer Schreibzugriff ist über das Ende eines angeforderten Blocks hinausgelaufen und hat die Verwaltungsdaten überschrieben, die die Speicherverwaltung neben den Nutzdaten führt. Auffallen tut das erst, wenn diese Daten das nächste Mal gelesen werden, und das kann Sekunden später und in einem ganz anderen Programmteil sein.
Lösung
Den Ablauf unter AddressSanitizer wiederholen, also mit -fsanitize=address -g -fno-omit-frame-pointer übersetzen und starten. Der Sanitizer meldet den Überlauf an der Stelle, an der geschrieben wird, und nennt zusätzlich, wo der betroffene Block angefordert wurde. Damit habt ihr beide Enden der Kette, statt nur das Ende, an dem es zerbrochen ist.
Symptom
Der Absturz tritt nur im optimierten Bau auf, im Debug-Bau läuft alles durch.
Ursache
Im Code steht undefiniertes Verhalten, und der Optimierer nutzt die Annahme aus, dass dieser Fall nicht eintritt. Typische Auslöser sind eine gelesene, aber nie geschriebene Variable, ein Zugriff auf denselben Speicher über zwei unvereinbare Zeigertypen, ein Überlauf bei vorzeichenbehafteter Ganzzahlarithmetik oder eine Funktion, die auf einem Pfad ohne Rückgabewert endet.
Lösung
Mit -fsanitize=undefined übersetzen und den Ablauf wiederholen; die Meldungen benennen Zeile und Art des Verstoßes. Zusätzlich mit -Wall -Wextra bauen und die Warnungen ernst nehmen. Wichtig ist die Reihenfolge: -fno-strict-aliasing kann den Absturz verschwinden lassen, ist aber ein Hilfsmittel zur Eingrenzung und keine Behebung, denn der Verstoß bleibt im Code stehen.
Symptom
Eine Funktion gibt einen Zeiger zurück, und der Aufrufer bekommt Datenmüll oder stürzt kurz darauf ab.
Ursache
Der Zeiger zeigt auf eine lokale Variable oder auf ein lokales Feld. Sobald die Funktion zurückkehrt, gehört dieser Bereich des Stapels dem nächsten Aufruf, der ihn überschreibt. Solange nichts dazwischenkommt, sehen die Daten sogar noch richtig aus, was diesen Fehler so lange am Leben hält.
Lösung
AddressSanitizer erkennt das als Zugriff nach der Rückkehr, wenn ihr die entsprechende Prüfung über die Umgebungsvariable ASAN_OPTIONS mit detect_stack_use_after_return=1 einschaltet. Zur Behebung entscheidet der Aufrufer über den Speicher: Entweder er übergibt einen eigenen Puffer samt Länge, oder die Funktion fordert Speicher an und der Vertrag hält fest, wer ihn freigibt.
Symptom
Der Absturz passiert direkt beim Betreten einer Funktion, ohne dass dort überhaupt ein Zeiger im Spiel ist.
Ursache
Der Stapel ist übergelaufen. Auslöser sind meistens ein großes lokales Feld, eine Rekursion ohne wirksame Abbruchbedingung oder ein Thread mit kleiner Stapelgröße, der dieselbe Funktion aufruft, die im Hauptprogramm noch passte. Das Betriebssystem legt hinter den Stapel eine Schutzseite, und der Zugriff darauf löst dasselbe Signal aus wie jeder andere unerlaubte Zugriff.
Lösung
In der Rücksprungkette nach einer sich wiederholenden Aufruffolge suchen, das ist der Rekursionsfall. Andernfalls die Größe der lokalen Variablen in den beteiligten Funktionen prüfen und große Felder statisch anlegen oder anfordern. Bei Threads die eingestellte Stapelgröße gegen den tatsächlichen Bedarf halten, statt sie aus Gewohnheit klein zu lassen.
Symptom
Auf der Konsole steht nur Segmentation fault, und es gibt keine Datei zum Untersuchen.
Ursache
Core Dumps sind abgeschaltet oder landen nicht dort, wo ihr sie sucht. Das Größenlimit für Kerndateien steht bei vielen Systemen auf null, und auf Systemen mit einem eigenen Dienst für Absturzabbilder wandern die Dateien in dessen Ablage statt in das Arbeitsverzeichnis.
Lösung
Das Limit für die Sitzung mit ulimit -c unlimited anheben und nachsehen, welches Muster in /proc/sys/kernel/core_pattern eingetragen ist. Steht dort eine Weiterleitung an einen Dienst, holt ihr das Abbild über dessen Werkzeug ab, unter systemd ist das coredumpctl. Danach den Dump zusammen mit genau dem Binärabbild öffnen, aus dem er stammt, sonst passen die Symbole nicht.
Symptom
Der Absturz tritt nur unter Last und nur auf dem Zielsystem auf, im Test nie.
Ursache
In den meisten Fällen ein Wettlauf zwischen Threads: Zwei Abläufe greifen ohne Absicherung auf dieselben Daten zu, und die Reihenfolge entscheidet, ob ein Zeiger noch gültig ist. Auf dem Entwicklungsrechner mit weniger Kernen und anderer Auslastung trifft die ungünstige Reihenfolge schlicht seltener ein.
Lösung
Den Ablauf unter ThreadSanitizer laufen lassen, also mit -fsanitize=thread übersetzen. Er meldet konkurrierende Zugriffe auch dann, wenn sie in diesem Durchlauf nicht zum Absturz geführt haben, und nennt beide beteiligten Stellen. AddressSanitizer und ThreadSanitizer lassen sich nicht gemeinsam einschalten, das sind zwei getrennte Läufe.
Vom Absturz zur Ursache in fünf Schritten
- 01 Core Dump einschalten und den Absturz einmal sauber einfangen.
- 02 Mit Debug-Informationen bauen, ohne die Optimierungsstufe zu wechseln.
- 03 Rücksprungkette lesen und den ersten eigenen Rahmen finden.
- 04 Denselben Ablauf unter AddressSanitizer wiederholen und den echten Ort ablesen.
- 05 Die Ursache im Quelltext belegen und einen Test schreiben, der sie festhält.
Ein Ablauf, der auch beim schwierigen Fall trägt
Die folgenden sechs Schritte kosten zusammen weniger Zeit als ein einziger Nachmittag mit eingefügten Ausgabezeilen. Sie funktionieren unabhängig davon, ob der Absturz jedes Mal oder alle drei Tage auftritt.
Den Absturz überhaupt einfangen
Du sorgst dafür, dass beim nächsten Mal ein vollständiger Core Dump entsteht, findest ihn wieder und weißt, welche Systemeinstellungen das verhindern können. Ohne diesen Schritt bleibt jede weitere Untersuchung Vermutung.
Mit Debug-Informationen bauen, ohne die Optimierung zu verlieren
Debug-Informationen und Optimierung schließen sich nicht aus. Du übersetzt so, dass die Rücksprungkette lesbar bleibt und der Fehler trotzdem in derselben Fassung auftritt wie im Feld, statt ihn beim Umbauen zu verlieren.
Den Backtrace richtig lesen
Du gehst vom obersten Rahmen nach unten, erkennst den ersten Rahmen aus eurem eigenen Code und siehst dir dort die Werte an, statt die Bibliotheksfunktion zu verdächtigen, in der es zufällig geknallt hat.
Sanitizer einsetzen statt zu vermuten
Du weißt, welcher Sanitizer welche Fehlerklasse findet, wie stark er den Ablauf verlangsamt und warum er den Fehler an der verursachenden Stelle meldet und nicht an der Absturzstelle.
Die vier häufigsten Muster wiedererkennen
Zugriff über den Rand hinaus, Zugriff nach der Freigabe, ein Zeiger auf eine lokale Variable und ein übergelaufener Stapel sehen im Absturzbericht unterschiedlich aus. Du ordnest den Bericht dem Muster zu, bevor du zu suchen anfängst.
Denselben Fehler beim nächsten Mal früher finden
Du kennst die Compilerschalter, die eine ganze Reihe dieser Fehler schon beim Übersetzen oder beim ersten Testlauf melden, und weißt, welche davon in den Bauprozess gehören und welche nur in die Testkonfiguration.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Was ein Segmentation Fault überhaupt meldet
Die Speicherverwaltungseinheit des Prozessors bildet die Adressen, mit denen euer Programm arbeitet, auf echten Speicher ab und kennt für jeden Bereich die erlaubten Zugriffsarten. Fragt das Programm eine Adresse an, für die es keine gültige Abbildung gibt, oder schreibt es in einen Bereich, der nur gelesen werden darf, meldet der Kern das dem Prozess als Signal. Die Standardbehandlung dieses Signals beendet den Prozess und schreibt auf Wunsch ein Abbild seines Speichers. Auf der Kommandozeile erscheint es als Segmentation fault, ein Shell-Skript sieht den Rückgabewert 139.
Daneben gibt es zwei verwandte Meldungen, die oft in denselben Topf geworfen werden, obwohl sie etwas anderes bedeuten. Ein Bus Error tritt auf, wenn die Adresse zwar abgebildet ist, aber nicht die verlangte Ausrichtung hat oder hinter das Ende einer eingeblendeten Datei zeigt; auf Architekturen mit strengen Ausrichtungsregeln ist das ein häufiges Bild. Ein Abbruch über SIGABRT dagegen kommt aus dem Programm selbst, ausgelöst von einer fehlgeschlagenen Zusicherung oder von der Speicherverwaltung, die eine beschädigte Struktur bemerkt hat. Diese Unterscheidung spart Zeit, denn ein Abbruch mit Meldung sagt euch schon, wonach ihr suchen müsst.
Wichtig ist die Einordnung: Ein Segmentation Fault ist keine Fehlerdiagnose, sondern die Notbremse. Er tritt genau dann ein, wenn der falsche Zugriff zufällig auf einer Seite landet, die dem Prozess nicht gehört. Landet derselbe falsche Zugriff auf einer Seite, die ihm gehört, passiert gar nichts, und ihr habt stattdessen einen stillen Datenfehler. Deshalb ist ein Absturz nicht das schlechteste Ergebnis, sondern eher ein Glücksfall in der Fehlersuche.
Zuerst reproduzierbar machen, dann suchen
Bevor irgendein Werkzeug zum Einsatz kommt, braucht ihr einen Ablauf, der den Absturz auslöst. Das kann eine Eingabedatei sein, eine Folge von Anfragen, ein bestimmter Gerätezustand oder eine Kombination aus allen dreien. Diese Beschreibung gehört in den Fehlerbericht und später in einen Test, denn sonst wisst ihr am Ende nicht, ob die Änderung geholfen hat oder ob der Fehler nur gerade nicht auftritt. Wenn der Fall nur beim Kunden eintritt, ist der Core Dump die Reproduktion, und dann geht die ganze Aufmerksamkeit dahin, ihn vollständig einzusammeln.
Beim Übersetzen gibt es einen verbreiteten Irrtum: Debug-Informationen und Optimierung schließen einander nicht aus. Mit -g übersetzt der Compiler die Zuordnung von Maschinencode zu Quelltextzeilen zusätzlich in das Binärabbild, ohne den erzeugten Code zu ändern. Ihr könnt also die Optimierungsstufe beibehalten, in der der Fehler auftritt, und trotzdem eine lesbare Rücksprungkette bekommen. Zusätzlich hilft -fno-omit-frame-pointer, weil die Kette damit auch dann vollständig bleibt, wenn Funktionen ineinander gezogen wurden.
Was ihr dagegen nicht tun solltet, ist der reflexhafte Wechsel auf einen unoptimierten Bau. Wenn der Absturz dort verschwindet, habt ihr die Information verloren, mit der ihr ihn suchen könntet, und gleichzeitig einen starken Hinweis bekommen, dass undefiniertes Verhalten im Spiel ist. Der richtige nächste Schritt ist dann nicht die Debug-Fassung, sondern der Sanitizer für undefiniertes Verhalten auf der optimierten Fassung.
Den Core Dump lesen, ohne sich zu verlaufen
Ein Core Dump ist eine Momentaufnahme des Prozessspeichers zum Zeitpunkt des Abbruchs. Damit er entsteht, muss das Größenlimit für Kerndateien angehoben sein, und ihr müsst wissen, wohin das System sie schreibt. Das Muster dafür steht in einer Datei unterhalb von /proc, und auf Systemen mit einem eigenen Dienst für Absturzabbilder ist dort statt eines Dateinamens eine Weiterleitung an dieses Programm eingetragen. Unter systemd holt ihr die Abbilder mit coredumpctl wieder heraus, das die Zuordnung zu Zeitpunkt, Programm und Signal gleich mitliefert.
Geöffnet wird der Dump zusammen mit genau dem Binärabbild, aus dem er stammt. Passt die Datei nicht, sind die Symbole falsch, und ihr lest eine erfundene Rücksprungkette. In einem Debugger liefert der Befehl für die Rücksprungkette die Liste der Aufrufe, in ausführlicher Form auch die lokalen Variablen jedes Rahmens. Die Leserichtung ist von oben nach unten: Ganz oben steht die Funktion, in der es abgebrochen ist, und die ist häufig eine Bibliotheksfunktion. Interessant ist der erste Rahmen, der aus eurem eigenen Code stammt.
Dort seht ihr euch die Werte an. Ein Zeiger mit dem Wert null bedeutet, dass ein Rückgabewert nicht geprüft wurde, meistens von einer Funktion, die Speicher anfordert, eine Datei öffnet oder in einer Tabelle sucht. Ein Zeiger mit einem sehr kleinen Wert deutet auf eine Rechnung mit einem Nullzeiger als Basis, und dann steht der Abstand in der Struktur, deren Feld gelesen werden sollte. Ein Zeiger mit einem Wert, der wie ein Text oder wie eine Zahl aus den Nutzdaten aussieht, ist ein starker Hinweis darauf, dass eine benachbarte Variable überschrieben wurde, und damit seid ihr wieder beim ersten Fall aus der Übersicht oben.
Sanitizer und Valgrind: welches Werkzeug wann
AddressSanitizer ist das Werkzeug, mit dem ihr anfangt. Er wird beim Übersetzen eingebaut, ersetzt die Speicherverwaltung durch eine eigene und legt um jeden angeforderten Block eine Schutzzone. Ein Zugriff darauf wird sofort gemeldet, mit der Stelle des Zugriffs, der Stelle der Anforderung und, bei einem Zugriff nach der Freigabe, auch der Stelle der Freigabe. Er findet Überläufe auf Heap, Stapel und in globalen Daten, Zugriffe nach der Freigabe und nach dem Verlassen des Gültigkeitsbereichs sowie nicht freigegebenen Speicher. Der Ablauf wird dabei etwa doppelt so langsam, und der Speicherbedarf steigt deutlich, was für Testläufe unproblematisch ist.
Daneben stehen zwei Geschwister. Der UndefinedBehaviorSanitizer meldet Überläufe bei vorzeichenbehafteter Arithmetik, fehlerhafte Ausrichtung, Verschiebungen über die Breite hinaus und Umwandlungen, die nicht darstellbar sind; er lässt sich mit AddressSanitizer gemeinsam einschalten. ThreadSanitizer findet konkurrierende Zugriffe zwischen Threads und schließt sich mit AddressSanitizer gegenseitig aus, ist also ein eigener Lauf. Für alle drei gilt: Sie sehen nur, was tatsächlich ausgeführt wird. Ein Pfad ohne Test bleibt ungeprüft, und deshalb gehören sie in den Bauprozess und nicht in eine einmalige Sitzung.
Valgrind mit dem Werkzeug Memcheck braucht dagegen kein neues Übersetzen und ist damit die Wahl, wenn ihr ein fertiges Binärabbild vor euch habt oder wenn eine Bibliothek ohne Quelltext im Verdacht steht. Es findet zusätzlich die Nutzung nicht initialisierten Speichers, die AddressSanitizer nicht abdeckt. Der Preis steht in der eigenen Dokumentation: Ein Programm unter Memcheck läuft zehn- bis fünfzigmal langsamer als ohne. Für einen begrenzten Ablauf ist das vertretbar, für eine vollständige Testsuite im Bauprozess in der Regel nicht.
Damit derselbe Fehler nicht wiederkommt
Ein Teil dieser Fehler lässt sich schon beim Übersetzen abfangen. Die Open Source Security Foundation pflegt dafür einen Leitfaden mit begründeten Compilerschaltern für C und C++, und die wichtigsten daraus kosten wenig und wirken sofort: Warnungen mit -Wall und -Wextra und ausgewählte davon als Fehler, gehärtete Fassungen der Bibliotheksfunktionen über _FORTIFY_SOURCE, Schutzwerte auf dem Stapel mit -fstack-protector-strong und ein Schutz gegen das Überspringen der Stapel-Schutzseite. Diese Schalter gehören in die Auslieferungskonfiguration, nicht nur in den Testbau.
Dazu kommt die statische Prüfung. GCC bringt mit -fanalyzer eine Analyse mit, die Nullzeiger-Zugriffe, doppelte Freigaben und Nutzung nach der Freigabe entlang der Pfade durch das Programm meldet, ohne dass es dafür laufen muss. Sie ist langsam und produziert bei großen Dateien auch Fehlalarme, was sie zu einem guten Kandidaten für einen nächtlichen Lauf macht statt für jeden Übersetzungsvorgang. In sicherheitsgerichteten Projekten übernimmt diese Rolle ein Werkzeug, das gegen ein Regelwerk wie MISRA C prüft.
Der letzte Schritt gehört zur Organisation und nicht zum Werkzeug: Jeder gefundene Speicherfehler bekommt einen Test, der ihn auslöst, und dieser Test läuft im Bauprozess unter AddressSanitizer. Das ist der einzige Weg, der verhindert, dass dieselbe Stelle in einem Jahr wieder auffällt, weil jemand die Bedingung umgebaut hat. Ein Bauprozess, der eine Testsuite einmal normal und einmal unter Sanitizern durchlaufen lässt, ist ein Tagesprojekt und die billigste Maßnahme in dieser ganzen Liste.
Dazu passende Kurse
Wer den Umgang mit Speicher nicht im Fehlerfall lernen will, sondern vorher, findet dafür C-Kurse, in denen Zeiger und Speicherverwaltung wirklich geübt werden .
Damit Sanitizer und Prüfläufe nicht von Hand gestartet werden müssen, gehören sie in den Bauprozess, und dazu passen die Kurse zu Werkzeugen rund um Bauprozess und Tests .
Wie sicher bist du beim Thema wirklich?
Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.
Wo du genau das übst
Sehr umfangreicher Überblick über Zusammenhänge und Anwendungsbereiche. Sehr zu empfehlen.
Effektives, gemeinsames Erarbeiten der Aufgaben mit vielen praktischen Tipps.
Sehr guter Trainer und gute Aufbereitung der Inhalte und auch eingehen auf Fragen der Beteiligten.
Häufige Fragen
Der Absturz verschwindet, sobald ich eine Ausgabezeile einfüge. Was heißt das?
Reicht es nicht, überall vor dem Zugriff auf null zu prüfen?
Wir entwickeln für einen Mikrocontroller ohne Betriebssystem. Was davon gilt dort?
Wie unterscheide ich einen Speicherfehler von einer Beschädigung durch fremden Code?
Lohnt sich das alles, oder sollten wir gleich in einer speichersicheren Sprache neu schreiben?
Passt thematisch dazu
Wenn derselbe Fehlertyp zum dritten Mal auftaucht, ist der gangbare Weg nicht das ganze Projekt, sondern der Neubau einer einzelnen Komponente in einer speichersicheren Sprache .
Deine Ansprechpartner
Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.
Yves Hoppe
Weiterbildung & Beratung
Hilft dir, aus dem Programmierung-Programm den passenden Kurs für deinen Stand zu finden.
Norbert Jansen
Beratung & Inhouse
Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.
Fehlersuche an echtem Code statt an Beispielen aus dem Buch
Zeiger, Lebensdauer und Speicherverwaltung versteht man am schnellsten dort, wo etwas schiefgeht. Bei cmt arbeitest du mit Debugger und Sanitizern an Code, der dem aus deinem Projekt ähnlich sieht.