Всем привет! Перевод статьи подготовлен специально для студентов курса «Разработчик С++». Интересно развиваться в данном направлении? Приходите онлайн 13 декабря в 20:00 по мск. на мастер-класс «Практика использования Google Test Framework»!
В этой статье мы рассмотрим, как clang реализует vtables (таблицы виртуальных методов) и RTTI (идентификацию типа времени исполнения). В первой части мы начнем с базовых классов, а затем рассмотрим множественное и виртуальное наследование.
Обратите внимание, что в этой статье нам предстоит покопаться в двоичном представлении, сгенерированном для различных частей нашего кода, с помощью gdb. Это довольно низкий уровень, но я сделаю всю тяжелую работу за вас. Я не думаю, что большинство будущих постов будут описывать детали такого низкого уровня.
Дисклеймер: все написанное здесь зависит от реализации, может измениться в любой будущей версии, так что не следует на это полагаться. Мы рассматриваем это только в образовательных целях.
отлично, тогда давайте начнем.
Часть 1 — vtables — Основы
Давайте рассмотрим следующий код:
#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
имеет размер 1 байт, потому что в C++ классы не могут иметь нулевой размер. Однако сейчас это не важно.
Размер VirtualClass
составляет 8 байт на 64-битной машине. Почему? Потому что внутри есть скрытый указатель, указывающий на vtable. vtables — это статические таблицы трансляции, создаваемые для каждого виртуального класса. Эта статья рассказывает об их содержании и о том, как они используются.
Чтобы получить более глубокое понимание того, как выглядят vtables, давайте рассмотрим следующий код с помощью gdb, чтобы выяснить, как распределена память:
#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>}
Вот что мы узнали из вышеизложенного:
— Несмотря на то, что у классов нет членов данных, существует скрытый указатель на vtable;
— vtable для p1 и p2 одинаков. vtables — это статические данные для каждого типа;
— d1 и d2 наследуют vtable-указатель от Parent, который указывает на vtable Derived;
— Все vtables указывают на смещение 16 (0x10) байтов в vtable. Это мы также обсудим позже.
Давайте продолжим нашу gdb-сессию, чтобы увидеть содержимое vtables. Я буду использовать команду x, которая выводит память на экран. Мы собираемся вывести 300 байтов в шестнадцатеричном формате, начиная с 0x400b40. Почему именно этот адрес? Потому что выше мы видели, что указатель vtable указывает на 0x400b50, а символ для этого адреса 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
...
Примечание: мы смотрим на де-декорированные (demangled) символы. Если вам действительно интересно, _ZTV — это префикс для vtable, _ZTS — это префикс для строки типа (имени), а _ZTI для typeinfo.
Вот структура vtable Parent
:
Адрес | Значение | Содержание |
---|---|---|
0x400ba8 | 0x0 | top_offset (подробнее об этом позже) |
0x400bb0 | 0x400b78 | Указатель на typeinfo for Parent (также является частью вышеприведенного дампа памяти) |
0x400bb8 | 0x400aa0 | Указатель на Parent::Foo() (1). _vptr Parent указывает сюда. |
0x400bc0 | 0x400a90 | Указатель на Parent::FooNotOverridden() (2) |
Вот структура vtable Derived
:
Адрес | Значение | Содержание |
---|---|---|
0x400b40 | 0x0 | top_offset (подробнее об этом позже) |
0x400b48 | 0x400b90 | Указатель на typeinfo for Derived (также является частью вышеприведенного дампа памяти) |
0x400b50 | 0x400a80 | Указатель на Derived::Foo() (3).,_vptr Derived указывает сюда. |
0x400b58 | 0x400a90 | Указатель на Parent::FooNotOverridden() (такой же, как и у 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
Помните, что указатель vtable в Derived указывал на смещение +16 байтов в vtable? Третий указатель является адресом указателя первого метода. Хотите третий метод? Нет проблем — добавьте 2 sizeof(void) к указателю vtable. Хотите запись typeinfo? перейдите к указателю перед ним.
Двигаемся дальше — как насчет структуры записей typeinfo?
Parent
:
Адрес | Значение | Содержание |
---|---|---|
0x400b78 | 0x602090 | Вспомогательный класс для методов type_info (1) |
0x400b80 | 0x400b69 | Строка, представляющая имя типа (2) |
0x400b88 | 0x0 | 0 означает отсутствие родительской записи typeinfo |
А вот запись typeinfo Derived
:
Адрес | Значение | Содержание |
---|---|---|
0x400b90 | 0x602210 | Вспомогательный класс для методов type_info (3) |
0x400b98 | 0x400b60 | Строка, представляющая имя типа (4) |
0x400ba0 | 0x400b78 | Указатель на запись typeinfo Parent |
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_class_type_info, вы можете найти некоторую информацию здесь, а также здесь.
Это исчерпывает мои навыки с gdb, а также завершает эту часть. Я предполагаю, что некоторые люди сочтут это слишком низким уровнем, или, возможно, просто не имеющим практической ценности. Если это так, я бы порекомендовал пропустить части 2 и 3, перейдя прямо к части 4.
Часть 2 — Множественное наследование
Мир иерархий одиночного наследования проще для компилятора. Как мы видели в первой части, каждый дочерний класс расширяет родительский vtable, добавляя записи для каждого нового виртуального метода.
Давайте рассмотрим множественное наследование, которое усложняет ситуацию, даже когда наследование реализуется только чисто от интерфейсов.
Посмотрим на следующий фрагмент кода:
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;
};
Структура Child |
---|
_vptr$Mother |
mother_data (+ заполнение) |
_vptr$Father |
father_data |
child_data (1) |
Обратите внимание, что есть 2 указателя vtable. Интуитивно я бы ожидал 1 или 3 указателя (Mother, Father и Child). На самом деле невозможно иметь один указатель (подробнее об этом далее), и компилятор достаточно умен, чтобы объединять записи дочерней vtable Child как продолжение vtable Mother, сохраняя таким образом 1 указатель.
Почему у Child не может быть одного указателя vtable для всех трех типов? Помните, что указатель Child может быть передан в функцию, принимающую указатель Mother или Father, и оба будут ожидать, что указатель this будет содержать правильные данные в правильных смещениях. Эти функции не обязательно должны знать о Child, и определенно не следует предполагать, что Child — это действительно то, что находится под указателем Mother/Father, которым они оперируют.
(1) Не имеет отношения к этой теме, но, тем не менее, интересно, что child_data фактически помещается в заполнении Father. Это называется «tail padding» и может быть темой будущего поста.
Вот структура vtable
:
Адрес | Значение | Содержание |
---|---|---|
0x4008b8 | 0 | top_offset (подробнее об этом позже) |
0x4008c0 | 0x400930 | указатель на typeinfo for Child |
0x4008c8 | 0x400800 | Mother::MotherMethod(). _vptr$Mother указывает сюда. |
0x4008d0 | 0x400810 | Child::ChildMethod() |
0x4008d8 | -16 | top_offset (подробнее об этом позже) |
0x4008e0 | 0x400930 | указатель на typeinfo for Child |
0x4008e8 | 0x400820 | Father::FatherMethod(). _vptr$Father указывает сюда. |
В этом примере экземпляр Child будет иметь тот же указатель при приведении к указателю Mother. Но при приведении к указателю Father компилятор вычисляет смещение указателя this, чтобы указать на _vptr$Father часть Child (3-е поле в структуре Child, см. таблицу выше).
Другими словами, для данного Child c;: (void)&c != (void)static_cast<Father*>(&c). Некоторые люди не ожидают этого, и, возможно, однажды эта информация сэкономит вам время на отладку.
Я находил это полезным не один раз. Но подождите, это еще не все.
Что, если Child решил переопределить один из методов Father? Рассмотрим этот код:
class Mother {
public:
virtual void MotherFoo() {}
};
class Father {
public:
virtual void FatherFoo() {}
};
class Child : public Mother, public Father {
public:
void FatherFoo() override {}
};
Ситуация становится сложнее. Функция может принимать аргумент Father* и вызывать FatherFoo() для него. Но если вы передадите экземпляр Child, ожидается, что он вызовет переопределенный метод Child с правильным указателем this. Тем не менее, вызывающий не знает, что он действительно содержит Child. Он имеет указатель на смещение Child, где находится расположение Father. Кто-то должен выполнить смещение указателя this, но как это сделать? Какую магию выполняет компилятор, чтобы заставить это работать?
Прежде чем мы ответим на это, обратите внимание, что переопределение одного из методов Mother не очень мудрено, так как указатель this одинаков. Child знает, что нужно читать после vtable Mother, и ожидает, что методы Child будут сразу после нее.
Вот решение: компилятор создает метод ”переходник” (thunk), который исправляет указатель this, а затем вызывает “настоящий” метод. Адрес метода-переходника будет находиться под vtable Father, в то время как “настоящий” метод будет под vtable Child.
Вот 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
Что означает:
Адрес | Значение | Содержание |
---|---|---|
0x4008e8 | 0 | top_offset (скоро!) |
0x4008f0 | 0x400960 | typeinfo for Child |
0x4008f8 | 0x400800 | Mother::MotherFoo() |
0x400900 | 0x400810 | Child::FatherFoo() |
0x400908 | -8 | top_offset |
0x400910 | 0x400960 | typeinfo for Child |
0x400918 | 0x400820 | не виртуальный переходник Child::FatherFoo() |
Объяснение: как мы видели ранее, у Child есть 2 vtables — одна используется для Mother и Child, а другая для Father. В vtable Father, FatherFoo() указывает на “переходник”, а в vtable Child указывает непосредственно на Child::FatherFoo().
А что в этом “переходнике”, спросите вы?
(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)
Как мы уже обсуждали — смещения this и вызов FatherFoo(). И на сколько мы должны сместить this, чтобы получить Child? top_offset!
Обратите внимание, что я лично считаю, что имя non-virtual thunk чрезвычайно запутанно, поскольку это запись в виртуальной таблице для виртуальной функции. Я не уверен, что тут не виртуального, но это только мое мнение.
Пока на этом всё, в ближайшем будущем переведем 3 и 4 части. Следите за новостями!
Комментарии (6)
monah_tuk
13.12.2019 01:47Я предполагаю, что некоторые люди сочтут это слишком низким уровнем, или, возможно, просто не имеющим практической ценности.
Гнать таких людей в шею. Хотя бы обзорное представление иметь нужно. Особенно сильно пригождается в embedded: мне приходилось анализировать стрельбу по vtable. А то будет возникать недоумение: у меня тут функция non-pure-virtual вызывается, а исключение по нулевому адресу генерируется, КАК ТАКОЕ ВОЗМОЖНО ВООБЩЕ!!!111 Ну или когда вдруг работает код, совершено не тот, который ожидается. Кроме того, просто становится понятным: почему не стоит вызывать виртуальные функции в конструкторе и деструкторе, особенно в базовых классах.
Другое дело, что не стоит полагаться на это при кодировании, завязывать логику и, тем более, какие-то хаки.
nickolaym
13.12.2019 04:46+1Надо бы где-нибудь разжиться спецификацией на ABI C++ и/или документацией на компиляторы, вместо того, чтобы заниматься реверс-инжинерингом "что же там гусь накомпилил".
Моего гугл-фу не хватило найти сейчас спеки на гуся/шланга/студию.
Всё, что нашёл более-менее толкового — это проект документации "Itanium C++ ABI" (там тексты разной степени древности, от 1999 до 2017)
Вот тут — сводная таблица примеров разных наследований и лэяутов таблиц.
https://itanium-cxx-abi.github.io/cxx-abi/cxx-vtable-ex.html
Или вот ещё: https://refspecs.linuxfoundation.org/cxxabi-1.86#vtable
Но там очень многабукв на английском
FoxOne
13.12.2019 11:31Нужно другое представление информации. Вывод gdb плохо читаем, как бы ни старались авторы. Картинки со стрелочками помогли бы.
NightShad0w
Спасибо за перевод. Картинок бы хотелось. А то текстовое описание указателей, указывающих на указывающие на методы указатели немного трудно воспринимать.