Hola a todos! La traducción del artículo fue preparada especialmente para estudiantes del curso "Desarrollador C ++" . ¿Es interesante desarrollar en esta dirección? Entra en línea el 13 de diciembre a las 20:00 hora de Moscú. a la clase magistral "Practica usando el marco de prueba de Google" !

En este artículo, veremos cómo clang implementa vtables (tablas de métodos virtuales) y RTTI (identificación de tipo de tiempo de ejecución). En la primera parte, comenzamos con las clases base, y luego observamos la herencia múltiple y virtual.
Tenga en cuenta que en este artículo tenemos que profundizar en la representación binaria generada para varias partes de nuestro código usando gdb. Este es un nivel bastante bajo, pero haré todo el trabajo duro por usted. No creo que la mayoría de las publicaciones futuras describan los detalles de un nivel tan bajo.
Descargo de responsabilidad : todo lo escrito aquí depende de la implementación, puede cambiar en cualquier versión futura, por lo que no debe confiar en él. Consideramos esto solo con fines educativos.

excelente, entonces comencemos.
Parte 1 - vtables - Conceptos básicos
Veamos el siguiente código:
#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
tiene un tamaño de 1 byte, porque en C ++ las clases no pueden tener un tamaño de cero. Sin embargo, esto no es importante ahora.
VirtualClass
tiene 8 bytes en una máquina de 64 bits. Por qué Porque dentro hay un puntero oculto que apunta a una vtable. Las vtables son tablas de traducción estática creadas para cada clase virtual. Este artículo habla sobre su contenido y cómo se usan.
Para obtener una comprensión más profunda de cómo se ve vtables, veamos el siguiente código con gdb para descubrir cómo se asigna la memoria:
#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>}
Esto es lo que aprendimos de lo anterior:
- A pesar de que las clases no tienen miembros de datos, hay un puntero oculto a vtable;
- vtable para p1 y p2 es lo mismo. vtables son datos estáticos para cada tipo;
- d1 y d2 heredan el puntero vtable de Parent, que apunta a vtable Derived;
- Todas las vtables indican un desplazamiento de 16 (0x10) bytes en la vtable. También discutiremos esto más adelante.
Continuemos nuestra sesión de gdb para ver el contenido de vtables. Usaré el comando x, que muestra la memoria en la pantalla. Vamos a generar 300 bytes en hexadecimal, comenzando con 0x400b40. ¿Por qué exactamente esta dirección? Porque vimos anteriormente que el puntero vtable apunta a 0x400b50, y el símbolo de esta dirección es 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 ...
Nota: nos fijamos en los personajes decodificados (demandados). Si está realmente interesado, _ZTV es el prefijo para vtable, _ZTS es el prefijo para la cadena de tipo (nombre) y _ZTI para typeinfo.
Aquí está la estructura principal de vtable Parent
:
Aquí está la estructura vtable Derived
:
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
¿Recuerdas que el puntero de vtable en Derived apuntaba a un desplazamiento de +16 bytes en vtable? El tercer puntero es la dirección del puntero del primer método. ¿Quieres un tercer método? No hay problema: agregue 2 sizeof (void ) al puntero vtable. ¿Quieres un registro typeinfo? ve al puntero delante de él.
Continuando: ¿qué pasa con la estructura de registros de typeinfo?
Parent
:
Y aquí está la entrada typeinfo Derived
:
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"
Si desea saber más sobre __si_class_type_info, puede encontrar información aquí y aquí .
Esto agota mis habilidades con gdb y también completa esta parte. Sugiero que algunas personas consideren esto demasiado bajo, o tal vez simplemente no tenga un valor práctico. Si es así, recomendaría omitir las partes 2 y 3, yendo directamente a la parte 4 .
Parte 2 - Herencia múltiple
El mundo de las jerarquías de herencia única es más fácil para el compilador. Como vimos en la primera parte, cada clase secundaria extiende la vtable primaria agregando entradas para cada nuevo método virtual.
Veamos la herencia múltiple, lo que complica la situación, incluso cuando la herencia se implementa solo desde las interfaces.
Veamos el siguiente fragmento de código:
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; };
Tenga en cuenta que hay 2 punteros vtable. Intuitivamente, esperaría 1 o 3 punteros (Madre, Padre e Hijo). De hecho, es imposible tener un puntero (más sobre esto más adelante), y el compilador es lo suficientemente inteligente como para combinar las entradas del niño vtable Child como una continuación de vtable Mother, ahorrando así 1 puntero.
¿Por qué un niño no puede tener un puntero vtable para los tres tipos? Recuerde que un puntero secundario se puede pasar a una función que acepte un puntero materno o paterno, y ambos esperarán que este puntero contenga los datos correctos en los desplazamientos correctos. Estas funciones no necesitan saber sobre el Niño, y definitivamente no debe suponer que el Niño es realmente lo que está debajo del puntero Madre / Padre con el que operan.
(1) No es relevante para este tema, pero, sin embargo, es interesante que child_data se coloque realmente en el relleno del Padre. Esto se llama relleno de cola y puede ser el tema de una publicación futura.
Aquí está la estructura vtable
:
En este ejemplo, la instancia secundaria tendrá el mismo puntero cuando se lance al puntero Madre. Pero cuando se convierte al puntero Padre, el compilador calcula el desplazamiento de este puntero para apuntar a la parte _vptr $ Padre del Niño (tercer campo en la estructura del Niño, vea la tabla anterior).
En otras palabras, para un Niño dado c;: (void ) & c! = (Void ) static_cast <Father *> (& c). Algunas personas no esperan esto, y quizás algún día esta información le ahorrará algún tiempo de depuración.
He encontrado esto útil más de una vez. Pero espera, eso no es todo.
¿Qué pasa si el Niño decide anular uno de los métodos del Padre? Considera este código:
class Mother { public: virtual void MotherFoo() {} }; class Father { public: virtual void FatherFoo() {} }; class Child : public Mother, public Father { public: void FatherFoo() override {} };
La situación se está poniendo más difícil. La función puede tomar el argumento Father * y llamar a FatherFoo () para ello. Pero si pasa la instancia Child, se espera que llame al método Child anulado con el puntero correcto. Sin embargo, la persona que llama no sabe que realmente contiene a Child. Tiene un puntero al desplazamiento del Niño, donde está la ubicación del Padre. Alguien tiene que compensar este puntero, pero ¿cómo hacerlo? ¿Qué magia hace el compilador para que esto funcione?
Antes de responder a esto, tenga en cuenta que anular uno de los métodos Mother no es muy complicado, ya que este puntero es el mismo. El niño sabe qué leer después de vtable Madre, y espera que los métodos del Niño estén justo después.
Aquí está la solución: el compilador crea un método thunk que corrige el puntero this y luego llama al método "real". La dirección del método del adaptador estará debajo de vtable Father, mientras que el método "real" estará debajo de vtable Child.
Aquí está el 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
Qué significa
Explicación: como vimos anteriormente, Child tiene 2 vtables: una se usa para Mother and Child y la otra para Father. En vtable Father, FatherFoo () apunta a un "adaptador", y en vtable Child apunta directamente a Child :: FatherFoo ().
¿Y qué hay en este "adaptador", preguntas?
(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)
Como ya hemos discutido, esto es compensaciones y se llama a FatherFoo (). ¿Y cuánto deberíamos cambiar esto para obtener Child? top_offset!
Tenga en cuenta que personalmente considero que el nombre de thunk no virtual es extremadamente confuso, ya que es una entrada de tabla virtual para una función virtual. No estoy seguro de que no sea virtual, pero esta es solo mi opinión.
Eso es todo por ahora, en el futuro cercano traduciremos 3 y 4 partes. Sigue las noticias!