10 points par GN⁺ 2025-11-19 | 1 commentaires | Partager sur WhatsApp
  • safe_c.h est un fichier d’en-tête personnalisé de 600 lignes qui ajoute au langage C des fonctionnalités de sûreté et de confort issues de C++ et de Rust, utilisé pour implémenter un grep sûr pour les threads (cgrep) sans fuite mémoire
  • Grâce à RAII, aux smart pointers et à l’attribut de nettoyage automatique (cleanup), la gestion des ressources est automatisée sans appels manuels à free()
  • Avec des vecteurs, des vues, un type Result et des macros de contrat, il permet de gérer en toute sûreté les dépassements de tampon, le traitement des erreurs et la validation des préconditions
  • Des mécanismes comme le déverrouillage automatique des mutex, des macros de création de threads et l’optimisation de prédiction de branchement garantissent la sûreté sans sacrifier la concurrence ni les performances
  • Au final, il démontre qu’il est possible d’écrire du code C sans fuites ni segfaults avec des performances identiques (niveau -O2)

Aperçu de safe_c.h

  • safe_c.h est un fichier d’en-tête qui transpose dans du code C des fonctionnalités de C++ et de Rust
    • Il fournit le même comportement de RAII (nettoyage automatique) même sur des compilateurs qui ne prennent pas en charge l’attribut C23 [[cleanup]] (GCC 11, Clang 18, etc.)
    • La macro CLEANUP(func) libère automatiquement les ressources à la sortie de la fonction
    • Les macros LIKELY() et UNLIKELY() optimisent la prédiction de branchement sur le hot path

Gestion mémoire : UniquePtr et SharedPtr

  • UniquePtr est un smart pointer à propriétaire unique qui appelle automatiquement free() à la fin du scope
    • Avec la macro AUTO_UNIQUE_PTR(), la mémoire est libérée automatiquement même en cas d’erreur ou de retour anticipé
  • SharedPtr est une structure à comptage automatique de références qui détruit la ressource quand la dernière référence est libérée
    • shared_ptr_init() et shared_ptr_copy() gèrent automatiquement l’incrémentation et la décrémentation des références
    • Il est utilisé pour gérer des structures partagées en toute sûreté entre threads

Prévention des buffer overflows : Vector et View

  • La macro DEFINE_VECTOR_TYPE() permet de créer des vecteurs extensibles et sûrs vis-à-vis du type
    • La réallocation, la gestion de capacité et le nettoyage (cleanup) sont pris en charge automatiquement
    • Avec AUTO_TYPED_VECTOR(), la mémoire est libérée automatiquement à la fin du scope
  • StringView et Span sont des structures de référence non propriétaires pour manipuler des tranches de chaînes ou de tableaux sans malloc séparé
    • DEFINE_SPAN_TYPE() permet de définir un Span par type
    • Les vérifications de bornes garantissent un accès sûr aux tableaux

Gestion des erreurs : type Result et RAII

  • La structure Result est un type de retour distinguant succès et échec, similaire à Result<T, E> de Rust
    • DEFINE_RESULT_TYPE() génère des structures de résultat adaptées à chaque type
    • RESULT_IS_OK() et RESULT_UNWRAP_ERROR() permettent une gestion explicite des erreurs
  • Combiné à l’attribut CLEANUP, il automatise la libération des ressources à la sortie de la fonction
    • La macro AUTO_MEMORY() nettoie automatiquement la mémoire allouée par malloc

Contrats et chaînes sûres

  • Les macros requires() / ensures() permettent d’exprimer les préconditions et postconditions des fonctions
    • En cas d’échec, un message d’erreur clair est affiché
  • safe_strcpy() est une fonction de copie avec vérification de la taille du tampon, pour éviter les overflows
    • En cas d’échec, elle renvoie false pour une gestion sûre de l’erreur

Concurrence : déverrouillage automatique et macros de thread

  • Une fonction de déverrouillage automatique de mutex basée sur CLEANUP aide à prévenir les deadlocks
    • pthread_mutex_unlock() est appelée automatiquement à la sortie du scope
  • Les macros SPAWN_THREAD() et JOIN_THREAD() simplifient la création et la jonction des threads
    • Elles sont utilisées pour implémenter le pool de threads de traitement de fichiers de cgrep

Optimisation des performances

  • Les macros LIKELY() / UNLIKELY() fournissent une prédiction de branchement sur le hot path
    • Un effet d’optimisation de niveau PGO peut ainsi être obtenu même sur des builds en -O2
  • L’ajout de fonctionnalités de sûreté n’entraîne pas de baisse de performances

Conclusion

  • cgrep, construit avec safe_c.h, représente 2 300 lignes de code C et élimine plus de 50 appels manuels à free()
  • Il implémente un code C sûr, sans fuite mémoire ni segfault, tout en conservant le même assembleur et la même vitesse d’exécution
  • C’est un exemple de combinaison entre la simplicité et la liberté du C et une sûreté moderne
  • L’auteur prévoit d’expliquer dans un prochain article pourquoi cgrep est plus de deux fois plus rapide que ripgrep et consomme 20 fois moins de mémoire
  • safe_c.h est présenté comme adapté aux nouveaux projets, tout en signalant qu’une approche fondée sur les macros peut rendre le débogage plus difficile
  • Sa justesse et sa sûreté ont été vérifiées avec divers analyseurs statiques (GCC analyzer, ASAN, UBSAN, Clang-tidy, etc.)

1 commentaires

 
GN⁺ 2025-11-19
Commentaires sur Hacker News
  • Cet article montre le problème de coût qui apparaît lorsqu’on implémente des abstractions sûres (safe abstractions) en C
    L’implémentation des pointeurs partagés utilise un mutex POSIX, ce qui (1) n’est pas indépendant de la plateforme et (2) impose le surcoût du mutex même en mono‑thread
    Autrement dit, ce n’est pas une « zero-cost abstraction »
    Le shared_ptr de C++ a le même problème, mais Rust le résout en distinguant deux types, Rc et Arc

    • Le shared_ptr de C++ n’utilise pas de mutex mais des opérations atomiques
      C’est similaire à Arc en Rust, et l’implémentation du billet est simplement inefficace
      Cela dit, C++ n’a pas de type équivalent à Rc, donc il y a toujours un coût lorsqu’on veut un simple pointeur à comptage de références
    • Dans les environnements glibc et libstdc++, si on ne lie pas pthreads, shared_ptr n’est pas thread-safe
      À l’exécution, il recherche les symboles pthread pour choisir un chemin atomique ou non atomique
      Je pense qu’il vaudrait mieux utiliser les atomiques en permanence
    • J’ai l’impression qu’il est bien plus important de faire en sorte que le code ne plante pas
      Le cross-platform est surtout un « nice to have » dans la plupart des cas
      Le surcoût des mutex est agaçant, mais il reste acceptable sur des CPU modernes
      Je sais que Rust est excellent, mais l’écosystème C est tellement vaste qu’il est difficile de le remplacer complètement
    • On pourrait aussi implémenter le comptage de références avec les opérations atomiques C11 plutôt qu’avec des mutex
      Dans ce cas, je vois mal quel avantage apporte le mutex
    • Les mutex POSIX sont déjà implémentés sur plusieurs plateformes, donc je pense que c’est au contraire une API plus générale
  • Il existe un projet appelé FUGC, un ramasse-miettes créé par Fil (aka pizlonator), pour rendre C memory-safe
    Il peut être appliqué à du code existant avec très peu de modifications et transforme C/C++ en langages memory-safe
    Voir le post HN associé et le site officiel

    • C’est grâce à ça que j’ai découvert ce projet pour la première fois. Je trouve que c’est une tentative vraiment intéressante
    • Mais je n’ai pas envie d’accepter la baisse de performances d’un ramasse-miettes
  • J’ai l’impression que cet article présente un peu mal le cœur du sujet de la memory safety
    La libération automatique des variables locales ou les vérifications de bornes ne suffisent pas
    Le vrai problème, c’est la gestion de la durée de vie mémoire de l’ensemble du programme
    Par exemple, quand on retourne un UniquePtr ou qu’on copie un SharedPtr, n’oublie-t-on pas le comptage de références ? Qui gère la durée de vie des éléments d’une intrusive list ?
    Au fond, cette approche ne me semble pas très différente de l’ancien motif #define xfree(p)

    • UniquePtr est possible, puisqu’on peut retourner une structure par valeur
      En revanche, la copie de SharedPtr n’incrémente pas automatiquement le comptage de références
    • Je me demande pourquoi le motif #define xfree(p) est mauvais
  • On dit que C23 a introduit l’attribut [[cleanup]], mais en réalité c’est une extension GCC et il faut l’écrire [[gnu::cleanup()]]
    Voir cet exemple de code

    • J’avais du mal à trouver des informations à ce sujet, mais au final il semble que seule la syntaxe ait changé et que la fonctionnalité elle-même reste une extension
  • Il y avait une blague du genre : « C++ : regardez comme les autres langages peinent à imiter ne serait-ce qu’une partie de mes pouvoirs »
    Je me demande pourquoi imiter C++ avec des macros, mais c’est quand même une tentative intéressante

    • J’ai trouvé intéressant le processus consistant à créer un C plus sûr sans intégrer toutes les fonctionnalités de C++
      Mais au final, en voyant qu’on en arrive à imiter jusqu’à des fonctionnalités de C++17, je me demande s’il ne vaudrait pas mieux simplement utiliser C++
    • Je veux un langage analysable
      C reste relativement facile à manipuler, mais C++ est tellement complexe qu’il est difficile de l’aborder sans frontend
    • C est simple, donc c’est un bon langage pour bidouiller
      En passant à C++, tout se complique avec la chaîne de build, le name mangling, la dépendance à libstdc++, etc.
    • Ce projet peut n’autoriser qu’une partie des fonctionnalités de C++ et imposer ainsi une syntaxe restreinte
      À l’inverse, si on utilise C++ dans un style C, on n’a pas ce type de contrainte
    • Le fait que les fournisseurs de CPU embarqués ne fournissent pas de compilateur C++ est aussi une contrainte bien réelle
  • Ce n’est pas compatible avec la gestion d’exceptions basée sur setjmp/longjmp
    En revanche, on peut l’intégrer avec une paire de macros cleanup inspirée de pthread_cleanup_push de POSIX
    On implémente des routines de nettoyage basées sur la pile avec cleanup_push(fn, type, ptr, init) et cleanup_pop(ptr)
    Cette approche a l’avantage de détecter les erreurs d’équilibrage à la compilation

  • Il ne faut pas le confondre avec le vrai safec.h de safeclib
    Voir les en-têtes de safeclib

    • Je me demande pourquoi quelqu’un voudrait maintenir une implémentation d’Annex K
      C’est considéré comme un échec de conception à cause du global constraint handler, et la plupart des toolchains ne le prennent pas en charge
      Voir ce document associé
  • Si on utilise le langage Nim, on obtient toutes les fonctionnalités fournies par safe_c.h
    Nim compile vers C et offre à la fois sûreté et performances
    Il fournit nativement diverses fonctionnalités, comme le comptage de références automatique basé sur ARC, defer, Option[T], le bounds-checking, likely/unlikely, etc.
    Voir le site officiel, l’introduction à ARC, les view types, la documentation d’Option, le template likely

  • Si cette approche vise la portabilité, il est plus prudent en pratique d’en rester à C99
    Le compilateur C de MSVC est exigeant, mais il est quasiment indispensable pour le cross-platform
    J’ai moi aussi créé un en-tête similaire, mais je n’y ai pas inclus d’utilitaires de cleanup à cause des problèmes de portabilité

    • En faisant générer du code C++ (basé sur des destructeurs) par des macros, c’est possible même sans attribut cleanup
      Si le code C compile aussi en C++, cela fonctionne bien
    • Même sous Windows, on peut développer sans problème avec MSYS2 + GCC
      Un gestionnaire de paquets est fourni en plus
    • À noter que MSVC prend désormais en charge C17
  • L’article mentionne plusieurs fois cgrep, mais il n’y a aucun lien vers son code
    Il existe de nombreux projets du même nom sur GitHub, mais la plupart sont écrits dans d’autres langages

    • Moi non plus je ne sais pas de quel cgrep il s’agit, et j’aimerais bien l’essayer moi-même