- 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
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_ptrin C++ hat dasselbe Problem, aber Rust löst es durch die Unterscheidung zwischen den beiden TypenRcundArcshared_ptrin C++ verwendet keine Mutexe, sondern atomare OperationenDas ist vergleichbar mit Rusts
Arc, und die Implementierung im Blog ist einfach nur ineffizientAllerdings gibt es in C++ keinen Typ, der
Rcentspricht, sodass weiterhin Kosten entstehen, wenn man einfach nur einen referenzgezählten Pointer haben möchteshared_ptrnicht thread-sicher, wenn nicht gegen pthreads gelinkt wirdZur 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
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
Ich sehe nicht so recht, welchen Vorteil der Mutex in diesem Fall bringt
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
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
UniquePtroder beim Kopieren einesSharedPtrnicht 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)UniquePtrist möglich, weil man Strukturen per Wert zurückgeben kannAber beim Kopieren von
SharedPtrwird das Erhöhen des Referenzzählers nicht automatisch erledigt#define xfree(p)schlecht sein sollEs heißt zwar, C23 habe das Attribut
[[cleanup]]eingeführt, tatsächlich ist es aber eine GCC-Erweiterung und muss als[[gnu::cleanup()]]geschrieben werdenSiehe den Beispielcode
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
Wenn am Ende aber sogar Funktionen bis hin zu C++17 nachgeahmt werden, fragt man sich, ob man nicht einfach C++ verwenden sollte
C bleibt gut handhabbar, aber C++ ist so komplex, dass es ohne Frontend schwer zugänglich ist
Mit C++ kommen zusätzliche Komplexität durch Build-Chain, Name Mangling und Abhängigkeiten von libstdc++ hinzu
Wenn man C++ dagegen im C-Stil verwendet, gibt es solche Einschränkungen nicht
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_pushMit
cleanup_push(fn, type, ptr, init)undcleanup_pop(ptr)wird eine stackbasierte Cleanup-Routine implementiertDieser Ansatz hat den Vorteil, Ungleichgewichte bereits zur Compile-Zeit zu erkennen
Man sollte es nicht mit dem eigentlichen
safec.hvon safeclib verwechselnSiehe die safeclib-Header
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.hbietetNim kompiliert nach C und bietet gleichzeitig Sicherheit und Performance
Automatische referenzzählende Speicherverwaltung auf ARC-Basis,
defer,Option[T], Bounds-Checking,likely/unlikelyund viele weitere Funktionen sind standardmäßig vorhandenSiehe die offizielle Website, die ARC-Einführung, view types, die Option-Dokumentation und das
likely-TemplateWenn 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 der C-Code auch als C++ kompiliert wird, funktioniert das gut
Ein Paketmanager wird ebenfalls mitgeliefert
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
cgrepgemeint ist, und würde es gern selbst ausprobieren