Common Intermediate Language и системное программирование в Microsoft .NET

Верификация CIL-кода. Библиотеки для создания метаинструментов

Разбить на страницы
Показывать лекцию целиком

Говорят, что код программ не разрушает память, если эти программы, будучи запущенными в одном адресном пространстве, корректно изолированы друг от друга, то есть ошибки в одной программе не могут привести к изменению структур данных другой программы.

Верификация кода - это процесс автоматического доказательства того, что этот код не разрушает память.

В этой главе мы рассмотрим общие вопросы верификации кода и алгоритм верификации, приведенный в спецификации CLI и реализованный в Microsoft .NET Framework.

Классификация применяемых на практике алгоритмов верификации

Алгоритмы верификации являются метавычислительными алгоритмами (верификаторы кода стоят в одном ряду с такими программами, как частичные вычислители и суперкомпиляторы). Поэтому совершенно неудивительно, что алгоритмы верификации в общем случае имеют проблемы, свойственные любым метавычислительным алгоритмам, а именно: экспоненциальная сложность и потенциальная недетерминируемость.

Понятно, что алгоритмы, работающие неизвестно сколько времени (а возможно, вообще никогда не завершающиеся) и потребляющие неизвестно какое количество ресурсов, представляют скорее теоретический, нежели практический интерес. Поэтому их разработчикам приходится каким-то образом ограничивать задачу, чтобы получить применимые на практике результаты. Для алгоритмов верификации кода известно два подхода, позволяющие снизить их сложность и добиться терминируемости:

  • Концентрация усилий на выявлении только таких ошибок в программах, поиск которых не требует экспоненциальной сложности и выполняется за конечное время.

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

  • Работа лишь с некоторым подмножеством программ, для которых факт неразрушения ими памяти можно доказать за конечное время с использованием алгоритма приемлемой сложности. Этот тип верификаторов отличается тем, что существуют не разрушающие память программы, которые не могут быть доказаны алгоритмом.
  • Если задача некоторого метавычислительного алгоритма была как-то ограничена, то сразу же возникает вопрос о том, какие входные данные (программы) он сможет обработать. Для суперкомпиляторов и частичных вычислителей этот вопрос до сих пор остается открытым (ответ на него приходится искать методом проб и ошибок). Для верификаторов ответ зависит от того, какой подход (первый или второй) был выбран для ограничения задачи:

  • Верификаторы, предназначенные для выявления определенного класса ошибок в программах, используются при разработке программ на этапе тестирования. Тем самым, на вход таких верификаторов подаются любые программы в надежде, что в них будут обнаружены ошибки.

    Запуск такого верификатора можно рассматривать как один из тестов, которым подвергается программа. Если в некоторой программе верификатор не обнаружил ни одной ошибки, то можно считать, что его запуск был напрасным.

  • Верификаторы, способные доказать, что программа, принадлежащая некоторому классу программ, не разрушает память, служат для обеспечения безопасности. Они гарантируют, что запуск прошедшей верификацию программы не может привести к сбою всей системы.

    Использование таких верификаторов оправдано и удобно в случаях, если существуют специальные компиляторы, генерирующие такой код, который не только заведомо не разрушает память, но и гарантированно проходит верификацию.

    Тем самым ответ на вопрос о том, для каких программ верификатор может доказать, что они не разрушают память, становится тривиально прост: для тех программ, которые были откомпилированы специальным компилятором.

  • Особенности верификатора кода, используемого в .NET

    Верификатор кода .NET относится ко второму типу. Он может доказать, что сборки, генерируемые компиляторами C#, J# и Visual Basic .NET, не разрушают память. При этом он терминируем и имеет линейную сложность.

    Здесь может возникнуть следующий вопрос: зачем нужен верификатор, если компиляторы и так генерируют не разрушающий память код? Чтобы ответить на этот вопрос, нужно вспомнить, что:

  • существуют компиляторы (например, Visual C++ with Managed Extensions), которые в общем случае могут порождать код, разрушающий память;
  • в языке C# предусмотрены unsafe-блоки и unsafe-методы, внутри которых может находиться код, способный разрушить память;
  • любой CIL-код, в том числе и разрушающий память, можно непосредственно компилировать с помощью ILASM;
  • всеми этими возможностями может воспользоваться злоумышленник для написания вредоносного кода.
  • Другими словами, верификатор .NET предназначен не для поиска ошибок в программах, а для обеспечения безопасности системы.

    Мы будем различать два достаточно близких понятия, относящихся к верификации. Это верифицированный код и верифицируемый код.

    Верифицируемый код - это код, для которого верификатор может доказать, что он не разрушает память. Другими словами, для верифицируемого кода можно заранее, еще до запуска верификатора, сказать, что он успешно пройдет верификацию (такой код может генерироваться компилятором C#).

    Верифицированный код - это код, для которого верификатор доказал, что он не разрушает память. То есть чтобы из верифицируемого кода получить верифицированный код, нужно обязательно запустить верификатор.

    Алгоритм верификации

    В спецификации CLI предложен базовый вариант алгоритма верификации. Любая реализация CLI должна включать верификатор, верифицирующий по крайней мере те программы, которые допускает базовый алгоритм.

    Алгоритм верификации представляет собой вариант абстрактного интерпретатора CIL-кода. Линейность алгоритма верификации достигается за счет того, что он просматривает тело метода последовательно инструкция за инструкцией (при этом ни одна инструкция не обрабатывается дважды).

    Совместимость типов

    Пусть S и T - типы. Тогда S[] и T[] - соответствующие им массивные типы, а S и T - типы соответствующих управляемых указателей.

    Тот факт, что S совместим по присваиванию с T, мы будем записывать как S := T.

    Операция := рефлексивна и транзитивна.

    Правила совместимости типов:

  • S := T, если S - базовый класс для T или интерфейс, реализуемый T, и при этом T не является типом-значением.
  • S := T, если S и T - интерфейсы, и реализация T требует реализации S.
  • S := null, если S - объектный тип или интерфейс.
  • S[] := T[], если S := T и размерности массивов совпадают.
  • S := T, если S := T.
  • Если ни одно из этих правил не выполняется, то типы S и T несовместимы.

    Конфигурации стека

    Будем называть конфигурацией стека данные о количестве слотов на стеке и типы значений, лежащих в этих слотах. При этом конфигурацию, содержащую 0 слотов, будем называть пустой конфигурацией.

    Рассмотрим две операции над конфигурациями, которые используются в алгоритме верификации:

  • проверка совместимости двух конфигураций;
  • слияние двух конфигураций.
  • Операция проверки совместимости имеет два операнда: конфигурации C и K. Она возвращает булевское значение, показывающее, совместима ли конфигурация C с конфигурацией K.

    Приведем алгоритм вычисления операции проверки совместимости:

  • Если количество слотов в конфигурациях C и K различно, то возвращаем false. В противном случае пусть N - количество слотов в конфигурации C (естественно, и в K тоже).
  • Если N = 0, то возвращаем true.
  • Пусть i пробегает значения от 1 до N. Тогда для каждого такого i выполняем следующее:
  • пусть S - тип i-го слота конфигурации C, а T - тип i-го слота конфигурации K ;
  • если не T := S, то возвращаем false.
  • Возвращаем true.
  • Операция слияния двух конфигураций также имеет два операнда: конфигурации C и K. Она может либо закончиться неуспехом, либо возвращает конфигурацию R, вычисляемую по следующему алгоритму:

  • Если количество слотов в конфигурациях C и K различно, то алгоритм завершается неуспехом. В противном случае пусть N - количество слотов в конфигурации C (естественно, и в K тоже).
  • Если N = 0, то возвращаем пустую конфигурацию.
  • Пусть i пробегает значения от 1 до N. Тогда для каждого такого i выполняем следующее:
  • пусть S - тип i-го слота конфигурации C, а T - тип i-го слота конфигурации K.
  • вычисляем тип U i-го слота результирующей конфигурации:
  • если S := T, то U = S.
  • в противном случае, если T := S, то U = T.
  • в противном случае, если T и S - объектные типы и у них существует ближайший базовый тип V, то U = V.
  • в противном случае, алгоритм завершается неуспехом.
  • Возвращаем результирующую конфигурацию.
  • Описание алгоритма

    В процессе работы алгоритма для каждой инструкции CIL вычисляется конфигурация стека. При этом фактические значения, лежащие на стеке, в локальных переменных и параметрах метода, не учитываются.

    Алгоритм верификации работает со следующими данными:

  • Неизменяемые данные:
  • Метаданные сборки, в том числе количество и типы локальных переменных и параметров метода.
  • Массив P, содержащий инструкции CIL в том порядке, в каком они записаны в теле метода. Пусть N - количество инструкций.
  • Изменяемые данные:
  • Массив M размера N, в котором хранятся вычисляемые в процессе верификации конфигурации стека.
  • Возможны два результата работы алгоритма:

  • Успешная верификация.

    Метод не содержит неверифицируемых инструкций. Все возможные пути передачи управления в теле метода рассмотрены. Для каждой инструкции вычислена конфигурация стека.

  • Неуспешная верификация.

    Метод либо содержит неверифицируемые инструкции, либо в процессе вычисления конфигурации стека для некоторой инструкции было выявлено противоречие.

  • В начале работы алгоритма в массиве M не записано ни одной конфигурации.

    Алгоритм последовательно просматривает массив P, то есть на каждом шаге работает с одной инструкцией P[i], где i пробегает значения от 1 до N (будем считать, что массивы P и M нумеруются, начиная с единицы).

    На каждом шаге алгоритма выполняются следующие действия:

  • Итак, P[i] - текущая рассматриваемая инструкция. Тогда смотрим, что собой представляет M[i]. Если M[i] еще не содержит конфигурацию стека, то записываем в M[i] пустую конфигурацию.
  • Проверяем, верифицируема ли инструкция P[i], учитывая конфигурацию стека M[i] и информацию, содержащуюся в метаданных. Если инструкция в принципе не верифицируема или не может быть выполнена при заданной конфигурации стека, то алгоритм завершается неуспехом.
  • Осуществляем абстрактное выполнение инструкции P[i]. Другими словами, вычисляем конфигурацию стека S, которая получилась бы после реального выполнения инструкции.
  • Определяем, на какие инструкции может быть передано выполнение после инструкции P[i]. Для каждой такой инструкции P[j] выполняем следующее:
  • если j < i, то проверяем, совместима ли конфигурация S с конфигурацией M[j]. Если оказывается, что несовместима, то алгоритм завершается неуспехом;
  • если j >= i и M[j] еще не содержит конфигурации стека, то M[j] = S ;
  • если j >= i и M[j] уже содержит конфигурацию стека, то выполняем попытку слияния конфигураций S и M[j]. Если попытка увенчалась успехом, то записываем результат слияния в M[j]. В противном случае алгоритм завершается неуспехом.
  • Если все инструкции были просмотрены и ни на одном шаге алгоритм не завершился неуспехом, то метод успешно прошел верификацию.

    Библиотеки для создания метаинструментов

    Во второй главе мы рассмотрели написанную на языке C программу, осуществлявшую генерацию сборки .NET. Эта программа не использовала никаких библиотек для работы с метаданными и CIL-кодом, то есть задача генерации сборки была решена на самом низком уровне - на уровне двоичных форматов. Хотя такой подход во многих случаях бывает оправдан, на практике гораздо удобнее воспользоваться специальными библиотеками, предназначенными для разработчиков метаинструментов.

    Напомним, что метаинструменты для платформы .NET - это программы, рассматривающие сборки .NET в качестве объектов анализа, генерации или преобразования. При этом анализ существующей сборки выполняется верификаторами, загрузчиками, отладчиками и т.д. Генерация новой сборки осуществляется компиляторами и RAD-средствами. Преобразование сборки выполняется оптимизаторами и средствами, затрудняющими декомпиляцию и взлом сборки.

    Сборка .NET представляет собой совокупность метаданных, CIL-кода и, возможно, ресурсов. Поэтому библиотеки, необходимые для создания метаинструментов, должны обеспечивать чтение и генерацию этой информации. В составе .NET Framework SDK поставляются две библиотеки, частично решающие эти задачи. Это Metadata Unmanaged API и библиотека рефлексии (Reflection API). Кроме того, существуют созданные сторонними разработчиками библиотеки AbsIL SDK и Reflection Extension API. В данном разделе проведем обзор этих библиотек.

    Metadata Unmanaged API

    Metadata Unmanaged API осуществляет импорт и генерацию метаданных. Это API, как явствует из его названия, работает не под управлением .NET Runtime. Оно предназначено, главным образом, для использования в компиляторах и загрузчиках, которым требуется высокая скорость доступа к метаданным и работа с метаданными на низком уровне.

    При использовании Metadata Unmanaged API метаинструмент работает с образом сборки в памяти (в документации такой образ называется scope). Метаинструмент может создать образ новой сборки или загрузить существующую сборку из файла, а кроме того, может сохранить образ сборки в файл. Навигация через иерархию метаданных осуществляется на достаточно низком уровне с использованием токенов метаданных (напомним, что токен некоторого элемента метаданных - это 32-разрядное число, старший байт которого обозначает таблицу, в которой хранятся элементы метаданных соответствующего типа, а остальные три байта являются индексом элемента в этой таблице). Metadata Unmanaged API предоставляет прямой доступ к таблицам метаданных, позволяет сливать несколько сборок в одну, но не содержит явных средств для работы с CIL-кодом. Кроме того, метаинструмент, использующий это API, должен быть написан на C++.

    Для того чтобы использовать Metadata Unmanaged API из программы, написанной на Visual C++, необходимо включить в программу следующие строки:

    #include <corhlpr.h>
    #pragma comment(lib, "format.lib")

    Первая строка подключает заголовочный файл, в котором описываются нужные интерфейсы, а также вспомогательные структуры данных и функции. Вторая строка дает указание компоновщику использовать библиотеку format.lib.

    Взаимодействие с Metadata Unmanaged API осуществляется через набор COM-интерфейсов. Они перечислены в таблице 4.2.

    Интерфейсы Metadata Unmanaged API
    Интерфейс Описание
    IMetadataDispenserEx Позволяет загружать образ исполняемого либо объектного файла в память или создавать новый образ
    IMetadataImport Предназначен для чтения метаданных
    IMetadataEmit Предназначен для генерации метаданных

    Работа с Metadata Unmanaged API начинается с инициализации системы COM и получения указателя на интерфейс IMetadataDispenserEx:

    CoInitialize(NULL);
    IMetaDataDispenser *dispenser;
    HRESULT h = CoCreateInstance(
     CLSID_CorMetaDataDispenser,
     NULL,
     CLSCTX_INPROC_SERVER, 
     IID_IMetaDataDispenserEx,
     (void **)dispenser
     );
    if (h)
     printf("Error");

    Затем можно с помощью метода OpenScope этого интерфейса загрузить существующую сборку в память или с помощью метода DefineScope создать новую сборку. Оба метода возвращают указатель на интерфейс, через который можно в дальнейшем осуществлять чтение или генерацию метаданных. В следующем фрагменте программы происходит вызов метода OpenScope для чтения метаданных из сборки test.exe:

    IMetaDataImport *mdimp;
    HRESULT h = dispenser->OpenScope(
     L"test.exe",
     0,
     IID_IMetaDataImport,
     (IUnknown**)mdimp
     );
    if (h)
     printf("Error");

    При завершении работы с Metadata Unmanaged API необходимо освободить указатели на полученные интерфейсы:

    mdimp->Release();
    dispenser->Release();

    Чтение метаданных из сборки осуществляется через методы интерфейса IMetadataImport, которые условно можно разделить на три основные группы:

  • Методы EnumXXX возвращают массивы токенов, описывающих определенную категорию элементов метаданных. Ответственность за выделение достаточного количества памяти для хранения возвращаемого массива токенов лежит на программисте, использующем библиотеку, а так как количество токенов заранее неизвестно, то приходится использовать прием, проиллюстрированный следующим примером. В нем мы получаем массив токенов, который соответствует типам, объявленным в сборке (другими словами, получаем содержимое таблицы TypeDef):
    mdTypeDef tmp;
    mdTypeDef *tokens;
    HCORENUM Enum = 0;
    unsigned long tokenCount;
    mdimp->EnumTypeDefs(Enum,tmp,1,tokenCount);
    mdimp->CountEnum(Enum,tokenCount);
    if (tokenCount > 0)
    {
      tokens = new mdTypeDef [tokenCount];
      tokens[0] = tmp;
      if (tokenCount > 1)
    	mdimp->EnumTypeDefs(
    	Enum,tokens+1,tokenCount-1,
    	tokenCount
    	);
      mdimp->CloseEnum(Enum);
    }

    Первый вызов метода EnumTypeDefs создает дескриптор массива токенов (он записывается в переменную Enum ), а также возвращает первый элемент этого массива (он сохраняется в переменной tmp ).

    Затем метод CountEnum записывает в переменную tokenCount размер массива токенов, после чего выделяется нужное количество памяти (для массива tokens ) и второй раз вызывается метод EnumTypeDefs. Обратите внимание, что первому элементу массива tokens мы присваиваем значение переменной tmp.

  • Методы FindXXX предназначены для поиска элементов метаданных, удовлетворяющих некоторым критериям. Например, поиск типа по его имени ("MyType1") проводится следующим образом:
    mdTypeDef token;
    mdimp->FindTypeByName(L"MyType1",NULL,token);
  • Методы GetXXX используются для получения свойств элементов метаданных. Например, получение содержимого строки, токен которой хранится в переменной strToken, выглядит так:
    unsigned short s[1024];
    unsigned long len;
    mdimp->GetUserString(strToken,s,1024,len);

    Обратите внимание, что для хранения одного символа применяется тип unsigned short. Причина в том, что для представления строковых данных в .NET используется 16-разрядная кодировка Unicode.

  • Генерация метаданных осуществляется через методы интерфейса IMetadataEmit, которые можно условно разделить на две группы:

  • Методы DefineXXX добавляют новые элементы метаданных;
  • Методы SetXXX устанавливают свойства элементов метаданных.
  • Сгенерированные метаданные могут быть сохранены на диске при помощи метода Save.

    Reflection API

    Библиотека рефлексии содержит классы для работы с метаданными на высоком уровне (эти классы располагаются в пространствах имен System.Reflection и System.Reflection.Emit ). Она соответствует спецификации CLS, поэтому использующий ее метаинструмент может быть написан на любом языке платформы .NET, который является потребителем CLS-библиотек.

    Чтение метаданных через рефлексию

    Метаинструмент, использующий библиотеку рефлексии, получает доступ к метаданным сборки через экземпляр класса Assembly, который создается при загрузке сборки в память. Затем через методы класса Assembly можно получить объекты класса Module, которые соответствуют модулям, входящим в сборку. Класс Module, в свою очередь, позволяет получить набор экземпляров класса Type для входящих в модуль типов, а уже через эти объекты можно добраться до конструкторов (класс ConstructorInfo ), методов (класс MethodInfo ) и полей (класс FieldInfo ) типов. То есть библиотека рефлексии спроектирована таким образом, что создание объектов рефлексии, соответствующих элементам метаданных, осуществляется путем вызова методов других объектов рефлексии (схема получения доступа к объектам рефлексии показана на рис. 4.5, при этом сплошные стрелки обозначают основные пути получения доступа, а пунктирные - дополнительные пути). Другими словами, конструкторы классов, входящих в библиотеку, для пользователя библиотеки недоступны. Информацию о любом элементе метаданных можно прочитать из свойств соответствующего ему объекта рефлексии.

    (рис 4.5) Получение доступа к объектам рефлексии

    Экземпляр класса Assembly, с создания которого, как правило, начинается работа со сборкой через рефлексию, можно получить разными способами:

  • Если нас интересует текущая работающая сборка, мы можем вызвать статический метод GetExecutingAssembly класса Assembly:
    Assembly assembly = Assembly.GetExecutingAssembly()
  • Если мы хотим получить доступ к сборке, в которой объявлен некоторый тип данных, мы можем воспользоваться статическим методом GetAssembly:
    Assembly assembly = Assembly.GetAssembly(typeof(int))
  • И, наконец, если нам надо загрузить внешнюю сборку, мы используем статический метод LoadFrom:
    Assembly assembly = Assembly.LoadFrom("test.exe")
  • Сборка .NET, как правило, состоит из одного модуля, хотя существует возможность включения в сборку сразу нескольких модулей. Получение доступа к объектам класса Module, описывающим модули, из которых состоит сборка, осуществляется одним из двух способов:

  • Если мы знаем имя модуля, то мы можем использовать метод GetModule класса Assembly:
    Module module = assembly.GetModule("SomeModule.exe")
  • Кроме того, мы можем вызвать метод GetModules для получения массива объектов класса Module, соответствующих всем модулям в сборке:
    Module[] modules = assembly.GetModules(false)
  • Через объект класса Module мы можем обратиться к глобальным полям и функциям модуля (глобальные поля и функции не принадлежат ни одному классу), используя методы GetField и GetFields для полей, а также GetMethod и GetMethods для функций. При этом полям будут соответствовать объекты класса FieldInfo, а методам - объекты класса MethodInfo. В следующем примере мы выводим на экран сигнатуры всех глобальных функций некоторой сборки "test.exe":

    Assembly assembly = Assembly.LoadFrom("test.exe");
    Module[] modules = assembly.GetModules(false);
    foreach (Module mod in modules)
    {
      MethodInfo[] methods = mod.GetMethods();
      foreach (MethodInfo met in methods)
        Console.WriteLine(met);
    }

    Особое место в библиотеке рефлексии занимает класс Type, представляющий типы. Область применения этого класса значительно шире, чем области применения остальных классов рефлексии, и этот факт отражен в том обстоятельстве, что класс Type входит в пространство имен System, а не System.Reflection.

    В каждом классе существует унаследованный от класса System.Object метод GetType, возвращающий экземпляр класса Type, который описывает этот класс. В следующем примере метод GetType будет использован для динамического определения типа объекта o (так как этот объект представляет собой строку, то на печать будет выведено сообщение "Sytem.String"):

    object o = new String("qwerty");
    Type t = o.GetType();
    Console.WriteLine(t);

    В C# определен специальный оператор typeof, который возвращает объект класса Type. Например, если нам нужно получить объект Type, представляющий тип массива строк, мы можем записать:

    Type t = typeof(string[]);

    Кроме этого, доступ к информации о типе можно получить, зная имя этого типа:

  • путем вызова статического метода GetType класса Type:
    Type t = Type.GetType("System.Char");
  • через объекты классов Assembly или Module, которые соответствуют сборке или модулю, содержащему нужный тип:
  • Type t = module.GetType("Class1") ;
  • Type t = assembly.GetType("Class1").
  • Мы также можем воспользоваться методами GetTypes классов Assembly и Module для получения массива типов, объявленных в сборке или модуле. В следующем примере на экран выводится список типов, содержащихся в сборке "test.exe":

    Assembly assembly = Assembly.LoadFrom("test.exe");
    Type[] types = assembly.GetTypes();
    foreach (Type t in types)
      Console.WriteLine(t);

    Имея объект рефлексии, соответствующий некоторому типу, мы имеем возможность получить доступ к объектам рефлексии, описывающим его поля, конструкторы, методы, свойства и вложенные типы. При этом, если мы знаем их имена, мы можем воспользоваться методами GetField, GetConstructor, GetMethod, GetProperty и GetNestedType класса Type. А если мы хотим получить массивы объектов рефлексии, описывающих члены типа, то нам нужно вызвать методы GetFields, GetConstructors, GetMethods, GetProperties и GetNestedTypes. В следующем примере на экран выводится список типов, содержащихся в сборке, вместе с сигнатурами их методов:

    Assembly assembly = Assembly.LoadFrom("test.exe");
    Type[] types = assembly.GetTypes();
    foreach (Type t in types)
    {
      Console.WriteLine(""+t+":");
      MethodInfo[] methods = t.GetMethods();
      foreach (MethodInfo m in methods)
        Console.WriteLine("  "+m);
    }

    Для каждого метода и конструктора мы можем получить доступ к массиву объектов рефлексии, описывающих его параметры. Для этого служит метод GetParameters, объявленный в классах MethodInfo и ConstructorInfo. Данный метод возвращает массив объектов класса ParameterInfo:

    ParameterInfo[] parms = method.GetParameters();

    Управление объектами

    Кроме чтения метаданных, библиотека рефлексии позволяет создавать экземпляры типов, входящих в обрабатываемую сборку, вызывать методы этих типов, читать и изменять значения полей.

    Рассмотрим класс TestClass:

    class TestClass
    {
      private int val;
      public TestClass() { val = 7; }
      protected void print() { Console.WriteLine(val); }
    }

    В следующем примере мы используем метод Invoke класса MethodInfo для вызова метода print объекта класса TestClass. Обратите внимание, что метод print объявлен с модификатором доступа protected.

    static void CallMethodDemo()
    {
      TestClass a = new TestClass();
      BindingFlags flags = (BindingFlags)
       (BindingFlags.NonPublic | BindingFlags.Instance);
      MethodInfo printer =
        typeof(TestClass).GetMethod("print",flags);
      printer.Invoke(a, null);
    }

    Путем внесения небольших изменений в CallMethodDemo мы можем добиться того, что объект класса TestClass будет также создаваться через рефлексию (с помощью вызова его конструктора):

    static void CallMethodDemo2()
    {
      Type t = typeof(TestClass);
      BindingFlags flags = (BindingFlags)
       (BindingFlags.NonPublic | BindingFlags.Instance);
      ConstructorInfo ctor = t.GetConstructor(new Type[0]);
      MethodInfo printer = t.GetMethod("print",flags);
      object a = ctor.Invoke(null);
      printer.Invoke(a, null);
    }

    А теперь продемонстрируем, как через рефлексию можно получить значение поля объекта. Это достигается путем вызова метода GetField объекта класса Type:

    static void GetFieldDemo()
    {
      TestClass a = new TestClass();
      BindingFlags flags = (BindingFlags)
        (BindingFlags.NonPublic | 
         BindingFlags.Instance);
      FieldInfo val = 
        typeof(TestClass).GetField("val",flags);
      Console.WriteLine(val.GetValue(a));
    }

    Генерация метаданных и CIL-кода

    Для генерации метаданных в библиотеке рефлексии предназначены классы пространства имен System.Reflection.Emit. Создание новой сборки начинается с создания экземпляра класса AssemblyBuilder. Далее путем вызова методов класса AssemblyBuilder создается нужное количество модулей (объектов класса ModuleBuilder ), в модулях создаются типы (объекты класса TypeBuilder ), а в типах - конструкторы, методы и поля (объекты классов ConstructorBuilder, MethodBuilder и FieldBuilder, соответственно). То есть при генерации метаданных идеология такая же, как и при чтении их из готовой сборки (на рис. 4.6 представлена схема, показывающая последовательность создания метаданных).

    (рис 4.6) Последовательность создания метаданных

    В отличие от Metadata Unmanaged API, библиотека рефлексии содержит средства для генерации CIL-кода. Для этого предназначен класс ILGenerator. Он позволяет после создания объекта MethodBuilder (или ConstructorBuilder ) сгенерировать для соответствующего метода (или конструктора) поток инструкций.

    В следующем примере библиотека рефлексии используется для генерации сборки .NET, состоящей из одного глобального метода main, выводящего на экран сообщение "Hello, World!":

    using System;
    using System.Threading;
    using System.Reflection;
    using System.Reflection.Emit;
    class HelloGenerator
    {
      static void Main(string[] args)
      {
        AppDomain appDomain = Thread.GetDomain();
        AssemblyName assemblyName = new AssemblyName();
        assemblyName.Name = "hello.exe";
        AssemblyBuilder assembly = 
          appDomain.DefineDynamicAssembly(
            assemblyName,
            AssemblyBuilderAccess.RunAndSave
            );
        ModuleBuilder module = 
          assembly.DefineDynamicModule(
            "hello.exe", "hello.exe"
            );
        MethodBuilder mainMethod =
          module.DefineGlobalMethod(
            "main",
            MethodAttributes.Static |
              MethodAttributes.Public, 
            typeof(void), 
            null
            );
        MethodInfo ConsoleWriteLineMethod = 
          ((typeof(Console)).GetMethod("WriteLine", 
            new Type[] { typeof(string) } ));
        ILGenerator il = mainMethod.GetILGenerator();
        il.Emit(OpCodes.Ldstr,"Hello, World!");
        il.Emit(OpCodes.Call,ConsoleWriteLineMethod);
        il.Emit(OpCodes.Ret);
        module.CreateGlobalFunctions();
        assembly.SetEntryPoint(
          mainMethod,PEFileKinds.ConsoleApplication
          );
        assembly.Save("hello.exe");
      }
    }

    Сравнение возможностей библиотек

    Возможности, предоставляемые библиотекой Metadata Unmanaged API и библиотекой рефлексии, представлены в таблице 4.3.

    Сравнение возможностей библиотек
    Metadata Unmanaged API Reflection API
    Чтение метаданных + +
    Генерация метаданных + +
    Чтение CIL-кода - -
    Генерация CIL-кода - +

    Из таблицы следует, что ни одна из библиотек, поставляемых вместе с .NET Framework, не позволяет читать CIL-код, хотя эта возможность требуется целому ряду метаинструментов (например, верификаторам и оптимизаторам кода).

    Страницы:

    Говорят, что код программ не разрушает память, если эти программы, будучи запущенными в одном адресном пространстве, корректно изолированы друг от друга, то есть ошибки в одной программе не могут привести к изменению структур данных другой программы.

    Верификация кода - это процесс автоматического доказательства того, что этот код не разрушает память.

    В этой главе мы рассмотрим общие вопросы верификации кода и алгоритм верификации, приведенный в спецификации CLI и реализованный в Microsoft .NET Framework.

    Классификация применяемых на практике алгоритмов верификации

    Алгоритмы верификации являются метавычислительными алгоритмами (верификаторы кода стоят в одном ряду с такими программами, как частичные вычислители и суперкомпиляторы). Поэтому совершенно неудивительно, что алгоритмы верификации в общем случае имеют проблемы, свойственные любым метавычислительным алгоритмам, а именно: экспоненциальная сложность и потенциальная недетерминируемость.

    Понятно, что алгоритмы, работающие неизвестно сколько времени (а возможно, вообще никогда не завершающиеся) и потребляющие неизвестно какое количество ресурсов, представляют скорее теоретический, нежели практический интерес. Поэтому их разработчикам приходится каким-то образом ограничивать задачу, чтобы получить применимые на практике результаты. Для алгоритмов верификации кода известно два подхода, позволяющие снизить их сложность и добиться терминируемости:

  • Концентрация усилий на выявлении только таких ошибок в программах, поиск которых не требует экспоненциальной сложности и выполняется за конечное время.

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

  • Работа лишь с некоторым подмножеством программ, для которых факт неразрушения ими памяти можно доказать за конечное время с использованием алгоритма приемлемой сложности. Этот тип верификаторов отличается тем, что существуют не разрушающие память программы, которые не могут быть доказаны алгоритмом.
  • Если задача некоторого метавычислительного алгоритма была как-то ограничена, то сразу же возникает вопрос о том, какие входные данные (программы) он сможет обработать. Для суперкомпиляторов и частичных вычислителей этот вопрос до сих пор остается открытым (ответ на него приходится искать методом проб и ошибок). Для верификаторов ответ зависит от того, какой подход (первый или второй) был выбран для ограничения задачи:

  • Верификаторы, предназначенные для выявления определенного класса ошибок в программах, используются при разработке программ на этапе тестирования. Тем самым, на вход таких верификаторов подаются любые программы в надежде, что в них будут обнаружены ошибки.

    Запуск такого верификатора можно рассматривать как один из тестов, которым подвергается программа. Если в некоторой программе верификатор не обнаружил ни одной ошибки, то можно считать, что его запуск был напрасным.

  • Верификаторы, способные доказать, что программа, принадлежащая некоторому классу программ, не разрушает память, служат для обеспечения безопасности. Они гарантируют, что запуск прошедшей верификацию программы не может привести к сбою всей системы.

    Использование таких верификаторов оправдано и удобно в случаях, если существуют специальные компиляторы, генерирующие такой код, который не только заведомо не разрушает память, но и гарантированно проходит верификацию.

    Тем самым ответ на вопрос о том, для каких программ верификатор может доказать, что они не разрушают память, становится тривиально прост: для тех программ, которые были откомпилированы специальным компилятором.

  • Особенности верификатора кода, используемого в .NET

    Верификатор кода .NET относится ко второму типу. Он может доказать, что сборки, генерируемые компиляторами C#, J# и Visual Basic .NET, не разрушают память. При этом он терминируем и имеет линейную сложность.

    Здесь может возникнуть следующий вопрос: зачем нужен верификатор, если компиляторы и так генерируют не разрушающий память код? Чтобы ответить на этот вопрос, нужно вспомнить, что:

  • существуют компиляторы (например, Visual C++ with Managed Extensions), которые в общем случае могут порождать код, разрушающий память;
  • в языке C# предусмотрены unsafe-блоки и unsafe-методы, внутри которых может находиться код, способный разрушить память;
  • любой CIL-код, в том числе и разрушающий память, можно непосредственно компилировать с помощью ILASM;
  • всеми этими возможностями может воспользоваться злоумышленник для написания вредоносного кода.
  • Другими словами, верификатор .NET предназначен не для поиска ошибок в программах, а для обеспечения безопасности системы.

    Мы будем различать два достаточно близких понятия, относящихся к верификации. Это верифицированный код и верифицируемый код.

    Верифицируемый код - это код, для которого верификатор может доказать, что он не разрушает память. Другими словами, для верифицируемого кода можно заранее, еще до запуска верификатора, сказать, что он успешно пройдет верификацию (такой код может генерироваться компилятором C#).

    Верифицированный код - это код, для которого верификатор доказал, что он не разрушает память. То есть чтобы из верифицируемого кода получить верифицированный код, нужно обязательно запустить верификатор.

    Алгоритм верификации

    В спецификации CLI предложен базовый вариант алгоритма верификации. Любая реализация CLI должна включать верификатор, верифицирующий по крайней мере те программы, которые допускает базовый алгоритм.

    Алгоритм верификации представляет собой вариант абстрактного интерпретатора CIL-кода. Линейность алгоритма верификации достигается за счет того, что он просматривает тело метода последовательно инструкция за инструкцией (при этом ни одна инструкция не обрабатывается дважды).

    Совместимость типов

    Пусть S и T - типы. Тогда S[] и T[] - соответствующие им массивные типы, а S и T - типы соответствующих управляемых указателей.

    Тот факт, что S совместим по присваиванию с T, мы будем записывать как S := T.

    Операция := рефлексивна и транзитивна.

    Правила совместимости типов:

  • S := T, если S - базовый класс для T или интерфейс, реализуемый T, и при этом T не является типом-значением.
  • S := T, если S и T - интерфейсы, и реализация T требует реализации S.
  • S := null, если S - объектный тип или интерфейс.
  • S[] := T[], если S := T и размерности массивов совпадают.
  • S := T, если S := T.
  • Если ни одно из этих правил не выполняется, то типы S и T несовместимы.

    Конфигурации стека

    Будем называть конфигурацией стека данные о количестве слотов на стеке и типы значений, лежащих в этих слотах. При этом конфигурацию, содержащую 0 слотов, будем называть пустой конфигурацией.

    Рассмотрим две операции над конфигурациями, которые используются в алгоритме верификации:

  • проверка совместимости двух конфигураций;
  • слияние двух конфигураций.
  • Операция проверки совместимости имеет два операнда: конфигурации C и K. Она возвращает булевское значение, показывающее, совместима ли конфигурация C с конфигурацией K.

    Приведем алгоритм вычисления операции проверки совместимости:

  • Если количество слотов в конфигурациях C и K различно, то возвращаем false. В противном случае пусть N - количество слотов в конфигурации C (естественно, и в K тоже).
  • Если N = 0, то возвращаем true.
  • Пусть i пробегает значения от 1 до N. Тогда для каждого такого i выполняем следующее:
  • пусть S - тип i-го слота конфигурации C, а T - тип i-го слота конфигурации K ;
  • если не T := S, то возвращаем false.
  • Возвращаем true.
  • Операция слияния двух конфигураций также имеет два операнда: конфигурации C и K. Она может либо закончиться неуспехом, либо возвращает конфигурацию R, вычисляемую по следующему алгоритму:

  • Если количество слотов в конфигурациях C и K различно, то алгоритм завершается неуспехом. В противном случае пусть N - количество слотов в конфигурации C (естественно, и в K тоже).
  • Если N = 0, то возвращаем пустую конфигурацию.
  • Пусть i пробегает значения от 1 до N. Тогда для каждого такого i выполняем следующее:
  • пусть S - тип i-го слота конфигурации C, а T - тип i-го слота конфигурации K.
  • вычисляем тип U i-го слота результирующей конфигурации:
  • если S := T, то U = S.
  • в противном случае, если T := S, то U = T.
  • в противном случае, если T и S - объектные типы и у них существует ближайший базовый тип V, то U = V.
  • в противном случае, алгоритм завершается неуспехом.
  • Возвращаем результирующую конфигурацию.
  • Описание алгоритма

    В процессе работы алгоритма для каждой инструкции CIL вычисляется конфигурация стека. При этом фактические значения, лежащие на стеке, в локальных переменных и параметрах метода, не учитываются.

    Алгоритм верификации работает со следующими данными:

  • Неизменяемые данные:
  • Метаданные сборки, в том числе количество и типы локальных переменных и параметров метода.
  • Массив P, содержащий инструкции CIL в том порядке, в каком они записаны в теле метода. Пусть N - количество инструкций.
  • Изменяемые данные:
  • Массив M размера N, в котором хранятся вычисляемые в процессе верификации конфигурации стека.
  • Возможны два результата работы алгоритма:

  • Успешная верификация.

    Метод не содержит неверифицируемых инструкций. Все возможные пути передачи управления в теле метода рассмотрены. Для каждой инструкции вычислена конфигурация стека.

  • Неуспешная верификация.

    Метод либо содержит неверифицируемые инструкции, либо в процессе вычисления конфигурации стека для некоторой инструкции было выявлено противоречие.

  • В начале работы алгоритма в массиве M не записано ни одной конфигурации.

    Алгоритм последовательно просматривает массив P, то есть на каждом шаге работает с одной инструкцией P[i], где i пробегает значения от 1 до N (будем считать, что массивы P и M нумеруются, начиная с единицы).

    На каждом шаге алгоритма выполняются следующие действия:

  • Итак, P[i] - текущая рассматриваемая инструкция. Тогда смотрим, что собой представляет M[i]. Если M[i] еще не содержит конфигурацию стека, то записываем в M[i] пустую конфигурацию.
  • Проверяем, верифицируема ли инструкция P[i], учитывая конфигурацию стека M[i] и информацию, содержащуюся в метаданных. Если инструкция в принципе не верифицируема или не может быть выполнена при заданной конфигурации стека, то алгоритм завершается неуспехом.
  • Осуществляем абстрактное выполнение инструкции P[i]. Другими словами, вычисляем конфигурацию стека S, которая получилась бы после реального выполнения инструкции.
  • Определяем, на какие инструкции может быть передано выполнение после инструкции P[i]. Для каждой такой инструкции P[j] выполняем следующее:
  • если j < i, то проверяем, совместима ли конфигурация S с конфигурацией M[j]. Если оказывается, что несовместима, то алгоритм завершается неуспехом;
  • если j >= i и M[j] еще не содержит конфигурации стека, то M[j] = S ;
  • если j >= i и M[j] уже содержит конфигурацию стека, то выполняем попытку слияния конфигураций S и M[j]. Если попытка увенчалась успехом, то записываем результат слияния в M[j]. В противном случае алгоритм завершается неуспехом.
  • Если все инструкции были просмотрены и ни на одном шаге алгоритм не завершился неуспехом, то метод успешно прошел верификацию.

    Библиотеки для создания метаинструментов

    Во второй главе мы рассмотрели написанную на языке C программу, осуществлявшую генерацию сборки .NET. Эта программа не использовала никаких библиотек для работы с метаданными и CIL-кодом, то есть задача генерации сборки была решена на самом низком уровне - на уровне двоичных форматов. Хотя такой подход во многих случаях бывает оправдан, на практике гораздо удобнее воспользоваться специальными библиотеками, предназначенными для разработчиков метаинструментов.

    Напомним, что метаинструменты для платформы .NET - это программы, рассматривающие сборки .NET в качестве объектов анализа, генерации или преобразования. При этом анализ существующей сборки выполняется верификаторами, загрузчиками, отладчиками и т.д. Генерация новой сборки осуществляется компиляторами и RAD-средствами. Преобразование сборки выполняется оптимизаторами и средствами, затрудняющими декомпиляцию и взлом сборки.

    Сборка .NET представляет собой совокупность метаданных, CIL-кода и, возможно, ресурсов. Поэтому библиотеки, необходимые для создания метаинструментов, должны обеспечивать чтение и генерацию этой информации. В составе .NET Framework SDK поставляются две библиотеки, частично решающие эти задачи. Это Metadata Unmanaged API и библиотека рефлексии (Reflection API). Кроме того, существуют созданные сторонними разработчиками библиотеки AbsIL SDK и Reflection Extension API. В данном разделе проведем обзор этих библиотек.

    Metadata Unmanaged API

    Metadata Unmanaged API осуществляет импорт и генерацию метаданных. Это API, как явствует из его названия, работает не под управлением .NET Runtime. Оно предназначено, главным образом, для использования в компиляторах и загрузчиках, которым требуется высокая скорость доступа к метаданным и работа с метаданными на низком уровне.

    При использовании Metadata Unmanaged API метаинструмент работает с образом сборки в памяти (в документации такой образ называется scope). Метаинструмент может создать образ новой сборки или загрузить существующую сборку из файла, а кроме того, может сохранить образ сборки в файл. Навигация через иерархию метаданных осуществляется на достаточно низком уровне с использованием токенов метаданных (напомним, что токен некоторого элемента метаданных - это 32-разрядное число, старший байт которого обозначает таблицу, в которой хранятся элементы метаданных соответствующего типа, а остальные три байта являются индексом элемента в этой таблице). Metadata Unmanaged API предоставляет прямой доступ к таблицам метаданных, позволяет сливать несколько сборок в одну, но не содержит явных средств для работы с CIL-кодом. Кроме того, метаинструмент, использующий это API, должен быть написан на C++.

    Для того чтобы использовать Metadata Unmanaged API из программы, написанной на Visual C++, необходимо включить в программу следующие строки:

    #include <corhlpr.h>
    #pragma comment(lib, "format.lib")

    Первая строка подключает заголовочный файл, в котором описываются нужные интерфейсы, а также вспомогательные структуры данных и функции. Вторая строка дает указание компоновщику использовать библиотеку format.lib.

    Взаимодействие с Metadata Unmanaged API осуществляется через набор COM-интерфейсов. Они перечислены в таблице 4.2.

    Интерфейсы Metadata Unmanaged API
    Интерфейс Описание
    IMetadataDispenserEx Позволяет загружать образ исполняемого либо объектного файла в память или создавать новый образ
    IMetadataImport Предназначен для чтения метаданных
    IMetadataEmit Предназначен для генерации метаданных

    Работа с Metadata Unmanaged API начинается с инициализации системы COM и получения указателя на интерфейс IMetadataDispenserEx:

    CoInitialize(NULL);
    IMetaDataDispenser *dispenser;
    HRESULT h = CoCreateInstance(
     CLSID_CorMetaDataDispenser,
     NULL,
     CLSCTX_INPROC_SERVER, 
     IID_IMetaDataDispenserEx,
     (void **)dispenser
     );
    if (h)
     printf("Error");

    Затем можно с помощью метода OpenScope этого интерфейса загрузить существующую сборку в память или с помощью метода DefineScope создать новую сборку. Оба метода возвращают указатель на интерфейс, через который можно в дальнейшем осуществлять чтение или генерацию метаданных. В следующем фрагменте программы происходит вызов метода OpenScope для чтения метаданных из сборки test.exe:

    IMetaDataImport *mdimp;
    HRESULT h = dispenser->OpenScope(
     L"test.exe",
     0,
     IID_IMetaDataImport,
     (IUnknown**)mdimp
     );
    if (h)
     printf("Error");

    При завершении работы с Metadata Unmanaged API необходимо освободить указатели на полученные интерфейсы:

    mdimp->Release();
    dispenser->Release();

    Чтение метаданных из сборки осуществляется через методы интерфейса IMetadataImport, которые условно можно разделить на три основные группы:

  • Методы EnumXXX возвращают массивы токенов, описывающих определенную категорию элементов метаданных. Ответственность за выделение достаточного количества памяти для хранения возвращаемого массива токенов лежит на программисте, использующем библиотеку, а так как количество токенов заранее неизвестно, то приходится использовать прием, проиллюстрированный следующим примером. В нем мы получаем массив токенов, который соответствует типам, объявленным в сборке (другими словами, получаем содержимое таблицы TypeDef):
    mdTypeDef tmp;
    mdTypeDef *tokens;
    HCORENUM Enum = 0;
    unsigned long tokenCount;
    mdimp->EnumTypeDefs(Enum,tmp,1,tokenCount);
    mdimp->CountEnum(Enum,tokenCount);
    if (tokenCount > 0)
    {
      tokens = new mdTypeDef [tokenCount];
      tokens[0] = tmp;
      if (tokenCount > 1)
    	mdimp->EnumTypeDefs(
    	Enum,tokens+1,tokenCount-1,
    	tokenCount
    	);
      mdimp->CloseEnum(Enum);
    }

    Первый вызов метода EnumTypeDefs создает дескриптор массива токенов (он записывается в переменную Enum ), а также возвращает первый элемент этого массива (он сохраняется в переменной tmp ).

    Затем метод CountEnum записывает в переменную tokenCount размер массива токенов, после чего выделяется нужное количество памяти (для массива tokens ) и второй раз вызывается метод EnumTypeDefs. Обратите внимание, что первому элементу массива tokens мы присваиваем значение переменной tmp.

  • Методы FindXXX предназначены для поиска элементов метаданных, удовлетворяющих некоторым критериям. Например, поиск типа по его имени ("MyType1") проводится следующим образом:
    mdTypeDef token;
    mdimp->FindTypeByName(L"MyType1",NULL,token);
  • Методы GetXXX используются для получения свойств элементов метаданных. Например, получение содержимого строки, токен которой хранится в переменной strToken, выглядит так:
    unsigned short s[1024];
    unsigned long len;
    mdimp->GetUserString(strToken,s,1024,len);

    Обратите внимание, что для хранения одного символа применяется тип unsigned short. Причина в том, что для представления строковых данных в .NET используется 16-разрядная кодировка Unicode.

  • Генерация метаданных осуществляется через методы интерфейса IMetadataEmit, которые можно условно разделить на две группы:

  • Методы DefineXXX добавляют новые элементы метаданных;
  • Методы SetXXX устанавливают свойства элементов метаданных.
  • Сгенерированные метаданные могут быть сохранены на диске при помощи метода Save.

    Reflection API

    Библиотека рефлексии содержит классы для работы с метаданными на высоком уровне (эти классы располагаются в пространствах имен System.Reflection и System.Reflection.Emit ). Она соответствует спецификации CLS, поэтому использующий ее метаинструмент может быть написан на любом языке платформы .NET, который является потребителем CLS-библиотек.

    Чтение метаданных через рефлексию

    Метаинструмент, использующий библиотеку рефлексии, получает доступ к метаданным сборки через экземпляр класса Assembly, который создается при загрузке сборки в память. Затем через методы класса Assembly можно получить объекты класса Module, которые соответствуют модулям, входящим в сборку. Класс Module, в свою очередь, позволяет получить набор экземпляров класса Type для входящих в модуль типов, а уже через эти объекты можно добраться до конструкторов (класс ConstructorInfo ), методов (класс MethodInfo ) и полей (класс FieldInfo ) типов. То есть библиотека рефлексии спроектирована таким образом, что создание объектов рефлексии, соответствующих элементам метаданных, осуществляется путем вызова методов других объектов рефлексии (схема получения доступа к объектам рефлексии показана на рис. 4.5, при этом сплошные стрелки обозначают основные пути получения доступа, а пунктирные - дополнительные пути). Другими словами, конструкторы классов, входящих в библиотеку, для пользователя библиотеки недоступны. Информацию о любом элементе метаданных можно прочитать из свойств соответствующего ему объекта рефлексии.

    (рис 4.5) Получение доступа к объектам рефлексии

    Экземпляр класса Assembly, с создания которого, как правило, начинается работа со сборкой через рефлексию, можно получить разными способами:

  • Если нас интересует текущая работающая сборка, мы можем вызвать статический метод GetExecutingAssembly класса Assembly:
    Assembly assembly = Assembly.GetExecutingAssembly()
  • Если мы хотим получить доступ к сборке, в которой объявлен некоторый тип данных, мы можем воспользоваться статическим методом GetAssembly:
    Assembly assembly = Assembly.GetAssembly(typeof(int))
  • И, наконец, если нам надо загрузить внешнюю сборку, мы используем статический метод LoadFrom:
    Assembly assembly = Assembly.LoadFrom("test.exe")
  • Сборка .NET, как правило, состоит из одного модуля, хотя существует возможность включения в сборку сразу нескольких модулей. Получение доступа к объектам класса Module, описывающим модули, из которых состоит сборка, осуществляется одним из двух способов:

  • Если мы знаем имя модуля, то мы можем использовать метод GetModule класса Assembly:
    Module module = assembly.GetModule("SomeModule.exe")
  • Кроме того, мы можем вызвать метод GetModules для получения массива объектов класса Module, соответствующих всем модулям в сборке:
    Module[] modules = assembly.GetModules(false)
  • Через объект класса Module мы можем обратиться к глобальным полям и функциям модуля (глобальные поля и функции не принадлежат ни одному классу), используя методы GetField и GetFields для полей, а также GetMethod и GetMethods для функций. При этом полям будут соответствовать объекты класса FieldInfo, а методам - объекты класса MethodInfo. В следующем примере мы выводим на экран сигнатуры всех глобальных функций некоторой сборки "test.exe":

    Assembly assembly = Assembly.LoadFrom("test.exe");
    Module[] modules = assembly.GetModules(false);
    foreach (Module mod in modules)
    {
      MethodInfo[] methods = mod.GetMethods();
      foreach (MethodInfo met in methods)
        Console.WriteLine(met);
    }

    Особое место в библиотеке рефлексии занимает класс Type, представляющий типы. Область применения этого класса значительно шире, чем области применения остальных классов рефлексии, и этот факт отражен в том обстоятельстве, что класс Type входит в пространство имен System, а не System.Reflection.

    В каждом классе существует унаследованный от класса System.Object метод GetType, возвращающий экземпляр класса Type, который описывает этот класс. В следующем примере метод GetType будет использован для динамического определения типа объекта o (так как этот объект представляет собой строку, то на печать будет выведено сообщение "Sytem.String"):

    object o = new String("qwerty");
    Type t = o.GetType();
    Console.WriteLine(t);

    В C# определен специальный оператор typeof, который возвращает объект класса Type. Например, если нам нужно получить объект Type, представляющий тип массива строк, мы можем записать:

    Type t = typeof(string[]);

    Кроме этого, доступ к информации о типе можно получить, зная имя этого типа:

  • путем вызова статического метода GetType класса Type:
    Type t = Type.GetType("System.Char");
  • через объекты классов Assembly или Module, которые соответствуют сборке или модулю, содержащему нужный тип:
  • Type t = module.GetType("Class1") ;
  • Type t = assembly.GetType("Class1").
  • Мы также можем воспользоваться методами GetTypes классов Assembly и Module для получения массива типов, объявленных в сборке или модуле. В следующем примере на экран выводится список типов, содержащихся в сборке "test.exe":

    Assembly assembly = Assembly.LoadFrom("test.exe");
    Type[] types = assembly.GetTypes();
    foreach (Type t in types)
      Console.WriteLine(t);

    Имея объект рефлексии, соответствующий некоторому типу, мы имеем возможность получить доступ к объектам рефлексии, описывающим его поля, конструкторы, методы, свойства и вложенные типы. При этом, если мы знаем их имена, мы можем воспользоваться методами GetField, GetConstructor, GetMethod, GetProperty и GetNestedType класса Type. А если мы хотим получить массивы объектов рефлексии, описывающих члены типа, то нам нужно вызвать методы GetFields, GetConstructors, GetMethods, GetProperties и GetNestedTypes. В следующем примере на экран выводится список типов, содержащихся в сборке, вместе с сигнатурами их методов:

    Assembly assembly = Assembly.LoadFrom("test.exe");
    Type[] types = assembly.GetTypes();
    foreach (Type t in types)
    {
      Console.WriteLine(""+t+":");
      MethodInfo[] methods = t.GetMethods();
      foreach (MethodInfo m in methods)
        Console.WriteLine("  "+m);
    }

    Для каждого метода и конструктора мы можем получить доступ к массиву объектов рефлексии, описывающих его параметры. Для этого служит метод GetParameters, объявленный в классах MethodInfo и ConstructorInfo. Данный метод возвращает массив объектов класса ParameterInfo:

    ParameterInfo[] parms = method.GetParameters();

    Управление объектами

    Кроме чтения метаданных, библиотека рефлексии позволяет создавать экземпляры типов, входящих в обрабатываемую сборку, вызывать методы этих типов, читать и изменять значения полей.

    Рассмотрим класс TestClass:

    class TestClass
    {
      private int val;
      public TestClass() { val = 7; }
      protected void print() { Console.WriteLine(val); }
    }

    В следующем примере мы используем метод Invoke класса MethodInfo для вызова метода print объекта класса TestClass. Обратите внимание, что метод print объявлен с модификатором доступа protected.

    static void CallMethodDemo()
    {
      TestClass a = new TestClass();
      BindingFlags flags = (BindingFlags)
       (BindingFlags.NonPublic | BindingFlags.Instance);
      MethodInfo printer =
        typeof(TestClass).GetMethod("print",flags);
      printer.Invoke(a, null);
    }

    Путем внесения небольших изменений в CallMethodDemo мы можем добиться того, что объект класса TestClass будет также создаваться через рефлексию (с помощью вызова его конструктора):

    static void CallMethodDemo2()
    {
      Type t = typeof(TestClass);
      BindingFlags flags = (BindingFlags)
       (BindingFlags.NonPublic | BindingFlags.Instance);
      ConstructorInfo ctor = t.GetConstructor(new Type[0]);
      MethodInfo printer = t.GetMethod("print",flags);
      object a = ctor.Invoke(null);
      printer.Invoke(a, null);
    }

    А теперь продемонстрируем, как через рефлексию можно получить значение поля объекта. Это достигается путем вызова метода GetField объекта класса Type:

    static void GetFieldDemo()
    {
      TestClass a = new TestClass();
      BindingFlags flags = (BindingFlags)
        (BindingFlags.NonPublic | 
         BindingFlags.Instance);
      FieldInfo val = 
        typeof(TestClass).GetField("val",flags);
      Console.WriteLine(val.GetValue(a));
    }

    Генерация метаданных и CIL-кода

    Для генерации метаданных в библиотеке рефлексии предназначены классы пространства имен System.Reflection.Emit. Создание новой сборки начинается с создания экземпляра класса AssemblyBuilder. Далее путем вызова методов класса AssemblyBuilder создается нужное количество модулей (объектов класса ModuleBuilder ), в модулях создаются типы (объекты класса TypeBuilder ), а в типах - конструкторы, методы и поля (объекты классов ConstructorBuilder, MethodBuilder и FieldBuilder, соответственно). То есть при генерации метаданных идеология такая же, как и при чтении их из готовой сборки (на рис. 4.6 представлена схема, показывающая последовательность создания метаданных).

    (рис 4.6) Последовательность создания метаданных

    В отличие от Metadata Unmanaged API, библиотека рефлексии содержит средства для генерации CIL-кода. Для этого предназначен класс ILGenerator. Он позволяет после создания объекта MethodBuilder (или ConstructorBuilder ) сгенерировать для соответствующего метода (или конструктора) поток инструкций.

    В следующем примере библиотека рефлексии используется для генерации сборки .NET, состоящей из одного глобального метода main, выводящего на экран сообщение "Hello, World!":

    using System;
    using System.Threading;
    using System.Reflection;
    using System.Reflection.Emit;
    class HelloGenerator
    {
      static void Main(string[] args)
      {
        AppDomain appDomain = Thread.GetDomain();
        AssemblyName assemblyName = new AssemblyName();
        assemblyName.Name = "hello.exe";
        AssemblyBuilder assembly = 
          appDomain.DefineDynamicAssembly(
            assemblyName,
            AssemblyBuilderAccess.RunAndSave
            );
        ModuleBuilder module = 
          assembly.DefineDynamicModule(
            "hello.exe", "hello.exe"
            );
        MethodBuilder mainMethod =
          module.DefineGlobalMethod(
            "main",
            MethodAttributes.Static |
              MethodAttributes.Public, 
            typeof(void), 
            null
            );
        MethodInfo ConsoleWriteLineMethod = 
          ((typeof(Console)).GetMethod("WriteLine", 
            new Type[] { typeof(string) } ));
        ILGenerator il = mainMethod.GetILGenerator();
        il.Emit(OpCodes.Ldstr,"Hello, World!");
        il.Emit(OpCodes.Call,ConsoleWriteLineMethod);
        il.Emit(OpCodes.Ret);
        module.CreateGlobalFunctions();
        assembly.SetEntryPoint(
          mainMethod,PEFileKinds.ConsoleApplication
          );
        assembly.Save("hello.exe");
      }
    }

    Сравнение возможностей библиотек

    Возможности, предоставляемые библиотекой Metadata Unmanaged API и библиотекой рефлексии, представлены в таблице 4.3.

    Сравнение возможностей библиотек
    Metadata Unmanaged API Reflection API
    Чтение метаданных + +
    Генерация метаданных + +
    Чтение CIL-кода - -
    Генерация CIL-кода - +

    Из таблицы следует, что ни одна из библиотек, поставляемых вместе с .NET Framework, не позволяет читать CIL-код, хотя эта возможность требуется целому ряду метаинструментов (например, верификаторам и оптимизаторам кода).

    Вернуться к учебному плану