Идентификация виртуальных функций
А мы летим орбитами, путями неизбитыми,Прошит метеоритами простор.Оправдан риск и мужество, космическая музыка
Вплывает в деловой наш разговор.
"Трава у дома" Земляне
Виртуальная функция по определению обозначает "определяемая по время выполнения программы". При вызове виртуальной функции выполняемый код должен соответствовать динамическому типу объекта, из которого вызывается функция. Поэтому, адрес виртуальной функции не может быть определен на стадии компиляции – это приходится делать непосредственно в момент ее вызова. Вот почему вызов виртуальной функции – всегда косвенный
вызов (исключение составляют лишь виртуальные функции статических объектов, - см. "Статическое связывание").
В то время как не виртуальные функции вызываются в точности так же, как и обычные Си-функции, вызов виртуальных функций кардинально отличается. Конкретная схема зависит от реализации конкретного компилятора, но общем случае ссылки на все виртуальные функции помещаются в специальный массив – виртуальную таблицу (virtual table –
сокращенно VTBL), а в каждый экземпляр объекта, использующий хотя бы одну виртуальную функцию, помещается указатель на виртуальную таблицу (virtual table pointer – сокращенно VPRT). Причем, независимо от числа виртуальный функций, каждый объект имеет только один указатель.
Вызов виртуальных функций всегда происходит косвенно, через ссылку на виртуальную таблицу – например: CALL [EBX+0х10], где EBX
– регистр, содержащий смещение виртуальной таблицы в памяти, а 0x10 – смещение указателя на виртуальную функцию внутри виртуальной таблицы.
Анализ вызова виртуальных функций наталкивается на ряд сложностей, самая коварная из которых, – необходимость обратной трассировки кода для отслеживания значения регистра, используемого для косвенной адресации. Хорошо, если он инициализируется непосредственным значением типа "MOV EBX, offset VTBL" недалеко от места использования, но значительно чаще указатель на VTBL передается функции как неявный аргумент или (что еще хуже) один и тот же указатель используется для вызова двух различных виртуальных функций и возникает неопределенность – какое именно значение (значения) он имеет в данной ветке программы?
Разберем следующий пример ( предварительно вспомнив, что если одна и та же не виртуальная функция присутствует и базовом, и в производном классе – всегда вызывается функция базового класса).
#include <stdio.h>
class Base{
public:
virtual void demo(void)
{
printf("BASE\n");
};
virtual void demo_2(void)
{
printf("BASE DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual BASE DEMO 3\n");
};
};
class Derived: public Base{
public:
virtual void demo(void)
{
printf("DERIVED\n");
};
virtual void demo_2(void)
{
printf("DERIVED DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual DERIVED DEMO 3\n");
};
};
main()
{
Base *p = new Base;
p->demo();
p->demo_2();
p->demo_3();
p = new Derived;
p->demo();
p->demo_2();
p->demo_3();
}
Листинг 24 Демонстрация вызова виртуальных функций
Результат ее компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push esi
push 4
call ??2@YAPAXI@Z ; operator new(uint)
; EAX c- указатель на выдел. блок памяти
; Выделяем четыре байта памяти для экземпляра нового объекта.
; Объект состоит из одного лишь указателя на VTBL.
add esp, 4
test eax, eax
jz short loc_0_401019 ; --> Ошибка выделения памяти
; проверка успешности выделения памяти
mov dword ptr [eax], offset BASE_VTBL
; Вот здесь в только что созданный экземпляр объекта копируется
; указатель на виртуальную таблицу класса BASE.
; То, что это именно виртуальная таблица класса BASE, можно узнать
; проанализировав элементы этой таблицы – они указывают на члены
; класса BASE, следовательно, сама таблица – виртуальная таблица
; класса BASE
mov esi, eax ; ESI = **BASE_VTBL
; заносим в ESI указатель на экземпляр объекта (указатель на указатель
; на BASE_VTBL
; Зачем? Дело в том, что на самом деле в ESI
заносится указатель на
; экземпляр объекта (см. "Идентификация объектов, структур и массивов),
; но нам на данном этапе все эти детали ни к чему, поэтому, мы просто
; говорим, что в ESI – указатель на указатель на виртуальную таблицу
; базового класса, не вникая для чего понадобился этот двойной указатель.
jmp short loc_0_40101B
loc_0_401019: ; CODE XREF: sub_0_401000+Dj
xor esi, esi
; принудительно обнуляем указатель на экземпляр объекта (эта ветка получает управление
; только в случае неудачного выделения памяти для объекта) нулевой указатель
; словит обработчик структурных исключений при первой же попытке обращения
loc_0_40101B: ; CODE XREF: sub_0_401000+17j
mov eax, [esi] ; EAX = *BASE_VTBL == *BASE_DEMO
; заносим в EAX указатель на виртуальную таблицу класса BASE,
; не забывая о том, что указатель на виртуальную таблицу одновременно
; является указателем и на первый элемент этой таблицы.
; А первый элемент виртуальной таблицы, содержащий указатель
; на первую (в порядке объявления) виртуальную функцию класса.
mov ecx, esi ; ECX = this
; заносим в ECX указатель на экземпляр объекта, передавая вызываемой функции
; неявный аргумент – указатель this
(см. "Идентификация аргументов функций")
call dword ptr [eax] ; CALL BASE_DEMO
; Вот он – вызов виртуальной функции! Чтобы понять – какая именно функция
; вызывается, мы должны знать значение регистра EAX. Прокручивая экран
; дизассемблера вверх, мы видим – EAX
указывает на BASE_VTBL, а первый
; член BASE_VTBL
(см. ниже) указывает на функцию BASE_DEMO. Следовательно:
; а) этот код вызывает именно функцию BASE_DEMO
; б) функция BASE_DEMO
– это виртуальная
функция
mov edx, [esi] ; EDX = *BASE_DEMO
; заносим в EDX указатель на первый элемент виртуальной таблицы класса BASE
mov ecx, esi ; ECX = this
; заносим в ECX указатель на экземпляр объекта
; Это неявный аргумент функции – указатель this
(см. "Идентификация this")
call dword ptr [edx+4] ; CALL [BASE_VTBL+4] (BASE_DEMO_2)
; Еще один вызов виртуальной функции! Чтобы понять – какая именно функция
; вызывается, мы должны знать содержимое регистра EDX. Прокручивая экран
; дизассемблера вверх, мы видим, что он указывает на BASE_VTBL, а EDX+4,
; стало быть, указывает на второй элемент виртуальной таблицы класса BASE.
; Он же, в свою очередь, указывает на функцию BASE_DEMO_2
push offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"
call printf
; а вот вызов не виртуальной функции. Обратите внимание – он происходит
; как и вызов обычной Си функции. (Обратите внимание, что эта функция -
; встроенная, т.к. объявленная непосредственно в самом классе и вместо ее
; вызова осуществляется подстановка кода)
push 4
call ??2@YAPAXI@Z ; operator new(uint)
; Далее идет вызов функций класса DERIVED. Не будем здесь подробно
; его комментировать – сделайте это самостоятельно. Вообще же, класс
; DERIVED
понадобился только для того, чтобы показать особенности компоновки
; виртуальных таблиц
add esp, 8 ; Очистка после printf
& new
test eax, eax
jz short loc_0_40104A ; Ошибка выделения памяти
mov dword ptr [eax], offset DERIVED_VTBL
mov esi, eax ; ESI == **DERIVED_VTBL
jmp short loc_0_40104C
loc_0_40104A: ; CODE XREF: sub_0_401000+3Ej
xor esi, esi
loc_0_40104C: ; CODE XREF: sub_0_401000+48j
mov eax, [esi] ; EAX = *DERIVED_VTBL
mov ecx, esi ; ECX = this
call dword ptr [eax] ; CALL [DERIVED_VTBL] (DERIVED_DEMO)
mov edx, [esi] ; EDX = *DERIVED_VTBL
mov ecx, esi ; ECX=this
call dword ptr [edx+4] ; CALL [DERIVED_VTBL+4] (DERIVED_DEMO_2)
push offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"
call printf
; Обратите внимание – вызывается функция BASE_DEMO базового,
; а не производного класса!!!
add esp, 4
pop esi
retn
main endp
BASE_DEMO proc near ; DATA XREF: .rdata:004050B0o
push offset aBase ; "BASE\n"
call printf
pop ecx
retn
BASE_DEMO endp
BASE_DEMO_2 proc near ; DATA XREF: .rdata:004050B4o
push offset aBaseDemo2 ; "BASE DEMO 2\n"
call printf
pop ecx
retn
BASE_DEMO_2 endp
DERIVED_DEMO proc near ; DATA XREF: .rdata:004050A8o
push offset aDerived ; "DERIVED\n"
call printf
pop ecx
retn
DERIVED_DEMO endp
DERIVED_DEMO_2 proc near ; DATA XREF: .rdata:004050ACo
push offset aDerivedDemo2 ; "DERIVED DEMO 2\n"
call printf
pop ecx
retn
DERIVED_DEMO_2 endp
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: sub_0_401000+40o
dd offset DERIVED_DEMO_2
BASE_VTBL dd offset BASE_DEMO ; DATA XREF: sub_0_401000+Fo
dd offset BASE_DEMO_2
; Обратите внимание – виртуальные таблицы "растут" снизу вверх в порядке
; объявления классов в программе, а элементы виртуальных таблиц "растут"
; сверху вниз в порядке объявления виртуальных функций в классе.
; Конечно, так бывает не всегда (порядок размещения таблиц и их элементов
; нигде не декларирован и целиком лежит на "совести" компилятора, но на
; практике большинство из них ведут себя именно так) Сами же виртуальные
; функции располагаются вплотную друг к другу в порядке их объявления
Листинг 25

Рисунок 11 0x006 Художнику – добавить функции A, B и С Реализация вызова виртуальных функций
::идентификация чистой виртуальной функции. Если функция объявляется в базовом, а реализуется в производным классе – такая функция называется чистой виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, называется абстрактным классом.
Язык Си++ запрещает создание экземпляров абстрактного класса, да и как они могут создаваться, если, по крайней мере, одна из функций класса неопределенна?
На первый взгляд – не определена, и ладно, – какая в этом беда? Ведь на анализ программы это не влияет. На самом деле это не так – чистая виртуальная функция в виртуальной таблице замещается указателем на библиотечную функцию __purecall. Зачем она нужна? Дело в том, что на стадии компиляции программы невозможно гарантированно "отловить" все попытки вызова чисто виртуальных функций, но если такой вызов и произойдет, управление получит заранее подставленная сюда __purecall, которая выведет на экран "ругательство" по поводу запрета на вызов чисто виртуальных функций и завершит работу приложения. Подробнее об этом можно прочитать в технической заметке MSNDN
Q120919, датированной 27 июня 1997 года.
Таким образом, встретив в виртуальной таблице указатель на __purecall, можно с уверенностью утверждать, что мы имеем дело с чисто виртуальной функцией. Рассмотрим следующий пример:
#include <stdio.h>
class Base{
public:
virtual void demo(void)=0;
};
class Derived:public Base {
public:
virtual void demo(void)
{
printf("DERIVED\n");
};
};
main()
{
Base *p = new Derived;
p->demo();
}
Листинг 26 Демонстрация вызова чистой виртуальной функции
Результат его компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push 4
call ??2@YAPAXI@Z
add esp, 4
; Выделение памяти для нового экземляра объекта
test eax, eax
; Проверка успешности выделения памяти
jz short loc_0_401017
mov ecx, eax
; ECX = this
call GetDERIVED_VTBL
; занесение в экземпляр объекта указателя на виртуальную таблицу класса
; DERIVED
jmp short loc_0_401019
loc_0_401017: ; CODE XREF: main+Cj
xor eax, eax
; EAX = NULL
loc_0_401019: ; CODE XREF: main+15j
mov edx, [eax]
; тут возникает исключение по обращению к нулевому указателю
mov ecx, eax
jmp dword ptr [edx]
main endp
GetDERIVED_VTBL proc near ; CODE XREF: main+10p
push esi
mov esi, ecx
; Через регистр ECX функции передается неявный аргумент – this
call SetPointToPure
; функция заносит в экземпляр объекта указатель на __purecall
; специальную функцию - заглушку на случай незапланированного вызова
; чисто виртуальной функции
mov dword ptr [esi], offset DERIVED_VTBL
; занесение в экземпляр объекта указателя на виртуальную таблицу производного
; класса, с затиранием предыдущего значения (указателя на __purecall)
mov eax, esi
pop esi
retn
GetDERIVED_VTBL endp
DERIVED_DEMO proc near ; DATA XREF: .rdata:004050A8o
push offset aDerived ; "DERIVED\n"
call printf
pop ecx
retn
DERIVED_DEMO endp
SetPointToPure proc near ; CODE XREF: GetDERIVED_VTBL+3p
mov eax, ecx
mov dword ptr [eax], offset PureFunc
; Заносим по [EAX] (в экземляр нового объекта) указатель на специальную
; функцию - __purecall, которая предназначена для отслеживания попыток
; вызова чисто виртуальной функции в ходе выполнения программы -
; если такая попытка произойдет, __purecall выведет на экран "матюгательство"
; дескать, вызывать чисто виртуальную функцию нельзя и завершит работу
retn
SetPointToPure endp
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: GetDERIVED_VTBL+8o
PureFunc dd offset __purecall ; DATA XREF: SetPointToPure+2o
; указатель на функцию-заглушку __purecall. Следовательно, мы имеем дело
; с чисто виртуальной функцией
Листинг 27
::совместное использование виртуальной таблицы несколькими экземплярами объекта. Сколько бы экземпляров объекта ни существовало – все они пользуются одной и той же виртуальной таблицей.
Виртуальная таблица принадлежит самому объекту, но не экземпляру (экземплярам) этого объекта. Впрочем, из этого правила существуют и исключения (см. "Копии виртуальных таблиц").

Рисунок 12 0x007 все экземпляры объекта используют одну и ту же виртуальную таблицу
Для подтверждения сказанного рассмотрим следующий пример:
#include <stdio.h>
class Base{
public:
virtual demo ()
{
printf("Base\n");
}
};
class Derived:public Base{
public:
virtual demo()
{
printf("Derived\n");
}
};
main()
{
Base * obj1 = new Derived;
Base * obj2 = new Derived;
obj1->demo();
obj2->demo();
}
Листинг 28 Демонстрация совместного использование одной копии виртуальной таблицы несколькими экземплярами класса
Результат его компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push esi
push edi
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память под первый экземпляр объекта
test eax, eax
jz short loc_0_40101B
mov ecx, eax ; EAX
– указывает на первый экземпляр объекта
call GetDERIVED_VTBL
; в EAX – указатель на виртуальную таблицу класса DERIVED
mov edi, eax ; EDI = *DERIVED_VTBL
jmp short loc_0_40101D
loc_0_40101B: ; CODE XREF: main+Ej
xor edi, edi
loc_0_40101D: ; CODE XREF: main+19j
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память под второй экземпляр объекта
test eax, eax
jz short loc_0_401043
mov ecx, eax ; ECX – this
call GetDERIVED_VTBL
; обратите внимание – второй экземпляр использует ту же самую
; виртуальную
таблицу
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: GetDERIVED_VTBL+8o
BASE_VTBL dd offset BASE_DEMO ; DATA XREF: GetBASE_VTBL+2o
; Обратите внимание – виртуальная таблица одна на все экземпляры класса
Листинг 29
::копии виртуальных таблиц. ОК, для успешной работы, - понятное дело, - вполне достаточно и одной виртуальной таблицы, однако, на практике приходится сталкиваться с тем, что исследуемый файл прямо-таки кишит копиями этих виртуальных таблиц. Что же это за напасть такая, откуда она берется и как с ней бороться?
Если программа состоит из нескольких файлов, компилируемых в самостоятельные obj-модули (а такой подход используется практически во всех мало-мальски серьезных проектах), компилятор, очевидно, должен поместить в каждый obj "свою" собственную виртуальную таблицу для каждого используемого модулем класса. В самом деле – откуда компилятору знать о существовании других obj и наличии в них виртуальных таблиц? Вот так и возникают никому не нужные дубли, отъедающие память и затрудняющие анализ. Правда, на этапе компоновки, линкер может обнаружить копии и удалить их, да и сами компиляторы используют различные эвристические приемы для повышения эффективности генерируемого кода. Наибольшую популярность завоевал следующий алгоритм: виртуальная таблица помещается в тот модуль, в котором содержится реализация первой невстроенной не виртуальной функции класса. Обычно каждый класс реализуется в одном модуле и в большинстве случаев такая эвристика срабатывает. Хуже если класс состоит из одних виртуальных или встраиваемых функций – в этом случае компилятор "ложится" и начинает запихивать виртуальные таблицы во все модули, где этот класс используется. Последняя надежда на удаление "мусорных" копий ложиться на линкер, но и линкер – не панацея. Собственно, эти проблемы должны больше заботить разработчиков программы (если их волнует количество занимаемой программой памятью), для анализа лишние копии – всего лишь досадна помеха, но отнюдь не непреодолимое препятствие!
::связанный список. В большинстве случаев виртуальная таблица представляет собой обыкновенный массив, но некоторые компиляторы представляют ее в виде связного списка, - каждый элемент виртуальной таблицы содержит указатель на следующий элемент, а сами элементы размещены не вплотную друг к другу, а рассеянны по всему исполняемому файлу.
На практике подобное, однако, встречается крайне редко, поэтому, не будем подробно на этом останавливаться, - достаточно лишь знать, что такое бывает, - если встретись со списками (впрочем, навряд ли вы с ними встретитесь) – разберетесь по обстоятельствам, благо это несложно.
::вызов через шлюз. Будьте так же готовы и к тому, чтобы встретить в виртуальной таблице указатель не на виртуальную функцию, а на код, который модифицирует этот указатель, занося в него смещение вызываемой функции. Этот прием был впервые предложен самим разработчиком языка – Бьерном Страуструпом, позаимствовавшим его из ранних реализаций Алгола-60. В Алголе код, корректирующий указатель вызываемой функции, называется шлюзом (thunk), а сам вызов – вызовом через шлюз. Вполне справедливо употреблять эту терминологии и по отношению к Си++.
Однако в настоящее время вызов через шлюз чрезвычайно мало распространен и не используется практически ни одним компилятором. Несмотря на то, что он обеспечивает более компактное хранение виртуальных таблиц, модификация указателя приводит к излишним накладным расходам на процессорах с конвейерной архитектурой, (а Pentium – наиболее распространенный процессор, - как раз и построен по такой архитектуре). Поэтому, использование шлюзовых вызовов оправдано лишь в программах, критических к размеру, но не к скорости.
Подробнее обо всем этом можно прочесть в руководстве по Алголу-60 (шутка), или у Бьерна Страуструпа в "Дизайне и эволюции языка С++".
::сложный пример или когда не виртуальные функции попадают в виртуальные таблицы. До сих пор мы рассматривали лишь простейшие примеры использования виртуальных функций. В жизни же порой встречается такое… Рассмотрим сложный случай наследования с конфликтом имен:
#include <stdio.h>
class A{
public:
virtual void f() { printf("A_F\n");};
};
class B{
public:
virtual void f() { printf("B_F\n");};
virtual void g() { printf("B_G\n");};
};
class C:public A, public B {
public:
void f(){ printf("C_F\n");}
}
main()
{
A *a = new A;
B *b = new B;
C *c = new C;
a->f();
b->f();
b->g();
c->f();
}
Листинг 30 Демонстрация помещения не виртуальных функций в виртуальные таблицы
Как будет выглядеть виртуальная таблица класса C? Так, давайте подумаем: раз класс C – производный от классов A и B, то он наследует функции обоих, но виртуальная функция f() класса B перекрывает одноименную виртуальную функцию класса A, поэтому, из класса А она не наследуется. Далее, поскольку не виртуальная функция f() присутствует и в производном классе С, она перекрывает виртуальную функцию производного класса (да, именно так, а вот не виртуальная не виртуальную функцию не перекрывает и она всегда вызывается из базового, а не производного класса). Таким образом, виртуальная таблица класса С должна содержать только один элемент – указатель на виртуальную функцию g(), унаследованную от B, а не виртуальная функция f() вызывается как обычная Си-функция. Правильно? Нет!
Это как раз тот случай, когда не виртуальная функция вызывается через указатель – как виртуальная функция. Более того, виртуальная таблица класса будет содержать не два, а три элемента! Третий элемент – это ссылка на виртуальную функцию f(), унаследованную от B, но тут же замещенная компилятором на "переходник" к C::f(). Уф… Как все непросто! Может, после изучения дизассемблерного листинга это станет понятнее?
main proc near ; CODE XREF: start+AFp
push ebx
push esi
push edi
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для экземпляра объекта A
test eax, eax
jz short loc_0_40101C
mov ecx, eax ; ECX = this
call Get_A_VTBL ; a[0]=*A_VTBL
; помещаем в экземпляр объекта указатель на его виртуальную таблицу
mov ebx, eax ; EBX = *a
jmp short loc_0_40101E
loc_0_40101C: ; CODE XREF: main+Fj
xor ebx, ebx
loc_0_40101E: ; CODE XREF: main+1Aj
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для экземпляра объекта B
test eax, eax
jz short loc_0_401037
mov ecx, eax ; ECX = this
call Get_B_VTBL ; b[0] = *B_VTBL
; помещаем в экземпляр объекта указатель на его виртуальную таблицу
mov esi, eax ; ESI = *b
jmp short loc_0_401039
loc_0_401037: ; CODE XREF: main+2Aj
xor esi, esi
loc_0_401039: ; CODE XREF: main+35j
push 8
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для экземпляра объекта B
test eax, eax
jz short loc_0_401052
mov ecx, eax ; ECX = this
call GET_C_VTBLs ; ret: EAX=*c
; помещаем в экземпляр объекта указатель на его виртуальную таблицу
; (внимание: загляните в функцию GET_C_VTBLs)
mov edi, eax ; EDI = *c
jmp short loc_0_401054
loc_0_401052: ; CODE XREF: main+45j
xor edi, edi
loc_0_401054: ; CODE XREF: main+50j
mov eax, [ebx] ; EAX = a[0] = *A_VTBL
mov ecx, ebx ; ECX = *a
call dword ptr [eax] ; CALL [A_VTBL] (A_F)
mov edx, [esi] ; EDX = b[0]
mov ecx, esi ; ECX = *b
call dword ptr [edx] ; CALL [B_VTBL] (B_F)
mov eax, [esi] ; EAX = b[0] = B_VTBL
mov ecx, esi ; ECX = *b
call dword ptr [eax+4] ; CALL [B_VTBL+4] (B_G)
mov edx, [edi] ; EDX = c[0] = C_VTBL
mov ecx, edi ; ECX = *c
call dword ptr [edx] ; CALL [C_VTBL] (C_F)
; Внимание! Вызов не виртуальной функции происходит как виртуальной!
pop edi
pop esi
pop ebx
retn
main endp
GET_C_VTBLs proc near ; CODE XREF: main+49p
push esi ; ESI = *b
push edi ; ECX = *c
mov esi, ecx ; ESI = *c
call Get_A_VTBL ; c[0]=*A_VTBL
; помещаем в экземпляр объекта C указатель на виртуальную таблицу класса A
lea edi, [esi+4] ; EDI = *c[4]
mov ecx, edi ; ECX = **_C_F
call Get_B_VTBL ; c[4]=*B_VTBL
; добавляем в экземпляр объекта C
указатель на виртуальную таблицу класса B
; т.е. теперь объект C содержит два указателя на две виртуальные таблицы
; базовых классов. Посмотрим далее, как компилятор справится с конфликтом
; имен…
mov dword ptr [edi], offset C_VTBL_FORM_B ; c[4]=*_C_VTBL
; Ага! указатель на виртуальную таблицу класса B
замещается указателем
; на виртуальную таблицу класса C
(смотри комментарии в самой таблице)
mov dword ptr [esi], offset C_VTBL ; c[0]=C_VTBL
; Ага, еще раз – теперь указатель на виртуальную таблицу класса A замещается
; указателем на виртуальную таблицу класса C. Какой неоптимальный код, ведь это
; было можно сократить еще на стадии компиляции!
mov eax, esi ; EAX = *c
pop edi
pop esi
retn
GET_C_VTBLs endp
Get_A_VTBL proc near ; CODE XREF: main+13p GET_C_VTBLs+4p
mov eax, ecx
mov dword ptr [eax], offset A_VTBL
; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B
retn
Get_A_VTBL endp
A_F proc near ; DATA XREF: .rdata:004050A8o
; виртуальная функиця f() класса A
push offset aA_f ; "A_F\n"
call printf
pop ecx
retn
A_F endp
Get_B_VTBL proc near ; CODE XREF: main+2Ep GET_C_VTBLs+Ep
mov eax, ecx
mov dword ptr [eax], offset B_VTBL
; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B
retn
Get_B_VTBL endp
B_F proc near ; DATA XREF: .rdata:004050ACo
; виртуальная функция f() класса B
push offset aB_f ; "B_F\n"
call printf
pop ecx
retn
B_F endp
B_G proc near ; DATA XREF: .rdata:004050B0o
; виртуальная функция g() класса B
push offset aB_g ; "B_G\n"
call printf
pop ecx
retn
B_G endp
C_F proc near ; CODE XREF: _C_F+3j
; Не виртуальная функция f() класса C
выглядит и вызывается как виртуальная!
push offset aC_f ; "C_F\n"
call printf
pop ecx
retn
C_F endp
_C_F proc near ; DATA XREF: .rdata:004050B8o
sub ecx, 4
jmp C_F
; смотрите, какая странная функция! Во-первых, она никогда не вызывается, а
; во-вторых, это переходник к функции C_F.
; зачем уменьшается ECX? В ECX компилятор поместил указатель this, который
; до уменьшения пытался указывать на виртуальную функцию f(), унаследованную
; от класса B. Но на самом же деле this указывал на этот переходник.
; А после уменьшения он стал указывать на предыдущий элемент виртуальной
; таблицы – т.е. функцию f() класса C, вызов которой и осуществляет JMP
_C_F endp
A_VTBL dd offset A_F ; DATA XREF: Get_A_VTBL+2o
; виртуальная таблица класса A
B_VTBL dd offset B_F ; DATA XREF: Get_B_VTBL+2o
dd offset B_G
; виртуальная таблица класса B – содержит указатели на две виртуальные функции
C_VTBL dd offset C_F ; DATA XREF: GET_C_VTBLs+19o
; виртуальная таблица класса C. Содержит указатель на не виртуальную функцию f()
C_VTBL_FORM_B dd offset _C_F ; DATA XREF: GET_C_VTBLs+13o
dd offset B_G
; виртуальная таблица класса C скопированная компилятором из класса B. Первоначально
; состояла из двух указателей на функции f() и g(), но еще на стадии
; компиляции компилятор разобрался в конфликте имен и заменил указатель на B::f()
; указателем на переходник к C::f()
Листинг 31
Таким образом, на самом деле виртуальная таблица производного класса включает в себя виртуальные таблицы всех базовых классов (во всяком случае, всех, откуда она наследует виртуальные функции).
В данном случае виртуальная таблица класса С содержит указатель на не виртуальную функцию С и виртуальную таблицу класса B. Задача – как определить, что функция C::f() не виртуальная? И как найти все базовые классы класса C?
Начнем с последнего – да, виртуальная таблица класса С не содержит никакого намека на его родственные отношения с классом A, но взгляните на содержимое функции GET_C_VTBLs, - видите: предпринимается попытка внедрить в C указатель на виртуальную таблицу А, следовательно, класс C – производный от A. Мне могут возразить, дескать, это не слишком надежный путь, компилятор мог бы оптимизировать код, выкинув обращение к виртуальной таблице класса А, которое все равно не нужно. Это верно, - мог бы, но на практике большинство компиляторов так не делают, а если и делают, все равно оставляют достаточно избыточной информации, позволяющей установить базовые классы. Другой вопрос – так ли необходимо устанавливать "родителей", от которых не наследуется ни одной функции? (Если хоть одна функция наследуется, никаких сложностей в поиске не возникает). В общем-то, для анализа это действительно некритично, но, чем точнее будет восстановлен исходный код программы, – тем нагляднее он будет и тем легче в нем разобраться.
Теперь перейдем к не виртуальной функции f(). Подумаем, что было бы – будь она на самом деле виртуальной? Тогда – она бы перекрыла одноименную функцию базовых классов и никакой "дикости" наподобие "переходников" в откомпилированной программе и не встретилось бы. А так – они говорят, что тут не все гладко и функция не виртуальная, хоть и стремится казаться такой. Опять-таки, умный компилятор теоретически может выкинуть переходник и дублирующийся элемент виртуальной таблицы класса С, но на практике этой интеллектуальности не наблюдается…
::статическое связывание. Есть ли разница как создавать экземпляр объекта – MyClass zzz;
или MyClass *zzz=new MyClass? Разумеется: в первом случае компилятор может определить адреса виртуальных функций еще на стадии компиляции, тогда как во втором – это приходится вычислять в ходе выполнения программы.
Другое различие: статические объекты размешаются в стеке (сегменте данных), а динамические – в куче. Таблица виртуальных функций упорно создается компиляторами в обоих случаях, а при вызове каждый функции (включая не виртуальные) подготавливается указатель this (как правило, помещаемый в один из регистров общего назначения – подробнее см. "Идентификация аргументов функций"), содержащий адрес экземпляра объекта.
Таким образом, если мы встречаем функцию, вызываемую непосредственно по ее смещению, но в то же время присутствующую в виртуальной таблице класса – можно с уверенностью утверждать, что это – виртуальная функция статичного экземпляра объекта.
Рассмотрим следующий пример:
#include <stdio.h>
class Base{
public:
virtual void demo(void)
{
printf("BASE DEMO\n");
};
virtual void demo_2(void)
{
printf("BASE DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual BASE DEMO 3\n");
};
};
class Derived: public Base{
public:
virtual void demo(void)
{
printf("DERIVED DEMO\n");
};
virtual void demo_2(void)
{
printf("DERIVED DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual DERIVED DEMO 3\n");
};
};
main()
{
Base p;
p.demo();
p.demo_2();
p.demo_3();
Derived d;
d.demo();
d.demo_2();
d.demo_3();
}
Листинг 32 Демонстрация вызова статической виртуальной функции
Результат ее компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_8 = byte ptr -8 ; derived
var_4 = byte ptr -4 ; base
; часто, (но не всегда!) экземпляры объектов в стеке расположены снизу вверх,
; т.е. в обратном порядке их объявления в программе
push ebp
mov ebp, esp
sub esp, 8
lea ecx, [ebp+var_4] ; base
call GetBASE_VTBL ; p[0]=*BASE_VTBL
; обратите внимание – экземпляр объекта размещается в стеке,
; а не в куче! Это, конечно, не еще не свидетельствует о статичной
; природе экземпляра объекта (динамичные объекты тоже могут размещаться в стеке)
; но намеком на "статику" все же служит
lea ecx, [ebp+var_4] ; base
; подготавливаем указатель this (на тот случай если он понадобится функции)
call BASE_DEMO
; непосредственный вызов функции! Вот, вкупе с ее наличием в виртуальной таблице
; свидетельство статичности объявления экземпляра объекта!
lea ecx, [ebp+var_4] ; base
; вновь подготавливаем указатель this
на экземляр base
call BASE_DEMO_2
; непосредственный вызов функции. Она есть в виртуальной таблице? Есть!
; значит, это виртуальная функция, а экземпляр объекта объявлен статичным
lea ecx, [ebp+var_4] ; base
; готовим указатель this для не виртуальной
функции demo_3
call BASE_DEMO_3
; этой функции нет в виртуальной таблице (см. виртуальную таблицу)
; значит, она не виртуальная
lea ecx, [ebp+var_8] ; derived
call GetDERIVED_VTBL ; d[0]=*DERIVED_VTBL
lea ecx, [ebp+var_8] ; derived
call DERIVED_DEMO
; аналогично предыдущему...
lea ecx, [ebp+var_8] ; derived
call DERIVED_DEMO_2
; аналогично
предыдущему...
lea ecx, [ebp+var_8] ; derived
call BASE_DEMO_3_
; внимание! Указатель this указывает на объект DERIVED, в то время как
; вызывается функция объекта BASE!!! Значит, функция BASE – производная
mov esp, ebp
pop ebp
retn
main endp
BASE_DEMO proc near ; CODE XREF: main+11p
; функция demo класса BASE
push offset aBase ; "BASE\n"
call printf
pop ecx
retn
BASE_DEMO endp
BASE_DEMO_2 proc near ; CODE XREF: main+19p
; функция demo_2 класса BASE
push offset aBaseDemo2 ; "BASE DEMO 2\n"
call printf
pop ecx
retn
BASE_DEMO_2 endp
BASE_DEMO_3 proc near ; CODE XREF: main+21p
; функция demo_3 класса BASE
push offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"
call printf
pop ecx
retn
BASE_DEMO_3 endp
DERIVED_DEMO proc near ; CODE XREF: main+31p
; функция demo класса DERIVED
push offset aDerived ; "DERIVED\n"
call printf
pop ecx
retn
DERIVED_DEMO endp
DERIVED_DEMO_2 proc near ; CODE XREF: main+39p
; функция demo класса DERIVED
push offset aDerivedDemo2 ; "DERIVED DEMO 2\n"
call printf
pop ecx
retn
DERIVED_DEMO_2 endp
BASE_DEMO_3_ proc near ; CODE XREF: main+41p
; функция demo_3 класса BASE
; Внимание! Смотрите – функция demo_3 дважды присутствует в программе!
; первый раз она входила в объект класса BASE, а второй – в объект класса
; DERIVED, который унаследовал ее от базового класса и сделал копию
; глупо, да? ведь лучше бы он обратился к оригиналу... Зато это упрощает
; анализ программы...
push offset aNonVirtualDeri ; "Non virtual DERIVED DEMO 3\n"
call printf
pop ecx
retn
BASE_DEMO_3_ endp
GetBASE_VTBL proc near ; CODE XREF: main+9p
; занесение в экземпляр объекта BASE
смещения его виртуальной таблицы
mov eax, ecx
mov dword ptr [eax], offset BASE_VTBL
retn
GetBASE_VTBL endp
GetDERIVED_VTBL proc near ; CODE XREF: main+29p
; занесение в экземпляр объекта DERIVED
смещения его виртуальной таблицы
push esi
mov esi, ecx
call GetBASE_VTBL
; ага! Значит, наш объект – производный от BASE!
mov dword ptr [esi], offset DERIVED_VTBL
; занесение указателя на виртуальную таблицу DERIVED
mov eax, esi
pop esi
retn
GetDERIVED_VTBL endp
BASE_VTBL dd offset BASE_DEMO ; DATA XREF: GetBASE_VTBL+2o
dd offset BASE_DEMO_2
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: GetDERIVED_VTBL+8o
dd offset DERIVED_DEMO_2
; обратите внимание на наличие виртуальной таблицы даже там, где она не нужна!
Листинг 33
::идентификация производных функций. Идентификация производных не виртуальных функций – весьма тонкий момент. На первый взгляд, коль они вызываются как и обычные Си-функции, распознать: в каком классе была объявлена функция невозможно – компилятор уничтожает эту информацию еще на стадии компиляции. Уничтожает, да не всю! Перед каждым вызовом функции (не важно производной или нет) в обязательном порядке формируется указатель this – на тот случай если он понадобится функции, указывающей на объект из которого вызывается эта функция. Для производных функций указатель this хранит смещение производного, а не базового объекта. Вот оно! Если функция вызывается с различными указателями this – это производная функция.
Сложнее выяснить – от какого объекта она происходит. Универсальных решений нет, но если выделить объект A с функциями f1(), f2()… И объект B с функциями f1(), f3(),f4()… то можно смело утверждать, что f1() – функция, производная от класса А. Правда, если из экземпляра класса функция f1() не вызывалась ни разу – определить производная она или нет – не удастся.
Рассмотрим все это на следующем примере:
#include <stdio.h>
class Base{
public:
void base_demo(void)
{
printf("BASE DEMO\n");
};
void base_demo_2(void)
{
printf("BASE DEMO 2\n");
};
};
class Derived: public Base{
public:
void derived_demo(void)
{
printf("DERIVED DEMO\n");
};
void derived_demo_2(void)
{
printf("DERIVED DEMO 2\n");
};
};
Листинг 34 Демонстрация идентификации производных функций
Результат компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push esi
push 1
call ??2@YAPAXI@Z ; operator new(uint)
; создаем новый экземпляр некоторого объекта. Пока мы еще не знаем какого
; пусть это будет объект A
mov esi, eax ; ESI = *a
add esp, 4
mov ecx, esi ; ECX = *a (this)
call BASE_DEMO
; вызываем BASE_DEMO, обращая внимание на то, что this
указывает на 'a'
mov ecx, esi ; ECX = *a (this)
call BASE_DEMO_2
; вызываем BASE_DEMO_2, обращая внимание на то, что this
указывает на 'a'
push 1
call ??2@YAPAXI@Z ; operator new(uint)
; создаем еще один экземпляр некоторого объекта, назовем его b
mov esi, eax ; ESI = *b
add esp, 4
mov ecx, esi ; ECX = *b (this)
call BASE_DEMO
; Ага! Вызываем BASE_DEMO, но на этот раз this
указывает на b
; значит, BASE_DEMO
связана родственными отношениями и с 'a' и с 'b'
mov ecx, esi
call BASE_DEMO_2
; Ага! Вызываем BASE_DEMO_2, но на этот раз this
указывает на b
; значит, BASE_DEMO_2 связана родственными отношениями и с 'a' и с 'b'
mov ecx, esi
call DERIVED_DEMO
; вызываем DERIVED_DEMO. Указатель this указывает на b, и никаких родственных
; связей DERIVED_DEMO
с 'a' не замечено. this
никогда не указывал на 'a'
; при ее вызове
mov ecx, esi
call DERIVED_DEMO_2
; аналогично...
pop esi
retn
main endp
Листинг 35
Ок, идентификация не виртуальных производных функций – вполне реальное дело. Единственная сложность – отличить экземпляры двух различных объектов от экземпляров одного и того же объекта.
Что же касается идентификации производных виртуальных функций – об этом уже рассказывалось выше. Производные виртуальные функции вызываются в два этапа – на первом в экземпляр объекта заносится смещение виртуальной таблицы базового класса, а затем оно замещается смещением виртуальной таблицы производного класса. Даже если компилятор оптимизирует код, оставшейся избыточности все равно с лихвой хватит для отличия производных функций от остальных.
::идентификация виртуальных таблиц. Теперь, основательно освоившись с виртуальными таблицами и функциями, рассмотрим очень коварный вопрос – всякий ли массив указателей на функции есть виртуальная таблица? Разумеется, нет! Ведь косвенный вызов функции через указатель – частое дело в практике программиста.
Массив указателей на функции… хм, конечно типичным его не назовешь, но и такое в жизни встречается!
Рассмотрим следующий пример – кривой и наигранный конечно, но чтобы продемонстрировать ситуацию, где массив указателей жизненно необходим, пришлось бы написать не одну сотню строк кода:
#include <stdio.h>
void demo_1(void)
{
printf("Demo 1\n");
}
void demo_2(void)
{
printf("Demo 2\n");
}
void call_demo(void **x)
{
((void (*)(void)) x[0])();
((void (*)(void)) x[1])();
}
main()
{
static void* x[2] =
{ (void*) demo_1,(void*) demo_2};
// Внимание: если инициализировать массив не при его объявлении
// а по ходу программы, т.е. x[0]=(void *) demo_1,...
// то компилятор сгенерирует адекватный код, заносящий
// смещения функций в ходе выполнения программы, что будет
// совсем не похоже на виртуальную таблицу!
// Напротив, инициализация при объявлении помещает уже
// готовые указатели в сегмент данных, смахивая на настоящую
// виртуальную таблицу (и экономя такты процессора к тому же)
call_demo(&x[0]);
}
Листинг 36 Демонстрация имитации виртуальных таблиц
А теперь посмотрим – сможем ли мы отличить "рукотворную" таблицу указателей от настоящей:
main proc near ; CODE XREF: start+AFp
push offset Like_VTBL
call demo_call
; ага, функции передается указатель на нечто очень похожее на виртуальную
; таблицу. Но мы-то, уже умудренные опытом, с легкостью раскалываем эту
; грубую подделку. Во-первых, указатели на VTBL
так просто не передаются,
; (там не такой тривиальный код), во-вторых они передаются не через стек,
; а через регистр. В-третьих, указатель на виртуальную таблицу ни одним
; существующим компилятором не используется непосредственно, а помещается
; в объект. Тут же нет ни объекта, ни указателя this
– в четвертых.
; словом, это не виртуальная таблица, хотя на беглый, нетренированный
; взгляд очень на нее похожа...
pop ecx
retn
main endp
demo_call proc near ; CODE XREF: sub_0_401030+5p
arg_0 = dword ptr 8
; вот-с! указатель – аргумент, а к виртуальным таблицам идет обращение
; через регистр...
push ebp
mov ebp, esp
push esi
mov esi, [ebp+arg_0]
call dword ptr [esi]
; происходит двухуровневый вызов функции – по указателю на массив
; указателей на функцию, что характерно для вызова виртуальных функций
; но, опять-таки слишком тривиальный код, - вызов виртуальных функций
; сопряжен с большой избыточностью, а во-вторых опять нет указателя this
call dword ptr [esi+4]
; аналогично – слишком просто для вызова виртуальной функции
pop esi
pop ebp
retn
demo_call endp
Like_VTBL dd offset demo_1 ; DATA XREF:main
dd offset demo_2
; массив указателей внешне похож на виртуальную таблицу, но
; расположен "не там" где обычно располагаются виртуальные таблицы
Листинг 37
Обобщая выводы, разбросанные по комментариям, повторим основные признаки "подделки" еще раз:
- слишком тривиальный код, - минимум используемых регистров и никакой избыточности, обращение к виртуальным таблицам происходит куда витиеватее;
- указатель на виртуальную функцию заносится в экземпляр объекта, и передается он не через стек, а через регистр (точнее – см. "Идентификация this");
- отсутствует указатель this, всегда подготавливаемый перед вызовом виртуальной функции;
- виртуальные функции и статические переменные располагаются в различных местах сегмента данных – поэтому сразу можно отличить одни от других.
А можно ли так организовать вызов функции по ссылке, чтобы компиляция программы давала код идентичный вызову виртуальной функции? Как сказать… Теоретически да, но практически – едва ли такое удастся осуществить (а уж непреднамеренно – тем более). Код вызова виртуальных функций в связи с большой избыточностью очень специфичен и легко различим "на глаз".Легко сымитировать общую технику работы с виртуальными таблицами, но без ассемблерных вставок невозможно воспроизвести ее в точности.
::заключение.
Вообще же, как мы видим, работа с виртуальными функциями сопряжена с огромной избыточностью и "тормозами", а их анализ связан с большими трудозатратами – приходится постоянно держать в голове множество указателей и помнить какой из них на что указывает. Но, как бы там ни было, никаких принципиально-неразрешимых преград перед исследователем не стоит.