10 Punkte von GN⁺ 2025-11-19 | 1 Kommentare | Auf WhatsApp teilen
  • safe_c.h ist eine 600 Zeilen lange benutzerdefinierte Header-Datei, die der Sprache C Sicherheits- und Komfortfunktionen aus C++ und Rust hinzufügt, und wird für eine speicherleckfreie thread-sichere grep-Implementierung (cgrep) verwendet
  • Durch RAII, Smart Pointer und das cleanup-Attribut für automatische Bereinigung wird die Ressourcenverwaltung ohne manuelle free()-Aufrufe automatisiert
  • Mit Vektoren, Views, Result-Typen und Vertragsmakros werden Buffer Overflows, Fehlerbehandlung und die Prüfung von Vorbedingungen sicher umgesetzt
  • Mit automatischer Freigabe von Mutexen, Makros zum Starten von Threads und Optimierung der Branch Prediction wird Sicherheit gewährleistet, ohne Parallelität und Performance zu opfern
  • Das Ergebnis zeigt, dass sich leckfreier C-Code ohne Segfaults bei gleicher Performance (auf -O2-Niveau) schreiben lässt

Überblick über safe_c.h

  • safe_c.h ist eine Header-Datei, die Funktionen aus C++ und Rust in C-Code überträgt
    • Sie bietet dasselbe RAII-Verhalten (automatische Bereinigung) auch auf Compilern, die das C23-Attribut [[cleanup]] nicht unterstützen (z. B. GCC 11, Clang 18)
    • Mit dem Makro CLEANUP(func) werden Ressourcen beim Verlassen der Funktion automatisch freigegeben
    • Die Makros LIKELY() und UNLIKELY() optimieren die Branch Prediction auf Hot Paths

Speicherverwaltung: UniquePtr und SharedPtr

  • UniquePtr ist ein Smart Pointer mit exklusivem Besitz, der beim Verlassen des Gültigkeitsbereichs automatisch free() aufruft
    • Wird er mit dem Makro AUTO_UNIQUE_PTR() deklariert, wird Speicher auch bei Fehlern oder vorzeitigem return automatisch freigegeben
  • SharedPtr ist eine Struktur mit automatischer Referenzzählung, bei der die Ressource automatisch zerstört wird, wenn die letzte Referenz freigegeben wird
    • Mit shared_ptr_init() und shared_ptr_copy() wird das Erhöhen und Verringern der Referenzzahl automatisch verarbeitet
    • Wird zur Verwaltung thread-sicher gemeinsam genutzter Strukturen verwendet

Schutz vor Buffer Overflows: Vector und View

  • Mit dem Makro DEFINE_VECTOR_TYPE() lassen sich typsichere, automatisch wachsende Vektoren erzeugen
    • Reallokation, Kapazitätsverwaltung und Bereinigung werden automatisch behandelt
    • Bei Deklaration mit AUTO_TYPED_VECTOR() erfolgt die Freigabe beim Verlassen des Scopes automatisch
  • StringView und Span sind nicht-besitzende Referenzstrukturen, die String- und Array-Slices ohne separates malloc verarbeiten
    • Mit DEFINE_SPAN_TYPE() lassen sich typspezifische Spans definieren
    • Mit integrierter Bounds-Prüfung für sicheren Array-Zugriff

Fehlerbehandlung: Result-Typ und RAII

  • Die Result-Struktur ist ein nach Erfolg/Fehlschlag unterscheidender Rückgabetyp, ähnlich wie Rusts Result<T, E>
    • Mit DEFINE_RESULT_TYPE() werden typspezifische Ergebnisstrukturen erzeugt
    • RESULT_IS_OK() und RESULT_UNWRAP_ERROR() ermöglichen eine klare Fehlerbehandlung
  • In Kombination mit dem Attribut CLEANUP werden Ressourcen beim Verlassen der Funktion automatisch freigegeben
    • Das Makro AUTO_MEMORY() bereinigt mit malloc allokierten Speicher automatisch

Verträge und sichere Strings

  • Mit den Makros requires() / ensures() werden Vor- und Nachbedingungen von Funktionen explizit gemacht
    • Bei einem Fehlschlag wird eine klare Fehlermeldung ausgegeben
  • safe_strcpy() ist eine Kopierfunktion mit Prüfung der Puffergröße und verhindert so Overflows
    • Im Fehlerfall wird false zurückgegeben, was eine sichere Fehlerbehandlung ermöglicht

Parallelität: automatische Entsperrung und Thread-Makros

  • Eine auf CLEANUP basierende Funktion zur automatischen Freigabe von Mutexen verhindert Deadlocks
    • Beim Verlassen des Scopes wird pthread_mutex_unlock() automatisch aufgerufen
  • Die Makros SPAWN_THREAD() und JOIN_THREAD() vereinfachen das Erstellen und Joinen von Threads
    • Sie werden für die Implementierung des Datei-verarbeitenden Thread-Pools von cgrep verwendet

Performance-Optimierung

  • Die Makros LIKELY() / UNLIKELY() liefern Branch Prediction für Hot Paths
    • Damit lassen sich Optimierungseffekte auf PGO-Niveau auch in Builds mit -O2 erzielen
  • Trotz zusätzlicher Sicherheitsfunktionen gibt es keinen Performance-Verlust

Fazit

  • cgrep, das safe_c.h verwendet, besteht aus 2.300 Zeilen C-Code und eliminiert mehr als 50 manuelle free()-Aufrufe
  • Es implementiert sicheren C-Code ohne Speicherlecks und Segfaults, während identischer Assembler-Code und dieselbe Laufzeit erhalten bleiben
  • Es ist ein Beispiel dafür, wie sich die Einfachheit und Freiheit von C mit moderner Sicherheit verbinden lassen
  • Der Autor will in einem späteren Beitrag darauf eingehen, warum cgrep im Vergleich zu ripgrep mehr als doppelt so schnell ist und 20-mal weniger Speicher verbraucht
  • safe_c.h wird als für neue Projekte geeignet beschrieben; zugleich wird erwähnt, dass der Makro-basierte Ansatz das Debugging erschweren kann
  • Die Korrektheit und Sicherheit wurden mit verschiedenen statischen Analysewerkzeugen überprüft, darunter GCC analyzer, ASAN, UBSAN und Clang-tidy

1 Kommentare

 
GN⁺ 2025-11-19
Hacker-News-Kommentare
  • Dieser Artikel zeigt das Kostenproblem, das bei der Implementierung sicherer Abstraktionen (safe abstractions) in C entsteht
    Die Shared-Pointer-Implementierung verwendet POSIX-Mutexe und ist dadurch (1) nicht plattformunabhängig und (2) selbst im Single-Thread-Betrieb mit Mutex-Overhead belastet
    Sie ist also keine „zero-cost abstraction“
    Auch shared_ptr in C++ hat dasselbe Problem, aber Rust löst es durch die Unterscheidung zwischen den beiden Typen Rc und Arc

    • shared_ptr in C++ verwendet keine Mutexe, sondern atomare Operationen
      Das ist vergleichbar mit Rusts Arc, und die Implementierung im Blog ist einfach nur ineffizient
      Allerdings gibt es in C++ keinen Typ, der Rc entspricht, sodass weiterhin Kosten entstehen, wenn man einfach nur einen referenzgezählten Pointer haben möchte
    • In Umgebungen mit glibc und libstdc++ ist shared_ptr nicht thread-sicher, wenn nicht gegen pthreads gelinkt wird
      Zur Laufzeit werden pthread-Symbole gesucht, um zwischen einem atomaren und einem nicht-atomaren Pfad zu wählen
      Ich denke, es wäre besser, einfach immer atomare Operationen zu verwenden
    • Ich halte es für viel wichtiger, dafür zu sorgen, dass der Code nicht abstürzt
      Cross-Platform ist in den meisten Fällen eher „nice to have“
      Der Mutex-Overhead ist lästig, aber auf modernen CPUs verkraftbar
      Rust ist großartig, aber das C-Ökosystem ist so riesig, dass es sich nur schwer vollständig ersetzen lässt
    • Man könnte den Referenzzähler auch mit C11-atomaren Operationen statt mit Mutexen implementieren
      Ich sehe nicht so recht, welchen Vorteil der Mutex in diesem Fall bringt
    • POSIX-Mutexe sind bereits auf vielen Plattformen implementiert und daher meiner Meinung nach eher die allgemeiner einsetzbare API
  • Es gibt ein Projekt namens FUGC, einen von Fil (aka pizlonator) entwickelten Garbage Collector, der C speichersicher machen soll
    Er lässt sich fast ohne Änderungen auf bestehenden Code anwenden und macht aus C/C++ speichersichere Sprachen
    Siehe den zugehörigen HN-Beitrag und die offizielle Website

    • Dadurch habe ich dieses Projekt überhaupt erst kennengelernt. Ich halte es für einen wirklich großartigen Versuch
    • Ich möchte jedoch keine Performance-Einbußen durch einen Garbage Collector in Kauf nehmen
  • Dieser Artikel scheint den Kern der Speichersicherheit etwas falsch darzustellen
    Automatisches Freigeben lokaler Variablen oder Bounds-Checking allein reichen nicht aus
    Das eigentliche Problem ist die Verwaltung der Speicherlebensdauer im gesamten Programm
    Zum Beispiel: Vergisst man beim Zurückgeben eines UniquePtr oder beim Kopieren eines SharedPtr nicht das Referenzzählen, und wer verwaltet die Lebensdauer von Elementen in einer intrusive list?
    Letztlich wirkt der Ansatz des Artikels auf mich nicht sehr anders als das frühere Muster #define xfree(p)

    • UniquePtr ist möglich, weil man Strukturen per Wert zurückgeben kann
      Aber beim Kopieren von SharedPtr wird das Erhöhen des Referenzzählers nicht automatisch erledigt
    • Ich frage mich, warum das Muster #define xfree(p) schlecht sein soll
  • Es heißt zwar, C23 habe das Attribut [[cleanup]] eingeführt, tatsächlich ist es aber eine GCC-Erweiterung und muss als [[gnu::cleanup()]] geschrieben werden
    Siehe den Beispielcode

    • Es war schwer, dazu verlässliche Informationen zu finden; am Ende scheint sich nur die Syntax geändert zu haben, während die Funktion selbst weiterhin eine Erweiterung ist
  • Es gab einmal den Witz: „C++: Seht, wie sehr sich andere Sprachen abmühen müssen, um auch nur einen Teil meiner Kräfte nachzuahmen“
    Ich frage mich, warum man C++ mit Makros nachahmen will, aber es ist auf jeden Fall ein interessanter Versuch

    • Es war interessant zu sehen, wie man sichereres C schaffen kann, ohne alle Funktionen von C++ zu übernehmen
      Wenn am Ende aber sogar Funktionen bis hin zu C++17 nachgeahmt werden, fragt man sich, ob man nicht einfach C++ verwenden sollte
    • Ich möchte eine parsebare Sprache
      C bleibt gut handhabbar, aber C++ ist so komplex, dass es ohne Frontend schwer zugänglich ist
    • C ist einfach und deshalb eine gute Sprache zum Hacken
      Mit C++ kommen zusätzliche Komplexität durch Build-Chain, Name Mangling und Abhängigkeiten von libstdc++ hinzu
    • Dieses Projekt kann durch das Zulassen nur eines Teils der C++-Funktionen eine eingeschränkte Syntax erzwingen
      Wenn man C++ dagegen im C-Stil verwendet, gibt es solche Einschränkungen nicht
    • Dass Embedded-CPU-Anbieter keine C++-Compiler bereitstellen, ist ebenfalls eine praktische Einschränkung
  • Mit setjmp/longjmp-basierter Exception-Behandlung ist das nicht kompatibel
    Stattdessen ließe es sich mit einem Paar von Cleanup-Makros integrieren, inspiriert von POSIX pthread_cleanup_push
    Mit cleanup_push(fn, type, ptr, init) und cleanup_pop(ptr) wird eine stackbasierte Cleanup-Routine implementiert
    Dieser Ansatz hat den Vorteil, Ungleichgewichte bereits zur Compile-Zeit zu erkennen

  • Man sollte es nicht mit dem eigentlichen safec.h von safeclib verwechseln
    Siehe die safeclib-Header

    • Ich frage mich, warum man überhaupt eine Implementierung von Annex K warten möchte
      Wegen des globalen Constraint-Handlers gilt es als Design-Fehlschlag, und die meisten Toolchains unterstützen es nicht
      Siehe das zugehörige Dokument
  • Wenn man die Sprache Nim verwendet, bekommt man alle Funktionen, die safe_c.h bietet
    Nim kompiliert nach C und bietet gleichzeitig Sicherheit und Performance
    Automatische referenzzählende Speicherverwaltung auf ARC-Basis, defer, Option[T], Bounds-Checking, likely/unlikely und viele weitere Funktionen sind standardmäßig vorhanden
    Siehe die offizielle Website, die ARC-Einführung, view types, die Option-Dokumentation und das likely-Template

  • Wenn dieser Ansatz auf Portabilität abzielt, ist es realistisch gesehen am sichersten, bei C99 zu bleiben
    Der C-Compiler von MSVC ist anspruchsvoll, aber für Cross-Platform-Unterstützung fast unverzichtbar
    Ich habe selbst einen ähnlichen Header erstellt, aber wegen der Portabilitätsprobleme keine Cleanup-Utilities aufgenommen

    • Wenn man per Makros C++-Code erzeugt, der auf Destruktoren basiert, geht es auch ohne Cleanup-Attribut
      Wenn der C-Code auch als C++ kompiliert wird, funktioniert das gut
    • Auch unter Windows kann man mit MSYS2 + GCC problemlos entwickeln
      Ein Paketmanager wird ebenfalls mitgeliefert
    • Zur Info: MSVC unterstützt inzwischen C17
  • Im Artikel fehlen Code-Links zu cgrep, das mehrfach erwähnt wird
    Auf GitHub gibt es viele Projekte mit demselben Namen, die meisten sind jedoch in anderen Sprachen geschrieben

    • Ich weiß auch nicht, welches cgrep gemeint ist, und würde es gern selbst ausprobieren