Voyage en C++ie : les symboles

MISC n° 093 | septembre 2017 | Serge Guelton
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Cet article est le premier d'une mini-série sur le C++, ou plutôt sur les binaires compilés depuis C++, leurs particularités et comment les concepts du langage se retrouvent parfois dans le binaire final.

Comprendre le lien entre du code C++ et sa version compilée fournit parfois une aide précieuse à l’analyste. Dans cette optique, cette mini-série explore différentes facettes d’un binaire et leur lien avec le source d’origine quand ce dernier est du C++.

Pour commencer, nous allons nous intéresser à la table de symboles, aux sections, bref, à la carcasse ELF d'un code objet — on restera dans le monde Linux. Le compilateur utilisé est g++ (Debian 6.3.0-12) 6.3.0 20170406par conséquent, à moins qu’un drapeau différent soit utilisé dans les exemples, c’est la version gnu14 du standard qui est utilisée (C++14 avec quelques extensions GNU).

1. Rappel sur l’édition de liens

Le langage C++ supporte la compilation séparée, à savoir la compilation d’un programme par morceaux, généralement différents fichiers objets (.o) provenant de la compilation de codes sources (.cpp), mis en commun avec des archives (.a) et des bibliothèques dynamiques (.so) , le tout pour former, si ce n’est une nouvelle bibliothèque, un programme. Chacun de ces codes objet contient du code ou des données associées à un nom de symbole, et des références vers d’autres symboles qui peuvent être présents dans un autre code objet, une bibliothèque, plusieurs fois (et on risque alors un conflit) ou jamais (ce qui peut être problématique). Le rôle de l’édition de liens est de faire ce travail d’appariement.

2. Le Name Mangling, l'encodage des symboles

En C, chaque fonction externe se voit associer, une fois compilée, une entrée dans la table des symboles.

int foo() { return 0; }

compilé par :

> cc -c foo.c

0000000000000000 T foo

aura une table des symboles simple où l'on retrouve l'identifiant foo :

> nm foo.o

Le même code mis dans un fichier foo.cpp et compilé avec g++ (ou autre) donnera :

> g++ -c foo.cpp

> nm foo.o
0000000000000000 T _Z3foov

On ne retrouve plus l'identifiant d'origine, ou plutôt on le retrouve au milieu de toute une décoration, le mangling. Cette transformation de nom permet principalement de supporter la surcharge de fonction, comme l'illustre le code suivant :

int foo() { return 0; }

int foo(int n) { return n; }

compilé par :

> g++ -c foo.cpp
> nm foo.o
000000000000000b T _Z3fooi

0000000000000000 T _Z3foov

Le changement de signature est reflété par le changement d'identifiant, et on peut représenter toutes les surcharges de cette façon. D'ailleurs le drapeau -C de nm permet d'obtenir une version plus familière de cette même table des symboles :

> nm -C foo.o
000000000000000b T foo(int)
0000000000000000 T foo()

La commande c++filt peut être utilisée de façon similaire :

> c++filt _Z3fooi

foo(int)

On remarquera que le type de retour ne fait pas partie du mangling, puisque si on change le type de la fonction en void foo();, le symbole associé restera _Z3foov. Ce qui n'est pas si surprenant puisque, en C++, le type de retour ne rentre pas en compte dans la détermination des surcharges.

Donc si vous rencontrez un symbole répondant au doux nom de _Z3fooRSt6vectorIiSaIiEERKNSt7__cxx114listIiS0_EE, vous savez maintenant que sous sa cagoule, il s'appelle plutôt foo(std::vector<int, std::allocator<int> >&, std::__cxx11::list<int, std::allocator<int> > const&) !

Les symboles n'ont pas le même nom, seule leur version demangled est similaire. Eh bien cette duplication est une spécificité de g++ qu'on ne retrouve pas avec clang++.

Notez que le mangling n'est pas unique ! C'est là qu'intervient la notion d'ABI (Application Binary Interface), et un même compilateur peut utiliser différents mangling suivant l'ABI ciblée. Par exemple, le compilateur msvc peut utiliser l'ABI MS pour Windows 64 bits, GCC utilise on ABI pour Linux 32 ou 64 bits, qui peut être reprise (et c'est le cas par défaut) par Clang, etc. Mais ces ABI évoluent ! Rien que pour GCC, il existe plus de 10 versions mineures de l'ABI C++, comme documenté dans la page info gcc sur le drapeau -fabi-version=N. Le lecteur curieux pour s’essayer au décodage manuel en suivant l’ABI itanium suivie par GCC : https://itanium-cxx-abi.github.io/cxx-abi/abi.html#mangling.

3. Lien C : C++

Le mangling explique à lui seul le besoin du extern "C" utilisé pour déclarer une fonction venant d'une bibliothèque C appelée par un code C++, ou pour déclarer une fonction C++ ayant vocation à être utilisée depuis un code C : il force l'utilisation du mangling C pour ce symbole (ou ce groupe si on utilise la notation avec accolades) :

int foo() { return 0;}

extern "C" int bar() { return 0;}

Une fois compilé et analysé :

> nm bar.o

000000000000000b T bar
0000000000000000 T _Z3foov

On notera que puisque le mangling n'utilise pas de symboles réservés, il est tout à fait possible de déclarer depuis C un symbole importable sans extern "C" :

 

int _Z3foov() { return 0; }

Et une déclaration de fonction C valide, que l'on peut utiliser depuis C++ à travers extern "C" int _Z3foov() bien sûr, ou, de façon non portable (car dépendant de l’ABI utilisée), int foo();.

Le C peut a priori être remplacé par d'autres langages, mais le standard n'impose que ce dernier. Il y a bien gcc qui permette d'utiliser extern "java" pour une utilisation avec gcj, mais ça reste bien anecdotique.

4. Le mot-clé static

Ce mot-clé respecte l'héritage du C : une définition marquée static apparaît dans la table des symboles, mais n'est pas visible lors de l'étape de lien :

static int foo() { return 0;}

int bar() { return foo();}

Compilé et examiné :

> g++ -c static.cpp
> nm static.o
000000000000000b T _Z3barv

0000000000000000 t _ZL3foov

On notera que le drapeau --extern-only de nm ne liste bien que le symbole bar :

> nm --extern-only static.o

000000000000000b T _Z3barv

C'est ce qui permet de définir deux symboles static dans deux unités de compilation différentes sans qu'elles rentrent en conflit.

5. One Definition Rule

Considérons le code suivant :

template<class T> T bar() { return {};}

int foo() { return bar<int>();}

Une fois compilé sans optimisation, on a une table des symboles assez surprenante :

> g++ template.cpp -c
> nm template.o
0000000000000000 W _Z3barIiET_v

0000000000000000 T _Z3foov

La fonction bar est instanciée une fois pour le type int, ce qui crée un nouveau symbole (en l'absence d'optimisation). Or il arrive souvent qu'une même fonction template soit instanciée dans plusieurs unités de compilation, ne serait-ce que celles de la bibliothèque standard. Eh bien tout se passe bien, car le symbole est marqué comme weak, identifié d'un W dans la sortie de nm. Un symbole normal gagne sur un symbole weak et si deux symboles du même nom marqués weak sont présents lors du lien, l'un des deux est choisi, à la discrétion de l'implémentation, ce qui est tout à fait l'effet attendu ici !

Ce principe de « plusieurs définitions du moment qu'elles sont identiques » est plus connu sous le nom d'ODR, One Definition Rule. Il est aussi valable pour une fonction membre quand celle-ci est définie dans la classe :

struct foo {
   void bar() {}
};
void foobar() {
   foo().bar();

}

La table des symboles comprend des entrées pour foobar et l'appel à foo::bar (le constructeur par défaut ne faisant rien, il semble avoir été ignoré) :

> g++ class.cpp -c
> nm class.o
0000000000000000 T foobar()

0000000000000000 W foo::bar()

Là encore, foo::bar est marqué weak, ce qui est en accord avec l'usage de mettre ce genre de petite méthode dans le fichier d'en-tête.

6. Le mot-clé inline

Il n'est pas rare, dans des fichiers d'en-tête, d'avoir des fonctions marquées inline. Comme ces fichiers sont potentiellement inclus dans plusieurs sources qui peuvent être liées ensembles, on devrait avoir un conflit de symboles lors de l'édition de liens. Et on l'aurait si le mot-clef inline n'était pas utilisé. Regardons la table des symboles pour deux fonctions identiques, l'une étant marquée inline et l'autre non :

inline int foo() { return 0;}

int bar() { return foo();}

Une fois compilé sans optimisation, on a la table des symboles suivante :

> g++ inline.cpp -c
> nm inline.o
0000000000000000 T _Z3barv

0000000000000000 W _Z3foov

La fonction marquée inline est bien présente, mais elle est marquée du sceau de la OneDefinitionRule vue précédemment, ce qui est en accord avec la pratique de mettre ses fonctions dans les fichiers d'en-tête pour qu'un compilateur puisse les expanser un peu partout, sans créer d'erreur de liens.

Notons qu'il est possible de marquer une fonction static inline, dans ce cas elle aura une visibilité statique et sera en plus weak.

7. COMDAT Section

Si on s'attarde un peu plus sur le code objet généré à partir de inline.cpp, on découvre que la fonction _Z3barv n'est pas définie dans la section .text comme sa comparse _Z3foov, mais dans une section suffixée par son nom, la section [6] dans l’exemple ci-dessous :

> readelf --sections --section-groups inline.o
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[...]
[ 1] .group GROUP 0000000000000000 00000040 0000000000000008 0000000000000004 11 10 4
[ 2] .text PROGBITS 0000000000000000 00000048 000000000000000b 0000000000000000 AX 0 0 1
[...]
[ 6] .text._Z3barv PROGBITS 0000000000000000 00000053 000000000000000b 0000000000000000 AXG 0 0 1

[...]

COMDAT group section [ 1] `.group' [_Z3barIiET_v] contains 1 sections:

[Index] Name

[ 6] .text._Z3barIiET_v

La section [1] est intéressante, puisqu'elle introduit une COMDATgroupsection qui contient une valeur... [6] .text._Z3barv. On apprend donc que cette section séparée est en fait une COMDAT section, qui a quelques particularités : si l'éditeur de lien rencontre deux sections COMDAT avec le même nom, il peut en jeter une (discarded) ; et quand une section COMDAT est jetée, tous les symboles associés sont aussi jetés. C'est donc une version sous stéroïdes des symboles marqués weak.

La version Windows des COMDAT permet également de spécifier une stratégie associée à la fusion des COMDAT, mais ça dépasse le cadre de cet article.

8. Initialisation statique

La code suivant définit un symbole global possédant un petit code d'initialisation :

struct foo {

int m;
 foo() : m{1} {}
} f;

L'inspection de sa table des symboles est pleine de surprises !

 

> g++ static.cpp -c
> nm -C static.o
0000000000000000 B f
000000000000002c t _GLOBAL__sub_I_f
0000000000000000 t __static_initialization_and_destruction_0(int, int)
0000000000000000 W foo::foo()
0000000000000000 W foo::foo()
0000000000000000 n foo::foo()

On retrouve le symbole f, jusque-là rien de bien surprenant. Une méthode définie dans sa classe est sujette à la One Definition Rule là encore, le lien weak n'est pas surprenant. Il est aussi marqué n ce qui semble être un symbole de debug d'après la page de manuel de nm. Plus intéressant, il y a ces deux symboles _GLOBAL__sub_I_f et __static_initialization_and_destruction_0(int, int). En inspectant le .o avec plus d'attention, on apprend que ce sont tous deux des symboles de fonction :

> readelf --syms static.o
[...]
6: 0000000000000000 44 FUNC LOCAL DEFAULT 2 _Z41__static_initializati
7: 000000000000002c 21 FUNC LOCAL DEFAULT 2 _GLOBAL__sub_I_f

[...]

 

Et en les désassemblant, on apprend que _GLOBAL__sub_I_f appelle __static_initialization_and_destruction_0. Et en inspectant le fichier ELF, on voit que :

 

> readelf -a static.o
[...]
Relocation section '.rela.init_array' at offset 0x390 contains 1 entries:
 Offset Info Type Sym. Value Sym. Name + Addend
 000000000000 000200000001 R_X86_64_64 0000000000000000 .text + 2c

ce qui nous apprend que _GLOBAL__sub_I_f est dans la section .init_array. Or les entrées de cette section sont exécutées au chargement du binaire, avant la fonction main, comme se doivent de l'être les constructeurs statiques. Ouf !

On retrouve d'ailleurs ici les embryons du Static Initialization Order Fiasco, puisque ces différents symboles vont se retrouver dans le même binaire, et puisqu'aucune information n'est disponible dans le code pour préciser leur ordre relatif, un ordre sera choisi parmi les possibles, peut-être en fonction de l'ordre des arguments de l'éditeur de lien ?

Conclusion

À peine quelques concepts élémentaires, et déjà pas mal d'interactions avec le format ELF, C++ ne faillit pas à sa réputation et est plein de surprises ! Dans un opus ultérieur, on s'intéressera à d'autres aspects du langage…

Remerciements

J'en profite pour remercier chaleureusement Lancelot Six, Adrien Guinet et Juan Martinez pour leur relecture, merci les gars o/