Вся правда о TypeHandle в .NET



В разных умных книжках и статьях про .NET я часто наталкивался на упоминания про TypeHandle. Чаще всего пишут, что у каждого .NET-объекта в заголовке находится некоторый TypeHandle, который представляет собой ссылку на тип. Ещё пишут, что TypeHandle — это всегда указатель на таблицу методов типа. А в некоторых местах мне доводилось встречать информацию о том, что TypeHandle указывает на некий TypeDesc. В общем, я устал от неразберихи: давайте вместе разберёмся что к чему. А для этого нам придётся немного подизассемблировать, поизучать дампы памяти и залезть в исходники CLI.

Что нам понадобится?

  • Нам нужна будет Visual Studio. А в ней нам понадобится консольное приложение, над которым мы будем ставить наши эксперименты. Для чистоты эксперимента не забываем поставить сборку проекта в Release mode, а для честного дебага уберём галочку Suppress JIT optimization on module load (Tools -> Options -> Debugging -> General). В свойствах проекта на вкладке Debug нужно включить опцию Enable native code debugging. Для простоты примера будем собирать наш проект под x86.

  • Расширение отладки SOS.

  • Shared Source Common Language Infrastructure 2.0

Пример 1

Начнём с совсем простого примера:

object a = new object();
Console.WriteLine(a);
Console.ReadLine();

Последние пара строчек нужна затем, чтобы можно было нормально подебажить (в дальнейшем я их приводить не буду). Давайте поставим точку останова на второй строчке и запустим наше приложение из студии (через F5). Для удобной отладки нам понадобится несколько окошек: Disassembly, Registers, Memory (их можно найти в Debug -> Windows).

Наш объект только что создался, а его адрес вернулся нам через регистр eax:

;       object a = new object();
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        esi 
00000004  mov         ecx,65C4B060h 
00000009  call        FE6BF7A0      ; адрес нового объекта записывается в eax
0000000e  mov         esi,eax 
;       Console.WriteLine(a);
00000010  call        63ECA5E4 
;       ...

В окне Registers находим значение eax (у вас адреса будут другие)

EAX = 01ED1598 EBX = 0543EA64 
ECX = 65C4B060 EDX = 005495E8 
ESI = 01ED1598 EDI = 0543E9D0 
EIP = 01CF2970 ESP = 0543E9B0 
EBP = 0543E9B4 EFL = 00000212 

и копируем его в поле Address окна Memory:

0x01ED1594  00000000  
0x01ED1598  65c4b060  
0x01ED159C  00000000  

Заметьте, что я привёл дамп памяти размером 12 байт — именно столько занимает сейчас наш объект. Разберёмся более подробно: в заголовке каждого объекта всегда присутствует два поля: SyncBlockIndex (который размещается непосредственно перед объектом, т.е. обладает отрицательным смещением) и то, что мы пока назовём «ссылкой на тип». Под архитектуру x86 каждое из этих полей занимает 4 байта. Но особенности работы GC требуют, чтобы минимальный размер объекта был 12 байт. Поэтому CLR аккуратненько дополняет объект 4 байтами до нужного размера. Давайте посмотрим на наш объект с помощью SOS. Откроем Immediate Window (для каждой дебаг-сессии необходимо включить SOS с помощью команды .load sos.dll) и воспользуемся командой !DumpObj, которой отдадим адрес нашего объекта:

.load sos.dll
!DumpObj 0x01ED1598
Name:        System.Object
MethodTable: 65c4b060
EEClass:     65854920
Size:        12(0xc) bytes
File:        C:\WINDOWS\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Object
Fields:

Ага, теперь понятно: значение 0x65c4b060 — это адрес таблицы методов (MethodTable) для нашего объекта. Давайте проверим эту гипотезу: воспользуемся командой !DumpMT для просмотра таблицы методов (если вы запустите эту команду с ключом -MD , то кроме заголовочной информации увидите ещё и все методы):

!DumpMT 65c4b060
EEClass:         65854920
Module:          65851000
Name:            System.Object
mdToken:         02000002
File:            C:\WINDOWS\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
BaseSize:        0xc
ComponentSize:   0x0
Slots in VTable: 12
Number of IFaces in IFaceMap: 0

Казалось бы всё понятно: в начале каждого объекта хранится ссылка на MethodTable — так CLR узнаёт к какому типу относится объект. Но не будем делать выводы по одному примеру: давайте взглянем на исходники CLI. В этом нам поможет SSCLI (в узких кругах известная как Rotor) — это открытые исходники реализации CLI от Microsoft. Увы, последняя версия SSCLI 2.0 датируется 2006-ым годом и относится к .NET Framework 2.0. Понадеемся, что базовые принципы хранения объектов в памяти не сильно поменялись за последнее время. Если открыть файл sscli20\clr\src\vm\object.h, то ближе к началу можно найти такие строчки:

class Object
{
  protected:
    MethodTable*    m_pMethTab;

Ну, вроде всё верно: в объекте действительно хранится указатель на MethodTable. Такое заключение вы можете встретить во многих статьях и книжках. Только вот некоторые называют его просто указателем на MethodTable, а некоторые — TypeHandle. Как считаете, правильно ли это? Давайте разбираться дальше.

Пример 2

А теперь перейдём к устройству массива:

var a = new object[1];
a[0] = new object();  

Точно также перейдём в дебаггер, найдём адрес массива через регистры и посмотрим дамп памяти:

0x02F9240C  00000000  // SyncBlockIndex (a)
0x02F92410  65bfab98  // System.Object[] MethodTable (a)
0x02F92414  00000001  // a.Length
0x02F92418  65c4b060  // (???) Type of elements in a
0x02F9241C  02f92424  // &a[0]
0x02F92420  00000000  // SyncBlockIndex (a[0])
0x02F92424  65c4b060  // System.Object MethodTable (a[0])
0x02F92428  00000000  // Free space (a[0])

После ссылки на таблицу методов для массива a идёт количество элементов в массиве (1), а затем — «ссылка на тип» элементов массива. Обратите внимание, я ещё ничего не утверждаю об этих данных в общем случае. Просто имеется известный факт о том, что у массивов, элементы которых являются ссылочным типом, имеются дополнительные данные, которые некоторым образом характеризуют тип элементов массива. После всех этих служебных данных находится содержание массива — единственный элемент, хранящий адрес созданного object. Легко видеть, что поле

0x02F92418  65c4b060 // System.Object[] MethodTable

указывает на таблицу методов для System.Object. Ну, вроде бы всё понятно: в массивах, элементы которого являются ссылочным типом, появляется дополнительное поле, которое указывает на MethodTable типа элементов. Но так ли это? Продолжим наше исследование.

Пример 3

А теперь создадим jagged-массив:

var a = new object[1][];
a[0] = new object[1];

Обратимся к дампу памяти:

0x0301240C  00000000  // SyncBlockIndex (a)
0x03012410  011731d4  // System.Object[][] MethodTable (a)
0x03012414  00000001  // a.Length
0x03012418  65854d7a  // (???) Type of elements in a
0x0301241C  03012424  // &a[0]
0x03012420  00000000  // SyncBlockIndex (a[0])
0x03012424  65bfab98  // System.Object[] MethodTable
0x03012428  00000001  // a[0].Length
0x0301242C  65c4b060  // Type of elements in a[0] = System.Object MethodTable

В этом дампе можно увидеть нечто странное: поле, которое должно определять тип элементов массива a (по адресу 0x03012418) не ведёт на System.Object[] MethodTable — ведь адрес этой таблицы можно найти по адресу (0x03012424) при описании MethodTable для a[0] — и они различаются. Давайте убедимся, что значение 0x65854d7a не определяет MehtodTable:

!DumpMT 65854d7a
65854d7a is not a MethodTable

Хм... Но что же это тогда такое? Давайте обратимся к исходникам CLI за объяснением. В фале sscli20\clr\src\vm\object.h также можно найти следующий код:

// ArrayBase encapuslates all of these details.  In theory you should never
// have to peek inside this abstraction
class ArrayBase : public Object
{
    ...
    // This MUST be the first field, so that it directly follows Object.  This is because
    // Object::GetSize() looks at m_NumComponents even though it may not be an array (the
    // values is shifted out if not an array, so it's ok). 
    DWORD       m_NumComponents;
    ...
    // What comes after this conceputally is:
    // TypeHandle elementType;        Only present if the method table is shared among many types (arrays of pointers)
    // INT32      bounds[rank];       The bounds are only present for Multidimensional arrays   
    // INT32      lowerBounds[rank];  Valid indexes are lowerBounds[i] <= index[i] < lowerBounds[i] + bounds[i]

Мы видим, что для массивов из элементов ссылочного типа (arrays of pointers) действительно появляется дополнительное поле, а тип его — TypeHandle. Но что же это такое? Перейдём к файлу sscli20\clr\src\vm\typehandle.h . В самом начале файла к комментариях можно найти следующую полезную информацию:

// A TypeHandle is the FUNDAMENTAL concept of type identity in the CLR.
// That is two types are equal if and only if their type handles
// are equal.  A TypeHandle, is a pointer sized struture that encodes 
// everything you need to know to figure out what kind of type you are
// actually dealing with.  

// At the present time a TypeHandle can point at two possible things
//
//      1) A MethodTable    (Intrinsics, Classes, Value Types and their instantiations)
//      2) A TypeDesc       (all other cases: arrays, byrefs, pointer types, function pointers, generic type variables)  
//
// or with IL stubs, a third thing:
//
//      3) A MethodTable for a native value type.
//
// MTs that satisfy IsSharedByReferenceArrayTypes are not 
// valid TypeHandles: for example no allocated object will
// ever return such a type handle from Object::GetTypeHandle(), and
// these type handles should not be passed across the JIT Interface
// as CORINFO_CLASS_HANDLEs.  However some code in the EE does create 
// temporary TypeHandles out of these MTs, so we can't yet assert 
// !IsSharedByReferenceArrayTypes() in the TypeHandle constructor.

Ага, значит TypeHandle может быть как указателем на MethodTable, так и указателем на TypeDesc, в зависимости от типа объекта. Для массивов он указывает на TypeDesc. Тип object[][] — это массив, элементами которого являются object[], для которых TypeHandle=TypeDesc. Эта информация объясняет наш пример, но всё ещё остаются некоторые вопросы. Например: а как же отличить, на что именно указывает TypeHandle? Поможет нам в этом дальнейшее изучение исходников CLI:

   FORCEINLINE BOOL IsUnsharedMT() const {
        LEAF_CONTRACT;
        STATIC_CONTRACT_SO_TOLERANT;
        return((m_asTAddr & 2) == 0);
    }

    FORCEINLINE BOOL IsTypeDesc() const  {
        WRAPPER_CONTRACT;
        return(!IsUnsharedMT());
    }

Всё зависит от второго бита в адресе: нулевое значение определяет MethodTable, а единичное — TypeDesc. Если мы работаем с шестнадцатеричными адресами, то можно легко определить вид TypeHandle по последней цифре:

MethodTable: 0, 1, 4, 5, 8, 9, C, D
TypeDesc   : 2, 3, 6, 7, A, B, E, F

А теперь взглянем ещё раз на дамп памяти нашего примера. Можно видеть, что для System.Object[] в дампе присутствуют указатели как на его TypeDesc, так и на MethodTable. Не смотря на то, что под TypeHandle в данном случае подразумевается TypeDesc, заголовочный указатель для a[0] всё-таки указывает на MethodTable. Поэтому некорректно говорить о том, что в заголовке каждого объекта хранится TypeHandle: там хранится указатель на MethodTable, а это далеко не всегда одно и то же.

Пример 4

Последний пример проиллюстрирует недавно полученное правило про последнюю цифру адреса. Мы можем получить TypeHandle прямо из управляемого кода, а по этому значению мы можем определить, что именно под ним подразумевается:

private void Run()
{
    Print(typeof(int));
    Print(typeof(object));
    Print(typeof(Stream));
    Print(typeof(int[]));
    Print(typeof(int[][]));
    Print(typeof(object[]));
}

private void Print(Type type)
{
    bool isTypeDesc = ((int)type.TypeHandle.Value & 2) > 0;
    Console.WriteLine("{0}: {1} => {2}", 
        type.Name.PadRight(10), 
        type.TypeHandle.Value.ToString("X"), 
        (isTypeDesc ? "TypeDesc" : "MethodTable"));
}

У меня этот код выводит следующее:

Int32     : 65C4C480 => MethodTable
Object    : 65C4B060 => MethodTable
Stream    : 65C4D954 => MethodTable
Int32[]   : 65854C8A => TypeDesc
Int32[][] : 658F6BD6 => TypeDesc
Object[]  : 65854D7A => TypeDesc

Выводы

В ходе нашего маленького исследования были получены следующие выводы:

  • TypeHandle является указателем либо на MethodTable, либо на TypeDesc (зависит от типа объекта)
  • В заголовке каждого объекта для идентификации его типа всегда хранится указатель на MethodTable (это не всегда TypeHandle)
  • Для массивов, чьи элементы должны представлять ссылочный тип, хранится дополнительное поле, которое представляет собой TypeHandle для типа элементов.
Поделиться:
Исходный код поста находится на GitHub:
https://github.com/AndreyAkinshin/aakinshin.net/blob/master/web/_posts/ru/2013/typehandle.md