Всем привет! Перевод статьи подготовлен специально для студентов курса «Разработчик С++». Интересно развиваться в данном направлении? Приходите онлайн 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)


  1. NightShad0w
    11.12.2019 21:50
    +3

    Спасибо за перевод. Картинок бы хотелось. А то текстовое описание указателей, указывающих на указывающие на методы указатели немного трудно воспринимать.


  1. NeoCode
    11.12.2019 22:29
    +1

    Интересно, это единственный вариант организации vtables (применяемый во всех компиляторах), или есть варианты?


    1. sergegers
      11.12.2019 23:35
      +1

      Реализация виртуального наследования отличается, а обычное более-менее похоже.


  1. monah_tuk
    13.12.2019 01:47

    Я предполагаю, что некоторые люди сочтут это слишком низким уровнем, или, возможно, просто не имеющим практической ценности.

    Гнать таких людей в шею. Хотя бы обзорное представление иметь нужно. Особенно сильно пригождается в embedded: мне приходилось анализировать стрельбу по vtable. А то будет возникать недоумение: у меня тут функция non-pure-virtual вызывается, а исключение по нулевому адресу генерируется, КАК ТАКОЕ ВОЗМОЖНО ВООБЩЕ!!!111 Ну или когда вдруг работает код, совершено не тот, который ожидается. Кроме того, просто становится понятным: почему не стоит вызывать виртуальные функции в конструкторе и деструкторе, особенно в базовых классах.


    Другое дело, что не стоит полагаться на это при кодировании, завязывать логику и, тем более, какие-то хаки.


  1. 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
    Но там очень многабукв на английском


  1. FoxOne
    13.12.2019 11:31

    Нужно другое представление информации. Вывод gdb плохо читаем, как бы ни старались авторы. Картинки со стрелочками помогли бы.