Voyage en C++ie : un code d’exception

Magazine
Marque
MISC
Numéro
119
Mois de parution
janvier 2022
Spécialité(s)


Résumé

Cet article s'inscrit dans la continuation d'une série d'articles sur les artefacts bas niveau que l'on peut rencontrer dans un binaire compilé à partir de sources en C++. Cet épisode aborde (enfin !) un des morceaux les plus indigestes de la traduction de C++ en assembleur : la gestion d'exceptions sous Linux, suivant la spécification dite Itanium.


Body

Le langage C++ offre plusieurs moyens d'interagir avec le système d'exceptions. On peut lever une exception (via l'instruction throw <value>), attraper une exception en filtrant suivant le type (via l'instruction catch(<type> <identifier>)) ou indépendamment de son type (via l'instruction catch(...)). Il est également possible de relancer l'exception en cours de traitement (via l'instruction throw;).

Depuis C++11, une fonction peut être déclarée noexcept pour indiquer qu'elle ne peut pas lancer d'exception, là où une fonction non décorée pourrait en lancer. Et bien évidemment, le mot clef try permet de délimiter un bloc de code où une exception pourrait être levée.

Le fichier d'en-tête <exception> permet de spécialiser la manière de gérer certaines situations exceptionnelles (huhu) : std::set_terminate(...) modifie la fonction appelée quand une exception n'est pas gérée, std::current_exception() renvoie l'exception en cours de traitement.

Plutôt que de paraphraser le néanmoins passionnant Itanium C++ ABI: Exception Handling [1], le reste de cet article aborde le sujet de manière inductive, à travers l'observation des artefacts produits par la compilation de petits fragments de code et l’analyse du code de la bibliothèque standard C++.

1. Le lancer d'exceptions

Le code suivant :

void foo() {
    throw int(3);
}

génère deux appels de fonctions : 1) __cxa_allocate_exception et 2) __cxa_throw. On peut trouver une implémentation documentée de ces deux appels dans le code source de la libcxxabi [4], l'implémentation de la bibliothèque standard C++ portée par le projet LLVM.

La première de ces fonctions, dont la signature est void *__cxa_allocate_exception(size_t thrown_size) throw(), est décrite comme « allouant un objet de type _cxa_exception, le remplissant de zéro et réservant thrown_size octets également initialisés à zéro à la suite pour pouvoir y allouer l’exception ». C’est donc un allocateur mémoire spécialisé qui alloue (sur le tas) assez de mémoire pour stocker l'exception à lever.

La seconde de ces fonctions, dont la signature est void __cxa_throw(void *thrown_object, std::type_info *tinfo, void (*dest)(void *)), est richement commentée. On y apprend entre autres qu’elle « récupère l’en-tête d’exception à partir d’une adresse d’exception, sauvegarde les gestionnaires unexpected_handler et terminate_handler courants, met à jour l’en-tête de l’exception avec des informations de type et qu’elle appelle _Unwind_RaiseException de la bibliothèque unwind en lui passant thrown_object en paramètre, et enfin elle incrémente le compteur d’exceptions non gérées ».

unexpected_handler et terminate_handler sont liés à std::set_unexpected et std::set_terminate que l’on détaille en fin d’article.

On apprend donc que la gestion d’exceptions met en œuvre des fonctions qui font partie de la bibliothèque standard, et d’autres qui font partie d’une bibliothèque spécialisée dans la gestion des exceptions…

2. L'attrape-exception

Après ce bref aperçu sur le lancer d'exception, regardons comment une exception est attrapée en compilant le code suivant :

#include <cstdio>
void foo();
void pain() {
  try {foo();}
  catch(...) {puts("whoot");}
}

Trois appels de fonction sortent du lot : 1) __cxa_begin_catch 2) __cxa_end_catch et 3)_Unwind_Resume. Un retour aux sources (huhu) se révèle là encore fort instructif pour la fonction de signature void* __cxa_begin_catch(void* unwind_arg) throw() : « Pour les exceptions natives, on incrémente le compteur d’exceptions, on ajoute l’exception à la pile des exceptions en cours de gestion si elle n’y est pas déjà (à cause d’un rethrow), on décrémente le compteur d’exceptions non gérées et on renvoie un pointeur sur l’exception, extrait de __cxa_exception ».

On comprend assez rapidement l'intérêt de la fonction __cxa_end_catch à la lecture des commentaires :

/* (...)
* This routine can be called for either a native or foreign exception.
* For a native exception:
* * Locates the most recently caught exception and decrements its handler count.
* * Removes the exception from the caught exception stack, if the handler count goes to zero.
* * If the handler count goes down to zero, and the exception was not re-thrown
*    by throw, it locates the primary exception (which may be the same as the one
*    it's handling) and decrements its reference count. If that reference count
*    goes to zero, the function destroys the exception. In any case, if the current
*    exception is a dependent exception, it destroys that.
*
* (...)
*/
void __cxa_end_catch();

On apprend ainsi que la mémoire allouée pour les exceptions stockées dans __cxa_exception est gérée à travers un compteur de références !

On ne trouve aucune référence à _Unwind_Resume dans la libcxx. Normal, le projet LLVM fournit une implémentation de la bibliothèque unwind dans un projet indépendant : libunwind. On y lit que cette fonction fait partie d’un processus complexe en deux phases, une phase de recherche et une phase de nettoyage passe le contrôle de l’exécution à la personality function attachée à chaque frame traversée. À charge de cette fonction de continuer ou non la traversée de la pile d’appel pour continuer le processus de gestion d’exception. Il est peut-être temps de comprendre le rôle de cette dernière.

3. Une personnalité hors du commun

Dans les codes assembleurs précédents, on a omis de mentionner la présence d'une globale nommée __gxx_personality_v0. C'est la clef pour la suite de notre quête. On trouve le code source, heureusement commenté, de cette fonction dans la libcxxabi (commentaires simplifiés pour les besoins de l'article).

/*
The personality function branches on actions like so:
 
_UA_SEARCH_PHASE
 
     Scan for anything that could stop unwinding:
 
        1. A catch clause that will catch this exception
        2. A catch (...)
        3. An exception spec that will catch this exception
     If a handler is found
         Save state in header
         return _URC_HANDLER_FOUND
     Else a handler not found
         return _URC_CONTINUE_UNWIND
 
_UA_CLEANUP_PHASE
 
     If _UA_HANDLER_FRAME
         If _UA_FORCE_UNWIND
             How did this happen? return _URC_FATAL_PHASE2_ERROR
         Recover state from header
         Transfer control to landing pad. return _URC_INSTALL_CONTEXT
 
     Else
 
         This branch handles both normal C++ non-catching handlers (cleanups)
           and forced unwinding.
         Scan for anything that can not stop unwinding:
 
             1. A cleanup.
 
         If a cleanup is found
             transfer control to it. return _URC_INSTALL_CONTEXT
         Else a cleanup is not found: return _URC_CONTINUE_UNWIND
*/
LIBCXXABI_FUNC_VIS _Unwind_Reason_Code __gxx_personality_v0(...)

La personality function se trouve donc bien être au cœur de la gestion du flot de contrôle spécifique aux exceptions. Lors de l'appel à _Unwind_RaiseException déclenché par __cxa_throw, un processus de parcours de la pile d'appel commence (stack unwinding). Ce processus se déroule en deux phases : une phase de recherche (_UA_SEARCH_PHASE) qui parcourt la pile d'appel et met à jour le exception header suivant la présence ou non de gestionnaire d'exceptions ; une phase de nettoyage qui utilise les informations collectées lors de la phase précédente pour transférer le contrôle au landing pad associé à cette fonction.

4. Quelques détails de plus

L'inspection du code de std::set_terminate, std::set_unexpected et std::current_exception permet de confirmer l'intuition que nous sommes en train de développer.

On découvre que std::set_terminate manipule une variable globale __cxa_terminate_handler utilisée pour positionner un des champs du exception header. Ce champ est utilisé dans de nombreux cas d'erreur (p.e. en cas de corruption de l'exception header), mais aussi si aucun gestionnaire d'exceptions n'est trouvé. On appelle alors :

// It is possible that no eh table entry specify how to handle
// this exception. By spec, terminate it immediately.
call_terminate(native_exception, unwind_exception);

std::set_unexpected positionne une fonction appelée quand une exception est levée alors que les spécifications d’une fonction l’empêche, e.g. noexcept .

Quant à std::current_exception, cette fonction appelle à son tour __cxa_current_primary_exception dont l'implémentation ne nous surprend pas :

/*
    Returns a pointer to the thrown object (if any) at the top of the
    caughtExceptions stack. Atomically increment the exception's referenceCount.
    If there is no such thrown object or if the thrown object is foreign,
    returns null. (...)
*/
void *__cxa_current_primary_exception() throw() {
    /* ... */
    void* thrown_object = thrown_object_from_cxa_exception(exception_header);
    __cxa_increment_exception_refcount(thrown_object);
    return thrown_object;
}

Autrement dit, on reconstruit l'exception en cours à partir du exception header, on gère le compteur de référence et aille don [5].

Reste la question de l'attribut noexcept que l'on peut adjoindre à une déclaration de fonction. Quelle différence entre les appels à foo et bar dans le code suivant ?

void impl();
void foo() {
    return impl();
}
void bar() noexcept {
    return impl();
}

La seule différence dans le code généré vient de la présence d'une pseudo-instruction .cfi_personality 0x3,__gxx_personality_v0. La documentation de notre assembleur préféré [2] indique que cette instruction associe une personality fonction à la fonction en cours. On note aussi la présence d'une section nommée gcc_except_table qui contient des labels nommés LSDA (Language-Specific Data Area) et LSDACS (LSDA Call Site). La lecture de la spécification nous apprend que la LSDA contient des informations spécifiques à la fonction à laquelle elle est attachée (landing pad start pointer, types table pointer), tandis que la LSDACS contient des données lues par la personality function et spécifiques à chaque instruction pouvant lever une exception (offset du site d'appel, offset du landing pad, etc.).

On notera également la présence d'une section nommée eh_frame qui contient quant à elle (au format DWARF) les informations dont l' unwinder a besoin pour nettoyer la pile lors du stack unwinding.

5. Assemblage

Tentons de résumer le cycle de vie d’une exception :

  1. Le compilateur génère un appel à __cxa_allocate_exception puis __cxa_throw. lorsqu’une exception est levée. L’une alloue l’exception sur le tas, l’autre transfère le flot de contrôle à _Unwind_RaiseException de libunwind.
  2. _Unwind_RaiseException parcourt chaque frame et regarde la personality function qui y est attachée pour lui transférer le contrôle. Celle-ci, en fonction des informations présentes dans la Language-Specific Data Area, détermine si l’exception peut être gérée localement ou non.
  3. Si aucune frame ne gère l’exception, un gestionnaire spécifique, __cxa_terminate_handler, est appelé.
  4. Si un gestionnaire est trouvé, _Unwind_RaiseException retraverse la pile d’appel et appelle à nouveau la personality fonction en indiquant qu’il faut effectuer le nettoyage de la frame, jusqu’à arriver à la frame déterminée à l’étape 2 où l’exception est proprement gérée.

Conclusion

Après avoir mis bout à bout les différents éléments d'information que l'on a pu collecter, il est bon de confronter l'image mentale que l'on a pu se construire à la froide réalité de la spécification. Pour ceux qui désirent aller dans cette direction, je leur recommande fortement la lecture de Itanium C++ ABI: Exception Handling référencé en fin d'article. C'est peut-être aussi le bon moment pour jeter un œil nouveau à la FAQ de l'ISOC++ sur les exceptions !

Remerciements

L’auteur tient à remercier Béartice Creusillet et Adrien Guinet pour leur relecture. Vous assurez.

Références

[0] FAQ de l'ISOC++ sur les exceptions : https://isocpp.org/wiki/faq/exceptionsdiamond.fr/

[1] Itanium C++ ABI: Exception Handling : https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html

[2] Manuel Binutils, section CFI : https://sourceware.org/binutils/docs/as/CFI-directives.html

[3] Une approche encore plus orientée « mains dans le cambouis » que celle proposée dans cet article : https://monkeywritescode.blogspot.com/p/c-exceptions-under-hood.html

[4] Code source des différents projets rattachés à LLVM : https://github.com/llvm/llvm-project

[5] Vieille expression paysanne bourguignonne signifiant « allons-y »



Article rédigé par

Par le(s) même(s) auteur(s)

Crévindiou, c’est pas du bon C d’chez nous ça, cé du C deu’l ville !

Magazine
Marque
GNU/Linux Magazine
Numéro
267
Mois de parution
janvier 2024
Spécialité(s)
Résumé

IANAL (I Am Not A Linguist), mais quand j’entends du québécois, je ne comprends pas tout, mais je comprends. Mais qu’en est-il des dialectes du langage C ? Car oui, le langage C a des dialectes, et nous allons voyager un peu à travers l’un d’entre eux, le dialecte GNU, supporté principalement par GCC, mais aussi, en partie, par Clang.

Les derniers articles Premiums

Les derniers articles Premium

Générez votre serveur JEE sur-mesure avec Wildfly Glow

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Et, si, en une ligne de commandes, on pouvait reconstruire son serveur JEE pour qu’il soit configuré, sur mesure, pour les besoins des applications qu’il embarque ? Et si on pouvait aller encore plus loin, en distribuant l’ensemble, assemblé sous la forme d’un jar exécutable ? Et si on pouvait même déployer le tout, automatiquement, sur OpenShift ? Grâce à Wildfly Glow [1], c’est possible ! Tout du moins, pour le serveur JEE open source Wildfly [2]. Démonstration dans cet article.

Bénéficiez de statistiques de fréquentations web légères et respectueuses avec Plausible Analytics

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Pour être visible sur le Web, un site est indispensable, cela va de soi. Mais il est impossible d’en évaluer le succès, ni celui de ses améliorations, sans établir de statistiques de fréquentation : combien de visiteurs ? Combien de pages consultées ? Quel temps passé ? Comment savoir si le nouveau design plaît réellement ? Autant de questions auxquelles Plausible se propose de répondre.

Les listes de lecture

11 article(s) - ajoutée le 01/07/2020
Clé de voûte d'une infrastructure Windows, Active Directory est l'une des cibles les plus appréciées des attaquants. Les articles regroupés dans cette liste vous permettront de découvrir l'état de la menace, les attaques et, bien sûr, les contre-mesures.
8 article(s) - ajoutée le 13/10/2020
Découvrez les méthodologies d'analyse de la sécurité des terminaux mobiles au travers d'exemples concrets sur Android et iOS.
10 article(s) - ajoutée le 13/10/2020
Vous retrouverez ici un ensemble d'articles sur les usages contemporains de la cryptographie (whitebox, courbes elliptiques, embarqué, post-quantique), qu'il s'agisse de rechercher des vulnérabilités ou simplement comprendre les fondamentaux du domaine.
Voir les 66 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous