Jetez un œil à l'intérieur des exécutables et des bibliothèques pour faciliter le débogage

À première vue, les exécutables produits par un compilateur et les bibliothèques utilisées pendant le processus de construction semblent ne pas être très accessibles. Ce sont ces boîtes noires qui font fonctionner une application, ou rendent l'éditeur de liens heureux lorsque vous lui remettez le «bon» fichier de bibliothèque. Il y a aussi beaucoup à dire pour ne pas creuser trop profondément non plus, car normalement les choses fonctionnent™ sans avoir à se soucier de ces détails supplémentaires.

Le fait est que les exécutables et les bibliothèques contiennent beaucoup d'informations qui ne sont normalement utilisées que par le système d'exploitation, la chaîne d'outils, les débogueurs et les outils similaires. Que ces fichiers soient au format Windows PE, Linux à l'ancienne a.out ou moderne .elf, lorsque les choses vont au sud pendant le développement, il faut parfois trouver les bons outils pour les inspecter afin de comprendre ce qui se passe.

Cet article se concentrera principalement sur la plate-forme Linux, bien que la plupart s'appliquent également à BSD et MacOS, et dans une certaine mesure à Windows.

Ouverture de la Black Box

Quelle que soit la plateforme sur laquelle vous vous trouvez, les formats exécutables et de bibliothèque ont tous un certain nombre de sections communes. Il y a bien sûr la section avec les instructions réelles, ainsi que la section avec toutes les chaînes de texte et les valeurs constantes que nous mettons dans le code avant de le compiler. Si nous avons demandé au compilateur de générer des symboles de débogage et dit à l'éditeur de liens de les laisser en place, nous avons également les symboles de débogage inclus dans sa propre section. Nous les examinerons plus loin dans cet article.

Dans l'ELF (Executable and Linkable Format) couramment utilisé sous Linux et de nombreux autres systèmes d'exploitation, la disposition approximative suit ce diagramme. Toutes ces sections ne sont pas requises et leur inclusion dépend des options sélectionnées lors de la création du fichier exécutable.

Un aperçu rapide des propriétés d'un fichier exécutable peut être obtenu avec l'utilitaire de fichier:

ELF 32-bit LSB shared object, Intel 80386, version 1 (GNU/Linux), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID(sha1)=0558c7ef0f6845826d012b4ccc14948a2ffe8277, stripped

Cette sortie nous indique que nous avons affaire à un binaire 32 bits, compilé pour l'architecture x86, qui utilise un certain nombre de bibliothèques partagées et dont les symboles de débogage ont été supprimés.

Si des symboles de débogage sont toujours présents, nous obtenons:

ELF 32-bit LSB shared object, Intel 80386, version 1 (GNU/Linux), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID(sha1)=0558c7ef0f6845826d012b4ccc14948a2ffe8277, with debug_info, not stripped

Dans ce cas particulier, nous avons affaire à un binaire qui a été compilé sur Raspbian Buster pour x86, qui est une version 32 bits de Linux, de sorte que toutes les correspondances.

Pour un fichier exécutable Windows, nous obtenons la sortie suivante, moins étendue:

PE32+ executable (GUI) x86-64, for MS Windows

Cela nous indique que nous avons affaire à un exécutable PE (Windows), compilé pour l'architecture 64 bits x86-64.

Comme on peut le deviner à ce stade, les bibliothèques, à la fois dynamiques et partagées, utilisent le même format que les exécutables, donc par exemple en examinant un .so fichier de bibliothèque partagée sur Linux générerait presque la même sortie lorsque nous utilisons le fichier commander.

Partager de manière responsable

La possibilité de charger des bibliothèques dynamiques (partagées) au démarrage de l'application est unique pour les systèmes d'exploitation (de bureau). Ici, l'hypothèse est faite que les bibliothèques requises sont présentes sur le système hôte et dans le chemin de recherche du chargeur de bibliothèque (un composant du système d'exploitation). Les bibliothèques peuvent également être versionnées pour indiquer différentes révisions. Cela se produit généralement via le nom de fichier, avec le nom générique (par ex. libfoo.so) lié au fichier réel (libfoo.so.0.1). S'il y a un décalage avec la version, cela peut entraîner une erreur de symbole, que nous verrons dans la section suivante.

Lorsqu'un exécutable utilise des fichiers de bibliothèque partagée, il est facile de vérifier les dépendances directes (encodées dans le fichier exécutable) qu'il utilise, en vérifiant l'exécutable avec l'utilitaire ldd, qui a un gotcha qui ne fonctionne pas bien avec l'ancienne. a.out format. Ce n'est pas vraiment un problème avec le développement moderne sur Windows, Linux / BSD et MacOS, qui utilisent respectivement les formats PE (PE32 +), ELF et Mach-O. Pour le développement intégré (par exemple ARM Cortex-M), le format ELF est également utilisé comme format intermédiaire avant de générer l'image binaire.

Liste des dépendances

La sortie de base de ldd indique où les dépendances directes se trouvent sur le système de fichiers et quelles dépendances ne sont pas trouvées. Par exemple, il s'agit de la sortie (fortement) abrégée de ldd pour ffplay.exe sous MSYS2 sous Windows:

$ ldd /mingw64/bin/ffplay.exe
        ntdll.dll => /c/Windows/SYSTEM32/ntdll.dll (0x77780000)
        kernel32.dll => /c/Windows/system32/kernel32.dll (0x77660000)
        KERNELBASE.dll => /c/Windows/system32/KERNELBASE.dll (0x7fefd730000)
        msvcrt.dll => /c/Windows/system32/msvcrt.dll (0x7fefed80000)
        SHELL32.dll => /c/Windows/system32/SHELL32.dll (0x7fefdab0000)
        SHLWAPI.dll => /c/Windows/system32/SHLWAPI.dll (0x7fefda10000)
        GDI32.dll => /c/Windows/system32/GDI32.dll (0x7feff0e0000)
        USER32.dll => /c/Windows/system32/USER32.dll (0x77560000)
        LPK.dll => /c/Windows/system32/LPK.dll (0x7fefeb30000)
        USP10.dll => /c/Windows/system32/USP10.dll (0x7feff6e0000)
        SDL2.dll => /mingw64/bin/SDL2.dll (0x644c0000)
        (...)

Les dépendances affichées pour l'exécutable moyen peuvent être assez massives (la liste complète est environ huit fois plus longue), mais il est utile comme une vérification rapide de la validité pour voir non seulement si une dépendance a été satisfaite, mais aussi si le chargeur d'application a choisi le bibliothèque de droite. Il peut arriver par exemple qu'un système possède deux versions différentes d'une bibliothèque (par exemple dans / usr / shared / bin et / usr / bin), ce qui peut conduire à une situation hilarante où vous passez une demi-journée à déboguer différentes bibliothèques et versions d'application, à restaurer des versions de code «de travail connues» et à perdre votre raison.

Une autre chose qu'un outil comme ldd indique à quelle adresse la bibliothèque a été chargée, mais cela n'est utile que pour des niveaux vraiment avancés de débogage et d'optimisation.

Quand les symboles disparaissent

Les choses s'amusent quand on parle de symboles dans le contexte des formats exécutables et de bibliothèque. Il ne s'agit pas de symboles de débogage, qui sont un sujet complètement différent, mais des symboles qui font partie intégrante de la recherche de sections de code, que ce soit lors de l'exécution ou lors de la liaison de fichiers objets et de bibliothèques statiques. Les symboles manquants entraînent également des erreurs d'exécution amusantes, où un «point d'entrée» n'est pas trouvé dans une bibliothèque partagée.

Un moyen rapide de résoudre ces problèmes consiste généralement à vous assurer que vous disposez des versions correspondantes des bibliothèques pour le code ou le fichier exécutable. Parfois, tout cela est vérifié, et le chargeur d'application ou l'outil de liaison vous donne toujours des lèvres sur les symboles manquants, alors qu'est-ce qui donne?

Dans le cas de la liaison de code, cela peut être aussi simple que le mauvais ordre de liaison, car les chaînes d'outils pour la plupart des langues utilisent un style de liaison opportuniste qui se souvient des symboles manquants, mais ne se souvient pas des symboles qu'il a déjà vus. Alors que dans des langages comme Ada, ce n'est pas un problème, dans les langages de style C, déterminer l'ordre de liaison dans les commandes données à l'éditeur de liens est essentiel.

Un autre problème est celui où un langage (comme C ++) prend en charge les fonctions de surcharge pour prendre en charge différents arguments et types de retour, et le changement de nom est utilisé (pour obtenir un symbole unique). Si un fichier d'en-tête a été compilé en mode C ++, lorsqu'il est censé être lié à une bibliothèque qui a été compilée en tant que code C, sans changement de nom, cela ferait que l'éditeur de liens donne l'erreur "symbole manquant" pour ces fonctions.

Afin de déterminer si un symbole manquant est vraiment manquant, mal déformé, laissé démêlé ou dans une autre bibliothèque ou un fichier objet, on peut utiliser un utilitaire comme readelf pour vérifier quels symboles sont réellement dans le fichier. Notez que (évidemment) readelf ne prend en charge que les fichiers de style ELF. Un utilitaire plus générique qui se concentre uniquement sur les symboles dans une variété de formats est nm. Par exemple, cette sortie de l'entrée Wikipedia sur nm:

# nm test.o
0000000a T _Z15global_functioni
00000025 T _Z16global_function2v
00000004 b _ZL10static_var
00000000 t _ZL15static_functionv
00000004 d _ZL15static_var_init
00000008 b _ZZ15global_functioniE16local_static_var
00000008 d _ZZ15global_functioniE21local_static_var_init
         U __gxx_personality_v0
00000000 B global_var
00000000 D global_var_init
0000003b T main
00000036 T non_mangled_function

Cela montre à quoi ressemble la sortie de nm lorsqu'un compilateur C ++ est utilisé. Nm peut être chargé de démêler les symboles pour en faciliter la lecture si nécessaire. Quoi qu'il en soit, sa sortie nous indique si un symbole existe dans le fichier ou n'est pas défini («U»). Il précisera également où le symbole est défini (quelle section) et de quel type de symbole il s'agit (le cas échéant). Dans l'exemple ci-dessus, nous voyons un symbole non défini ('U'), quelques symboles de section de texte (code) ('T' et 't'), un symbole dans la section de données non initialisées (BSS, 'B' & 'b ') et deux dans la section des données initialisées (' D 'et' d ').

Parmi ceux-ci, nous aurions juste besoin de remettre à l'éditeur de liens une bibliothèque ou un fichier objet contenant le seul symbole non défini pour créer ce lien de code et produire un exécutable.

Dernier recours: démarrage de l'application de suivi

Chose ennuyeuse, parfois tout semble en ordre, mais l'application ne démarre pas ou se ferme à mi-chemin avec un message mystérieux. C'est là qu'un utilitaire comme strace peut être extrêmement utile, car il trace tous les appels système impliquant l'application à partir du moment où l'application démarre. Souvent, le problème avec une application qui ne se charge pas est dû à une dépendance indirecte qui ne peut pas être chargée, à un paramètre d'environnement inapproprié ou à un fichier qui a été accidentellement défini en lecture seule.

Le simple fait de lancer strace avec l'application comme argument produira une liste des appels système tels qu'ils ont été effectués par l'application, y compris les erreurs, comme un fichier manquant:

open("/foo/bar", O_RDONLY) = -1 ENOENT (No such file or directory)

Ou une dépendance de bibliothèque manquante:

open("/usr/lib/libfoo.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)

Emballer

De toute évidence, rien de tout cela n'est l'aboutissement du débogage de la liaison et de l'exécution des exécutables, des binaires et d'un assortiment de problèmes connexes. Comme pour tant de choses dans la vie, au final, c'est surtout l'expérience qui compte. Au fil du temps, on développera une intuition pour savoir où se situe probablement le problème et comment trouver le coupable le plus rapidement possible.

Ayant passé de nombreuses années dans le développement de logiciels commerciaux et ayant survécu à une gamme de projets de loisirs (trop) ambitieux, je peux certainement dire qu'il y a beaucoup de connaissances que j'aurais aimé avoir plus tôt. D'un autre côté, le fait de découvrir pourquoi certaines choses ne fonctionnaient pas et de corriger cette injustice contre l'ordre du monde était généralement gratifiant en soi.

Cela dit, il faut bien choisir ses batailles. Parfois, apprendre des choses à partir de rien ne vaut pas la peine, et s’appuyer sur la connaissance des autres n’a pas à rougir. Surtout quand c'est vendredi après-midi et que le client attend la livraison de la nouvelle version lundi. J'espère que cet article a été utile à cet égard.

François Zipponi
Je suis François Zipponi, éditorialiste pour le site 10-raisons.fr. J'ai commencé ma carrière de journaliste en 2004, et j'ai travaillé pour plusieurs médias français, dont le Monde et Libération. En 2016, j'ai rejoint 10-raisons.fr, un site innovant proposant des articles sous la forme « 10 raisons de... ». En tant qu'éditorialiste, je me suis engagé à fournir un contenu original et pertinent, abordant des sujets variés tels que la politique, l'économie, les sciences, l'histoire, etc. Je m'efforce de toujours traiter les sujets de façon objective et impartiale. Mes articles sont régulièrement partagés sur les réseaux sociaux et j'interviens dans des conférences et des tables rondes autour des thèmes abordés sur 10-raisons.fr.