C ++ vtables. Teil 1 (Grundlagen + Mehrfachvererbung)

Hallo allerseits! Die Übersetzung des Artikels wurde speziell für Studierende des Kurses "C ++ Developer" erstellt . Ist es interessant, sich in diese Richtung zu entwickeln? Kommen Sie am 13. Dezember um 20:00 Uhr Moskauer Zeit online. zur Meisterklasse "Üben mit dem Google Test Framework" !



In diesem Artikel werden wir uns ansehen, wie clang vtables (virtuelle Methodentabellen) und RTTI (Runtime Type Identification) implementiert. Im ersten Teil beginnen wir mit den Basisklassen und betrachten dann die mehrfache und virtuelle Vererbung.


Bitte beachten Sie, dass wir in diesem Artikel die Binärdarstellung untersuchen müssen, die mit gdb für verschiedene Teile unseres Codes generiert wurde. Dies ist ein ziemlich niedriges Niveau, aber ich werde die ganze harte Arbeit für Sie tun. Ich glaube nicht, dass die meisten zukünftigen Posts die Details eines so niedrigen Niveaus beschreiben werden.


Haftungsausschluss : Alles, was hier geschrieben wird, hängt von der Implementierung ab und kann sich in zukünftigen Versionen ändern. Sie sollten sich also nicht darauf verlassen. Wir betrachten dies nur zu Bildungszwecken.


ausgezeichnet, dann lass uns anfangen.


Teil 1 - vtables - Grundlagen


Schauen wir uns den folgenden Code an:


#include <iostream> using namespace std; class NonVirtualClass { public: void foo() {} }; class VirtualClass { public: virtual void foo() {} }; int main() { cout << "Size of NonVirtualClass: " << sizeof(NonVirtualClass) << endl; cout << "Size of VirtualClass: " << sizeof(VirtualClass) << endl; } 

 $ #    main.cpp $ clang++ main.cpp && ./a.out Size of NonVirtualClass: 1 Size of VirtualClass: 8 

NonVirtualClass hat eine Größe von 1 Byte, da in C ++ Klassen keine Größe von Null haben können. Dies ist jedoch jetzt nicht wichtig.


VirtualClass ist 8 Byte auf einem 64-Bit-Computer. Warum? Weil sich im Inneren ein versteckter Zeiger befindet, der auf eine vtable zeigt. vtables sind statische Übersetzungstabellen, die für jede virtuelle Klasse erstellt werden. Dieser Artikel spricht über ihren Inhalt und wie sie verwendet werden.


Sehen wir uns den folgenden Code mit gdb an, um herauszufinden, wie Speicher zugewiesen wird, um ein besseres Verständnis für die Darstellung von vtables zu erhalten:


 #include <iostream> class Parent { public: virtual void Foo() {} virtual void FooNotOverridden() {} }; class Derived : public Parent { public: void Foo() override {} }; int main() { Parent p1, p2; Derived d1, d2; std::cout << "done" << std::endl; } 

 $ #         ,  gdb $ clang++ -std=c++14 -stdlib=libc++ -g main.cpp && gdb ./a.out ... (gdb) #  gdb  -  C++ (gdb) set print asm-demangle on (gdb) set print demangle on (gdb) #     main (gdb) b main Breakpoint 1 at 0x4009ac: file main.cpp, line 15. (gdb) run Starting program: /home/shmike/cpp/a.out Breakpoint 1, main () at main.cpp:15 15 Parent p1, p2; (gdb) #     (gdb) n 16 Derived d1, d2; (gdb) #     (gdb) n 18 std::cout << "done" << std::endl; (gdb) #  p1, p2, d1, d2 -     ,    (gdb) p p1 $1 = {_vptr$Parent = 0x400bb8 <vtable for Parent+16>} (gdb) p p2 $2 = {_vptr$Parent = 0x400bb8 <vtable for Parent+16>} (gdb) p d1 $3 = {<Parent> = {_vptr$Parent = 0x400b50 <vtable for Derived+16>}, <No data fields>} (gdb) p d2 $4 = {<Parent> = {_vptr$Parent = 0x400b50 <vtable for Derived+16>}, <No data fields>} 

Folgendes haben wir daraus gelernt:
- Obwohl Klassen keine Datenelemente haben, gibt es einen versteckten Zeiger auf vtable.
- vTabelle für p1 und p2 ist gleich. vtables sind statische Daten für jeden Typ.
- d1 und d2 erben den vtable-Zeiger von Parent, der auf vtable Derived zeigt;
- Alle vtables geben einen Versatz von 16 (0x10) Bytes in der vtable an. Wir werden das später noch besprechen.


Setzen wir unsere gdb-Sitzung fort, um den Inhalt von vtables zu sehen. Ich werde den Befehl x verwenden, der den Speicher auf dem Bildschirm anzeigt. Wir werden 300 Bytes hexadezimal ausgeben, beginnend mit 0x400b40. Warum genau diese Adresse? Weil wir oben gesehen haben, dass der vtable-Zeiger auf 0x400b50 zeigt und das Symbol für diese Adresse vtable for Derived+16 (16 == 0x10) .


 (gdb) x/300xb 0x400b40 0x400b40 <vtable for Derived>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b48 <vtable for Derived+8>: 0x90 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400b50 <vtable for Derived+16>: 0x80 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400b58 <vtable for Derived+24>: 0x90 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400b60 <typeinfo name for Derived>: 0x37 0x44 0x65 0x72 0x69 0x76 0x65 0x64 0x400b68 <typeinfo name for Derived+8>: 0x00 0x36 0x50 0x61 0x72 0x65 0x6e 0x74 0x400b70 <typeinfo name for Parent+7>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b78 <typeinfo for Parent>: 0x90 0x20 0x60 0x00 0x00 0x00 0x00 0x00 0x400b80 <typeinfo for Parent+8>: 0x69 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400b88: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b90 <typeinfo for Derived>: 0x10 0x22 0x60 0x00 0x00 0x00 0x00 0x00 0x400b98 <typeinfo for Derived+8>: 0x60 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400ba0 <typeinfo for Derived+16>: 0x78 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400ba8 <vtable for Parent>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400bb0 <vtable for Parent+8>: 0x78 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400bb8 <vtable for Parent+16>: 0xa0 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400bc0 <vtable for Parent+24>: 0x90 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 ... 

Hinweis: Wir betrachten dekorierte (entwirrte) Charaktere. Wenn Sie wirklich interessiert sind, ist _ZTV das Präfix für vtable, _ZTS das Präfix für den Typ string (name) und _ZTI für typeinfo.



Hier ist die vtable Parent Struktur von vtable Parent :


Die AdresseWertInhalt
0x400ba80x0top_offset (dazu später mehr)
0x400bb00x400b78Zeiger auf TypInfo für Parent (ebenfalls Teil des obigen Speicherauszugs)
0x400bb80x400aa0Zeiger auf Parent :: Foo () (1) . _vptr Übergeordnete Punkte hier.
0x400bc00x400a90Zeiger auf Parent :: FooNotOverridden () (2)

Hier ist die von vtable Derived Struktur:


Die AdresseWertInhalt
0x400b400x0top_offset (dazu später mehr)
0x400b480x400b90Zeiger auf TypInfo für Abgeleitet (auch Teil des obigen Speicherauszugs)
0x400b500x400a80Zeiger auf Abgeleitet :: Foo () (3) ., _ Vptr Abgeleitete Punkte hier.
0x400b580x400a90Zeiger auf Parent :: FooNotOverridden () (wie Parent)

1:


 (gdb) # ,        0x400aa0 (gdb) info symbol 0x400aa0 Parent::Foo() in section .text of a.out 

2:


 (gdb) info symbol 0x400a90 Parent::FooNotOverridden() in section .text of a.out 

3:


 (gdb) info symbol 0x400a80 Derived::Foo() in section .text of a.out 

Denken Sie daran, dass der vtable-Zeiger in Derived auf einen Versatz von +16 Bytes in der vtable zeigte? Der dritte Zeiger ist die Adresse des Zeigers der ersten Methode. Willst du eine dritte Methode? Kein Problem - fügen Sie dem vtable-Zeiger 2 sizeof (void ) hinzu. Möchten Sie einen Typeinfo-Datensatz? Gehe zum Zeiger davor.


Weitermachen - wie sieht es mit der Typeinfo-Datensatzstruktur aus?


Parent :


Die AdresseWertInhalt
0x400b780x602090Hilfsklasse für type_info (1) -Methoden
0x400b800x400b69Eine Zeichenfolge, die den Typnamen darstellt (2)
0x400b880x00 bedeutet, dass kein übergeordneter Typeinfo-Eintrag vorhanden ist

Und hier ist die typeinfo Derived Eintrag:


Die AdresseWertInhalt
0x400b900x602210Hilfsklasse für type_info (3) -Methoden
0x400b980x400b60Zeichenfolge, die den Typnamen darstellt (4)
0x400ba00x400b78Zeiger auf einen TypInfo Parent-Eintrag

1:


 (gdb) info symbol 0x602090 vtable for __cxxabiv1::__class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out 

2:


 (gdb) x/s 0x400b69 0x400b69 <typeinfo name for Parent>: "6Parent" 

3:


 (gdb) info symbol 0x602210 vtable for __cxxabiv1::__si_class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out 

4:


 (gdb) x/s 0x400b60 0x400b60 <typeinfo name for Derived>: "7Derived" 

Wenn Sie mehr über __si_class_type_info erfahren möchten, finden Sie hier und hier einige Informationen.


Dies erschöpft meine Fähigkeiten mit GDB und vervollständigt auch diesen Teil. Ich schlage vor, dass einige Leute dies für zu niedrig oder einfach nicht von praktischem Wert halten. In diesem Fall würde ich empfehlen, die Teile 2 und 3 zu überspringen und direkt zu Teil 4 überzugehen .


Teil 2 - Mehrfachvererbung


Die Welt der einzelnen Vererbungshierarchien ist für den Compiler einfacher. Wie wir im ersten Teil gesehen haben, erweitert jede untergeordnete Klasse die übergeordnete vtable, indem Einträge für jede neue virtuelle Methode hinzugefügt werden.


Betrachten wir die Mehrfachvererbung, die die Situation kompliziert, selbst wenn die Vererbung nur über Schnittstellen implementiert wird.


Schauen wir uns den folgenden Codeausschnitt an:


 class Mother { public: virtual void MotherMethod() {} int mother_data; }; class Father { public: virtual void FatherMethod() {} int father_data; }; class Child : public Mother, public Father { public: virtual void ChildMethod() {} int child_data; }; 

Kinderstruktur
_vptr $ Mutter
Mutter_Daten (+ Auffüllen)
Vater
father_data
child_data (1)

Beachten Sie, dass es 2 vtable-Zeiger gibt. Intuitiv würde ich 1 oder 3 Zeiger erwarten (Mutter, Vater und Kind). Tatsächlich ist es unmöglich, einen Zeiger zu haben (dazu später mehr), und der Compiler ist intelligent genug, um die Einträge der untergeordneten Tabelle Child als Fortsetzung der untergeordneten Tabelle Mother zu kombinieren, wodurch 1 Zeiger gespeichert wird.


Warum kann ein Kind nicht einen vtable-Zeiger für alle drei Typen haben? Denken Sie daran, dass ein untergeordneter Zeiger an eine Funktion übergeben werden kann, die einen Mutter- oder Vaterzeiger akzeptiert, und beide erwarten, dass dieser Zeiger die richtigen Daten an den richtigen Offsets enthält. Diese Funktionen müssen nichts über Child wissen, und Sie sollten definitiv nicht davon ausgehen, dass Child wirklich das ist, was sich unter dem Mutter / Vater-Zeiger befindet, mit dem sie arbeiten.


(1) Es ist für dieses Thema nicht relevant, aber dennoch ist es interessant, dass child_data tatsächlich in die Füllung von Father eingefügt wird. Dies wird als Schwanzpolsterung bezeichnet und ist möglicherweise Gegenstand eines zukünftigen Beitrags.


Hier ist die vtable Struktur:


Die AdresseWertInhalt
0x4008b80top_offset (dazu später mehr)
0x4008c00x400930Zeiger auf TypInfo für Child
0x4008c80x400800Mother :: MotherMethod (). _vptr $ Mutter zeigt hier.
0x4008d00x400810Child :: ChildMethod ()
0x4008d8-16top_offset (dazu später mehr)
0x4008e00x400930Zeiger auf TypInfo für Child
0x4008e80x400820Father :: FatherMethod (). _vptr $ Vater zeigt hier.

In diesem Beispiel hat die Child-Instanz denselben Zeiger, wenn sie auf den Mother-Zeiger umwandelt. Beim Umwandeln in den Vater-Zeiger berechnet der Compiler den Versatz dieses Zeigers, um auf den _vptr $ Vater-Teil des Kindes zu verweisen (3. Feld in der Kind-Struktur, siehe obige Tabelle).


Mit anderen Worten, für ein gegebenes Kind c: (nichtig ) & c! = (Nichtig ) static_cast <Vater *> (& c). Einige Leute erwarten dies nicht, und vielleicht sparen Sie eines Tages mit diesen Informationen einige Zeit beim Debuggen.


Ich fand das mehr als einmal nützlich. Aber warte, das ist noch nicht alles.


Was ist, wenn das Kind beschließt, eine der Methoden des Vaters außer Kraft zu setzen? Betrachten Sie diesen Code:


 class Mother { public: virtual void MotherFoo() {} }; class Father { public: virtual void FatherFoo() {} }; class Child : public Mother, public Father { public: void FatherFoo() override {} }; 

Die Situation wird schwieriger. Die Funktion kann das Argument Father * annehmen und FatherFoo () dafür aufrufen. Wenn Sie jedoch die Child-Instanz übergeben, wird erwartet, dass die überschriebene Child-Methode mit dem richtigen Zeiger aufgerufen wird. Der Anrufer weiß jedoch nicht, dass er wirklich Child enthält. Es hat einen Zeiger auf den Child-Offset, an dem sich der Standort des Vaters befindet. Jemand muss diesen Zeiger versetzen, aber wie geht das? Welche Magie macht der Compiler, um diese Arbeit zu machen?


Beachten Sie, dass das Überschreiben einer der Mother-Methoden nicht sehr schwierig ist, da dieser Zeiger derselbe ist, bevor wir darauf antworten. Child weiß, was nach vtable Mother zu lesen ist, und erwartet, dass Child-Methoden direkt danach sind.


Hier ist die Lösung: Der Compiler erstellt eine Thunk-Methode, die diesen Zeiger korrigiert und dann die "echte" Methode aufruft. Die Adresse der Adaptermethode befindet sich unter der vtable Father, während sich die "echte" Methode unter der vtable Child befindet.


Hier ist der vtable Child :


 0x4008e8 <vtable for Child>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x4008f0 <vtable for Child+8>: 0x60 0x09 0x40 0x00 0x00 0x00 0x00 0x00 0x4008f8 <vtable for Child+16>: 0x00 0x08 0x40 0x00 0x00 0x00 0x00 0x00 0x400900 <vtable for Child+24>: 0x10 0x08 0x40 0x00 0x00 0x00 0x00 0x00 0x400908 <vtable for Child+32>: 0xf8 0xff 0xff 0xff 0xff 0xff 0xff 0xff 0x400910 <vtable for Child+40>: 0x60 0x09 0x40 0x00 0x00 0x00 0x00 0x00 0x400918 <vtable for Child+48>: 0x20 0x08 0x40 0x00 0x00 0x00 0x00 0x00 

Was bedeutet:


Die AdresseWertInhalt
0x4008e80top_offset (kommt bald!)
0x4008f00x400960typeinfo für kind
0x4008f80x400800Mutter :: MotherFoo ()
0x4009000x400810Kind :: FatherFoo ()
0x400908-8top_offset
0x4009100x400960typeinfo für kind
0x4009180x400820kein virtueller Adapter Child :: FatherFoo ()

Erklärung: Wie wir zuvor gesehen haben, hat Child 2 vtables - eine wird für Mutter und Kind und die andere für Vater verwendet. In vtable Father verweist FatherFoo () auf einen „Adapter“ und in vtable Child direkt auf Child :: FatherFoo ().


Und was ist in diesem "Adapter", fragst du?


 (gdb) disas /m 0x400820, 0x400850 Dump of assembler code from 0x400820 to 0x400850: 15 void FatherFoo() override {} 0x0000000000400820 <non-virtual thunk to Child::FatherFoo()+0>: push %rbp 0x0000000000400821 <non-virtual thunk to Child::FatherFoo()+1>: mov %rsp,%rbp 0x0000000000400824 <non-virtual thunk to Child::FatherFoo()+4>: sub $0x10,%rsp 0x0000000000400828 <non-virtual thunk to Child::FatherFoo()+8>: mov %rdi,-0x8(%rbp) 0x000000000040082c <non-virtual thunk to Child::FatherFoo()+12>: mov -0x8(%rbp),%rdi 0x0000000000400830 <non-virtual thunk to Child::FatherFoo()+16>: add $0xfffffffffffffff8,%rdi 0x0000000000400837 <non-virtual thunk to Child::FatherFoo()+23>: callq 0x400810 <Child::FatherFoo()> 0x000000000040083c <non-virtual thunk to Child::FatherFoo()+28>: add $0x10,%rsp 0x0000000000400840 <non-virtual thunk to Child::FatherFoo()+32>: pop %rbp 0x0000000000400841 <non-virtual thunk to Child::FatherFoo()+33>: retq 0x0000000000400842: nopw %cs:0x0(%rax,%rax,1) 0x000000000040084c: nopl 0x0(%rax) 

Wie wir bereits besprochen haben, handelt es sich um Offsets, und FatherFoo () wird aufgerufen. Und um wie viel sollten wir das verschieben, um ein Kind zu bekommen? top_offset!


Bitte beachten Sie, dass ich persönlich den nicht virtuellen Thunk-Namen als äußerst verwirrend empfinde, da es sich um einen virtuellen Tabelleneintrag für eine virtuelle Funktion handelt. Ich bin mir nicht sicher, ob es nicht virtuell ist, aber dies ist nur meine Meinung.




Das ist alles für jetzt, in naher Zukunft werden wir 3 und 4 Teile übersetzen. Befolgen Sie die Nachrichten!

Source: https://habr.com/ru/post/de479802/


All Articles