Exploration des tables de fonctions virtuelles sous héritage virtuel

Héritage virtuel simple

Examinons d’abord un cas sans fonction virtuelle explicite :

#include <iostream>

struct BaseVirt
{
    void afficher() { std::cout << "BaseVirt::afficher()\n"; }
    int donnee_base = 100;
};

struct Derivee : virtual public BaseVirt
{
    void methode() { std::cout << "Derivee::methode()\n"; }
    int donnee_derivee = 200;
};

int main(int argc, char *argv[])
{
    Derivee d;
    d.methode();
    return 0;
}

Bien qu’aucune fonction virtuelle ne soit déclarée, le compilateur génère tout de même une table virtuelle (vtable). Compiler Explorer permet de le confirmer visuellement.

Pourquoi une vtable est-elle nécessaire en l’absence de fonctions virtuelles ? La réponse se trouve dans l’organisation mémoire et les informations qu’elle contient.

▲ Figure 1 – Disposition mémoire d’un objet et de sa vtable en héritage virtuel simple

vbase_offset

Considérons le code suivant :

Derivee *pd = new Derivee;
pd->afficher();
delete pd;

L’appel à afficher() via un pointeur sur la classe dérivée nécessite un ajustement du pointeur this. Le code assembleur correspondant illustre ce mécanisme :

movq    -24(%rbp), %rax  ; chargement du pointeur pd (0x7fffffffdf60)
movq    (%rax), %rax     ; déréférencement pour obtenir les 8 premiers octets de l’objet,
                         ; soit l’adresse vtable_for_Derivee+24 (0x555555557d58)
subq    $24, %rax        ; soustraction de 24 pour pointer vers le vbase_offset
movq    (%rax), %rax     ; obtention du vbase_offset (12)
movq    %rax, %rdx       
movq    -24(%rbp), %rax  ; récupération de l’adresse de l’objet (this)
addq    %rdx, %rax       ; this = this + vbase_offset = 0x7fffffffdf60 + 12 = 0x7fffffffdf6c
movq    %rax, %rdi       
call    BaseVirt::afficher()

L’accès au sous-objet de la classe de base virtuelle nécessite donc un décalage dynamique stocké dans la vtable : le vbase_offset. Sa présence est indispensable, comme nous le verrons plus tard.

VTT

En l’absence de fonctions virtuelles, le pointeur vptr ne désigne pas le début de la vtable. Il pointe ici vers l’extérieur de la vtable (offset +24), exactement à l’entrée d’une structure appelée VTT (Virtual Table Table). La VTT est une table de pointeurs vers des vtables. Dans notre exemple, elle ne contient qu’une seule entrée : vtable for Derivee + 24. Ainsi, le vptr pointe vers une adresse qui contient elle-même la valeur de ce vptr (0x555555557d58). Ce comportement, surprenant au premier abord, sera clarifié lors de l’étude de l’héritage en diamant.

La structure __vmi_class_type_info

Nous nous concentrons sur les membres qui diffèrent de l’exemple précédent.

__flags & __base_count

  • __flags : aucune base répétée ni diamant, donc la valeur est 0.
  • __base_count : une seule classe de base directe, donc 1.

__base_class_type_info::offset_flags

L’ABI Itanium C++ indique que les bits de poids fort de __offset_flags représentent un décalage signé. Pour une base virtuelle, il s’agit de l’offset (négatif) entre l’adresse du vbase_offset et celle du vptr. Dans la Figure 1, l’adresse du vbase_offset est 0x555555557d40, celle du vptr est 0x555555557d58, la différence est -24. La valeur de __offset_flags est 0xffffffffffffe803 ; en la décalant de 8 bits vers la droite, on obtient 0xffffffffffffe8, soit -24 en complément à deux. Les 8 bits de poids faible indiquent les flags (ici __virtual_mask | __public_mask = 0x03).

Pourquoi une vtable est-elle nécessaire ?

Même sans fonction virtuelle déclarée, une vtable est générée pour stocker des informations essentielles à l’exécution, comme le vbase_offset pour les conversions de pointeur vers la base virtuelle, ou les informations de type pour dynamic_cast.

Héritage en diamant

Exemple de code :

class GrandParent
{
public:
    GrandParent() {}
    virtual ~GrandParent() {}
    virtual void foo() {}
    virtual void zoo() {}
    int donnee_gp = 100;
};

class Parent1 : virtual public GrandParent
{
public:
    Parent1() {}
    virtual ~Parent1() {}
    virtual void foo() override {}
    int donnee_p1 = 200;
};

class Parent2 : virtual public GrandParent
{
public:
    Parent2() {}
    virtual ~Parent2() {}
    virtual void zoo() override {}
    int donnee_p2 = 300;
};

class Enfant : public Parent1, public Parent2
{
public:
    Enfant() {}
    virtual ~Enfant() {}
    int donnee_enfant = 400;
};

int main()
{
    Enfant *p_enfant = new Enfant;
    Parent1 *p_p1 = p_enfant;
    GrandParent *p_gp1 = p_p1;
    p_gp1->foo();

    Parent1 *p_parent1 = new Parent1;
    GrandParent *p_gp2 = p_parent1;
    p_gp2->foo();

    delete p_enfant;
    delete p_parent1;
    return 0;
}

Nécessité de la VTT

La VTT est une liste de pointeurs de vtables utilisée pendant la construction et la destruction. Analysons la construction d’un objet Enfant.

▲ Figure 2 – Disposition de la VTT et des vtables associées

(cod = complete object destructor, dd = deleting destructor)

Construction pas à pas

Étape 1 : construction du sous-objet virtuel GrandParent

L’allocation mémoire et l’appel au constructeur de la base virtuelle produisent la disposition illustrée ci-dessous.

▲ Figure 3 – Sous-objet virtuel et sa vtable après construction

Étape 2 : construction de Parent1

Lors de la construction de Parent1, le compilateur utilise une vtable de construction (construction vtable) spécifique, différente de la vtable de l’objet complet. La raison est que la disposition mémoire de Parent1 en tant que sous-objet diffère de celle d’un objet Parent1 complet : le décalage vers la base virtuelle GrandParent n’est pas le même (32 octets ici contre 16 pour l’objet complet). Les vtables de construction contiennent donc les décalages corrects pour cette phase transitoire.

Le passage du constructeur de Enfant à celui de Parent1 fournit un deuxième paramètre (pointeur dans la VTT) qui permet de retrouver les vtables de construction adéquates.

Enfant::Enfant() [complete object constructor]:
       pushq   %rbx
       movq    %rdi, %rbx
       leaq    32(%rdi), %rdi
       call    GrandParent::GrandParent() [base object constructor]
       movq    %rbx, %rdi
       movl    $VTT for Enfant+8, %esi
       call    Parent1::Parent1() [base object constructor]

Parent1::Parent1() [base object constructor]:
       movq    (%rsi), %rax       ; vptr de construction pour Parent1-dans-Enfant
       movq    8(%rsi), %rdx      ; vptr de construction pour la base virtuelle
       movq    %rax, (%rdi)
       movq    -24(%rax), %rax    ; récupération du vbase_offset (32)
       movq    %rdx, (%rdi,%rax)  ; placement du vptr de la base virtuelle
       movl    $200, 8(%rdi)
       ret

▲ Figure 4 – Disposition après construction de Parent1 en tant que sous-objet, avec construction vtable

▲ Figure 5 – Disposition de Parent1 en tant qu’objet complet

La VTT et les vtables de construction sont également utilisées lors de la destruction, processus inverse de la construction.

Analyse des entrées de la vtable

Destructeur virtuel nul dans les construction vtables

Dans les construction vtables, l’entrée du destructeur virtuel est mise à zéro par GCC (comportement non standard, absent chez Clang). Cela empêche vraisemblablement des doubles libérations accidentelles si un destructeur virtuel était appelé durant la construction d’une autre base.

Rôle de vbase_offset

Considérons la conversion d’un pointeur Parent1* vers GrandParent*. Le compilateur ne peut pas connaître à l’avance si Parent1 est un objet complet ou un sous-objet. Le décalage vers la base virtuelle est donc lu dynamiquement dans la vtable via vbase_offset.

Extrait assembleur :

movq    0(%rbp), %rax   ; vptr de Parent1
movq    -24(%rax), %rdi ; vbase_offset (32)
addq    %rbp, %rdi      ; this ajusté pour pointer sur le sous-objet virtuel
movq    (%rdi), %rax    
call    *16(%rax)       

▲ Figure 6 – Disposition de l’objet Enfant et de sa vtable

Les deux types de constructeurs

Le complete object constructor est responsable de la construction de l’objet entier et de ses bases ; il appelle les base object constructor en leur passant en second paramètre l’adresse dans la VTT. Ces derniers se chargent d’initialiser les vptr adéquats.

vcall_offset expliqué

Quand une fonction virtuelle est appelée sur un pointeur de base virtuelle, il faut parfois ajuster this vers la classe dérivée qui a redéfini la fonction. Ce rôle est dévolu aux fonctions thunk qui utilisent un vcall_offset stocké dans la vtable de la base virtuelle.

Par exemple, pour foo() redéfini dans Parent1, le thunk exécute :

virtual thunk to Parent1::foo():
    movq    (%rdi), %r10      
    addq    -32(%r10), %rdi   ; application du vcall_offset (-32)
    jmp     .LTHUNK2          

Chaque fonction virtuelle redéfinie possède un vcall_offset potentiellement différent, car le décalage vers la classe dérivée concernée varie. Dans la Figure 6, pour foo() le décalage est -32, pour zoo() (redéfini par Parent2) il est -16, et pour les destructeurs également -32. Lorsqu’aucune redéfinition n’a lieu (cas de Parent1 complet pour zoo()), le vcall_offset est mis à 0.

Étiquettes: C++ vtable Itanium ABI Inheritance virtual inheritance

Publié le 4 septembre à 23h10