Cet article est le second d'une mini-série sur le C++, ou plutôt sur les binaires compilés depuis C++, leurs particularités, 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 cette fois, nous allons nous intéresser aux objets, à l'héritage, aux fonctions virtuelles et à comment ces concepts se retrouvent dans le fichier ELF final — on restera dans le monde Linux. Le compilateur utilisé est clang++ 5.0, et c’est le standard C++11 qui est utilisé. Et enfin, on cible du x86 64 bits.
1. Un objet, c’est bien peu de choses
Les concepts de classe, d'héritage, de fonctions membres virtuelles n'existent pas au niveau assembleur. Ils ne sont même pas présents explicitement au niveau LLVM, c'est dire. Parfois, ils ne changent rien, comme la l’attribut de fonction membre override, ou les visibilités public, private, etc. Parfois, ils laissent une trace dans le binaire, et c'est ça qui va nous intéresser.
Commençons simplement, avec un constructeur par défaut :
struct S {
int field;
} s;
Si je compile ce code avec GCC, comme j'ai déclaré une variable globale, j'obtiens :
s:
.zero 4
.size s, 4
Si je remplace le constructeur par une factory pour appeler le constructeur dynamiquement :
struct S {
int field;
};
S make_s() {
return {};
}
On obtient (en compilant avec clang++ -O2 a.cc -S -o - -std=c++11 -masm=intel | grep -E -v '^\s*[\.#]' | sed 's/ *#.*//' un code qui peut paraître troublant :
_Z6make_sv:
xor eax, eax
ret
OK pour le xor qui donne une valeur par défaut au champ field, mais on remarque que la valeur de retour se confond avec l'unique membre de S. Cela correspond au fait que la notion de type a disparu, notre objet n'est plus qu'une séquence de champs qui tient là dans un registre. Si on enrichit un peu notre objet :
#include <tuple>
std::tuple<int, double> make_tuple() {
return {};
}
On obtient :
_Z10make_tuplev:
xorps xmm0, xmm0
xor eax, eax
ret
On notera que même si on s’attend, pour notre architecture, à ce que le tuple fasse 4 + 8 = 12 octets, un sizeof nous confirme qu’elle en fait 16, il y a eu du padding.
Et si notre objet grossit un peu, on ne se contente plus de passer une valeur de retour, mais on modifie un pointeur vers l'objet non initialisé, passé en paramètre :
#include <tuple>
std::tuple<int, double[2]> make_tuple() {
return {};
}
Se compile en :
_Z10make_tuplev:
xorps xmm0, xmm0
movups xmmword ptr [rdi], xmm0
mov dword ptr [rdi + 16], 0
mov rax, rdi
ret
On l'a compris, une classe, ce n'est jamais rien d'autre qu'un paquet d'octets rangés les uns à côté des autres. On notera au passage que l'utilisation d'un std::tuple<...> est correctement optimisée par le compilateur.
2. L'héritage Mitsouko
Si un objet est équivalent à la suite de ses variables membres, qu'en est-il d'un objet dont la classe est définie par héritage ? On a un premier élément de réponse avec le code de la section précédente, puisque dans la libstdc++, les tuples sont définis par héritage : quand on hérite d'une classe, on ajoute (récursivement) les champs de la classe héritée aux nôtres, comme l'illustre ce petit exemple :
struct P {
double p ;
};
struct Q {
int p ;
};
struct R {
double p ;
};
struct S : P, Q, R {
};
S make_s() {
return {};
}
Qui une fois compilé, donne :
_Z6make_sv:
xorps xmm0, xmm0
movups xmmword ptr [rdi], xmm0
mov qword ptr [rdi + 16], 0
mov rax, rdi
ret
Toutes les règles d'héritage virtuel ne changent rien à cette organisation de la mémoire. Du moins tant qu'il n'y a pas de fonctions virtuelles, mais on verra ça un peu plus loin.
3. Les fonctions membres
Les familiers de Python seront certainement ravis d'observer le résultat de la compilation de la fonction suivante :
class S {
int field;
public:
__attribute__((noinline)) int get() const { return field; }
};
int foo(S s) {
return s.get();
}
Et là, stupeur et tremblements :
_Z3foo1S:
push rax
mov dword ptr [rsp], edi
mov rdi, rsp
call _ZNK1S3getEv
pop rcx
ret
_ZNK1S3getEv:
mov eax, dword ptr [rdi]
ret
La fonction S::get() const prend en premier paramètre, à travers rdi, la valeur de this.
4. Les fonctions virtuelles
Ahhh les fonctions virtuelles. Elles ont fait la gloire de Java et permettent de briller en société en parlant de vtable. Le principe est simple. Si on a un appel de méthode virtuelle, la résolution de méthode se fait à l'exécution et non à la compilation :
struct S {
virtual int get() const;
};
int foo(S& s) {
return s.get();
}
Le code compilé est assez curieux au premier abord :
_Z3fooR1S:
mov rax, qword ptr [rdi]
jmp qword ptr [rax]
Le mov déréférence le pointeur passé en paramètre (oui, une référence n'est jamais qu'un pointeur forcément initialisé) et la valeur obtenue est utilisée pour faire un appel de fonction indirect à travers un jmp. Ce premier déréférencement correspond à l'accès à la vtable, comme on peut le voir si on crée une instance de classe dérivant de S :
struct S {
virtual int get() const;
};
struct P : S {
virtual int get() const { return 1; }
};
P make_p() {
return {};
}
Le code compilé pour la fonction make_p n'est pas aussi vide qu'on l'y croirait :
_Z6make_pv:
mov qword ptr [rdi], _ZTV1P+16
mov rax, rdi
ret
_ZNK1P3getEv:
mov eax, 1
ret
[...]
_ZTV1P:
.quad 0
.quad _ZTI1P
.quad _ZNK1P3getEv
.size _ZTV1P, 24
.type _ZTS1P,@object
.section .rodata._ZTS1P,"aG",@progbits,_ZTS1P,comdat
.weak _ZTS1P
_ZTS1P:
.asciz "1P"
.size _ZTS1P, 3
.type _ZTI1P,@object
.section .rodata._ZTI1P,"aG",@progbits,_ZTI1P,comdat
.weak _ZTI1P
_ZTI1P:
.quad _ZTVN10__cxxabiv120__si_class_type_infoE+16
.quad _ZTS1P
.quad _ZTI1S
.size _ZTI1P, 24
Alors il y a pas mal de choses à dire. Si on regarde _Z6make_pv, on voit que l'objet P n'est pas vide, mais contient un champ, qui est initialisé à _ZTV1P+16, soit _ZNK1P3getEv, l'adresse de la fonction à effectivement appeler dans le cas d'un appel virtuel.
On notera aussi que _ZTV1P+8 contient les fameuses RTTI les RunTime Type Informations. D'ailleurs, si on utilise le drapeau de compilation -fno-rtti, cette valeur est bien mise à 0.
On parle de virtual table parce qu'il y a une entrée pour chaque fonction virtuelle, comme dans le cas suivant :
struct S {
virtual int get() const;
virtual int& get();
};
struct P : S {
int n;
virtual int get() const { return n; }
virtual int& get() { return n; }
};
P make_p() {
return {};
}
Qui, une fois compilé et un peu nettoyé, donne :
_Z6make_pv:
mov qword ptr [rdi + 8], 0
mov qword ptr [rdi], _ZTV1P+16
mov rax, rdi
ret
_ZNK1P3getEv:
mov eax, dword ptr [rdi + 8]
ret
_ZN1P3getEv:
lea rax, [rdi + 8]
ret
_ZTV1P:
.quad 0
.quad 0
.quad _ZNK1P3getEv
.quad _ZN1P3getEv
.size _ZTV1P, 32
On identifie rapidement dans _ZTV1P+16 les adresses des deux fonctions virtuelles de P. Voilà pour les fonctions virtuelles !
5. Fonctions virtuelles et héritage multiple
Si on combine ce qu'on a découvert sur les vtable et sur l'héritage, on peut facilement prédire ce qui se passera lors de la compilation de ce fragment de code :
struct P {
virtual int get() const { return 1;}
};
struct S {
int field;
virtual int &get() { return field;}
};
struct F : P, S {
};
F make_f() {
return {};
}
A priori, on devrait avoir un champ dans F, ainsi que l'initialisation de la vtable de P et celle de S. Vérifions :
_Z6make_fv:
mov qword ptr [rdi + 16], 0
mov eax, _ZTV1F+40
movq xmm0, rax
mov eax, _ZTV1F+16
movq xmm1, rax
punpcklqdq xmm1, xmm0
movdqu xmmword ptr [rdi], xmm1
mov rax, rdi
ret
_ZNK1P3getEv:
mov eax, 1
ret
_ZN1S3getEv:
lea rax, [rdi + 8]
ret
_ZTV1F:
.quad 0
.quad _ZTI1F
.quad _ZNK1P3getEv
.quad -8
.quad _ZTI1F
.quad _ZN1S3getEv
.size _ZTV1F, 48
Et voilà ! Une table virtuelle pour _ZTV1F+40 qui stocke _ZN1S3getEv et une pour _ZTV1F+16 qui stocke _ZNK1P3getEv. D'ailleurs, un static_assert(sizeof(F) == 24, "large!") nous garantit que notre petit objet, même s'il ne contient qu'un seul champ field de 4 octets sur ma machine, contient aussi des champs cachés : les deux adresses des vtables (8 octets chacune). Les 4 octets restants sont là pour l'alignement, d'ailleurs si on ajoute la directive #pragma pack(1), on obtient sizeof(F) == 20.
Conclusion
Déjà, merci à Lancelot Six, Paul Blottière et Adrien Guinet pour leurs relectures attentives /o/. Et en guise de conclusion, on ne peut qu'apprécier ce fameux principe du costless abstraction en C++. Par rapport à la complexité du langage, l'assembleur généré est d'une surprenante légèreté !