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.
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 :
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 :
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 :
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).
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 :
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 :
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 ?
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 :
- 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.
- _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.
- Si aucune frame ne gère l’exception, un gestionnaire spécifique, __cxa_terminate_handler, est appelé.
- 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 »