Редакционная коллегия: В.В. Борисенко В.С. Люцарев И.В. Машечкин А.А. Михалев Е.В. Панкратьев А.М. Чеповский В.Г. Чирски...
103 downloads
777 Views
826KB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
Редакционная коллегия: В.В. Борисенко В.С. Люцарев И.В. Машечкин А.А. Михалев Е.В. Панкратьев А.М. Чеповский В.Г. Чирский А.В. Шкред
Главный редактор серии: А.В. Михалев
МОСКОВСКИМ ГОСУДАРСТВЕННЫМ УНИВЕРСИТЕТОМ имени М.В. Ломоносова и Интернет-Университетом Информационных Технологий при поддержке корпорации Microsoft
Серия издается совместно
ОСНОВЫ ИНФОРМАТИКИ И МАТЕМАТИКИ
Серия учебных пособий по информатике и ее математическим основам открыта в 2005 году с целью современного изложения широкого спектра направлений информатики на базе соответствующих разделов математических курсов, а также примыкающих вопросов, связанных с информационными технологиями. Особое внимание предполагается уделять возможности использования материалов публикуемых пособий в преподавании информатики и ее математических основ для непрофильных специальностей. Редакционная коллегия также надеется представить вниманию читателей широкую гамму практикумов по информатике и ее математическим основам, реализующих основные алгоритмы и идеи теоретической информатики. Выпуск серии начат при поддержке корпорации Microsoft в рамках междисциплинарного научного проекта МГУ имени М.В. Ломоносова.
Информация о серии
Интернет-университет информационных технологий Москва • 2006
Допущено учебно-методическим объединением вузов по университетскому политехническому образованию в качестве учебного пособия для студентов высших учебных заведений, обучающихся по направлению «Информатика и вычислительная техника»
Common Intermediate Language и системное программирование в Microsoft .NET
А.В. Макаров, С.Ю. Скоробогатов, А.М. Чеповский
А.В. Макаров, С.Ю. Скоробогатов, А.М. Чеповский Common Intermediate Language и системное программирование в Microsoft.NET : учеб. пособие для студентов вузов, обучающихся по направлению «Информатика и вычисл. техника» / А. В. Макаров, С. Ю. Скоробогатов, А. М. Чеповский. – М. : Интернет-Ун-т Информ. Технологий, 2006. – 328 с. : ил. – ISBN 5-9556-0055-8.
© Текст: А.В. Макаров, С.Ю. Скоробогатов, А.М. Чеповский, 2006 © Оформление: Интернет-университет информационных технологий, 2006
ISBN 5-9556-0055-8
Допущено учебно-методическим объединением вузов по университетскому политехническому образованию в качестве учебного пособия для студентов высших учебных заведений, обучающихся по направлению «Информатика и вычислительная техника»
В книге описаны основы архитектуры платформы .NET и промежуточный язык этой платформы – Common Intermediate Language (CIL). Подробно рассмотрен прием программирования, называемый динамической генерацией кода. Дано введение в многозадачность и описаны подходы к разработке параллельных приложений на базе платформы .NET. Адресовано всем изучающим вопросы создания метаинструментария и разработки компиляторов для различных операционных систем. Для студентов и преподавателей университетов, а также для специалистов, повышающих свою квалификацию.
М15
УДК 004.72(075.8) ББК 32.973.202-018 М15
Основой данной книги явился учебный курс, задачей которого было изучение достижений компьютерных наук в области системного программного обеспечения на примере революционных для практического программирования технологий, реализованных в платформе .NET. Курс читается на дополнительном образовании механико-математического факультета МГУ им. М.В. Ломоносова и для студентов одной из программистских специальностей МГТУ им. Н.Э. Баумана. Наш учебник посвящен системному программированию в .NET. Это означает, что в нем мы в основном будем затрагивать вопросы, существенные для разработчиков системного программного обеспечения. Поэтому из нашего учебника вы ничего не узнаете о технологиях ASP .NET и ADO .NET и не научитесь использовать очень удобную библиотеку Windows.Forms для создания графического пользовательского интерфейса. Кроме всего прочего, языки программирования C# и Visual Basic .NET тоже останутся за кадром нашего изложения. Однако многие примеры в учебнике будут написаны на C#, так как мы исходим из предположения, что вы уже знакомы с этим языком или способны достаточно легко понять примеры на объектно-ориентированном языке программирования. Вместо этого книга поможет изучить архитектуру платформы .NET и промежуточный язык этой платформы – Common Intermediate Language (сокращенно CIL). Реализация концепции промежуточного языка является наиболее интересным достижением современной компьютерной технологии. Именно эта технология и сам промежуточный язык рассматривается в нашей книге. Кроме того, мы подробно рассматриваем прием программирования, называемый динамической генерацией кода. Этот прием широко использовался еще 10-15 лет назад, но потом в силу некоторых причин стал менее популярен. Его смысл заключается в том, что код программы порождается прямо во время ее выполнения! Технология .NET, похоже, способна дать новый импульс этому направлению программирования, так как в .NET включены специальные средства для поддержки динамической генерации кода. В заключительных двух главах книги обсуждается параллельное программирование, которое становится все более популярным в программистском сообществе из-за бурного развития «материальной части» для высокопроизводительных вычислений. Рассматриваются механизмы многоза-
ПРЕДИСЛОВИЕ
дачности и создание приложений с параллельным выполнением операций, предоставляемых ядром операционной системы Windows. Обсуждается реализация параллельного выполнения кода в .NET, использование библиотечных средств платформы .NET для создания параллельных приложений. Изложение материала основывается на документированной спецификации [1 - 5]. В краткий список русскоязычной литературы [6 – 11] внесены книги, которые можно использовать для углубления знаний по рассмотренным темам. Материалы книги могут использоваться в соответствии с требованиями «Совокупности знаний по информатики» рекомендаций Компьютерного общества Института инженеров по электротехнике и электронике (IEEE-CS) и Ассоциации по вычислительной технике (ACM) «Computing Curricula 2001 Computer Science» (CC2001) [перевод: Рекомендации по преподаванию информатики в университетах/ Пер. с англ.: СПб.: Издательство СПбГУ, 2002. – 372 с.] в таких областях знаний как Операционные системы (OS), Языки программирования (PL) и Программная инженерия (SE). Перечислим разделы «Совокупности знаний по информатики» документа CC2001, которым соответствует содержание книги: OS2. Основы операционных систем; OS3. Параллелизм; OS4. Планирование и диспетчеризация; OS5. Управление памятью; PL2. Виртуальные машины; PL4. Переменные и типы данных; PL5. Механизмы абстракции; PL6. Объектно-ориентированное программирование; PL8. Системы трансляции; PL9. Системы типов; PL11. Разработка языков программирования; SE2. Использование программных интерфейсов приложений (API); SE3. Программные средства и окружения. Книга печатается в серии, открытой публикацией отечественных рекомендаций по преподаванию информатики: Преподавание информатики и математических основ информатики для непрофильных специальностей классических университетов: [учеб. пособие]/В. В. Борисенко [и др.]; [ред. А.В. Михалев]. – М.: Интернет-Ун-т Информ. Технологий, 2005. – 144 с.: ил., табл. – (Основы информатики и математики). Приведем разделы «Совокупность знаний по математике и информатике» вышеупомянутой разработки, которым соответствует содержание книги:
vi
P3. Операционные системы; P4. Низкоуровневое программирование; P7. Объектно-ориентированное программирование; IT1. Языки программирования. Книга и сопутствующие ей учебные курсы появились при поддержке корпорации Microsoft. Авторы надеются, что данная книга поможет в освоении последних достижений практического программирования студентам самых различных специальностей, преподавателям программирования и людям, самостоятельно изучающим современные компьютерные технологии.
vii
ОБ АВТОРАХ
Чеповский Андрей Михайлович – доцент, к.т.н. Преподает в МГТУ им. Н.Э. Баумана и на дополнительном образовании механико-математического факультета МГУ им. М.В. Ломоносова, консультант МГУП. На протяжении многих лет читал различные курсы по программированию: алгоритмические языки, функциональное программирование, теоретическое программирование, теория формальных языков, информационные системы и базы данных, распределенные системы обработки информации, параллельное программирование.
Скоробогатов Сергей Юрьевич – ассистент МГТУ им. Н.Э. Баумана. Ведет занятия по курсам алгоритмических языков, функциональному программированию, разработке программного обеспечения.
Макаров Андрей Владимирович – старший преподаватель МГТУ им. Н.Э. Баумана. В течении многих лет читает курсы по архитектуре компьютеров, операционным системам, системному программированию, программированию под ОС Windows.
viii
Глава 2. Структура программных компонентов . . . . . . . . . . . . . . . . . . . . . 32 2.1. Формат исполняемых файлов . . . . . . . . . . . . . . . . . . . . . . . . . . 32 2.1.1. Управление памятью в Windows . . . . . . . . . . . . . . . . . . . 34 2.1.2. Обзор структуры PE-файла . . . . . . . . . . . . . . . . . . . . . . 36 2.1.3. Заголовки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 2.1.4. Особые секции PE-файла . . . . . . . . . . . . . . . . . . . . . . . . 49 2.1.5. Заголовок CLI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52 2.1.6. Пример генерации PE-файла . . . . . . . . . . . . . . . . . . . . . 53 2.2. Формат метаданных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64 2.2.1. Расположение метаданных и кода внутри сборки . . . . 65 2.2.2. Структура метаданных . . . . . . . . . . . . . . . . . . . . . . . . . . 67 2.2.3. Таблицы метаданных . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68 2.3. Взаимодействие программных компонентов . . . . . . . . . . . . . . 72 2.3.1. Обзор компонентных технологий . . . . . . . . . . . . . . . . . 73 2.3.2. Взаимодействие компонентов в среде .NET . . . . . . . . 76 2.3.3. Общая спецификация языков . . . . . . . . . . . . . . . . . . . . 82
Глава 1. Введение в архитектуру Microsoft .NET Framework . . . . . . . . . . . 1 1.1. Знакомство с .NET . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.1.1. Главные темы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.1.2. Предшественники платформы .NET . . . . . . . . . . . . . . . 3 1.1.3. Обзор архитектуры .NET . . . . . . . . . . . . . . . . . . . . . . . . . 5 1.2. Общая система типов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 1.2.1. Ядро системы типов .NET . . . . . . . . . . . . . . . . . . . . . . . 11 1.2.2. Дополнительные элементы системы типов .NET . . . . 17 1.3. Виртуальная система выполнения . . . . . . . . . . . . . . . . . . . . . . 21 1.3.1. Состояние виртуальной машины . . . . . . . . . . . . . . . . . 21 1.3.2. Состояние метода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 1.4. Автоматическое управление памятью . . . . . . . . . . . . . . . . . . . . 28 1.4.1. Выделение памяти в управляемой куче . . . . . . . . . . . . 28 1.4.2. Алгоритм сборки мусора . . . . . . . . . . . . . . . . . . . . . . . . . 29 1.4.3. Основные приемы повышения эффективности сборки мусора . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Оглавление
ix
Глава 4. Анализ кода на CIL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132 4.1. Граф потока управления . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132 4.1.1. Основные элементы графа потока управления . . . . . 133 4.1.2. Блоки обработки исключений в графе потока управления . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134 4.1.3. Дерево блоков в графе потока управления . . . . . . . . . 138 4.2. Преобразование линейной последовательности инструкций в граф потока управления . . . . . . . . . . . . . . . . . . 140 4.2.1. Создание массива узлов . . . . . . . . . . . . . . . . . . . . . . . . 141 4.2.2. Создание дерева блоков . . . . . . . . . . . . . . . . . . . . . . . . 142 4.2.3. Присвоение родительских блоков узлам графа . . . . . 145 4.2.4. Формирование дуг . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
Глава 3. Common Intermediate Language . . . . . . . . . . . . . . . . . . . . . . . . . . 83 3.1. Поток инструкций языка CIL . . . . . . . . . . . . . . . . . . . . . . . . . . 83 3.1.1. Формат потока инструкций . . . . . . . . . . . . . . . . . . . . . . 83 3.2. Язык CIL: инструкции общего назначения . . . . . . . . . . . 88 3.2.1. Инструкции для загрузки и сохранения значений . . . 88 3.2.2. Арифметические инструкции . . . . . . . . . . . . . . . . . . . . 91 3.2.3. Инструкции для организации передачи управления . 100 3.3. Язык CIL: инструкции для поддержки объектной модели . . 105 3.3.1. Инструкции для работы с объектами . . . . . . . . . . . . . 105 3.3.2. Инструкции для работы с массивами . . . . . . . . . . . . . 108 3.3.3. Инструкции для работы с типами-значениями . . . . . 111 3.3.4. Инструкции для работы с типизированными ссылками . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115 3.4. Язык CIL: обработка исключений . . . . . . . . . . . . . . . . . . . . . 116 3.4.1. Предложения обработки исключений в заголовках методов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 117 3.4.2. Инструкции CIL для обработки исключений . . . . . . 119 3.4.3. Правила размещения областей . . . . . . . . . . . . . . . . . . 121 3.4.4. Ограничения на передачу управления . . . . . . . . . . . . 122 3.4.5. Семантика обработки исключений . . . . . . . . . . . . . . . 123 3.5. Синтаксис ILASM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123 3.5.1. Основные элементы лексики . . . . . . . . . . . . . . . . . . . . 124 3.5.2. Синтаксис . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125 3.5.3. Пример программы . . . . . . . . . . . . . . . . . . . . . . . . . . . . 128
x
Глава 6. Основы многозадачности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183 6.1. Многозадачность в Windows . . . . . . . . . . . . . . . . . . . . . . . . . . 183 6.1.1. Основные понятия . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183 6.1.2. Реализация в Windows . . . . . . . . . . . . . . . . . . . . . . . . . . 194 6.2. Общие подходы к реализации приложений с параллельным выполнением операций . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 201 6.2.1. Асинхронный ввод-вывод . . . . . . . . . . . . . . . . . . . . . . . 202 6.2.2. Асинхронные вызовы процедур . . . . . . . . . . . . . . . . . . 207 6.2.3. Процессы, потоки и объекты ядра . . . . . . . . . . . . . . . 207 6.2.4. Основы использования потоков и волокон . . . . . . . . 212
Глава 5. Динамическая генерация кода . . . . . . . . . . . . . . . . . . . . . . . . . . 163 5.1. Введение в динамическую генерацию кода . . . . . . . . . . . . . . 163 5.1.1. Обобщенный алгоритм интегрирования . . . . . . . . . . . 164 5.1.2. Представление выражений . . . . . . . . . . . . . . . . . . . . . . 165 5.1.3. Трансляция выражений в C# . . . . . . . . . . . . . . . . . . . . 166 5.1.4. Трансляция выражений в CIL . . . . . . . . . . . . . . . . . . . 168 5.1.5. Сравнение эффективности трех способов вычисления выражений . . . . . . . . . . . . . . . . . . . . . . . . 169 5.2. Генерация линейных участков кода для стековой машины . 170 5.2.1. Генерация кода для выражений . . . . . . . . . . . . . . . . . . 170 5.2.2. Оптимизация линейных участков кода . . . . . . . . . . . . 173 5.3. Генерация развилок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174 5.3.1. Генерация кода для логических выражений . . . . . . . . 175 5.3.2. Генерация кода для управляющих конструкций . . . . 178 5.3.3. Оптимизация кода, содержащего развилки . . . . . . . . 179
4.3. Верификация CIL-кода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147 4.3.1. Классификация применяемых на практике алгоритмов верификации . . . . . . . . . . . . . . . . . . . . . . . 147 4.3.2. Особенности верификатора кода, используемого в .NET . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148 4.3.3. Алгоритм верификации . . . . . . . . . . . . . . . . . . . . . . . . 149 4.4. Библиотеки для создания метаинструментов . . . . . . . . . . . . 152 4.4.1. Metadata Unmanaged API . . . . . . . . . . . . . . . . . . . . . . . 153 4.4.2. Reflection API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156 4.4.3. Сравнение возможностей библиотек . . . . . . . . . . . . . 162
xi
Приложение B. Исходный код программы Integral . . . . . . . . . . . . . . . . 302 B.1. Expr.cs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 302 B.2. Integral.cs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 308
Приложение Б. Исходный код программы CilCodec . . . . . . . . . . . . . . . 291
Приложение A. Исходный код программы pegen . . . . . . . . . . . . . . . . . . 274 A.1. macros.h . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 274 A.2. pe.h . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 276 A.3. pe.c . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 281 A.4. main.c . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 288
Литература . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 273
Глава 7. Разработка параллельных приложений для ОС Windows . . . . . . 218 7.1. Применение потоков и волокон . . . . . . . . . . . . . . . . . . . . . . . 218 7.1.1. Пулы потоков, порт завершения ввода-вывода . . . . . 218 7.1.2. Память, локальная для потоков и волокон . . . . . . . . . 225 7.1.3. Привязка к процессору и системы с неоднородным доступом к памяти . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 230 7.2. Взаимодействие процессов и потоков . . . . . . . . . . . . . . . . . . 231 7.2.1. Синхронизация потоков . . . . . . . . . . . . . . . . . . . . . . . . 231 7.2.2. Процессы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 243 7.3. Параллельные операции в .NET . . . . . . . . . . . . . . . . . . . . . . . 250 7.3.1. Потоки и пул потоков . . . . . . . . . . . . . . . . . . . . . . . . . . 251 7.3.2. Асинхронный ввод-вывод . . . . . . . . . . . . . . . . . . . . . . 255 7.3.3. Асинхронные процедуры . . . . . . . . . . . . . . . . . . . . . . . 257 7.3.4. Синхронизация и изоляция потоков . . . . . . . . . . . . . . 260 7.3.5. Таймеры . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 271
xii
1
1.1.1.1. Разработка метаинструментов Создание любого программного продукта подразумевает знакомство программиста с предметной областью. То есть разработчик бухгалтерской программы должен в какой-то степени разбираться в бухучете, а создатель Интернет-магазина – в принципах ведения торговли. Нетрудно догадаться, что создание новых инструментов для разработки программ требует от программиста знакомства с тем, с чем он и так хорошо знаком – с разработкой программ! Наверное, поэтому это занятие столь увлекательно.
В этой книге, рассматривая возможности системного программирования в .NET, мы будем преследовать две главные цели: разработка метаинструментов и конструирование компиляторов.
1.1.1. Главные темы
Без всякого преувеличения можно сказать, что платформа .NET стоит в одном ряду с самыми значительными достижениями корпорации Microsoft. Более того, с точки зрения программиста, работающего в области создания компиляторов и других средств разработки программ, .NET является технологией неизмеримо более привлекательной, чем все продукты, ранее созданные в Microsoft. Разработка платформы .NET началась в 1998 году. Изначально ей дали рабочее название Project 42, которое затем было изменено на COM Object Runtime (сокращенно, COR). Видимо, аббревиатура COR использовалась достаточно длительное время, так как ее до сих пор можно найти в названиях dll-файлов и именах библиотечных функций. Потом платформа сменила еще несколько названий: Lightning, COM+ 2.0, Next Generation Web Services и, в конце концов, стала называться .NET Framework. Спецификация основной части платформы .NET стандартизована ассоциацией ECMA (European Computer Manufactures Association). Это означает, что корпорация Microsoft приветствует независимые реализации платформы.
1.1. Знакомство с .NET
Глава 1. Введение в архитектуру Microsoft .NET Framework
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Оптимизатор
Back-end
Резюмируя вышесказанное, можно сказать, что в большинстве учебников и учебных курсов, посвященных разработке компиляторов, основное внимание уделяется алгоритмам лексического и синтаксического анализа, то есть они учат в основном программированию «front-end'ов». В нашем учебнике мы концентрируем внимание на архитектуре и языке целевой платформы (.NET), а также изучаем работу с графами потоков управления. Это означает, что мы ориентируемся на программирование «back-end'ов».
Рис. 1.1. Структура современного компилятора
Front-end
Промежуточное представление (возможно, граф потока управления)
1.1.1.2. Конструирование компиляторов В структуре практически любого современного компилятора можно выделить, по крайней мере, две части: «front-end» и «back-end». «Frontend» осуществляет лексический и синтаксический анализ программы и переводит программу в некоторое промежуточное представление. А «backend» на основе этого промежуточного представления генерирует код для целевой аппаратной платформы. Между этими двумя частями может находиться оптимизатор, анализирующий и преобразующий промежуточное представление программы (см. рис. 1.1). В нашем учебнике мы будем рассматривать представление кода в виде графа потока управления, узлы которого соответствуют инструкциям языка, а ребра обозначают передачу управления между ними. Такое представление можно использовать в качестве промежуточного представления кода в компиляторе.
Мы будем называть метаинструментами программы, для которых другие программы выступают в роли данных. Метаинструменты используются для разработки, тестирования, анализа и преобразования программ. Это могут быть компиляторы, средства быстрой разработки приложений (RAD), оптимизаторы, отладчики, верификаторы, профайлеры и т.п. Знания, полученные из этого учебника, вы сможете применять для создания метаинструментов, которые работают на платформе .NET.
2
3
1.1.2.2. Технология ANDF Технология ANDF (Architectural Neutral Distribution Format) была разработана в первой половине 1990-х годов в OSF (Open Software Foundation) для увеличения переносимости программного обеспечения. Смысл технологии заключается в разделении процесса компиляции программ на две разнесенные во времени и пространстве фазы:
1.1.2.1. UCSD p-System Операционная система UCSD p-System была разработана в 1978 году в Калифорнийском университете для учебных целей. Главное ее достоинство заключалось в том, что она могла работать как на компьютерах PDP11, стоявших в вычислительном центре университета, так и на домашних микрокомпьютерах студентов. Независимость операционной системы от аппаратной платформы достигалась путем введения понятия виртуальной p-машины (p-Machine), обладавшей собственным набором инструкций, который назывался p-кодом (p-code). Сама операционная система и все работавшие в ней программы были закодированы на p-коде, поэтому для того чтобы запустить их на новой аппаратной платформе, требовалось всего лишь реализовать для этой платформы интерпретатор p-кода. Виртуальная p-машина была похожа на обычный компьютер и обладала процессором и памятью. Программы хранились в памяти машины вместе с данными, а все вычисления выполнялись через расположенный в памяти стек. Виртуальный процессор содержал пять регистров, один из которых использовался для хранения адреса текущей выполняемой инструкции (program counter – PC), а остальные обеспечивали работу со стеком. Платформа .NET использует похожую схему обеспечения независимости программ от аппаратной платформы. Все программы, работающие на платформе .NET, закодированы на языке CIL (Common Intermediate Language), который представляет собой набор инструкций некой абстрактной стековой машины. Основное отличие UCSD p-System от .NET заключается в принципах выполнения программ. Программы, закодированные в p-коде, непосредственно выполнялись интерпретатором, тогда как программы на CIL перед выполнением транслируются в код для конкретного процессора специальным компилятором.
Многие идеи, которые легли в основу платформы .NET, были разработаны задолго до ее появления. В этом разделе мы совершим краткий экскурс в историю и рассмотрим несколько программных систем, которые по праву можно считать предшественниками платформы .NET.
1.1.2. Предшественники платформы .NET
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
ST10
RS6000
ANDF
Ada 95
ST9
SPARC Philips-XA
PowerPC
Java
1.1.2.3. Платформа Java Платформа Java по архитектуре и своим возможностям наиболее близка к платформе .NET. Она была разработана в середине 1990-х годов в Sun Microsystems для бытовых приборов, подключаемых к компьютерным
Рис. 1.2. Схема использования технологии ANDF
Инсталляторы для каждой целевой платформы
MIPS
80x86
C/C++
Генераторы ANDF для различных языков программирования
1. перевод программы в формат ANDF; 2. трансляция программы, представленной в формате ANDF, в исполняемый файл при установке программы на компьютер пользователя. Формат ANDF не зависит ни от языков программирования, ни от особенностей аппаратных платформ и операционных систем. Программы, распространяемые в формате ANDF, могут быть установлены на любой платформе, для которой имеется транслятор из ANDF в исполняемый код. Схема использования технологии ANDF показана на рис. 1.2. Для каждого языка программирования реализован компилятор, который генерирует файл в формате ANDF. Такой компилятор называется генератором ANDF (ANDF producer). Для каждой аппаратной платформы реализован инсталлятор ANDF (ANDF installer), который переводит программу из формата ANDF в формат исполняемых файлов. Технология ANDF имеет много общего с принципами распространения программного обеспечения, используемыми на платформе .NET. Программы для .NET также распространяются в независимом от аппаратной платформы виде. Более того, программа, устанавливаемая на компьютер пользователя, может быть тут же переведена в код для процессора, используемого в этом компьютере.
4
5
1.1.3.1. Спецификация CLI Разработчику системного программного обеспечения важно понимать, что .NET – всего лишь одна из возможных реализаций так называемой общей инфраструктуры языков (Common Language Infrastructure, сокращенно CLI), спецификация которой разработана корпорацией Microsoft. Можно, руководствуясь этой спецификацией, разработать собственную реализацию CLI (рис. 1.3). В настоящее время ведутся по крайней мере два посвященных этому проекта. Это платформа Mono, создаваемая компанией Ximian, и разрабатываемый в рамках GNU проект Portable .NET. Кроме того, Microsoft распространяет в исходных текстах еще одну свою реализацию CLI, работающую как в Windows, так и под управлением
Платформа .NET состоит из двух основных компонентов. Это Common Language Runtime и .NET Framework Class Library. Common Language Runtime (сокращенно CLR) можно назвать «двигателем» платформы .NET. Его задача – обеспечить выполнение приложений .NET, которые, как правило, закодированы на языке CIL, рассчитаны на автоматическое управление памятью и вообще требуют гораздо больше заботы, чем обычные приложения Windows. Поэтому CLR занимается управлением памятью, компиляцией и выполнением кода, работой с потоками управления, обеспечением безопасности и т.п. .NET Framework Class Library – это набор классов на все случаи жизни. Далее мы рассмотрим эту библиотеку подробнее, а сейчас остановимся на двух ключевых моментах, которые с ней связаны. Во-первых, на платформе .NET реализованы компиляторы для различных языков программирования, и большинство этих языков позволяют легко использовать одну и ту же библиотеку классов. То есть .NET Framework Class Library – это единая библиотека для всех языков платформы .NET. Во-вторых, использование этой библиотеки позволяет существенно сократить размер приложений, что способствует их распространению через Internet.
1.1.3. Обзор архитектуры .NET
сетям. Затем произошло стремительное развитие Internet-технологий, которое способствовало широкому распространению Java. В настоящее время Java является основным конкурентом платформы .NET. Краеугольным камнем платформы Java является виртуальная машина, которая отвечает за независимость Java-программ от операционных систем и аппаратных платформ. Набор инструкций этой виртуальной машины (так называемый Java byte-code) может выполняться как на специализированных Java-процессорах, так и путем компиляции в исполняемый код конкретной аппаратной платформы.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
L
Linux F
FreeBSD
Рис. 1.3. Существующие реализации CLI и поддерживаемые ими операционные системы
Windows
F
Итак, чтобы понять, как работает .NET, необходимо изучить спецификацию CLI. Этим мы и займемся в ближайшее время, а пока перечислим ее составные части: • Общая система типов (Common Type System, сокращенно CTS) – охватывает большую часть типов, встречающихся в распространенных языках программирования. • Виртуальная система исполнения (Virtual Execution System, сокращенно VES) – отвечает за загрузку и выполнение программ, написанных для CLI. • Система метаданных (Metadata System) – предназначена для описания типов, хранится в независимом от конкретного языка программирования виде, используется для передачи типовой информации между различными метаинструментами, а также между этими инструментами и VES. • Общий промежуточный язык (Common Intermediate Language, сокращенно CIL) – независимый от платформы объектно-ориентированный байт-код, выступающий в роли целевого языка для любого поддерживающего CLI компилятора. • Общая спецификация языков (Common Language Specification, сокращенно CLS) – соглашение между разработчиками языков программирования и разработчиками библиотек классов, в котором определено подмножество CTS и набор правил. Если разработчики языка реализуют хотя бы определенное в этом согла-
W
W
L
W
F
L
Portable.NET
Common Language Infrastructure
Mono
SSCLI (Rotor)
W
.NET Framework
FreeBSD. Эта реализация называется Shared Source CLI (иногда можно услышать другое название – Rotor).
6
7
1.1.3.3. Сборка мусора Одни из самых неприятных ошибок, которые портят жизнь программисту, это, безусловно, ошибки, связанные с управлением памятью. В таких языках, как C и C++, в которых управление памятью целиком возло-
1.1.3.2. JIT-компиляция Программы для платформы .NET распространяются в виде так называемых сборок (assemblies). Каждая сборка представляет собой совокупность метаданных, описывающих типы, и CIL-кода. Ключевой особенностью выполнения программ в среде .NET является JIT-компиляция. Аббревиатура JIT расшифровывается как Just-InTime, и термин JIT-компиляция можно перевести как компиляция программ «на лету». JIT-компиляция заключается в том, что CIL-код, находящийся в запускаемой сборке, тут же компилируется в машинный код, на который затем передается управление. Такая схема выполнения программ в среднем является более эффективной, чем интерпретация инструкций CIL, так как потеря времени на предварительную компиляцию CIL-кода с лихвой компенсируется высокой скоростью работы откомпилированного кода. В .NET реализованы два JIT-компилятора: один компилирует сборку непосредственно перед ее выполнением, а другой позволяет откомпилировать ее заранее и поместить в так называемый кэш откомпилированных сборок. JIT-компилятор первого типа вызывается автоматически при запуске программы, а JIT-компилятор второго типа реализован в виде служебной программы ngen, которая входит в состав .NET Framework SDK. Программу ngen нельзя воспринимать как простой компилятор, позволяющий превратить сборку .NET в обычное приложение Windows. Дело в том, что откомпилированная сборка не может быть непосредственно запущена пользователем – загрузчик выдает сообщение об ошибке, гласящее, что запускаемая программа не является правильным приложением Windows. Откомпилированная сборка запускается системой только при вызове исходной сборки!
шении подмножество CTS и при этом действуют в соответствии с указанными правилами, то пользователь языка получает возможность использовать любую соответствующую спецификации CLS библиотеку. То же самое верно и для разработчиков библиотек: если их библиотеки используют только определяемое в соглашении подмножество CTS и при этом написаны в соответствии с указанными правилами, то эти библиотеки можно использовать из любого соответствующего спецификации CLS языка.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
жено на программиста, львиная доля времени, затрачиваемого на отладку программы, приходится на борьбу с подобными ошибками. Давайте перечислим типичные ошибки при управлении памятью (некоторые из них особенно усугубляются в том случае, если в программе существуют несколько указателей на один и тот же блок памяти): 1. Преждевременное освобождение памяти (premature free). Эта беда случается, если мы пытаемся использовать объект, память для которого была уже освобождена. Указатели на такие объекты называются висящими (dangling pointers), а обращение по этим указателям дает непредсказуемый результат. 2. Двойное освобождение (double free). Иногда бывает важно не перестараться и не освободить ненужный объект дважды. 3. Утечки памяти (memory leaks). Когда мы постоянно выделяем новые блоки памяти, но забываем освобождать блоки, ставшие ненужными, память в конце концов заканчивается. 4. Фрагментация адресного пространства (external fragmentation). При интенсивном выделении и освобождении памяти может возникнуть ситуация, когда непрерывный блок памяти определенного размера не может быть выделен, хотя суммарный объем свободной памяти вполне достаточен. Это происходит, если используемые блоки памяти чередуются со свободными блоками и размер любого из свободных блоков меньше, чем нам нужно. Проблема особенно критична в серверных приложениях, работающих в течение длительного времени. В программах, работающих в среде .NET, все вышеперечисленные ошибки никогда не возникают, потому что эти программы используют реализованное в CLR автоматическое управление памятью, а именно – сборщик мусора. Если не вдаваться в излишние на данном этапе изучения .NET подробности, можно сказать, что работа сборщика мусора заключается в освобождении памяти, занятой ненужными объектами. При этом сборщик мусора также умеет «двигать» объекты в памяти, тем самым устраняя фрагментацию адресного пространства. Все эти чудеса, которые творит сборщик мусора, возможны исключительно благодаря тому, что во время выполнения программы известны типы всех используемых в ней объектов. Другими словами, данные, с которыми работает программа, находятся под полным контролем среды выполнения и называются, соответственно, управляемыми данными (managed data).
8
9
Абстрагировавшись от конкретных особенностей .NET, можно сказать, что основная цель системы типов заключается в предотвращении определенного класса ошибок в программе до ее выпонения.
1.2. Общая система типов
1.1.3.4. Верификация кода При разработке платформы .NET было уделено много внимания обеспечению безопасности выполняемого программного кода. С точки зрения обеспечения безопасности можно привести следующую классификацию CIL-кода: • Недопустимый код (illegal code). Это код, который не может быть обработан JIT-компилятором, то есть не может быть транслирован в машинный код. • Допустимый код (legal code). Это код, который может быть представлен в виде машинного кода. При этом он может содержать вредоносные фрагменты (например, вирусы) или ошибки, способные нарушить работу не только программы, но и среды выполнения и даже операционной системы. • Безопасный код (safe code). Безопасный код не содержит вредоносных фрагментов (в том числе ошибок) и не может повредить ни системе выполнения, ни операционной системе, ни другим выполняемым программам. • Верифицируемый код (verifiable code). Верифицируемый код – это код, безопасность которого может быть строго доказана алгоритмом верификации, встроенным в CLR. Весь код, который поступает в JIT-компилятор, автоматически подвергается верификации. Верификатор платформы .NET реализует достаточно простой линейный алгоритм проверки правильной работы программного кода с типами данных и для каждого метода, входящего в сборку .NET, способен дать ответ на вопрос, проходит код этого метода верификацию или нет. В зависимости от настроек безопасности .NET система выполнения может разрешить или не разрешить выполнять на машине код, отбракованный верификатором. Следует понимать, что верифицируемый код всегда является безопасным, а обратное в общем случае неверно. То есть, можно себе представить такой CIL-код, который определенно является безопасным, но в силу тех или иных причин не может пройти верификацию.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
В качестве примеров перехватываемых ошибок можно привести деление на ноль и обращение к памяти по нулевому указателю. А неперехватываемые ошибки возникают, например, при передаче управления на неправильный адрес или при выходе за границы массива (если отсутствует динамическая проверка размера массива). Фрагмент программы, в котором не могут возникнуть неперехватываемые ошибки, называется безопасным (safe). Языки программирования, которые обеспечивают безопасность написанных на них программ, также называются безопасными (safe languages). Безопасность – одно из важнейших свойств языка. Она уменьшает время отладки благодаря отсутствию в программах неперехватываемых ошибок. Кроме того, она гарантирует целостность данных, что позволяет использовать автоматическое управление памятью (в частности, сборку мусора). Для любого языка программирования можно определить класс ошибок, называемых запрещенными (forbidden errors). В этот класс следует включить все неперехватываемые ошибки, а также некоторое подмножество перехватываемых ошибок. Говорят, что фрагмент программы имеет хорошее поведение (well behaved), если в нем не могут возникать запрещенные ошибки. Языки программирования, которые гарантируют хоро-
Рис. 1.4. Классификация ошибок в программах
Разрешенные перехватываемые ошибки
Перехватываемые ошибки
Запрещенные перехватываемые ошибки
Неперехватываемые ошибки
Ошибки
Давайте кратко приведем классификацию ошибок и разберемся, с какими ошибками помогает справиться система типов. При этом мы не будем рассматривать ошибки в алгоритмах, а ограничимся ошибками, возникающими в результате неправильного кодирования алгоритмов. Все ошибки можно разделить на две категории: перехватываемые и неперехватываемые (рис. 1.4). При возникновении перехватываемой ошибки (trapped error) выполнение программы немедленно прекращается, а неперехватываемая ошибка (untrapped error) остается незамеченной и может проявиться через некоторое время в абсолютно неожиданном месте.
10
11
Общая система типов достаточно обширна, так как при ее проектировании учитывалась необходимость поддержки различных языков программирования и, кроме того, уделялось большое внимание эффективности выполнения программ в среде .NET. Поэтому, для ясности изложения
1.2.1. Ядро системы типов .NET
шее поведение всех написанных на них программ, называются языками со строгой проверкой (strongly checked). Таким образом, для программы, написанной на языке со строгой проверкой, справедливы следующие утверждения: • неперехватываемые ошибки не могут возникнуть; • запрещенные перехватываемые ошибки также невозможны; • другие перехватываемые ошибки могут возникать, и борьба с ними остается в компетенции программиста. Существуют два пути для диагностики запрещенных ошибок: статическая проверка программы до ее выполнения (static checking) и динамическая проверка во время выполнения (dynamic checking). Статическая проверка характерна для языков, имеющих систему типов, а динамическая проверка – для так называемых бестиповых (typeless) языков, в которых либо вообще нет системы типов, либо существует только один универсальный тип данных. Динамическая проверка требует дополнительных ресурсов, поэтому статическая проверка является предпочтительной, так как чем больше ошибок диагностируется статически, тем выше эффективность программы. Система типов в языке программирования разрабатывается для того чтобы можно было осуществлять статическую проверку программы. Она представляет собой набор правил, определяющих условия, при которых конструкции языка не вызывают запрещенных ошибок. Так как платформа .NET спроектирована с учетом поддержки разных языков программирования, то ее общая система типов (Common Type System – CTS) является объединением систем типов основных распространенных в настоящее время языков. Из этого следует, что все языки платформы .NET (объектно-ориентированные, процедурные, функциональные) совместно используют единую систему типов, и это обеспечивает взаимодействие программных компонентов, написанных на разных языках. Наличие в .NET общей системы типов позволяет осуществлять статическую проверку программы не только на уровне компилятора, но и на уровне системы выполнения. Другими словами, система может проводить верификацию двоичных исполняемых файлов непосредственно перед их запуском. Это гарантирует безопасность кода, выполняемого в среде .NET и тем самым обеспечивает возможность автоматического управления памятью (сборку мусора).
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Интерфейсы
Массивы
Классы
Встроенные ссылочные типы
Самоописывающие типы
Ссылочные типы
Типы-значения представляют собой примитивные типы данных (целые числа и числа с плавающей запятой). Существуют еще пользовательские типы-значения, но мы обсудим их позже.
Рис. 1.5. Ядро общей системы типов
Типы с плавающей запятой
Целые типы
Типы-значения
Типы
материала мы сначала рассмотрим основное подмножество общей системы типов. Будем называть это подмножество ядром системы типов. Схема ядра общей системы типов .NET приведена на рис. 1.5. На схеме все типы делятся на две категории: это типы-значения (value types) и ссылочные типы (reference types). Для того чтобы понять причину такого разделения, приведем следующую аналогию. В некоторых языках программирования используются два способа передачи параметров при вызове функции: передача параметра по значению и передача параметров по ссылке. Передача параметра по значению (by value) подразумевает копирование значения параметра, а при передаче параметра по ссылке (by reference) копирования не происходит (вместо этого вызываемая функция получает адрес параметра). Обобщив эту аналогию, мы получим основное отличие типов-значений от ссылочных типов, а именно: использование типов-значений всегда связано с копированием их значений, а работа со ссылочными типами всегда осуществляется через адреса их значений.
12
13
native unsigned int
bool char int8 int16 int32 int64 unsigned int8 unsigned int16 unsigned int32 unsigned int64 native int
Тип
System.UIntPtr
Имя в .NET Framework Class Library System.Boolean System.Char System.SByte System.Int16 System.Int32 System.Int64 System.Byte System.Uint16 System.Uint32 System.UInt64 System.IntPtr
Таблица 1.1. Целые типы
булевский (8 бит) символ Unicode (16 бит) целое со знаком (8 бит) целое со знаком (16 бит) целое со знаком (32 бит) целое со знаком (64 бит) целое без знака (8 бит) целое без знака (16 бит) целое без знака (32 бит) целое без знака (64 бит) целое со знаком (разрядность процессора) целое без знака (разрядность процессора)
Описание
1.2.1.1. Встроенные типы-значения Встроенные типы-значения делятся на две группы: целые типы и типы с плавающей запятой. Они перечислены в таблицах 1.1 и 1.2, соответственно. В первом столбце каждой из таблиц приведены имена типов, используемые в текстовом представлении CIL (в программах, компилируемых ассемблером ILASM). Во втором столбце перечислены имена, используемые для тех же самых типов в библиотеке классов .NET. В третьем столбце находится краткое описание, в котором указывается знаковость и разрядность типа.
Ссылочные типы описывают так называемые объектные ссылки (object references), которые представляют собой адреса объектов. Значения любого типа хранятся в ячейках (location). В качестве ячеек могут выступать локальные и глобальные переменные, параметры методов, поля объектов и элементы массивов. Для каждой ячейки известен тип значений, которые она может содержать. Особо важным является то обстоятельство, что ячейки не могут содержать объекты. Все объекты размещаются в специальной области памяти, называемой кучей (heap). Таким образом, в ячейках могут храниться только значения типов-значений или объектные ссылки.
Введение в архитектуру Microsoft .NET Framework
вещественное (32 бит) вещественное (64 бит)
1.2.1.2. Самоописывающие ссылочные типы В некоторых объектно-ориентированных языках программирования (например, в C++) объекты могут храниться как в куче (в динамической памяти), так и в переменных: глобальных (в статической памяти) и локальных (на стеке). Поэтому системы типов в таких языках содержат отдельные типы для самого объекта и для объектной ссылки (указателя на объект). В среде .NET объекты и объектные ссылки хранятся раздельно, а именно: объекты хранятся в куче, а ссылки – в ячейках. Поэтому общая система типов спроектирована таким образом, что один и тот же ссылочный тип может являться как типом объекта, так и типом объектной ссылки. Каждый объект в куче содержит информацию о своем типе. Поэтому ссылочные типы, представляющие объекты, называются самоописывающими (self-describing). Два самоописывающих типа являются встроенными – это System.Object (или просто object в текстовом представлении CIL) и System.String (или string). Тип System.Object является общим базовым классом, от которого непосредственно или транзитивно наследует любой другой класс. Тип System.String используется для представления строковых данных в формате Unicode. Основу самоописывающих типов составляют классы. Классы могут агрегировать значения других типов, а также наследоваться друг от друга (в .NET поддерживается только одиночное наследование). Классы могут содержать следующие элементы: • Поля (fields). Поля являются ячейками, в которых хранятся значения других типов.
Для встроенных типов-значений определены правила преобразования значений одного типа в другой тип. Такие преобразования бывают сужающие (narrowing) и расширяющие (widening). При сужающих преобразованиях значение с большей разрядностью переводится в значение с меньшей разрядностью, что может приводить к потере значащих битов. Расширяющие преобразования никогда не приводят к такой потере.
Имя в .NET Framework Class Library System.Single System.Double
Описание
CIL и системное программирование в Microsoft .NET
Таблица 1.2. Типы с плавающей запятой
float32 float64
Тип
14
15
Массивы бывают как одномерными (в этом случае они обрабатываются особенно эффективно благодаря использованию специальных инструкций CIL), так и многомерными. Кроме того, система поддерживает массивы, нижняя граница которых отлична от нуля.
Рис. 1.6. Особенности представления массивов
Массив типа-значения
Массив ссылочного типа
• Методы (methods). Методы представляют собой функции классов. Они бывают статическими (static method) и объектными (instance method). Вызываемый объектный метод всегда получает ссылку на объект, для которого он вызывается. Объектные методы делятся на виртуальные и невиртуальные. • Свойства (properties). Свойство представляет собой пару методов, один из которых возвращает некоторое значение, а другой устанавливает это значение. • События (events). События используются для асинхронного внесения изменений в объект. Типы-массивы также относятся к самоописывающим типам, то есть каждый массив представляет собой объект в куче, доступ к которому осуществляется через объектную ссылку. Хотя, строго говоря, типы-массивы не являются классами, считается, что все они наследуют от библиотечного класса System.Array. Типы-массивы интересны тем, что, в отличие от классов, определяемых программистом самостоятельно, они формируются системой автоматически. То есть если мы имеем некоторый тип X, то тип массива, состоящего из элементов типа X, нам уже объявлять не нужно – об этом позаботится система выполнения.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
1.2.1.5. Идентичность и равенство значений Для объектных ссылок и значений типов-значений вводятся отношения идентичности (identity) и равенства (equality). Эти отношения являют-
1.2.1.4. Совместимость ячеек по присваиванию Значение может иметь сразу несколько типов. Например, если некоторый объект реализует несколько интерфейсов, то каждый из них является его типом. Та же ситуация наблюдается при наследовании: если класс имеет несколько суперклассов, то объект такого класса может выступать в качестве любого из этих суперклассов. Как уже говорилось ранее, ячейки, в которых могут храниться значения, также имеют тип. Соответственно, ячейка может содержать только значение, совместимое с ее типом. То есть, общая система типов не допускает присваивание ячейке несовместимого с ее типом значения. Формулируется следующее условие совместимости значения и ячейки: для того чтобы некоторое значение можно было присвоить заданной ячейке, необходимо и достаточно, чтобы хотя бы один из типов значения совпадал с типом ячейки.
1.2.1.3. Типы-интерфейсы Интерфейсы служат для компенсации отсутствия в .NET множественного наследования. Они могут рассматриваться как чисто абстрактные классы, содержащие только перечисленные ниже элементы: • Абстрактные методы. • Статические методы. • Статические поля. • Абстрактные свойства. • Абстрактные события. Хотя любой класс может наследоваться только от одного базового класса, он может реализовывать произвольное количество интерфейсов. То есть, интерфейс определяет контракт, которому должен удовлетворять любой класс, реализующий этот интерфейс. Нужно отметить, что интерфейс может содержать реализации статических методов, но все остальные методы, включая методы свойств и событий, должны оставаться абстрактными.
Особого внимания заслуживают особенности представления массивов объектов и массивов типов-значений. Дело в том, что так как объекты не могут храниться в ячейках, мы вынуждены вместо массивов объектов использовать массивы объектных ссылок (см. рис. 1.6), в то время как значения типов-значений хранятся прямо в элементах массива.
16
17
A = B= C
A идентична B A не идентична C B не идентична C
Рис. 1.7. Пример, объясняющий отношения идентичности и равенства объектных ссылок
“Hello”
“Hello”
Из соображений эффективности выполнения программ разработчики платформы .NET добавили в общую систему типов дополнительные элементы, а именно: пользовательские типы-значения (структуры и пере-
1.2.2. Дополнительные элементы системы типов .NET
Отношение идентичности для объектных ссылок вводится следующим образом: две объектных ссылки идентичны тогда и только тогда, когда они содержат адреса одного и того же объекта. На рис. 1.7 изображены три объектных ссылки A, B и C, а также два равных объекта-строки. Так как ссылки A и B содержат адрес одного и того же объекта, то они идентичны между собой, но при этом они не идентичны ссылке C, содержащей адрес другого объекта. Отношение равенства для объектных ссылок формулируется так: две объектных ссылки равны тогда и только тогда, когда они содержат адреса равных объектов. Все три объектные ссылки, изображенные на рис. 1.7, равны между собой. Отношение идентичности для типов-значений определяется следующим образом: два значения идентичны тогда и только тогда, когда они принадлежат одному и тому же типу-значению, и представляющие их последовательности битов равны. Отношение равенства для примитивных типов-значений совпадает с отношением идентичности. Отношения идентичности и равенства играют большую роль при программировании в среде .NET. Любой объект имеет виртуальный метод Equals, унаследованный от System.Object и выполняющий сравнение объектов на равенство. В зависимости от реализации этого метода, отношение равенства может существенно меняться. При переопределении метода Equals нужно иметь в виду, что два идентичных объекта обязательно должны быть равны.
C
B
A
ся отношениями эквивалентности, то есть они рефлексивны, симметричны и транзитивны.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Классы
Массивы
Упакованные типы-значения
Типы с плавающей запятой
Структуры
Перечисления
Неправляемые указатели
Управляемые указатели
Указатели
Интерфейсы
1.2.2.1. Структуры и перечисления Как показал опыт платформы Java, которая была разработана задолго до платформы .NET, одной из основных причин ухудшения производительности Java-программ является медленная работа сборщика мусора, вызванная большим количеством мелких объектов в куче. Это явление можно наблюдать в двух случаях: 1. Интенсивное создание временных объектов с очень малым временем жизни. Зачастую такие объекты создаются и используются в теле одного метода. 2. Использование гигантских массивов объектов, при котором возникает ситуация, изображенная на рис. 1.6, а именно: в массиве хранятся ссылки на огромное количество небольших объектов. Разработчиками .NET был подмечен тот факт, что использование типов-значений вместо объектов позволяет избежать описанных выше проблем, потому что:
Рис. 1.8. Общая система типов
Встроенные ссылочные типы
Самоописывающие типы
Ссылочные типы
Целые типы
Встроенные типы-значения
Типы-значения
Типы
числения) и указатели. Схема общей системы типов, на которой отражены эти дополнительные элементы, приведена на рис. 1.8.
18
19
1.2.2.2. Указатели Использование указателей может значительно увеличить производительность, и в некоторых языках программирования указатели применяются исключительно часто (например, в C). Однако считается, что применение указателей чревато появлением в программах большого количества трудноуловимых неперехватываемых ошибок. Поэтому, например, система типов уже упоминавшейся платформы Java обходится без указателей. Тем не менее, разработчикам .NET удалось добавить указатели в общую систему типов. При этом появилось две категории указателей: управляемые указатели (managed pointers) и неуправляемые указатели (unmanaged pointers). Для того чтобы программы оставались безопасными, на использование управляемых указателей наложен целый ряд ограничений: 1. Управляемые указатели могут содержать только адреса ячеек, то есть они могут указывать исключительно только на глобальные и локальные переменные, параметры методов, поля объектов и ячейки массивов. Для полноты картины следует заметить, что
1. временные значения хранятся не в куче, а непосредственно в локальных переменных метода; 2. в массивах типов-значений содержатся не ссылки на значения, а непосредственно сами значения. Поэтому в общую систему типов были добавлены так называемые пользовательские типы-значения. Эти типы могут быть объявлены программистом, но, как и встроенные типы-значения, размещаются не в куче, а в ячейках. Пользовательские типы-значения делятся на структуры и перечисления. Структуры являются аналогом классов. Они, как и классы, могут содержать поля, методы, свойства и события. Все структуры неявно наследуют от библиотечного класса System.ValueType, и, более того, встроенные типы-значения также наследуют от этого класса. Тут сразу следует заметить, что система типов не предусматривает никакого наследования структур, кроме данного неявного. Другими словами, структуры не могут наследоваться друг от друга и, тем более, не могут наследоваться от классов (кроме System.ValueType). Перечисления представляют собой структуры с одним целочисленным полем Value. Кроме того, перечисления содержат набор констант, определяющих возможные значения поля Value. При этом для каждой константы в перечислении хранится ее имя. Перечисления неявно наследуют от библиотечного класса System.Enum, который, в свою очередь, является наследником все того же класса System.ValueType.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
1.2.2.3. Упакованные типы-значения Наличие в общей системе типов структур, которые во многом напоминают классы, но в действительности классами не являются, в некоторых случаях вызывает некоторые неудобства. Например, в библиотеке классов .NET существуют достаточно удобные контейнерные классы (наиболее часто используется класс ArrayList, представляющий массив с динамически меняющимся размером). Эти классы могут хранить ссылки на любые объекты, но не могут работать с типами-значениями. Для решения этой проблемы в общей системе типов предусмотрены так называемые упакованные типы-значения. Эти типы являются ссылочными и самоописывающими. Объекты этих типов предназначены для хранения значений типов-значений. Упакованные типы-значения не могут быть объявлены программистом. Система автоматически определяет такой тип для любого типа-значения. Получение объекта упакованного типа-значения осуществляется путем упаковки (boxing). Упаковка заключается в том, что в куче создается пустой объект нужного размера, а затем значение копируется внутрь этого объекта. С помощью упаковки мы можем превратить значение любого типазначения (встроенного примитивного типа, структуры, перечисления) в объект и в дальнейшем работать с этим значением как с настоящим объектом (в том числе, мы можем положить его в ArrayList). Если же нам требуется произвести обратное действие, мы можем осуществить распаковку (unboxing). Распаковка заключается в том, что мы получаем управляемый указатель на содержимое объекта упакованного типа-значения.
управляемые указатели могут содержать адрес, непосредственно следующий за последним элементом массива. 2. За каждым указателем закреплен тип ячейки, на которую он может указывать. Другими словами, void-указатели запрещены. 3. Указатели могут храниться только в локальных переменных и параметрах методов. 4. Запрещены указатели на указатели. На использование неуправляемых указателей никаких ограничений не накладывается, то есть они могут содержать абсолютно любой адрес. Программа, в которой используются неуправляемые указатели, автоматически считается небезопасной и не может пройти верификацию.
20
21
Изучение работы виртуальной машины CLI заключается в том, чтобы понять, что представляет собой состояние виртуальной машины и как это состояние меняется во времени. В этом разделе мы не будем затрагивать вопрос изменения состояния, так как оно связано с выполнением инструкций языка CIL, разговор о котором мы отложим до третьей главы нашего учебника. На рис. 1.9 показана схема состояния виртуальной машины, из которой видно, что виртуальная машина может выполнять сразу несколько нитей (threads). Как уже говорилось ранее, виртуальная машина является всего лишь моделью поведения конкретных реализаций CLI, поэтому мы будем предполагать, что все нити выполняются параллельно. На самом деле, нити могут работать как параллельно, так и в режиме вытесняющей многозадачности, могут отображаться на процессы или на нити операци-
1.3.1. Состояние виртуальной машины
Виртуальная система выполнения (Virtual Execution System – VES) представляет собой абстрактную виртуальную машину, способную выполнять управляемый код. Можно сказать, что виртуальная система выполнения существует только «на бумаге», потому что ни одна из реализаций CLI не содержит интерпретатора CIL-кода (вместо этого используется JITкомпилятор, транслирующий инструкции CIL в команды процессора). Другими словами, виртуальная система выполнения не зря называется виртуальной (то есть мнимой), ее предназначение – служить образцом, которому должна соответствовать любая реализация CLI. Какую бы технологию ни использовала эта реализация для выполнения программ, эта технология должна работать так же, как работала бы виртуальная система выполнения. Если сравнить CLI с ее ближайшим конкурентом – платформой Java, можно прийти к выводу, что VES является значительно более абстрактной моделью, чем виртуальная машина Java (Java Virtual Machine – JVM). Причина такого отличия кроется в том, что изначально Java была ориентирована на реализацию в бытовых приборах. При этом, естественно, подразумевалось, что байт-код Java будет непосредственно выполняться специальными процессорами, и поэтому JVM является фактически спецификацией такого процессора. Аппаратная реализация VES никогда даже не предполагалась, и это позволило избежать при составлении ее спецификации ненужных деталей, дав тем самым каждой реализации CLI большую свободу выбора наиболее оптимальной стратегии выполнения CIL-кода.
1.3. Виртуальная система выполнения
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Состояние метода
Состояние метода
Состояние метода
Состояние метода
Нить
Куча
Состояние кучи определяется состояниями содержащихся в ней объектов. Спецификация VES содержит упоминание о том, что допустимо существование сразу нескольких куч (например, реализация CLI может ран-
Рис. 1.9. Состояние виртуальной машины
Состояние метода
Состояние метода
Нить
Состояние метода
Нить
Среда выполнения
онной системы, а могут и не отображаться. Сейчас для нас такие детали не имеют значения. Состояние виртуальной машины является совокупностью состояний нитей и состояния кучи. Состояние нити представляет собой односвязный список состояний методов. Метод, состояние которого находится в самом конце этого списка, является активным, то есть выполняемым в данный момент времени. Если активный метод вызовет другой метод, то в конец списка будет добавлено новое состояние для вызываемого метода. Если же активный метод закончит свою работу, то его состояние будет удалено из списка. Такая схема состояния нити является абстракцией традиционной модели последовательности вызовов методов. Традиционная модель предполагает наличие для каждой нити единого стека, в котором для каждого вызванного метода размещаются его параметры, локальные переменные, а также служебная информация, необходимая для обеспечения возврата управления в вызвавший метод. Разработчики спецификации CLI сознательно отказались от использования в явном виде единого стека вызовов. Это позволяет реализациям CLI выбирать наиболее эффективные соглашения о вызовах и размещении данных в стеке, учитывая особенности конкретных аппаратных средств.
22
23
Описатель безопасности
Стек вычислений
Рис. 1.10. Состояние метода
Область локальных данных
Параметры
Состояние возврата
Описатель метода
Локальные переменные
Неизменяемые данные
Изменяемые данные Указатель инструкции
Состояние метода
На рис. 1.10 изображена схема состояния метода. Элементы состояния метода можно условно разделить на две группы: изменяемые данные и неизменяемые данные. Изменяемые данные доступны из тела метода для чтения и записи, в то время как неизменяемые данные либо доступны только для чтения либо вообще предназначены для внутреннего использования в системе выполнения.
1.3.2. Состояние метода
жировать объекты по размеру и использовать для их хранения разные кучи). Однако на рис. 1.9 изображена только одна куча, так как, по нашему мнению, количество куч определяется конкретными реализациями CLI и для понимания работы VES не существенно. Спецификация VES предполагает, что для удаления ненужных объектов из кучи будет использоваться какой-либо алгоритм автоматического управления памятью. При этом детали такого алгоритма не рассматриваются, то есть в реализациях CLI могут применяться различные алгоритмы сборки мусора. В заключение необходимо отметить, что состояния нитей и состояние кучи должны находиться в общем адресном пространстве. При этом параметры и локальные переменные метода являются частью состояния метода и, следовательно, видимы только для нити, в которой этот метод выполняется. Однако они могут содержать ссылки на объекты, размещенные в куче и видимые для других нитей. Поэтому изменение объекта в куче из одной нити может повлиять на работу других нитей и считается побочным эффектом.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Давайте перечислим элементы состояния метода, входящие в группу изменяемых данных: • Указатель инструкции (Instruction Pointer). Содержит адрес следующей инструкции в теле метода, которая будет выполнена системой выполнения. (Когда мы говорим, что указатель инструкции относится к изменяемым данным, мы имеем в виду, что его значение изменяется при переходе от инструкции к инструкции.) • Стек вычислений (Evaluation Stack). Виртуальная система выполнения работает по принципу стекового процессора. Это означает, что операнды инструкций, а также возвращаемые инструкциями значения хранятся в специальной области памяти, а именно на стеке вычислений. Каждое состояние метода имеет собственный стек вычислений, содержимое которого сохраняется при вызове методов (то есть, если наш метод вызывает другой метод, то по завершении работы вызванного метода содержимое стека никуда не денется). • Локальные переменные (Local Variable Array). Для хранения локальных переменных в состоянии метода предусмотрена отдельная область памяти, состоящая из так называемых слотов (slots). Каждой локальной переменной соответствует свой слот. Значения локальных переменных сохраняются при вызове методов аналогично содержимому стека вычислений. • Параметры (Argument Array). Фактические параметры, переданные методу, записываются в специальную область памяти, которая организована так же, как и область локальных переменных. • Область локальных данных (Local Memory Pool). В языке CIL предусмотрена инструкция localloc, которая позволяет динамически размещать объекты в области памяти, локальной для метода. Объекты в этой области живут до тех пор пока метод не завершится. Обратите внимание, что стек вычислений, локальные переменные и параметры, а также локальные данные метода представляют собой логически отдельные области памяти. Каждая конкретная реализация CLI самостоятельно решает вопрос, где размещать эти области. В группу неизменяемых данных входят следующие элементы состояния метода: • Описатель метода (methodInfo handle). Содержит сигнатуру метода, в которую входят количество и типы формальных параметров, а также тип возвращаемого значения. Кроме этого, описатель метода включает в себя информа-
24
25
1.3.2.1. Стек вычислений Итак, несмотря на то, что большинство современных процессоров для организации вычислений используют регистры, в виртуальной системе выполнения вместо регистров применяется стек вычислений. Это связано, скорее всего, с тем, что стековые вычисления достаточно легко можно отобразить на регистры процессора, так как модель, использующая стек, более абстрактна, чем регистровая модель. Стек вычислений в VES состоит из слотов. При этом глубина стека (максимальное количество слотов) всегда ограничена и задается статически в заголовке метода. Решение ограничить глубину стека было принято разработчиками спецификации CLI для того, чтобы облегчить создание JIT-компиляторов. На входе метода стек вычислений всегда пуст. Затем он используется для передачи операндов инструкциям CIL, для передачи фактических параметров вызываемым методам, а также для получения результатов выполнения инструкций и вызываемых методов. Если метод возвращает какое-то значение, то оно кладется на стек вычислений перед завершением метода. Важной особенностью организации стека вычислений является то обстоятельство, что его слоты не адресуются, то есть мы не можем получить указатель на какой-либо слот и записать в него значение. Каждый слот стека вычислений может содержать ровно одно значение одного из следующих типов: • int64 – 8-байтовое целое со знаком; • int32 – 4-байтовое целое со знаком; • native int – знаковое целое, разрядность которого зависит от аппаратной платформы (может быть 4 или 8 байт); • F – число с плавающей точкой, разрядность которого зависит от аппаратной платформы (не может быть меньше 8 байт);
цию о количестве и типах локальных переменных и об обработчиках исключений. Описатель метода доступен из кода метода, но в основном он используется системой выполнения при сборке мусора и обработке исключений. • Описатель безопасности (Security Descriptor). Используется системой безопасности CLI и недоступен из кода метода. • Состояние возврата (Return State Handle). Служит для организации списка состояний методов внутри системы выполнения и недоступно из кода метода. Фактически представляет собой указатель на состояние метода, из тела которого был вызван текущий метод.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
• & – управляемый указатель; • O – объектная ссылка; • Пользовательский тип-значение. Таким образом, слоты стека вычислений могут иметь различный размер в зависимости от типов записанных в них значений. Также мы можем видеть, что допустимые типы значений для стека вычислений не совпадают с общей системой типов CTS. Например, в CTS существуют целые типы разрядности 1 и 2 байта, которые не могут содержаться на стеке вычислений. И наоборот, тип F стека вычислений не имеет аналога в CTS. Кроме того, для стека вычислений все управляемые указатели и объектные ссылки отображаются в два типа: & и O соответственно. Давайте обсудим, как в VES осуществляется работа с типами данных, не поддерживаемыми напрямую стеком вычислений. Во-первых, короткие целые типы (bool, char, int8, int16, unsigned int8, unsigned int16) при загрузке на стек вычислений расширяются до int32. При этом знаковые короткие целые типы (int8, int16) расширяются с сохранением знака, а беззнаковые расширяются путем добавления нулевых битов. При сохранении значения со стека вычислений в переменной, параметре, поле объекта или элементе массива происходит обратное сужающее преобразование. Во-вторых, беззнаковый тип unsigned int32 при загрузке на стек вычислений становится знаковым int32, и аналогично, беззнаковый unsigned int64 становится знаковым int64. При этом, естественно, никаких преобразований не происходит – просто последовательность бит, которая раньше считалась беззнаковым целым, копируется на стек вычислений. Вообще говоря, утверждение, что целые типы int32, int64 и native int на стеке вычислений имеют знак, достаточно спорно. Правильнее было бы сказать, что они могут представлять как знаковые, так и беззнаковые целые числа в зависимости от того, какие инструкции CIL используются для их обработки. В-третьих, типы float32 и float64 при копировании на стек вычислений преобразуются к типу F. Разрядность этого типа определяется конкретной реализацией CLI, которая, однако, должна гарантировать, что точность типа F не ниже, чем точность типа float64. В-четвертых, типы-перечисления при копировании на стек вычислений автоматически превращаются в целые типы. Вообще, VES устроена таким образом, что типы-перечисления и целые типы являются совместимыми по присваиванию. Этим они отличаются от обычных типов-значений, которые при копировании на стек сохраняют свой тип и не совместимы с целыми типами. И, наконец, для VES не имеет значения, какой точный тип имеют управляемые указатели и объектные ссылки. Любой управляемый указатель
26
27
1.3.2.3. Область локальных данных Область локальных данных является составной частью состояния метода и используется для размещения объектов, тип и/или размер которых неизвестен на этапе компиляции, но которые по тем или иным причинам нежелательно размещать в куче. Память из области локальных данных может быть явно выделена с помощью инструкции localloc. Так как в языке CIL не существует инструкции, освобождающей память в области локальных данных, то эту область невозможно использовать для реализации менеджера памяти общего назначения.
1.3.2.2. Локальные переменные и параметры Для хранения локальных переменных и параметров метода используются два массива, которые, как и стек вычислений, состоят из слотов. При этом каждой переменной и каждому параметру соответствует ровно один слот. Для доступа к локальным переменным и параметрам используются их индексы в массивах переменных и параметров. При этом нумерация осуществляется с нуля. Если один и тот же слот стека вычислений в разные моменты времени может содержать данные разных типов, то слоты, используемые для хранения переменных и параметров, строго типизированы, но зато поддерживают все типы, определенные в спецификации CTS. Типы локальных переменных и параметров задаются в заголовке метода и доступны во время выполнения программы через описатель метода. Специальный флаг, также находящийся в заголовке метода, показывает, нужно ли обнулять локальные переменные при входе в метод. Слоты, из которых состоят массивы переменных и параметров, адресуемы. Это означает, что в языке CIL существуют специальные инструкции, позволяющие получить адрес локальной переменной или параметра метода в виде управляемого указателя. Компилятор, генерирующий CIL-код, не должен делать никаких предположений о том, как переменные и параметры размещены в памяти. Дело в том, что реализации CLI могут любым образом переупорядочивать переменные и параметры, могут произвольно выравнивать их в памяти и даже использовать для их хранения регистры процессора.
считается имеющим тип &, а любая объектная ссылка представляется типом O. Это означает, что согласно спецификации CLI система выполнения не обязана отслеживать правильность типов управляемых указателей и объектных ссылок. Действительно, контроль за правильностью типов находится в компетенции верификатора.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Под управляемую кучу резервируется непрерывная область адресного пространства процесса. Система выполнения поддерживает специальный указатель (назовем его HeapPtr), содержащий адрес, по которому будет выделена память для следующего объекта. Когда куча не содержит ни одного объекта, HeapPtr указывает на начало кучи. Выделение памяти для объекта заключается в увеличении HeapPtr на количество байт, занимаемое этим объектом в куче. Для некоторых объектов определены методы Finalize, выполняющие некие действия при удалении объекта из кучи. Эти методы являются аналогами деструкторов языка C++ и используются главным образом для освобождения системных ресурсов, связанных с объектами. В целях повышения эффективности сборщика мусора при выделении памяти для объекта, имеющего метод Finalize, адрес этого объекта заносится в список завершения (finalization list). Если сравнить механизм выделения памяти в управляемой куче .NET с работой функции malloc языка C, можно прийти к выводу, что функция malloc работает гораздо менее эффективно. Причина в том, что исполняющая среда языка C организует кучу в виде связного списка блоков памя-
1.4.1. Выделение памяти в управляемой куче
Одной из основных особенностей платформы .NET, делающих ее привлекательной для разработки приложений, является механизм автоматического управления памятью, известный как сборка мусора (garbage collection). Спецификация CLI утверждает, что память для объектов, используемых в программе, выделяется в управляемой куче (managed heap), которая периодически очищается от ненужных объектов сборщиком мусора. Принцип работы сборщика мусора в спецификации не определен, поэтому разработчики реализаций CLI могут использовать любые алгоритмы, корректно выполняющие очистку управляемой кучи. В .NET реализован так называемый сборщик мусора с поколениями (generational garbage collector), работающий на основе построения графа достижимости объектов.
1.4. Автоматическое управление памятью
Область локальных данных существует ровно столько, сколько исполняется метод, состоянию которого она принадлежит. После прекращения работы метода она автоматически освобождается. В верифицированном коде использование области локальных данных запрещено.
28
29
Перед тем как приступить к описанию алгоритма сборки мусора в .NET, необходимо сделать важное замечание, касающееся уровня абстракции, на котором мы будем рассматривать этот вопрос. Дело в том, что нам придется отрешиться от понятий, с которыми оперирует спецификация CLI, потому что сборщик мусора относится не к спецификации, а к конкретной реализации. Другими словами, мы не можем обсуждать сборщик мусора в терминах виртуальной системы выполнения. Вместо этого нам придется перейти на уровень конкретной системы выполнения, имеющей следующие особенности: • она исполняет не CIL-код, а порожденный JIT-компилятором код процессора семейства Intel x86; • для каждого потока выполнения существует стек, в котором расположены фреймы вызванных методов. Каждый фрейм содержит адрес возврата, адрес фрейма предыдущего метода в стеке, а также локальные переменные и параметры метода; • стеки вычислений в явном виде отсутствуют. Вместо них используются регистры процессора и стек потока; • объектные ссылки представляют собой обычные указатели на объекты в управляемой куче. Ключевую роль в работе сборщика мусора играет понятие корень (root). Корнем считается указатель на объект кучи, расположенный вне кучи. Таким образом, корнями являются глобальные переменные, статические поля классов, локальные переменные и параметры методов, а также регистры процессора, содержащие указатели на объекты кучи. Работа сборщика мусора основана на предположении, что объекты, непосредственно или транзитивно достижимые из корней, нужно сохранить в куче, так как они могут использоваться в программе. Все остальные объекты можно удалить. Запуск сборщика мусора осуществляется в тот момент, когда совокупный размер объектов в куче достигает некоторой границы. При этом все потоки, запущенные приложением, приостанавливаются до завершения сборки мусора. Для каждой точки в коде программы сборщик мусора может эффективно определить набор корней благодаря специальной таблице корней. Эта таблица строится JIT-компилятором, и в ней каждому адресу в коде ка-
1.4.2. Алгоритм сборки мусора
ти. При этом размеры блоков в общем случае различны. Функции malloc приходится выполнять поиск свободного блока нужного размера, разбивать этот блок и затем вносить необходимые изменения в список блоков. Ясно, что выполнение этих действий требует значительно больше времени, чем простое увеличение указателя HeapPtr.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Проведение сборки мусора только для части объектов кучи позволяет существенно сократить время работы сборщика. Поэтому все объекты делятся на три категории, называемые поколениями. В поколении 0 сборка
1.4.3. Основные приемы повышения эффективности сборки мусора
ждого метода сопоставляется набор тех регистров процессора, локальных переменных и параметров этого метода, которые являются корнями. При этом локальные переменные и параметры задаются своими смещениями относительно фрейма. Таким образом, для определения полного набора корней сборщику мусора достаточно выполнить следующие операции: 1. взять адреса всех глобальных переменных и статических полей классов, имеющих ссылочные типы; 2. определить точки выполнения для каждого потока программы и добавить корни, которые в таблице корней соответствуют адресам этих точек; 3. просканировать стек каждого потока, обращаясь к таблице корней в точках вызова методов и добавляя полученные из таблицы корни. После вычисления полного набора корней сборщик мусора строит граф достижимости объектов кучи. Входами в граф служат объекты, на которые указывают корни. Поля этих объектов, имеющие ссылочные типы, сканируются, и в граф добавляются объекты, на которые эти поля указывают. Затем сканируются поля этих добавленных объектов. Процесс построения графа продолжается до тех пор, пока в него не войдут все объекты, достижимые из корней. При этом информацию о полях объектов сборщик мусора берет из метаданных, и ни один объект не рассматривается дважды. Среди объектов, не попавших в граф достижимости, сборщик мусора ищет такие объекты, адреса которых записаны в список завершения. Эти адреса добавляются в очередь завершения, а сами объекты считаются достижимыми и не подлежащими удалению. Методы Finalize объектов, попавших в очередь завершения, выполняются затем в отдельном потоке. Нетрудно сообразить, что эти объекты могут быть удалены только при следующей сборке мусора, и, более того, только при условии, что их методы Finalize успели выполниться и не привели к тому, чтобы объекты стали достижимыми из корней. После того как определены все достижимые объекты, сборщик мусора выполняет дефрагментацию кучи. Дефрагментация заключается в сдвиге достижимых объектов к началу кучи на место удаляемых недостижимых объектов. При этом сборщику мусора приходится корректировать поля объектов и корни.
30
31
мусора проводится чаще всего. Объекты, пережившие сборку мусора в поколении 0, переводятся в поколение 1, в котором сборка мусора осуществляется реже. Объекты, не удаленные после сборки мусора в поколении 1, переводятся в поколение 2. Сборка мусора в поколении 2 выполняется совсем редко. Эффективность организации сборки мусора с поколениями обосновывается тем, что молодые объекты имеют меньшее время жизни. Это утверждение получено эмпирическим путем и справедливо для подавляющего большинства реальных приложений. Еще одним способом увеличения производительности сборщика мусора является выделение отдельной кучи для больших объектов. Большими считаются объекты, размер которых превышает 85000 байт. Куча больших объектов никогда не дефрагментируется, и все объекты в ней считаются принадлежащими поколению 2.
Введение в архитектуру Microsoft .NET Framework
CIL и системное программирование в Microsoft .NET
Исполняемый файл (executable file) – это файл, который может быть загружен в память загрузчиком операционной системы и затем исполнен. В операционной системе Windows исполняемые файлы, как правило, имеют расширения «.exe» и «.dll». Расширение «.exe» имеют программы, которые могут быть непосредственно запущены пользователем. Расширение «.dll» имеют так называемые динамически связываемые библиотеки (dynamic link libraries). Эти библиотеки экспортируют функции, используемые другими программами. Для того чтобы загрузчик операционной системы мог правильно загрузить исполняемый файл в память, содержимое этого файла должно соответствовать принятому в данной операционной системе формату исполняемых файлов. В разных операционных системах в разное время существовало и до сих пор существует множество различных форматов. В этой главе мы рассмотрим формат Portable Executable (PE). Формат PE – это основной формат для хранения исполняемых файлов в операционной системе Windows. Сборки .NET тоже хранятся в этом формате. Кроме того, формат PE может использоваться для представления объектных файлов. Объектные файлы служат для организации раздельной компиляции программы. Смысл раздельной компиляции заключается в том, что части программы (модули) компилируются независимо в объектные файлы, которые затем связываются компоновщиком в один исполняемый файл. А теперь – немного истории. Формат PE был создан разработчиками Windows NT. До этого в операционной системе Windows использовались форматы New Executable (NE) и Linear Executable (LE) для представления исполняемых файлов, а для хранения объектных файлов использовался Object Module Format (OMF). Формат NE предназначался для 16-разрядных приложений Windows, а формат LE, изначально разработанный для OS/2, был уже 32-разрядным. Возникает вопрос: почему разработчики Windows NT решили отказаться от существующих форматов? Ответ становится очевидным, если обратить внимание на то, что большая часть команды, работавшей над созданием Windows NT, ранее работала в Digital Equipment Corporation. Они занимались в DEC разработкой инструмента-
2.1. Формат исполняемых файлов
Глава 2. Структура программных компонентов
32
33
рия для операционной системы VAX/VMS, и у них уже были навыки и готовый код для работы с исполняемыми файлами, представленными в формате Common Object File Format (COFF). Соответственно, формат COFF в слегка модифицированном виде был перенесен в Windows NT и получил название PE. В «.NET Framework Glossary» сказано, что PE – это реализация Microsoft формата COFF. В то же время в [5] утверждается, что PE – это формат исполняемых файлов, а COFF – это формат объектных файлов. Вообще, мы можем наблюдать путаницу в документации Microsoft относительно названия формата. В некоторых местах они называют его COFF, а в некоторых – PE. Правда, можно заметить, что в новых текстах название COFF используется все меньше и меньше. Более того, формат PE постоянно эволюционирует. Например, несколько лет назад в Microsoft отказались от хранения отладочной информации внутри исполняемого файла, и поэтому теперь многие поля в структурах формата COFF просто не используются. Кроме того, формат COFF – 32-разрядный, а последняя редакция формата PE (она называется PE32+) может использоваться на 64-разрядных аппаратных платформах. Поэтому, видимо, дело идет к тому, что название COFF вообще перестанут использовать. Интересно отметить, что исполняемые файлы в устаревших форматах NE и LE до сих пор поддерживаются Windows. Исполняемые файлы в формате NE можно запускать под управлением NTVDM (NT Virtual DOS Machine), а формат LE используется для виртуальных драйверов устройств (VxD). Почему в названии формата PE присутствует слово «portable» («переносимый»)? Дело в том, что Windows NT была реализована не только для платформы Intel x86, но и для платформ MIPS R4000, DEC Alpha и PowerPC. И во всех реализациях для хранения исполняемых файлов использовался формат PE. При этом речь не шла о достижении двоичной совместимости между этими платформами, то есть exe-файл, предназначенный для выполнения на платформе Intel x86, нельзя было запустить на PowerPC. Важно понимать, что переносимость формата еще не означает переносимость исполняемых файлов, записанных в этом формате. Формат PE переносим в том смысле, что он слабо зависит от типа процессора и поэтому подходит для разных платформ (в том числе и для платформы .NET). Далее в этой главе мы не будем затрагивать 64-разрядный вариант формата PE, потому что в настоящее время сборки .NET хранятся в прежнем 32-разрядном формате. Однако отметим, что 64-разрядный PE очень слабо отличается от 32-разрядного. Основное отличие касается разрядности полей структур PE-файла.
Структура программных компонентов
0
Виртуальное адресное пространство также делится на виртуальные страницы размером в 4096 байт. При этом процессу выделяется только то количество виртуальных страниц, которое ему реально нужно. Поэтому тот факт, что процесс может адресовать 4 Гб виртуального адресного пространства, еще не означает, что каждому процессу выделяется по 4 Гб оперативной памяти. Как правило, процесс использует только малую часть своего адресного пространства, хотя стремительное удешевление модулей памяти способно в самое ближайшее время существенно изменить картину и вызвать повсеместно переход к 64-разрядным архитектурам.
Рис. 2.1. Виртуальное адресное пространство процесса
Доступно процессу
2.1.1.1. Виртуальное адресное пространство процесса Каждый процесс в Windows запускается в своем виртуальном адресном пространстве размером в 4 Гб. При этом первые 2 Гб адресного пространства могут непосредственно использоваться процессом, а остальные 2 Гб резервируются операционной системой для своих нужд (рис. 2.1). 4 ГБ Зарезервировано операционной системой 2 ГБ
Прежде чем перейти к рассмотрению формата PE, необходимо поговорить об особенностях управления памятью в Windows, так как без знания этих особенностей невозможно понять некоторые существенные детали формата. Управление памятью в Windows NT/2k/XP/2k3 осуществляет менеджер виртуальной памяти (virtual-memory manager). Он использует страничную схему управления памятью, при которой вся физическая память делится на одинаковые отрезки размером в 4096 байт, называемые физическими страницами. Если физических страниц не хватает для работы системы, редко используемые страницы могут вытесняться на жесткий диск, в один или несколько файлов подкачки (pagefiles). Вытесненные страницы затем могут быть загружены обратно в память, если возникнет необходимость. Таким образом, программы могут использовать значительно большее количество памяти, чем реально присутствует в системе.
2.1.1. Управление памятью в Windows
CIL и системное программирование в Microsoft .NET
35
Виртуальный адрес vx
0
Физический адрес px Физическая страница pnum
Физическая память
0
Рассмотрим на примере, как осуществляется перевод некоторого виртуального адреса vx в физический адрес px (рис 2.2). Сначала вычисляется номер vnum виртуальной страницы, соответствующий виртуальному адресу vx, а также смещение delta виртуального адреса относительно начала этой виртуальной страницы: vnum := vx div 4096; delta := vx mod 4096; Далее возможны три варианта развития событий: 1. Виртуальная страница vnum недоступна. В этом случае перевод виртуального адреса vx в физический адрес невозможен, и процесс завершается с сообщением «Access Violation»; 2. Виртуальная страница находится в файле страничной подкачки,
Рис. 2.2. Перевод виртуального адреса в физический адрес
Виртуальная страница vnum
Виртуальное адресное пространство
Виртуальные страницы могут отображаться операционной системой в страницы физической памяти, могут храниться в файле подкачки, а также могут быть вообще недоступны процессу. Обращение к недоступной виртуальной странице вызывает аварийное завершение процесса с сообщением «Access violation». Адресное пространство процесса называется виртуальным, потому что процесс для работы с памятью использует не реальные адреса физической памяти, а так называемые виртуальные адреса. При обращении по некоторому виртуальному адресу происходит перевод этого виртуального адреса в физический адрес. Перевод виртуальных адресов в физические адреса реализован на аппаратном уровне в процессоре и поэтому осуществляется достаточно быстро.
Структура программных компонентов
delta
34
delta
• Процессы изолированы друг от друга. Один процесс не может обратиться к памяти другого процесса. • Передача виртуальных адресов между процессами совершенно бессмысленна. Один и тот же виртуальный адрес в адресных пространствах разных процессов соответствует разным физическим адресам. • Процессы используют преимущества плоской адресации памяти. Виртуальный адрес представляет собой 32-разрядное целое значение, что делает возможной легкую реализацию адресной арифметики.
Исполняемые файлы в формате PE, кроме всего прочего, обладают одной приятной особенностью – PE-файл, загруженный в оперативную память для исполнения, почти ничем не отличается от своего представления на диске. PE-файл сравнивается со сборным домом: стоит привезти его на место, свинтить отдельные детали, подключить электричество и водопровод, и все – можно жить.
2.1.2. Обзор структуры PE-файла
2.1.1.2. Отображаемые в память файлы Отображаемые в память файлы (memory-mapped files) – это мощная возможность операционной системы. Она позволяет приложениям осуществлять доступ к файлам на диске тем же самым способом, каким осуществляется доступ к динамической памяти, то есть через указатели. Смысл отображения файла в память заключается в том, что содержимое файла (или часть содержимого) отображается в некоторый диапазон виртуального адресного пространства процесса, после чего обращение по какому-либо адресу из этого диапазона означает обращение к файлу на диске. Естественно, не каждое обращение к отображенному в память файлу вызывает операцию чтения/записи. Менеджер виртуальной памяти кэширует обращения к диску и тем самым обеспечивает высокую эффективность работы с отображенными файлами.
ми:
и ее надо сначала загрузить в память. Тогда пусть pnum будет номером физической страницы, в которую мы загружаем нашу виртуальную страницу; 3. Виртуальная страница уже находится в памяти, и ей соответствует некоторая физическая страница. В этом случае pnum – номер этой физической страницы. После чего адрес px вычисляется следующим образом: px := pnum*4096 + delta; Такая организация памяти процесса обладает следующими свойства-
CIL и системное программирование в Microsoft .NET
Заголовок MS-DOS
Таблица секций Доп. заголовок PE-файла Заголовок PE-файла
Секция 1
Секция 2
...
Секция N
Образ в памяти
37
Благодаря этой особенности загрузчик операционной системы должен просто отобразить отдельные части PE-файла в адресное пространство процесса, подправить абсолютные адреса в исполняемом коде в соответствии с таблицей релокаций, создать таблицу адресов импорта и затем передать управление на точку входа (в случае exe-файла). На рис. 2.3 изображена схема PE-файла. Слева показана структура файла на диске, а справа – его образ в памяти. Мы видим, что PE-файл начинается с заголовков, за которыми располагаются несколько секций. В секциях размещаются код и данные исполняемого файла, а также служебная информация, необходимая загрузчику (например, секция «.reloc» на схеме содержит таблицу релокаций). Секции в оперативной памяти должны быть выровнены по границам страниц, поэтому загрузчик отображает каждую секцию, начиная с новой страницы адресного пространства процесса. Это приводит к тому, что в памяти секции, как правило, располагаются менее компактно, чем в файле (и это отражено на схеме). Так как расположение элементов PE-файла в памяти и на диске отличаются, для их локализации приходится вводить два понятия: относитель-
Рис. 2.3. PE-файл на диске и в оперативной памяти
Заголовок MS-DOS
Таблица секций Доп. заголовок PE-файла Заголовок PE-файла
Секция 1
Секция 2
...
Секция N
Секция .reloc
Неотображаемые в память данные
PE-файл
Структура программных компонентов
Смещение в файле
36
RVA
CIL и системное программирование в Microsoft .NET
2.1.2.1. Секции Секция в PE-файле представляет либо код, либо некоторые данные (глобальные переменные, таблицы импорта и экспорта, ресурсы, таблица релокаций). Каждая секция имеет набор атрибутов, задающий ее свойства. Атрибуты секции определяют, доступна ли секция для чтения и записи, содержит ли она исполняемый код, должна ли она оставаться в памяти после загрузки исполняемого файла, могут ли различные процессы использовать один экземпляр этой секции и т.д. Исполняемый файл всегда содержит, по крайней мере, одну секцию, в которой помещен исполняемый код. Кроме этого, как правило, в исполняемом файле содержится секция с данными, а динамические библиотеки обязательно включают отдельную секцию с таблицей релокаций. Каждая секция имеет имя. Оно не используется загрузчиком и предназначено главным образом для удобства человека. Разные компиляторы и компоновщики дают секциям различные имена. Например, компоновщик от Microsoft размещает код в секции «.text», константы – в секции «.rdata», таблицы импорта и экспорта – в секциях «.idata» и «.edata», таблицу релокаций – в секции «.reloc», ресурсы – в секции «.rsrc». В то же время компоновщик фирмы Borland использует имена «CODE» для секций, содержащих код, и «DATA» для секций с данными. Выравнивание секций в исполняемом файле на диске и в образе файла в памяти чаще всего отличается. В памяти они, как правило, выровнены по границам страниц. В принципе, возможно сгенерировать PE-файл с одинаковым выравниванием секций как на диске, так и в памяти. Смещения элементов в таком файле будут совпадать с их RVA, что существен-
ный виртуальный адрес элемента в памяти (Relative Virtual Address – RVA) и смещение элемента в файле (file offset). RVA некоторого элемента PE-файла – это разность виртуального адреса данного элемента и базового адреса, по которому PE-файл загружен в память. Например, если файл загружен по адресу 0x400000, и некоторый элемент в нем располагается по адресу 0x402000, то RVA этого элемента равен (0x402000 – 0x400000) = 0x2000. Смещение элемента в файле представляет собой количество байт, которое надо отсчитать от начала файла, чтобы попасть на начало элемента. Смещения используются гораздо реже, чем RVA, потому что основное их назначение состоит в обеспечении соответствия положения секций PEфайла в файле на диске и в памяти. Загрузчик формирует образ PE-файла в памяти таким образом, что соблюдается следующее правило: пусть ox и oy – смещения каких-то элементов в файле, а rx и ry – RVA этих элементов, тогда если ox < oy, то rx < ry.
38
39
2.1.2.3. Импорт функций В PE-файле существует специальная секция «.idata», описывающая функции, который этот файл импортирует из динамических библиотек. Описание импортируемых функций в секции «.idata» приводит к тому, что библиотеки загружаются загрузчиком операционной системы еще до запуска программы. В принципе, необязательно описывать каждую импортируемую функцию в этой секции, так как динамические библиотеки можно загружать с помощью функции LoadLibrary из Win32 API прямо во время выполнения программы. В процессе загрузки программы осуществляется связывание (binding) функций, импортируемых из динамических библиотек. Связывание подразумевает загрузку соответствующих динамических библиотек и составление таблицы адресов импорта (Import Address Table – IAT). Адрес каж-
2.1.2.2. Выбор базового адреса образа PE-файла в памяти Давайте обсудим, каким образом загрузчик определяет базовый адрес, по которому нужно загрузить PE-файл. Для exe-файлов это тривиальная задача: в заголовках файла присутствует поле ImageBase, содержащее значение базового адреса. Так как для выполнения exe-файла создается отдельный процесс со своим виртуальным адресным пространством, то обычно не возникает никаких проблем с тем чтобы отобразить файл в это адресное пространство по заданному адресу. Как правило, все exe-файлы содержат в поле ImageBase значение 0x400000. А вот выбор базового адреса для dll-файла куда сложнее. Дело в том, что динамическая библиотека, как правило, загружается в адресное пространство уже существующего процесса, и хотя dll-файл тоже содержит некоторое значение в поле ImageBase, очень часто может так получиться, что этот адрес уже занят чем-то другим (например, по нему уже загружена другая динамическая библиотека). Что же делать загрузчику, если он не может загрузить dll-файл по заданному адресу? Ему ничего не остается, как загрузить этот файл по какому-то другому адресу. Но тут возникает новая проблема – в файле могут содержаться инструкции с абсолютными адресами (это, в основном, инструкции абсолютных переходов, инструкции вызова подпрограмм, а также инструкции для работы с глобальными данными). При загрузке динамической библиотеки по другому адресу все адреса, содержащиеся в этих инструкциях, становятся неправильными, и загрузчик вынужден их поправить. Для того, чтобы загрузчик мог это сделать, в файле содержится таблица релокаций, в которой прописаны RVA всех абсолютных адресов.
но упрощает создание генератора кода. Недостатком такого подхода является увеличение размеров PE-файлов.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
2.1.3.1. Заголовок MS-DOS Каждый PE-файл начинается с небольшой (128 байт) программы, записанной в формате исполняемых файлов MS-DOS. Эта программа выводит на экран сообщение «This program cannot be run in DOS mode». В настоящее время наличие такого «заголовка» вряд ли имеет смысл, но во время повсеместного использования операционной системы MS-DOS люди зачастую случайно пытались запускать PE-файлы из ДОСовской командной строки, и эта маленькая программа в начале файла давала им возможность осознать свою ошибку, выбросить MS-DOS на помойку и установить наконец-то Windows NT! Рассмотрим шестнадцатеричный дамп заголовка MS-DOS, представленный на рис. 2.4.
2.1.3. Заголовки
2.1.2.4. Экспорт функций Экспорт функций из сборки .NET осуществляется достаточно редко. Дело в том, что наличие метаданных в сборках позволяет нам экспортировать любые элементы сборки, такие как классы, методы, поля, свойства и т.д. Таким образом, обычный механизм экспорта функций становится ненужным. Необходимость в экспорте функций возникает только тогда, когда сборка .NET должна использоваться обычной программой Windows, код которой не управляется средой выполнения .NET. Информация об экспортируемых функциях хранится внутри PE-файла в специальной секции «.edata». При этом каждой функции присваивается уникальный номер, и с этим номером связывается RVA тела функции, и, возможно, имя функции. Не всякая экспортируемая функция имеет имя, так как имена служат, главным образом, для удобства программистов.
дой импортируемой функции заносится в эту таблицу и в дальнейшем используется для вызова данной функции. Секция «.idata» в сборках .NET в некотором смысле носит вспомогательный характер, так как импортируемые сборкой динамические библиотеки описываются в метаданных. Задача этой секции – обеспечить запуск среды выполнения .NET, поэтому в ней описывается только одна импортируемая из mscoree.dll функция (_CorExeMain для exe-файлов и _CorDllMain – для dll-файлов). При запуске сборки .NET управление сразу же передается этой функции, которая запускает Common Language Runtime, осуществляющий JIT-компиляцию программы и контролирующий в дальнейшем ее выполнение.
40
41
Заголовок начинается с сигнатуры «MZ». Она представляет собой инициалы одного из разработчиков операционной системы MS-DOS 2.0 Марка Збиковски и знаменита тем, что ни одна инструкция процессоров семейства Intel x86 с нее не начинается. В свое время эта ее особенность давала загрузчику исполняемых файлов MS-DOS возможность отличать exe-файлы, которые появились только во второй версии MS-DOS, от comфайлов. Исполняемые com-файлы пришли в MS-DOS из операционной системы CP/M. Их формат был настолько примитивным, что вряд ли заслуживает того, чтобы вообще называться форматом исполняемых файлов. Загрузчик должен был попросту загрузить com-файл в память, и после нехитрых манипуляций, не вдаваясь в подробности внутренней структуры файла, передать управление на его начало. В принципе, PE-файл не обязан начинаться именно с такого заголовка. Вы можете поместить в его начало любой exe-файл, работающий в MSDOS. При этом 32-разрядное слово, расположенное по смещению 0x3c в этом exe-файле, должно содержать его размер. Для стандартного заголовка это значение равно 0x00000080 (подчеркнуто в дампе). Сразу после заголовка MS-DOS следует сигнатура PE-файла, состоящая из четырех байт: 0x50, 0x45, 0x00 и 0x00 (в строковом представлении она выглядит как «PE\0\0»). Поэтому при просмотре дампа PE-файла очень просто понять, где заканчивается заголовок MS-DOS – достаточно поискать глазами две буквы «PE».
Рис. 2.4. Шестнадцатитеричный дамп заголовка MS-DOS
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
2.1.3.2. Заголовок PE-файла Заголовок PE-файла непосредственно следует за сигнатурой PE-файла. В современной документации он называется «PE File Header», но в более старых текстах можно встретить название «COFF Header». Заголовок PE-файла состоит из следующих полей: unsigned short Machine; Это поле содержит идентификатор процессора, для которого предназначен исполняемый файл. Для сборок .NET всегда используется значение 0x14c. unsigned short NumberOfSections; Задает количество секций в PE-файле. Массив заголовков секций следует сразу после всех заголовков, и это поле, таким образом, определяет размер этого массива. long TimeDateStamp; Время создания файла. Отсчитывается в секундах от начала 1 января 1970 года по Гринвичу. Самый простой способ получения времени в этом формате – вызов функции time() из стандартной библиотеки языка C. long PointerToSymbolTable; long NumberOfSymbols; Эти два поля использовались раньше для организации хранения отладочной информации внутри COFF-файла. В настоящий момент они не используются и всегда содержат нули. unsigned short OptionalHeaderSize; Задает размер дополнительного заголовка PE-файла, который следует непосредственно за заголовком PE-файла. Сборки .NET, как правило, содержат значение 0xE0 в этом поле. Вообще, наличие этого поля позволяет расширять формат путем добавления новых полей в дополнительный заголовок PE-файла. unsigned short Characteristics; Представляет собой комбинацию флагов, задающую характеристики исполняемого файла. Для сборок .NET требуется установить следующий набор флагов: 0x0002 – файл является исполняемым; 0x0004 – файл не содержит информации о номерах строк исход-
При разработке программного обеспечения, выполняющего чтение PE-файлов, важно не забыть осуществить проверку сигнатуры. Дело в том, что исполняемые файлы в устаревших форматах также начинаются с похожего заголовка MS-DOS, после которого располагаются другие сигнатуры: «NE» для 16-разрядных приложений Windows, «LE» для виртуальных драйверов устройств, и даже «LX» для исполняемых файлов OS/2.
42
43
2.1.3.3. Дополнительный заголовок PE-файла Дополнительный заголовок PE-файла следует сразу за основным заголовком. В современной документации он называется «PE Optional Header». Строго говоря, «optional» означает «необязательный», а не «дополнительный». Дело в том, что в объектных файлах этот заголовок действительно необязателен, но так как в исполняемых файлах он всегда присутствует, мы будем называть его «дополнительным». Поля дополнительного заголовка можно разделить на три группы: • Стандартные поля. Группа стандартных полей пришла в PE из формата COFF. Они содержат основную информацию, необходимую для загрузки и исполнения PE-файла. • Поля, специфичные для Windows NT. Эти поля специально предназначены для загрузчика Windows NT. В формате COFF они изначально не присутствовали. • Директории данных. Местонахождение некоторых важных структур данных в образе загруженного в память PE-файла задается в так называемых директориях данных (Data Directories). Каждая директория содержит RVA и размер соответствующей структуры. Всего в дополнительном заголовке хранятся 16 директорий данных. В состав дополнительного заголовка PE-файла входят следующие стандартные поля: unsigned short Magic; Константа, задающая тип PE-файла: 0x010B – 32-разрядный файл; 0x020B – 64-разрядный файл. Для сборок .NET должно быть установлено значение 0x010B. char LMajor; Старшее число версии компоновщика. Для сборок .NET – 6.
ной программы; 0x0008 – файл не содержит информации о символах исходной программы; 0x0100 – файл предназначен для исполнения на 32-разрядной машине. Если сборка представляет собой динамическую библиотеку, то дополнительно нужно установить флаг 0x2000. Таким образом, значение поля Characteristics для exe-файлов – 0x010E, а для dll-файлов – 0x210E. Если исполняемый файл не содержит таблицы релокаций, то дополнительно нужно установить флаг 0x0001.
Структура программных компонентов
44
char LMinor; Младшее число версии компоновщика. Для сборок .NET – 0. long CodeSize; Суммарный размер всех кодовых секций, всегда выровнен по значению SectionAlignment (см. далее). long InitializedDataSize; Суммарный размер всех секций, содержащих инициализированные данные. Выровнен по значению SectionAlignment (см. далее). Для сборок .NET характерно то, что в состав секций, содержащих инициализированные данные, включают секции с метаданными и CIL-кодом. long UninitializedDataSize; Суммарный размер всех секций, содержащих неинициализированные данные. В сборках .NET, как правило, это поле содержит значение 0 (нет таких секций). long EntryPointRVA; RVA точки входа в программу. Для dll-файлов (обычных, не сборок .NET) может быть равен 0, а может указывать на код, вызываемый в процессе инициализации, завершения работы, а также во время создания или уничтожения потоков управления. Передаче управления на точку входа всегда предшествует корректировка абсолютных адресов (в соответствии с таблицей релокаций), а также формирование таблицы адресов импорта. Для сборок .NET (как exe, так и dll) значение этого поля всегда указывает на 6 байт, расположенных в кодовой секции PE-файла. Эти 6 байт начинаются с двух байтов 0xFF 0x25, за которыми следует некий абсолютный адрес x. Тем самым кодируется следующая инструкция: jmp dword ptr ds:[x] Для exe-файлов адрес x представляет собой сумму значения поля ImageBase (как правило, это 0x400000) и RVA ячейки в таблице адресов импорта, которая соответствует функции _CorExeMain, импортируемой из динамической библиотеки mscoree.dll. Для dll-файлов адрес x представляет собой сумму значения поля ImageBase (как правило, это либо 0x400000, либо 0x10000000, либо 0x11000000) и RVA ячейки в таблице адресов импорта, которая соответствуюет функции _CorDllMain, импортируемой из динамической библиотеки mscoree.dll. Интересно, что описание этого поля в [2] явно не соответствует действительности: «RVA of entry point, needs to point to bytes 0xFF 0x25 followed by the RVA+0x4000000 in a section marked
CIL и системное программирование в Microsoft .NET
45
execute/read for EXEs or 0 for DLLs». Налицо две ошибки: лишний ноль в адресе (который подчеркнут), а также информация о том, что для dll-файлов поле должно быть равно 0. long BaseOfCode; RVA первой кодовой секции в PE-файле. Описание этого поля в [2] абсолютно неправильное: «RVA of the code section, always 0x00400000 for exes and 0x10000000 for DLL.» Авторы явно путают относительные адреса с абсолютными, а также базовый адрес образа PE-файла в памяти с адресом кодовой секции. long BaseOfData; RVA первой секции, содержащей данные. Видимо, не используется загрузчиком, потому что различные версии компоновщика по-разному устанавливают это поле. В 64-разрядной версии формата PE от этого поля вообще отказались. В сборках .NET, не содержащих секций с данными, принято записывать в это поле RVA секции, которая могла бы идти непосредственно после последней секции в PE-файле. Следующие поля специфичны для Windows NT: long ImageBase; Предпочтительный базовый адрес, по которому PE-файл загружается в память (то есть, если файл загружается по этому адресу, то применение таблицы релокаций не нужно). Для exe-файлов, как правило, равен 0x400000, а для dll-файлов – 0x10000000. Нужно отметить, что в выборе базового адреса для dll-файлов наблюдается большой плюрализм мнений. Например, dll-файлы, сгенерированные компилятором C#, содержат в поле ImageBase значение 0x11000000. А dll-файлы, сгенерированные ассемблером ILASM, содержат в этом поле значение 0x400000 (как и exe-файлы). long SectionAlignment; Задает выравнивание секций в памяти. Для сборок .NET всегда равно 0x2000. long FileAlignment; Задает выравнивание секций в PE-файле. Для сборок .NET разрешены значения 0x200 и 0x1000. unsigned short OSMajor; Старшее число версии Windows, для которой предназначена сборка. Это поле игнорируется загрузчиком, и в случае сборок .NET должно содержать значение 4. unsigned short OSMinor; Младшее число версии Windows, для которой предназначена
Структура программных компонентов
46
сборка. Это поле игнорируется загрузчиком, и в случае сборок .NET должно содержать значение 0. unsigned short UserMajor; Старшее число версии данного PE-файла. Для сборок .NET всегда 0. unsigned short UserMinor; Младшее число версии данного PE-файла. Для сборок .NET всегда 0. unsigned short SubsysMajor; Старшее число версии подсистемы Windows, которая требуется для запуска программы. В свое время применялось для того, чтобы отличать программы, использующие новый по тем временам интерфейс Windows 95 и Windows NT 4.0. В настоящее время не используется. Для сборок .NET всегда равно 4. unsigned short SubsysMinor; Младшее число версии подсистемы Windows. Для сборок .NET всегда равно 0. long Reserved; Это поле зарезервировано и всегда содержит 0. long ImageSize; Размер образа PE-файла в памяти. Это поле равно RVA секции, которая могла бы идти непосредственно после последней секции в PE-файле. Естественно, что оно выровнено по значению SectionAlignment. long HeaderSize; Суммарный размер всех заголовков, включая заголовок MSDOS, заголовок PE-файла, дополнительный заголовок PE-файла и массив заголовков секций. Суммарный размер кратен значению из поля FileAlignment. long FileChecksum; Контрольная сумма PE-файла. Для сборок .NET – всегда 0. unsigned short SubSystem; Идентифицирует подсистему для запуска PE-файла. Для сборок .NET допустимы следующие значения: 0x2 – необходим графический пользовательский интерфейс Windows; 0x3 – запускается в консольном режиме; 0x9 – необходим графический пользовательский интерфейс Windows CE. В [2] приводится неверная информация об этом поле: «Subsystem required to run this image. Shall be either IMAGE_SUBSYSTEM_WINDOWS_CE_GUI (0x3) or IMAGE_SUBSYS-
CIL и системное программирование в Microsoft .NET
47
TEM_WINDOWS_GUI (0x2).» unsigned short DLLFlags; В [2] сказано, что это поле всегда равно 0. На практике оно иногда содержит значение 0x400 («No safe exception handler»), когда именно – установить пока не удалось. Самое интересное, что в [5] флаг 0x400 вообще не описан. long StackReserveSize; Количество виртуальной памяти, резервируемое под стек. Как правило, содержит 0x100000. long StackCommitSize; Начальный размер стека. Как правило, равен 0x1000. long HeapReserveSize; Количество виртуальной памяти, резервируемое под кучу. Как правило, содержит 0x100000. long HeapCommitSize; Начальный размер кучи. Как правило, равен 0x1000. long LoaderFlags; Не используется и всегда содержит 0. long NumberOfDataDirectories; Количество директорий данных в дополнительном заголовке. Для сборок .NET обязательно равно 16. В конце дополнительного заголовка размещается массив из 16 директорий данных. Каждая директория данных состоит из двух полей: long RVA; RVA некоторой структуры. Если данная структура отсутствует в PE-файле, это поле равно 0. long size; Размер структуры. Для отсутствующей структуры размер равен 0. Для сборок .NET важны 4 из 16 директорий данных (остальные 12 директорий, как правило, могут быть обнулены): • Директория импорта (номер 2, находится по смещению 8 относительно начала массива директорий). Указывает на данные о функциях, импортруемых из динамическх библиотек (другими словами, указывает на секцию «.idata»). • Директория релокаций (номер 6, смещение 40). Указывает на таблицу релокаций. • Директория таблицы адресов импорта (номер 13, смещение 96). В некотором смысле дублирует директорию импорта, указывая на таблицу адресов импорта. • Директория заголовка CLI (номер 15, смещение 112). Указывает на заголовок, описывающий метаданные сборки .NET.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
2.1.3.4. Заголовки секций Непосредственно после дополнительного заголовка следует массив заголовков секций. Количество секций и, соответственно, размер этого массива задается полем NumberOfSections заголовка PE-файла. Секции в массиве отсортированы по их начальным адресам (по RVA). Заголовок каждой секции состоит из следующих полей: char Name[8]; Имя секции представляет собой ASCIIZ-строку, содержащую не более 8 символов. Если имя содержит ровно 8 символов, то оно не оканчивается на 0. long VirtualSize; Размер секции, когда она загружена в память. Значение этого поля не нужно выравнивать. Если размер секции в памяти превышает размер той же секции в PE-файле (см. далее SizeOfRawData), то разница заполняется нулями. long VirtualAddress; RVA секции, когда она загружена в память. long SizeOfRawData; Размер секции в PE-файле, выровненный по значению FileAlignment из дополнительного заголовка PE-файла. Если секция содержит только неинициализированные данные, значение этого поля должно быть равно 0. long PointerToRawData; Смещение секции относительно начала PE-файла. Значение этого поля всегда выровнено по значению FileAlignment из дополнительного заголовка PE-файла. long PointerToRelocations; Смещение таблицы релокаций для данной секции. Используется только в объектных файлах – в исполняемых файлах равно 0. long PointerToLinenumbers; Смещение информации о номерах строк. В сборках .NET всегда равно 0. short NumberOfRelocations; Количество релокаций для этой секции. В исполняемых файлах всегда равно 0. short NumberOfLinenumbers; Количество номеров строк. В сборках .NET всегда равно 0. long Characteristics; Комбинация флагов, задающая свойства секции: 0x00000020 – секция содержит исполняемый код; 0x00000040 – секция содержит инициализированные данные;
48
49
2.1.4.1. Секция импорта В секции импорта перечисляются все dll-файлы, используемые программой, а также все функции и глобальные переменные, импортируемые из этих файлов. Для краткости будем называть такие функции и глобальные переменные символами. Директория импорта в дополнительном заголовке PE-файла должна указывать на данные, расположенные в секции импорта. Секция импорта содержит названия dll-файлов и имена импортируемых символов, представленные в виде ASCIIZ-строк. При этом используется непрямая схема хранения этих строк, потому что вся основная информация в секции импорта организована в виде таблиц, а строковые данные, в силу их произвольного размера, в таблицах хранить неудобно. Поэтому названия dll-файлов и имена символов компактно хранятся где-то внутри PE-файла (чаще всего – в каком-нибудь свободном месте секции импорта), и вместо них в таблицах записываются их RVA. Схема секции импорта приведена на рис. 2.5. Ключевым элементом этой секции является таблица импорта (Import Directory Table), представляющая собой массив так называемых входов в таблицу импорта (Import Directory Entry). При этом самый последний вход в таблицу импорта заполнен нулями и сигнализирует о конце массива. Каждому dll-файлу, используемому программой, соответствует ровно один вход в таблицу импорта. Этот вход содержит указатели (в форме RVA) на два идентичных массива, которые называются таблицей адресов
Секции PE-файла, как правило, содержат исполняемый код и данные, которые не имеют специального смысла для загрузчика. Но из всякого правила бывают исключения, поэтому далее мы рассмотрим структуру секции импорта «.idata», а также особенности хранения таблицы релокаций в секции «.reloc». В состав PE-файла могут входить и другие особые секции, но мы не будем их обсуждать, так как они не встречаются в сборках .NET.
2.1.4. Особые секции PE-файла
0x00000080 – секция содержит неинициализированные данные; 0x02000000 – секция может быть удалена из исполняемого файла (этот флаг установлен для секции «.reloc», содержащей таблицу релокаций); 0x20000000 – код секции может быть исполнен; 0x40000000 – секция доступна для чтения; 0x80000000 – секция доступна для записи. Для секций, содержащих метаданные и CIL-код, необходимо использовать значение флагов 0x60000020.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
NULL
NULL
Hint/Name Table
У тех, кто внимательно прочитал предыдущий абзац, обязательно должен возникнуть вопрос: а зачем нужно хранить в секции импорта два идентичных массива ILT и IAT? Дело в том, что раньше никакого ILT не было и секция импорта содержала только массив IAT. При загрузке программы происходило так называемое связывание, при котором информация из IAT использовалась для определения адресов импортируемых символов. Эти адреса записывались загрузчиком прямо в IAT (естественно, в образе PE-файла в памяти, а не на диске) поверх той информации, которая там содержалась. Необходимость в дополнительном массиве ILT возникла после изобретения предварительного связывания, при котором таблица адресов импорта заранее заполняется адресами импортируемых символов. Предварительное связывание осуществляется утилитой BIND, которая вычисляет эти адреса и записывает их прямо в PE-файл на диске. Это позволяет несколько ускорить загрузку программы, но при этом возникают новые проблемы. А что если предварительно связанный dll-файл вдруг изменится? Ведь тогда все адреса могут поменяться? Увы, это так. Правда, загрузчик способен определить этот факт и вычислить новые адреса, и для этого ему как раз и нужна копия таблицы адресов импорта, которая находится в ILT.
Рис. 2.5. Схема секции импорта
Import Directory Entry 3
Import Lookup Table
NULL
Import Directory Entry 2
Import Directory Entry 1
Import Directory Table
Import Address Table
импорта (Import Address Table, далее IAT) и таблицей имен импорта (Import Lookup Table, далее ILT). Элементы этих массивов описывают символы, импортируемые из данного dll-файла. При этом каждый массив заканчивается нулевым элементом. Директория таблицы адресов импорта в дополнительном заголовке PE-файла должна указывать на таблицу адресов импорта (IAT).
50
51
2.1.4.2. Секция релокаций В секции релокаций («.reloc») содержится таблица исправлений (Fix-up Table), в которой перечислены все абсолютные адреса в PE-файле, которые надо исправить, если файл загружается по адресу, отличному от указанного в поле ImageBase.
Мы не будем рассматривать детали организации секции импорта, относящиеся к механизму предварительного связывания. Вход в таблицу импорта представляет собой структуру, состоящую из нескольких полей: long ImportLookupTableRVA; RVA таблицы имен импорта (ILT). Ранее, до изобретения предварительного связывания, это поле называлось Characteristics. long TimeDateStamp; Это поле изначально равно нулю (в PE-файле на диске), но после загрузки dll-файла в него (уже в памяти) записывается время загрузки. (В предварительно связанном PE-файле в поле TimeDateStamp должно быть записано значение -1.) long ForwarderChain; Должно быть равно -1. long NameRVA; RVA ASCIIZ-строки, содержащей имя dll-файла. long ImportAddressTableRVA; RVA таблицы адресов импорта (IAT). Теперь рассмотрим, как организованы наши идентичные массивы ILT и IAT. Их элементами являются 32-разрядные целые числа. Если старший бит (31-й) такого 32-разрядного числа установлен в 1, то оставшиеся 31 бит обозначают порядковый номер импортируемого символа. Если же старший бит равен 0, то это 32-разрядное число обозначает RVA структуры Hint/Name, в которой хранится имя импортируемого символа. Структура Hint/Name состоит из трех полей: short Hint; Это поле является подсказкой для загрузчика. Оно содержит предполагаемый номер импортируемого символа. Загрузчик сначала ищет этот символ по указанному номеру. В случае неудачи он выполняет бинарный поиск символа по имени. char Name[x]; Имя импортируемого символа в виде ASCIIZ-строки. char Pad; Это поле служит для выравнивания структуры по четной границе, то есть оно присутствует только тогда, когда структура имеет нечетный размер. Всегда равно нулю.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
Директория заголовка CLI в дополнительном заголовке PE-файла должна указывать на заголовок CLI, который служит главным образом для локализации метаданных в PE-файле. Заголовок CLI состоит из следующих полей: long Cb; Размер заголовка в байтах. short MajorRuntimeVersion; short MinorRuntimeVersion; Эти два поля содержат информацию о версии CLR, для которой
2.1.5. Заголовок CLI
Директория релокаций в дополнительном заголовке PE-файла должна указывать на таблицу исправлений. Таблица исправлений разбита на блоки. Каждый блок описывает исправления, которые нужно внести в определенную страницу (4K байт) загруженного в память PE-файла. Каждый блок должен начинаться на 32битовой границе. В начале каждого блока располагается заголовок, состоящий из следующих полей: long PageRVA; Это поле содержит RVA страницы PE-файла, исправления в которой описываются данным блоком. long BlockSize; Суммарный размер блока в байтах, включая заголовок. После заголовка следует массив 16-разрядных слов, каждое из которых описывает одно исправление. При этом старшие четыре бита каждого из этих слов задают тип исправления, а остальные 12 бит обозначают смещение относительно начала страницы, соответствующей данному блоку. В сборках .NET в качестве типа исправления используется значение 3. Этот тип означает, что по заданному смещению относительно начала описываемой блоком страницы PE-файла находится 32-разрядное значение, которое необходимо исправить. Рассмотрим, как загрузчик выполняет исправление образа PE-файла. Пусть ActualAddress – это адрес, по которому загружен PE-файл. И пусть delta – это смещение исправляемого 32-разрядного значения относительно начала страницы. Тогда адрес исправляемого значения вычисляется следующим образом: FixupAddress = ActualAddress + PageRVA + delta; Внесение исправления в 32-разрядное значение, которое находится по адресу FixupAddress, выполняется так (преобразования типов для простоты не указаны): *FixupAddress += ActualAddress – ImageBase;
52
53
В приложении A приведен исходный код программы pegen, демонстрирующей генерацию PE-файла. Эта программа создает сборку hello.exe, работа которой заключается в дублировании строки, введенной пользователем с клавиатуры. Несмотря на то, что генерируемая сборка столь примитивна, программа pegen может служить основой для разработки реального генератора исполняемых файлов .NET. Программа pegen написана на языке C и состоит из двух частей: 1. модуль генерации PE-файла, оформленный как отдельная библиотека; 2. главный модуль, использующий модуль генерации для создания простейшей сборки .NET.
2.1.6. Пример генерации PE-файла
предназначена данная сборка. В настоящее время эти поля должны содержать значения 2 и 0 соответственно. struct { long RVA, Size; } Metadata; В этом поле указываются RVA и размер в байтах метаданных в образе PE-файла. long Flags; Это поле описывает свойства сборки. Для обычных сборок .NET равно 1. long EntryPointToken; Токен метаданных, указывающий на точку входа в сборку. struct { long RVA, Size; } Resources; В этом поле указываются RVA и размер в байтах ресурсов сборки. struct { long RVA, Size; } StrongNameSignature; В этом поле указываются RVA и размер данных, используемых загрузчиком CLI для контроля версий связываемых динамических библиотек. long CodeManagerTable[2]; Это поле не используется и всегда заполнено нулями. struct { long RVA, Size; } VTableFixups; В этом поле указываются RVA и размер данных, используемых загрузчиком для исправления таблиц виртуальных методов. Так как эти таблицы, вообще говоря, порождаются только некоторыми «экзотическими» компиляторами (предположительно, Visual C++ With Managed Extensions), мы их рассматривать не будем. long ExportAddressTableJumps[2]; Это поле не используется и всегда заполнено нулями. long ManagedNativeHeader[2]; Это поле не используется и всегда заполнено нулями.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
В модуле генерации определена функция make_file, которая принимает блок входных параметров и дескриптор выходного файла: void make_file (FILE* file, PINPUT_PARAMETERS inP) { make_headers (file, inP); // 1 этап make_text_section (file, inP); // 2 этап make_cli_section (file, inP); // 3 этап make_reloc_section (file, inP); // 4 этап }; Как видно из приведенного листинга, эта функция вызывает еще четыре функции, поскольку процесс генерации PE файла разбит на четыре этапа. Блок входных параметров описывается структурой INPUT_PARAMETERS: unsigned long Type; Тип исполняемого файла: exe или dll. Поле может принимать значения: EXE_TYPE – выходной файл-exe; DLL_TYPE – выходной файл-dll. unsigned char* metadata; Это поле содержит указатель на область памяти, где находятся метаданные в бинарном виде. unsigned char* cilcode; Указатель на область памяти, где лежит CIL-код методов в бинарном виде. unsigned long SizeOfMetadata; Размер метаданных. unsigned long SizeOfCilCode; Размер CIL-кода методов. unsigned long ImageBase; Базовый адрес загрузки. unsigned long FileAlignment; Выравнивание секций в файле. unsigned long EntryPointToken; Точка входа в сборку (токен метаданных, соответствующий некоторому статическому методу). unsigned short Subsystem; Тип подсистемы Console или Windows GUI. Поле может принимать значения: IMAGE_SUBSYSTEM_WINDOWS_GUI – подсистема Windows GUI; IMAGE_SUBSYSTEM_WINDOWS_CUI – подсистема Windows CUI. Этих входных данных достаточно для генерации сборки .NET. Подробно рассмотрим каждый этап выполнения программы.
54
55
struct _IMAGE_OPTIONAL_HEADER { //Дополнительный заголовок PE unsigned short Magic; unsigned char LMajor; unsigned char LMinor; unsigned long CodeSize; unsigned long SizeOfInitializedData; unsigned long SizeOfUninitializedData; unsigned long EntryPointRVA; unsigned long BaseOfCode; unsigned long BaseOfData; unsigned long ImageBase; unsigned long SectionAlignment; unsigned long FileAlignment; unsigned short OSMajor; unsigned short OSMinor; unsigned short UserMajor; unsigned short UserMinor;
struct _IMAGE_FILE_HEADER { // заголовок PE unsigned short Machine; unsigned short NumberOfSections; unsigned long TimeDateStamp; unsigned long PointerToSymbolTable; unsigned long NumberOfSymbols; unsigned short OptionalHeaderSize; unsigned short Characteristics; }PeHdr;
2.1.6.1. Этап 1. Заполнение заголовка PE-файла Первый этап включает заполнение структуры HEADERS. Всю работу на этом этапе выполняет функция make_headers, принимающая блок входных параметров и файловый дескриптор. Прототип функции: void make_headers (FILE* file, PINPUT_PARAMETERS inP); Структура HEADERS включает в себя заголовок MS-DOS, сигнатуру PE, заголовок PE, дополнительный заголовок PE, директории данных и заголовки секций. Формат структур IMAGE_DATA_DIRECTORY и IMAGE_SECTION_HEADER, которые входят в структуру HEADERS, можно найти дальше: struct HEADERS { char ms_dos_header[128]; // заголовок MS-DOS unsigned long signature; // сигнатура PE
Структура программных компонентов
56
short short long long long long short short long long long long long long
SubsysMajor; SubsysMinor; Reserved; ImageSize; HeaderSize; FileCheckSum; Subsystem; DllFlags; StackReserveSize; StackCommitSize; HeapReserveSize; HeapCommitSize; LoaderFlags; NumberOfDataDirectories;
// Поле не используется в сборках. Заполняется нулями struct IMAGE_DATA_DIRECTORY STUB5;
// Директория заголовка CLI struct IMAGE_DATA_DIRECTORY CLI_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями struct IMAGE_DATA_DIRECTORY STUB4;
// Директория таблицы адресов импорта struct IMAGE_DATA_DIRECTORY IAT_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями struct IMAGE_DATA_DIRECTORY STUB3[6];
// Директория релокации struct IMAGE_DATA_DIRECTORY BASE_RELOC_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями struct IMAGE_DATA_DIRECTORY STUB2[3];
// Директория импорта struct IMAGE_DATA_DIRECTORY IMPORT_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями struct IMAGE_DATA_DIRECTORY STUB1;
unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned unsigned }OptHdr;
CIL и системное программирование в Microsoft .NET
57
struct IMAGE_SECTION_HEADER { // Заголовок секции unsigned char Name[8]; unsigned long VirtualSize; unsigned long VirtualAddress; unsigned long SizeOfRawData; unsigned long PointerToRawData; unsigned long PointerToRelocations; unsigned long PointerToLinenumbers; unsigned short NumberOfRelocations; unsigned short NumberOfLinenumbers; unsigned long Characteristics; }; В свою очередь функция make_headers вызывает функцию make_headers_const, которая заполняет поля-константы, одинаковые во всех сборках. Для нашего учебного примера выберем расположение секции в файле, указанное на рис. 2.6. Как можно заметить, сгенерированная сборка .NET состоит из 3 секций: 1. Секция «.text» (содержит тела методов и метаданные); 2. Секция «.cli» (содержит точку входа, заголовок CLI, таблицу импорта); 3. Секция «.reloc» (секция релокаций). Следовательно, после дополнительного заголовка в структуре HEADERS будут находиться 3 заголовка секций. Для сборок .NET необходимы 4 директории данных: 1. Директория импорта; 2. Директория релокации; 3. Директория заголовка CLI; 4. Директория таблицы адресов импорта.
struct IMAGE_DATA_DIRECTORY { // Директория данных unsigned long RVA; unsigned long Size; };
// Заголовок .text секции struct IMAGE_SECTION_HEADER TEXT_SECTION; // Заголовок .cli секции struct IMAGE_SECTION_HEADER CLI_SECTION; // Заголовок .reloc секции struct IMAGE_SECTION_HEADER RELOC_SECTION; };
Структура программных компонентов
RVA_OF_TEXT
RVA
Рис. 2.6. Схематичное расположение секций и заголовков
Заголовок MS-DOS
Заголовок PE
Дополнительный заголовок PE
Таблица секций
Метаданные
Тела методов
Секция .text
Точка входа (jmp _CorExeMain)
Заголовок CLI
Таблицы для импорта mscoree.dll
Секция .cli
Секция релокации
CIL и системное программирование в Microsoft .NET
RVA_OF_CLI
На основе блока входных параметров вычисляется расположение секций в памяти. Вычисления осуществляются внутри набора макросов (см. таблицу 2.1).
58
RVA_OF_RELOC
59
Код функции align: #include <stdlib.h> unsigned long align(unsigned long x, unsigned long alignment) { div_t t = div(x,alignment); return t.rem == 0 ? x : (t.quot+1)*alignment; };
Формат и назначение структуры CLI_SECTION_IMAGE описан в 2.1.6.3.
Макрос: RVA_OF_RELOC(params) Описание: RVA секции «.reloc». Принимает в качестве аргумента блок входных параметров (INPUT_PARAMETERS) Подстановка: RVA_OF_CLI(params) + SIZEOF_CLI_M В свою очередь макрос SIZEOF_CLI_M определен как: align(sizeof(struct CLI_SECTION_IMAGE), SECTION_ALIGNMENT)
Макрос: RVA_OF_CLI(params) Описание: RVA секции «.cli». Принимает в качестве аргумента блок входных параметров (INPUT_PARAMETERS) Подстановка: RVA_OF_TEXT + align(params->SizeOfMetadata, SECTION_ALIGNMENT)
Макрос: RVA_OF_TEXT Описание: RVA секции «.text» Подстановка: align(sizeof(struct HEADERS), SECTION_ALIGNMENT) Код функции align приведен в конце таблицы. align – округляет первый аргумент в большую сторону до числа, кратного значению SECTION_ALIGNMENT. SECTION_ALIGNMENT – фиксированное выравнивание секций 0x2000
Таблица 2.1. Описание макросов
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
2.1.6.3. Этап 3. Генерация секции «.cli» Всю работу на этом этапе выполняет функция make_cli_section. Прототип функции: void make_cli_section (FILE* file, PINPUT_PARAMETERS inP); В секции «.cli» содержится структура CLI_SECTION_IMAGE, в которой находится точка входа в приложение, заголовок CLI, таблица импорта и таблица адресов импорта: struct CLI_SECTION_IMAGE { struct _JMP_STUB { // Точка входа unsigned short JmpInstruction; unsigned long JmpAddress; }JMP_STUB; struct _CLI_HEADER { // Заголовок CLI unsigned long cb; unsigned short MajorRuntimeVersion;
2.1.6.2. Этап 2. Генерация секции «.text» Функция, выполняющая работу на этом этапе – make_text_section. Прототип функции: void make_text_section (FILE* file, PINPUT_PARAMETERS inP); В секции «.text» находятся метаданные и тела методов. Сначала в памяти выделяется массив, кратный выравниванию в файле. Размер массива задается макросом SIZEOF_TEXT(params), который определен следующим образом: #define SIZEOF_TEXT(params) \ align(params->SizeOfMetadata+params->SizeOfCilCode, \ params->FileAlignment) Макрос принимает в качестве аргумента блок входных параметров. В выделенную память записываются метаданные из массива metadata и тела методов из массива cilcode, адреса которых передаются в функцию через поля inP->metadata и inP->cilcode блока входных параметров. Затем этот массив записывается в выходной файл сразу после заголовка HEADERS. Если размер метаданных и CIL-кода не кратен inP->FileAlignment, то разница дописывается нулями.
В заключение структура HEADERS пишется в начало выходного файла, причем записывается количество байт, равное значению макроса SIZE_OF_HEADERS(params), который объявлен следующим образом: #define SIZEOF_HEADERS(params) \ align(sizeof(struct HEADERS), params->FileAlignment) Обычно размер структуры HEADERS не кратен inP->FileAlignment, следовательно разница дописывается нулями.
60
61
Поле JmpAddress заполняется значением выражения: RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE,IMPORT_TABLE.Hint) + inP->ImageBase; Заметим, что #define OFFSETOF(s,m) (size_t)&(((s *)0)->m). Таким образом, к абсолютному адресу секции «.cli» прибавляется смещение поля Hint в структуре CLI_SECTION_IMAGE. Сразу за точкой входа находится заголовок CLI, который служит для определения положения метаданных в PE-файле. В заголовке находится информация об RVA и размере метаданных, а также информация о версии CLR, для которой предназначена сборка и токен метаданных, указывающий на точку входа в сборку. У DLL токен точки входа равен 0, т.к. DLL не может сама выполнять какие-либо действия.
};
struct _IMPORT_TABLE { // Import Address Table unsigned long HintNameTableRVA2; unsigned long zero2; // Вход в таблицу импорта unsigned long ImportLookupTableRVA; unsigned long TimeDateStamp; unsigned long ForwarderChain; unsigned long NameRVA; unsigned long ImportAddressTableRVA; unsigned char zero[20]; // Import Lookup Table unsigned long HintNameTableRVA1; unsigned long zero1; // Hint/Name Table unsigned short Hint; char Name[12]; // Dll name (“mscoree.dll”) char DllName[12]; }IMPORT_TABLE;
unsigned short MinorRuntimeVersion; struct IMAGE_DATA_DIRECTORY MetaData; unsigned long Flags; unsigned long EntryPointToken; struct IMAGE_DATA_DIRECTORY NotUsed[6]; }CLI_HEADER;
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
2.1.6.5. Метаданные и методы Если описать метаданные и методы сгенерированной сборки на CIL с использованием синтаксиса ILASM, то получится следующая IL-программа:
2.1.6.4. Этап 4. Генерация секции «.reloc» Функция, ответственная за этот этап – make_reloc_section. Прототип данной функции: void make_reloc_section (FILE* file, PINPUT_PARAMETERS inP); Заключительная секция релокации содержит исправления для единственного абсолютного адреса в сборке, который находится в точке входа jmp dword ptr ds:[x] в секции «.cli». Адрес x надо исправить, если сборка грузится по адресу, отличному от базового. Сгенерированная секция «.reloc» содержит единственную структуру RELOC_SECTION, в которой есть все необходимые поля для исправления. Поле PageRVA содержит адрес страницы, в которой надо произвести исправление. Заполняется значением макроса RVA_OF_CLI. Поле BlockSize заполняется значением макроса SIZEOF_RELOC_NOTALIGNED, который определен так: #define SIZEOF_RELOC_NOTALIGNED sizeof(struct RELOC_SECTION). В сборках .NET в качестве типа исправления используется значение 3. Смещение адреса x на странице равно 2, т.к. расположение секций в памяти выровнено по страницам: struct RELOC_SECTION { unsigned long PageRVA; // адрес страницы unsigned long BlockSize; // размер блока unsigned short TypeOffset; // тип исправления и // смещение на странице unsigned short Padding; // завершающие нули }; Структура записывается в конец файла после секции «.cli». Чтобы размер файла был кратен inP->FileAlignment, в него дописывается определенное количество нулей.
В конце работы функции структура CLI_SECTION_IMAGE пишется в выходной файл, сразу после секции «.text». Записывается количество байт, равное значению макроса SIZEOF_CLI, который имеет следующий вид: #define SIZEOF_CLI(params) \ align(sizeof(struct CLI_SECTION_IMAGE), params->FileAlignment) Если структура CLI_SECTION_IMAGE не кратна inP->FileAlignment, то разница дописывается нулями.
62
63
Метаданные, используемые при генерации сборки, находятся в массиве metadata, который в программе описан следующим образом (полное описание не приводится из-за его большого размера, полностью листинг массива metadata приводится в исходных текстах учебного примера): unsigned char metadata[] = { 0x42, 0x53, 0x4A, 0x42, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, . . . . . . . . . . . . . . . . . . . . . . . . 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; В такой же форме в программе находится CIL-код методов: unsigned char cilcode[] = { 0x56, 0x72, 0x01, 0x00, 0x00, 0x70, 0x28, 0x02,
.assembly extern mscorlib { .ver 1:0:5000:0 } .assembly arith { .hash algorithm 0x00008004 .ver 1:0:1:1 } .module arith.exe // MVID: {86612D1B-0333-4F08-A88A-857326D72DDF} .imagebase 0x11000000 .subsystem 0x00000003 .file alignment 4096 .corflags 0x00000001 // Image base: 0x02ef0000 .method public static void calc() cil managed { .entrypoint // Code size 21 (0x15) .maxstack 8 IL_0000: ldstr “Hello” IL_0005: call void [mscorlib]System.Console::WriteLine(string) IL_000a: call string [mscorlib]System.Console::ReadLine() IL_000f: call void [mscorlib]System.Console::WriteLine(string) IL_0014: ret }
Структура программных компонентов
};
0x00, 0x00, 0x0A, 0x28, 0x01, 0x00, 0x00, 0x0A, 0x28, 0x02, 0x00, 0x00, 0x0A, 0x2A
CIL и системное программирование в Microsoft .NET
Метаданные служат для описания типов, находящихся в сборке .NET, и хранятся в исполняемых файлах. Для хранения метаданных используется достаточно сложный двоичный формат, изложение которого заняло бы слишком много времени, поэтому в этом разделе мы ограничимся лишь частичным и в большой степени поверхностным описанием формата метаданных. Если поставить себе цель в двух словах охарактеризовать формат метаданных в сборках .NET, то можно сказать следующее: он был бы значительно проще, если бы его разработчики не уделяли чрезмерного внимания вопросу компактности хранения метаданных. Дело в том, что спецификация формата определяет по нескольку способов кодирования одной и той же информации, описывает способы сжатия отдельных элементов метаданных (например, сигнатур методов) и, тем самым, оказывается загроможденной множеством деталей, затрудняющим разработку метаинструментов. Для того чтобы провести обзор формата метаданных в этом разделе, мы используем следующий прием: рассмотрим только те детали формата, которые используются в незатейливом примере, выводящем на экран надпись «Hello, World!», а затем ожидающим ввода данных с клавиатуры. Может показаться, что пример слишком прост, однако, как мы увидим в даль-
2.2. Формат метаданных
2.1.6.6. Пример работы программы Итак, попробуем запустить нашу программу, набрав в консоли pegen.exe (так будет называться наша программа): C:\>Pegen.exe Если все прошло успешно, то на экране мы увидим сообщение об успешной генерации сборки hello.exe: File: hello.exe generated Запустим сгенерированную сборку: C:\>hello.exe Программа распечатает на экране строку «Hello» и попросит ввести произвольный текст. Введем, например, строку: Hello Programm В результате программа распечатает на экране строку, введенную ранее, и закончит свою работу: Hello Programm
64
65
В предыдущем разделе данной главы мы рассмотрели формат исполняемых файлов .NET и выяснили, что он дает разработчику достаточно большую свободу для размещения отдельных элементов внутри исполняемого файла. В частности, исполняемые файлы могут содержать несколько секций, и расположение этих секций, а также данных внутри них является более или менее произвольным. Давайте выберем для нашего учебного примера схему размещения данных внутри исполняемого файла, изображенную на рисунке 2.7. Мы будем использовать две секции: секция «.text» будет содержать всю основную информацию, а в секции «.reloc» будет размещена таблица релокаций.
2.2.1. Расположение метаданных и кода внутри сборки
// Завершаем выполнение ret }
// Выводим введенную строку на экран call void [mscorlib]System.Console::WriteLine(string)
// Ожидаем, пока пользователь введет строку call string [mscorlib]System.Console::ReadLine()
// Выводим строку на экран call void [mscorlib]System.Console::WriteLine(string)
нейшем, генерация метаданных даже для такого несложного примера требует больших усилий. Сборку .NET, соответствующую нашему примеру, можно получить, если откомпилировать следующую программу, записанную в синтаксисе ассемблера ILASM: .assembly HelloWorld { .hash algorithm 0x00008004 .ver 1:0:1:1 } .module HelloWorld.exe // hello() – единственный метод в нашей сборке .method public static void hello() cil managed { .entrypoint .maxstack 8 // Загружаем строку “Hello, World!” на стек ldstr “Hello, World!”
Структура программных компонентов
Рис. 2.7. Размещение данных внутри исполняемого файла для учебного примера
Заголовок MS-DOS
Заголовок PE-файла
Дополнительный заголовок PE-файла
Таблица секций
Тело метода hello
Метаданные
Таблицы для импорта из mscoree.dll Точка входа (jmp_CorExeMain) Заголовок CLI
Секция .text
Секция .reloc
CIL и системное программирование в Microsoft .NET
На схеме видно, что в начале секции «.text» располагается тело метода hello, за которым следуют метаданные. Напомним, что расположение метаданных задается в заголовке CLI, который мы рассматривали в предыдущем разделе данной главы. Тем самым, мы вольны выбрать для метаданных практически любое место внутри секции, и их расположение, изображенное на схеме, является лишь одним из многих возможных вариантов. Заметим, что метаданные и CIL-код практически не зависят от остальных элементов формата исполняемого файла. Они занимают часть сборки .NET, при этом заголовок CLI указывает на метаданные, а внутри метаданных хранятся RVA тел методов. Тело метода hello в нашем примере расположено в самом начале секции исключительно для того, чтобы облегчить вычисление RVA этого метода (RVA метода совпадает с RVA секции).
66
67
Offset; Size; Name [x];
каждого потока */ StreamHeader
struct MetadataRoot { long Signature; /* 0x424A5342 */ ... unsigned short Streams; }
/* Для struct { long long char }
Потоки метаданных (metadata streams) предназначены для хранения определенных видов информации. Заголовок каждого потока метаданных представляет собой структуру, состоящую из трех полей: long Offset; Смещение потока метаданных относительно начала метаданных в файле (то есть относительно начала корня метаданных). long Size; Размер потока метаданных в байтах (должен быть кратен четырем).
Рис. 2.8. Структура метаданных
Корень метаданных
Заголовки потоков метаданных
Blob heap (#Blob)
String heap (#Strings)
User String heap (#US)
GUID heap (#GUID)
Таблицы метаданных
На рисунке 2.8 представлена схема структуры метаданных. Метаданные начинаются с заголовка, называемого корнем метаданных (metadata root). Корень метаданных начинается с 32-разрядной сигнатуры 0x424A5342. Если каждый байт сигнатуры рассматривать в виде ASCII-кода, то получится строка «BSJB», составленная из начальных букв имен четырех основных разработчиков .NET Framework: Брайана Харри (Brian Harry), Сьюзан Радке-Спроулл (Susan Radke-Sproull), Джейсона Зандера (Jason Zander) и Билла Эванса (Bill Evans). Далее в корне метаданных следует информация, относящаяся к версии .NET, а заканчивается корень метаданных 16-разрядным целым числом, содержащим количество так называемых потоков метаданных, заголовки которых располагаются непосредственно после корня метаданных.
2.2.2. Структура метаданных
Структура программных компонентов
Потоки метаданных
CIL и системное программирование в Microsoft .NET
Имя потока “#GUID”
”#~”
Описание Представляет собой последовательность 128-битных глобальных уникальных идентификаторов Содержит строковые константы, определенные в программе Содержит названия элементов метаданных (типов, методов, полей и т.п.) Содержит двоичные данные, описывающие метаданные (например, сигнатуры методов) Содержит физическое представление таблиц метаданных
В спецификации CLI определены несколько десятков видов таблиц метаданных. Мы ограничимся рассмотрением только тех из них, которые используются в нашем учебном примере. На рис. 2.9 представлена структура потока таблиц метаданных для учебного примера. Поток таблиц начинается с заголовка таблиц, непосредственно после которого следуют сами таблицы. Заголовок таблиц метаданных содержит большое количество полей, из которых нас интересуют в первую очередь три поля: char HeapSizes; Различные биты этого поля задают размеры индексов, используемых для адресации куч метаданных. Бит 0 соответствует куче строк, бит 1 – куче GUID'ов, бит 3 – куче двоичных данных.
2.2.3. Таблицы метаданных
Таблицы метаданных
Куча двоичных данных “#Blob”
Куча пользовательских ”#US” строк Куча строк “#Strings”
Поток Куча GUID'ов
Таблица 2.2. Потоки метаданных
char Name[x]; ASCIIZ-строка, содержащая имя потока метаданных. Это поле имеет переменную длину. В спецификации CLI определено пять видов потоков метаданных. Четыре потока метаданных представляют собой так называемые кучи, то есть хранилища однородных объектов, таких как строки и GUID'ы, и один поток метаданных имеет реляционную структуру и содержит таблицы метаданных. В таблице 2.2 приведено описание каждого из пяти потоков.
68
8
0
16
00000000
24
00000000
32
00000011
00000000 48
40
69
2.2.3.1. Таблица сборок (Assembly – 0x20) В этой таблице содержится только одна запись, описывающая нашу сборку. В этой записи для нас интерес представляют следующие поля:
Если некоторый бит установлен, это означает, что соответствующая куча адресуется 32-разрядными индексами. В противном случае куча адресуется 16-разрядными индексами. char Valid[8]; Размер этого поля – 64 бита. При этом каждый бит соответствует одной таблице метаданных. Если некоторый бит установлен, значит, соответствующая ему таблица присутствует в метаданных. unsigned long Rows[7]; Массив 32-разрядных целых чисел, содержащих количество записей в каждой из присутствующих таблиц метаданных. В нашем учебном примере этот массив имеет размер 7, так как мы используем семь таблиц. На рис. 2.10 представлено распределение элементов метаданных, используемых в учебном примере, по таблицам метаданных. При этом каждая таблица имеет порядковый номер, который соответствует номеру описывающего ее бита в массиве Valid. Давайте подробнее рассмотрим каждую из семи используемых в примере таблиц метаданных.
56
00000000
struct MetadataTables { ... long HeapSizes; ... char Valid[8]; ... unsigned long Rows[7]; } 00000000
Рис. 2.9. Структура потока таблиц метаданных
0000100
01000111
Заголовок таблиц
Module Table – 0x00
TypeRef Table 0x01
TypeDef Table – 0x02
Method Table – 0x06
ModuleRef Table – 0x0A
Assembly Table – 0x20
AssemblyRef Table – 0x23
Структура программных компонентов
<Module> System.Console HelloWorld.exe
TypeDef Table – 0x02
TypeRef Table – 0x01
Module Table – 0x00
short MajorVersion; short MinorVersion; short BuildNumber; short RevisionNumber; Эти четыре поля хранят информацию о версии сборки. short Name; Это поле содержит индекс в куче строк, по которому хранится имя сборки («HelloWorld»).
Рис. 2.10. Распределение элементов метаданных учебного примера по таблицам
hello
WriteLine
Method Table – 0x06
ReadLine
HelloWorld
Assembly Table – 0x20
MemberRef Table – 0x0A
mscorlib
AssemblyRef Table – 0x23
CIL и системное программирование в Microsoft .NET
2.2.3.3. Таблица определенных в сборке типов (TypeDef – 0x02) В этой таблице каждая запись соответствует одному типу, объявленному в сборке. В нашем учебном примере нет классов, но для того чтобы
2.2.3.2. Таблица модулей (Module – 0x00) Таблица модулей содержит только одну запись, описывающую модуль. В этой записи существенными являются два поля: short Name; Это поле содержит индекс в куче строк, по которому хранится имя модуля («HelloWorld.exe»). short Mvid; Это поле содержит индекс в куче GUID'ов, по которому хранится глобальный уникальный идентификатор модуля.
70
71
2.2.3.5. Таблица импортируемых сборок (AssemblyRef – 0x23) Все импортируемые сборки должны быть перечислены в таблице импортируемых сборок. Каждая запись этой таблицы содержит следующие поля: short MajorVersion; short MinorVersion; short BuildNumber; short RevisionNumber; Эти четыре поля хранят информацию о версии импортируемой сборки. short Name;
2.2.3.4. Таблица методов (Method – 0x06) Таблица методов описывает методы, объявленные в сборке. Каждая запись этой таблицы содержит информацию об одном методе, представленную в следующих полях: long RVA; RVA тела метода в исполняемом файле. short Flags; Набор флагов, задающих область видимости метода и другие его атрибуты. short Name; Это поле содержит индекс в куче строк, по которому хранится имя метода. short Signature; Индекс в куче двоичных данных, по которому расположена сигнатура метода. short ParamList; В этом поле хранится индекс в таблице описателей параметров метода.
объявить глобальную функцию, нам требуется специальный абстрактный тип <Module>. Дело в том, что все глобальные функции и поля считаются принадлежащими этому типу. Запись, описывающая этот тип, содержит следующие интересующие нас поля: short Name; Это поле содержит индекс в куче строк, по которому хранится имя типа («<Module>»). short FieldList; short MethodList; Эти два поля содержат индексы в таблицах полей и методов, начиная с которых расположены описатели полей и методов типа.
Структура программных компонентов
Это поле содержит индекс в куче строк, по которому хранится имя импортируемой сборки (в нашем случае это «mscorlib»).
CIL и системное программирование в Microsoft .NET
В настоящее время большой популярностью пользуется компонентный подход к разработке программного обеспечения. Этот подход характеризуется тем, что создаваемый программный продукт состоит из взаимодействующих компонентов. При этом различные компоненты могут независимо разрабатываться разными группами программистов, и при создании каждого компонента может применяться наиболее подходящий язык программирования. В качестве примера можно привести Microsoft Visual Studio .NET и Microsoft Office.
2.3. Взаимодействие программных компонентов
2.2.3.7. Таблица членов импортируемых типов (MemberRef – 0x0A) В таблице членов импортируемых типов перечислены все методы, поля и свойства этих типов, которые используются в программе. Каждая запись этой таблицы содержит следующие поля: short Class; Специальным образом закодированная информация об импортируемом типе. short Name; Это поле содержит индекс в куче строк, по которому хранится имя члена импортируемого типа. short Signature; Индекс в куче двоичных данных, по которому расположена сигнатура члена импортируемого типа.
2.2.3.6. Таблица импортируемых типов (TypeRef – 0x01) Каждая запись в этой таблице соответствует одному из импортируемых типов и содержит следующие поля: short ResolutionScope; Специальным образом закодированная информация о том, какой сборке или какому модулю принадлежит данный тип. short Name; Это поле содержит индекс в куче строк, по которому хранится имя импортируемого типа (в нашем случае это «Console»). short Namespace; Это поле содержит индекс в куче строк, по которому хранится имя пространства имен; данному пространству принадлежит импортируемый тип (в нашем случае это «System»).
72
73
2.3.1.1. Библиотеки подпрограмм Наиболее древний способ заключается в использовании библиотек подпрограмм. Такие библиотеки создаются одной группой разработчиков,
Существует множество способов и технологий организации компонентного программирования. Давайте проведем беглый обзор достижений в этой области.
2.3.1. Обзор компонентных технологий
К сожалению, проблемы в организации взаимодействия компонентов зачастую перевешивают преимущества компонентного подхода. Действительно, языки программирования используют различные несовместимые между собой модели данных, соглашения о вызове подпрограмм, форматы исполняемых и объектных файлов и т.п. Поэтому взаимодействие компонентов, написанных на разных языках, требует от разработчика дополнительных усилий и, кроме того, может существенно снизить эффективность получаемого кода. Отсутствие удовлетворительной технологии взаимодействия компонентов приводит к следующим негативным явлениям: • Для разработки программной системы используется только один язык программирования, даже если часть системы удобней было бы реализовать на другом языке. • Программа выглядит как гигантский монолит, из которого трудно выделить отдельные части и, соответственно, невозможно заменить одну часть на другую. • Появляются чрезмерно универсальные языки программирования, а существующие языки наделяются несвойственными им возможностями. Например, функциональные и логические языки оснащаются библиотеками для разработки графического пользовательского интерфейса. В компонентной системе можно выделить три вида взаимодействия компонентов: 1. взаимодействие внутри адресного пространства одного процесса; 2. межпроцессное взаимодействие, при котором компоненты работают в разных процессах; 3. взаимодействие в сети, когда компоненты запущены на разных компьютерах. В этом разделе мы ограничимся рассмотрением первого случая, то есть изучим, как на платформе .NET реализовано взаимодействие компонентов, работающих в рамках одного процесса. Межпроцессное и сетевое взаимодействие используется, главным образом, в распределенных системах, изучение которых выходит за рамки нашего учебника.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
2.3.1.2. Открытые исходные тексты Еще один интересный путь создания компонентных программ придумали в мире открытых исходников, в котором компоненты распространяются в виде исходных текстов. Тем самым вроде бы решается часть проблем. По крайней мере, можно попытаться переносить эти компоненты с платформы на платформу (перекомпилируя исходники и исправляя возникающие ошибки). Кроме того, исходные тексты в отличие от двоичных файлов содержат информацию о типах, что позволяет использовать объектно-ориентированные возможности. Но и здесь нас подстерегают неприятности: • Если компоненты написаны на разных языках, то соединить их вместе не так-то просто. Отсюда проистекает тяга фанатов открытых исходников к превращению своих программ в набор маленьких утилит, написанных на C и соединенных посредством корявых и трудночитаемых скриптов. • Открытые компоненты почти всегда имеют так называемые «заразные» лицензии (например, GNU Public License – GPL). Та-
а затем могут быть использованы другими программистами в других проектах. Исходный код библиотек может быть закрыт, то есть они могут распространяться в откомпилированном виде, защищая тем самым интересы разработчиков. Однако организация компонентного программирования на базе библиотек подпрограмм обладает следующими недостатками: • Двоичный код библиотеки жестко привязан к аппаратной платформе, так как содержит инструкции конкретного процессора. Это уменьшает переносимость программы, использующей библиотеку. • Как правило, библиотека рассчитана на использование конкретного компоновщика и может поддерживать ограниченное количество языков программирования, компиляторы которых генерируют объектные файлы в нужном формате и используют нужные соглашения о вызове подпрограмм. • Библиотеки подпрограмм плохо подходят для объектно-ориентированных языков, так как не содержат никакой информации о типах. Например, если вы разработаете библиотеку классов на C++, вам придется распространять вместе с ней заголовочные файлы, содержащие объявления классов. Динамические библиотеки некоторым образом облегчают компонентное программирование. Они поддерживаются операционной системой, и поэтому разработчики компиляторов вынуждены следовать требованиям, налагаемым операционной системой. То есть динамические библиотеки не зависят от причуд компиляторов и компоновщиков и могут использоваться для большего диапазона языков программирования. Однако в остальном они не лучше, чем обычные библиотеки подпрограмм.
74
75
2.3.1.3. Технологии COM и CORBA Особого внимания заслуживают технологии COM (Component Object Model) и CORBA (Common Object Request Broker Architecture). Технология COM, разработанная корпорацией Microsoft, поддерживает все три вида взаимодействия компонентов, а именно: взаимодействие внутри адресного пространства процесса, межпроцессное взаимодействие и взаимодействие по сети. Технология CORBA ориентирована исключительно на взаимодействие компонентов по сети. Первое, с чем сталкивается программист при использовании COM или CORBA, это необходимость описывать метаданные на языке IDL (Interface Definition Language). В COM и CORBA используются несколько разные варианты языка IDL, но для нас сейчас это несущественно. Подобное неудобство объясняется тем, что эти технологии не подразумевают специальной поддержки в компиляторах языков программирования. Например, мы можем написать COM-объект на языке C++, но ничего не знающий о COM компилятор C++, естественно, не сгенерирует никаких метаданных! Вот поэтому метаданные нужно отдельно описывать на языке IDL. Технологии COM и CORBA определяют двоичный стандарт для передачи данных между компонентами. Так как компоненты могут быть написаны на разных языках и, соответственно, внутреннее представление данных в них может существенно различаться, то компонент, осуществляющий передачу, вынужден выполнять преобразование передаваемых данных в стандартное представление (маршалинг), а компоненту, принимающему данные, приходится выполнять преобразование данных из стандартного представления к своему внутреннему представлению (демаршалинг). При межпроцессном взаимодействии и взаимодействии по сети затраты на преобразование данных не играют существенной роли, но при взаимодействии внутри адресного пространства одного процесса они могут сильно сказаться на производительности программы. На рис. 2.11 изображена схема взаимодействия двух объектов при использовании технологии COM или CORBA. Объекты Client и Server находятся в разных компонентах, поэтому объект Client не может непосредственно передать сообщение объекту Server. Вместо этого он обращается к специальному объекту-заглушке ClientStub, который осуществляет упаковку сообщения и затем передает его информационной магистрали (в терминах CORBA информационная магистраль называется ORB – Object Request Broker). Информационная магистраль передает сообщение
кие лицензии требуют, чтобы программы, использующие эти компоненты, сами распространялись в виде открытых исходников. Этот печальный факт существенно ограничивает применимость открытых компонентов.
Структура программных компонентов
Client Stub
магистраль
Информационная Server Stub
Server
Компонент2
Рис. 2.11. Взаимодействие двух объектов через COM или CORBA
Client
Компонент1
CIL и системное программирование в Microsoft .NET
Организация компонентного программирования на платформе .NET необычайно проста и удобна для программиста. Давайте обсудим, какие особенности .NET обеспечивают это удобство. Во-первых, компилятор любого языка программирования, реализованного на платформе .NET, генерирует сборки, содержащие CIL-код и метаданные. При этом формат этих сборок определяется спецификацией CLI и не зависит от языка программирования. Во-вторых, любая программа, работающая в среде .NET, использует общую систему типов CTS. Поэтому представление данных в памяти определяется CTS и не зависит от языка программирования, на котором написана программа.
2.3.2. Взаимодействие компонентов в среде .NET
серверному объекту-заглушке ServerStub, который распаковывает сообщение и вызывает соответствующий метод объекта Server. Результат, возвращаемый методом объекта Server, совершает обратный путь к объекту Client аналогичным образом. Данный пример показывает, что использование технологий COM и CORBA связано с большими трудностями. Ситуация, пожалуй, облегчается лишь тем, что объекты-заглушки могут быть сгенерированы автоматически на основе описания объекта Server на языке IDL (для этого существуют специальные компиляторы). Хотя технологии COM и CORBA продолжают активно применяться, есть все основания утверждать, что в самом ближайшем будущем они будут вытеснены более эффективными и удобными технологиями (например, .NET).
76
77
2.3.2.1. Видимость и контроль доступа Метаданные в сборках .NET содержат полную информацию о типах. При этом каждый тип может экспортироваться или не экспортироваться из сборки, а каждый член типа (метод, поле, свойство и т.д.) должен быть объявлен с определенным значением флага доступа.
Компоненты на платформе .NET представляют собой сборки .NET. Сборка .NET может статически импортировать любую другую сборку и свободно использовать типы, экспортируемые из этой сборки. Для этого информация об импортируемой сборке заносится в таблицу метаданных AssemblyRef, информация о каждом импортируемом типе – в таблицу TypeRef, а информация о каждом импортируемом методе и поле – в таблицу MemberRef. Кроме того, сборка может импортироваться динамически через рефлексию (мы рассмотрим эту возможность в пункте 4.4.2).
Server
Компонент2
Рис. 2.12. Взаимодействие двух объектов в среде .NET
Client
Компонент1
Common Language Runtime
В-третьих, выполнение любой программы управляется средой выполнения CLR. Это означает, что среда выполнения контролирует JITкомпиляцию CIL-кода программы, выполняет управление памятью и в каждый момент времени имеет всю информацию о состоянии программы. Эти три особенности платформы .NET позволяют среде выполнения автоматически обеспечивать взаимодействие компонентов вне зависимости от того, на каком языке они написаны. На рис. 2.12 изображена схема взаимодействия двух объектов на платформе .NET. Объекты Client и Server находятся в разных компонентах, работающих в адресном пространстве одного процесса (здесь мы не рассматриваем межпроцессное взаимодействие, а также взаимодействие по сети). Передача сообщения от объекта Client объекту Server сводится к простому вызову соответствующего метода объекта Server, то есть в среде .NET не нужны никакие объекты-заглушки.
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
0x00000004
0x00000005
0x00000006
0x00000007
NestedFamily
NestedAssembly
NestedFamAndAssem
NestedFamOrAssem
Описание Тип не экспортируется из сборки Тип экспортируется из сборки Вложенный тип доступен везде Вложенный тип доступен только внутри того типа, в который он вложен Вложенный тип доступен наследникам того типа, в который он вложен Вложенный тип доступен везде внутри сборки Вложенный тип доступен наследникам того типа, в который он вложен, но только внутри сборки Вложенный тип доступен везде внутри сборки, и, кроме того, наследникам того типа, в который он вложен
2.3.2.2. Пример межъязыкового взаимодействия Рассмотрим учебный пример, который представляет собой компонентную систему, написанную сразу на четырех языках. Диаграмма классов примера дана на рисунке 2.13. Абстрактный класс SortedArray реализован на Visual Basic .NET. В этом классе определено поле Arr, представляющее собой массив целых чисел. Конструктор класса SortedArray копирует в это поле массив, передаваемый ему в качестве параметра, а затем вызывает абстрактный метод
Доступ к методам, полям и свойствам типа определяется значением флага доступа. Описание допустимых значений приводится в таблице 2.4.
Значение 0x00000000 0x00000001 0x00000002 0x00000003
Флаг NotPublic Public NestedPublic NestedPrivate
Таблица 2.3. Флаги видимости для типов
Видимость типа (т.е. экспортируется он из сборки или нет) определяется флагом видимости и хранится в поле Flags соответствующей этому типу записи в таблице метаданных TypeDef. В таблице 2.3 приведен набор флагов видимости для типов.
78
0x00000003 0x00000004 0x00000005 0x00000006
Assembly Family FamOrAssem Public
79
InsertSortedArray +.ctor() #Sort()
Public MustInherit Class SortedArray Protected Arr() As Integer Protected MustOverride Sub Sort() Public Sub New(ByVal A() As Integer) Dim i As Integer Arr = New Integer(A.Length – 1) {}
Sort() для сортировки этого массива. Для доступа к отсортированному массиву используются свойства Array и Count:
Main +main()
Описание Доступ контролируется компилятором Доступен только внутри типа Доступен наследникам типа, объявленным внутри сборки Доступен только внутри сборки Доступен наследникам типа Доступен внутри сборки, а также наследникам типа Доступен везде
Рис. 2.13. Диаграмма классов учебного примера
BubleSortedArray +.ctor() #Sort()
SortedArray #Arr +.ctor() #Sort +get_Array():Integer +get_Count():Integer
0x00000001 0x00000002
Значение 0x00000000
Private FamAndAssem
Флаг CompilerControlled
Таблица 2.4. Флаги доступа для членов типа
Структура программных компонентов
End Class
For i = 0 To A.Length – 1 Arr(i) = A(i) Next Sort() End Sub Default Public ReadOnly Property Array (ByVal Index As Integer) As Integer Get Return Arr(Index) End Get End Property Public ReadOnly Property Count() As Integer Get Return Arr.Length End Get End Property
CIL и системное программирование в Microsoft .NET
public __gc class BubbleSortedArray: public SortedArray { protected: void Sort() { for (int i = Arr->Length, flag = 1; i > 1 && flag; i--) { flag = 0; for (int j = 0; j < i-1; j++) if (Arr[j] < Arr[j+1]) { int tmp = Arr[j]; Arr[j] = Arr[j+1]; Arr[j+1] = tmp; flag = 1; } } }
Класс BubbleSortedArray написан на Visual C++ with Managed Extensions. Он переопределяет абстрактный метод Sort(), реализуя в нем пузырьковую сортировку: using namespace VBLib;
80
81
public: BubbleSortedArray(int A __gc []): SortedArray(A) { } }; Класс InsertSortedArray написан на Visual C#. Он переопределяет абстрактный метод Sort(), реализуя в нем сортировку вставками. using VBLib; public class InsertSortedArray: SortedArray { protected override void Sort() { for (int i = 0; i < Arr.Length-1; i++) { int max = i; for (int j = i+1; j < Arr.Length; j++) if (Arr[j] > Arr[max]) max = j; int tmp = Arr[i]; Arr[i] = Arr[max]; Arr[max] = tmp; } } public InsertSortedArray(int[] A): base(A) { } } И, наконец, все вышеперечисленные классы используются в программе, написанной на Visual J#. package JsApp; import VBLib.SortedArray; public class Main { public static void main(String[] args) { int A[] = new int[] { 5, 1, 6, 0, -4, 3 }; SortedArray SA1 = new BubbleSortedArray(A), SA2 = new InsertSortedArray(A); for (int i = 0; i < SA1.get_Count(); i++) System.out.print(“”+SA1.get_Array(i)+” “); System.out.println(); for (int i = 0; i < SA2.get_Count(); i++) System.out.print(“”+SA2.get_Array(i)+” “); System.out.println(); } }
Структура программных компонентов
CIL и системное программирование в Microsoft .NET
Различные языки программирования, которые уже реализованы или могут быть реализованы на платформе .NET, используют общую систему типов (CTS) в качестве модели данных. Общая система типов поддерживает все типы, с которыми работают распространенные в настоящее время языки программирования, но не каждый язык поддерживает все типы, реализованные в общей системе типов. Например, Visual Basic допускает типизированные ссылки в качестве параметров методов, и эти типизированные ссылки реализованы в общей системе типов, но C# их не понимает и, соответственно, не может использовать методы с такими параметрами. Для того, чтобы любую библиотеку классов можно было использовать из любого языка платформы .NET, разработчики .NET придумали общую спецификацию языков (Common Language Specification – CLS). В этой спецификации оговариваются правила, которым должны следовать разработчики языков и библиотек. То есть она описывает некоторое подмножество общей системы типов, и если некий язык реализует хотя бы это подмножество, а библиотека использует только входящие в это подмножество типы, то такая библиотека может быть использована из этого языка. В терминологии CLS библиотеки, соответствующие спецификации CLS, называются средами (frameworks), но мы будем называть их CLS-библиотеками. Компиляторы, генерирующие код, из которого можно получить доступ к CLS-библиотекам, называются потребителями (consumers). Компиляторы, которые являются потребителями, но, к тому же, способны создавать новые CLS-библиотеки, называются расширителями (extenders). Приведем основные правила общей спецификации языков: 1. Спецификация распространяется только на доступные извне части экспортируемых из библиотеки типов. То, что остается внутри библиотеки, может использовать любые типы, не оглядываясь на спецификацию. 2. Упакованные типы-значения, неуправляемые указатели и типизированные ссылки не должны использоваться. Дело в том, что отнюдь не во всех языках, реализованных на платформе .NET, есть соответствующие понятия, и, более того, добавление этих понятий во все языки представляется нецелесообразным. 3. Регистр букв в идентификаторах не имеет значения. Это правило объясняется тем, что в некоторых языках программирования (например, в Паскале) регистр букв не различается. 4. Методы не должны иметь переменного количества параметров. 5. Глобальные поля и методы не поддерживаются спецификацией. 6. Объекты исключений должны наследовать от System.Exception.
2.3.3. Общая спецификация языков
82
83
Тело метода в сборке .NET закодировано в виде потока инструкций языка CIL. Поток инструкций представляет собой массив байт, в котором размещены последовательности байт, кодирующие каждую инструкцию. При этом инструкции размещаются последовательно друг за другом без промежутков. Если сравнить поток инструкций CIL с потоком инструкций обычного процессора, можно заметить одно очень существенное отличие. Дело в том, что обычный процессор занимается непосредственным выполнением инструкций, то есть в каждый конкретный момент времени его интересует только та инструкция, которую он в этот момент выполняет. Это означает, что поток инструкций для обычного процессора может содержать последовательности байт, не являющиеся правильными кодами инструкций, при условии, что на эти последовательности никогда не будет передано управление. Такие «неправильные» последовательности зачастую представляют собой некоторые данные (или места, зарезервированные для данных), используемые программой. Так как поток инструкций CIL предназначен для JIT-компиляции, он не может содержать «непра-
3.1.1. Формат потока инструкций
Язык CIL (Common Intermediate Language) является независимым от аппаратной платформы объектно-ориентированным ассемблером, используемым на платформе .NET для представления исполняемого кода. Для выполнения сборки .NET содержащийся в ней CIL-код переводится JIT-компилятором, входящим в состав CLR, в код конкретного процессора. Инструкции CIL можно разделить на четыре основные группы. В первую группу входят инструкции общего назначения, которые служат для организации вычислений. Вторая группа содержит инструкции для работы с объектной моделью. Третья служит для генерации и обработки исключений. В четвертую мы относим неверифицируемые инструкции, которые генерируются, главным образом, компилятором языка C для представления небезопасных конструкций языка. Разработчики набора инструкций CIL уделили большое внимание компактности CIL-кода. Для этого в набор инструкций было введено большое количество сокращенных вариантов инструкций, представляющих собой частные случаи других инструкций и кодирующихся меньшим количеством байт.
3.1. Поток инструкций языка CIL
Глава 3. Common Intermediate Language
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Размер в байтах Описание 0 У некоторых инструкций встроенные операнды отсутствуют int8 1 Знаковое 8-битовое целое число int32 4 Знаковое 32-битовое целое число int64 8 Знаковое 64-битовое целое число unsigned int8 1 Беззнаковое 8-битовое целое число unsigned int16 2 Беззнаковое 16-битовое целое число float32 4 32-битовое число с плавающей запятой float64 8 64-битовое число с плавающей запятой token 4 Токен метаданных switch переменный Массив адресов переходов для инструкции switch
Операнд none
Таблица 3.1. Варианты встроенных операндов инструкций CIL
3.1.1.1. Формат инструкции Последовательность байт, кодирующая инструкцию CIL, начинается с кода инструкции. Часто используемые инструкции имеют однобайтовые коды. Инструкции, которые используются реже, имеют двухбайтовые коды (при этом первый байт всегда равен 0xFE). В разделе, посвященном виртуальной системе выполнения VES, говорилось о том, что операнды инструкций CIL размещаются на стеке вычислений. Тем не менее, многие инструкции имеют дополнительные встроенные операнды (inline operands), которые находятся прямо в потоке инструкций. Например, инструкция ldloc, загружающая на стек вычислений значение локальной переменной, имеет встроенный операнд, задающий номер переменной. А инструкция call, вызывающая метод, имеет встроенный операнд, задающий токен метаданных, по которому можно найти описание вызываемого метода. Встроенные операнды размещаются в потоке инструкций сразу после кода инструкции. В таблице 3.1 перечислены все варианты встроенных операндов. Для кодирования встроенных операндов, занимающих более одного байта и не являющихся токенами метаданных, используется порядок байт, при котором младший байт идет первым («little-endian»).
вильных» последовательностей байт. То есть даже если поток инструкций CIL содержит «мертвые» участки, которые никогда не получат управление, эти участки должны представлять собой правильную последовательность инструкций CIL. Разные инструкции CIL кодируются последовательностями байт различной длины. Размер каждой инструкции, а также порядок и смысл составляющих ее байт определяется описанием инструкции, которое можно найти в [3].
84
85
Особого внимания заслуживает встроенный операнд для инструкции switch. Эта инструкция осуществляет множественный условный переход в зависимости от некоторого целого значения, которое берется из стека вычислений. Ее встроенный операнд представляет собой массив адресов переходов. Он кодируется следующим образом: сначала идет 32-разрядное целое число без знака, обозначающее количество адресов переходов (размер массива), затем следуют сами адреса. При этом каждый адрес кодируется в виде 32-разрядного целого числа со знаком. Рассмотрим примеры кодирования инструкций CIL: 1. Инструкция ldarg.0 загружает на стек вычислений значение первого аргумента метода. Она является сокращенной версией инструкции ldarg, не содержит встроенных операндов и имеет код 0x02: /* 02 */ ldarg.0 2. Инструкция arglist загружает на стек вычислений специальный описатель массива переменных параметров метода. Она не содержит встроенных операндов и имеет двухбайтовый код 0xFE 0x00: /* FE 00 */ arglist 3. Инструкция ldc.i4.s 16 загружает на стек вычислений целочисленную константу 16. Она является сокращенной версией инструкции ldc.i4, имеет код 0x1F и содержит встроенный операнд типа int8: /* 1F | 10 */ ldc.i4.s 16 4. Инструкция ldc.r4 1.0 загружает на стек вычисления число 1.0 (константу с плавающей запятой). Она имеет код 0x22 и содержит встроенный операнд типа float32: /* 22 | 0000803F */ ldc.r4 1.0 5. Инструкция isinst System.String служит для динамической проверки типа объекта на стеке вычислений. Она имеет код 0x75 и содержит встроенный операнд типа token, в котором хранится токен метаданных, указывающий на тип: /* 75 | (02)00000F */ isinst System.String В скобки помещен первый байт токена метаданных, обозначающий номер таблицы метаданных. Обратите внимание, что значение токена метаданных для типа System.String в различных сборках может отличаться. 6. Инструкция call System.String::Compare вызывает метод. Ее встроенный операнд содержит токен метаданных, указывающий на описание вызываемого метода: /* 28 | (06)0000CD */ call System.String::Compare
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
3.1.1.3. Ограничения на последовательности инструкций В спецификации языка CIL в описании каждой инструкции указаны условия, при которых допустимо ее использование. Кроме того, на формирование последовательности инструкций наложен ряд ограничений, позволяющих упростить создание JIT-компилятора. Наличие этих ограничений означает, что в программах допускаются не любые сочетания инструкций, а только те сочетания, которые удовлетворяют некоторым условиям. Упрощение JIT-компилятора благодаря введению ограничений на последовательности инструкций достигается, главным образом, за счет того, что эти ограничения позволяют использовать в JIT-компиляторе однопроходный алгоритм перевода CIL-кода в код процессора.
3.1.1.2. Адреса переходов В состав набора инструкций CIL входят инструкции для организации условных и безусловных переходов. Встроенные операнды этих инструкций содержат адреса переходов. При этом допустимы только такие адреса, которые указывают на первые байты инструкций в теле данного метода. Мы будем называть абсолютным адресом инструкции смещение первого байта инструкции относительно начала потока инструкций. Инструкцию, на которую передается управление в результате выполнения инструкции перехода, назовем целью перехода. В качестве адресов перехода используются не абсолютные адреса целей перехода, а так называемые относительные адреса. Относительный адрес является разностью абсолютного адреса цели перехода и абсолютного адреса инструкции, непосредственно следующей за инструкцией перехода. Для того чтобы лучше понять принцип вычисления адресов перехода, обратимся к следующему примеру: ... target_addr: add ; цель перехода ... br rel_addr ; инструкция перехода next_addr: ... Здесь используется инструкция безусловного перехода br. При этом в качестве цели перехода выступает инструкция add, расположенная по абсолютному адресу target_addr. Если инструкция, следующая за инструкцией br, имеет абсолютный адрес next_addr, то адрес перехода rel_addr вычисляется следующим образом: reladdr := target_addr – next_addr Адреса переходов кодируются во встроенных операндах инструкций перехода в виде 8-битных или 32-битных целых чисел со знаком. При этом 8-битные адреса используются в сокращенных вариантах инструкций перехода.
86
87
Давайте сформулируем эти ограничения: 1. Ограничение на структуру стека вычислений. Структура стека вычислений определяется количеством и типами значений, лежащих на стеке. Для любой инструкции, входящей в поток инструкций, структура стека должна быть постоянной вне зависимости от того, из какого места программы на нее передается управление. 2. Ограничение на размер стека вычислений. В заголовке метода должна быть указана максимальная глубина стека вычислений. Другими словами, максимальное количество значений, которое может размещаться на стеке вычислений в процессе выполнения метода, должно быть заранее известно еще до JIT-компиляции этого метода. Это ограничение, на первый взгляд, может показаться несколько странным, если принять во внимание, что благодаря ограничению 1 JIT-компилятор может легко вычислить максимальную глубину стека в процессе компиляции. Цель введения подобного ограничения состоит в том, чтобы JIT-компилятор, приступая к компиляции метода, мог сразу выделить нужное количество памяти под свои внутренние структуры данных. 3. Ограничение на обратные переходы. Если при последовательном переборе инструкций, формирующих тело метода, JIT-компилятор встречает инструкцию, расположенную сразу за инструкцией безусловного перехода, и если на эту инструкцию еще не было перехода, то JIT-компилятор не может вычислить структуру стека вычислений для этой инструкции. В этом случае он предполагает, что стек для этой инструкции должен быть пуст. Чтобы лучше понять данную ситуацию, рассмотрим пример: ... br L2 L1: ldc.0 ; здесь стек считается пустым ... L2: br L1 ; обратный переход на L1 ... Когда JIT-компилятор доходит до инструкции ldc.0, расположенной непосредственно после инструкции безусловного перехода br L2, он не может определить для нее структуру стека вычислений, так как еще не дошел до того места программы, откуда на нее передается управление. В принципе, просканировав дальше программу, это место можно обнаружить (это инструкция br L1), но тогда алгоритм JIT-компилятора должен быть многопроходным.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
3.2.1.2. Работа с переменными и параметрами методов Локальные переменные и параметры методов имеют номера от 0 до 65534. Существуют три варианта инструкций для работы с переменными и параметрами: • сокращенные инструкции, которые работают с переменными и параметрами, имеющими номера от 0 до 3; • сокращенные инструкции, допускающие номера переменных и параметров от 0 до 255; • обычные инструкции, работающие с любыми переменными и параметрами. В таблице 3.3 перечислены инструкции, выполняющие загрузку значений переменных и параметров на стек вычислений. Все они имеют следующую диаграмму стека: ... -> ... , value Кроме инструкций, загружающих значения переменных и параметров, существуют инструкции, загружающие на вершину стека вычислений адреса переменных и параметров (см. таблицу 3.4). Загружаемые адреса имеют тип управляемых указателей. Диаграмма стека для этих инструкций выглядит следующим образом: ... -> ... , address
3.2.1.1. Загрузка констант Эта группа инструкций (см. таблицу 3.2) служит для загрузки константных значений на стек вычислений. При этом значения кодируются в самих инструкциях в виде их кодов или встроенных операндов. Диаграмма стека для всех инструкций этой группы выглядит следующим образом: ... -> ... , constant
Инструкции для загрузки и сохранения значений предназначены главным образом для обмена значениями между стеком вычислений и памятью, то есть они выполняют копирование значений на стек вычислений и сохранение значений со стека вычислений в память.
3.2.1. Инструкции для загрузки и сохранения значений
В этом разделе мы рассмотрим ту часть инструкций языка CIL, которая служит для организации вычислений, а именно: • инструкции для загрузки и сохранения значений; • арифметические инструкции; • инструкции для организации передачи управления.
3.2. Язык CIL: инструкции общего назначения
88
float64
Инструкция Встроенный операнд 0x02 – 0x05 ldarg.0 – – ldarg.3 0x06 – 0x09 ldloc.0 — – ldloc.3 0x0E ldarg.s unsigned int8 0x11 ldloc.s unsigned int8 0xFE 0x09 ldarg unsigned int16 0xFE 0x0C ldloc unsigned int16
Код
ldc.r8
0x23
int32 int64 float32
Загрузка константы null Загрузка целого числа -1 (int32) Загрузка целых чисел от 0 до 8 (int32) Загрузка целых чисел от -128 до 127 (int32) Загрузка целых чисел (int32) Загрузка целых чисел (int64) Загрузка чисел с плавающей запятой (F) Загрузка чисел с плавающей запятой (F)
Описание
89
Загрузка параметров с номерами от 0 до 3 Загрузка локальных переменных с номерами от 0 до 3 Загрузка параметров с номерами от 0 до 255 Загрузка локальных переменных с номерами от 0 до 255 Загрузка параметров с номерами от 0 до 65534 Загрузка локальных переменных с номерами от 0 до 65534
Описание
Таблица 3.3. Инструкции для загрузки параметров и локальных переменных
ldc.i4 ldc.i8 ldc.r4
0x20 0x21 0x22
Инструкция Встроенный операнд 0x14 ldnull – 0x15 ldc.m1 – 0x16 – 0x1E ldc.0 – – ldc.8 0x1F ldc.s int8
Код
Таблица 3.2. Инструкции для загрузки констант
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
ldloca
0xFE 0x0D
unsigned int16 unsigned int16
Загрузка адресов параметров с номерами от 0 до 255 Загрузка адресов локальных переменных с номерами от 0 до 255 Загрузка адресов параметров с номерами от 0 до 65534 Загрузка адресов локальных переменных с номерами от 0 до 65534
Описание
Инструкция Встроенный Описание операнд 0x0A – 0x0D stloc.0 – – Сохранение значений stloc.3 в локальных переменных с номерами от 0 до 3 0x10 starg.s unsigned Сохранение значений int8 в параметрах с номерами от 0 до 255 0x13 stloc.s unsigned Сохранение значений int8 в локальных переменных с номерами от 0 до 255 0xFE 0x0B starg unsigned Сохранение значений int16 в параметрах с номерами от 0 до 65534 0xFE 0x0E stloc unsigned Сохранение значений int16 в локальных переменных с номерами от 0 до 65534
Код
Таблица 3.5. Инструкции для сохранения значений в параметрах и локальных переменных
Инструкции, представленные в таблице 3.5, выполняют сохранение значения на вершине стека в переменную или параметр. Они имеют следующую диаграмму стека: ... , value -> ...
ldarga
0xFE 0x0A
0x12
Инструкция Встроенный операнд ldarga.s unsigned int8 ldloca.s unsigned int8
Таблица 3.4. Инструкции для загрузки адресов параметров и локальных переменных
0x0F
Код
90
91
Арифметические инструкции можно разделить на четыре категории: • бинарные операции; • унарные операции;
3.2.2. Арифметические инструкции
3.2.1.4. Специальные инструкции для работы со стеком В отличие от «железных» стековых процессоров, CLI не содержит развитой системы инструкций для чисто стековых манипуляций. В таблице 3.8 представлены две имеющиеся в наличии инструкции.
3.2.1.3. Косвенная загрузка и сохранение значений При косвенной загрузке и сохранении значений работа с памятью осуществляется через адреса (управляемые и неуправляемые указатели). Особенностью инструкций данной группы является наличие разных инструкций для работы со значениями разных типов. Причина в том, что при загрузке или сохранении значения бывает необходимо выполнить его преобразование к другому типу, а так как JIT-компилятор в процессе компиляции не собирает информацию о типах управляемых указателей, ему надо явно указывать тип загружаемых и сохраняемых значений. Необходимость выполнения преобразований объясняется тем, что не все примитивные типы могут находиться на стеке вычислений (поэтому, например, значение типа int8 при загрузке на стек расширяется до int32). В таблице 3.6 перечислены инструкции для косвенной загрузки значений. Обратите внимание, что инструкции ldind.i8 и ldind.u8 являются псевдонимами (имеют один и тот же код). Дело в том, что загрузка любых 64-разрядных целых значений на стек не вызывает их преобразования, ибо хотя на стеке не предусмотрено наличие беззнаковых 64-разрядных значений, их загрузка все равно сводится к простому побитовому копированию. Вышесказанное справедливо также и для 32-разрядных целых значений, но для их загрузки зачем-то зарезервировано сразу две инструкции. Диаграмма стека для инструкций косвенной загрузки выглядит следующим образом: ... , address -> ... , value Инструкций для косвенного сохранения значений (см. таблицу 3.7) меньше, чем инструкций для косвенной загрузки (можно заметить, что инструкции для сохранения значений беззнаковых целых типов отсутствуют). Причина в том, что сохранение беззнаковых целых ничем не отличается от сохранения знаковых целых. Диаграмма стека для инструкций косвенного сохранения выглядит следующим образом: ... , address , value -> ...
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
• инструкция ckfinite, проверяющая конечность значений с плавающей точкой; • инструкции преобразования значений.
3.2.2.1. Бинарные арифметические операции Бинарные арифметические операции потребляют со стека вычислений два операнда. Соответственно, диаграмма стека для таких операций выглядит следующим образом: ... , value1 , value2 -> ... , result Действие бинарных операций можно записать как result := value1 op value2,
0x50
0x4F
0x4E
0x4D
0x4C
0x4B
0x4A
0x49
0x48
0x47
Инструкция Встроенный Описание операнд ldind.i1 – Косвенная загрузка значения int8 ldind.u1 – Косвенная загрузка значения unsigned int8 ldind.i2 – Косвенная загрузка значения int16 ldind.u2 – Косвенная загрузка значения unsigned int16 ldind.i4 – Косвенная загрузка значения int32 ldind.u4 – Косвенная загрузка значения unsigned int32 ldind.i8 – Косвенная загрузка значения (ldind.u8) int64 и unsigned int64 ldind.i – Косвенная загрузка значения native int ldind.r4 – Косвенная загрузка значения float32 ldind.r8 – Косвенная загрузка значения float64 ldind.ref – Косвенная загрузка объектной ссылки
Таблица.3.6. Инструкции для косвенной загрузки значений
0x46
Код
92
Инструкция Встроенный Описание операнд dup – Копирование значения на вершине стека:... , value -> ... , value, value pop – Удаление значения с вершины стека:... , value -> ... то есть например, если op соответствует операции вычитания, то из value1 вычитается value2. Некоторые бинарные операции могут использоваться для операндов различных типов. Другими словами, в коде инструкции не содержится информации о типах ее операндов, так как эти типы определяются на этапе JIT-компиляции. Поэтому, например, одну и ту же инструкцию add можно использовать для сложения как двух целых чисел, так и двух чисел с плава-
0x26
0x25
Код
93
Инструкция Встроенный Описание операнд stind.ref – Косвенное сохранение объектной ссылки stind.i1 – Косвенное сохранение значения int8 stind.i2 – Косвенное сохранение значения int16 stind.i4 – Косвенное сохранение значения int32 stind.i8 – Косвенное сохранение значения int64 stind.r4 – Косвенное сохранение значения float32 stind.r8 – Косвенное сохранение значения float64 stind.i – Косвенное сохранение значения native int
Таблица 3.8. Специальные инструкции для работы со стеком.
0xDF
0x57
0x56
0x55
0x54
0x53
0x52
0x51
Код
Таблица 3.7. Инструкции для косвенного сохранения значений
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
and or xor
0x5F 0x60 0x61
– – –
– –
Сложение Вычитание Умножение Деление Деление беззнаковых целых чисел Остаток от деления Остаток от деления беззнаковых целых чисел Побитовое И Побитовое ИЛИ Побитовое ИСКЛЮЧАЮЩЕЕ ИЛИ
Описание
Инструкции, представленные в таблице 3.10, используются только для целочисленных операндов. Они отличаются от базовых бинарных операций тем, что осуществляют контроль переполнения (при переполнении генерируется исключение OverflowException). Операции сдвига (см. таблицу 3.11) выполняют сдвиг значения первого операнда (value1) в нужную сторону на количество бит, указанное во втором операнде (value2). Операции, приведенные в таблице 3.12, выполняют сравнение значений своих операндов. Результатом сравнения являются числа 0 или 1 (типа int32). Число 0 обозначает ложь, а число 1 – истину. Семантика операций сравнения для чисел с плавающей запятой существенно отличается от их семантики для целых чисел. Дело в том, что
rem rem.un
Инструкция Встроенный операнд add – sub – mul – div – div.un –
0x5D 0x5E
0x58 0x59 0x5A 0x5B 0x5C
Код
Таблица 3.9. Базовые бинарные арифметические операции
ющей запятой. При этом применение бинарной операции не допускается, если тип одного ее операнда – целый, а другого – с плавающей запятой. Тип результата бинарной операции зависит от типов операндов. Если операнды целые, то и результат будет целый. Если операнды представляют собой числа с плавающей запятой, то результатом будет являться число с плавающей запятой. В таблице 3.9 представлены базовые инструкции, выполняющие бинарные операции.
94
Инструкция Встроенный Описание операнд shl – Сдвиг целых чисел влево shr – Сдвиг целых чисел со знаком вправо shr.un – Сдвиг целых чисел без знака вправо числа с плавающей запятой могут дополнительно принимать значения +inf (положительная бесконечность), -inf (отрицательная бесконечность) и NaN (Not a Number – не число). Поэтому описание каждой инструкции содержит две части: для целых чисел и для чисел с плавающей запятой. При этом в описании используются следующие обозначения: • I и J – целые числа со знаком, причем I < J; • K и L – целые числа без знака, причем K < L; • A и B – конечные числа с плавающей запятой (то есть они не равны NaN, +inf и -inf), причем A < B; • C – любое число с плавающей запятой (может принимать значения NaN, +inf и -inf).
0x64
0x62 0x63
Код
Инструкция Встроенный Описание операнд add.ovf – Сложение целых чисел со знаком с контролем переполнения add.ovf.un – Сложение целых чисел без знака с контролем переполнения mul.ovf – Умножение целых чисел со знаком с контролем переполнения mul.ovf.un – Умножение целых чисел без знака с контролем переполнения sub.ovf – Вычитание целых чисел со знаком с контролем переполнения sub.ovf.un – Вычитание целых чисел без знака с контролем переполнения
Таблица 3.11. Операции сдвига
0xDB
0xDA
0xD9
0xD8
0xD7
0xD6
Код
95
Таблица 3.10. Бинарные арифметические операции с контролем переполнения
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
0xFE 0x03
0xFE 0x04
0xFE 0x02
Инструкция Встроенный Описание операнд ceq – Сравнение на равенство. Для целых чисел: I ceq I => 1, иначе => 0 Для чисел с плавающей запятой: +inf ceq +inf => 1, -inf ceq -inf => 1, A ceq A => 1, иначе => 0 cgt – Сравнение на «больше». Для целых чисел: J cgt I => 1, иначе => 0 Для чисел с плавающей запятой: A cgt -inf => 1, +inf cgt A => 1, +inf cgt -inf => 1, B cgt A => 1, иначе => 0 clt – Сравнение на «меньше». Для целых чисел: I clt J => 1, иначе => 0 Для чисел с плавающей запятой: A clt +inf => 1, -inf clt A => 1, -inf clt +inf => 1, A clt B => 1, иначе => 0 cgt.un – Сравнение на «больше» беззнаковых целых чисел или неупорядоченных чисел с плавающей
Таблица 3.12. Операция сравнения
0xFE 0x01
од
96
0x65 0x66
Код
clt.un
–
97
запятой. (Два числа с плавающей запятой называются неупорядоченными, если хотя бы одно из них равно NaN.) Для целых чисел: L cgt.un K => 1, иначе => 0 Для чисел с плавающей запятой: NaN cgt.un C => 1, C cgt.un NaN => 1, A cgt.un -inf => 1, +inf cgt.un A => 1, +inf cgt.un -inf => 1, B cgt.un A => 1, иначе => 0 Сравнение на «меньше» беззнаковых целых чисел или неупорядоченных чисел с плавающей запятой. Для целых чисел: K clt.un L => 1, иначе => 0 Для чисел с плавающей запятой: NaN clt.un C => 1, C clt.un NaN => 1, A clt.un +inf => 1, -inf clt.un A => 1, -inf clt.un +inf => 1, A clt.un B => 1, иначе => 0
Инструкция Встроенный Описание операнд neg – Изменение знака числа not – Побитовое НЕ (для целых чисел)
Таблица 3.13. Унарные арифметические операции
0xFE 0x05
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция Встроенный Описание операнд ckfinite – Проверка того, что число с плавающей запятой является конечным
3.2.2.4. Преобразование значений Инструкции преобразования значений потребляют один операнд со стека вычислений и преобразуют его к нужному типу. Диаграмма стека для этих инструкций выглядит следующим образом: ... , value -> ... , result Базовые инструкции преобразования представлены в таблице 3.15. Они обладают следующими особенностями: • Преобразование чисел с плавающей запятой к целому типу обрезает дробную часть числа. Если при этом возникает переполнение, то возвращаемый результат неопределен (зависит от реализации).
0xC3
Код
Таблица 3.14. Инструкция ckfinite
3.2.2.3. Инструкция ckfinite Инструкция ckfinite (см. таблицу 3.14) генерирует исключение ArithmeticException, если число с плавающей запятой, находящееся на вершине стека вычислений, равно NaN, +inf или -inf. Если исключение не генерируется, то стек вычислений не меняется, поэтому диаграмма стека выглядит следующим образом: ... , value -> ... , value
3.2.2.2. Унарные арифметические операции В таблице 3.13 приведены две инструкции, выполняющие унарные арифметические операции. Диаграмма стека для унарных операций выглядит следующим образом: ... , value -> ... , result Инструкция neg применима как для целых чисел, так и для чисел с плавающей запятой и обладает двумя особенностями: • Результатом применения этой инструкции к наименьшему отрицательному целому числу (такое число не имеет «парного» положительного числа) является само это наименьшее отрицательное число. Для того чтобы иметь возможность перехватить эту ситуацию, необходимо вместо инструкции neg использовать sub.ovf. • Результатом применения этой инструкции к NaN является NaN.
98
99
– – – –
Встроенный операнд – – – – – – – – – Преобразовать к int8 Преобразовать к int16 Преобразовать к int32 Преобразовать к int64 Преобразовать к float32 Преобразовать к float64 Преобразовать к unsigned int32 Преобразовать к unsigned int64 Преобразовать беззнаковое целое число в число с плавающей запятой Преобразовать к unsigned int16 Преобразовать к unsigned int8 Преобразовать к native int Преобразовать к unsigned native int
Описание
• Преобразование значения с плавающей запятой к типу float32 может вызывать потерю точности. Кроме того, если это значение слишком велико для float32, то результатом преобразования является +inf или -inf. • Инструкция conv.r.un интерпретирует целое значение, лежащее на вершине стека, как не имеющее знака и преобразует его к вещественному типу (либо float32, либо float64 в зависимости от значения). • Если переполнение возникает при преобразовании значения одного целого типа к другому целому типу, то обрезаются старшие биты значения. В таблице 3.16 приведены инструкции для преобразования значений, имеющих знак, к целым типам с контролем переполнения. В случае возникновения переполнения эти инструкции генерируют исключение OverflowException. Инструкции, представленные в таблице 3.17, используются для преобразования беззнаковых значений к нужному типу и генерируют исключение OverflowException в случае переполнения.
conv.u2 conv.u1 conv.i conv.u
conv.i1 conv.i2 conv.i4 conv.i8 conv.r4 conv.r8 conv.u4 conv.u8 conv.r.un
0x67 0x68 0x69 0x6A 0x6B 0x6C 0x6D 0x6E 0x76
0xD1 0xD2 0xD3 0xE0
Инструкция
Код
Таблица 3.15. Преобразование значений без контроля переполнения.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
conv.ovf.i1 conv.ovf.u1 conv.ovf.i2 conv.ovf.u2 conv.ovf.i4 conv.ovf.u4 conv.ovf.i8 conv.ovf.u8 conv.ovf.i conv.ovf.u
0xB3 0xB4 0xB5 0xB6 0xB7 0xB8 0xB9 0xBA 0xD4 0xD5
Встроенный операнд – – – – – – – – – –
Преобразование к int8 Преобразование к unsigned int8 Преобразование к int16 Преобразование к unsigned int16 Преобразование к int32 Преобразование к unsigned int32 Преобразование к int64 Преобразование к unsigned int64 Преобразование к native int Преобразование к unsigned native int
Описание
conv.ovf.i1.un conv.ovf.i2.un conv.ovf.i4.un conv.ovf.i8.un conv.ovf.u1.un conv.ovf.u2.un conv.ovf.u4.un conv.ovf.u8.un conv.ovf.i.un conv.ovf.u.un
0x82 0x83 0x84 0x85 0x86 0x87 0x88 0x89 0x8A 0x8B
Встроенный операнд – – – – – – – – – –
Преобразование к int8 Преобразование к int16 Преобразование к int32 Преобразование к int64 Преобразование к unsigned int8 Преобразование к unsigned int16 Преобразование к unsigned int32 Преобразование к unsigned int64 Преобразование к native int Преобразование к unsigned native int
Описание
Инструкции для организации передачи управления можно разделить на пять категорий: • инструкции безусловного перехода;
3.2.3. Инструкции для организации передачи управления
Инструкция
Код
Таблица 3.17. Преобразование беззнаковых значений с контролем переполнения
Инструкция
Таблица 3.16. Преобразование значений со знаком с контролем переполнения
Код
100
инструкции условного перехода; инструкция множественного выбора switch; инструкция вызова метода call; инструкция возврата из метода ret.
101
Инструкция Встроенный Описание операнд br.s int8 Короткий безусловный переход br int32 Длинный безусловный переход
0x3A
0x39
0x2D
0x2C
Код
Инструкция Встроенный Описание операнд brfalse.s int8 Короткий условный переход, если значение равно 0 или null brtrue.s int8 Короткий условный переход, если значение не равно 0 или null brfalse int32 Длинный условный переход, если значение равно 0 или null brtrue int32 Длинный условный переход, если значение не равно 0 или null
Таблица 3.19. Базовые инструкции условного перехода
3.2.3.2. Условный переход Базовые инструкции условного перехода, приведенные в таблице 3.19, потребляют со стека вычислений один операнд и, в зависимости от его значения, осуществляют или не осуществляют переход по указанному во встроенном операнде относительному адресу. Диаграмма стека для этих инструкций выглядит следующим образом: ... , value -> ... Как и в случае инструкций безусловного перехода, существуют короткий и длинный варианты инструкций условного перехода, которые отличаются только разрядностью встроенного операнда (int8 и int32).
0x2B 0x38
Код
Таблица 3.18. Инструкции безусловного перехода
3.2.3.1. Безусловный переход Существуют две инструкции безусловного перехода (см. таблицу 3.18), которые различаются только разрядностью встроенного операнда (int8 и int32). При этом встроенный операнд этих инструкций обозначает относительное смещение цели перехода.
• • • •
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
0x44
0x42 0x43
0x3F 0x40 0x41
0x3D 0x3E
0x3B 0x3C
Код
Инструкция Встроенный Описание операнд beq int32 ceq; brtrue bge int32 (int): clt; brfalse (F) : clt.un; brfalse bgt int32 cgt; brtrue ble int32 (int): cgt; brfalse (F): cgt.un; brfalse blt int32 clt; brtrue bne.un int32 ceq; brfalse bge.un int32 (int): clt.un; brfalse (F): clt; brfalse bgt.un int32 cgt.un; brtrue ble.un int32 (int): cgt.un; brfalse (F): cgt; brfalse blt.un int32 clt.un; brtrue
Таблица 3.20. Дополнительные длинные инструкции условного перехода
Инструкции brfalse присвоены два псевдонима: brnull и brzero, имеющих одинаковый с ней код. Аналогично, brfalse.s имеет псевдонимы brnull.s и brzero.s. Кроме базовых, существуют дополнительные инструкции условного перехода, потребляющие сразу два операнда. Каждая из дополнительных инструкций условного перехода является сокращенной записью последовательности из двух инструкций, первая из которых является бинарной операцией сравнения, а вторая – базовой инструкцией условного перехода. Таким образом, дополнительные инструкции условного перехода имеют следующую диаграмму стека: ... , value1 , value2 -> ... Длинные варианты дополнительных инструкций условного перехода перечислены в таблице 3.20, а короткие – в таблице 3.21. В описании каждой инструкции приводится эквивалентная ей комбинация бинарной операции сравнения и базовой инструкции условного перехода. В связи с тем, что семантика операций сравнения для целых чисел существенно отличается от их семантики для чисел с плавающей запятой (см. пункт 3.2.2.1), для инструкций bge, ble, bge.un и ble.un (и для коротких вариантов этих инструкций) приводится по две эквивалентные им комбинации. Комбинация, помеченная меткой (int), используется в случае целых операндов, а комбинация, помеченная меткой (F), используется в том случае, если операнды представляют собой числа с плавающей запятой.
102
Инструкция Встроенный Описание операнд beq.s int8 ceq; brtrue.s bge.s int8 (int): clt; brfalse.s (F): clt.un; brfalse.s bgt.s int8 cgt; brtrue.s ble.s int8 (int): cgt; brfalse.s (F): cgt.un; brfalse.s blt.s int8 clt; brtrue.s bne.un.s int8 ceq; brfalse.s bge.un.s int8 (int): clt.un; brfalse.s (F): clt; brfalse.s bgt.un.s int8 cgt.un; brtrue.s ble.un.s int8 (int): cgt.un; brfalse.s (F): cgt; brfalse.s blt.un.s int8 clt.un; brtrue.s
0x42
Код
Инструкция Встроенный операнд switch unsigned int32, int32 ... int32
Таблица 3.22. Инструкция switch
Осуществляет переход по таблице переходов в соответствии со значением на вершине стека
Описание
3.2.3.3. Инструкция switch Инструкция множественного выбора switch представлена в таблице 3.22. Встроенный операнд этой инструкции имеет сложный формат: первое 32-разрядное слово содержит размер N таблицы переходов, после чего следует N 32-рязрядных относительных смещений целей перехода. Инструкция switch берет со стека вычислений один операнд. Обозначим этот операнд через I. Значение I интерпретируется как целое число без знака и сравнивается с N. Если I < N, то управление передается на I-тую цель в таблице переходов (нумерация целей осуществляется с 0). Ес-
0x37
0x35 0x36
0x32 0x33 0x34
0x30 0x31
0x2E 0x2F
Код
103
Таблица 3.21. Дополнительные короткие инструкции безусловного перехода
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция Встроенный Описание операнд call token Выполняет вызов метода
0x2A
Код
Инструкция Встроенный Описание операнд ret – Осуществляет возврат из метода
Таблица 3.24. Инструкция ret
3.2.3.5. Инструкция ret Инструкция ret (см. таблицу 3.24) осуществляет возврат из метода. Если метод возвращает значение, то оно должно быть загружено на вершину стека вычислений.
Информация о методе, на которую указывает токен метаданных, позволяет JIT-компилятору определить, является ли вызываемый метод статическим, экземплярным, виртуальным или глобальной функцией. Особенностью инструкции call (по сравнению с инструкцией callvirt, которую мы будем рассматривать далее в этой главе) является то, что адрес вызываемого метода вычисляется статически, то есть еще во время JIT-компиляции. Параметры вызываемого метода должны быть расположены в стеке слева направо, то есть сначала на стек должен быть загружен первый параметр, затем второй и т.д. При вызове экземплярного метода в качестве первого параметра должна выступать объектная ссылка (параметр this). Если вызываемый метод возвращает значение, то оно загружается на стек вызывающего метода.
0x28
Код
Таблица 3.23. Инструкция call
3.2.3.4. Инструкция call Инструкция call (см. таблицу 3.23) выполняет вызов метода, который указан во встроенном операнде инструкции. Встроенный операнд представляет собой токен метаданных, указывающий на описывающую вызываемый метод запись в таблицах метаданных. Непосредственно перед инструкцией call может стоять префикс .tail, который говорит о том, что состояние текущего метода должно быть освобождено перед передачей управления вызываемому методу (хвостовой вызов).
ли I >= N, то управление передается на инструкцию, непосредственно следующую за инструкцией switch. Для инструкции switch можно записать следующую диаграмму стека: ... , value -> ...
104
105
Инструкция Встроенный Описание операнд newobj token Создает новый объект и вызывает для него конструктор Диаграмма стека для инструкции newobj: ... , arg1, ... , argN -> ... , obj Инструкция newobj потребляет со стека вычислений параметры конструктора и оставляет на стеке ссылку на созданный объект. Параметры вызываемого конструктора должны быть расположены в стеке слева направо, то есть сначала на стек должен быть загружен первый аргумент, затем второй и т.д.
0x73
Код
Таблица 3.25. Инструкция newobj
3.3.1.1. Создание объектов Инструкция newobj (см. таблицу 3.25) выполняет выделение памяти для объекта в куче и затем вызывает для этого объекта конструктор. Операции выделения памяти и вызова конструктора объединены в одной инструкции не случайно, так как это гарантирует отсутствие в куче неинициализированных объектов. Токен метаданных, находящийся во встроенном операнде инструкции, указывает на описатель конструктора в таблицах метаданных. Тип создаваемого объекта определяется через информацию о конструкторе (это тот класс, внутри которого объявлен конструктор).
Инструкции для работы с объектами – это базовые инструкции для поддержки объектно-ориентированной парадигмы.
3.3.1. Инструкции для работы с объектами
Язык CIL, в отличие от большинства других ассемблерных языков, содержит богатый набор инструкций, предназначенных для поддержки объектной модели. Этот набор можно разделить на четыре основные категории: • инструкции для работы с объектами; • инструкции для работы с массивами; • инструкции для работы с типами-значениями; • инструкции для работы с типизированными ссылками.
3.3. Язык CIL: инструкции для поддержки объектной модели
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция Встроенный Описание операнд castclass token Проверяет, соответствует ли тип объекта на вершине стека вычислений указанному типу. В случае несоответствия генерирует исключение InvalidCastException. isinst token Проверяет, соответствует ли тип объекта на вершине стека вычислений указанному типу. Если соответствует, то оставляет объект на стеке, в противном случае заменяет объект на null
Диаграмма стека для инструкций проверки типа объекта: ... , obj -> ... , obj Через инструкции проверки типа объекта реализуются операции приведения типов в языках высокого уровня.
0x75
0x74
Код
Таблица 3.26. Инструкция проверки типа объекта
3.3.1.2. Проверка типа объекта В таблице 3.26 приведены две инструкции, осуществляющие проверку типа объекта, ссылка на который лежит на вершине стека вычислений. Токен метаданных, находящийся во встроенном операнде инструкции, указывает на описатель типа в таблицах метаданных.
Хотя первым (неявным) параметром для любого конструктора является ссылка на инициализируемый объект (параметр this), перед вызовом инструкции newobj этот параметр не должен загружаться на стек вычислений. Дело в том, что ссылка на объект формируется в процессе выполнения инструкции (после выделения памяти в куче и до вызова конструктора) и затем автоматически передается конструктору. Происходит как бы «подкладывание» ссылки this под другие параметры конструктора на стеке вычислений. Особый случай применения инструкции newobj связан с созданием экземпляров типов-значений на стеке вычислений. Если указанный во встроенном операнде конструктор принадлежит типу-значению, то новый экземпляр этого типа создается не в куче, а прямо на стеке вычислений.
106
107
Инструкция Встроенный Описание операнд ldfld token Загружает значение поля объекта. Диаграмма стека: ... , obj -> ... , value ldflda token Загружает адрес поля объекта. Диаграмма стека: ... , obj -> ... , addr stfld token Сохраняет значение в поле объекта. Диаграмма стека: ... , obj, value -> ... ldsfld token Загружает значение статического поля объекта. Диаграмма стека: ... -> ... , value ldsflda token Загружает адрес статического поля объекта. Диаграмма стека: ... -> ... , addr stsfld token Сохраняет значение в статическом поле объекта. Диаграмма стека: ... , val -> ... 3.3.1.4. Вызов виртуальных методов Инструкция callvirt (см. таблицу 3.28) отличается от инструкции call главным образом тем, что адрес вызываемого метода определяется во время выполнения программы путем анализа типа объекта, для которого вызывается метод. Тем самым реализуется идея позднего связывания, необходимая для поддержки полиморфизма. Диаграмма стека для инструкции callvirt: ... , obj, arg1, ... , argN -> ... , retVal
0x80
0x7F
0x7E
0x7D
0x7C
0x7B
Код
Таблица 3.27. Инструкция для работы с полями объектов
3.3.1.3. Работа с полями объектов В таблице 3.27 приведены инструкции, которые загружают на стек вычислений значения и адреса полей объектов, а также сохраняют значения со стека в полях объектов. Токены метаданных во встроенных операндах инструкций указывают на информацию о нужном поле.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция Встроенный Описание операнд ldstr token Создает на вершине стека объект-строку
В принципе, массивы являются такими же объектами, как и экземпляры других классов. Но для более эффективной реализации и для сокращения размеров CIL-кода в наборе инструкций CLI предусмотрена спе-
3.3.2. Инструкции для работы с массивами
Диаграмма стека для инструкции ldstr: ... -> ... , obj
0x72
Код
Таблица 3.29. Инструкция ldstr
3.3.1.5. Загрузка строковых констант Для загрузки на стек вычислений строковых констант предусмотрена отдельная инструкция ldstr, приведенная в таблице 3.29. Токен, находящийся во встроенном операнде инструкции, указывает на образ строки в куче пользовательских строк, находящейся в составе метаданных. Инструкция создает объект класса System.String, копирует в него образ строки и оставляет ссылку на созданный объект на вершине стека вычислений.
Токен метаданных, находящийся во встроенном операнде инструкции, указывает на информацию о вызываемом методе в таблицах метаданных (имя метода, класс и сигнатура). Определение метода, который нужно вызвать, происходит следующим образом. Система выполнения анализирует класс объекта, для которого вызывается метод (на диаграмме стека этот объект обозначен как obj), выполняя поиск принадлежащего этому классу экземплярного метода, имеющего нужные имя и сигнатуру. Если такой метод отсутствует, аналогичный поиск производится по порядку на всей цепочке суперклассов, от которых наследует класс объекта. Если в результате метод не будет найден, то генерируется исключение MissingMethodException, но эта ситуация невозможна в верифицированном коде.
Инструкция Встроенный Описание операнд callvirt token Вызов метода с использованием позднего связывания
Таблица 3.28. Инструкция callvirt
0x6F
Код
108
109
Инструкция Встроенный Описание операнд newarr token Создает новый массив с элементами указанного типа
Инструкция Встроенный Описание операнд ldlen – Загружает на стек длину массива
3.3.2.3. Работа с элементами массивов Инструкции ldelem (см. таблицу 3.32) предназначены для загрузки значения элемента одномерного массива на стек вычислений.
Диаграмма стека для инструкции ldlen: ... , array -> ... , length Инструкция потребляет со стека объектную ссылку на массив и оставляет на стеке его размер в виде числа типа native unsigned int.
0x8E
Код
Таблица 3.31. Инструкция ldlen
3.3.2.2. Загрузка длины массива Инструкция ldlen (см. таблицу 3.31) загружает размер одномерного массива на стек вычислений.
Диаграмма стека для инструкции newarr: ... , num -> ... , array Инструкция newarr потребляет со стека вычислений размер массива (на диаграмме обозначен как num) и оставляет объектную ссылку на созданный массив в куче. Элементы созданного массива автоматически обнуляются.
0x8D
Код
Таблица 3.30. Инструкция newarr
3.3.2.1. Создание массивов Инструкция newarr (см. таблицу 3.30) выделяет память под одномерный массив, индексируемый с нуля. Тип элементов массива указывается через токен метаданных во встроенном операнде инструкции (в качестве типа элементов может выступать тип-значение).
циальная инструкция для работы с одномерными массивами, индексируемыми с нуля.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция
ldelem.i1 ldelem.u1
ldelem.i2 ldelem.u2
ldelem.i4 ldelem.u4
ldelem.i8 (ldelem.u8) ldelem.i
ldelem.r4 ldelem.r8 ldelem.ref
Код
0x90 0x91
0x92 0x93
0x94 0x95
0x96
0x97
0x98 0x99 0x9A
Встроенный Описание операнд – Загрузить элемент типа int8 – Загрузить элемент типа unsigned int8 – Загрузить элемент типа int16 – Загрузить элемент типа unsigned int16 – Загрузить элемент типа int32 – Загрузить элемент типа unsigned int32 – Загрузить элемент типа int64 или unsigned int64 – Загрузить элемент типа native int или unsigned native int – Загрузить элемент типа float32 – Загрузить элемент типа float64 – Загрузить элемент – объектную ссылку
Таблица 3.32. Инструкции для загрузки элементов массивов
Так как в процессе JIT-компиляции не отслеживаются точные типы объектных ссылок на стеке вычислений, то JIT-компилятору требуются указания о том, элементы какого типа содержатся в массиве. Поэтому для разных типов существуют разные варианты инструкции ldelem. Следует иметь в виду, что значения целых типов, разрядность которых меньше 32 бит, при загрузке на стек удлиняются. Значения со знаком и значения без знака удлиняются по-разному, поэтому наличие отдельных инструкций для знаковых и беззнаковых целых типов вполне объяснимы. При этом остается неясным, для чего потребовалось определять две инструкции ldelem.i4 и ldelem.u4 для 32-разрядных целых типов, потому что они, очевидно, имеют одинаковый эффект. Диаграмма стека для инструкций ldelem выглядит следующим образом: ... , array, index -> ... , value Инструкция потребляет со стека объектную ссылку на массив и индекс элемента (типа native int), значение которого надо загрузить.
110
111
Инструкция Встроенный Описание операнд ldelema token Загружает адрес элемента массива с указанным индексом
3.3.3.2. Загрузка размера значения Инструкция sizeof (см. таблицу 3.36) загружает на стек вычислений размер в байтах типа-значения (размер представляет собой значение типа unsigned int32). Во встроенном операнде этой инструкции содержится токен метаданных, указывающий на информацию о типе-значении.
3.3.3.1. Инициализация значения Инструкция initobj (см. таблицу 3.35) предназначена для инициализации значения типа-значения. Во встроенном операнде этой инструкции содержится токен метаданных, указывающий на информацию о типе-значении. В отличие от инструкции newobj, инструкция initobj не вызывает конструктор. Инструкция initobj потребляет со стека вычислений адрес значения: ... , addr -> ...
Специальный набор инструкций предусмотрен для поддержки операций с типами-значениями.
3.3.3. Инструкции для работы с типами-значениями
Инструкции stelem (см. таблицу 3.34) предназначены для сохранения значения со стека вычислений в элементе одномерного массива. Аналогично инструкциям ldelem, существуют различные варианты stelem для разных типов элементов массива. При этом варианты stelem для беззнаковых целых типов отсутствуют за ненадобностью. Диаграмма стека для инструкций stelem: ... , array, index, value -> ...
0x8F
Код
Таблица.3.33. Инструкция ldelema
Инструкция ldelema (см. таблицу 3.33) загружает на стек вычислений адрес элемента одномерного массива (управляемый указатель). Тип элементов массива указывается через токен метаданных во встроенном операнде инструкции (в качестве типа элементов может выступать тип-значение). Диаграмма стека для нее выглядит следующим образом: ... , array, index -> ... , addr
Common Intermediate Language
stelem.i1
stelem.i2
stelem.i4
stelem.i8
stelem.r4
stelem.r8
stelem.ref
stelem.i
0x9C
0x9D
0x9E
0x9F
0xA0
0xA1
0xA2
0x9B
Встроенный Описание операнд – Сохраняет значение типа int8 в элементе массива с указанным индексом – Сохраняет значение типа int16 в элементе массива с указанным индексом – Сохраняет значение типа int32 в элементе массива с указанным индексом – Сохраняет значение типа int64 в элементе массива с указанным индексом – Сохраняет значение типа float32 в элементе массива с указанным индексом – Сохраняет значение типа float64 в элементе массива с указанным индексом – Сохраняет значение объектной ссылки в элементе массива с указанным индексом – Сохраняет значение типа native int в элементе массива с указанным индексом
0xFE 0x15
Инструкция Встроенный Описание операнд initobj token Заполняет все поля значения нулями
Таблица 3.35. Инструкция initobj
Инструкция
Код
CIL и системное программирование в Microsoft .NET
Таблица 3.34. Инструкции для сохранения значений в элементы массивов
Код
112
113
Инструкция Встроенный Описание операнд sizeof token Загружает на стек размер значения указанного типа
0x81
0x71
0x70
Код
Инструкция Встроенный Описание операнд cpobj token Копирует значение. Диаграмма стека: ... , destAddr, srcAddr -> ... (Здесь destAddr – адрес приемника, а srcAddr – адрес источника.) ldobj token Загружает значение на стек вычислений. Диаграмма стека: ... , addr -> ... , valObj (Здесь addr – адрес загружаемого значения.) stobj token Сохраняет значение со стека вычислений в память. Диаграмма стека: ... , addr, valObj -> ... (Здесь addr – адрес, по которому будет сохранено значение.)
Таблица 3.37. Инструкции для копирования значений
3.3.3.3. Копирование значений Инструкции, приведенные в таблице 3.37, выполняют копирование значений типов-значений. Во встроенных операндах этих инструкций содержится токен метаданных, указывающий на информацию о типе-значении в таблицах метаданных. Инструкция ldobj используется главным образом при вызове методов для загрузки параметров (если вызываемый метод имеет параметры типов-значений). Инструкции cpobj и stobj применяются сравнительно редко, хотя и имеют однобайтовые коды.
Диаграмма стека для инструкций sizeof: ...-> ... , size
0xFE 0x1C
Код
Таблица 3.36.Инструкция sizeof
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция Встроенный Описание операнд unbox token Выполняет распаковку значения. Диаграмма стека: ... , obj -> ... , ptr box token Упаковывает значение. Диаграмма стека: ... , value -> ... , obj
0xD0
Код
Инструкция Встроенный Описание операнд ldtoken token Загружает описатель токена метаданных (структуру RuntimeTypeHandle, RuntimeMethodHandle, RuntimeFieldHandle)
Таблица 3.39. Инструкция ldtoken
3.3.3.5. Загрузка описателя токена метаданных Инструкция ldtoken (см. таблицу 3.39) применяется для работы с библиотекой рефлексии. Фактически она переводит токены метаданных в специальные структуры данных рефлексии. Так как переводимый токен жестко зашит в инструкцию (он находится во встроенном операнде), то можно говорить о том, что инструкция ldtoken представляет собой инструкцию загрузки константы.
Выполнение инструкции box заключается в создании в куче «объекта-обертки» для значения, после чего осуществляется побитовое копирование значения внутрь «обертки». При распаковке значения с помощью инструкции unbox никакого копирования не происходит. Вместо этого на стек вычислений загружается адрес значения находящегося внутри «обертки».
0x8C
0x79
Код
Таблица 3.38. Инструкции для упаковки и распаковки значений
3.3.3.4. Упаковка и распаковка значений Инструкции, приведенные в таблице 3.38, выполняют упаковку и распаковку значений типов-значений. Во встроенных операндах этих инструкций содержится токен метаданных, указывающий на информацию о типе-значении в таблицах метаданных.
114
115
Инструкция Встроенный Описание операнд mkrefany token Создает типизированную ссылку на вершине стека вычислений
Инструкция refanytype
Код 0xFE 0x1D
Встроенный Описание операнд – Загружает токен, хранящийся в типизированной ссылке
Таблица 3.41. Инструкция refanytype
3.3.4.2. Загрузка типа типизированной ссылки Инструкция refanytype (см. таблицу 3.41) загружает токен метаданных, хранящийся в типизированной ссылке, на вершину стека вычислений.
Диаграмма стека для инструкций mkrefany: ... , ptr -> ... , typedRef
0xC6
Код
Таблица 3.40. Инструкция mkrefany
3.3.4.1. Создание типизированной ссылки Инструкция mkrefany (см. таблицу 3.40) предназначена для создания типизированных ссылок. Она упаковывает вместе управляемый указатель на некоторое значение и токен метаданных, описывающий тип этого значения. При этом токен содержится во встроенном операнде инструкции.
Типизированные ссылки в системе типов .NET реализованы исключительно для поддержки некоторых особенностей синтаксиса и семантики языка Visual Basic .NET. Они представляют собой гибрид управляемого указателя и типа-значения. Для работы с типизированными ссылками предусмотрены три инструкции CIL, которые мы рассмотрим в этом разделе.
3.3.4. Инструкции для работы с типизированными ссылками
Диаграмма стека для инструкций ldtoken: ... -> ... , runtimeHandle Эта инструкция отнесена к группе инструкций для работы с типамизначениями, потому что описатели токенов представляют собой значения типов-значений.
Common Intermediate Language
Диаграмма стека для инструкций refanytype: ... , typedRef -> ... , type
CIL и системное программирование в Microsoft .NET
refanyval
0xC2
Встроенный Описание операнд token Загружает адрес, хранящийся в типизированной ссылке
Существует два основных способа перехвата ошибок, возникающих в процессе работы программы: • Обработка кодов возврата. Функция, выполнение которой может привести к ошибочной ситуации, возвращает некоторое значение, сообщающее, успешно или неуспешно функция выполнила свою задачу. Перехват ошибок заключается в том, что в коде, вызывающем такую функцию, стоят проверки ее возвращаемого значения. Этот способ хорошо работает, если глубина стека вызовов функций в программе относительно невелика. В противном случае код программы из-за постоянных проверок становится громоздким и трудночитаемым. • Обработка исключений. Этот способ заключается в том, что в случае возникновения ошибки генерируется так называемая исключительная ситуация (исключение), которая описывается некоторым объектом. Генерация исключения приводит к передаче управления на фрагмент кода программы, называемый обработчиком исключения. Преимуществом такого подхода является то, что перехват ошибок локализован в отдельной части программы, а не распределен по всему коду, как в случае с обработкой кодов возврата.
3.4. Язык CIL: обработка исключений
Диаграмма стека для инструкций refanyval: ... , typedRef -> ... , ptr
Инструкция
Код
Таблица 3.42. Инструкция refanyval
3.3.4.3. Загрузка значения типизированной ссылки Инструкция refanyval (см. таблицу 3.42) загружает управляемый указатель, хранящийся в типизированной ссылке, на вершину стека вычислений.
116
117
Для дальнейшего изложения нам понадобится ввести понятия области в коде метода и координат области. Будем называть областью непрерывную последовательность инструкций в коде метода. При этом область будет определяться своими координатами, а именно парой чисел (offset, length), где offset – это смещение первой инструкции области относительно начала тела метода, а length – длина области. Как смещение, так и длину будем измерять в байтах. Заголовок каждого метода содержит специальный массив, элементы которого называются предложениями обработки исключений (exception handling clause). Каждое предложение обработки исключений представляет собой структуру, состоящую из нескольких полей. В этих полях записаны координаты двух или трех областей, а именно: в любом предложении присутствуют координаты защищенной области (protected block) и области обработчика (exception handler), а в некоторых предложениях дополнительно описана область фильтра (filter block). Если говорить в терминах языка C#, то защищенная область – это try-блок, а область обработчика – это либо catch-блок, либо finally-блок. Аналог для области фильтра в языке C# отсутствует, но зато он есть в Visual Basic .NET и в Visual C++ with Managed Extensions. Область фильтра содержит код, принимающий решение о том, может ли данное исключение быть обработано обработчиком. Естественно, такое представление о назначении областей в предложении обработки исключений несколько примитивно и понадобится нам лишь на начальном этапе. Давайте рассмотрим два возможных формата, в которых кодируется массив предложений обработки исключений. В каждом из двух форматов содержатся одни и те же поля, и различаются они только размерами полей. Первый формат называется коротким форматом (см. таблицу 3.43) и используется тогда, когда смещения областей не превышают 65535 байт, а длины областей не превышают 255 байт. Во втором, длинном формате (см. таблицу 3.44) допускаются любые смещения и длины. Строго говоря,
3.4.1. Предложения обработки исключений в заголовках методов
Основная трудность для понимания деталей реализации обработки исключений в CLI заключается в том, что обработка исключений частично закодирована в телах методов (в виде специальных инструкций), а частично – в заголовках методов. Скорее всего, такая смешанная схема была выбрана разработчиками CLI для обеспечения компактности сборок. Поэтому мы в данном разделе сначала рассмотрим ту часть информации об обработке исключений, которая расположена в заголовках методов, затем перейдем к инструкциям CIL и, в конце концов, свяжем все воедино, приведя семантику обработки исключений виртуальной системой выполнения.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Размер 2 2 1 2 1 4 4
Поле Flags TryOffset TryLength HandlerOffset HandlerLength ClassToken FilterOffset
Описание Флаги Координаты защищенной области Координаты области обработчика Токен метаданных Смещение области фильтра
Размер 4 4 4 4 4 4 4
Поле Flags TryOffset TryLength HandlerOffset HandlerLength ClassToken FilterOffset
Описание Флаги Координаты защищенной области Координаты области обработчика Токен метаданных Смещение области фильтра
Итак, координаты защищенной области задаются парой (TryOffset, TryLength), а координаты области обработчика – парой (HandlerOffset, HandlerLength). Для области фильтра указывается только ее смещение, потому что подразумевается, что она непосредственно предшествует области обработчика (длину области фильтра можно вычислить: она равна HandlerOffset – FilterOffset). Обратите внимание, что смещения полей ClassToken и FilterOffset совпадают. Это означает, что фактически они представляют собой одно поле. Просто иногда оно интерпретируется как токен метаданных, а иногда – как смещение области фильтра. Поле Flags, возможные значения которого перечислены в таблице 3.45, задает тип обработчика. Всего возможны четыре типа обработчиков исключений, отличаю-
Смещение 0 4 8 12 16 20 20
Таблица 3.44. Поля предложения обработки исключений в случае длинного формата
Смещение 0 2 4 5 7 8 8
Таблица 3.43. Поля предложения обработки исключений в случае короткого формата
не любые, а укладывающиеся в 32 бита. Но на практике этого более чем достаточно.
118
119
Описание Обработчик исключений с фильтрацией по типу Обработчик исключений с пользовательской фильтрацией Обработчик finally Обработчик fault
0x7A
Код
Инструкция Встроенный Описание операнд throw – Генерирует исключение
Таблица 3.46. Инструкция throw
3.4.2.1. Инструкции для генерации исключений Инструкция throw (см. таблицу 3.46) генерирует исключение, включая тем самым механизм обработки исключений.
В CIL предусмотрено несколько инструкций, отвечающих за порождение исключений и передачу управления из обработчиков исключений.
3.4.2. Инструкции CIL для обработки исключений
Первые два типа обработчиков мы будем относить к категории обработчиков с фильтрацией, а последние два – к категории обработчиков без фильтрации.
Значение 0 1 2 4
Таблица 3.45. Допустимые значения поля Flags предложения обработки исключений
щихся друг от друга тем, по каким критериям принимается решение о передаче на них управления: 1. Обработчик с фильтрацией по типу. Получает управление, если тип исключения совместим по присваиванию с типом, указанным в поле ClassToken предложения обработки исключений. 2. Обработчик с пользовательской фильтрацией. Решение о том, получит или не получит управление обработчик, принимает код, содержащийся в области фильтра. 3. Обработчик finally. Вызывается при выходе из защищенной области, независимо от того, было или не было сгенерировано исключение. 4. Обработчик fault. Вызывается, если внутри защищенной области было сгенерировано любое исключение.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Инструкция Встроенный Описание операнд rethrow – Генерирует то же самое исключение, что было поймано обработчиком
0xDD 0xDE
Код
Инструкция Встроенный Описание операнд leave int32 Выход из области leave.s int8 Выход из области (короткий переход)
Таблица 3.48. Инструкция leave
3.4.2.2. Инструкции передачи управления между блоками Инструкции, представленные в таблице 3.48, являются аналогами инструкций безусловного перехода и используются для выхода из защищенных областей и областей обработчиков с фильтрацией. Необходимость существования этих инструкций обусловлена тем фактом, что обычные инструкции безусловного перехода не могут пересекать границы этих областей.
Диаграмма стека для инструкции rethrow: ... -> ...
0xFE 0x1A
Код
Таблица 3.47. Инструкция rethrow
Диаграмма стека для инструкции throw: ... , obj -> ... Объектная ссылка, которую инструкция throw потребляет со стека вычислений, должна указывать на объект в куче, описывающий исключение. Вообще говоря, в качестве такого объекта может выступать объект любого типа, в том числе упакованный тип-значение, но спецификация CLS требует, чтобы базовым классом для типа объекта-исключения являлся класс System.Exception. Если при выполнении инструкции throw на стеке вычислений лежит нулевая ссылка, то генерируется исключение System.NullReferenceException. Инструкция rethrow (см. таблицу 3.47) разрешена только внутри обработчика исключений с фильтрацией по типу и предназначена для генерации того же самого исключения, которое было поймано обработчиком.
120
121
endfinally (endfault)
0xDC
Встроенный Описание операнд – Выход из обработчиков finally и fault
Инструкция Встроенный Описание операнд endfilter – Завершение области фильтра
Итак, в общем случае, предложение обработки исключений определяет три области в коде метода: область защищенного блока, область фильтра и область обработчика (фильтр может отсутствовать). Эти области должны быть расположены в соответствии с определенными правилами: 1. Области, определяемые в предложении обработки исключений, не могут перекрываться. 2. Область фильтра всегда расположена непосредственно перед областью обработчика и завершается инструкцией endfilter. 3. Для любой пары предложений обработки исключений A и B должно быть справедливо следующее:
3.4.3. Правила размещения областей
Диаграмма стека для инструкции endfilter: ... , value -> ...
0xFE 0x11
Код
Таблица 3.50. Инструкция endfilter
Диаграмма стека для инструкции endfinally: ... -> ... Инструкция endfilter (см. таблицу 3.50) завершает область фильтра. Ее основная задача состоит в том, чтобы вернуть целое число (0 или 1). Значение 0 означает, что данное исключение не может быть обработано и нужно поискать другой обработчик. Значение 1 говорит о том, что нужно передать управление на обработчик.
Инструкция
Код
Таблица 3.49. Инструкция endfinally
Диаграмма стека для инструкции leave: ... -> Как видно из диаграммы, побочным эффектом при выполнении инструкции leave является очистка стека вычислений. Инструкция endfinally (см. таблицу 3.49) используется для выхода из областей обработчиков без фильтрации. У нее есть псевдоним – endfault.
Common Intermediate Language
a. если защищенная область предложения A находится внутри защищенной области предложения B, то области фильтра и обработчика предложения A также должны располагаться внутри защищенной области предложения B; b. если защищенная область предложения A не пересекается с защищенной областью предложения B, то области фильтров и обработчиков этих предложений тоже не должны пересекаться; c. если защищенная область предложения A совпадает с защищенной областью предложения B, то области фильтров и обработчиков этих предложений не должны пересекаться.
CIL и системное программирование в Microsoft .NET
Передача управления внутрь защищенных областей, из них и между ними и их обработчиками регламентирована следующими правилами: 1. Передача управления на обработчики осуществляется только через механизм обработки исключений. 2. Существует только два способа передать управление извне на защищенную область: a. передача управления на первую инструкцию защищенной области; b. использование инструкции leave из области обработчика с фильтрацией, связанной с данной защищенной областью (область обработки связана с защищенной областью, если их координаты указаны в одном и том же предложении обработки исключений). 3. Перед входом в защищенную область стек вычислений должен быть пустым. 4. Для выхода из защищенной области, из области фильтра или из области обработчика существуют только следующие возможности: a. порождение исключения инструкцией throw; b. использование инструкции leave из защищенной области или области с фильтрацией; c. использование инструкции endfilter из области фильтра; d. использование инструкции endfinally из области без фильтрации; e. использование инструкции rethrow из области с фильтрацией.
3.4.4. Ограничения на передачу управления
122
123
В составе .NET Framework SDK поставляется ассемблер ILASM, который позволяет компилировать текстовые файлы, содержащие CIL-код и
3.5. Синтаксис ILASM
Давайте рассмотрим последовательность действий, осуществляемую системой выполнения для обработки сгенерированного исключения. Пусть в некотором методе инструкция, расположенная по некоторому адресу, породила исключение. Система выполнения обрабатывает это исключение в два этапа. Задача первого этапа – поиск подходящего для этого исключения обработчика с фильтрацией. Задача второго этапа – выполнение нужных обработчиков без фильтрации и передача управления найденному во время первого этапа обработчику с фильтрацией. Выполнение первого этапа начинается с просмотра массива предложений обработки исключений, принадлежащего методу, где произошло исключение. В этом массиве осуществляется поиск такого предложения, что: 1. оно описывает обработчик с фильтрацией; 2. адрес инструкции, породившей исключение, попадает в диапазон адресов защищенной области этого предложения; 3. исключение удовлетворяет фильтру обработчика. Таким образом, на первом этапе finally и fault-блоки пропускаются. Кроме того, происходит последовательный вызов фильтров для блоков с пользовательской фильтрацией. Если в методе, внутри которого было сгенерировано исключение, не оказалось подходящего предложения обработки исключений, то система выполнения переходит на следующий метод в стеке вызовов (то есть, на метод, из которого данный метод был вызван). В следующем методе система выполнения продолжает искать нужное предложение, причем в качестве адреса инструкции, породившей исключение, используется адрес инструкции, вызывающей предыдущий метод. Первый этап может завершиться либо нахождением подходящего предложения, либо обнаружением того факта, что подходящее предложение не существует на всей последовательности методов в стеке вызовов. В первом случае система переходит к следующему этапу, а во втором – выполнение программы аварийно завершается. На втором этапе система выполнения повторно просматривает массивы предложений, вызывая все обработчики без фильтрации. Она останавливается, когда доходит до предложения, найденного на первом этапе, после чего вызывает обработчик, описываемый этим предложением.
3.4.5. Семантика обработки исключений
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
125
IL-программа представляет собой последовательность объявлений. В этом разделе мы рассмотрим синтаксис объявлений следующих элементов IL-программы: • сборка; • модуль; • тип; • поле; • метод.
Программы в IL-формате состоят из следующих лексических элементов: • идентификаторы; • метки; • константы; • зарезервированные слова; • специальные знаки; • комментарии. Идентификаторы и метки чаще всего представляют последовательности символов, начинающиеся с латинской буквы (или с символов «_», «$», «@» и «?»), за которой следуют латинские буквы, цифры или символы «_», «$», «@» и «?». Кроме того, для идентификаторов и меток существует особая форма записи в апострофах: она позволяет включать в идентификаторы любые символы Unicode. Например: Label_1 $Name 'Идентификатор' Несколько идентификаторов могут быть объединены в один идентификатор с помощью точек. Например: System.Console.WriteLine Целочисленные константы записываются либо в десятичной системе счисления, либо в шестнадцатеричной (тогда перед ними ставится префикс «0x»). Например: 128 -10 0xFF10B000 В вещественных константах точка используется для разделения целой и дробной части, а символы «e» и «E» служат для указания экспоненциальной части. Кроме того, поддерживается особая форма записи float32 (целая_константа) и float64 (целая_константа), позволяющая представить целое число в виде числа с плавающей точкой. Например: 5.5 -1.05e10 float32(128) float64(50) Строковые константы записываются в двойных кавычках и могут содержать Escape-последовательности «\t», «\n» и «\xxx», где восьмеричное число xxx задает код символа от 0 до 255. Для переноса строковой константы на другую строку программы используется символ «\». Кроме того, для строковых констант поддерживается операция конкатенации «+». Например: “Alpha Beta Gamma” “Hello, World\n” “Concat”+”enation”
3.5.2.2. Типы Объявление типа осуществляется с помощью директивы «.class» и состоит из четырех частей: 1. последовательность атрибутов типа; 2. имя типа; 3. базовый тип; 4. список реализуемых интерфейсов.
3.5.2.1. Сборки и модули Каждый IL-файл для ассемблера ILASM представляет собой отдельный модуль сборки. Мы не будем касаться вопросов компиляции сборки, состоящей из нескольких модулей, поэтому приведем образец заголовка IL-файла для одномодульной сборки: .assembly MyProgram { } .module MyProgram.exe .assembly extern mscorlib { } В заголовке используются три директивы: директива «.assembly» позволяет задать имя нашей сборки. Директива «.module» определяет имя модуля и совпадает с именем исполняемого файла, в который будет записана откомпилированная сборка. Директива «.assembly extern» указывает, что мы будем импортировать сборку mscorlib, в которой находится основная часть библиотеки классов .NET. В фигурных скобках после имени сборки могут перечисляться свойства сборки, но в простейшем случае их можно оставить пустыми.
3.5.2. Синтаксис
Комментарии в IL-программах записываются так же, как в языке C#: • Если в строке программы встречается «//», то остаток строки считается комментарием. • Текст, начинающийся с «/*», оканчивающийся на «*/» и не содержащий «*/», считается комментарием.
Common Intermediate Language
3.5.1. Основные элементы лексики
метаданные. В этом разделе мы проведем краткий обзор формата, в котором записываются эти файлы, и рассмотрим несколько примеров программ. Будем называть IL-форматом формат файлов, поддерживаемый ассемблером ILASM, а программы, записанные в IL-формате, – IL-программами.
124
CIL и системное программирование в Microsoft .NET
Описание Тип является абстрактным классом Тип является интерфейсом Тип не экспортируется из сборки Тип экспортируется из сборки Тип не может являться базовым классом для другого типа (от него нельзя наследовать) Экземпляры типа могут быть сериализованы
После атрибутов следует идентификатор, задающий имя объявляемого типа. Если объявляемый тип наследует от какого-нибудь другого типа (базового класса), отличного от System.Object, то необходимо указать имя базового класса после ключевого слова «extends». При этом, если в качестве базового класса выбран System.ValueType, то объявляемый тип будет типом-значением. Если объявляемый тип реализует методы каких-либо интерфейсов, то должен быть приведен список этих интерфейсов после ключевого слова «implements». Рассмотрим несколько примеров: 1. Объявление экспортируемого абстрактного класса, реализующего интерфейс IEnumerable. .class public abstract MyAbstractClass extends [mscorlib]System.Object implements [mscorlib]System.Collections.IEnumerable { } 2. Объявление неэкспортируемого интерфейса. .class private interface MyInterface { } 3. Объявление экспортируемого типа-значения. .class public sealed MyValueType extends [mscorlib]System.ValueType { } Обратите внимание, что перед именами библиотечных классов и интерфейсов в квадратных скобках указывается имя сборки, в которой они содержатся.
serializable
Атрибут abstract interface private public sealed
Таблица 3.51. Атрибуты типов
Последовательность атрибутов следует непосредственно после ключевого слова «.class». В таблице 3.51 приведен набор наиболее часто используемых атрибутов.
126
127
Описание Поле видимо внутри сборки Поле видимо для наследников типа Поле видимо для всех Поле видимо только внутри типа Поле является статическим
3.5.2.4. Методы Методы объявляются внутри объявлений типов. Объявление метода осуществляется с помощью директивы «.method» и состоит из пяти частей: 1. последовательность атрибутов метода; 2. тип возвращаемого значения; 3. имя метода; 4. список параметров метода; 5. тело метода. Последовательность атрибутов следует непосредственно после ключевого слова «.method». В таблице 3.53 приведен набор наиболее часто используемых атрибутов. После атрибутов следует тип возвращаемого значения и идентификатор, задающий имя метода. Если метод не возвращает значения, в качест-
После атрибутов следует тип поля и идентификатор, задающий имя поля. Рассмотрим несколько примеров: 1. Объявление поля x типа массив. .field private int32[] x 2. Объявление поля table типа Hashtable. .field public class [mscorlib]System.Collections.Hashtable table
Атрибут assembly family public private static
Таблица 3.52. Атрибуты полей
3.5.2.3. Поля Поля объявляются внутри объявлений типов. Объявление поля осуществляется с помощью директивы «.field» и состоит из трех частей: 1. последовательность атрибутов поля; 2. тип; 3. имя поля. Последовательность атрибутов следует непосредственно после ключевого слова «.field». В таблице 3.52 приведен набор наиболее часто используемых атрибутов.
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Давайте рассмотрим пример программы, написанной прямо на CIL с использованием синтаксиса ILASM. Мы не станем приводить весь текст программы сразу, а будем рассматривать ее постепенно, по частям. Естественно, наша программа будет начинаться с заголовка, объявляющего имена сборки и модуля и импортирующего стандартную библиотеку:
3.5.3. Пример программы
ве типа возвращаемого значения указывается void. Конструкторы всегда имеют имя «.ctor», а статические конструкторы – «.cctor». Список параметров метода следует за именем метода и заключается в круглые скобки. Для каждого параметра указывается его тип и имя. Прежде чем перейти к рассмотрению синтаксиса объявлений тел методов, приведем несколько примеров заголовков методов: 1. Объявление конструктора с двумя параметрами. .method public void .ctor (int32 x, class [mscorlib]System.String s) 2. Виртуальный метод с управляемым указателем в качестве параметра. .method private virtual int32 myMethod(int32& pX) 3. Статический метод, возвращающий массив: .method public static int32[] MyStaticMethod() Тело метода заключается в фигурные скобки и содержит инструкции языка CIL. Каждая инструкция записывается на новой строке программы. Если нужно, то инструкции может предшествовать метка, отделяемая от инструкции двоеточием. Например: Hello: ldstr “Hello, World!” call void [mscorlib]System.Console.WriteLine(string) Кроме инструкций CIL тело метода может содержать директивы тела метода. Они перечислены в таблице 3.54.
Описание Метод видим внутри сборки Метод видим для наследников типа Метод видим для всех Метод видим только внутри типа Метод является абстрактным Метод является виртуальным Метод не может переопределяться в наследниках Метод является статическим
Таблица 3.53. Атрибуты методов
Атрибут assembly family public private abstract virtual final static
128
129
Описание Показывает, что данный метод является точкой входа в сборку (метод должен быть статическим, возвращать int32 или ничего не возвращать, иметь в качестве параметров массив строк или вообще не иметь параметров) Определяет набор локальных переменных метода. Локальные переменные объявляются аналогично параметрам метода Задает глубину стека вычислений
Далее объявим тип-значение Point, реализующий понятие точки на плоскости. Он будет содержать два поля x и y типа float64, а также конструктор и статический метод, вычисляющий расстояние между двумя точками: .class public sealed Point extends [mscorlib]System.ValueType { .field public float64 x .field public float64 y .method public void .ctor (float64 x, float64 y) { .maxstack 3 ldarg.0 dup ldarg.1 stfld float64 Point::x ldarg.2 stfld float64 Point::y ret } .method public static float64 Distance (valuetype Point a, valuetype Point b) { .maxstack 3 ldarga a ldfld float64 Point::x
.assembly Sample1 { } .module sample1.exe .assembly extern mscorlib { }
.maxstack число
.locals (объявления)
Директива .entrypoint
Таблица 3.54. Директивы тела метода
Common Intermediate Language
ldarga ldfld sub dup mul ldarga ldfld ldarga ldfld sub dup mul add call ret
float64 [mscorlib]System.Math::Sqrt(float64)
a float64 Point::y b float64 Point::y
b float64 Point::x
CIL и системное программирование в Microsoft .NET
} } А теперь объявим вспомогательный класс SampleClass, который будет содержать точку входа в нашу сборку. Метод Demo (точка входа) будет вычислять расстояние между точками (0.0,0.0) и (1.0,1.0) и выводить результат на экран: .class public SampleClass { .method public static void Demo() { .entrypoint .maxstack 3 ldc.r8 0.0 ldc.r8 0.0 newobj void Point::.ctor(float64,float64) ldc.r8 1.0 ldc.r8 1.0 newobj void Point::.ctor(float64,float64) call float64 Point::Distance(valuetype Point, valuetype Point) call void [mscorlib]System.Console::WriteLine (float64) ret } } Откомпилируем нашу программу, которая записана в текстовом файле sample1.il. Подразумевается, что мы работаем в Windows и у нас переменная окружения path настроена таким образом, что программы ILASM и PEVERIFY можно вызывать без указания путей. Наберем в консоли команду:
130
131
1,4142135623731
В результате на экран выводится расстояние между точками (0.0,0.0) и (1.0,1.0):
sample1.exe
Таким образом, сборка успешно прошла верификацию и мы можем рискнуть ее запустить:
All Classes and Methods in sample1.exe Verified
Microsoft (R) .NET Framework PE Verifier Version 1.1.4322.573 Copyright (C) Microsoft Corporation 1998-2002. All rights reserved.
В ответ верификатор выведет на экран:
peverify sample1.exe
Итак, наша программа успешно откомпилировалась и на диске появилась сборка sample1.exe. Попробуем провести ее верификацию:
Assembled method Point::.ctor Assembled method Point::Distance Assembled method SampleClass::Demo Creating PE file Emitting members: Global Class 1 Fields: 2; Methods: 2; Class 2 Methods: 1; Resolving member refs: 9 -> 9 defs, 0 refs Writing PE file Operation completed successfully
Microsoft (R) .NET Framework IL Assembler. Version 1.1.4322.573 Copyright (C) Microsoft Corporation 1998-2002. All rights reserved. Assembling 'sample1.il', no listing file, to EXE --> 'sample1.EXE' Source file is ANSI
Мы получим следующее сообщение от компилятора ILASM:
ilasm sample1.il
Common Intermediate Language
CIL и системное программирование в Microsoft .NET
Код метода в сборке .NET представляет собой линейную последовательность CIL-инструкций и массив описателей блоков обработки исключений. Так как представленный в теле метода алгоритм в общем случае нелинейный, то есть содержит ветвления и циклы, то кодирование его в виде линейной последовательности требует определения семантики передачи управления от одной инструкции CIL к другой. Можно выделить четыре механизма передачи управления между инструкциями: 1. Явная передача управления с помощью инструкции перехода. При этом в параметре инструкции перехода указано относительное смещение инструкции, на которую будет передано управление. 2. Неявная (или естественная) передача управления на следующую инструкцию в последовательности. 3. Передача управления на обработчик исключения при выходе (нормальном или аварийном) из защищенного блока. При такой передаче управления просматривается массив описателей блоков обработки исключений до нахождения первого подходящего блока, из описателя этого блока берется адрес обработчика и осуществляется переход на инструкцию по этому адресу. 4. Передача управления между методами. В качестве примера рассмотрим фрагмент программы на языке CIL: .method private static int32 find(int32[] X, int32 k) { .locals init (int32 i, int32 result) .try { ldc.i4.0 [2] stloc.0 [2] br.s loop_cond [1] loop_body: ldloc.0 [2] ldc.i4.1 [2] add [2] stloc.0 [2] loop_cond: ldarg.0 [2] ldloc.0 [2] ldelem.i4 [2] ldarg.1 [2]
4.1. Граф потока управления
Глава 4. Анализ кода на CIL
132
133
Граф потока управления – это ориентированный граф, узлы которого соответствуют инструкциям CIL, а дуги изображают передачу управления между инструкциями. В качестве примера рассмотрим фрагмент программы на CIL: .method private static void print(int32[] X) { .locals init (int32 i)
4.1.1. Основные элементы графа потока управления
bne.un.s loop_body [1] ldloc.0 [2] stloc.1 [2] leave.s exit [3] } catch System.IndexOutOfRangeException { pop [2] ldc.i4.m1 [2] stloc.1 [2] leave.s exit [3] } exit: ldloc.1 [2] ret [4] } Метод find выполняет поиск элемента k в массиве X. Если элемент найден, то возвращается его индекс. В противном случае возвращается -1. В листинге программы справа от каждой инструкции в квадратных скобках приведен тип передачи управления от нее на следующую инструкцию. Схема представления кода в виде линейной последовательности инструкций типична для ассемблерных языков и является наиболее компактной. Действительно, бoльшая часть кода метода состоит из линейных последовательностей инструкций с неявной передачей управления, а так как неявная передача определяется порядком следования инструкций и не требует дополнительного кодирования, то код занимает меньше места. Некоторые метаинструменты, выполняющие только анализ CIL-кода, могут непосредственно работать с линейной последовательностью инструкций. Это JIT-компиляторы, интерпретаторы, верификаторы и отладчики. Но для метаинструментов, которые выполняют преобразование CILкода, такое представление неудобно, так как при попытке вставить новую инструкцию в последовательность или удалить инструкцию из последовательности необходимо корректировать адреса во всех инструкциях перехода и во всех описателях блоков обработки исключений. Этих проблем можно избежать, если вместо линейной последовательности инструкций использовать представление CIL-кода в виде графа потока управления.
Анализ кода на CIL
}
loop_cond:
loop_body:
ldc.i4.0 stloc.0 br.s loop_cond ldarg.0 ldloc.0 ldelem.i4 call void System.Console::WriteLine(int32) ldloc.0 ldc.i4.1 add stloc.0 ldloc.0 ldarg.0 ldlen conv.i4 blt.s loop_body ret
CIL и системное программирование в Microsoft .NET
Как известно, блоки обработки исключений в CIL реализованы в виде массива описателей, который хранится отдельно от CIL-кода. Для аде-
4.1.2. Блоки обработки исключений в графе потока управления
Граф потока управления для приведенного в примере метода изображен на рис. 4.1. Узлы графа обозначены прямоугольниками, в которых записаны инструкции CIL. Точка входа в метод обозначена специальным узлом с меткой «Метод print». Передачи управления между инструкциями показаны стрелками. Любопытно, что инструкция безусловного перехода br.s на графе отсутствует. Действительно, она не несет никакого смысла и нужна только для кодирования тела метода в виде линейной последовательности инструкций. Количество дуг, исходящих из узла графа, зависит от записанной в нем инструкции. Это видно на примере инструкции blt.s, из которой исходит сразу две дуги. Дуга, помеченная числом 1, обозначает передачу управления в случае истинности проверяемого инструкцией blt.s условия. В случае ложности условия передача управления осуществляется по дуге, помеченной числом 0. Вообще, имеет смысл нумеровать дуги, исходящие из узла графа. При этом номер дуги должен задавать ее семантику. Всего можно выделить четыре варианта нумерации дуг графа. Эти варианты представлены в таблице 4.1.
134
0
1
stloc.0
add
ldc.i4.1
135
кватного представления блоков обработки исключений нам придется добавить в граф потока управления специальные узлы, обозначающие входы в блоки, а также специальные дуги, отражающие взаимосвязи между ними. Кроме того, мы обобщим понятие блока, введя так называемый блок тела метода. Блоки, представленные в графе потока управления, можно разделить на четыре основные категории: • Блок тела метода – главный блок графа, в который непосредственно или транзитивно входят все остальные узлы графа. Этот блок содержится в графе в единственном экземпляре и задает точку входа в граф (в примере он был помечен строкой «Метод print»). • Защищенный блок – соответствует try-блоку в программе. При выходе из защищенного блока управление может передаваться на один или несколько блоков обработки исключений.
Рис. 4.1. Граф потока управления для метода print
ret
blt.s
ldloc.0
call ...
ldlen
conv.i4
ldelem.i4
ldarg.0
stloc.0
ldarg.0
ldloc.0
ldc.14.0
Метод print
Анализ кода на CIL
Из узлов, которые соответствуют некоторым инструкциям, связанным с выходом из блока (throw, rethrow, endfinally, endfilter, ret), вообще не исходит дуг
0
• Блок обработки исключений – прикреплен к защищенному блоку и может получить управление при выходе из этого защищенного блока. В графе существуют три типа блоков обработки исключений: блок с фильтрацией по типу (catch-блок), блок с пользовательской фильтрацией и finally/fault-блок. • Блок фильтрации – прикреплен к блоку обработки исключений с пользовательской фильтрацией и осуществляет принятие решения о передачи управления на обработчик. Независимо от категории, к которой принадлежит блок, он имеет ровно один вход – входной узел, а выход из него может осуществляться только через один или несколько выходных узлов.
Дуга с номером 0 обозначает передачу управления на следующую инструкцию
2
Нумерация для инструкции условного перехода Нумерация для последовательных инструкций Нумерация для «тупиковых» инструкций
Дуга с номером 0 обозначает передачу управления, которая происходит при «неудаче» (когда ни один из случаев, перечисленных в инструкции switch, не получил управления). Если в инструкции switch записано N переходов, то передачи управления для этих переходов обозначают дугами с номерами от 1 до N Дуги с номерами 0 и 1 обозначают соответственно false-ветку и true-ветку условного перехода
Семантика
1
Количество дуг Любое
Таблица 4.1. Варианты нумерации дуг графа
CIL и системное программирование в Microsoft .NET
Вариант нумерации Нумерация для инструкции switch
136
Exception handling block
...
Exception handling block
...
...
...
Exit
Exit
Exit
137
Схема обработки исключений отражена в структуре графа с помощью введения дополнительных связей между защищенным блоком и блоками обработки исключений. Так, каждый защищенный блок имеет ссылки на соответствующие блоки обработки исключений. При этом ссылки пронумерованы в том порядке, в каком происходит выполнение обработчиков исключений. Другими словами, обработка исключений требует введения в граф особых дуг, которые отражают не передачу управления между инструкциями, а последовательность обработчиков исключений, присоединенных к защищенному блоку (см. рис. 4.2). Блок обработки исключений с пользовательской фильтрацией имеет более сложную структуру, чем другие блоки обработки исключений. Он соединен дополнительной дугой с блоком фильтрации. Подразумевается, что при активации блока с пользовательской фильтрацией управление сначала передается на этот блок фильтрации, который принимает решение о том, следует или нет передавать управление собственно на обработчик. Проиллюстрируем следующим примером особенности графа потока управления с блоками обработки исключений: .method private static int32 checkedAdd(int32 x, int32 y) { .locals init (int32 result) .try { ldarg.0 ldarg.1 add.ovf stloc.0 leave.s exit }
Рис. 4.2. Защищенный блок и прикрепленные к нему блоки обработки исключений
N
O
Protected block
Анализ кода на CIL
CIL и системное программирование в Microsoft .NET
ldc.i4.0
stloc.0
add.ovf
leave.s
stloc.0
leave.s
Когда выполнение некоторой инструкции в линейной последовательности инструкций порождает исключение, всегда можно легко определить защищенный блок, которому эта инструкция принадлежит. Для этого требуется всего лишь просканировать массив описателей блоков и
4.1.3. Дерево блоков в графе потока управления
Рис. 4.3. Граф потока управления для метода checkedAdd
ret
ldloc.0
pop
Catch-блок
ldarg.0
Защищенный блок
ldarg.1
Граф потока управления для метода checkedAdd показан на рис. 4.3.
catch System.OverflowException { pop ldc.i4.0 stloc.0 leave.s exit } exit: ldloc.0 ret }
Метод checkedAdd
138
139
Инструкции
Защищенный блок
Инструкции
Инструкции
Блоки обработки исключений
Инструкции
Инструкции
Блоки обработки исключений
Защищенный блок
Совсем другая ситуация возникает в графе потока выполнения. Чтобы определить, в какой блок входит некоторая инструкция графа (то есть узел графа), мы должны каким-то образом пройти от этой инструкции по дугам графа в обратном направлении до входа в блок. Время выполнения этой операции зависит от количества инструкций в блоке и значительно превышает время поиска блока в случае линейной последовательности инструкций.
Рис. 4.4. Дерево блоков в структуре графа потока управления
Инструкции
Блоки обработки исключений
Защищенный блок
Блок тела метода
найти первый блок, покрывающий диапазон адресов, в который попадает адрес нужной инструкции. Время выполнения этой операции зависит только от длины массива описателей, а так как он чаще всего бывает достаточно коротким, время поиска оказывается невелико.
Анализ кода на CIL
CIL и системное программирование в Microsoft .NET
Разработчик любого метаинструмента, использующего граф потока управления в процессе анализа CIL-кода, сталкивается с проблемой преобразования линейной последовательности инструкций в граф потока управления. В данной главе мы рассмотрим алгоритм такого преобразования. Наш алгоритм будет работать на уровне метода, то есть в качестве входных данных для него будут выступать тело метода и массив предложений обработки исключений для этого метода. На выходе алгоритма будет построенный граф потока управления. Таким образом, для того чтобы построить набор графов потока управления для всей сборки, необходимо запустить этот алгоритм для каждого метода, входящего в сборку. Итак, пусть дан массив инструкций P размера N и массив предложений обработки исключений EH размера M. Требуется построить граф потока управления и возвратить ссылку на блок тела метода построенного графа. Мы будем предполагать, что в массивах P и EH адреса инструкций предварительно заменены на их номера (это касается встроенных операндов инструкций перехода, а также границ областей в предложениях обработки исключений). Кроме того, все массивы, которые мы будем рассматривать при описании алгоритма, включая массивы P и EH, будут индексироваться, начиная с нуля.
4.2. Преобразование линейной последовательности инструкций в граф потока управления
Однако путем введения в граф дополнительных дуг, задающих иерархию блоков, мы можем сделать операцию поиска блока, в который непосредственно входит некоторая инструкция, очень эффективной. Пусть каждый узел графа, кроме блока тела метода, будет соединен особой дугой с блоком, в который этот узел непосредственно входит. В результате добавления таких дуг в структуре графа получается дерево блоков. На рис. 4.4 изображена схема такого дерева, на которой наши дополнительные дуги обозначены незакрашенными стрелками, а дуги, соединяющие защищенные блоки с блоками обработки исключений, обозначены пунктирными стрелками. Обычные дуги графа потока управления, задающие передачу управления между инструкциями, на схеме не изображены. Понятно, что операция поиска блока, в который входит инструкция, на графе, включающем дерево блоков, сводится к переходу по одной дуге от инструкции к блоку. Эта операция выполняется за константное время, то есть не зависит ни от количества защищенных блоков, ни от количества инструкций в блоке.
140
141
На первом этапе работы алгоритма мы создаем узел графа для каждой инструкции и формируем из созданных узлов массив. На входе первого этапа мы имеем массив P. Для каждой инструкции, входящей в массив P, мы создаем соответствующий ей узел графа. В этот узел записываются все данные об инструкции, кроме информации о передаче управления и принадлежности блокам. Другими словами, созданные узлы не связываются друг с другом дугами и не имеют ссылки на родительский блок. Узлы записываются в массив Nodes, состоящий из N элементов. При этом в массиве Nodes сохраняется порядок инструкций, то есть если некоторая инструкция располагается в массиве P по индексу i, то соответствующий ей узел будет размещен в i-том элементе массива Nodes. На C#-подобном псевдоязыке первый этап работы алгоритма можно записать следующим образом: Nodes = новый массив узлов размера N; for (int i = 0; i < N; i++) {
4.2.1. Создание массива узлов
Напомним также, что каждое предложение в массиве EH имеет следующий набор полей: • Flags. Задает тип обработчика исключений: обработчик с фильтрацией по типу, обработчик с пользовательской фильтрацией, обработчик finally или обработчик fault. • TryOffset. Номер инструкции в массиве P, с которой начинается защищенная область. • TryLength. Количество инструкций, входящих в защищенную область. • HandlerOffset. Номер инструкции в массиве P, с которой начинается область обработчика. • HandlerLength. Количество инструкций, входящих в область обработчика. • ClassToken. Токен метаданных, обозначающий тип исключения (используется в случае обработчика с фильтрацией по типу). • FilterOffset. Номер инструкции в массиве P, с которой начинается область фильтра (используется в случае обработчика с пользовательской фильтрацией).
Анализ кода на CIL
CIL и системное программирование в Microsoft .NET
На втором этапе работы алгоритма мы строим дерево блоков на основе информации, находящейся в массиве предложений обработки исключений. На входе второго этапа мы имеем массив EH. На выходе получаем дерево блоков и вспомогательный массив B, связывающий блоки с информацией о диапазонах входящих в них инструкций (другими словами, с информацией об областях кода). Каждый элемент массива B будет состоять из трех полей: • Поле block. Это поле содержит ссылку на блок. • Поле offset. Содержит целое число, обозначающее индекс первой инструкции блока в массиве P. • Поле length. Содержит количество инструкций, входящих в блок (длина блока). Следует отметить, что размер массива B заранее неизвестен и зависит от информации в массиве EH. Нетрудно догадаться, что минимальное количество блоков в массиве B равно M+2 (если мы имеем один защищенный блок, M блоков обработки исключений и один блок тела метода). Аналогично, максимальное количество блоков вычисляется по формуле 2*M+1 (M защищенных блоков, M блоков обработки исключений и один блок тела метода). Таким образом, необходимо либо сделать массив B динамическим, либо выделить для него 2*M+1 записей. В любом случае, пусть в дальнейшем BN обозначает текущее количество блоков, информация о которых хранится в массиве B. Сначала создадим блок тела метода (назовем его MBB). Он будет являться корнем дерева, которое мы строим. К нему в дальнейшем будут «прицеплены» все остальные узлы графа, и именно его наш алгоритм будет возвращать в результате своей работы. Для блока MBB в массив B добавляется запись, поле start которой содержит значение 0, а поле length – значение N.
4.2.2. Создание дерева блоков
Nodes[i] = новый узел, содержащий информацию об инструкции P[i]; } Следует особо отметить, что для инструкций безусловного перехода также создаются отдельные узлы. Инструкцию nop мы будем считать инструкцией безусловного перехода по относительному адресу 0. Эти узлы для инструкций безусловного перехода являются временными и на последнем этапе алгоритма удаляются из графа.
142
143
B = новый массив размера 2*M+1 для хранения информации о блоках; B[0].block = MBB; B[0].start = 0; B[0].length = N; BN = 1; for (int i = M-1; i >= 0; i--) {
Напомним, что предложения обработки исключений расположены в массиве EH в определенном порядке, гарантирующем, что более вложенный блок находится ближе к началу массива, чем объемлющий его блок. Так как нам требуется строить дерево блоков в направлении от корня к листам, то есть от менее вложенных блоков к более вложенным, то мы будем просматривать массив EH от конца до начала. Итак, пусть переменная i пробегает значения от M-1 до 0 включительно. Тогда для каждого i-го предложения мы будем выполнять следующее: 1. Поиск в массиве B блока, диапазон инструкций которого содержит защищенную область i-го предложения. Мы перебираем элементы массива B в обратном порядке, начиная с последнего элемента, поэтому найденный блок будет самым вложенным из блоков, диапазон инструкций которых содержит защищенную область i-го предложения. 2. Создание нового защищенного блока и добавление его в дерево. Возможен случай, когда диапазон инструкций блока, найденного в пункте 1, совпадает с защищенной областью i-го предложения. Если это так, то создание нового защищенного блока не требуется. В противном случае мы создаем новый блок и добавляем информацию о нем в массив B. При этом родителем для созданного блока становится блок, найденный в пункте 1. 3. Создание блока обработки исключений. На основе информации i-го предложения мы создаем новый блок обработки исключений, добавляем его в массив B и связываем его с защищенным блоком. При этом родителем созданного блока будет являться родительский блок защищенного блока. 4. Создание блока фильтрации. Если мы имеем дело с блоком обработки исключений с пользовательской фильтрацией, нам придется создать новый блок фильтрации. При этом родителем для блока фильтрации является блок обработки исключений. На нашем псевдоязыке второй этап алгоритма можно записать следующим образом: MBB = новый блок тела метода, в который записана информация о методе:имя метода, сигнатура и данные о типах локальных переменных;
Анализ кода на CIL
144
}
if (EH[i].Flags обозначает блок с пользовательской фильтрацией) { /* Создание блока фильтрации */ B[BN].block = новый блок фильтрации; Сделать блок B[BN-1].block родителем блока B[BN].block; B[BN].offset = EH[i].FilterOffset; B[BN].length = EH[i].HandlerOffset – EH[i].FilterOffset; BN++; }
/* Создание блока обработки исключений */ B[BN].block = новый блок обработки исключений, тип которого определяется значением EH[i].Flags; Сделать родителем блока B[BN].block тот же блок, который является родителем блока B[j].block; Добавить блок B[BN].block в конец списка обработчиков, ассоциированных с блоком B[j].block; B[BN].offset = EH[i].HandlerOffset; B[BN].length = EH[i].HandlerLength; BN++;
if (tryOffset != B[j].offset || tryLength != B[j].length) { /* Создание нового защищенного блока и добавление его в дерево */ B[BN].block = новый защищенный блок; Сделать блок B[j].block родителем блока B[BN].block; B[BN].offset = tryOffset; B[BN].length = tryLength; j = BN; BN++; }
/* Поиск в массиве B блока, диапазон инструкций которого содержит защищенную областью i-го предложения */ int tryOffset = EH[i].TryOffset, tryLength = EH[i].TryLength; for (int j = BN-1; j >= 0; j--) if (tryOffset >= B[j].offset && tryOffset+tryLength = i и M[j] еще не содержит конфигурации стека, то M[j] = S; c. если j >= i и M[j] уже содержит конфигурацию стека, то выполняем попытку слияния конфигураций S и M[j]. Если попытка увенчалась успехом, то записываем результат слияния в M[j]. В противном случае алгоритм завершается неуспехом. Если все инструкции были просмотрены и ни на одном шаге алгоритм не завершился неуспехом, то метод успешно прошел верификацию.
152
153
IMetadataImport IMetadataEmit
Интерфейс IMetadataDispenserEx
Описание Позволяет загружать образ исполняемого либо объектного файла в память или создавать новый образ Предназначен для чтения метаданных Предназначен для генерации метаданных
Таблица 4.2. Интерфейсы 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 #pragma comment(lib, “format.lib”) Первая строка подключает заголовочный файл, в котором описываются нужные интерфейсы, а также вспомогательные структуры данных и функции. Вторая строка дает указание компоновщику использовать библиотеку format.lib. Взаимодействие с Metadata Unmanaged API осуществляется через набор COM-интерфейсов. Они перечислены в таблице 4.2.
4.4.1. Metadata Unmanaged API
Анализ кода на CIL
CIL и системное программирование в Microsoft .NET
Работа с 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, которые условно можно разделить на три основные группы: 1. Методы EnumXXX возвращают массивы токенов, описывающих определенную категорию элементов метаданных. Ответственность за выделение достаточного количества памяти для хранения возвращаемого массива токенов лежит на программисте, использующем библиотеку, а так как количество токенов заранее неизвестно, то приходится использовать прием, проиллюстрированный следующим примером. В нем мы получаем массив
154
155
токенов, который соответствует типам, объявленным в сборке (другими словами, получаем содержимое таблицы 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. 2. Методы FindXXX предназначены для поиска элементов метаданных, удовлетворяющих некоторым критериям. Например, поиск типа по его имени («MyType1») проводится следующим образом: mdTypeDef token; mdimp->FindTypeByName(L”MyType1”,NULL,&token); 3. Методы GetXXX используются для получения свойств элементов метаданных. Например, получение содержимого строки, токен которой хранится в переменной strToken, выглядит так: unsigned short s[1024]; unsigned long len; mdimp->GetUserString(strToken,s,1024,&len); Обратите внимание, что для хранения одного символа применяется тип unsigned short. Причина в том, что для представления строковых данных в .NET используется 16-разрядная кодировка Unicode.
Анализ кода на CIL
CIL и системное программирование в Microsoft .NET
4.4.2.1. Чтение метаданных через рефлексию Метаинструмент, использующий библиотеку рефлексии, получает доступ к метаданным сборки через экземпляр класса Assembly, который создается при загрузке сборки в память. Затем через методы класса Assembly можно получить объекты класса Module, которые соответствуют модулям, входящим в сборку. Класс Module, в свою очередь, позволяет получить набор экземпляров класса Type для входящих в модуль типов, а уже через эти объекты можно добраться до конструкторов (класс ConstructorInfo), методов (класс MethodInfo) и полей (класс FieldInfo) типов. То есть библиотека рефлексии спроектирована таким образом, что создание объектов рефлексии, соответствующих элементам метаданных, осуществляется путем вызова методов других объектов рефлексии (схема получения доступа к объектам рефлексии показана на рис. 4.5, при этом сплошные стрелки обозначают основные пути получения доступа, а пунктирные – дополнительные пути). Другими словами, конструкторы классов, входящих в библиотеку, для пользователя библиотеки недоступны. Информацию о любом элементе метаданных можно прочитать из свойств соответствующего ему объекта рефлексии. Экземпляр класса Assembly, с создания которого, как правило, начинается работа со сборкой через рефлексию, можно получить разными способами: 1. Если нас интересует текущая работающая сборка, мы можем вызвать статический метод GetExecutingAssembly класса Assembly: Assembly assembly = Assembly.GetExecutingAssembly(). 2. Если мы хотим получить доступ к сборке, в которой объявлен некоторый тип данных, мы можем воспользоваться статическим методом GetAssembly: Assembly assembly = Assembly.GetAssembly(typeof(int)).
Библиотека рефлексии содержит классы для работы с метаданными на высоком уровне (эти классы располагаются в пространствах имен System.Reflection и System.Reflection.Emit). Она соответствует спецификации CLS, поэтому использующий ее метаинструмент может быть написан на любом языке платформы .NET, который является потребителем CLS-библиотек.
4.4.2. Reflection API
Генерация метаданных осуществляется через методы интерфейса IMetadataEmit, которые можно условно разделить на две группы: 1. Методы DefineXXX добавляют новые элементы метаданных; 2. Методы SetXXX устанавливают свойства элементов метаданных. Сгенерированные метаданные могут быть сохранены на диске при помощи метода Save.
156
PropertyInfo
ConstructorInfo
MethodInfo
FieldInfo
ParameterInfo
157
Assembly assembly = Assembly.LoadFrom(“test.exe”); Module[] modules = assembly.GetModules(false);
3. И, наконец, если нам надо загрузить внешнюю сборку, мы используем статический метод LoadFrom: Assembly assembly = Assembly.LoadFrom(“test.exe”). Сборка .NET, как правило, состоит из одного модуля, хотя существует возможность включения в сборку сразу нескольких модулей. Получение доступа к объектам класса Module, описывающим модули, из которых состоит сборка, осуществляется одним из двух способов: 1. Если мы знаем имя модуля, то мы можем использовать метод GetModule класса Assembly: Module module = assembly.GetModule(“SomeModule.exe”). 2. Кроме того, мы можем вызвать метод GetModules для получения массива объектов класса Module, соответствующих всем модулям в сборке: Module[] modules = assembly.GetModules(false). Через объект класса Module мы можем обратиться к глобальным полям и функциям модуля (глобальные поля и функции не принадлежат ни одному классу), используя методы GetField и GetFields для полей, а также GetMethod и GetMethods для функций. При этом полям будут соответствовать объекты класса FieldInfo, а методам – объекты класса MethodInfo. В следующем примере мы выводим на экран сигнатуры всех глобальных функций некоторой сборки «test.exe»:
Рис. 4.5. Получение доступа к объектам рефлексии
Type
Module
Assembly
Анализ кода на CIL
CIL и системное программирование в Microsoft .NET
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[]); Кроме этого, доступ к информации о типе можно получить, зная имя этого типа: 1. путем вызова статического метода GetType класса Type: Type t = Type.GetType(“System.Char”); 2. через объекты классов 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); Имея объект рефлексии, соответствующий некоторому типу, мы имеем возможность получить доступ к объектам рефлексии, описывающим его поля, конструкторы, методы, свойства и вложенные типы. При
158
159
4.4.2.2. Управление объектами Кроме чтения метаданных, библиотека рефлексии позволяет создавать экземпляры типов, входящих в обрабатываемую сборку, вызывать методы этих типов, читать и изменять значения полей. Рассмотрим класс 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);
этом, если мы знаем их имена, мы можем воспользоваться методами 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();
Анализ кода на CIL
MethodInfo printer = typeof(TestClass).GetMethod(“print”,flags); printer.Invoke(a, null);
CIL и системное программирование в Microsoft .NET
4.4.2.3. Генерация метаданных и CIL-кода Для генерации метаданных в библиотеке рефлексии предназначены классы пространства имен System.Reflection.Emit. Создание новой сборки начинается с создания экземпляра класса AssemblyBuilder. Далее путем вызова методов класса AssemblyBuilder создается нужное количество модулей (объектов класса ModuleBuilder), в модулях создаются типы (объекты класса TypeBuilder), а в типах – конструкторы, методы и поля (объекты классов ConstructorBuilder, MethodBuilder и FieldBuilder, соответственно). То есть при генерации метаданных идеология такая же, как и при чтении их из готовой сборки (на рис. 4.6 представлена схема, показывающая последовательность создания метаданных).
} Путем внесения небольших изменений в 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)); }
160
161
MethodBuilder
ConstructorBuilder
ModuleBuilder
TypeBuilder
В следующем примере библиотека рефлексии используется для генерации сборки .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”
ILGenerator
ParameterBuilder
Рис. 4.6. Последовательность создания метаданных
PropertyBuilder
FieldBuilder
AssemblyBuilder
В отличие от Metadata Unmanaged API, библиотека рефлексии содержит средства для генерации CIL-кода. Для этого предназначен класс ILGenerator. Он позволяет после создания объекта MethodBuilder (или ConstructorBuilder) сгенерировать для соответствующего метода (или конструктора) поток инструкций.
Анализ кода на CIL
}
}
); 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”);
CIL и системное программирование в Microsoft .NET
Reflection API + + – +
Из таблицы следует, что ни одна из библиотек, поставляемых вместе с .NET Framework, не позволяет читать CIL-код, хотя эта возможность требуется целому ряду метаинструментов (например, верификаторам и оптимизаторам кода).
Чтение метаданных Генерация метаданных Чтение CIL-кода Генерация CIL-кода
Metadata Unmanaged API + + – –
Таблица 4.3. Сравнение возможностей библиотек
Возможности, предоставляемые библиотекой Metadata Unmanaged API и библиотекой рефлексии, представлены в таблице 4.3.
4.4.3. Сравнение возможностей библиотек
162
163
Динамическая генерация кода – это прием программирования, заключающийся в том, что фрагменты кода порождаются и запускаются непосредственно во время выполнения программы. Этот прием был известен достаточно давно, но усложнение архитектуры компьютеров, и, что особенно важно, усложнение наборов команд процессоров привело к тому, что в последние 10-15 лет динамическая генерация кода в некоторой степени потеряла популярность. Целью динамической генерации кода является использование информации, доступной только во время выполнения программы, для повышения качества исполняемого кода. В терминах метавычислений можно сказать, что динамическая генерация кода позволяет специализировать фрагменты программы по данным, известным во время выполнения. В некотором смысле, любой JIT-компилятор как раз использует динамическую генерацию кода: имея некоторую программу, записанную на промежуточном языке (байт-коде), и зная, какой процессор работает в системе, JIT-компилятор динамически транслирует программу в инструкции этого процессора. При этом можно считать, что тип процессора – эта как раз та часть информации, которая становится известной только во время выполнения программы. Естественно, не стоит чересчур увлекаться динамической генерацией кода: этот прием далеко не всегда дает ускорение программы. Можно сказать, что применение динамической генерации оправдано, если: 1. процесс вычислений в некотором фрагменте программы преимущественно определяется информацией, известной только во время выполнения; 2. запуск этого фрагмента осуществляется многократно; 3. выполнение фрагмента связано с существенными затратами времени процессора. В .NET доступно два способа организации динамической генерации кода: 1. порождение программы на языке C# и вызов компилятора C#; 2. непосредственное порождение метаданных и CIL-кода.
5.1. Введение в динамическую генерацию кода
Глава 5. Динамическая генерация кода
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
Для интегрирования функций нам потребуется некое представление функции, которое бы не зависело от конкретного способа вычисления значения функции. Идеальным вариантом такого представления является абстрактный класс Function: public abstract class Function { public abstract double Eval(double x); } Объявив такой класс, мы предполагаем, что от него будут наследоваться другие классы, реализующие в методе Eval конкретный способ вычисления значения функции в заданной точке. Имея класс Function, мы можем записать обобщенный алгоритм интегрирования методом прямоугольников. В качестве параметров этот алгоритм принимает объект f, представляющий интегрируемую функцию, пределы интегрирования a и b, а также количество разбиений n:
5.1.1. Обобщенный алгоритм интегрирования
Если сравнить эти два способа, можно придти к выводу, что порождение C#-программы несколько проще, нежели генерация CIL-кода. Однако, наличие в библиотеке классов пространства имен System.Reflection.Emit позволяет избежать рутинных и трудоемких операций по работе с физическим представлением метаданных и CIL-кода. Кроме того, генерация CIL-кода выполняется на порядок быстрее и дает большую гибкость. Поэтому для программиста, знакомого с набором инструкций CIL, второй способ является более предпочтительным. В этом разделе мы рассмотрим простой пример программы на языке C#, выполняющей численное интегрирование функции, которую пользователь вводит с клавиатуры (то есть интегрируемая функция становится известной только в процессе выполнения программы). Исходный код примера приведен в Приложении B. Характерной особенностью задачи численного интегрирования является необходимость многократного вычисления значения функции в разных точках. При этом, так как функция представлена в виде строки, это вычисление связано со значительными затратами времени процессора. Таким образом, данная задача по всем признакам подходит для использования динамической генерации кода. Мы будет выполнять вычисление значения функции тремя способами: 1. Без динамической генерации кода (путем непосредственной интерпретации выражения). 2. Путем динамической генерации программы на языке C#. 3. Путем динамической генерации метаданных и CIL-кода. Затем мы сравним эффективность каждого способа.
164
165
В нашем примере пользователь будет вводить выражение с клавиатуры, то есть оно будет представлено в виде текстовой строки. Такое представление неудобно ни для непосредственного вычисления значения функции, ни для генерации кода, вычисляющего ее значение. Поэтому нам понадобится парсер и некоторое представление, в которое этот парсер будет переводить введенную с клавиатуры текстовую строку. Детали синтаксического анализа и представления выражений мы рассматривать не будем: интересующиеся могут обратиться к полным исходным текстам примера. Скажем лишь, что анализ осуществляется методом рекурсивного спуска и транслирует выражение в дерево, в узлах которого расположены объекты, представляющие арифметические операции и их операнды. Каждый из этих объектов является экземпляром одного из четырех классов, обозначающих числовые константы, переменные, унарные и бинарные операции. Причем все эти классы наследуют от абстрактного класса Expression: public abstract class Expression { public abstract string GenerateCS(); public abstract void GenerateCIL(ILGenerator il); public abstract double Evaluate(double x); } В классе Expression объявлены три абстрактных метода, которые каждый класс-наследник реализует по-своему. Метод Evaluate выполняет
5.1.2. Представление выражений
static double Integrate(Function f, double a, double b, int n) { double h = (b-a)/n, sum = 0.0; for (int i = 0; i < n; i++) sum += h*f.Eval((i+0.5)*h); return sum; } Для проверки работоспособности алгоритма можно объявить тестовый класс TestFunction, реализующий вычисление функции f(x) = x * sin(x): public class TestFunction: Function { public override double Eval(double x) { return x * Math.Sin(x); } }
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
Как уже говорилось, самым простым способом динамической генерации кода является порождение текста C#-программы и компиляция этой программы с помощью компилятора C#, доступного через библиотеку классов .NET. В нашем примере динамическую генерацию сборки осуществляет статический метод CompileToCS, который получает транслируемое выражение в виде объекта класса Expression и возвращает объект Function: static Function CompileToCS(Expression expr) { ICodeCompiler compiler = new CSharpCodeProvider().CreateCompiler(); CompilerParameters parameters = new CompilerParameters();
5.1.3. Трансляция выражений в C#
непосредственное вычисление значения выражения, метод GenerateCS транслирует выражение в фрагмент программы на C#, а метод GenerateCIL транслирует выражение в CIL-код. Вопросы генерации кода будут обсуждаться в следующих разделах, поэтому сейчас мы только приведем пример CIL-кода, генерируемого методом GenerateCIL для дерева объектов, которые представляют выражение ”2*x*x*x+3*x*x+4*x+5”: ldc.r8 2.0 ldarg.1 mul ldarg.1 mul ldarg.1 mul ldc.r8 3.0 ldarg.1 mul ldarg.1 mul add ldc.r8 4.0 ldarg.1 mul add ldc.r8 5.0 add Метод GenerateCS фактически восстанавливает из дерева строковое представление выражения и в особых комментариях не нуждается.
166
167
} Классы, отвечающие за компиляцию исходного кода, относятся к пространству имен System.CodeDom.Compiler. Основную функциональность, необходимую нам для компиляции сгенерированной C#-программы, обеспечивает класс CSharpCodeProvider. Метод CreateCompiler этого класса создает экземпляр компилятора C#, к которому можно обращаться через интерфейс ICodeCompiler. Параметры компиляции задаются через объект класса CompilerParameters. В нашем случае это имена сборок, импортируемых генерируемой программой: System.dll и Integral.exe. Обратите внимание, что Integral.exe – это сборка, получаемая при компиляции рассматриваемого нами примера. Она импортируется по причине того, что динамически генерируемый класс FunctionCS должен наследовать от определенного в ней абстрактного класса Function. Параметры компиляции и строка, содержащая текст программы, передаются методу CompileAssemblyFromSource экземпляра компилятора C#. Метод компилирует программу и возвращает объект класса CompilerResults, содержащий результаты компиляции. Из этого объекта мы можем получить объект рефлексии Assembly, представляющий созданную в памяти динамическую сборку. Используя данный объект, мы создаем экземпляр определенного в динамической сборке класса FunctionCS, который в дальнейшем может быть использован для вычисления значения функции в процессе интегрирования.
CompilerResults compilerResults = compiler.CompileAssemblyFromSource(parameters,code); Assembly assembly = compilerResults.CompiledAssembly; return assembly.CreateInstance(“FunctionCS”) as Function;
string e = expr.GenerateCS(); string code = “public class FunctionCS: Function\n”+ “{\n”+ “ public override double Eval(double x)\n”+ “ {\n”+ “ return “+e+”;\n”+ “ }\n”+ “}\n”;
parameters.ReferencedAssemblies.Add(“System.dll”); parameters.ReferencedAssemblies.Add(“Integral.exe”); parameters.GenerateInMemory = true;
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
Более сложный, но эффективный способ динамической генерации кода предоставляется классами, относящимися к пространству имен System.Reflection.Emit. Эти классы в нашем примере используются в статическом методе CompileToCIL, который осуществляет трансляцию выражения напрямую в CIL: static Function CompileToCIL(Expression expr) Метод начинается с создания заготовки для будущей динамической сборки. Сборка будет выполняться в том же домене приложений, что и основная программа, поэтому объектную ссылку на домен приложений мы получаем путем вызова статического метода Thread.GetDomain. Затем вызываем метод DefineDynamicAssembly домена приложений и получаем объект класса AssemblyBuilder, позволяющий строить динамическую сборку: AppDomain appDomain = Thread.GetDomain(); AssemblyName assemblyName = new AssemblyName(); assemblyName.Name = “f”; AssemblyBuilder assembly = appDomain.DefineDynamicAssembly( assemblyName, AssemblyBuilderAccess.RunAndSave ); Теперь мы можем создать в сборке модуль и добавить в него класс FunctionCIL, наследующий от класса Function. Обратите внимание, что при генерации кода через классы пространства имен System.Reflection.Emit явно прописывать импортируемые сборки не надо (например, не надо прописывать сборку Integral.exe, из которой импортируется класс Function), так как это выполняется автоматически: ModuleBuilder module = assembly.DefineDynamicModule(“f.dll”, “f.dll”); TypeBuilder typeBuilder = module.DefineType( “FunctionCIL”, TypeAttributes.Public | TypeAttributes.Class, typeof(Function) ); В каждом классе должен быть конструктор. Компилятор C# создает конструкторы без параметров по умолчанию, поэтому при генерации C#кода нам не надо было явно объявлять конструктор в классе FunctionCS. Однако, при генерации динамической сборки через классы пространства имен System.Reflection.Emit конструкторы автоматически не добавляются, и нам придется сделать это самостоятельно:
5.1.4. Трансляция выражений в CIL
168
169
Давайте оценим эффективность рассмотренных способов вычисления выражений при интегрировании. Для этого будем интегрировать функцию «2*x*x*x+3*x*x+4*x+5» от 0.0 до 10.0 с 10000000 разбиений. В таблице 5.1 представлены результаты измерений, проведенных на компьютере с процессором Intel Pentium 4 с тактовой частотой 3000 МГц и 1 Гб оперативной памяти.
5.1.5. Сравнение эффективности трех способов вычисления выражений
ILGenerator il = evalMethod.GetILGenerator(); expr.GenerateCIL(il); il.Emit(OpCodes.Ret); Итак, мы закончили формирование класса FunctionCIL. Осталось создать для него объект рефлексии и через этот объект вызвать конструктор: Type type = typeBuilder.CreateType(); ConstructorInfo ctor = type.GetConstructor(new Type[0]); return ctor.Invoke(null) as Function; Таким образом, получается объект класса FunctionCIL, который в дальнейшем можно использовать для вычисления значения функции в процессе интегрирования.
ConstructorBuilder cons = typeBuilder.DefineConstructor( MethodAttributes.Public, CallingConventions.Standard, new Type[] { } ); ILGenerator consIl = cons.GetILGenerator(); consIl.Emit(OpCodes.Ldarg_0); consIl.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[0])); consIl.Emit(OpCodes.Ret); Разобравшись с конструктором, переходим к методу Eval. Как уже говорилось, код этого метода почти полностью генерируется в методе GenerateCIL выражения, остается лишь добавить в конец инструкцию ret: MethodBuilder evalMethod = typeBuilder.DefineMethod( “Eval”, MethodAttributes.Public | MethodAttributes.Virtual, typeof(double), new Type[] { typeof(double) } );
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
172
63
::= ::= ::= ::=
Expr field f minus Expr Expr BinOp Expr local x assign Expr
ArgList ::= Expr ArgList
5.2.1.1. Абстрактный синтаксис выражений Пусть наши выражения оперируют с константами, переменными и параметрами. Разрешим использование в выражениях как значений примитивных типов, так и объектов (в частности, массивов). В качестве операций мы будем использовать четыре арифметические бинарные операции, унарный минус, обращение к полю объекта, доступ к элементу массива и вызов экземплярного метода объекта. Пусть абстрактный синтаксис для наших выражений содержит правила, приведенные в таблице 5.2.
BinaryOp BinaryOp BinaryOp BinaryOp
::= ::= ::= ::=
plus minus mul div
ArgList ::= пусто
Expr ::= Expr index Expr assign Expr Expr ::= Expr field f assign Expr Expr ::= Expr call s ArgList
Expr ::= arg x assign Expr
Expr Expr Expr Expr
Expr ::= local x Expr ::= arg x Expr ::= Expr index Expr
Правило Expr ::= const c
171
Описание Некоторая константа. Мы не уточняем тип константы, так как для дальнейшего изложения это несущественно. Это может быть целое число, число с плавающей запятой, строка, значение null Локальная переменная с именем x Параметр метода с именем x Доступ к элементу массива. Здесь первое выражение должно возвращать ссылку на массив, а второе – целое число, означающее индекс элемента Доступ к полю f объекта Унарный минус Бинарная арифметическая операция Операция присваивания переменной x значения выражения Операция присваивания параметру x значения выражения Операция присваивания элементу массива значения выражения Операция присваивания полю f объекта значения выражения Вызов экземплярного метода s для некоторого объекта с передачей списка фактических параметров Непустой список фактических параметров метода Пустой список фактических параметров Сложение Вычитание Умножение Деление
Таблица 5.2. Абстрактный синтаксис выражений
Динамическая генерация кода
Вообще говоря, линейные участки кода получаются, главным образом, в процессе генерации кода для выражений. Естественно, выражения бывают разные, и заранее неизвестно, какого именно типа выражения придется генерировать в различных задачах, связанных с динамической генерацией кода. Поэтому мы рассмотрим некоторый тип выражений, наиболее часто встречающийся на практике, и покажем, как для выражений этого типа порождать линейные последовательности инструкций CIL.
5.2.1. Генерация кода для выражений
В этом разделе мы рассмотрим специфику генерации линейных участков кода на языке CIL. Линейными участками мы будем называть участки кода, не содержащие развилок и защищенных блоков.
5.2. Генерация линейных участков кода для стековой машины
Результаты показывают, что динамическая генерация кода может на два порядка уменьшить время работы программы.
172
Время вычисления интеграла функции, мс 29422
547
Время на создание динамической сборки, мс –
Таблица 5.1. Результаты измерений эффективности трех способов вычисления выражений
Способ вычисления значения функции Интерпретация дерева выражения Предварительная компиляция в C# Предварительная компиляция в CIL
170
CIL и системное программирование в Microsoft .NET
5.2.1.2. Отображение абстрактного синтаксиса выражений в CIL Определим набор функций, отображающих различные деревья абстрактного синтаксиса в соответствующие им последовательности инструкций CIL. Будем считать, что каждая функция принимает в качестве параметра дерево абстрактного синтаксиса (оно записывается в квадратных скобках) и возвращает последовательность инструкций (при этом запятые обозначают операцию объединения последовательностей): GenExpr[const c] = нужный вариант инструкции ldc; GenExpr[local x] = ldloc x; GenExpr[arg x] = ldarg x; GenExpr[Expr1 index Expr2] = GenExpr[Expr1], GenExpr[Expr2], ldelem.нужный тип; GenExpr[Expr field f] = GenExpr[Expr], ldfld f; GenExpr[minus Expr] = GenExpr[Expr], neg; GenExpr[Expr1 BinOp Expr2] = GenExpr[Expr1], GenExpr[Expr2], GenBinOp[BinOp]; GenExpr[local x assign Expr] = GenExpr[Expr], dup, stloc x; GenExpr[arg x assign Expr] = GenExpr[Expr], dup, starg x; GenExpr[Expr1 index Expr2 assign Expr3] = GenExpr[Expr1], GenExpr[Expr2], GenExpr[Expr3], dup, stloc временная переменная, stelem.нужный тип, ldloc временная переменная; GenExpr[Expr1 field f assign Expr2] =
172
173
Зачастую бывает удобно разделить фазы генерации и оптимизации кода. Это позволяет существенно упростить генератор, сделать его независимым от оптимизатора и, кроме того, повторно использовать оптимизатор с другими генераторами. Существует большое количество методов оптимизации, но в контексте динамической генерации кода, требующей быстрой работы оптимизатора, не все эти методы применимы. Поэтому мы рассмотрим один из самых простых методов – так называемую peephole-оптимизацию. Суть peephole-оптимизации заключается в том, что оптимизатор ищет в коде метода сравнительно короткую последовательность инструкций, удовлетворяющую некоторому образцу, и заменяет ее более эффективной последовательностью инструкций. Алгоритм peephole-оптимизации использует понятие фрейма. Фрейм можно представить как окошко, двигающееся по коду метода. Содержимое фрейма сравнивается с образцом, и в случае совпадения выполняется преобразование (см. рис. 5.1). Peephole-оптимизация линейного участка кода должна выполняться многократно до тех пор, пока на очередном проходе фрейма по этому участку кода не будет найдено ни одного образца. С другой стороны, алгоритм peephole-оптимизации может быть остановлен в любой момент, что позволяет добиться требуемой скорости работы оптимизатора.
5.2.2. Оптимизация линейных участков кода
GenArgList[Expr ArgList] = GenExpr[Expr], GenArgList[ArgList]; GenArgList[пусто] = ; GenBinOp[plus] = add; GenBinOp[minus] = sub; GenBinOp[mul] = mul; GenBinOp[div] = div;
GenExpr[Expr1], GenExpr[Expr2], dup, stloc временная переменная, stfld f, ldloc временная переменная; GenExpr[Expr call s ArgList] = GenExpr[Expr], GenArgList[ArgList], call(callvirt) s;
Динамическая генерация кода
dup stloc.0 stloc.1
stloc.0
stloc.1
ldloc.0
Рис. 5.1. Peephole-оптимизация
dup
dup
CIL и системное программирование в Microsoft .NET
Генерация кода, содержащего инструкции переходов, представляет некоторую сложность по сравнению с генерацией линейного кода. Дело в том, что появляются переходы вперед по коду, то есть переходы на инструкции, которые еще не были сгенерированы. Общий метод решения этой проблемы заключается в том, что такие инструкции переходов генерируются частично, то есть сначала вместо них в код вставляются заглушки, в которых не прописаны адреса переходов, а затем, когда адрес становится известен, заглушки заменяются на настоящие инструкции переходов. Интересен факт, что генерация развилок существенно упрощается, если в процессе генерации придерживаться определенных требований структурированной парадигмы в программировании. Эти требования заключаются в том, что в генерируемой программе используются только пять структурных конструкций, а именно: последовательность (рис. 5.2a), выбор (рис. 5.2b), множественный выбор (рис. 5.2c), цикл с предусловием (рис. 5.2d) и цикл с постусловием (рис. 5.2e). При этом конструкции могут быть вложены друг в друга.
5.3. Генерация развилок
В таблице 5.3 приведен список некоторых образцов и замен, которые можно использовать для peephole-оптимизации CIL-кода.
174
175
ldc.i4.0 stloc(starg) x dup stloc(starg) x ldloc(ldarg) y add (или любая коммутативная бинарная операция)
pop pop –
pop
Замена dup stloc(starg) x ldloc (ldarg) x dup ckfinite
Логические выражения отличаются от рассмотренных ранее в этой главе арифметических выражений тем, что могут вычисляться не полностью. Например, в выражении (a = 10) and (sin(x) = 0.5) второе равенство имеет смысл вычислять, только если первое равенство истинно (то есть если значение переменной a равно 10). Это означает, что в коде, вычисляющем логические выражения, должны активно использоваться условные переходы.
5.3.1. Генерация кода для логических выражений
Структурные конструкции удобны тем, что имеют ровно один вход и ровно один выход. Этот факт в сочетании с тем, что они вкладываются друг в друга, позволяет использовать для их порождения рекурсивные алгоритмы. В данном разделе мы предложим как раз рекурсивный вариант генерации структурных конструкций.
Образец stloc(starg) x ldloc(ldarg) x ldloc (ldarg) x ldloc (ldarg) x ckfinite ckfinite not(neg) pop add(sub,mul,div,...) pop ldc.i4.0 add(sub) ldloca(ldarga) x initobj int32 stloc(starg) x ldloc(ldarg) y ldloc(ldarg) x add (или любая коммутативная бинарная операция)
Таблица 5.3. Некоторые образцы и замены для peephole-оптимизации CIL-кода
Динамическая генерация кода
b)
c)
Рис. 5.2. Структурные конструкции
a)
d)
e)
CIL и системное программирование в Microsoft .NET
5.3.1.2. Отображение абстрактного синтаксиса логических выражений в CIL Аналогично функциям GenExpr из раздела 5.2.1.2, определим набор функций GenLogExpr, которые отображают деревья абстрактного синтаксиса, соответствующие логическим выражениям, в CIL. Напомним, что каждая функция принимает в качестве параметра дерево абстрактного синтаксиса и возвращает последовательность инструкций: GenLogExpr[Expr] = GenExpr[Expr]; GenLogExpr[LogExpr1 ComparisonOp LogExpr2] = GenLogExpr[LogExpr1], GenLogExpr[LogExpr2], GenComparisonOp[ComparisonOp]; GenLogExpr[LogExpr1 and LogExpr2] =
5.3.1.1. Абстрактный синтаксис логических выражений Будем рассматривать логические выражения, которые содержат арифметические выражения, рассмотренные в разделе 5.2.1, в качестве подвыражений. Пусть также логические выражения содержат операции сравнения (равно, меньше, больше) и логические операции (логическое И, логическое ИЛИ, логическое НЕ). Дополним абстрактный синтаксис выражений, приведенный ранее в данной главе, новым нетерминалом LogExpr. Правила для этого нетерминала приведены в таблице 5.4.
176
177
Применение логического НЕ Равенство Меньше Больше
Применение логического ИЛИ
Применение логического И
Описание Вырожденный случай, когда логическое выражение не содержит ни одной логической операции или операции сравнения Сравнение двух выражений
GenLogExpr[LogExpr1], dup, brfalse LABEL, GenLogExpr[LogExpr2], and, LABEL: ; GenLogExpr[LogExpr1 or LogExpr2] = GenLogExpr[LogExpr1], dup, brtrue LABEL, GenLogExpr[LogExpr2], or, LABEL: ; GenLogExpr[not LogExpr] = GenLogExpr[LogExpr], not; ComparisonOp[equal] = ceq; ComparisonOp[less] = нужный вариант инструкции clt; ComparisonOp[greater] = нужный вариант инструкции cgt;
LogExpr ::= LogExpr ComparisonOp LogExpr LogExpr ::= LogExpr and LogExpr LogExpr ::= LogExpr or LogExpr LogExpr ::= not LogExpr ComparisonOp ::= equal ComparisonOp ::= less ComparisonOp ::= greater
Правило LogExpr ::= Expr
Таблица 5.4. Абстрактный синтаксис логических выражений
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
Непустая последовательность предложений Пустая последовательность предложений
Цикл с постусловием
Цикл с предусловием
Описание Предложение является выражением. Это может быть, например, выражение, содержащее операцию присваивания или вызов метода объекта Выбор с двумя альтернативами
5.3.2.2. Отображение абстрактного синтаксиса управляющих конструкций в CIL Как уже говорилось, структурные управляющие конструкции допускают рекурсивный алгоритм генерации. Поэтому мы можем определить набор функций GenStatement, транслирующих деревья абстрактного синтаксиса в последовательности инструкций.
Statement ::= if LogExpr StatementList else StatementList Statement ::= while LogExpr StatementList Statement ::= do StatementList while LogExpr StatementList ::= Statement StatementList StatementList ::= пусто
Правило Statement ::= Expr
Таблица 5.5. Абстрактный синтаксис управляющих конструкций
5.3.2.1. Абстрактный синтаксис управляющих конструкций В таблице 5.5 приведен абстрактный синтаксис для последовательности, выбора и циклов с предусловием и постусловием. При записи абстрактного синтаксиса используется определенный ранее нетерминал LogExpr для представления условий выбора и циклов.
Воспользовавшись уже отработанной схемой генерации кода, перейдем на уровень выше и рассмотрим генерацию основных структурных управляющих конструкций.
5.3.2. Генерация кода для управляющих конструкций
178
179
5.3.3.1. Удаление избыточных инструкций сохранения значений в переменных Это преобразование уменьшает количество присваиваний. Оно осуществляется только для переменных, адреса которых не используются.
Рассмотрим несколько простых методов оптимизации кода, содержащего развилки, а именно: • удаление избыточных инструкций сохранения значений в переменных; • удаление псевдонимов переменных; • воспроизведение констант; • удаление неиспользуемых переменных. Хороших результатов можно достичь, если применять эти методы в совокупности с peephole-оптимизацией. При этом получаемая цепочка оптимизирующих преобразований должна выполняться над одним и тем же кодом многократно до тех пор, пока не будет достигнута неподвижная точка.
5.3.3. Оптимизация кода, содержащего развилки
GenStatement[Expr] = GenExpr[Expr], pop; GenStatement[if LogExpr StatementList1 else StatementList2] = GenLogExpr[LogExpr], brfalse LABEL1, GenStatementList[StatementList1], br LABEL2, LABEL1: GenStatementList[StatementList2], LABEL2: ; GenStatement[while LogExpr StatementList] = LABEL1: GenLogExpr[LogExpr], brfalse LABEL2, GenStatementList[StatementList], br LABEL1, LABEL2: ; GenStatement[do StatementList while LogExpr] = LABEL: GenStatementList[StatementList], GenLogExpr[LogExpr], brtrue LABEL; GenStatementList[Statement StatementList] = GenStatement[Statement], GenStatementList[StatementList]; GenStatementList[пусто] = ;
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
stloc X
stloc X
ldloc X
Рис. 5.3. Пример графа использования переменной
stloc X
stloc X
Под переменными мы будем понимать как локальные переменные, так и параметры методов. Другими словами, избыточные инструкции stloc и starg удаляются только для переменных, не использующихся в инструкциях ldloca и ldarga. Для обнаружения избыточных инструкций сохранения значений выполняется анализ использования переменной, состоящий из двух фаз: 1. Построение графа использования переменной. 2. Анализ графа использования переменной. Инструкции ldloc(ldarg) X и stloc(starg) X будем называть инструкциями использования переменной X. Мы будем говорить, что в графе потока управления инструкция использования B следует за инструкцией использования A на пути w, если: 1. инструкции A и B используют одну и ту же переменную X; 2. путь w соединяет A и B; 3. путь w не содержит ни одной инструкции использования переменной X, кроме инструкций A и B. Граф использования переменной X – это ориентированный граф, в узлах которого находятся инструкции использования переменной X, а дуги задают отношение следования для этих инструкций. То есть, если инструкция B следует за инструкцией A на каком-либо пути в графе потока управления, то в графе использования переменной X имеется дуга от инструкции A к инструкции B. Анализ графа использования переменной заключается в нахождении таких инструкций stloc(starg), за которыми не следует ни одной инструкции ldloc(ldarg). Эти инструкции являются избыточными и заменяются инструкциями pop. На рисунке 5.3 изображен пример графа использования переменной. Серым цветом обозначены избыточные инструкции stloc.
180
181
5.3.3.3. Воспроизведение констант Это преобразование позволяет избавиться от переменных, имеющих константное значение. Переменная Y имеет константное значение C тогда и только тогда, когда:
Рис. 5.4. Удаление псевдонима Y переменной X
ldloc X
pop
stloc Y
ldloc Y
ldloc X
ldloc X
5.3.3.2. Удаление псевдонимов переменных Это преобразование позволяет избавиться от лишних присваиваний и уменьшает количество локальных переменных. Под переменной будем понимать, опять же, как локальные переменные, так и параметры методов. Переменная Y является псевдонимом переменной X тогда и только тогда, когда: 1. переменная X используется в теле метода только один раз, причем в инструкции ldloc(ldarg) X; 2. за инструкцией ldloc(ldarg) X непосредственно следует инструкция stloc(starg) Y (впрочем, допускается наличие между ними любого количества инструкций dup). Причем инструкция stloc(starg) Y является первым использованием переменной Y (назовем ее инструкцией инициализации переменной Y). Схема удаления псевдонима Y переменной X показана на рис. 5.4. При удалении осуществляются два действия: 1. Инструкция инициализации переменной Y заменяется инструкцией pop. 2. Все использования переменной Y заменяются использованиями переменной X.
Динамическая генерация кода
CIL и системное программирование в Microsoft .NET
pop
ldc C
stloc Y
ldloc Y
Рис. 5.5. Схема воспроизведения константы C, являющейся значением переменной Y
ldc C
ldc C
5.3.3.4. Удаление неиспользуемых переменных Если некоторая переменная не используется в графе метода или встречается только в инструкциях stloc(starg), то она удаляется. При этом все инструкции stloc(starg), использующие эту переменную, заменяются инструкциями pop.
1. первым использованием переменной Y является инструкция stloc(starg) Y (назовем ее инструкцией инициализации переменной Y); 2. инструкция инициализации переменной Y непосредственно следует за инструкцией ldc C (любая инструкция загрузки константы на стек вычислений). Впрочем, допускается наличие между ними любого количества инструкций dup; 3. за исключением инструкции инициализации, переменная Y используется только в инструкциях ldloc(ldarg) Y. Схема воспроизведения константы C, являющейся значением переменной Y, показана на рисунке 5.5. При воспроизведении осуществляются два действия: 1. Инструкция инициализации переменной Y заменяется инструкцией pop. 2. Инструкции ldloc(ldarg) Y заменяются инструкциями ldc C.
182
183
Начиная знакомство с многозадачными системами, необходимо выделить понятия мультипроцессирования и мультипрограммирования. • Мультипроцессирование – использование нескольких процессоров для одновременного выполнения задач. • Мультипрограммирование – одновременное выполнение нескольких задач на одном или нескольких процессорах.
6.1.1. Основные понятия
Для современных операционных систем и для различных систем программирования в современном мире поддержка разработки и реализация многозадачности стала необходимой. При этом на применяемые решения влияет значительное число факторов. Конкретная реализация очень сильно зависит от того, какая вычислительная система и какие аспекты работы этой системы рассматриваются с точки зрения многозадачности. Так, например, в некоторых случаях эффективным способом реализации многозадачности может быть использование асинхронных операций ввода-вывода; в других случаях будет целесообразным использование механизмов передачи сообщений; очень часто применяется обмен данными через область совместно доступной памяти. Поэтому знакомство следует начать с основных сведений о вычислительных системах. Современные операционные системы, как правило, поддерживают несколько различных механизмов многозадачности. Конкретный выбор в конкретной системе зачастую оказывает значительное влияние на правила разработки приложений. Поэтому следующим шагом должно быть знакомство с поддержкой многозадачности операционными системами. Мы рассмотрим в общих чертах реализацию многозадачности в Windows. Однако, главным является не компьютер и не установленная на нем операционная система – всё это делается только для того, чтобы обеспечить эффективное выполнение приложений. Соответственно знакомство с многозадачностью должно завершиться обсуждением средств, которые операционная система предоставляет разработчикам приложений, и некоторым приемам разработки приложений.
6.1. Многозадачность в Windows
Глава 6. Основы многозадачности
Основы многозадачности
CIL и системное программирование в Microsoft .NET
В современных компьютерах одновременно сосуществует несколько различных реализаций мультипроцессирования. Так, например, практически всегда применяются функционально различные устройства – центральный процессор, видеосистема, контроллеры прямого доступа к памяти (по сути, специализированные процессоры), интеллектуальные периферийные устройства и так далее. В большинстве случаев организация одновременной работы функционально различных устройств осуществляется на уровне операционной системы и требует непосредственного доступа к аппаратуре компьютера. Для разработчиков приложений возможность использования такого мультипроцессирования во многих случаях ограничивается применением асинхронных функций ввода-вывода. Кроме того, очень часто используются многопроцессорные вычислительные системы, в которых используется несколько центральных процессоров. Сложилось несколько подходов к созданию таких компьютеров:
Рис. 6.1. Разные подходы к реализации мультипроцессирования
Ассиметричное ультипроцессирование
Симметричное мультипроцессирование
Одинаковые устройства
Различные устройства
Мультипроцессирование
6.1.1.1. Мультипроцессирование Говоря о мультипроцессировании, необходимо выделить ситуации, когда используются различные виды оборудования, например, одновременная работа центрального процессора и графического ускорителя видеокарты; либо когда организуется одновременная работа равноправных устройств, выполняющих сходные задачи. Последний случай (см. рис. 6.1) также предполагает различные подходы – с выделением управляющего и подчиненных устройств (асимметричное мультипроцессирование), либо с использованием полностью равноправных (симметричное мультипроцессирование).
В завершение знакомства укажем некоторые термины, определяющие базовые понятия, которыми оперируют операционные системы.
184
185
ОЗУ
ШИНА
ЦПУ 2
...
ЦПУ N
• при необходимости создания систем с качественно большим числом процессоров прибегают к MPP (Massively Parallel Processors) системам. Для этого используют несколько однопроцессорных или SMP-систем, объединяемых с помощью некоторого коммуникационного оборудования в единую сеть (см. рис. 6.3). При этом может применяться как специализированная высокопроизводительная среда передачи данных, так и обычные сетевые средства – типа Ethernet. В MPP системах оперативная память каждого узла обычно изолирована от других узлов, и для обмена данными требуется специально организованная пересылка данных по сети. Для MPP систем критической становится среда передачи данных; однако в случае мало связанных между собой процессов возможно одновременное использование большого числа процессоров. Число процессоров в MPP системах может измеряться сотнями и тысячами.
Рис. 6.2. SMP компьютер
ЦПУ 1
• наиболее массовыми являются так называемые SMP (Shared Memory Processor или Symmetric MultiProcessor) машины. В таких компьютерах несколько процессоров подключены к общей оперативной памяти и имеют к ней равноправный и конкурентный доступ (см. рис. 6.2). По мере увеличения числа процессоров производительность оперативной памяти и коммутаторов, связывающих процессоры с памятью, становится критически важной. Обычно в SMP используются 2-8 процессоров; реже число процессоров достигает десятков. Взаимодействие одновременно выполняющихся процессов осуществляется посредством использования общей памяти, к которой имеют равноправный доступ все процессоры.
Основы многозадачности
186
Среда передачи данных
SMP 2
SMP N
а
н
и
Ш
ОЗУ
ЦПУ 2
ШИНА
а
н
и
Ш ... ОЗУ
ЦПУ N
Рис. 6.4. NUMA или cc-NUMA система
ОЗУ
ЦПУ 1
а
н
и
Ш
• иногда используют так называемые NUMA и cc-NUMA архитектуры; они являются компромиссом между SMP и MPP системами: оперативная память является общей и разделяемой между всеми процессорами, но при этом память неоднородна по времени доступа. Каждый процессорный узел имеет некоторый объем оперативной памяти, доступ к которой осуществляется максимально быстро; для доступа к памяти другого узла потребуется значительно больше времени (см. рис. 6.4). cc-NUMA отличается от NUMA тем, что в ней на аппаратном уровне решены вопросы когерентности кэш-памяти (cache-coherent) различных процессоров. Формально на NUMA системах могут работать обычные операционные системы, созданные для SMP систем, хотя для обеспечения высокой производительности приходится решать нетипичные для SMP задачи оптимального размещения данных и планирования с учетом неоднородности памяти.
Рис. 6.3. MPP система
SMP 1
...
CIL и системное программирование в Microsoft .NET
187
6.1.1.2. Мультипрограммирование Мультипрограммирование (то есть одновременное выполнение разного кода на одном или нескольких процессорах) возможно и без реального мультипроцессирования. Конечно, при наличии только одного процессора должен существовать некоторый механизм, обеспечивающий переключение процессора между разными выполняемыми потоками. Такой режим разделения процессорного времени позволяет одному процессору обслуживать несколько задач «как бы одновременно»: осуществляя быстрое переключение между разными задачами и выполняя в данный момент времени код только одной задачи, процессор создает иллюзию одновременного выполнения кода разных задач. Более того, даже на многопроцессорных системах при реальной возможности распараллеливания задач по разным процессорам, обычно используют механизм разделения времени на каждом из доступных процессоров. Формально мультипрограммирование предполагает именно разделение процессорного времени, поэтому иногда его противопоставляют мультипроцессированию: реали-
Таким образом, с точки зрения реализации мультипроцессирования, для разработчиков ПО важно иметь представление о том, каковы средства взаимодействия между параллельно работающими ветвями кода – общая память с равноправным или неравноправным доступом, либо некоторая коммуникационная среда с механизмом пересылки данных. Наибольшее распространение получили SMP и MPP системы, соответственно, большинство операционных систем содержат необходимые средства для эффективного управления SMP системами. Для реализации MPP систем, как правило, используются обычные операционные системы на всех узлах и либо обычные сетевые технологии, с применением распространенных стеков протоколов, либо специфичное коммуникационное оборудование со своими собственными драйверами и собственными средствами взаимодействия с приложениями. NUMA системы распространены в меньшей степени, хотя выпускаются серийно. Нормальным при этом является применение массовых операционных систем, рассчитанных на SMP установки, несмотря на то, что это несколько снижает эффективность использования NUMA. Windows содержит встроенные механизмы, необходимые для работы на SMP; также возможна установка этой ОС на cc-NUMA системах (современные версии Windows имеют механизмы поддержки cc-NUMA систем). Специальных, встроенных в ОС средств для исполнения приложений на MPP системах в Windows не предусмотрено. Windows предполагает альтернативное применение MPP систем, построенных на обычных сетях, для реализации web- или файловых серверов с балансировкой нагрузки по узлам кластера.
Основы многозадачности
CIL и системное программирование в Microsoft .NET
зация многозадачности на одном процессоре в противовес использованию многих процессоров. Важно подчеркнуть, что мультипрограммирование предполагает управление одновременно выполняющимися приложениями пользователя, а не вообще всяким кодом. Любая реальная вычислительная система должна предусматривать специальные меры для своевременного обслуживания поступающих прерываний, исключений и остановок. Такое обслуживание должно выполняться независимо от работы приложений пользователя и в большинстве случаев имеет абсолютный приоритет над приложениями, так как задержка в обработке подобных событий чревата возникновением неустранимых сбоев и потерь данных. В результате операционные системы предоставляют некоторый механизм, обслуживающий возникающие прерывания и только в промежутках между прерываниями – приложения пользователя. Более того, поскольку аппаратные прерывания происходят в большинстве случаев асинхронно по отношению к приложениям и по отношению к другим прерываниям, то получается так, что система должна содержать два планировщика или диспетчера – один для прерываний, другой для приложений. Работа диспетчера прерываний здесь не рассматривается, поскольку относится сугубо к ядру операционной системы и практически не затрагивает работу приложений. В мультипрограммировании ключевым местом является способ составления расписания, по которому осуществляется переключение между задачами (планирование), а также механизм, осуществляющий эти переключения. По времени планирования можно выделить статическое и динамическое составление расписания (см. рис. 6.5). При статическом планировании расписание составляется заранее, до запуска приложений, и операционная система в дальнейшем просто выполняет составленное расписание. В случае динамического планирования порядок запуска задач и передачи управления задачам определяется непосредственно во время исполнения. Статическое расписание свойственно системам реального времени, когда необходимо гарантировать заданное время и сроки выполнения необходимых операций. В универсальных операционных системах статическое расписание практически не применяется. Динамическое расписание предполагает составление плана выполнения задач непосредственно во время их выполнения. Выделяют динамическое планирование с использованием квантов времени – когда каждый выполняемой задаче назначают определенной продолжительности квант времени (фиксированной или переменной продолжительности) и планирование с использованием приоритетов – когда задачам назначают специфичные приоритеты и переключение задач осуществляют с учетом этих приоритетов. В реальных операционных системах обычно имеет место какая-либо комбинация этих подходов.
188
Вытесняющая многозадачность
С абсолютными приоритетами
С использованием квантов (постоянных или динамических)
189
Понятия абсолютных и относительных приоритетов связаны с их влиянием на момент переключения с одной задачи на другую: в системах с абсолютными приоритетами такое переключение выполняется, как только в очереди готовых к исполнению задач появляется задача с более высоким приоритетом, чем выполняемая. В системах с относительными приоритетами появление более приоритетной задачи не приводит к немедленному переключению – момент переключения задач будет определяться по каким-либо иным критериям. Выделяют понятия вытесняющей и невытесняющей многозадачности: в случае невытесняющей многозадачности решение о переключении принимает выполняемая в данный момент задача, а в случае вытесняющей многозадачности такое решение принимается операционной системой (или иным арбитром), независимо от работы активной в данный момент задачи. На приведенном графе состояний задачи (см. рис. 6.6) прямая линия от состояния «выполнение» к состоянию «готовность» нарисована пунктиром, чтобы выделить отличие невытесняющей многозадачности от вытесняющей. В случае невытесняющей многозадачности выполняющаяся задача может либо завершиться, либо перейти в состояние «ожидание». И тот, и другой переходы определены логикой работы самой задачи. На
Рис. 6.5. Планирование задач
Невытесняющая многозадачность
С относительными приоритетами
С использованием приоритетов
Динамическое
Статическое
Планирование
Основы многозадачности
Запуск задачи
Выполнение
Готовность
Задача вытеснена из состояния выполнения
графе состояний задачи в случае невытесняющей многозадачности пунктирной линии от состояния «выполнение» к состоянию «готовность» не будет. В случае вытесняющей многозадачности вытеснение осуществляется по решению системы, а не только по инициативе задачи. Для невытесняющей многозадачности характерно, что операционная система передает задаче управление и далее ожидает от нее сигнала, информирующего о возможности переключения на другую задачу; сама по себе операционная система выполняемую задачу не прерывает. Именно поэтому невытесняющая многозадачность рассматривается как многозадачность с относительными приоритетами – пока задача сама не сообщит, что настал подходящий для переключения момент, система не сможет передать управление никакой другой, даже высокоприоритетной, задаче. Невытесняющая многозадачность проста в реализации, особенно на однопроцессорных машинах, и, кроме того, обеспечивает очень малый уровень накладных расходов на реализацию плана. Недостатками являются повышенная сложность разработки приложений и невысокая защищенность системы от некачественных приложений. Характерный пример невытесняющей многозадачности – 16-ти разрядные Windows (включая собственно 16-ти разрядные версии Windows, выполнение 16-ти разрядных приложений в Windows-95, 98, ME и выполнение 16-ти разрядных приложений в рамках одной Windows-машины в
Задача переходит в режим ожидания
Ожидание
Выполнение задачи в некоторых случаях может быть возобновлено немедленно по завершении ожидания
Ожидаемое событие произошло и задача может выполняться
CIL и системное программирование в Microsoft .NET
Рис. 6.6. Граф состояний задачи
Завершение задачи
Задача выбрана для выполнения
190
191
dt=квант Перепланирование по исчерпанию кванта
Так, если моменты перепланирования наступают только вследствие явного вызова функций приложением, мы имеем дело с невытесняющей многозадачностью и относительными приоритетами. Если смена приоритета вызывает перепланирование – значит, это система с абсолютными приоритетами и вытесняющей многозадачностью. Если моменты перепланирования наступают по исчерпанию временных квантов (возможно постоянного размера, а возможно и переменного), то система поддерживает вытесняющую многозадачность с квантованием.
Рис. 6.7. Моменты перепланирования задач
Задача 1
Задача 2
Задача 3
Задача 4
Задача 5
Понижение приоритета Запуск новой задачи задачей 2 Начало операции Вызов функции Повышение приоритета ввода-вывода явно вызывающей задачей 2 задачей 3 препланирование
NT, 2000, XP и 2003). В таких приложениях операционная система не прерывает выполнение текущей задачи до вызова ею функций типа GetMessage или WaitMessage, во время которых Windows осуществляет при необходимости переключение на другую задачу. Вытесняющая многозадачность предполагает наличие некоторого арбитра, принадлежащего обычно операционной системе, который принимает решение о вытеснении текущей выполняемой задачи какой-либо другой, готовой к выполнению, асинхронно с работой текущей задачи. В качестве некоторого обобщения можно выделить понятие «момент перепланирования», когда активируется планировщик задач и принимает решение о том, какую именно задачу в следующий момент времени надо начать выполнять. Принципы, по которым назначаются моменты перепланирования, и критерии, по которым осуществляется выбор задачи, определяют способ реализации многозадачности и его сильные и слабые стороны.
Основы многозадачности
CIL и системное программирование в Microsoft .NET
Большинство современных операционных систем используют комбинированные планировщики, одновременно применяющие квантование с переменной продолжительностью кванта и абсолютные или относительные приоритеты (см. рис. 6.7). Выбор задачи, которая начнет выполняться вследствие срабатывания планировщика, также определяется многими факторами. Среди важнейших – приоритет готовых к исполнению задач. Однако, помимо этого, часто принимают во внимание текущее и предыдущее состояния задачи. Такой подход позволяет реализовать достаточно сложный и тщательно сбалансированный планировщик задач. Очень часто применяют такой прием: назначенный задаче приоритет рассматривается в качестве некоторого «базового», а планировщик операционной системы может в определенных рамках корректировать реальный приоритет в зависимости от истории выполнения задачи. Типичными причинами коррекции приоритета являются: • запуск задачи (для возможно скорейшего начала исполнения); • досрочное освобождение процессора до исчерпания отведенного кванта (велик шанс, что задача и в этот раз так же быстро отдаст управление); • частый вызов операций ввода-вывода (при этом задача чаще находится в ожидании завершения операции, нежели занимает процессорное время); • продолжительное ожидание в очереди (приоритет ожидающей задачи часто постепенно начинают увеличивать); • и многие другие. Работа планировщика существенно усложняется в случае SMP машин, когда необходимо принимать во внимание привязку задач к процессорам (иногда задаче можно назначить конкретный процессор) и то, на каком процессоре задача выполнялась до того (это позволяет эффективнее использовать кэш-память процессора). Реализации Windows NT, 2000, XP, 2003+ предусматривают достаточно развитый и сложный планировщик, учитывающий множество факторов и корректирующий как назначение и длительность отводимых задаче квантов, так и приоритеты задач. При этом планировщик является настраиваемым, и его логика работы несколько отличается в зависимости от настроек системы (некоторые доступны через панель управления) и от назначения системы (работа планировщика различна у серверов и рабочих станций). Важно отметить, что Windows является гибкой системой разделения времени с вытесняющей многозадачностью и не может рассматриваться в качестве системы реального времени. Даже те процессы, которые с точки зрения Windows относятся к классу процессов так называемого «реального времени», на самом деле требованиям, предъявляемым к системам реального времени, не удовлетворяют. Такие процессы получат приоритет-
192
193
6.1.1.3. Базовая терминология В операционных системах сложилось несколько подходов к реализации многозадачности, и, соответственно, принятая в разных операционных системах терминология несколько отличается. Так, обычно разделяют понятия задачи, процесса и потока. При этом понятие задачи является в большей степени историческим, либо очень специфичным. Это понятие сформировалось, когда единицей выделения процессорного времени была сама задача; планировщик же мог только переключать задачи. В современных системах большее распространение получил подход, в котором в рамках одной «задачи» может быть выделено несколько одновременно выполняемых ветвей кода, соответственно термин «задача» заместился терминами «процесс» и «поток». Процесс является объектом планирования адресного пространства и некоторых ресурсов, выделенных задаче. Но при этом процесс не является потребителем процессорного времени и не подлежит планированию. Поток является объектом планирования процессорного времени. Дополнительно к этому с потоком ассоциируют некоторые другие свойства, например, пользователя, – это позволяет потокам даже одного процесса действовать от имени разных пользователей (воплощение) и использовать механизмы ограничения доступа к ресурсам операционной системы. Например, сервер, предоставляющий доступ к каким-либо файлам, может создать поток, обслуживающий конкретного клиента, и воплотить его с правами доступа, назначенными этому клиенту. Однако в данный момент для нас важно, что управление процессорным временем осуществляется применительно к потокам, а управление адресным пространством – применительно к процессам; каждый процесс содержит, как минимум, один поток.
ное распределение процессорного времени и будут обрабатываться планировщиком с учетом их «особого статуса»; однако при этом нельзя гарантировать строгого выполнения временных ограничений. Более того, в силу используемых механизмов управления памятью, нельзя точно предсказать время, необходимое для выполнения той или иной операции. В любой момент времени при самом невинном обращении к какой-либо переменной или функции может потребоваться обработка ошибок доступа, подкачка выгруженных страниц, освобождение памяти и т.д. – то есть действия, время завершения которых предсказать крайне трудно. Фактически можно давать лишь вероятностные прогнозы по времени выполнения той или иной операции. До определенных рамок Windows можно применять в мягких системах реального времени – с достаточно свободными ограничениями, но даже незначительная вероятность превышения временных ограничений иногда просто недопустима.
Основы многозадачности
CIL и системное программирование в Microsoft .NET
В современных полновесных реализациях Windows (Windows 2000, Windows XP, Windows 2003) планировщик ядра выделяет процессорное время потокам. Управление волокнами возложено на приложения пользователя: Windows предоставляет набор функций, с помощью которых приложение может управлять созданными волокнами. Фактически для волокон реализуется невытесняющая многозадачность средствами приложения; с точки зрения операционной системы, все волокна должны быть созданы в рамках потоков (один поток может быть «расщеплен» на множество волокон средствами приложения) и система никак не вмешивается в их планирование.
6.1.2. Реализация в Windows
Принято также деление потоков на потоки ядра и потоки пользователя (эти термины тоже неоднозначны). Потоки ядра в данном контексте являются потоками, для управления которыми предназначен планировщик, принадлежащий ядру операционной системы. Потоки пользователя при этом рассматриваются как потоки, которые управляются планировщиком пользовательского процесса. Строго говоря, потоки пользователя являлись переходным этапом между «задачами» и «процессами»: с точки зрения операционной системы использовались «задачи», которым выделялись и ресурсы, и процессорное время, тогда как разделение «задачи» на «потоки» осуществлялось непосредственно в самом приложении. В Windows для обозначения этих понятий использованы термины process (процесс), thread (поток) и fiber (волокно). Достаточно часто термин «thread» переводится на русский язык как «нить», а не «поток». Термин «fiber» также может переводиться либо как «нить», либо как «волокно». Поток соответствует потоку ядра и планируется ядром операционной системы, а волокно соответствует потоку пользователя и планируется в приложении пользователя. В операционной системе для описания потоков используются объекты двух типов – так называемые дескрипторы и контекст. Дескрипторы содержат информацию, описывающую поток, но не его текущее состояние исполнения. Контекст потока содержит информацию, описывающую непосредственно состояние исполнения потока. Так, например, дескриптор должен содержать переменные окружения, права доступа, назначенные потоку, приоритет, величину кванта и так далее, тогда как контекст должен сохранять информацию о состоянии стека, регистров процессора и т.д. Дескрипторы содержат актуальную в каждый момент информацию, а контекст обновляется в тот момент, когда поток выходит из исполняемого состояния. Из контекста восстанавливается состояние потока при возобновлении исполнения. Пока поток выполняется, содержимое контекста не является актуальным и не соответствует его реальному состоянию.
194
195
Выполнение
Выбран для выполнения
Готовность
Рис. 6.8. Граф состояний потока
Завершение потока
Переключение контекста на выбранный поток
Выбор для выполнения
Запуск потока
Ожидание
Переходное состояние
Стек потока загружен
Ожидаемое событие произошло и поток может выполняться, Поток переходит но его стек был в режим выгружен ожидания
Ожидаемое событие произошло и поток может выполняться
Поток вытеснен
В Windows определен список событий, которые приводят к перепланированию потоков: • создание и завершение потока; • выделенный потоку квант исчерпан; • поток вышел из состояния ожидания; • поток перешел в состояние ожидания; • изменен приоритет потока; • изменена привязка к процессору. В целях уменьшения затрат на планирование потоков несколько изменен граф состояний потока. На рис. 6.6 приведен «классический» вид графа состояний задачи, а на рис. 6.8 – граф состояний потока в Windows. Переход из состояния «готовность» в состояние «выполнение» сделан в два этапа – выбранный к выполнению поток подготавливается к выполнению и переводится в состояние «выбран»; эта подготовка может осуществляться до наступления момента перепланирования, и в нужный момент достаточно просто переключить контекст выполняющегося потока на выбранный. Также в два этапа может происходить переход из состояния «ожидание» в «готовность»: если ожидание было долгим, то стек потока может быть выгружен из оперативной памяти. В этом случае поток переводится в промежуточное состояние до завершения загрузки стека – в списке готовых к выполнению потоков находятся только те, которые можно начать выполнять без лишнего ожидания.
Основы многозадачности
CIL и системное программирование в Microsoft .NET
6.1.2.1. Управление квантованием Квантование потоков осуществляется по тикам системного таймера, продолжительность одного тика составляет обычно 10 или 15 мс, больший по продолжительности тик назначают многопроцессорным машинам. Каждый тик системного таймера соответствует 3 условным единицам; величина кванта может варьироваться от 2 до 12 тиков (от 6 до 36 единиц). Параметр реестра HKLM\SYSTEM\CurrentControlSet\Control\PriorityControl\ Win32PrioritySeparation предназначен для управления квантованием. На
При выборе потока для выполнения учитываются приоритеты потоков (абсолютные приоритеты) – система начинает выполнять код потока с наибольшим приоритетом из числа готовых к исполнению. Процесс выбора потока для выполнения усложняется в случае SMP систем, когда помимо приоритета готового к исполнению потока учитывается, на каком процессоре ранее выполнялся код данного потока. В Windows выделяют понятие «идеального» процессора – им назначается процессор, на котором запускается приложение в первый раз. В дальнейшем система старается выполнять код потока именно на этом процессоре – для SMP систем это решение улучшает использование кэш-памяти, а для NUMA систем позволяет, по большей части, ограничиться использованием оперативной памяти, локальной для данного процессора. Заметим, что диспетчер памяти Windows при выделении памяти для запускаемого процесса старается учитывать доступность памяти для назначенного процессора в случае NUMA системы. В многопроцессорной системе используется либо первый простаивающий процессор, либо, при необходимости вытеснения уже работающего потока, проверяются идеальный процессор, последний использовавшийся и процессор с наибольшим номером. Если на одном из них работает поток с меньшим приоритетом, то последний вытесняется и заменяется новым потоком; в противном случае выполнение потока откладывается (даже если в системе есть процессоры, занятые потоками с меньшим приоритетом). Современные реализации Windows в рамках единого дерева кодов могут быть использованы для различных классов задач – от рабочих станций, обслуживающих преимущественно интерфейс пользователя, до серверных установок на многопроцессорных машинах. Чтобы можно было эффективно использовать одну ОС в столь разных классах систем, планировщик Windows динамически изменяет длительность квантов и приоритеты, назначаемые потокам. Администратор системы может в некоторой степени изменить поведение системы при назначении длительности квантов и приоритетов потоков.
196
197
5
3
2
1
0
1 := длинный квант 2 := короткий квант 0 или 3 := значения по умолчанию (1 для рабочих станций и 2 для серверов)
1 := переменная длительность кванта 2 := фиксированная длительность кванта 0 или 3 := значения по умолчанию (1 для рабочих станций и 2 для серверов)
Динамическое приращение длительности кванта допустимые значения 0, 1 и 2
4
24 36
36 36
12 36
18 18
6 18
12 18
Длинный квант 0 1 2
Короткий квант 0 1 2
Этот параметр может быть изменен с помощью панели управления, однако, лишь в очень ограниченных рамках: «System Properties|Advanced|Performance:Settings|Advanced|Adjust for best performance of:» позволяет выбрать только: «applications» короткие кванты переменной длины, значение 0x26 т.е. 10 01 10 (короткие кванты переменной длительности, 18 ед.).
Значение младших 2-х бит параметра Win32PrioritySeparation Переменная длительность Фиксированная длительность
Таблица 6.1. Длительность кванта
Рис. 6.9. Управление квантованием в Windows (длительность кванта показана в табл. 6.1)
...
HKLM\SYSTEM\CurrentControlSet\Control\PriorityControl\Win32PrioritySeparation
рис. 6.9 дан формат этого параметра для Windows 2000-2003, а в таблице 6.1 приводятся длительности квантов в условных единицах для разных значений полей параметра Win32PrioritySeparation.
Основы многозадачности
CIL и системное программирование в Microsoft .NET
6.1.2.2. Управление приоритетами В Windows выделяется 32 уровня приоритетов. 0 соответствует самому низкому приоритету (с таким приоритетом работает только специальный поток обнуления страниц), 31 – самому высокому. Этот диапазон делится на три части: • Приоритет 0 – соответствует приоритету потока обнуления страниц. • Приоритеты с 1 по 15 – соответствуют динамическим уровням приоритетов. Большинство потоков работают именно в этом диапазоне приоритетов, и Windows может корректировать в некоторых случаях приоритеты потоков из этого диапазона. • Приоритеты с 16 по 31 – соответствуют приоритетам «реального времени». Этот уровень достаточно высок для того, чтобы поток, работающий с таким приоритетом, мог реально помешать нормальной работе других потоков в системе – например, помешать обрабатывать сообщения от клавиатуры и мыши. Windows самостоятельно не корректирует приоритеты этого диапазона. Для некоторого упрощения управления приоритетами в Windows выделяют «классы приоритета» (priority class), которые задают базовый уровень приоритета, и «относительные приоритеты» потоков, которые корректируют указанный базовый уровень. Операционная система предостав-
«background services» длинные кванты фиксированной длины, значение 0x18 т.е. 01 10 00 (длинные кванты фиксированной длительности, 36 ед.). Более тонкая настройка возможна с помощью редактора реестра. Управление длительностью кванта связано с активностью процесса, которая определяется наличием интерфейса пользователя (GUI или консоль) и его активностью. Если процесс находится в фоновом режиме, то длительность назначенного ему кванта соответствует «нулевым» колонкам таблицы 6.1 (выделены серым цветом; т.е. длительности 6 или 24 – для переменной длины кванта или 18 и 36 – для фиксированной). Когда процесс становится активным, то ему назначается продолжительность квантов, исходя из значения двух младших бит параметра Win32PrioritySeparation в соответствии с приведенной таблицей. Еще один случай увеличения длительности кванта – процесс долгое время не получал процессорного времени (это может случиться, если все время есть активные процессы более высокого приоритета). В этой ситуации система раз в 3-4 секунды (в зависимости от продолжительности тика) назначает процессу повышенный приоритет и квант удвоенной длительности. По истечении этого кванта приоритет возвращается к прежнему значению и восстанавливается рекомендуемая длительность кванта.
198
199
ляет набор функций для управления классами и относительными приоритетами потоков. Планировщик операционной системы также может корректировать уровень приоритета (из диапазона 1-15), однако базовый уровень (т.е. класс) не может быть изменен. Такая коррекция приоритета выполняется в случае: • Завершения операции ввода-вывода – в зависимости от устройства, приоритет повышается на 1 – 8 уровней. • По окончании ожидания события или семафора (см. далее) – на один уровень. • При пробуждении GUI потоков – на 2 уровня. • По окончании ожидания потоком активного процесса (определяется по активности интерфейса) – на величину, указанную младшими 2 битами параметра Win32PrioritySeparation (см. управление длительностью кванта). В случае коррекции приоритета по одной из перечисленных причин, повышенный приоритет начинает постепенно снижаться до начального уровня потока – с каждым тиком таймера на один уровень. Еще один случай повышения приоритета (вместе с увеличением длительности кванта) – процесс долгое время не получал процессорного времени. В этой ситуации система раз в 3-4 секунды назначает процессу приоритет, равный 15, и квант удвоенной длительности. По истечении этого кванта приоритет возвращается к прежнему значению и восстанавливается рекомендуемая длительность кванта. Разработчик приложения может изменять класс приоритета, назначенный процессу, и относительный приоритет потоков в процессе в соответствии с приведенной таблицей 6.2. Планировщик при выборе потока для исполнения учитывает итоговый уровень приоритета. В зависимости от настройки планировщика, NORMAL_PRIORITY_CLASS с базовым уровнем приоритета 8 может быть «расщеплен» на два базовых уровня – для потоков активных процессов (базовый уровень 9) и для потоков фоновых процессов (базовый уровень 7). Для класса HIGH_PRIORITY_CLASS относительные приоритеты потока THREAD_PRIORITY_HIGHEST и THREAD_PRIORITY_TIME_CRITICAL дают одинаковое значение приоритета 15.
Основы многозадачности
Таблица 6.2. Соответствие классов приоритета и относительных приоритетов потоков уровням
IDLE 4
BELOW_NORMAL
200
6
Background 7
NORMAL Normal 8
Foreground 9
ABOVE_NORMAL 10
HIGH 13
CIL и системное программирование в Microsoft .NET
TIME_CRITICAL
TIME_CRITICAL
TIME_CRITICAL
TIME_CRITICAL
TIME_CRITICAL
TIME_CRITICAL
TIME_CRITICAL ABOVE_NORMAL NORMAL (13) BELOW_NORMAL LOWEST
HIGHEST ABOVE_NORMAL NORMAL (10) BELOW_NORMAL LOWEST
HIGHEST ABOVE_NORMAL NORMAL (9) BELOW_NORMAL LOWEST
HIGHEST ABOVE_NORMAL NORMAL (8) BELOW_NORMAL LOWEST
HIGHEST ABOVE_NORMAL NORMAL (7) BELOW_NORMAL LOWEST
HIGHEST ABOVE_NORMAL NORMAL (6) BELOW_NORMAL LOWEST
HIGHEST ABOVE_NORMAL NORMAL (4) BELOW_NORMAL LOWEST IDLE IDLE IDLE IDLE IDLE приоритет потока обнуления страниц IDLE IDLE
15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
Не рекомендуется создавать потоки с таким классом приоритета, так как это может повлиять на обработку сообщений от клавиатуры, мыши, сохранение на диске данных кэша и т.д.
Уровни приоритета с 16 по 31 отведены только для класса REALTIME_PRIORITY_CLASS
TIME_CRITICAL 6 5 4 3 HIGHEST ABOVE_NORMAL NORMAL (24) BELOW_NORMAL LOWEST -3 -4 -5 -6 -7 IDLE
31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16
REALTIME 24
Класс приоритета (задает базовый уровень приоритета)
Уровень
201
Как уже отмечалось, современные вычислительные системы содержат один или несколько центральных процессоров и, как правило, несколько специализированных устройств, способных к параллельной работе с центральными процессорами. Операционная система, функционирующая на таком компьютере, должна предоставлять и средства мультипрограммирования, и средства мультипроцессирования, в том числе мультипроцессирования на функционально различных устройствах. Используемые в операционной системе средства реализации многозадачности можно разделить на несколько групп: • Взаимодействие с устройствами. Существуют как специализированные средства взаимодействия, специфичные для конкретного вида устройств (например, для графических устройств), так и относительно универсальные средства, применимые к устройствам разных типов. Наиболее типичным примером являются средства асинхронного вводавывода. • Средства управления потоками и волокнами. Когда процесс запускается, операционная система в нем самостоятельно создает первичный поток, начинающий исполнение кода этого процесса. Создание всех остальных потоков процесса требует специальных действий. Операционная система должна предоставлять средства для создания потоков, волокон, их завершения, приостановки или возобновления, изменения их характеристик (например, приоритетов, прав доступа и т.д.). В Windows существует очень интересный гибрид средств управления потоками и асинхронного ввода-вывода под названием «порт завершения ввода-вывода». Несмотря на такое название, это, по большей части, именно специализированный механизм управления потоками. • Взаимодействие потоков в рамках одного процесса. Потоки, работающие в рамках одного процесса, имеют возможность взаимодействовать друг с другом, используя общее адресное пространство процесса. Это взаимодействие может, с одной стороны, приводить к конфликтам одновременного доступа (нужны средства разрешения конфликтов) и, с другой стороны, требовать средств изоляции некоторых данных одного потока от данных другого (нужны механизмы организации памяти, локальной для потока).
6.2. Общие подходы к реализации приложений с параллельным выполнением операций
Основы многозадачности
• Взаимодействие между процессами одного компьютера. Чуть более сложный случай – необходимость взаимодействия различных процессов в рамках одной вычислительной системы. Особенность этого случая связана с тем, что разные процессы работают в изолированных друг от друга адресных пространствах, однако при этом существует возможность осуществить обмен данными через общую, разделяемую несколькими процессами, память. Диспетчер памяти операционной системы должен предусмотреть средства организации разделяемой между процессами памяти. Кроме того, многие операционные системы предоставляют дополнительные средства межпроцессного взаимодействия; многие из них являются надстройками над средствами работы с разделяемой памятью. В данной книге рассматриваются базовые средства для организации разделяемой памяти, так как большая часть остальных средств либо является надстройкой над ними (например, обмен оконными сообщениями), либо использует обмен данными через файловые объекты (к примеру, почтовые ящики, каналы и пр.). • Взаимодействие между процессами разных компьютеров. В общем случае память разных компьютеров можно рассматривать как изолированную друг от друга. В этом случае для взаимодействия разных процессов потребуется пересылка сообщений, содержащих данные и некоторую управляющую информацию, между разными компьютерами (узлами сети). Операционная система должна предоставить некоторый базовый набор функций по пересылке данных по коммуникационной сети и, зачастую, богатый набор различных средств, работающих «поверх» этого базового уровня. В данной книге межузловое взаимодействие не рассматривается.
CIL и системное программирование в Microsoft .NET
#include <stdio.h> int sequential_io( char *filename, char *buffer, int size ) { FILE *fp; int done;
Обычные операции ввода-вывода происходят в синхронном режиме. Например, в приведенном ниже примере все операции выполняются строго последовательно: файл открывается, вызывается операция чтения данных, и только после того как все данные прочитаны, продолжается выполнение задачи:
6.2.1. Асинхронный ввод-вывод
202
203
} Такой подход прост в реализации, как с точки зрения операционной системы, так и с точки зрения пользователя («пользователем» операционной системы в данном случае выступает разработчик). Однако во время выполнения операций ввода-вывода сама программа не выполняется – она ожидает завершения ввода-вывода. Во многих случаях такие операции, во-первых, занимают достаточно много времени и, во-вторых, выполняются специализированным оборудованием без участия центрального процессора, который в это время находится в состоянии ожидания и не выполняет никакой полезной работы (по крайней мере, с точки зрения данной программы, так как в общем случае начало ожидания приводит к перепланировке потоков). Эффективность использования процессора можно было бы повысить, если бы существовала возможность выполнять код программы во время выполнения операций ввода-вывода. Конечно, это не всегда возможно или целесообразно. Например, если для продолжения работы программы необходимы данные, которые еще не получены, то нам все равно надо ожидать завершения ввода-вывода. Более того, структура приложения должна быть разработана с учетом специфики использования асинхронных операций ввода-вывода. В Windows для реализации асинхронного ввода-вывода предусмотрены функции типа ReadFile, WriteFile, ReadFileEx, WriteFileEx и др. и специальная структура OVERLAPPED, которая используется для взаимодействия с асинхронной операцией. Асинхронные операции применяются следующим образом: перед началом операции заполняется структура OVERLAPPED и вызывается нужная функция для выполнения ввода-вывода, которая ставит операцию в очередь и немедленно возвращает управление вызвавшей программе. После этого программа продолжает свою работу независимо от хода выполнения операции ввода-вывода. При необходимости можно выяснить состояние асинхронной операции, дождаться ее завершения или отменить ее, не дожидаясь завершения. Для этого предназначен специальный набор функций, например, CancelIo, GetOverlappedResult,
fp = fopen( filename, “rb” ); if ( fp ) { done = fread( fp, 1, size, buffer ); /* код не будет выполняться, пока чтение не завершится*/ fclose( fp ); } else { done = 0; } buffer[done] = '\0'; return done;
Основы многозадачности
CIL и системное программирование в Microsoft .NET
ov.Offset = 12345; if ( WriteFile( fh, buffer, sizeof(buffer), &dwWritten, &ov ) || GetLastError() == ERROR_IO_PENDING ) { /* пока операция ввода-вывода выполняется, выполняем некоторые операции. дожидаемся завершения операции ввода-вывода */ while (!GetOverlappedResult(fh, &ov, &dwWritten, FALSE)){} } else { /* возникла ошибка */ } Функция GetOverlappedResult в данном случае проверяет состояние операции ввода-вывода и возвращает признак ее завершения. Опрос состояния операции в цикле позволяет наиболее быстро
1. Выполнение асинхронных операций с опросом состояния; этот способ может обеспечить самую быструю реакцию на завершение операции ввода-вывода, но ценой более высокой загрузки процессора:
ZeroMemory( &ov, sizeof(OVERLAPPED) ); FillMemory( buffer, sizeof(buffer), 123 );
HANDLE fh = CreateFile( “file.dat”, FILE_READ_DATA|FILE_WRITE_DATA, FILE_SHARE_READ, (LPSECURITY_ATTRIBUTES)NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL|FILE_FLAG_OVERLAPPED, NULL ); if ( fh == INVALID_HANDLE_VALUE ) { /* возникла ошибка */ }
OVERLAPPED ov; DWORD dwWritten; BYTE buffer[ 5000000 ];
HasOverlappedIoCompleted и некоторые другие. Существует несколько вариантов использования асинхронного ввода-вывода; рассмотрим их на небольшом примере. Для начала надо описать необходимые переменные и открыть файл с разрешением асинхронных операций (FILE_FLAG_OVERLAPPED):
204
205
if ( WriteFile( fh, buffer, sizeof(buffer), &dwWritten, &ov ) || GetLastError() == ERROR_IO_PENDING ) { /* пока операция ввода-вывода выполняется, выполняем некоторые операции... дожидаемся завершения операции ввода-вывода */ GetOverlappedResult( fh, &ov, &dwWritten, TRUE ); } else { /* возникла ошибка */ } Функция GetOverlappedResult в данном случае проверяет состояние операции и, если она еще не завершена, вызывает функцию WaitForSingleObject для ожидания завершения операции ввода-вывода. Объект «событие» можно не создавать – в этом случае функция будет ожидать освобождения объекта «файл»; однако в случае нескольких, накладывающихся друг на друга, асинхронных операций будет непонятно, какая именно операция завершилась, и использование специфичных для каждой операции событий снимает эту проблему. Ожидание на объектах ядра является наиболее экономным, но реакция приложения на завершение ввода-вывода связана с работой планировщика, поэтому для достижения малых задержек иногда надо дополнительно повышать приоритеты потоков, переходящих в режим ожидания завершения ввода-вывода. 3. Выполнение асинхронных операций с использованием функций завершения операций ввода-вывода. Эта функция будет автоматически вызвана после завершения ввода-вывода и может
ov.Offset = 12345; ov.hEvent = CreateEvent((LPSECURITY_ATTRIBUTES)NULL, TRUE, FALSE, 0);
отреагировать на завершение операции (особенно на многопроцессорных машинах), однако ценой увеличения загрузки процессора, что может снизить общую производительность системы. Функция WriteFile возвращает значение TRUE, если операция записи завершена синхронно, а в случае ошибки или начатой асинхронной операции она возвращает FALSE, поэтому требуется анализ кода возникшей «ошибки», которая может и не являться ошибкой. 2. Выполнение асинхронных операций с ожиданием на объектах ядра:
Основы многозадачности
выполнить некоторые специальные действия. Процедура завершения обязательно вызывается в контексте того потока, который вызвал операцию ввода-вывода, – а для этого необходимо, чтобы поток был приостановлен, так как операционная система не должна прерывать работу активного потока. Следует перевести поток в состояние ожидания оповещения (alertable waiting) – при этом он не выполняется и может быть прерван для обработки процедуры завершения:
CIL и системное программирование в Microsoft .NET
ov.Offset = 12345; if ( WriteFileEx( fh, buffer, sizeof(buffer), &ov, io_done ) ) { /* пока операция ввода-вывода выполняется, выполняем некоторые операции и переходим в режим ожидания оповещения, например, так: */ if ( SleepEx( INFINITE, TRUE ) != WAIT_IO_COMPLETION ) { /* ввод-вывод пока не завершен, возможно, ошибка */ } } else { /* возникла ошибка */ } /* нам еще надо предоставить собственную процедуру завершения ввода-вывода. В простейшем варианте она может ничего не делать: */ VOID CALLBACK io_done( DWORD dwErr, DWORD dwWritten, LPOVERLAPPED lpOv ) { ... } Этот подход наиболее трудоемок и наименее распространен; на практике самым эффективным является механизм выполнения асинхронных операций с ожиданием на объектах ядра. Однако механизм вызова функций завершения (расширенный возможностью автоматического выбора потока, осуществляющего обработку функции завершения) послужил основой для реализации одного из очень эффективных механизмов взаимодействия потоков – порта завершения ввода-вывода. При использовании асинхронного ввода-вывода необходимо очень внимательно следить за выделением и освобождением ресурсов – особенно памяти, занятой структурами OVERLAPPED, и буферами, участвующими в операциях ввода-вывода. Асинхронный ввод-вывод является примером мультипроцессирования с использованием функционально различных устройств.
206
207
Обсуждение реализации мультипрограммирования в операционной системе неизбежно приводит к обсуждению вопросов безопасности. В мультипрограммной среде может одновременно выполняться значительное количество процессов, представляющих разных пользователей, и
6.2.3. Процессы, потоки и объекты ядра
VOID CALLBACK ApcProc( ULONG_PTR dwData ) { /* ... */ } int main( void ) { QueueUserAPC( ApcProc, GetCurrentThread(), 0 ); /* ... */ SleepEx( 1000, TRUE ); return 0; } Обычно APC используются самой системой для реализации асинхронного ввода-вывода и некоторых других операций, но разработчикам также предоставлена функция QueueUserAPC, с помощью которой можно ставить в APC очередь запросы для вызова собственных функций.
Для реализации асинхронного ввода-вывода в операционной системе предусмотрен специальный механизм, основанный на так называемых асинхронных вызовах процедур (Аsynchronous Procedure Call, APC). Это один из базовых механизмов, необходимый для нормального функционирования операционной системы. Практика показала, что такой механизм был бы эффективен и для реализации самих приложений. Более того, для реализации асинхронного ввода-вывода с поддержкой функции завершения система уже обязана была предоставить этот механизм. Для реализации этого механизма операционная система ведет списки процедур, которые она должна вызывать в контексте данного потока, с тем ограничением, что прерывать работу занятого потока в произвольный момент времени система не должна. Поэтому для обслуживания накопившихся в очереди процедур необходимо перевести поток в специальное состояние ожидания оповещения (alertable waiting) – для этого Win32 API предусматривает специальный набор функций: например, SleepEx, WaitForSingleObjectEx и др. Здесь и во многих примерах далее, чтобы не загромождать код примера, опущена проверка ошибок:
6.2.2. Асинхронные вызовы процедур
Основы многозадачности
CIL и системное программирование в Microsoft .NET
6.2.3.1. Объекты ядра Объекты ядра представлены в качестве некоторых структур и типов данных, управляемых ядром операционной системы и размещенных в памяти, принадлежащей ядру. Пользовательские процессы, как правило, не имеют возможности доступа к этим объектам напрямую. Для того чтобы пользовательский процесс мог оперировать такими объектами, введено понятие описатель (handle) объекта ядра. Так, например, объектами ядра являются файлы, процессы, потоки, события, почтовые ящики и многое другое. Все созданные описатели объектов ядра должны удаляться с помощью функции BOOL CloseHandle( HANDLE hKernelObject ), которая уменьшает счетчик использования объекта и уничтожает его, когда на объект никто больше не ссылается. Доступ к защищаемым объектам в Windows задается так называемыми дескрипторами безопасности (Security Descriptor). Дескриптор содержит информацию о владельце объекта и первичной группе пользователей и два списка управления доступом (ACL, Access Control List): один список задает разрешения доступа, другой – необходимость аудита при доступе к объекту. Список содержит записи, указывающие права выполнения действий, и запреты, назначенные конкретным пользователям и группам. При доступе к защищаемым объектам для начала проверяются запреты – если для данного пользователя и группы имеется запрет доступа, то дальнейшая проверка не выполняется и попытка доступа отклоняется. Если запретов нет, то проверяются права доступа – при отсутствии разрешений доступ отклоняется. Запрет обладает более высоким «приоритетом», чем наличие разрешений – это позволяет разрешить доступ, к примеру, целой группе пользователей и выборочно запретить некоторым ее членам. Объект, осуществляющий доступ (выполняющийся поток), обладает так называемым маркером доступа (access token). Маркер идентифицирует пользователя, от имени которого предпринимается попытка доступа, а также его привилегии и умолчания (например, стандартный ACL объектов, создаваемых этим пользователем). В Windows маркерами доступа обладают как потоки, так и процессы. С процессом связан так называемый первичный маркер доступа, который используется при созда-
ядро операционной системы вынуждено проверять полномочия каждого процесса при их попытках доступа к защищаемым объектам. Для того чтобы ядро операционной системы могло контролировать доступ к тем или иным объектам, сами объекты должны управляться ядром системы. Это приводит к понятию объектов ядра (kernel objects), которые создаются по запросу процессов ядром системы, управляются ядром, и доступ к которым также контролируется ядром системы.
208
209
нии потоков, а вот в дальнейшем поток может работать от имени какоголибо иного пользователя, используя собственный маркер воплощения (impersonation). Процессы и потоки в Windows являются с одной стороны «представителями» пользователя, выступающими от его имени, а с другой стороны – защищаемыми объектами, при доступе к которым выполняется проверка прав, то есть они обладают одновременно и маркерами доступа, и дескрипторами безопасности. Описатели объектов ядра в Windows позволяют разным процессам и потокам взаимодействовать с объектами с учетом их контекстов безопасности, располагаемых прав и требуемого режима доступа. Примерами защищаемых объектов являются процессы, потоки, файлы, большинство синхронизирующих примитивов (события, семафоры и т.д.), проекции файлов в память и многое другое. Для создания большинства объектов ядра используются функции, начинающиеся на слово «Create» и возвращающие описатель созданного объекта, например функции CreateFile, CreateProcess, CreateEvent и т.д. Многие объекты при их создании могут получить собственное имя или остаться неименованными. Любой процесс или поток может ссылаться на объекты ядра, созданные другим процессом или потоком. Для этого предусмотрено три механизма: • Объекты могут быть унаследованы дочерним процессом при его создании. В этом случае объекты ядра должны быть «наследуемыми», и родительский процесс должен принять меры к тому, чтобы потомок мог узнать их описатели. Возможность передавать описатель потомкам по наследованию явно указывается в большинстве функций, так или иначе создающих объекты ядра (обычно такие функции содержат аргумент «BOOL bInheritHandle», который указывает возможность наследования). • Объект может иметь собственное уникальное имя – тогда можно получить описатель этого объекта по его имени. Для разных типов объектов Win32 API предоставляет набор функций, начинающийся на Open...например, OpenMutex, OpenEvent и т.д. • Процесс-владелец объекта может передать его описатель любому другому процессу. Для этого процесс-владелец объекта должен получить специальный описатель объекта для «экспорта» в указанный процесс. В Win32 API для этого предназначена функция DuplicateHandle, создающая для объекта, заданного описателем в контексте данного процесса, новый описатель, корректный в контексте нового процесса:
Основы многозадачности
CIL и системное программирование в Microsoft .NET
6.2.3.2. Описатели процесса и потока Для взаимодействия потоков и процессов между собой необходимы средства, обеспечивающие идентификацию соответствующих объектов. В Windows для идентификации процессов и потоков используют их описатели (HANDLE) и идентификаторы (DWORD). Описатели идентифицируют в данном случае объект ядра, представляющий процесс или поток, при доступе к которому, как ко всякому объекту ядра, учитывается контекст защиты, проверяются права доступа и т.д. Идентификаторы процесса и потока, назначаемые при их создании, исполняют роль уникальных имен. Описатели и идентификаторы процессов и потоков можно получить при создании соответствующих объектов. Кроме того, можно узнать идентификаторы текущего процесса и потока (GetCurrentThreadId, GetCurrentProcessId), или по описателю узнать соответствующий идентификатор (GetProcessId и GetThreadId). Функции OpenProcess и OpenThread позволяют получить описатели этих объектов по их идентификатору. Функции GetCurrentProcess и GetCurrentThread возвращают описатели текущего процесса и потока, однако возвращаемое ими значение не является настоящим описателем, а представлено некоторой константой, получившей название «псевдоописатель». Эта константа, использованная вместо описателя потока или процесса, рассматривается как описатель процесса/потока, сделавшего вызов системной функции. Псевдоописателями можно свободно пользоваться в рамках процесса (потока), в котором они получены, а при попытке передать их другому процессу или потоку они будут рассматриваться как описатели того процесса (потока), в контексте которого используются. В тех случаях, когда необходимо дать другому процессу или потоку доступ к данным описателям, нужно с помощью DuplicateHandle сделать с них «копии», которые будут являться настоящими описателями в контексте процесса-получателя. Так, например, с помощью этой функции мож-
BOOL DuplicateHandle( HANDLE hFromProcess, HANDLE hSourceHandle, HANDLE hToProcess, LPHANDLE lpResultHandle, DWORD dwDesiredAccess, BOOL bInheritHandle, DWORD dwOptions ); Существенно проследить, чтобы все создаваемые описатели закрывались вызовом функции CloseHandle, включая описатели, созданные функцией DuplicateHandle. Хорошая практика при разработке приложений – проводить мониторинг выделяемых описателей и количества объектов в процессе (например, с помощью таких стандартных средств как менеджер задач или оснастка «производительность» панели управления).
210
211
У процессов и потоков есть интересная особенность – объекты ядра, представляющие процесс и поток, сразу после создания имеют счетчик использования не менее двух: во-первых, это описатель, возвращенный функцией, и, во-вторых, объект используется работающим потоком. В итоге завершение потока и завершение последнего потока в процессе не приводят к удалению соответствующих объектов – они будут сохраняться все время, пока существуют их описатели. Это сделано для того, чтобы уже после завершения работы потока или процесса можно было получить от него какую-либо информацию, чаще всего – код завершения (функции GetExitCodeThread и GetExitCodeProcess); Объекты ядра «процесс» и «поток» поддерживают также интерфейс синхронизируемых объектов, так что их можно использовать для синхронизации работы: поток считается занятым до завершения, а процесс занят до тех пор, пока в нем есть хоть один работающий поток. Если ни синхронизация с этими объектами, ни получение кодов завершения не требуются разработчику, надо сразу после создания соответствующего объекта закрывать его описатель. В Windows для задания приоритета работающего потока используют понятия классов приоритетов и относительных приоритетов в классе. При этом класс приоритета связывается с процессом, а относительный приоритет – с потоком, исполняющимся в данном процессе. Соответственно Win32 API предоставляет функции для изменения класса приоритета для процесса (GetPriorityClass, SetPriorityClass) и для изменения относительного приоритета потока (GetThreadPriority и SetThreadPriority). Планировщик операционной системы может динамически корректировать приоритет потока, кратковременно повышая уровень. Разработчикам предоставлена возможность отказаться от этой возможности или, наоборот, задействовать ее (функции GetProcessPriorityBoost, SetProcessPriorityBoost, GetThreadPriorityBoost и SetThreadPriorityBoost).
HANDLE hrealThread; DuplicateHandle( GetCurrentProcess(), GetCurrentProcess(), GetCurrentProcess(), &hrealThread, DUPLICATE_SAME_ACCESS, FALSE, 0 );
но превратить псевдоописатель процесса в настоящий описатель, действующий только в текущем процессе:
Основы многозадачности
CIL и системное программирование в Microsoft .NET
6.2.4.1. Потоко-безопасные и небезопасные функции При реализации многопоточного приложения следует учитывать возможные побочные эффекты. Наличие таких эффектов обусловлено реализацией библиотеки времени исполнения: она содержит много функций (в том числе внутренних, обеспечивающих семантику языка программирования), являющихся потоко-небезопасными. Примеры таких функций – стандартная процедура strtok, операторы new и delete или функции malloc, calloc, free и так далее. Фактически любая стандартная функция, оперирующая статическими переменными, объектами или данными, может являться потоко-небезопасной в силу того, что два потока могут получить одновременный конкурирующий доступ к этим данным и в итоге разрушить их. Существует несколько подходов к решению этой проблемы: • Можно предоставить потоко-безопасные аналоги (например, strtok_r, являющийся в Linux потоко-безопасным аналогом функции strtok). • Можно переписать код всех потоко-небезопасных функций так, чтобы они вместо глобальных объектов использовали локальную для потока память или синхронизировали доступ к общим данным. В Windows принят второй подход, однако, с некоторой оговоркой. Потоко-безопасные версии функций более ресурсо- и время- емкие, чем обычные. В итоге используется два вида библиотек: один для однопоточных приложений, другой для многопоточных. Следует отметить, что выбор той или иной библиотеки определяется, как правило, свойствами проекта (параметрами компилятора), а вовсе не кодом приложения. Поэтому при разработке многопотокового приложения важно проследить, чтобы при компиляции использовалась правильная версия библиотеки, во избежание возникновения трудно диагностируемых ошибок, проявляющихся в самых разных и совершенно «невинных» на первый взгляд местах кода. В случае Visual Studio однопоточные версии библиотек выбираются ключами /ML или /MLd компилятора, а многопоточные ключами /MT, /MD, /MTd
Для реализации мультипрограммирования или мультипроцессирования на однотипных устройствах можно применять процессы, потоки и волокна. Разница между ними связана с возможностью обмена данными: адресное пространство процессов изолировано друг от друга, поэтому взаимодействие затруднено; потоки находятся в общем адресном пространстве процесса и могут легко взаимодействовать друг с другом; волокна отчасти аналогичны потокам, но планирование волокон выполняется непосредственно приложением, а не операционной системой.
6.2.4. Основы использования потоков и волокон
212
213
int main( void ) { HANDLE hThread; unsigned dwThread; /* создаем новый поток */ hThread = (HANDLE)_beginthreadex (NULL, 0, ThreadProc, new int [128], 0, &dwThread ); /* код в этом месте может выполняться одновременно с кодом функции потока ThreadProc, планирование потоков осуществляется системой */ /* дождаться завершения созданного потока */ WaitForSingleObject( hThread, INFINITE ); CloseHandle( hThread ); return 0; }
unsigned __stdcall ThreadProc( void *param ) { /* вновь созданный поток будет выполнять эту функцию */ Sleep( 1000 ); delete[] (int*)param; return 0; /* завершение функции = завершение потока */ }
6.2.4.2. Работа с потоками Наличие специальных потоко-безопасных версий библиотек требует использования специальных функций для создания и завершения потоков, принадлежащих не системному API, а библиотеке времени исполнения. Так, вместо функций Win32 API CreateThread, ExitThread необходимо использовать библиотечные функции _beginthread, _endthread или _beginthreadex, _endthreadex. Это требование связано с тем, что при создании нового потока необходимо, помимо выполнения определенных действий по созданию потока со стороны операционной системы, инициализировать специфичные структуры данных, обслуживающих потоко-безопасные версии функций библиотеки времени исполнения:
или /MDd (свойства проекта Configuration Properties|C/C++|Code Generation|Runtime Library).
Основы многозадачности
CIL и системное программирование в Microsoft .NET
В данном примере можно было бы создавать поток не вызовом функции _beginthreadex (или _beginthread), а вызовом функции API CreateThread. Но при незначительном усложнении примера, скажем, создании не одного, а двух потоков, уже было бы возможно возникновение ошибки при одновременном обращении к операторам new или delete в разных потоках (причем именно «возможно», так как ничтожные временные задержки могут изменить поведение потоков – это крайне осложняет выявление таких ошибок). Применение функций библиотеки времени исполнения для создания потоков решает эту проблему. Windows содержит достаточно богатый набор функций для управления потоками, включающий функции создания и завершения потоков (функции API CreateThread, ExitThread, TerminateThread и их «обертки» в библиотеке времени исполнения _beginthread, _endthread, _beginthreadex и _endthreadex). Функция Sleep(DWORD dwMilliseconds) может переводить поток в «спячку» на заданное время. Продолжительность задается с точностью до кванта работы планировщика, то есть не лучше, чем 10-15 мс, несмотря на то, что при вызове функции задать можно до 1 мс. Измерение времени реальной паузы, заданной, например, вызовом Sleep(1), позволяет получить косвенную информацию о работе планировщика. В Windows существует интересная особенность, связанная с работой планировщика и измерением интервалов времени. Система предоставляет три способа измерения интервалов: • таймер низкого разрешения, основанный на квантах планировщика (GetTickCount); • «мультимедийный», с разрешением до 1 мс (timeGetTime, timeBeginPeriod и пр.); • высокоточный, использующий счетчик тактов процессора и с разрешением ощутимо лучше микросекунды на современных процессорах (QueryPerformanceCounter, QueryPerformanceFrequency). Обычно мультимедийный таймер работает с разрешением от 1-5 мс и хуже (зависит от аппаратуры), однако функция timeBeginPeriod позволяет изменить разрешение вплоть до 1 мс. Если стандартное разрешение мультимедийного таймера на данном компьютере хуже 5-10 мс, то у функции timeBeginPeriod есть побочный эффект – улучшение разрешения повлияет на работу планировщика во всей системе, а не только в процессе, вызвавшем эту функцию. В результате, если один процесс повысит разрешение мультимедийного таймера, то функция Sleep также получит возможность задавать интервалы вплоть до 1 мс и эффект наблюдается даже в других процессах. Если мультимедийный таймер на данной аппаратуре стандартно работает с разрешением порядка 1 мс, то такого влияния на планировщик не наблюдается.
214
215
6.2.4.3. Работа с волокнами Работа с волокнами в приложении в чем-то сложнее, в чем-то проще. Сложнее, потому что необходимо реализовать собственный планировщик волокон. Сложность разработки планировщика резко возрастает при необходимости синхронизации волокон – стандартные средства синхронизации Windows переводят в режим ожидания поток целиком, даже если он должен планировать множество волокон. Проще, потому что все волокна могут разделять один поток – в этом случае легко избежать проблем конкурирующего доступа к данным и можно применять любую библиотеку времени исполнения, в том числе потоко-небезопасную. При работе с волокнами используется функция ConvertThreadToFiber для предварительного создания необходимых операционной системе структур данных. Функция ConvertFiberToThread выполняет обратную задачу и уничтожает выделенные данные. После того как необходимые структуры созданы (поток «превращен» в волокно), появляется возможность создавать новые волокна (CreateFiber), удалять существующие (DeleteFiber) и планировать их исполнение (SwitchToFiber). Приведем пример применения двух рабочих волокон, выполняющих целевую функцию, и одного управляющего, удаляющего рабочие волокна по их завершении. Функция main превращает текущий поток в волокно (инициализация внутренних структур данных для работы с волокнами), затем создает рабочие волокна и организует цикл, в котором ожидает их завершения и удаляет. Цикл завершается тогда, когда все рабочие волокна удалены, после чего функция main принимает меры к корректному завершению работы с волокнами. Собственно целевая функция FiberProc эпизодически вызывает функцию SwitchToFiber для переключения выполняемого волокна. В данном примере для определения нового волокна, подлежащего исполнению, реализован простейший планировщик (функция schedule, инкапсулирующая вызов функции SwitchToFiber).
Есть частный случай применения функции Sleep – при задании интервала 0 вызов функции просто приводит к срабатыванию планировщика и, при наличии других готовых потоков, к их активации. Аналогичного эффекта можно добиться, применяя функцию SwitchToThread, вызывающую перепланирование потоков. Поток может быть создан в приостановленном (suspended) состоянии с помощью задания специального флага CREATE_SUSPENDED при вызове функций _beginthreadex или CreateThread, а также переведен в это состояние (функция SuspendThread) или, наоборот, пробужден с помощью функции ResumeThread.
Основы многозадачности
216
fiberEnd; fiberCtl; fiber[ FIBERS ];
int main( void ) { int i; fiberCtl = ConvertThreadToFiber( NULL ); fiberEnd = NULL; for ( i = 0; i < FIBERS; i++ ) { fiber[i] = CreateFiber( 10000, FiberProc, NULL ); }
VOID CALLBACK FiberProc( PVOID lpParameter ) { /* волокно будет выполнять код этой функции */ int i; for ( i = 0; i < 100; i++ ) { Sleep( 1000 ); shedule( TRUE ); /* выполнение продолжается */ } shedule( FALSE ); /* волокно завершается */ }
static void shedule( BOOL fDontEnd ) { int n, current; if ( !fDontEnd ) { /* волокно надо завершить */ fiberEnd = GetCurrentFiber(); SwitchToFiber(fiberCtl ); } /* выбираем следующее волокно для выполнения */ for ( n = 0; n < FIBERS; n++ ) { if ( fiber[n] && fiber[n] != GetCurrentFiber() ) break; } if ( n >= FIBERS ) return; /* нет других готовых волокон*/ SwitchToFiber( fiber[n] ); }
static LPVOID static LPVOID static LPVOID
#define FIBERS 2
#define _WIN32_WINNT 0x0400 #include <windows.h>
CIL и системное программирование в Microsoft .NET
217
Следует еще раз отметить одну важную особенность волокон – они работают в рамках одного потока и не позволяют задействовать возможности мультипроцессирования. Если нужно обеспечить параллельное исполнение кода на разных процессорах, то надо применять потоки либо даже отдельные процессы. В частных случаях возможно создание гибридных вариантов – когда несколько потоков выполняют несколько волокон; при этом число волокон может существенно превышать число потоков. Однако и в этом случае целесообразность применения волокон должна быть тщательно изучена: очень часто эффективнее не выполнять одновременно много мелких заданий, а выполнять их поочередно – тогда уменьшатся затраты на переключения и, возможно, возрастет утилизация кэша. Волокна представляют, по большей части, теоретический интерес, как возможность реализовать планировщик пользовательского режима, помимо существующего стандартного планировщика режима ядра.
}
for ( i = 0; i < FIBERS;) { SwitchToFiber( fiber[i] ); if ( fiberEnd ) { DeleteFiber(fiberEnd ); for ( i = 0; i < FIBERS; i++ ) { if ( fiber[i] == fiberEnd ) fiber[i] = NULL; } fiberEnd = NULL; } for ( i = 0; i < FIBERS; i++ ) if ( fiber[i] ) break; } ConvertFiberToThread(); return 0;
Основы многозадачности
CIL и системное программирование в Microsoft .NET
Одна из типовых задач – разработка серверов, обслуживающих асинхронно поступающие запросы. Реализация однопоточного сервера для такой задачи нецелесообразна, во-первых, потому что запросы могут приходить в то время, пока сервер занят выполнением предыдущего, а, во-вторых, потому что такой сервер не сможет эффективно задействовать многопроцессорную систему. Можно, конечно, запускать несколько экземпляров однопоточного сервера – но в этом случае потребуется разработка специального диспетчера, поддерживающего очередь запросов и их распределение по списку доступных экземпляров сервера. Альтернативным решением является разработка многопоточного сервера, создающего по специальному рабочему потоку для обработки каждого запроса. Этот вариант также имеет свои недостатки: создание и удаление потоков требует затрат времени, которые будут иметь место в обработке каждого запроса; сверх того, создание большого числа одновременно выполняющихся потоков приведет к общему снижению производительности (и значительному увеличению времени обработки каждого конкретного запроса). Эти соображения приводят к решению, получившему название пула потоков (thread pool). Для реализации пула потоков необходимо создание некоторого количества потоков, занятых обслуживанием запросов, и диспетчера с очередью запросов. При наличии необработанных запросов диспетчер находит свободный поток и передает запрос этому потоку; если свободных потоков нет, то диспетчер ожидает освобождения какого-либо
7.1.1. Пулы потоков, порт завершения ввода-вывода
При разработке параллельных приложений недостаточно создать несколько параллельных ветвей кода – необходимо обеспечить их согласованное выполнение и своевременный обмен данными. При этом возникает необходимость использования как общих для разных потоков и процессов данных, так и создания собственных локальных данных, недоступных другим параллельным ветвям кода, может потребоваться синхронизация и даже учет особенностей аппаратуры, на которой данный код будет исполняться.
7.1. Применение потоков и волокон
Глава 7. Разработка параллельных приложений для ОС Windows
218
219
HANDLE CreateIoCompletionPort( HANDLE FileHandle, HANDLE ExistingCompletionPort, ULONG_PTR CompletionKey, DWORD NumberOfConcurrentThreads );
из занятых потоков. Такой подход обеспечивает, с одной стороны, малые затраты на управление потоками, с другой – достаточно высокую загрузку процессоров и хорошую масштабируемость приложения. Обычно имеет смысл ограничивать общее число рабочих потоков либо числом доступных процессоров, либо кратным этому числом. Если потоки занимаются исключительно вычислительной работой, то их число не должно превышать число процессоров. Если потоки, сверх того, проводят некоторое время в состоянии ожидания (например, при выполнении операций ввода-вывода), то число потоков следует увеличить – решение следует принимать, исходя из доли времени, которое поток проводит в состоянии простоя, и из полного времени обработки запроса. Достаточно типичная рекомендация: ограничивать число потоков удвоенным числом процессоров. В случае вычислительных потоков накладные потери будут достаточно малы; в случае потоков, занятых вводом-выводом, утилизация процессоров будет близка к полной. Предполагается, что потоки, не занятые вводом-выводом и при этом проводящие много времени в состоянии ожидания, встречаются весьма редко. Реализация пула потоков является, на самом деле, нетривиальной задачей – необходимо поддерживать очередь запросов и учитывать состояния потоков из пула (поток простаивает; поток выполняется; поток выполняется, но находится в состоянии ожидания). Также следует учитывать возможность выгрузки части данных в файл подкачки (например, выгрузка стека давно не используемого потока) – иногда быстрее подождать завершения работающего потока, чем активировать простаивающий. Для учета всех этих соображений необходимо реализовать поддержку пулов потоков ядром операционной системы, так как на уровне приложения некоторые нужные сведения просто недоступны. В Windows такая поддержка реализована в виде порта завершения ввода-вывода. Этот объект ядра берет на себя функциональность, необходимую для организации очереди запросов (используя для этого очередь APC) и списков рабочих потоков, обеспечивая оптимальное управление пулом. С точки зрения разработчика приложения необходимо: • создать порт завершения ввода-вывода; • создать пул потоков, ожидающий поступления запросов от этого порта; • обеспечить передачу запросов порту. Порт завершения создается с помощью функции
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
4
В этом варианте функция CreateIoCompletionPort не создает нового порта, а возвращает переданный ей описатель уже существующего. Существующий порт завершения ввода-вывода можно связать с несколькими различными файлами одновременно; при этом процедура, обслуживающая завершение ввода-вывода, сможет различать, операция с каким именно файлом поставила в очередь данный запрос, с помощью параметра CompletionKey (здесь SOME_NUMBER), назначаемого разработчиком. Созданный порт можно не ассоциировать ни с одним файлом – тогда с по-
#define SOME_NUMBER 123 CreateIoCompletionPort( hFile, hCP, SOME_NUMBER, 0 );
HANDLE hCP; hCP = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, NULL, CONCURRENTS ); При простом создании порта завершения ввода-вывода достаточно указать только максимальное число одновременно работающих потоков (здесь CONCURRENTS, целесообразно ограничивать числом доступных процессоров). Далее, когда будет создаваться пул потоков, в нем можно будет создать и большее число потоков, чем указано при создании порта – система будет отслеживать, чтобы одновременно исполнялся код не более чем указанного числа потоков. При этом поток, перешедший в состояние ожидания, не считается исполняющимся, так что в случае потоков, проводящих часть времени в режиме ожидания, имеет смысл создавать пул потоков большего размера, чем указано при вызове функции CreateIoCompletionPort. 2. Ассоциирование порта с файлом:
#define CONCURRENTS
Эта функция выполняет две разных операции – во-первых, она создает новый порт завершения, и, во-вторых, она ассоциирует порт с завершением операций ввода-вывода с заданным файлом. Обе эти операции могут быть выполнены одновременно одним вызовом функции, а могут быть исполнены раздельно. Более того, вторая операция – ассоциирование порта завершения ввода-вывода с реальным файлом – может вообще не выполняться. Две типичных формы применения функции CreateIoCompletionPort: 1. Создание нового порта завершения ввода-вывода:
220
221
/* создаем пул потоков */ for ( i = 0; i < POOLSIZE; i++ ) { hthread[i] = (HANDLE)_beginthreadex( NULL,0,PoolProc,(void*)hcport,0,(unsigned*)&temp ); } Если созданный порт ассоциирован с одним или несколькими файлами, то после завершения асинхронных операций ввода-вывода в очереди порта будут размещаться асинхронные запросы, которые система будет направлять для обработки потокам из пула. Однако порт завершения ввода-вывода можно и не связывать с файлами – тогда для размещения запроса можно воспользоваться функцией PostQueuedCompletionStatus, которая размещает запросы в очереди без выполнения реальных операций ввода-вывода.
int main( void ) { int i; HANDLE hcport, hthread[ POOLSIZE ]; DWORD temp; /* создаем порт завершения ввода-вывода */ hcport = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, NULL, CONCURENTS ); После создания порта надо создать пул потоков. Число потоков в пуле обычно превышает число одновременно работающих потоков, задаваемое при создании порта:
unsigned __stdcall PoolProc( void *arg );
#define MAXQUERIES 15 #define CONCURENTS 3 #define POOLSIZE 5
#include <process.h> #define _WIN32_WINNT 0x0500 #include <windows.h>
мощью функции PostQueuedCompletionStatus надо будет помещать в очередь порта запросы, имитирующие завершение вводавывода. Рассмотрим небольшой пример (проверка ошибок для упрощения пропущена):
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
unsigned __stdcall PoolProc( void *arg ) { DWORD size; ULONG_PTR key; LPOVERLAPPED lpov; while ( GetQueuedCompletionStatus( (HANDLE)arg, &size, &key, &lpov, INFINITE
} В общем виде поток в пуле реализует цикл с выбором запросов из очереди с помощью функции GetQueuedCompletionStatus. Следует внимательно ознакомиться с описанием этой функции, чтобы грамотно обрабатывать возможные ошибочные ситуации и предусмотреть при необходимости завершение работы потока. В данном примере поток в течении 0.3 секунды просто ждет, то есть не исполняется, и порт завершения может передать запросы всем потокам пула, хотя их количество превышает максимальное число одновременно работающих потоков, указанное при создании порта:
/* для завершения работы посылаем специальные запросы */ for ( i = 0; i < POOLSIZE; i++ ) { PostQueuedCompletionStatus(hcport,0, (ULONG_PTR)-1,NULL); } /* дожидаемся завершения всех потоков пула и закрываем описатели */ WaitForMultipleObjects( POOLSIZE, hthread, TRUE, INFINITE ); for ( i = 0; i < POOLSIZE; i++ ) CloseHandle( hthread[i] ); CloseHandle( hcport ); return 0;
/* посылаем несколько запросов в порт */ for ( i = 0; i < MAXQUERIES; i++ ) { PostQueuedCompletionStatus( hcport, 1, i, NULL ); Sleep( 60 ); } Функция помещает в очередь запросов информацию о «как будто» выполненной операции ввода-вывода, полностью повторяя аргументы – такие как размер переданного блока данных, ключ завершения и указатель на структуру OVERLAPPED, содержащую сведения об операции. Мы можем передавать вместо этих значений произвольные данные. В данном примере, скажем, принято, что значение ключа завершения -1 совместно с длиной переданного блока 0 означает необходимость завершить поток:
222
223
DWORD WINAPI QueryFunction( PVOID pContext ) { ... return 0L; } Таким образом, управление пулом потоков сильно упрощается, хотя при этом теряется возможность связывания порта завершения ввода-вывода с конкретными файлами и все запросы должны размещаться в очереди явным вызовом функции QueueUserWorkItem. Есть и еще одна особенность у такого способа управления пулом – явного механизма задания числа потоков в пуле не предусмотрено. Однако у разработчика есть возможность управлять этим процессом с помощью последнего параметра функции, содержащего специфичные флаги. Так, с помощью флага WT_EXECUTEDEFAULT запрос будет направлен обычному потоку из пула, флаг WT_EXECUTEINIOTHREAD заставит систему обрабатывать запрос в потоке, который находится в состоянии ожидания оповещения (то есть, надо предусмотреть явные вызовы функции типа SleepEx или WaitForMultipleObjectsEx и т.д.). Флаг WT_EXECUTELONGFUNCTION предназна-
BOOL QueueUserWorkItem( LPTHREAD_START_ROUTINE QueryFunction, PVOID pContext, ULONG Flags ); Эта функция при необходимости создает пул потоков (число потоков в пуле определяется числом процессоров), создает порт завершения ввода-вывода и размещает в очереди порта запрос. Если нужный порт и пул потоков уже созданы, то она просто размещает новый запрос в очереди порта. При обработке запроса будет вызвана указанная параметром QueryFunction процедура с аргументом pContext:
} Рассмотренный механизм управления пулом потоков весьма эффективен, однако требует некоторого объема ручной работы по созданию порта, по созданию пула потоков и по управлению этими потоками. В современных реализациях Windows предусмотрена возможность автоматического создания и управления пулом потоков с помощью функции
)) { /* проверяем условия завершения цикла */ if ( !size && key == (ULONG_PTR)-1 ) break; Sleep( 300 ); } return 0L;
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
15 3 cnt; hEvent;
int main( void ) { int i; hEvent = CreateEvent( NULL, TRUE, FALSE, 0 ); /* в пуле будет не менее POOLSIZE потоков */ for ( i = 0; i < POOLSIZE; i++ ) { QueueUserWorkItem( QProc, NULL, WT_EXECUTELONGFUNCTION ); Sleep( 60 ); } /* остальные запросы будут распределяться между потоками пула,даже если их больше, чем число процессоров */ for ( ; i < MAXQUERIES; i++ ) { QueueUserWorkItem( QProc, NULL, WT_EXECUTEDEFAULT ); Sleep( 60 ); } /* со временем система может уменьшить число потоков пула */ /* дожидаемся обработки последнего запроса */ WaitForSingleObject( hEvent, INFINITE ); CloseHandle( hEvent ); return 0; }
DWORD WINAPI QProc( LPVOID lpData ) { int r = InterlockedIncrement( &cnt ); Sleep( 300 ); if ( r >= MAXQUERIES ) SetEvent( hEvent ); return 0L; }
#define MAXQUERIES #define POOLSIZE static LONG static HANDLE
#include <process.h> #define _WIN32_WINNT 0x0500 #include <windows.h>
чен для случаев, когда обработка запроса может привести к продолжительному ожиданию – тогда система может увеличить число потоков в пуле:
224
225
При разработке многопоточных приложений возникает необходимость обеспечивать не только параллельное исполнение кода потоков, но также их взаимодействие – обмен данными, доступ к общим, разделяемым всеми потоками данным и изоляцию некоторых данных одного потока от других. Поскольку все потоки разделяют общее адресное пространство процесса, то все они имеют общий и равноправный доступ ко всем данным, хранимым в адресном пространстве. Поэтому для потоков, как правило, не существует проблем с передачей данных друг другу – нужна лишь организация корректного взаимного доступа и изоляция собственных данных от данных других потоков. Очень часто для изоляции данных достаточно их размещать в стеке – тогда другие потоки смогут получить к ним доступ либо по явно переданным указателям, либо путем сканирования памяти в поисках стеков других потоков и нужных данных в этих стеках, что относится уже к достаточно трудоемким хакерским технологиям. Однако таким образом трудно организовать постоянное хранение данных, и необходимо постоянно явным образом передавать эти данные (или указатель на них) во все вызываемые процедуры; это достаточно неудобно и не всегда возможно. Для решения подобных задач в Windows предусмотрен механизм управления данными, локальными для потока (TLS память, Thread Local Storage). Система предоставляет небольшой специальный блок данных, ассоциированный с каждым потоком. В таком блоке возможно в общем случае хранение произвольных данных, однако, так как размеры этого блока крайне малы, то обычно там размещаются указатели на данные большего объема, выделяемые в приложении для каждого потока; в связи с этим ассоциированную с потоком память можно рассматривать как массив двойных слов или массив указателей. ОС Windows предоставляет четыре функции, необходимые для работы с локальной для потока памятью. Функция DWORD TlsAlloc(void) выделяет в ассоциированной с потоком памяти двойное слово, индекс которого возвращается вызвавшей процедуре. Если ассоциированный массив полностью использован, возвращаемое значение будет равно TLS_OUT_OF_INDEXES, что сообщает об ошибке выделения ячейки. Функция TlsFree освобождает выделенную ячейку. Если поток выделил некоторую ячейку в ассоциированном массиве, то все потоки данного процесса могут обращаться к ячейке с этим индек-
7.1.2. Память, локальная для потоков и волокон
Последний пример качественно проще, чем пример с явным созданием порта завершения ввода-вывода, хотя часть возможностей порта завершения при этом не может быть использована.
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
unsigned __stdcall ThreadProc( void *param ) { TlsSetValue( dwTlsData, (LPVOID)new int array[100] ); /* выделенные потоком данные размещены в общей куче, используемой всеми потоками, однако указатель на эти данные известен только потоку-создателю, так как сохраняется в локальной для потока области */ ProcA( (int)param ); Sleep( 0 ); if ( ProcB() != 100*(int)param ) { /* ОШИБКА!!! */ } delete[] (int*)TlsGetValue( dwTlsData ); return 0; }
int ProcB( void ) { int i, x; int *iptr = (int*)TlsGetValue( dwTlsData ); for ( i = x = 0; i < 100; i++ ) x += iptr[i]; return x; }
void ProcA( int x ) { int i; int *iptr = (int*)TlsGetValue( dwTlsData ); for ( i = 0; i < 100; i++ ) iptr[i] = x; }
#define THREADS 18 static DWORD dwTlsData;
#include <process.h> #include <windows.h>
сом – они получат доступ к ячейкам своих собственных ассоциированных массивов и не смогут узнать или изменить значения, сохраненные в этих ячейках другими потоками. Для доступа к данным зарезервированной ячейки используется функция TlsGetValue, возвращающая значение данной ячейки (в виде указателя, т.к. предполагается, что в ячейках хранятся указатели на некоторые структуры данных) и функция TlsSetValue, изменяющая значение в соответствующей ячейке:
226
227
}
for ( i = 0; i < 100; i++ ) iptr[i] = x;
void ProcA( int x ) { int i;
#define THREADS 18 __declspec(thread) static int iptr[ 100 ];
#include <process.h> #include <windows.h>
int main( void ) { HANDLE hThread[ THREADS ]; unsigned dwThread; dwTlsData = TlsAlloc(); /* создаем новые потоки */ for ( i = 0; i < THREADS; i++ ) hThread[i]=(HANDLE)_beginthreadex( NULL, 0, ThreadProc, (void*)i, 0, &dwThread ); /* дождаться завершения созданных потоков */ WaitForMultipleObjects( THREADS, hThread, TRUE, INFINITE ); for ( i = 0; i < THREADS; i++ ) CloseHandle( hThread[i] ); TlsFree( dwTlsData ); return 0; } В приведенном примере в функции main выделяется ячейка в ассоциированном списке, индекс которой сохраняется в глобальной переменной dwTlsData, после чего потоки могут сохранять в этой ячейке свои данные. В Visual Studio работа с локальной для потока памятью может быть упрощена при использовании _declspec(thread) при описании переменных. В этом случае компилятор будет размещать эти переменные в специальном сегменте данных (_TLS), который будет создаваться библиотекой времени исполнения и ссылки на который будут разрешаться с использованием ассоциированной с потоком памяти. Этот способ во многих случаях предпочтительнее явного управления локальной для потока памятью, так как независимо от числа модулей, использующих такой сегмент, будет задействован только один указатель в ассоциированной памяти (построитель объединит в один большой сегмент все _TLS сегменты модулей).
Разработка параллельных приложений для ОС Windows
unsigned __stdcall ThreadProc( void *param ) { ProcA( (int)param ); Sleep( 0 ); if ( ProcB() != 100*(int)param ) { /* ОШИБКА!!! */ } return 0; }
int ProcB( void ) { int i, x; for ( i = x = 0; i < 100; i++ ) x += iptr[i]; return x; }
CIL и системное программирование в Microsoft .NET
int main( void ) { HANDLE hThread[THREADS]; unsigned dwThread; int i; /* создаем новые потоки */ for ( i = 0; i < THREADS; i++ ) hThread[i] = (HANDLE)_beginthreadex( NULL, 0, ThreadProc, (void*)i, 0, &dwThread ); /* дождаться завершения созданных потоков */ WaitForMultipleObjects( THREADS, hThread, TRUE, INFINITE ); for ( i = 0; i < THREADS; i++ ) CloseHandle( hThread[i] ); TlsFree( dwTlsData ); return 0; } Следует внимательно следить за выделением и освобождением данных, указатели на которые сохраняются в TLS памяти (как в случае явного управления, так и при использовании _declspec(thread)). Могут возникнуть две потенциально ошибочных ситуации: 1. TLS память резервируется в то время, когда уже существуют потоки. Это возможно при явном управлении TLS памятью, и для существующих потоков будут зарезервированы ячейки, но придется предусмотреть специальные меры для их корректной инициализации или для исключения их использования до этого. 2. Все случаи завершения потока. Если TLS память содержит какие-либо указатели, то сама TLS память будет освобождена, а
228
229
VOID WINAPI FlsCallback( PVOID lpFlsData ) { ... } Функция отличается от ее аналога TlsAlloc указателем на специальную необязательную процедуру FlsCallback, предоставляемую разработчиком. Эта процедура будет вызвана автоматически при освобождении ячейки FLS памяти (как при завершении волокна, так и при завершении потока или возникновении ошибки), и разработчик может легко предоставить средства для освобождения памяти, указатели на которую были сохранены в ячейках FLS памяти.
DWORD FlsAlloc( PFLS_CALLBACK_FUNCTION lpCallback );
вот те данные, указатели на которые хранились в TLS памяти, – нет. Необходимо специально отслеживать все возможные случаи завершения потоков, включая завершение по ошибке, и принимать меры для освобождения выделенной памяти. При использовании _declspec(thread) эта ситуация встречается реже, так как позволяет хранить в _TLS сегментах данные любого фиксированного размера. Следует отметить еще один нюанс, связанный с использованием TLS памяти, волокон и оптимизации. В частных случаях волокна могут исполняться разными потоками – при этом одно и то же волокно должно иметь доступ к TLS памяти именно того потока, в котором оно в данный момент исполняется. А если компилятор генерирует оптимизированный код, то он может разместить указатель на данные TLS памяти в каком-либо регистре или временной переменной, что при переключении волокна на другой поток приведет к ошибке – будет использована TLS память предыдущего потока. Чтобы избежать такой ситуации, компилятору можно указать специальный ключ /GT, отключающий некоторые виды оптимизации при работе с TLS памятью. Это может потребоваться в крайне редких случаях – когда приложение использует несколько волокон, исполняемых в нескольких потоках, и при этом волокна должны использовать TLS память потоков. Аналогично TLS памяти, Windows поддерживает память, локальную для волокон, – так называемую FLS память, или Fiber Local Storage. При этом FLS память не зависит от того, какой именно поток выполняет данную нить. Для работы с FLS памятью Windows предоставляет набор функций, аналогичный Tls-функциям, отличие заключается только в функции выделения ячейки FLS памяти:
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
ОС Windows предоставляет небольшой набор функций, предназначенных для поддержки систем с неоднородным доступом к памяти (NUMA). К таким функциям относятся средства, обеспечивающие выполнение потоков на конкретных процессорах, и функции, позволяющие получить информацию о структуре NUMA машины. В некоторых случаях привязка потоков к процессорам может преследовать и иные цели, чем поддержка NUMA архитектуры. Так, например, привязка потока к процессору может улучшить использование кэша; на некоторых SMP машинах могут возникать проблемы с использованием таймеров высокого разрешения (опирающихся на счетчики процессоров) и т.д. Привязка потоков к процессору задается с помощью специального битового вектора (affinity mask), сохраняемого в целочисленной переменной. Каждый бит этого вектора указывает на возможность исполнения потока на процессоре, номер которого совпадает с номером бита. Таким образом, заданием маски сродства можно ограничить множество процессоров, на которых будет выполняться данный поток. В Windows такие маски назначаются процессу (функции GetProcessAffinityMask и SetProcessAffinityMask) и потоку (функция SetThreadAffinityMask). Маска, назначаемая потоку, должна быть подмножеством маски процесса. Помимо ограничения множества процессоров, на которых может исполняться поток, может быть целесообразно назначить потоку самый «удобный»
7.1.3. Привязка к процессору и системы с неоднородным доступом к памяти
DWORD dwFlsID; VOID WINAPI FlsCallback( PVOID lpFlsData ) { /* при завершении волокна или потока память будет освобождена */ delete[] (int*)lpFlsData; } void initialize( void ) { dwFlsID = FlsAlloc( FlsCallback ); ... } void fiberstart( void ) { FlsSetValue( dwFlsID, new int [ 100 ] ); /* здесь мы можем не следить за освобождением выделенной памяти */ } Остальные функции для работы с FLS аналогичны Tls-функциям как по описаниям, так и по применению.
230
231
#define THREADS 10 #define ASIZE 10000000 static LONG array[ASIZE];
При реализации мультипрограммирования существует проблема одновременного конкурирующего доступа нескольких потоков к общим разделяемым данным. В многопоточных приложениях она особенно актуальна, так как вся память процесса является общей и разделяемой всеми потоками, поэтому конфликты при одновременном доступе могут возникать достаточно часто. Рассмотрим простейшую программу, в которой несколько потоков увеличивают на 1 значение элементов общего массива. Так как начальные значения элементов массива 0, то в результате весь массив должен быть заполнен числами, соответствующими числу потоков: #include <stdio.h> #include <process.h> #include <windows.h>
7.2.1. Синхронизация потоков
Материалы, рассматриваемые в этом разделе, могут быть разделены на две категории – синхронизация потоков и работа с процессами. Они сгруппированы в силу того, что большая часть методов синхронизации потоков может быть успешно применена для потоков, принадлежащих разным процессам. Кроме того, многие средства взаимодействия потоков, даже основанные на совместном доступе к данным в едином адресном пространстве, могут быть использованы в рамках одного вычислительного узла – в случае применения общей, разделяемой процессами, памяти.
7.2. Взаимодействие процессов и потоков
для него процессор (по умолчанию – тот, на котором поток был запущен первый раз). Для этого предназначена функция SetThreadIdealProcessor. При использовании NUMA систем следует учитывать, что распределение доступных процессоров по узлам NUMA системы не обязательно последовательное – узлы со смежными номерами могут быть с аппаратной точки зрения весьма удалены друг от друга. Функция GetNumaHighestNodeNumber позволяет определить число NUMA узлов, после чего с помощью обращений к функциям GetNumaProcessorNode, GetNumaNodeProcessorMask и GetNumaAvailableMemoryNode можно определить размещение узлов NUMA системы на процессорах и доступную каждому узлу память.
Разработка параллельных приложений для ОС Windows
unsigned __stdcall ThreadProc( void *param ) { int i; for ( i = 0; i < ASIZE; i++ ) array[i]++; return 0; }
CIL и системное программирование в Microsoft .NET
int main( void ) { HANDLE hThread[THREADS]; unsigned dwThread; int i, errs; for ( i = 0; i < THREADS; i++ ) hThread[i] = (HANDLE)_beginthreadex( NULL, 0, ThreadProc, NULL, 0, &dwThread ); WaitForMultipleObjects( THREADS, hThread, TRUE, INFINITE ); for ( i = 0; i < THREADS; i++ ) CloseHandle( hThread[i] ); for ( errs=i=0; i #include <process.h> #define _WIN32_WINNT 0x0403 #include <windows.h>
Собственно работа с критическими секциями сводится к двум основным функциям: функция EnterCriticalSection, которая соответствует входу в критическую секцию, при необходимости с ожиданием, не ограниченным по времени (!); и функция LeaveCriticalSection, которая соответствует выходу из этой секции, возможно с пробуждением потоков, ожидающих ее освобождения. При этом система исключает вход в критическую секцию всех остальных потоков процесса, в то время как поток, уже вошедший в данную секцию, может входить в нее рекурсивно – надо лишь, чтобы число выходов из секции соответствовало числу входов:
Разработка параллельных приложений для ОС Windows
for ( errs=i=0; i #include <windows.h> #define DEFAULT_SECURITY
LONG lCounter; lCounter = 0; if ( WaitForSingleObject( hSem, 0 ) == WAIT_OBJECT_0 ) ReleaseSemaphore( hSem, 1, &lCounter ); /* теперь переменная lCounter содержит значение счетчика */ Семафоры предназначены для ограничения числа потоков, имеющих одновременный доступ к какому-либо ресурсу. Мьютексы Объекты исключительного владения могут быть использованы в одно время не более чем одним потоком. В этом отношении мьютексы подобны критическим секциям, с той оговоркой, что работа с ними выполняется в режиме ядра (при использовании критических секций переход в режим ядра необязателен) и что мьютексы могут быть использованы для межпроцессного взаимодействия, тогда как критические секции реализованы для применения внутри процесса. Для захвата мьютекса используется ожидающая функция WaitFor..., а для освобождения – функция ReleaseMutex. При создании мьютекса функцией CreateMutex можно указать, чтобы он создавался сразу в занятом состоянии:
К сожалению, средств для проверки текущего значения счетчика без изменения состояния семафора нет: функция ReleaseSemaphore позволяет узнать предыдущее значение, но при этом обязательно увеличит его значение хотя бы на 1 (попытка увеличить на 0 или на отрицательную величину рассматривается как ошибка), а ожидающая функция обязательно уменьшит счетчик, если семафор был свободен. Поэтому для определения значения счетчика надо использовать что-то вроде приведенного ниже примера:
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
int main( void ) { unsigned id; HANDLE hMutex, hThread; hMutex = CreateMutex( DEFAULT_SECURITY, TRUE, NULL ); hThread = (HANDLE)_beginthreadex( (void*)0, 0, TProc, (void*)hMutex, 0, &id ); Sleep( 1000 ); ReleaseMutex( hMutex ); WaitForSingleObject( hThread, INFINITE ); CloseHandle( hThread ); CloseHandle( hMutex ); return 0; } ОС Windows предоставляет достаточно удобный набор объектов, пригодных для синхронизации. Однако, в большинстве случаев эти объекты эффективны с точки зрения операционной системы, представляя самые базовые примитивы. В реальных задачах часто возникают ситуации, когда необходимо создавать на основе этих примитивов более сложные составные синхронизирующие объекты. Достаточно распространенными примерами таких задач являются задачи с барьерной синхронизацией или задачи с множественным доступом к ресурсу по чтению и исключительным доступом по записи. При реализации барьерной синхронизации надо обеспечить не только возможность проконтролировать достижение барьера всеми потоками, но также снова «поставить барьер» сразу после того, как потоки начнут свое выполнение (иначе какой-либо поток может быстро справиться со своей работой и снова придет к барьеру, пока тот еще открыт). При этом потоки, прошедшие барьер, могут начинать свое выполнение со значительной задержкой. Синхронизирующие объекты, обслуживающие ресурс с множественным доступом по чтению и исключительным по записи, должны отслеживать число потоков, осуществляющих чтение данного ресурса, и потоки, требующие изменения ресурса. Реализация такого объекта должна предусматривать работу в условиях высокой нагрузки – когда несколько потоков могут одновременно считывать ресурс и при этом не возникает пауз, когда ресурс является свободным; при этом к тому же ресурсу должны обращаться изменяющие его потоки, требуя при этом исключительного доступа. Стандартных типов объектов, решающих такие задачи, в Windows нет.
242
243
Процессы в Windows определяют адресное пространство, которое будет использоваться всеми потоками, работающими в этом процессе. В отличие от UNIX-подобных систем системного вызова типа fork в Windows не предусмотрено – новый процесс создается заново, с выделением для него адресного пространства, проецирования на него компонент системы, образа исполняемого файла, необходимых динамических библиотек и других компонент. Эта операция требует чуть больше ресурсов, чем в UNIX-подобных системах, однако выполняется достаточно быстро – диспетчер памяти позволяет просто проецировать на адресное пространство компоненты из других процессов с режимом «копирование при записи». Основные механизмы взаимодействия процессов могут быть разделены на несколько групп:
7.2.2 Процессы
int main() { HANDLE hTimer = NULL; LARGE_INTEGER liDueTime; hTimer = CreateWaitableTimer(NULL, TRUE, “WaitableTimer”); /* задать срабатывание через 5 секунд */ liDueTime.QuadPart=-50000000; SetWaitableTimer( hTimer, &liDueTime, 0, NULL, NULL, 0 ); WaitForSingleObject( hTimer, INFINITE ); return 0; } Таймеры могут служить в качестве синхронизирующих объектов, как в данном примере, а могут вызывать указанную разработчиком функцию, если поток в нужное время находится в ожидании оповещения. Следует подчеркнуть, что ожидающие таймеры обладают ограниченной точностью работы. При необходимости точно планировать время выполнения (например, в случае обработки потоков мультимедиа данных) надо использовать специальный таймер, предназначенный для работы с мультимедиа (см. функции timeGetSystemTime, timeBeginPeriod и др.).
#include <windows.h> #include <stdio.h>
7.2.1.4. Ожидающие таймеры Эти объекты предназначены для выполнения операций через заданные промежутки времени или в заданное время. Таймеры бывают периодическими или однократными, также их разделяют на таймеры с ручным сбросом и синхронизирующие:
Разработка параллельных приложений для ОС Windows
• Использование объектов ядра для взаимной синхронизации. Рассмотрено при обсуждении взаимной синхронизации потоков. При использовании именованных объектов или передаче описателей объектов ядра другим процессам рассмотренные средства могут использоваться для межпроцессной синхронизации. • Проецирование файлов в адресное пространство процесса (File Mapping). Один из базовых механизмов, рассматривается ниже. • Использование файловых объектов. Каналы (Pipes), почтовые ящики (Mailslots) и сокеты (Sockets). Еще один базовый механизм; чаще применяется для организации межузлового взаимодействия, за исключением анонимных каналов (unnamed pipes, anonymous pipes), которые используются для межпроцессного взаимодействия в рамках одного узла. В данном курсе эти механизмы не затрагиваются. • Механизмы, ориентированные на обмен оконными сообщениями (буфер обмена, DDE, сообщение WM_COPYDATA и др.). В своей основе используют механизм проецирования файлов для передачи данных между адресными пространствами процессов. В данном курсе эти механизмы не затрагиваются. • Вызов удаленных процедур (Remote Procedure Call, RPC). Является надстройкой, использующей проецирование для реальной передачи данных. Позволяет описать процедуры, реализованные в других процессах, и обращаться к ним как к обычным процедурам, локальным для данного процесса. RPC инкапсулирует вопросы нахождения реальной процедуры, выполняющей необходимую работу, передачу данных в эту процедуру и получение от нее ответа. RPC позволяет организовать не только межпроцессное взаимодействие, но также межузловое с передачей данных по сети. В данном курсе не рассматривается. • COM. Является еще более высокоуровневой абстракцией, в данном курсе также не рассматривается.
CIL и системное программирование в Microsoft .NET
7.2.2.1. Создание процессов Для создания процессов используются функции CreateProcess, CreateProcessAsUser, CreateProcessWithLogonW и CreateProcessWithTokenW. Функция CreateProcess создает новый процесс, который будет исполняться от имени текущего пользователя потока, вызвавшего эту функцию. Функция CreateProcessAsUser позволяет запустить процесс от имени другого пользователя, который идентифицируется его маркером безопасности (security token); однако вызвавший эту функцию поток должен принять меры к правильному использованию реестра, так как профиль нового пользователя не будет загружен. Функции CreateProcessWithTokenW и
244
245
(LPSECURITY_ATTRIBUTES)NULL
7.2.2.2. Адресное пространство процесса и проецирование файлов В Windows потоки работают в одном адресном пространстве процесса, поэтому обмен данными между ними в большинстве случаев сводится к доступу к общей разделяемой памяти. Иная ситуация в случае взаимодействия потоков, принадлежащим разным процессам: так как адресное пространство процессов изолировано друг от друга, то приходится прини-
int main( void ) { STARTUPINFO si; PROCESS_INFORMATION pi; memset( &si, 0, sizeof(si) ); memset( &pi, 0, sizeof(pi) ); si.cb = sizeof(si); CreateProcess( NULL, “cmd.exe”, DEFAULT_SECURITY, DEFAULT_SECURITY, FALSE, NORMAL_PRIORITY_CLASS, NULL, NULL, &si, &pi ); CloseHandle( pi.hThread ); WaitForSingleObject( pi.hProcess, INFINITE ); CloseHandle( pi.hProcess ); return 0; } При создании процесса ему можно передать описатели каналов (CreatePipe), предназначенные для перенаправления stdin, stdout и stderr. Описатели каналов должны быть наследуемыми. Для завершения процесса рекомендуется применять функцию ExitProcess, которая завершит процесс, сделавший этот вызов. В крайних случаях можно использовать функцию TerminateProcess, которая может завершить процесс, заданный его описателем. Этой функцией пользоваться не рекомендуется, так как при таком завершении разделяемые библиотеки будут удалены из адресного пространства уничтожаемого процесса без предварительных уведомлений – это может привести в некоторых случаях к утечке ресурсов.
#include <windows.h> #define DEFAULT_SECURITY
CreateProcessWithLogonW позволяют при необходимости загрузить профиль пользователя и, кроме того, функция CreateProcessWithLogonW сама получает маркер пользователя по известному учетному имени, домену и паролю:
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
мать специальные меры для передачи данных из адресного пространства одного процесса в адресное пространство другого. Доступная пользователю часть адресного пространства процесса выделяется реально не в физической оперативной памяти, а в файлах, данные которых проецируются на соответствующие фрагменты адресного пространства. Выделение памяти без явного указания проецируемого файла приведет к тому, что область для проецирования будет автоматически выделяться в файле подкачки страниц. Физическая оперативная память в значительной степени является кэшем для данных файлов. Выделение физической оперативной памяти применяется очень редко, например, средствами расширения адресного пространства (Address Windowing Extension, AWE), позволяющими использовать более чем 4Г адресного пространства в 32-х разрядном приложении Win32. Важная особенность средств управления адресным пространством и проецированием файлов – они используют так называемую гранулярность выделения ресурсов (ее можно узнать с помощью функции GetSystemInfo); в современных версиях Win32 гранулярность составляет 64К. Это означает, что если вы попробуете спроецировать в память файл размером 100 байт, то в адресном пространстве будет занят фрагмент в 65536 байт длиной, из которого реально будут заняты только первые 100 байт. Для управления адресным пространством предназначены функции VirtualAlloc, VirtualFree, VirtualAllocEx, VirtualFreeEx, VirtualLock и VirtualUnlock. С их помощью можно резервировать пространство в адресном пространстве процесса (без передачи физической памяти из файла) и управлять передачей памяти из файла подкачки страниц указанному диапазону адресов. Механизмы проецирования файлов в память различаются для обычных и для исполняемых файлов. Функции создания процессов (CreateProcess...) и загрузки библиотек (LoadLibrary, LoadLibraryEx), помимо специфичных действий, выполняют проецирование исполняемых файлов в адресное пространство процесса; при этом учитывается их деление на сегменты, наличие секций импорта, экспорта и релокаций и др. Обычные файлы проецируются как непрерывный блок данных на непрерывный диапазон адресов. Для явного проецирования файлов используется специальный объект ядра проекция файла (file mapping object). Этот объект предназначен для описания файла, который может быть спроецирован в память, но реального отображения файла или его части в память при создании проекции не происходит. Описатель объекта «проекция файла» можно получить с помощью функций CreateFileMapping и OpenFileMapping. Для проецирования файла или его части в память предназначены функции MapViewOfFile, MapViewOfFileEx и UnmapViewOfFile:
246
247
7.2.2.3. Межпроцессное взаимодействие с использованием проецирования файлов Проецирование файлов используется для создания разделяемой памяти: для этого один процесс должен создать объект «проекция файла», а другой – открыть его. После этого система будет гарантировать когерентность данных в этой проекции во всех процессах, которые ее используют, хотя проекции могут размещаться в разных диапазонах адресов. В примере ниже приводится текст двух приложений (first.cpp и second.cpp), которые обмениваются между собой данными через общий объект «проекция файла»: /* FIRST.CPP */ #include <windows.h> #define DEFAULT_SECURITY (LPSECURITY_ATTRIBUTES)NULL int main( void ) { HANDLE hMapping; LPVOID pMapping; STARTUPINFO si; PROCESS_INFORMATION pi;
#include <windows.h> #define DEFAULT_SECURITY (LPSECURITY_ATTRIBUTES)NULL int main( void ) { HANDLE hFile, hMapping; LPVOID pMapping; LPSTR p; int i; hFile = CreateFile( “abc.dat”, GENERIC_WRITE|GENERIC_READ, FILE_SHARE_WRITE, DEFAULT_SECURITY, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL ); hMapping = CreateFileMapping( hFile, DEFAULT_SECURITY, PAGE_READWRITE, 0, 256, NULL ); pMapping = MapViewOfFile( hMapping, FILE_MAP_WRITE, 0,0, 0 ); for ( p = (LPSTR)pMapping, i=0; i int main( int ac, char **av ) { HANDLE hMapping; LPVOID pMapping; int *p; int i; hMapping = OpenFileMapping( FILE_MAP_READ, FALSE, “FileMap-AB-874436342” ); pMapping = MapViewOfFile( hMapping, FILE_MAP_READ, 0,0, 0 ); for ( p = (int*)pMapping, i=0; i m_size ) to = m_size; for ( i = 0; i < m_size; i++ ) { for ( j = 0; j < m_size; j++ ) {
namespace TestNamespace { class TestApp { const int const int const int private static int private static double[,]
using System; using System.Threading;
Основные классы для реализации многопоточных приложений определены в пространстве имен System.Threading. Для описания собственных потоков предназначен класс Thread. При создании потока ему необходимо указать делегата, реализующего процедуру потока. К сожалению, в .NET, во-первых, не предусмотрено передачи аргументов в эту процедуру, вовторых, процедура должна быть статическим методом, и в-третьих, класс Thread является опечатанным. В результате передача каких-либо данных в процедуру потока вызывает определенные трудности и требует явного или косвенного использования статических полей, что не слишком удобно, зачастую нуждается в дополнительной синхронизации и плохо соответствует парадигме ООП. Приведенный ниже пример демонстрирует работу с созданием нескольких потоков для параллельного перемножения двух квадратных матриц. Начальные значения всех элементов матриц равны 1, поэтому результирующая матрица должна быть заполнена числами, равными размерности перемножаемых матриц. В процессе умножения и суммирования элементов матриц синхронизация не выполняется, поэтому при достаточно большом размере матриц гарантированно будут возникать ошибки (необходимо синхронизировать выполнение некоторых действий в потоках, чтобы избежать возникновения ошибок, – об этом ниже, при рассмотрении средств синхронизации):
252
}
253
} } Поток в .NET может находиться в одном из следующих состояний: незапущенном, исполнения, ожидания, приостановленном, завершенном и прерванном. Возможные переходы между этими состояниями изображены на рис. 7.1. Сразу после создания и до начала выполнения потока он находится в незапущенном состоянии (Unstarted). Текущее состояние можно определить с помощью свойства Thread.ThreadState. После запуска поток можно перевести в состояние исполнения (Running) вызовом метода Thread.Start. Работающий поток может быть переведен в состояние ожидания (WaitSleepJoin) явным или неявным вызовом соответствующих методов (Thread.Sleep, Thread.Join и др.) или приостановлен (Suspended) с помощью метода Thread.Suspend(). Исполнение приостановленного потока можно
} public static void Main() { Thread[] T = new Thread[ m_stripmax ]; int i,j,errs; for ( i = 0; i < m_size; i++ ) { for ( j = 0; j < m_size; j++ ) { m_A[i,j] = m_B[i,j] = 1.0; m_C[i,j] = 0.0; } } for ( i = 0; i < m_stripmax; i++ ) { T[i] = new Thread(new ThreadStart(ThreadProc)); T[i].Start(); } // дожидаемся завершения всех потоков for ( i = 0; i < m_stripmax; i++ ) T[i].Join(); // проверяем результат errs = 0; for ( i = 0; i < m_size; i++ ) for ( j = 0; j < m_size; j++ ) if ( m_C[i,j] != m_size ) errs++; Console.WriteLine(“Error count = {0}”, errs ); }
}
for ( k = from; k < to; k++ ) m_C[i,j] += m_A[i,k] * m_B[k,j];
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
Aborted
Stopped
Sleep(), Join() и переход к ожиданию
WaitSleepJoin
Interrupt() и завершение ожидания
Resume()
Suspended
Завершение функции потока нормальным образом переводит поток в состояние «завершен» (Stopped), а досрочное прекращение работы вызовом метода Thread.Abort переведет его в состояние «прерван» (Aborted). Кроме того, .NET поддерживает несколько переходных состояний (AbortRequested, StopRequested и SuspendRequested). Состояния потока в общем случае могут комбинироваться, например, вполне корректно сочетание состояния ожидания (WaitSleepJoin) и какого-либо переходного, скажем, AbortRequested. Для выполнения задержек в ходе выполнения потока предназначены два метода – Sleep, переводящий поток в состояние ожидания на заданное время, и SpinWait, который выполняет некоторую задержку путем многократных повторов внутреннего цикла. Этот метод дает высокую загрузку процессора, однако позволяет реализовать очень короткие паузы. К сожалению, продолжительность пауз зависит от производительности и загруженности процессора. Для получения и задания приоритета потока используется свойство Thread.Priority. Приоритеты потока в .NET базируются на подмножестве
Рис. 7.1. Состояния потока
Уничтожение объекта Thread
Abort()
Suspend()
return
Running
Start()
Unstarted
Создание объекта Thread
возобновить вызовом метода Thread.Resume. Также можно досрочно вывести поток из состояния ожидания вызовом метода Thread.Interrupt.
254
255
Для реализации асинхронного ввода-вывода в .NET предназначен абстрактный класс System.IO.Stream. В этом классе определены абстрактные синхронные методы чтения Read и записи Write, а также реализация асинхронных методов BeginRead, EndRead, BeginWrite и EndWrite. Асинхронные методы реализованы с помощью обращения к синхронным операциям фоновыми потоками пула. На основе абстрактного класса Stream в .NET Framework реализуются потомки, осуществляющие взаимодействие с разного рода потоками данных. Так, например, System.IO.FileStream реализует операции с файлами, System.IO.MemoryStream предоставляет возможность использования
7.3.2. Асинхронный ввод-вывод
относительных приоритетов Win32 API так, что при переносе на другие платформы существует возможность предоставить их корректные аналоги. В .NET используются приоритеты Highest, AboveNormal, Normal, BelowNormal и Lowest. Когда .NET приложение начинает исполняться в среде Windows, CLR создает внутренний пул потоков, используемый средой для реализации асинхронных операций ввода-вывода, вызова асинхронных процедур, обработки таймеров и других целей. Потоки могут добавляться в пул по мере надобности. Этот пул реализуется на основе пула потоков, управляемого операционной системой (построенного на основе порта завершения ввода-вывода). Для взаимодействия с пулом потоков предусмотрен класс ThreadPool, и единственный объект, принадлежащий этому классу, создается CLR при запуске приложения. Все домены приложений в рамках одного процесса используют общий пул потоков. Разработчики могут использовать несколько статических методов класса ThreadPool. Так, например, существует возможность связать внутренний порт завершения ввода-вывода с файловым объектом, созданным неуправляемым кодом, для обработки событий, связанных с завершением ввода-вывода этим объектом (см. методы ThreadPool.BindHandle и описание порта завершения ввода-вывода в главе 7.1.1). Можно управлять числом потоков в пуле (методы GetAvailableThreads, GetMaxThreads, GetMinThreads и SetMinThreads), можно ставить в очередь асинхронных вызовов собственные процедуры (метод QueueUserWorkItem) и назначать процедуры, которые будут вызываться при освобождении какого-либо объекта (метод RegisterWaitForSingleObject). Эти два метода имеют «безопасные» и «небезопасные» (Unsafe...) версии; последние отличаются тем, что в стеке вызовов асинхронных методов не будут присутствовать данные о реальном контексте безопасности потока, поставившего в очередь этот вызов, – в подобном случае будет использоваться контекст безопасности самого пула потоков.
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
m_size = 100000000; m_data = new byte [m_size];
} } Данный пример демонстрирует использование FileStream для выполнения асинхронной операции записи большого объема данных.
public static void DoneWritting( IAsyncResult state ) { } static void Main(string[] args) { TestApp ta = new TestApp(); IAsyncResult state; Stream st = new FileStream( “test.dat”, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, 1, true ); state = st.BeginWrite( m_data, 0, m_size, new AsyncCallback(DoneWritting), null ); // код в этом месте будет выполняться // одновременно с выводом данных st.EndWrite( state ); st.Close(); }
public TestApp() { int i; for ( i = 0; i < m_size; i++ ) m_data[i] = (byte)i; }
using System; using System.IO; namespace TestNamespace { class TestApp { private const int private static byte[]
байтового массива в качестве источника или получателя данных, System.IO.BufferedStream является «надстройкой» над другими объектами, производными от System.IO.Stream, и обеспечивает буферизацию запросов чтения и записи. Некоторые классы вне пространства имен Sytem.IO также являются потомками Stream. Так, например, класс System.NET.Sockets.NetworkStream обеспечивает сетевое взаимодействие:
256
257
}
}
public static void Main() { GreetingData gd = new GreetingData(“Hello, world!”); ThreadPool.QueueUserWorkItem(new WaitCallback(AsyncProc), gd); Thread.Sleep( 1000 ); }
class TestApp { static void AsyncProc( Object arg ) { GreetingData gd = (GreetingData)arg; gd.Invoke(); }
using System; using System.Threading; namespace TestNamespace { class GreetingData { private string m_greeting; public GreetingData( string text ) { m_greeting = text; } public void Invoke() { Console.WriteLine( m_greeting ); } }
Для реализации вызова асинхронных процедур в .NET используются фоновые потоки пула, так же как для обработки асинхронных операций ввода-вывода. Класс ThreadPool предлагает два способа для вызова асинхронных процедур: явное размещение вызовов в очереди (QueueUserWorkItem) и связывание вызовов с переводом некоторых объектов в свободное состояние (RegisterWaitForSingleObject). Кроме того, .NET позволяет осуществлять асинхронные вызовы любых процедур с помощью метода BeginInvoke делегатов. Статический метод ThreadPool.QueueUserWorkItem ставит вызов указанной процедуры в очередь для обработки. Если пул содержит простаивающие потоки, то обработка этой функции начнется немедленно:
7.3.3. Асинхронные процедуры
При реализации собственных потомков класса Stream, возможно, будет иметь смысл переопределить не только абстрактные методы Read и Write, но также некоторые базовые (например, BeginRead, ReadByte и др.), универсальная реализация которых может быть неэффективной в конкретном случае.
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
namespace TestNamespace { class GreetingData { private string m_greeting; private RegisteredWaitHandle m_waithandle; public GreetingData( string text ) { m_greeting = text; } public void Invoke() { Console.WriteLine( m_greeting ); } public RegisteredWaitHandle WaitHandle { set { if (value==null) m_waithandle.Unregister( null ); m_waithandle = value; } } } class TestApp { static void AsyncProc( Object arg, bool isTimeout ) { GreetingData gd = (GreetingData)arg; if ( !isTimeout ) gd.WaitHandle = null; gd.Invoke(); } public static void Main() { GreetingData gd = new GreetingData(“Hello”); AutoResetEvent ev = new AutoResetEvent(false); gd.WaitHandle=ThreadPool.RegisterWaitForSingleObject( ev, new WaitOrTimerCallback(AsyncProc),
using System; using System.Threading;
При постановке в очередь асинхронного вызова можно указать объект, который является аргументом асинхронной процедуры (при создании собственных потоков передача аргументов процедуре потока затруднительна). Второй способ вызова асинхронных процедур связан с использованием объектов, производных от класса System.Threading.WaitHandle (это события и мьютексы). При этом вызов асинхронной процедуры связывается с переводом объекта в свободное состояние. Данный метод может быть использован также для организации повторяющегося через определенные интервалы вызова асинхронных процедур – при регистрации делегата указывается максимальный интервал ожидания, и если он исчерпывается, то вызов размещается в очереди пула, даже если объект остался занятым. Если объект попрежнему остается занятым, то вызов процедуры будет периодически размещаться в очереди после исчерпания каждого интервала ожидания.
258
}
259
namespace TestNamespace { public class GreetingData { private string m_greeting; public GreetingData( string text ) { m_greeting = text; } public static void Invoke( GreetingData arg ) { Console.WriteLine( arg.m_greeting ); } }
using System; using System.Threading;
} Приведенный пример демонстрирует использование периодического вызова асинхронной процедуры – при регистрации делегата (RegisterWaitForSingleObject) указывается максимальное время ожидания 1 секунда (1000 миллисекунд), после чего основной поток переводится в состояние «спячки» на 2.5 секунды. За это время в очередь пула поступает два вызова асинхронных процедур (с признаком вызова по тайм-ауту). Через 2.5 секунды основной поток пробуждается, переводит событие в свободное состояние, и в очередь пула поступает третий вызов. При обработке этого вызова регистрация делегата отменяется. Последний способ связан с использованием методов BeginInvoke и EndInvoke делегатов. Когда определяется какой-либо делегат функции, для него будут определены методы: BeginInvoke (содержащий все аргументы делегата плюс два дополнительных – AsyncCallback, который может быть вызван по завершении обработки асинхронного вызова, и AsyncState, с помощью которого можно определить состояние асинхронной процедуры) и EndInvoke, содержащий все выходные параметры (т.е. описанные как inout или out), плюс IAsyncResult, позволяющий узнать результат выполнения процедуры. Таким образом, использование BeginInvoke позволяет не только поставить в очередь вызов асинхронной процедуры, но также связать с завершением ее обработки еще один асинхронный вызов. Метод EndInvoke служит для ожидания завершения обработки асинхронной процедуры:
}
gd, 1000, false ); Thread.Sleep( 2500 ); ev.Set(); Console.ReadLine();
Разработка параллельных приложений для ОС Windows
public delegate void AsyncProcCallback ( GreetingData gd ); class TestApp { public static void Main() { GreetingData gd = new GreetingData( “Hello!!!” ); AsyncProcCallback apd = new AsyncProcCallback( GreetingData.Invoke ); IAsyncResult ar = apd.BeginInvoke( gd, null, null ); ar.AsyncWaitHandle.WaitOne(); } }
CIL и системное программирование в Microsoft .NET
public static void ThreadProc() {
7.3.4.1. Атомарные операции Платформа .NET предоставляет, аналогично базовой операционной системе Windows, набор некоторых основных операций над целыми числами (int и long), которые могут выполняться атомарно. Для этого предусмотрены четыре статических метода класса System.Threading.Interlocked, а именно Increment, Decrement, Exchange и CompareExchange. Применение этих методов аналогично соответствующим Interlocked... процедурам Win32 API. Возвращаясь к примеру использования потоков для умножения матриц, можно выделить один момент, требующий исправления: самое начало процедуры потока, там, где определяется номер полосы:
Проблемы, встающие перед разработчиками многопоточных приложений .NET, очень похожи на проблемы разработчиков приложений Win32 API. Соответственно, .NET предоставляет в значительной мере близкий набор средств взаимодействия потоков и их взаимной синхронизации. К этим средствам относятся атомарные операции, локальная для потока память, синхронизирующие примитивы и таймеры. Их применение в основе своей похоже на применение аналогичных средств API.
7.3.4. Синхронизация и изоляция потоков
} Данный пример иллюстрирует вызов асинхронной процедуры с использованием метода BeginInvoke и альтернативный механизм ожидания завершения – с использованием внутреннего объекта AsyncWaitHandle (класса WaitHandle), благодаря которому, собственно говоря, становится возможен вызов асинхронной процедуры, обслуживающей завершение обработки данной процедуры. В этом смысле асинхронный вызов процедур с помощью BeginInvoke очень близок к обработке асинхронных операций ввода-вывода.
260
261
7.3.4.2. Синхронизация потоков Основные средства взаимной синхронизации потоков в .NET обладают заметным сходством со средствами операционной системы. Среди них можно выделить: • Мониторы, близкие к критическим секциям Win32 API. • События и мьютексы, имеющие соответствующие аналоги среди объектов ядра. • Плюс дополнительный достаточно универсальный синхронизирующий объект, обеспечивающий множественный доступ потоков по чтению и исключительный – по записи. Последний синхронизирующий объект ReaderWriterLock закрывает очень типичный класс задач синхронизации – для многих объектов является совершенно корректным конкурентный доступ для чтения и требуются исключительные права для изменения данных. Причина в том, что изменение сложных объектов осуществляется не атомарно, поэтому во время постепенного внесения изменений объект кратковременно пребывает в некорректном состоянии – при этом должен быть исключен не только доступ других потоков, пытающихся внести изменения, но даже потоков, осуществляющих чтение. Мониторы Мониторы в .NET являются аналогами критических секций в Win32 API. Использование мониторов достаточно эффективно (это один из самых эффективных механизмов) и удобно настолько, что в .NET был предусмотрен механизм, который позволяет использовать практически любой объект, хранящийся в управляемой куче, для синхронизации доступа.
public static void ThreadProc() { int i,j,k, from, to; from = (Interlocked.Increment(ref m_stripused) – 1 ) * m_stripsize; to = from + m_stripsize; ...
int i,j,k, from, to; from = ( m_stripused++ ) * m_stripsize; to = from + m_stripsize; ... Здесь потенциально возможна ситуация, когда несколько потоков одновременно начнут выполнять этот код и получат идентичные номера полос. В этом месте самым эффективным было бы использование атомарных операций для увеличения значения поля m_stripused. Для этого фрагмент надо переписать:
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
Рис. 7.2. Использование кэша SyncBlock записей объектами управляемой кучи
... Данные объекта ...
Индекс в SyncBlock кэше
Информация об объекте
Объект k
... Данные объекта ...
...
SyncBlock[0] (пусто) SyncBlock[1] (используется) SyncBlock[2] (пусто) SyncBlock[3] (пусто) SyncBlock[4] (используется) SyncBlock[5] (пусто) ...
Информация об объекте
Индекс в SyncBlock (пусто)
Кэш SyncBlock записей
Информация о типах, таблицы указателей на методы и т.п.
Внутренние данные CLR
Объект j
... Данные объекта
Индекс в SyncBlock кэше
Информация об объекте
Объект i
Управляемая куча
Для этого с каждым объектом ссылочного типа сопоставляется запись SyncBlock, являющаяся, по сути, аналогом структуры CRITICAL_SECTION в Win32 API. Добавление такой записи к каждому объекту в управляемой куче чересчур накладно, особенно если учесть, что используются они относительно редко. Поэтому все записи SyncBlock выносятся в отдельный кэш, а в информацию об объекте включается ссылка на запись кэша (см. рис. 7.2). Такой прием позволяет, с одной стороны, содержать кэш синхронизирующих записей минимального размера, а с другой – любому объекту при необходимости можно сопоставить запись.
262
263
public static void ThreadProc() { int i,j,k, from, to; from = (Interlocked.Increment(ref m_stripused)–1) * m_stripsize; to = from + m_stripsize; if ( to > m_size ) to = m_size; for ( i = 0; i < m_size; i++ ) { for ( j = 0; j < m_size; j++ ) { for ( k = from; k < to; k++ ) m_C[i,j] += m_A[i,k] * m_B[k,j]; } } } Так как эта операция выполняется не атомарно, то вполне может быть так, что один поток считывает значение m_C[i,j], прибавляет к нему величину m_A[i,k] * m_B[k,j] и, прежде чем успевает записать в m_C[i,j] результат сложения, прерывается другим потоком. Второй поток успевает изменить величину m_C[i,j], потом первый снова пробуждается и записывает значение, вычисленное для предыдущего состояния элемента m_C[i,j], – то есть некорректную величину. Собственно говоря, именно эта ситуация и приводит к ошибкам, которые можно наблюдать в исходном примере. Ситуацию можно исправить, используя синхронизацию при доступе к элементу m_C[i,j] с помощью мониторов:
Обычно объекты не имеют сопоставленной с ними SyncBlock записи, однако она автоматически выделяется при первом использовании монитора. Класс Monitor, определенный в пространстве имен System.Threading, предлагает несколько статических методов для работы с записями синхронизации. Методы Enter и Exit являются наиболее применяемыми и соответствуют функциям EnterCriticalSection и LeaveCriticalSection операционной системы. Аналогично критическим секциям Win32 API, мониторы могут использоваться одним потоком рекурсивно. Еще несколько методов класса Monitor – Wait, Pulse и PulseAll – позволяют при необходимости временно разрешить доступ к объекту другому потоку, ожидающему его освобождения, не покидая критической секции. Продолжим рассмотрение примера с многопоточным умножением матриц. Помимо уже рассмотренной проблемы с назначением полос, в процедуре потока есть еще одно некорректное место – прибавление накоплением к элементу результирующей матрицы произведения двух элементов исходной матрицы:
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
... for ( j = 0; j < m_size; j++ ) { for ( k = from; k < to; k++ ) { Monitor.Enter( m_C ); try { m_C[i,j] += m_A[i,k] * m_B[k,j]; } finally { Monitor.Exit( m_C ); } } } ... В этом фрагменте надо выделить два существенных момента: во-первых, использование метода Exit в блоке finally, а во-вторых – использование всего массива m_C, а не отдельного элемента m_C[i,j]. Первое надо взять за правило, так как в случае возникновения исключения в критической секции блокировка может остаться занятой (т.е. в случае покидания секции без вызова метода Exit). Второе связано с тем, что элементы m_C[i,j] являются значениями, а не ссылочными типами. Для типов-значений соответствующее представление в управляемой куче не создается, и у них нет и не может быть ссылок на синхронизирующие записи SyncBlock. Самое плохое в этой ситуации то, что попытка собрать приложение, использующее типы-значения в качестве аргументов методов Enter и Exit (как в примере ниже), пройдет успешно: ... for ( j = 0; j < m_size; j++ ) { for ( k = from; k < to; k++ ) { Monitor.Enter( m_C[i,j] ); try { m_C[i,j] += m_A[i,k] * m_B[k,j]; } finally { Monitor.Exit( m_C[i,j] ); } } } ... В прототипах методов Enter и Exit указано, что они должны получать ссылочный тип object; соответственно тип-значение будет упакован, и методу Enter будет передан свой экземпляр упакованного типа-значения, на который будет поставлена блокировка, а методу Exit – свой экземпляр, на котором блокировки никогда не было. Понятно, что все остальные потоки
264
265
public static void ThreadProc() { int i,j,k, from, to; double R; from = (Interlocked.Increment(ref m_stripused) – 1) * m_stripsize; to = from + m_stripsize;
Использование ключевого слова lock предпочтительно, так как при этом выполняется дополнительная синтаксическая проверка – попытка использовать для блокировки тип-значение приведет к диагностируемой компилятором ошибке, вместо трудно отлавливаемой ошибки во время исполнения:
Monitor.Enter( obj ); try { ... } finally { Mointor.Exit( obj ); }
эквивалентна
lock ( obj ) { ... }
будут создавать и множить свои собственные упакованные представления типов-значений, и никакой синхронизации не произойдет. Поэтому при использовании мониторов важно проследить, чтобы вызовы разных методов в разных потоках использовали один общий объект ссылочного типа. Можно выделить интересный момент – типы объектов сами являются экземплярами класса Type, и для них выделяется место в управляемой куче. Это позволяет использовать тип объекта в качестве владельца записи SyncBlock: ... for ( j = 0; j < m_size; j++ ) { for ( k = from; k < to; k++ ) { Monitor.Enter( typeof(double) ); try { m_C[i,j] += m_A[i,k] * m_B[k,j]; } finally { Monitor.Exit( typeof(double) ); } } } ... Возможно неявное использование мониторов в C# с помощью ключевого слова lock:
Разработка параллельных приложений для ОС Windows
if ( to > m_size ) to = m_size; for ( i = 0; i < m_size; i++ ) { for ( j = 0; j < m_size; j++ ) { R = 0; for ( k = from; k < to; k++ ) R += m_A[i,k]*m_B[k,j]; lock ( m_C ) { m_C[i,j] += R; } } }
CIL и системное программирование в Microsoft .NET
} Данный пример показывает процедуру потока, осуществляющего пополосное умножение матриц с необходимой синхронизацией. Следует заметить, что синхронизация доступа требует дополнительных ресурсов процессора (в данном случае, качественно превышающих затраты на умножение и сложение двух чисел с плавающей запятой), поэтому целесообразно как можно сильнее сократить число блокировок и время их наложения. В примере для этого использована промежуточная переменная R, накапливающая частичный результат. Следует особо подчеркнуть, что мониторы и блокировки доступа только лишь позволяют разработчику реализовать соответствующую синхронизацию, но ни в коем случае не осуществляют принудительное ограничение конкурентного обращения к полям и методам объектов. Любой параллельно выполняющийся фрагмент кода сохраняет полную возможность обращаться со всеми объектами, независимо от того, связаны они с какими-либо блокировками или нет. Для синхронизации и блокирования доступа необходимо, чтобы все участники синхронизации явным образом использовали критические секции. Ожидающие объекты .NET предоставляет базовый класс WaitHandle, служащий для описания объекта, который находится в одном из двух состояний: занятом или свободном. На основе этого класса строятся другие классы синхронизирующих объектов .NET, такие как события (ManualResetEvent и AutoResetEvent) и мьютексы (Mutex). Класс WaitHandle является, по сути, оберткой объектов ядра операционной системы, поддерживающих интерфейс синхронизации. Свойство Handle объекта WaitHandle позволяет установить (или узнать) соответствие этого объекта .NET с объектом ядра операционной системы. Существует три метода класса WaitHandle для ожидания освобождения объекта: метод WaitOne, являющийся методом объекта, и статические методы WaitAny и WaitAll. Метод WaitOne является оберткой вызова WaitForSingleObject Win32 API, а методы WaitAny и WaitAll – вызова WaitForMultipleObjects. Соответственно семантике конкретных объектов ядра, представленных объектом WaitHandle, методы Wait...могут изменять
266
267
namespace TestNamespace { public class SomeData { public const int m_queries = 10; private static int m_counter = 0; private static Mutex m_mutex = new Mutex(); private static ManualResetEvent m_event = new ManualResetEvent( false ); public static void Invoke( int no ) { m_mutex.WaitOne(); m_counter++; if ( m_counter >= m_queries ) m_event.Set(); m_mutex.ReleaseMutex(); m_event.WaitOne(); } } public delegate void AsyncProcCallback( int no ); class TestApp { public static void Main() { int i; WaitHandle[] wh; AsyncProcCallback apd; wh = new WaitHandle[ SomeData.m_queries ]; apd = new AsyncProcCallback( SomeData.Invoke ); for ( i = 0; i < SomeData.m_queries; i++ ) wh[i] = apd.BeginInvoke(i,null,null).AsyncWaitHandle; WaitHandle.WaitAll( wh ); } } }
using System; using System.Threading;
или не изменять состояние ожидаемого объекта. Так, например, для событий с ручным сбросом (ManualResetEvent) состояние не меняется, а события с автоматическим сбросом и мьютексы (AutoResetEvent, Mutex) переводятся в занятое состояние. Объекты класса WaitHandle и производных от него, представляя объекты ядра операционной системы, могут быть использованы для межпроцессного взаимодействия. Конструкторы производных объектов (событий и мьютексов) позволяют задать имя объекта ядра, предназначенное для организации общего доступа к объектам процессами:
Разработка параллельных приложений для ОС Windows
CIL и системное программирование в Microsoft .NET
Приведенный пример показывает синхронизацию с использованием мьютекса, события с ручным сбросом и объекта WaitHandle, представляющего состояние асинхронного вызова. В примере делается 10 асинхронных вызовов, после чего приложение ожидает завершения всех вызовов с помощью метода WaitAll. Каждый асинхронный метод в секции кода, защищаемой мьютексом (здесь было бы эффективнее использовать монитор или блокировку), подсчитывает число сделанных вызовов и переходит к ожиданию занятого события. Самый последний асинхронный вызов установит событие в свободное состояние, после чего все вызовы должны завершиться. Помимо использования разных синхронизирующих объектов, в этом примере интересно поведение CLR: асинхронные вызовы должны обрабатываться в пуле потоков, однако число вызовов превышает число потоков в пуле. CLR по мере необходимости добавляет в пул потоки для обработки поступающих запросов. Потоки не являются наследниками класса WaitHandle в силу того, что для разных базовых платформ потоки могут быть реализованы в качестве потоков операционной системы или легковесных потоков, управляемых CLR. В последнем случае потоки .NET не будут иметь никаких аналогов среди объектов ядра операционной системы. Для синхронизации с потоками надо использовать метод Join класса Thread. Один «писатель», много «читателей» Одной из типичных задач синхронизации потоков является задача, в которой допускается одновременный конкурентный доступ многих объектов для чтения данных («читатели») и исключительный доступ единственного потока, вносящего в объект изменения («писатель»). В Win32 API стандартного объекта, реализующего подобную логику, не существует, поэтому каждый раз его надо проектировать и создавать заново. .NET предоставляет весьма эффективное стандартное решение: класс ReaderWriterLock. В приводимом ниже примере демонстрируется применение методов Acquire... и Release... для корректного использования блокировки доступа при чтении и записи. Тестовый класс содержит две целочисленные переменные, которые считываются и увеличиваются на 1 с небольшими задержками по отношению друг к другу. Пока операции синхронизируются, попытка чтения или изменения всегда будет возвращать четный результат, а вот если бы синхронизация не выполнялась, то в некоторых случаях получались бы нечетные числа: using System; using System.Threading; namespace TestNamespace { public class SomeData { public const int m_queries = 10; private ReaderWriterLock m_rwlock = new ReaderWriterLock();
268
}
} public delegate void AsyncProcCallback(SomeData sd, int no); class TestApp { public static void Main() { int i; SomeData sd = new SomeData(); WaitHandle[] wh; AsyncProcCallback apd; wh = new WaitHandle[ SomeData.m_queries ]; apd = new AsyncProcCallback( SomeData.Invoke ); for ( i = 0; i < SomeData.m_queries; i++ ) wh[i] = apd.BeginInvoke(sd,i,null,null).AsyncWaitHandle; WaitHandle.WaitAll( wh ); } }
private int m_a = 0, m_b = 0; public int summ() { int r; m_rwlock.AcquireReaderLock( -1 ); try { r = m_a; Thread.Sleep( 1000 ); return r + m_b; } finally { m_rwlock.ReleaseReaderLock(); } } public int inc() { m_rwlock.AcquireWriterLock( -1 ); try { m_a++; Thread.Sleep( 500 ); m_b++; return m_a + m_b; } finally { m_rwlock.ReleaseWriterLock(); } } public static void Invoke( SomeData sd, int no ) { if ( no % 2 == 0 ) { Console.WriteLine( sd.inc() ); } else { Console.WriteLine( sd.summ() ); } }
Разработка параллельных приложений для ОС Windows
269
CIL и системное программирование в Microsoft .NET
class SomeData { private static LocalDataStoreSlot m_tls = Thread.AllocateDataSlot(); public static void ThreadProc() { Thread.SetData( m_tls, ... ); ... } public void Main() { SomeData sd = new SomeData(); ... // создание и запуск потоков } }
class SomeData { [ThreadStatic] public static double xxx; ... Поле класса SomeData.xxx будет размещено в локальной для каждого потока памяти. Императивный подход связан с применением методов AllocateDataSlot, AllocateNamedDataSlot, GetNamedDataSlot, FreeNamedDataSlot, GetData и SetData класса Thread. Использование этих методов очень похоже на использование Tls... функций Win32 API, с той разницей, что вместо целочисленного индекса в TLS массиве потока (как это было в Win32 API) используется объект типа LocalDataStoreSlot, который выполняет функции прежнего индекса:
7.3.4.3. Локальная для потока память Применение локальной для потока памяти в .NET опирается на TLS память, поддерживаемую операционной системой. Аналогично Win32 API, возможны декларативный и императивный подходы для работы с локальной для потока памятью. Декларативный подход сводится к использованию атрибута ThreadStaticAttribute перед описанием любого статического поля. Например, в следующем фрагменте:
Конечно, аналогичного эффекта можно было бы добиться, просто используя блокировку (lock или методы класса Monitor) при доступе к объекту. Однако, такой подход потребует наложить блокировку исключительного доступа при чтении данных, что не эффективно. В обычных условиях вполне допустимо чтение данных несколькими одновременно выполняющимися потоками, что может дать заметное ускорение.
270
271
namespace TestNamespace { class TestTimer : Timer { private int m_minimal, m_maximal, m_counter; public int count { get{ return m_counter – m_minimal; }}
using System; using System.Timers;
.NET предлагает два вида таймеров: один описан в пространстве имен System.Timers, а другой – в пространстве имен System.Threading. Таймер пространства имен System.Threading является опечатанным и предназначен для вызова указанной асинхронной процедуры с заданным интервалом времени. Таймер пространства имен System.Timers может быть использован для создания собственных классов-потомков – в нем вместо процедуры асинхронного вызова применяется обработка события, с которым может быть сопоставлено несколько обработчиков. Кроме того, этот таймер может вызывать обработку события конкретным потоком, а не произвольным потоком пула:
7.3.5. Таймеры
Методы Allocate... и GetNamedDataSlot позволяют выделить новую ячейку в TLS памяти (или получить существующую именованную), методы GetData и SetData позволяют получить или сохранить ссылку на объект в TLS памяти. Использование TLS памяти в .NET менее удобно и эффективно, чем в Win32 API, но это связано не с реализацией TLS, а с реализацией потоков: • Во-первых, возможно размещение данных в TLS памяти только текущего потока, то есть нельзя положить данные до запуска потока. • Во-вторых, процедура потока не получает аргументов, то есть требуется предусмотреть отдельный механизм для передачи данных в функцию потока, а этот неизбежно реализуемый механизм окажется конкурентом существующей реализации TLS памяти. • В-третьих, использование TLS памяти в асинхронно вызываемых процедурах может быть ограничено теми соображениями, что заранее нельзя предугадать поток, который будет выполнять эту процедуру. • В-четвертых, использование методов ООП часто позволяет сохранить специфичные данные в полях объекта, вообще не прибегая к выделению TLS памяти.
Разработка параллельных приложений для ОС Windows
public TestTimer( int mn, int mx ) { Elapsed += new ElapsedEventHandler(OnElapsed); m_minimal = m_counter = mn; m_maximal = mx; AutoReset = true; Interval = 400; } static void OnElapsed( object src, ElapsedEventArgs e ) { TestTimer tt = (TestTimer)src; if ( tt.m_counter < tt.m_maximal ) tt.m_counter++; if ( tt.m_counter >= tt.m_maximal ) tt.Stop(); } static void Main(string[] args) { TestTimer tm = new TestTimer( 0, 10 ); tm.Start(); Thread.Sleep( 5000 ); tm.Stop(); }
CIL и системное программирование в Microsoft .NET
} } Приведенный выше пример иллюстрирует использование таймера пространства имен System.Timers.
272
273
1.
Common Language Infrastructure, Partition I: Concepts and Architecture. Microsoft. – .NET Framework SDK Tool Developer's Documentation. 2. Common Language Infrastructure, Partition II: Metadata Definition and Semantics. – .NET Framework SDK Tool Developer's Documentation. 3. Common Language Infrastructure, Partition III: CIL Instruction Set. – .NET Framework SDK Tool Developer's Documentation. 4. Common Language Runtime: Metadata Unmanaged API. – .NET Framework SDK Tool Developer's Documentation. 5. Microsoft Portable Executable and Common Object File Format Specification. – Microsoft Corporation, 1999. 6. М. Бертран. Объектно-ориентированное конструирование программных систем. – М.: Издательско-торговый дом «Русская редакция», 2005. – 1232 с. 7. Основы операционных систем. Курс лекций. Учебное пособие / В.Е. Карпов, К.А. Коньков / Под редакцией В.П. Иванникова. – М.: ИНТУИТ.РУ «Интернет- Университет Информационных Технологий», 2004. – 632 с. 8. Дж. Рихтер. Windows для профессионалов: создание эффективных Win32-приложений с учетом специфики 64-разрядной версии Windows. – СПб: Питер; М.: Издательско-торговый дом «Русская редакция», 2001. – 752 с. 9. Дж. Рихтер. Программирование на платформе Microsoft .NET Framework. – М.: Издательско-торговый дом «Русская редакция», 2003. – 512 с. 10. Д. Соломон, М. Руссинович. Внутреннее устройство Microsoft Windows 2000. – СПб: Питер; М.: Издательско-торговый дом «Русская редакция», 2001. – 752 с. 11. Д. Уоткинз, М. Хаммонд, Б. Эйбрамз. Программирование на платформе .NET. – М.: Издательский дом «Вильямс», 2003. – 368 с.
Литература
Литература
CIL и системное программирование в Microsoft .NET
2
#define SIZEOF_TEXT_NOTALIGNED(params) \
// Not aligned #define SIZEOF_HEADERS_NOTALIGNED \ sizeof(struct HEADERS)
#define SIZEOF_METHODS(params) \ align(params->SizeOfCilCode, SECTION_ALIGNMENT)
#define IMAGE_SUBSYSTEM_WINDOWS_GUI
0x00000020 0x00000040 0x00000080 0x02000000 0x20000000 0x40000000 0x80000000
#define SIZEOF_RELOC_M \ align(sizeof(struct RELOC_SECTION), SECTION_ALIGNMENT)
#define SIZEOF_CLI_M \ align(sizeof(struct CLI_SECTION_IMAGE), SECTION_ALIGNMENT)
#define SIZEOF_TEXT_M(params) \ align(params->SizeOfMetadata + params->SizeOfCilCode, \ SECTION_ALIGNMENT)
// Aligned to SectionAlignment boundary #define SIZEOF_HEADERS_M(params) \ align(sizeof(struct HEADERS), SECTION_ALIGNMENT)
#define SIZEOF_RELOC(params) \ align(sizeof(struct RELOC_SECTION), params->FileAlignment)
#define SIZEOF_CLI_NOTALIGNED \ sizeof(struct CLI_SECTION_IMAGE)
CNT_CODE CNT_INITIALIZED_DATA CNT_UNINITIALIZED_DATA MEM_DISCARDABLE MEM_EXECUTE MEM_READ MEM_WRITE
#define #define #define #define #define #define #define
0x2000 0x1 0x0 6 82
275
#define SIZEOF_CLI(params) \ align(sizeof(struct CLI_SECTION_IMAGE), params->FileAlignment)
#define SIZEOF_TEXT(params) \ align(params->SizeOfMetadata + params->SizeOfCilCode, \ params->FileAlignment)
// Aligned to FileAlignment boundary #define SIZEOF_HEADERS(params) \ align(sizeof(struct HEADERS), params->FileAlignment)
#define IMAGE_SUBSYSTEM_WINDOWS_CUI 3 #define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI 9
Исходный код программы pegen
#define IMAGE_FILE_MACHINE_I386 0x014c
SECTION_ALIGNMENT EXE_TYPE DLL_TYPE SIZEOF_JMP_STUB SIZEOF_IMPORT_TABLE
#define #define #define #define #define
#ifndef MACROS_H #define MACROS_H
A.1. macros.h
Исходный код программы pegen, выполняющей генерацию сборки .NET, размещен в четырех файлах: • macros.h Содержит макроопределения, которые используются в остальных файлах. • pe.h Интерфейс модуля генерации PE-файла. • pe.c Реализация модуля генерации PE-файла. • main.c Главный модуль, использующий модуль генерации для создания простейшей сборки .NET.
Приложение A. Исходный код программы pegen
274
CIL и системное программирование в Microsoft .NET
#endif
#define OFFSETOF(s,m) \ (size_t)&(((s *)0)->m)
#define TYPE_OFFSET(a,b) \ (a*0x1000 | b)
#define RVA_OF_RELOC(params) \ RVA_OF_CLI(params) + SIZEOF_CLI_M
#define RVA_OF_CLI(params) \ RVA_OF_TEXT + \ align(params->SizeOfMetadata + params->SizeOfCilCode, SECTION_ALIGNMENT)
// RVA of section #define RVA_OF_TEXT \ align(sizeof(struct HEADERS), SECTION_ALIGNMENT)
#define SIZEOF_DATA_DIRECTORY \ sizeof(struct IMAGE_DATA_DIRECTORY)
#define SIZEOF_RELOC_NOTALIGNED \ sizeof(struct RELOC_SECTION)
#define SIZEOF_CLI_HEADER \ sizeof(struct IMAGE_COR20_HEADER)
params->SizeOfMetadata + params->SizeOfCilCode
// Block of input parameters struct INPUT_PARAMETERS { unsigned long Type;
#include <stdio.h>
#ifndef PE_H
A.2. pe.h
276
long long long short
ImageBase; FileAlignment; EntryPointToken; Subsystem;
SizeOfMetadata; SizeOfCilCode;
*metadata; *cilcode;
make_file
(FILE *file, PINPUT_PARAMETERS inP);
// MS-DOS header // PE signature // PE header
char ms_dos_header[128]; unsigned long signature; struct _IMAGE_FILE_HEADER {
struct HEADERS {
struct IMAGE_SECTION_HEADER { unsigned char Name[8]; unsigned long VirtualSize; unsigned long VirtualAddress; unsigned long SizeOfRawData; unsigned long PointerToRawData; unsigned long PointerToRelocations; unsigned long PointerToLinenumbers; unsigned short NumberOfRelocations; unsigned short NumberOfLinenumbers; unsigned long Characteristics; };
typedef struct IMAGE_DATA_DIRECTORY *PIMAGE_DATA_DIRECTORY;
// Struct IMAGE_DATA_DIRECTORY struct IMAGE_DATA_DIRECTORY { unsigned long RVA; unsigned long Size; };
void
}; typedef struct INPUT_PARAMETERS *PINPUT_PARAMETERS;
unsigned unsigned unsigned unsigned
unsigned long unsigned long
unsigned char unsigned char
Исходный код программы pegen
277
278
short short long long long short short
Machine; NumberOfSections; TimeDateStamp; PointerToSymbolTable; NumberOfSymbols; OptionalHeaderSize; Characteristics;
struct _IMAGE_OPTIONAL_HEADER { // optional PE header unsigned short Magic; unsigned char LMajor; unsigned char LMinor; unsigned long CodeSize; unsigned long SizeOfInitializedData; unsigned long SizeOfUninitializedData; unsigned long EntryPointRVA; unsigned long BaseOfCode; unsigned long BaseOfData; unsigned long ImageBase; unsigned long SectionAlignment; unsigned long FileAlignment; unsigned short OSMajor; unsigned short OSMinor; unsigned short UserMajor; unsigned short UserMinor; unsigned short SubsysMajor; unsigned short SubsysMinor; unsigned long Reserved; unsigned long ImageSize; unsigned long HeaderSize; unsigned long FileCheckSum; unsigned short Subsystem; unsigned short DllFlags; unsigned long StackReserveSize; unsigned long StackCommitSize; unsigned long HeapReserveSize; unsigned long HeapCommitSize; unsigned long LoaderFlags; unsigned long NumberOfDataDirectories; }OptHdr;
unsigned unsigned unsigned unsigned unsigned unsigned unsigned }PeHdr;
CIL и системное программирование в Microsoft .NET
IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY IMAGE_DATA_DIRECTORY
STUB1; IMPORT_DIRECTORY; // import directory STUB2[3]; BASE_RELOC_DIRECTORY; STUB3[6]; IAT_DIRECTORY; // IAT directory STUB4; CLI_DIRECTORY; // CLI directory STUB5;
279
// entry point JmpInstruction; JmpAddress;
// Import table /* Import Address Table */ unsigned long HintNameTableRVA2; unsigned long zero2; /* Import Directory Entry */
struct _IMPORT_TABLE {
struct _CLI_HEADER { // CLI header unsigned long cb; unsigned short MajorRuntimeVersion; unsigned short MinorRuntimeVersion; struct IMAGE_DATA_DIRECTORY MetaData; unsigned long Flags; unsigned long EntryPointToken; struct IMAGE_DATA_DIRECTORY NotUsed[6]; }CLI_HEADER;
struct _JMP_STUB { unsigned short unsigned long }JMP_STUB;
// .CLI Section struct CLI_SECTION_IMAGE {
typedef struct HEADERS *PHEADERS;
};
struct IMAGE_SECTION_HEADER TEXT_SECTION; // .text section header struct IMAGE_SECTION_HEADER CLI_SECTION; // .cli section header struct IMAGE_SECTION_HEADER RELOC_SECTION; // .reloc section header
struct struct struct struct struct struct struct struct struct
Исходный код программы pegen
280
ImportLookupTableRVA; TimeDateStamp; ForwarderChain; NameRVA; ImportAddressTableRVA; zero[20];
struct IMAGE_COR20_HEADER { unsigned long cb; unsigned short MajorRuntimeVersion; unsigned short MinorRuntimeVersion; struct IMAGE_DATA_DIRECTORY MetaData; unsigned long Flags; unsigned long EntryPointToken; struct IMAGE_DATA_DIRECTORY Resources; struct IMAGE_DATA_DIRECTORY StrongNameSignature; struct IMAGE_DATA_DIRECTORY CodeManagerTable; struct IMAGE_DATA_DIRECTORY VTableFixups;
PageRVA; BlockSize; TypeOffset; Padding;
/* Dll name (“mscoree.dll”) */ char DllName[12]; }IMPORT_TABLE;
/* Hint/Name Table */ unsigned short Hint; char Name[12];
//.reloc Section struct RELOC_SECTION { unsigned long unsigned long unsigned short unsigned short };
};
long long long long long char
/* Import Lookup Table */ unsigned long HintNameTableRVA1; unsigned long zero1;
unsigned unsigned unsigned unsigned unsigned unsigned
CIL и системное программирование в Microsoft .NET
make_headers (FILE* file, PINPUT_PARAMETERS inP); make_text_section (FILE* file, PINPUT_PARAMETERS inP); make_cli_section (FILE* file, PINPUT_PARAMETERS inP); make_reloc_section (FILE* file, PINPUT_PARAMETERS inP);
<stdlib.h> <string.h> “pe.h” “macros.h”
ExportAddressTableJumps; ManagedNativeHeader;
unsigned char 0x4D, 0x5A, 0x04, 0x00, 0xB8, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
msdos_header[128] 0x90, 0x00, 0x03, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
= { 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
void make_file (FILE* file, PINPUT_PARAMETERS inP) { make_headers(file, inP); // Stage 1 make_text_section(file, inP); // Stage 2 make_cli_section(file, inP); // Stage 3 make_reloc_section(file, inP); // Stage 4 }
unsigned long align(unsigned long x, unsigned long alignment) { div_t t = div(x,alignment); return t.rem == 0 ? x : (t.quot+1)*alignment; };
void void void void
#include #include #include #include #include
A.3. pe.c
#endif
};
struct IMAGE_DATA_DIRECTORY struct IMAGE_DATA_DIRECTORY
Исходный код программы pegen
281
282
0x00, 0x1F, 0xB8, 0x73, 0x6D, 0x20, 0x69, 0x6F, 0x00,
0x00, 0xBA, 0x01, 0x20, 0x20, 0x62, 0x6E, 0x64, 0x00,
0x00, 0x0E, 0x4C, 0x70, 0x63, 0x65, 0x20, 0x65, 0x00,
0x80, 0x00, 0xCD, 0x72, 0x61, 0x20, 0x44, 0x2E, 0x00,
0x00, 0xB4, 0x21, 0x6F, 0x6E, 0x72, 0x4F, 0x0D, 0x00,
0x00, 0x09, 0x54, 0x67, 0x6E, 0x75, 0x53, 0x0D, 0x00,
0x00, 0xCD, 0x68, 0x72, 0x6F, 0x6E, 0x20, 0x0A, 0x00
// Optional Header Hdr->OptHdr.Magic = 0x010B; Hdr->OptHdr.LMajor = 6; Hdr->OptHdr.LMinor = 0; Hdr->OptHdr.SizeOfUninitializedData = 0; Hdr->OptHdr.SectionAlignment = SECTION_ALIGNMENT; Hdr->OptHdr.OSMajor = 4; Hdr->OptHdr.OSMinor = 0; Hdr->OptHdr.UserMajor = 0; Hdr->OptHdr.UserMinor = 0; Hdr->OptHdr.SubsysMajor = 4; Hdr->OptHdr.SubsysMinor = 0; Hdr->OptHdr.Reserved = 0; Hdr->OptHdr.FileCheckSum = 0; Hdr->OptHdr.DllFlags = 0x400; Hdr->OptHdr.StackReserveSize = 0x100000; Hdr->OptHdr.StackCommitSize = 0x1000; Hdr->OptHdr.HeapReserveSize = 0x100000; Hdr->OptHdr.HeapCommitSize = 0x1000; Hdr->OptHdr.LoaderFlags = 0;
Hdr->PeHdr.Machine = IMAGE_FILE_MACHINE_I386; Hdr->PeHdr.PointerToSymbolTable = 0; Hdr->PeHdr.NumberOfSymbols = 0; Hdr->PeHdr.OptionalHeaderSize = 0xe0;
Hdr->signature = 0x00004550;
// initialize constant fields in HEADERS structure void make_headers_const(PHEADERS Hdr){ memcpy(Hdr->ms_dos_header, msdos_header, 128);
};
0x00, 0x0E, 0x21, 0x69, 0x61, 0x74, 0x20, 0x6D, 0x24,
CIL и системное программирование в Microsoft .NET
// initialize to 0 memset(&Hdr->STUB1.RVA, memset(Hdr->STUB2, 0, 3 memset(Hdr->STUB3, 0, 6 memset(&Hdr->STUB4.RVA, memset(&Hdr->STUB5.RVA,
0, SIZEOF_DATA_DIRECTORY); * SIZEOF_DATA_DIRECTORY); * SIZEOF_DATA_DIRECTORY); 0, SIZEOF_DATA_DIRECTORY); 0, SIZEOF_DATA_DIRECTORY);
make_headers_const(&Hdr); Hdr.PeHdr.NumberOfSections = 3; Hdr.PeHdr.TimeDateStamp = (long)time(NULL);
struct HEADERS Hdr; char * image;
// initialize HEADERS structure void make_headers(FILE* file ,PINPUT_PARAMETERS inP){
};
= 0; = 0; = 0; = 0; 0x60000020;
= 0; = 0; = 0; = 0; 0x60000020;
// .RELOC section Hdr->RELOC_SECTION.PointerToRelocations = 0; Hdr->RELOC_SECTION.PointerToLinenumbers = 0; Hdr->RELOC_SECTION.NumberOfRelocations = 0; Hdr->RELOC_SECTION.NumberOfLinenumbers = 0; Hdr->RELOC_SECTION.Characteristics = 0x42000040;
// CLI section Hdr->CLI_SECTION.PointerToRelocations Hdr->CLI_SECTION.PointerToLinenumbers Hdr->CLI_SECTION.NumberOfRelocations Hdr->CLI_SECTION.NumberOfLinenumbers Hdr->CLI_SECTION.Characteristics =
// TEXT section Hdr->TEXT_SECTION.PointerToRelocations Hdr->TEXT_SECTION.PointerToLinenumbers Hdr->TEXT_SECTION.NumberOfRelocations Hdr->TEXT_SECTION.NumberOfLinenumbers Hdr->TEXT_SECTION.Characteristics =
Hdr->OptHdr.NumberOfDataDirectories = 16;
Исходный код программы pegen
283
284
= RVA_OF_CLI(inP) + SIZEOF_JMP_STUB; = SIZEOF_CLI_HEADER;
Hdr.TEXT_SECTION.VirtualSize = SIZEOF_TEXT_NOTALIGNED(inP); Hdr.TEXT_SECTION.VirtualAddress = SIZEOF_HEADERS_M(inP);
//TEXT section memset(Hdr.TEXT_SECTION.Name, 0, sizeof(Hdr.TEXT_SECTION.Name)); strcpy((char*)Hdr.TEXT_SECTION.Name, “.text”);
// CLI Directory Hdr.CLI_DIRECTORY.RVA Hdr.CLI_DIRECTORY.Size
// Base Reloc Directory Hdr.BASE_RELOC_DIRECTORY.RVA = RVA_OF_RELOC(inP); Hdr.BASE_RELOC_DIRECTORY.Size = 0x0C;
// Import Address Directory Hdr.IAT_DIRECTORY.RVA = RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.HintNameTableRVA2); Hdr.IAT_DIRECTORY.Size = 0x08;
// Import Directory Hdr.IMPORT_DIRECTORY.RVA = RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.ImportLookupTableRVA); Hdr.IMPORT_DIRECTORY.Size = 0x53;
Hdr.OptHdr.CodeSize = SIZEOF_TEXT_M(inP); Hdr.OptHdr.SizeOfInitializedData = SIZEOF_TEXT_M(inP); Hdr.OptHdr.EntryPointRVA = RVA_OF_CLI(inP); Hdr.OptHdr.BaseOfCode = RVA_OF_TEXT; Hdr.OptHdr.BaseOfData = 0; Hdr.OptHdr.ImageBase = inP->ImageBase; Hdr.OptHdr.FileAlignment = inP->FileAlignment; Hdr.OptHdr.ImageSize = RVA_OF_RELOC(inP) + SIZEOF_RELOC_M; Hdr.OptHdr.HeaderSize = SIZEOF_HEADERS(inP); Hdr.OptHdr.Subsystem = inP->Subsystem;
if(inP->Type == EXE_TYPE) Hdr.PeHdr.Characteristics = 0x010E; else Hdr.PeHdr.Characteristics = 0x210E;
CIL и системное программирование в Microsoft .NET
memset(image,0,SIZEOF_HEADERS(inP)); memcpy(image,(char *)&Hdr, SIZEOF_HEADERS_NOTALIGNED); fwrite(image,1,SIZEOF_HEADERS(inP),file); free(image);
image = malloc(SIZEOF_HEADERS(inP));
Hdr.RELOC_SECTION.VirtualSize = SIZEOF_RELOC_NOTALIGNED; Hdr.RELOC_SECTION.VirtualAddress = RVA_OF_RELOC(inP); Hdr.RELOC_SECTION.SizeOfRawData = SIZEOF_RELOC(inP); Hdr.RELOC_SECTION.PointerToRawData = SIZEOF_HEADERS(inP) + SIZEOF_TEXT(inP) + SIZEOF_CLI(inP); //END of initializing .RELOC section
//.RELOC section memset(Hdr.RELOC_SECTION.Name, 0, sizeof(Hdr.RELOC_SECTION.Name)); strcpy((char*)Hdr.RELOC_SECTION.Name, “.reloc”);
Hdr.CLI_SECTION.VirtualSize = SIZEOF_CLI_NOTALIGNED; Hdr.CLI_SECTION.VirtualAddress = SIZEOF_HEADERS_M(inP) + SIZEOF_TEXT_M(inP); Hdr.CLI_SECTION.SizeOfRawData = SIZEOF_CLI(inP); Hdr.CLI_SECTION.PointerToRawData = SIZEOF_HEADERS(inP) + SIZEOF_TEXT(inP); //END of initializing CLI section
image = malloc(SIZEOF_TEXT(inP)); memset(image, 0, SIZEOF_TEXT(inP)); memcpy(image, inP->metadata, inP->SizeOfMetadata);
// initialize .TEXT section void make_text_section(FILE * file, PINPUT_PARAMETERS inP) { char * image;
};
285
//.cli section memset(Hdr.CLI_SECTION.Name, 0, sizeof(Hdr.CLI_SECTION.Name)); strcpy((char*)Hdr.CLI_SECTION.Name, “.cli”);
Hdr.TEXT_SECTION.SizeOfRawData = SIZEOF_TEXT(inP); Hdr.TEXT_SECTION.PointerToRawData = SIZEOF_HEADERS(inP); //END of initializing TEXT section
Исходный код программы pegen
286
= 0; = 0;
= RVA_OF_CLI(inP) +
cls.IMPORT_TABLE.NameRVA = RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.DllName);
cls.IMPORT_TABLE.TimeDateStamp cls.IMPORT_TABLE.ForwarderChain
//Import Table cls.IMPORT_TABLE.ImportLookupTableRVA OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.HintNameTableRVA1);
memset(cls.CLI_HEADER.NotUsed, 0, 6*sizeof(struct IMAGE_DATA_DIRECTORY));
//CLI_HEADER cls.CLI_HEADER.cb = SIZEOF_CLI_HEADER; cls.CLI_HEADER.MajorRuntimeVersion = 2; cls.CLI_HEADER.MinorRuntimeVersion = 0; cls.CLI_HEADER.MetaData.RVA = RVA_OF_TEXT; cls.CLI_HEADER.MetaData.Size = inP->SizeOfMetadata; cls.CLI_HEADER.Flags = 1; cls.CLI_HEADER.EntryPointToken = inP->EntryPointToken;
cls.JMP_STUB.JmpAddress = RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.Hint) + inP->ImageBase;
//JMP_STUB cls.JMP_STUB.JmpInstruction = 0x25FF;
// initialize .CLI section void make_cli_section(FILE * file, PINPUT_PARAMETERS inP) { struct CLI_SECTION_IMAGE cls; char * image;
}
fwrite(image, 1, SIZEOF_TEXT(inP), file); free(image);
memcpy(image+inP->SizeOfMetadata, inP->cilcode, inP->SizeOfCilCode);
CIL и системное программирование в Microsoft .NET
rls.PageRVA = RVA_OF_CLI(inP); rls.BlockSize = SIZEOF_RELOC_NOTALIGNED; rls.TypeOffset = TYPE_OFFSET(0x3,0x2); rls.Padding = 0;
// initialize .RELOC section void make_reloc_section(FILE* file, PINPUT_PARAMETERS inP) { struct RELOC_SECTION rls; char * image;
};
image = malloc(SIZEOF_CLI(inP)); memset(image, 0, SIZEOF_CLI(inP)); memcpy(image, (char *) &cls, SIZEOF_CLI_NOTALIGNED); fwrite(image,1, SIZEOF_CLI(inP),file); free(image);
strcpy(cls.IMPORT_TABLE.DllName, “mscoree.dll”);
if(inP->Type == EXE_TYPE) strcpy(cls.IMPORT_TABLE.Name, “_CorExeMain”); else strcpy(cls.IMPORT_TABLE.Name, “_CorDllMain”);
cls.IMPORT_TABLE.Hint = 0;
cls.IMPORT_TABLE.zero2 = 0;
cls.IMPORT_TABLE.HintNameTableRVA2 = (RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.Hint));
cls.IMPORT_TABLE.zero1 = 0;
cls.IMPORT_TABLE.HintNameTableRVA1 = (RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.Hint)) ;
memset(cls.IMPORT_TABLE.zero, 0, 20);
cls.IMPORT_TABLE.ImportAddressTableRVA = RVA_OF_CLI(inP) + OFFSETOF(struct CLI_SECTION_IMAGE, IMPORT_TABLE.HintNameTableRVA2);
Исходный код программы pegen
287
};
CIL и системное программирование в Microsoft .NET
// METADATA extern unsigned char metadata[] 0x42, 0x53, 0x4A, 0x42, 0x01, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x76, 0x31, 0x2E, 0x31, 0x2E, 0x32, 0x00, 0x00, 0x00, 0x00, 0xAC, 0x00, 0x00, 0x00, 0x80, 0x23, 0x53, 0x74, 0x72, 0x69, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x40, 0x00, 0x00, 0x00, 0x23, 0x6C, 0x00, 0x00, 0x00, 0x40, 0x23, 0x42, 0x6C, 0x6F, 0x62, 0x6C, 0x01, 0x00, 0x00, 0x10, 0x23, 0x47, 0x55, 0x49, 0x44, 0x7C, 0x01, 0x00, 0x00, 0x96, 0x23, 0x7E, 0x00, 0x00, 0x00, 0x01, 0x04, 0x00, 0x01, 0x01, 0x7A, 0x5C, 0x56, 0x19, 0x34, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x74, 0x68, 0x00, 0x61, 0x72, 0x2E, 0x65, 0x78, 0x65, 0x00,
#include “pe.h” #include “macros.h”
#include <stdlib.h> #include <stdio.h>
= { 0x00, 0x00, 0x34, 0x00, 0x00, 0x6E, 0x01, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x0E, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0x69, 0x3C, 0x01, 0x00, 0x33, 0x05, 0x00, 0x67, 0x00, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x89, 0x00, 0x00, 0x00, 0x00, 0x00, 0x72, 0x74, 0x4D,
0x00, 0x00, 0x32, 0x00, 0x00, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB7, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x69, 0x68, 0x6F,
image = malloc(SIZEOF_RELOC(inP)); memset(image, 0, SIZEOF_RELOC(inP)); memcpy(image, (char *)&rls, SIZEOF_RELOC_NOTALIGNED); fwrite(image,1, SIZEOF_RELOC(inP),file); free(image);
A.4. main.c
288
0x64, 0x6C, 0x6C, 0x65, 0x6C, 0x4C, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x26, 0x01, 0x09, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x13, 0x04, 0x01, 0x00, 0x00, 0x00,
0x75, 0x63, 0x69, 0x6D, 0x65, 0x69, 0x4C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xD7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0x6C, 0x00, 0x62, 0x00, 0x00, 0x6E, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x2D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x06, 0x00, 0x01, 0x00, 0x01, 0x09, 0x00, 0x01, 0x01, 0x88, 0x00,
0x65, 0x6D, 0x00, 0x43, 0x57, 0x65, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4F, 0xDF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13, 0x00,
Исходный код программы pegen
0x3E, 0x73, 0x53, 0x6F, 0x72, 0x00, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1B, 0xA8, 0x00, 0x47, 0x00, 0x01, 0x01, 0x02, 0x01, 0x01, 0x2F, 0x11, 0x01, 0x16, 0x09, 0x37, 0x01, 0x00, 0x00, 0x00, 0x1F,
0x00, 0x63, 0x79, 0x6E, 0x69, 0x52, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2D, 0x8A, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x63, 0x6F, 0x73, 0x73, 0x74, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x6F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0x85, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x00, 0x14, 0x1A, 0x41, 0x05, 0x00, 0x00, 0x01, 0x00, 0x00,
0x61, 0x72, 0x74, 0x6F, 0x65, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x86, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
289
290
0x00, 0x00, 0x00, 0x00
}
fclose(f); return 0;
make_file(f, ¶ms);
if(params.Type == EXE_TYPE){ printf(“File: hello.exe generated\n”); f = fopen(“hello.exe”,”wb”); } else{ printf(“File: hello.dll generated\n”); f = fopen(“hello.dll”,”wb”); }
params.FileAlignment = 0x1000; params.SizeOfMetadata = sizeof(metadata); params.SizeOfCilCode = sizeof(cilcode); params.ImageBase = 0x400000; params.EntryPointToken = 0x06000001; params.Type = EXE_TYPE; params.Subsystem = IMAGE_SUBSYSTEM_WINDOWS_CUI; params.metadata = metadata; params.cilcode = cilcode;
struct INPUT_PARAMETERS params;
int main() { FILE *f;
// CIL CODE extern unsigned char cilcode[] = { 0x56, 0x72, 0x01, 0x00, 0x00, 0x70, 0x28, 0x02, 0x00, 0x00, 0x0A, 0x28, 0x01, 0x00, 0x00, 0x0A, 0x28, 0x02, 0x00, 0x00, 0x0A, 0x2A };
};
CIL и системное программирование в Microsoft .NET
291
System; System.Collections; System.Reflection; System.Reflection.Emit;
private OpCode code;
#region Private members
public override string ToString() { return String.Format(“ IL_{0:X8}: “,Offset) + Code.ToString().PadRight(10) + “ “ + formatOperand(); }
public object Operand { get { return operand; } set { operand = value; } }
public int Offset { get { return offset; } }
public OpCode Code { get { return code; } }
public class Instruction { public Instruction(OpCode code, int offset) { this.code = code; this.offset = offset; this.operand = null; }
using using using using
Исходный код программы CilCodec, выполняющей кодирование/декодирование потока инструкций языка CIL, располагается в файле CilCodec.cs:
Приложение Б. Исходный код программы CilCodec
Исходный код программы CilCodec
292
#endregion
}
case OperandType.InlineI: /* int32 */ ins.Operand = BitConverter.ToInt32(cilStream,offset); offset += 4;
default: /* token */ s = String.Format(“{0:X8}”,operand); return “(“+s.Substring(0,2)+”)”+s.Substring(2);
293
case OperandType.ShortInlineBrTarget: /* int8 */ ins.Operand = offset + 1 + (sbyte)cilStream[offset]; offset++; break;
switch (code.OperandType) { case OperandType.InlineNone: /* None */ break;
Instruction ins = new Instruction(code,offset); offset += code.Size;
while (offset < cilStream.Length) { OpCode code; short s2 = cilStream[offset]; if (s2 == 0xFE) { byte s1 = cilStream[offset+1]; code = (OpCode)(codes[((s2 16)); Result.Add((byte) ((int)ins.Operand >> 8)); Result.Add((byte) ((int)ins.Operand)); break;
foreach(int target in targets) { operands = BitConverter.GetBytes(target-nextOpOffset); foreach( byte op in operands ) Result.Add(op); } break;
CIL и системное программирование в Microsoft .NET
static byte[] test1 = new byte[] { 0x23, 0, 0, 0, 0, 0, 0, 0xF0, 0x3F, /* IL_0000: ldc.r8 1. 0x0A, /* IL_0009: stloc.0 0x2B, 0x1B, /* IL_000a: br.s IL_0027 0x03, /* IL_000c: ldarg.1 0x18, /* IL_000d: ldc.i4.2 0x5D, /* IL_000e: rem 0x17, /* IL_000f: ldc.i4.1 0x33, 0x0B, /* IL_0010: bne.un.s IL_001d 0x03, /* IL_0012: ldarg.1 0x17, /* IL_0013: ldc.i4.1 0x59, /* IL_0014: sub 0x10, 0x01, /* IL_0015: starg.s 1 0x06, /* IL_0017: ldloc.0
#region Sample instruction streams
}
299
*/ */ */ */ */ */ */ */ */ */ */ */ */
Console.WriteLine(areEqual ? “Codec is correct” : “Codec is incorrect”); Console.WriteLine(“---------------------------------------”);
byte[] cilStream2 = CilCodec.EncodeCil(instrArray); bool areEqual = cilStream.Length == cilStream2.Length; for (int i = 0; areEqual && i < cilStream.Length; i++) areEqual = cilStream[i] == cilStream2[i];
Instruction[] instrArray = CilCodec.DecodeCil(cilStream); foreach (Instruction ins in instrArray) Console.WriteLine(ins);
static void testCodec(byte[] cilStream) { Console.WriteLine(“Decoded CIL stream:”);
static void Main() { testCodec(test1); testCodec(test2); }
Исходный код программы CilCodec
300
0x30, 0xE8, 0x06, 0x0B, 0x2B, 0x00, 0x07, 0x2A
/* IL_0018: ldarg.0 /* IL_0019: mul /* IL_001a: stloc.0 /* IL_001b: br.s IL_0027 /* IL_001d: ldarg.1 /* IL_001e: ldc.i4.2 /* IL_001f: div /* IL_0020: starg.s 1 /* IL_0022: ldarg.0 /* IL_0023: ldarg.0 /* IL_0024: mul /* IL_0025: starg.s 0 /* IL_0027: ldarg.1 /* IL_0028: brtrue.s IL_000c /* IL_002a: br.s IL_0038 /* IL_002c: ldloc.0 0, 0, 0, 0, 0x24, 0x40, /* IL_002d: ldc.r8 10. /* IL_0036: div /* IL_0037: stloc.0 /* IL_0038: ldloc.0 0, 0, 0, 0, 0x14, 0x40, /* IL_0039: ldc.r8 5. /* IL_0042: bgt.s IL_002c /* IL_0044: ldloc.0 /* IL_0045: stloc.1 /* IL_0046: br.s IL_0048 /* IL_0048: ldloc.1 /* IL_0049: ret
static byte[] test2 = new byte[] { 0x02, /* IL_0000: ldarg.0 0x0B, /* IL_0001: stloc.1 0x07, /* IL_0002: ldloc.1 0x45, 0x03, 0, 0, 0, /* IL_0003: switch ( 0x02, 0, 0, 0, /* IL_0016, 0x06, 0, 0, 0, /* IL_001a, 0x0A, 0, 0, 0, /* IL_001e) 0x2B, 0x0C, /* IL_0014: br.s IL_0022
};
0, 0,
0xE2, 0x0C,
0x00,
0x01,
0x0A,
0x5B, 0x0A, 0x06, 0x23, 0, 0,
0x02, 0x5A, 0x0A, 0x2B, 0x03, 0x18, 0x5B, 0x10, 0x02, 0x02, 0x5A, 0x10, 0x03, 0x2D, 0x2B, 0x06, 0x23,
*/ */ */ */ */
*/ */ */
*/ */ */ */ */ */ */
*/ */ */ */
*/ */ */ */ */ */ */ */ */ */ */ */ */ */ */ */
CIL и системное программирование в Microsoft .NET
}
0x00,
0x04,
0x08,
0x0C,
#endregion
};
0x17, 0x0A, 0x2B, 0x16, 0x0A, 0x2B, 0x17, 0x0A, 0x2B, 0x16, 0x0A, 0x2B, 0x06, 0x2A
/* /* /* /* /* /* /* /* /* /* /* /* /* /*
IL_0016: IL_0017: IL_0018: IL_001a: IL_001b: IL_001c: IL_001e: IL_001f: IL_0020: IL_0022: IL_0023: IL_0024: IL_0026: IL_0027:
Исходный код программы CilCodec
ldc.i4.1 stloc.0 br.s IL_0026 ldc.i4.0 stloc.0 br.s IL_0026 ldc.i4.1 stloc.0 br.s IL_0026 ldc.i4.0 stloc.0 br.s IL_0026 ldloc.0 ret
*/ */ */ */ */ */ */ */ */ */ */ */ */ */
301
CIL и системное программирование в Microsoft .NET
System; System.Globalization; System.Reflection.Emit; System.Text.RegularExpressions;
public override string GenerateCS() { return “-(“+a.GenerateCS()+”)”; }
public UnaryExpression(Expression a) { this.a = a; }
class UnaryExpression: Expression { private Expression a;
public abstract class Expression { public abstract string GenerateCS(); public abstract void GenerateCIL(ILGenerator il); public abstract double Evaluate(double x); }
using using using using
B.1. Expr.cs
Исходный код программы Integral, демонстрирующей различные способы динамической генерации кода на примере вычисления определенного интеграла, состоит из двух файлов: • Expr.cs Содержит парсер арифметических выражений и классы для дерева абстрактного синтаксиса. • Integral.cs Содержит классы для динамической генерации кода и вычисления интеграла.
Приложение B. Исходный код программы Integral
302
303
public override void GenerateCIL(ILGenerator il)
public override string GenerateCS() { return “(“+a.GenerateCS()+”)”+opCs()+”(“+b.GenerateCS()+”)”; }
private string opCs() { if (op.Equals(OpCodes.Add)) return “+”; else if (op.Equals(OpCodes.Sub)) return “-”; else if (op.Equals(OpCodes.Mul)) return “*”; else return “/”; }
public BinaryExpression(Expression a, Expression b, OpCode op) { this.a = a; this.b = b; this.op = op; }
class BinaryExpression: Expression { private Expression a, b; private OpCode op;
}
public override double Evaluate(double x) { return -a.Evaluate(x); }
public override void GenerateCIL(ILGenerator il) { a.GenerateCIL(il); il.Emit(OpCodes.Neg); }
Исходный код программы Integral
304
a.GenerateCIL(il); b.GenerateCIL(il); il.Emit(op);
class VariableExpression: Expression
}
public override double Evaluate(double x) { return value; }
public override void GenerateCIL(ILGenerator il) { il.Emit(OpCodes.Ldc_R8,value); }
public override string GenerateCS() { return value.ToString(new CultureInfo(“”)); }
public ConstExpression(double value) { this.value = value; }
if (isAddOp())
public Expression Parse() { checkToken(); Expression result = null; OpCode op = OpCodes.Add;
}
token = Regex.Match(expr, “x|” + // identifier x REGEXP_NUMBER+”|”+ // floating-point numbers “\\+|\\-|\\*|/|”+ // arithmetic operators “\\(|\\)” // parens );
public Parser(string expr) {
public class Parser { private const string REGEXP_NUMBER = “[0-9]+(.[0-9])?”; private Match token;
class ConstExpression: Expression { private double value;
public override double Evaluate(double x) { return x; }
public override void GenerateCIL(ILGenerator il) { il.Emit(OpCodes.Ldarg_1); }
public override string GenerateCS() { return “x”; }
public VariableExpression() { }
}
{
Исходный код программы Integral
}
public override double Evaluate(double x) { if (op.Equals(OpCodes.Add)) return a.Evaluate(x) + b.Evaluate(x); else if (op.Equals(OpCodes.Sub)) return a.Evaluate(x) – b.Evaluate(x); else if (op.Equals(OpCodes.Mul)) return a.Evaluate(x) * b.Evaluate(x); else return a.Evaluate(x) / b.Evaluate(x); }
}
{
CIL и системное программирование в Microsoft .NET
305
306
return result;
if (isNumber()) {
private Expression parseFactor() { checkToken(); Expression result = null;
}
while (token.Success && isMulOp()) { OpCode op = token.Value.Equals(“*”) ? OpCodes.Mul : OpCodes.Div; token = token.NextMatch(); result = new BinaryExpression(result,parseFactor(),op); }
private bool isAddOp() { return Regex.IsMatch(token.Value,”\\+|\\-”); }
private bool isNumber() { return Regex.IsMatch(token.Value,REGEXP_NUMBER); }
private void throwError() { throw new Exception(“syntax error”); }
private void checkToken() { if (!token.Success) throwError(); }
return result;
private Expression parseTerm() { checkToken(); Expression result = parseFactor(); }
token = token.NextMatch();
} else throwError();
if (! token.Value.Equals(“)”)) throwError();
} else if (token.Value.Equals(“x”)) result = new VariableExpression(); else if (token.Value.Equals(“(“)) { token = token.NextMatch(); result = Parse();
IFormatProvider provider = new CultureInfo(“”); double val = Convert.ToDouble(token.Value,provider); result = new ConstExpression(val);
Исходный код программы Integral
}
return result;
while (token.Success && isAddOp()) { op = token.Value.Equals(“-”) ? OpCodes.Sub : OpCodes.Add; token = token.NextMatch(); result = new BinaryExpression(result,parseTerm(),op); }
result = parseTerm(); if (op.Equals(OpCodes.Sub)) result = new UnaryExpression(result);
{ op = token.Value.Equals(“-”) ? OpCodes.Sub : OpCodes.Add; token = token.NextMatch(); }
CIL и системное программирование в Microsoft .NET
307
}
CIL и системное программирование в Microsoft .NET
private bool isMulOp() { return Regex.IsMatch(token.Value,”\\*|/”); }
System; System.CodeDom.Compiler; System.Reflection; System.Reflection.Emit; System.Threading; Microsoft.CSharp;
public override double Eval(double x) { return expr.Evaluate(x); }
public InterpretingFunction(Expression expr) { this.expr = expr; }
public class InterpretingFunction: Function { private Expression expr;
public class TestFunction: Function { public override double Eval(double x) { return x * Math.Sin(x); } }
public abstract class Function { public abstract double Eval(double x); }
using using using using using using
B.2. Integral.cs
308
ModuleBuilder module =
AssemblyBuilder assembly = appDomain.DefineDynamicAssembly( assemblyName, AssemblyBuilderAccess.RunAndSave );
static Function CompileToCIL(Expression expr) { AppDomain appDomain = Thread.GetDomain(); AssemblyName assemblyName = new AssemblyName(); assemblyName.Name = “f”;
}
Assembly assembly = compilerResults.CompiledAssembly; return assembly.CreateInstance(“FunctionCS”) as Function;
CompilerResults compilerResults = compiler.CompileAssemblyFromSource(parameters,code);
class MainClass { static Function CompileToCS(Expression expr) { ICodeCompiler compiler = new CSharpCodeProvider().CreateCompiler(); CompilerParameters parameters = new CompilerParameters(); parameters.ReferencedAssemblies.Add(“System.dll”); parameters.ReferencedAssemblies.Add(“Integral.exe”); parameters.GenerateInMemory = true; string e = expr.GenerateCS(); string code = “public class FunctionCS: Function\n”+ “{\n”+ “ public override double Eval(double x)\n”+ “ {\n”+ “ return “+e+”;\n”+ “ }\n”+ “}\n”;
}
Исходный код программы Integral
309
310
static double Integrate(Function f, double a, double b, int n) {
}
ConstructorInfo ctor = type.GetConstructor(new Type[0]); return ctor.Invoke(null) as Function;
Type type = typeBuilder.CreateType();
ILGenerator il = evalMethod.GetILGenerator(); expr.GenerateCIL(il); il.Emit(OpCodes.Ret);
MethodBuilder evalMethod = typeBuilder.DefineMethod( “Eval”, MethodAttributes.Public | MethodAttributes.Virtual, typeof(double), new Type[] { typeof(double) } );
ILGenerator consIl = cons.GetILGenerator(); consIl.Emit(OpCodes.Ldarg_0); consIl.Emit(OpCodes.Call,typeof(object).GetConstructor(new Type[0])); consIl.Emit(OpCodes.Ret);
ConstructorBuilder cons = typeBuilder.DefineConstructor( MethodAttributes.Public, CallingConventions.Standard, new Type[] { } );
}
}
Console.WriteLine(“Interpreter: “+s1+” (“+(t2-t1)+”)”); Console.WriteLine(“C#: “+s2+” (“+ (t2_2-t2)+” + “+(t3-t2_2)+”)”); Console.WriteLine(“CIL: “+s3+” (“+ (t3_2-t3)+” + “+(t4-t3_2)+”)”);
DateTime t4 = DateTime.Now;
DateTime t3 = DateTime.Now; Function f3 = CompileToCIL(expr); DateTime t3_2 = DateTime.Now; double s3 = Integrate(f3,a,b,num);
DateTime t2 = DateTime.Now; Function f2 = CompileToCS(expr); DateTime t2_2 = DateTime.Now; double s2 = Integrate(f2,a,b,num);
DateTime t1 = DateTime.Now; Function f1 = new InterpretingFunction(expr); double s1 = Integrate(f1,a,b,num);
Parser parser = new Parser(s); Expression expr = parser.Parse();
static void Main() { int num = 10000000; double a = 0.0; double b = 10.0; string s = “2*x*x*x+3*x*x+4*x+5”;
return sum;
for (int i = 0; i < n; i++) sum += h*f.Eval((i+0.5)*h);
TypeBuilder typeBuilder = module.DefineType( “FunctionCIL”, TypeAttributes.Public | TypeAttributes.Class, typeof(Function) ); }
double h = (b-a)/n, sum = 0.0;
Исходный код программы Integral
assembly.DefineDynamicModule(“f.dll”, “f.dll”);
CIL и системное программирование в Microsoft .NET
311
Верификатор Верификация кода Виртуальная система выполнения Виртуальная страница Висящие указатели Волокно Встроенные типы-значения Встроенный операнд
Бинарные операции Блок обработки исключений Блок тела метода
Безопасный код Безопасный фрагмент программы Библиотека рефлексии
Ассемблер Атомарные операции
Асинхронный ввод-вывод
Асинхронные вызовы процедур
7 32-64, 66, 86 8, 28 22 149-152 29-30 3-4
185
185, 230
6 3 173-174 6 6
2 153-156
2 5
5
207, 257-260 201, 202-206, 255-257 Assembler 123-131, Atomic operation, 233-234, Interlocked operation 260-261 Safe code 9 Safe program fragment 10 Reflection API 77, 156-162, 167, 169 Binary operations 91-97, 165 Exception Handling Block 136, 137, 143 Method Body Block 135, 140, 142, 145 Verifier 2, 9, 147-148 Code verification 9, 147-152 Virtual Execution System – VES 6, 21-28 Virtual page 34-36 Dangling pointers 8 Fiber 194, 215-217 Built-in value types 13-14 Inline operand 84
.NET Framework Class Library Back-end Common Language Runtime – CLR Front-end Metadata Unmanaged API Mono p-System peephole optimization Portable .NET Shared Source CLI (Rotor) SMP, Shared Memory Processor MPP, Massively Parallel Processors JIT Compiler PE File Absolute instruction address Automatic memory management Active method Verification algorithm Garbage collection algorithm ANDF (Architectural Neutral Distribution Format) APC, asynchronous procedure call Asynchronous I/O
Предметный указатель
CIL и системное программирование в Microsoft .NET
JIT-компилятор PE-файл Абсолютный адрес инструкции Автоматическое управление памятью Активный метод Алгоритм верификации Алгоритм сборки мусора Архитектурно-нейтральный формат
312
Общая секция Общая система типов
Монитор Неверифицируемые инструкции Невытесняющая многозадачность Недопустимый код Неперехватываемые ошибки Область локальных данных Обработка исключений Обработчик finally Обработчик с пользовательской фильтрацией Обработчик с фильтрацией по типу Общая инфраструктура языков
Метаинструменты
Менеджер виртуальной памяти Метаданные
Вытесняющая многозадачность Генератор ANDF Граф потока управления Двойное освобождение Демаршалинг Дерево блоков Дескриптор потока Динамическая генерация кода Динамическая проверка типов Динамические библиотеки Дополнительный заголовок PE-файла Допустимый код Заголовок CLI Заголовок MS-DOS Заголовок PE-файла Заголовок секции Задача Запрещенные ошибки Защищенная область Защищенный блок Идеальный процессор Инсталлятор ANDF Квантование Контекст потока Корень метаданных Критические секции Куча Локальные переменные Маршалинг Маска сродства Межпроцессное взаимодействие
Предметный указатель
34 6,47,64-72, 76, 77, 151, 156 2, 6, 64, 133, 140, 152, 153, 156, 162 261-266 83,151 189, 190 9 10 27-28 116-123 119 119 119 5-7
189, 191 4 2 8 75 138-140 194 163 11 32 43-47 9 52-53 40-42 42-43 48-49 193, 194 10 117 135, 142 196, 230 4 188, 196-198 194 67 234-236, 261 13, 23, 29 27 75 230 243-244
Monitor Nonverifiable instructions Non-preemptive multitasking Illegal code Untrapped errors Local memory storage Exception handling Finally handler User-filtered handler Type-filtered handler Common Language Infrastructure – CLI Shared section, shared segment 249-250 Common Type System (CTS) 6, 9-20
Metainstruments
Preemptive multitasking ANDF Producer Control-flow graph Double free Demarshalling Block tree Thread descriptor Dynamic code production Dynamic type checking Dynamic Link Libraries – DLL PE Optional Header Legal code CLI Header MS-DOS Header PE Header Section Header Task Forbidden errors Protected area Protected block Ideal processor ANDF Installer Timeslice Thread context Metadata Root Critical Section Heap Local variables Marshalling Affinity mask IPC, Interprocess communication Virtual-memory manager Metadata
313
Сборка мусора Связывание функций
Разделяемый сегмент Распаковка Сборка .NET
Псевдонимы переменных Псевдоописатель Пул потоков
Проецирование файла Процесс
6, 82
36 229-230 225-229, 270-271 Trapped errors 10 Enumerations 18-19 sheduller 188 User value types 18-19 I/O completion port 201, 219-223 Thread 193, 209, 213-215, 251-254 Instruction stream 83-87 Metadata streams 67-68 User mode thread 194 Kernel mode thread 194 Thread-safety functions 212 Premature free 8 Affinity mask 230 Thread priority 189, 198-200, 211 File Mapping 245-249 Process 193, 209, 243-245 Variable aliases 181 Pseudo-handle 210-211 Thread pool 218-219, 223-224, 251-255 Shared segment, shared section 249-250 Unboxing 20 .NET assembly 7, 77, 125, 152, 157, 167 Garbage collection 7, 8, 28-31 Binding 39
Memory-mapped files FLS, Fiber Local Storage TLS, Thread Local Storage
6, 83-131 241-242, 266-268 Kernel objects 208-210, 236-238, 266 Alertable Waiting 206-207, 237 Waitable Timer 243 Security decriptor 25 Thread handle 210-211 Process handle 210-211 Optimizer 2, 152, 162, 173, Debugger 2, 152 Relative Virtual Address – RVA 38
Common Language Specification – CLS Common Intermediate Language – CIL Mutex
CIL и системное программирование в Microsoft .NET
Поток инструкций Потоки метаданных Потоки пользователя Потоки ядра Потоко-безопасные функции Преждевременное освобождение памяти Привязка к процессору Приоритет потока
Перехватываемые ошибки Перечисления Планировщик Пользовательские типы-значения Порт завершения ввода-вывода Поток
Отладчик Относительный виртуальный адрес элемента в памяти Отображаемые в память файлы Память, локальная для волокон Память, локальная для потоков
Ожидание оповещения Ожидающий таймер Описатель безопасности Описатель потока Описатель процесса Оптимизатор
Объекты ядра
Объект исключительного владения
Общий промежуточный язык
Общая спецификация языков
314
Таблица импортируемых сборок Таблица импортируемых типов Таблица методов Таблица модулей Таблица определенных в сборке типов Таблица сборок Таблица членов импортируемых типов Таблицы метаданных Таймеры Типизированные ссылки Типы-значения Типы-интерфейсы Указатели Унарные арифметические операции Упаковка Управляемые данные Утечки памяти Физическая страница Фрагментация адресного пространства Цель перехода Языки со строгой проверкой Ячейки
Совместимость по присваиванию Состояние виртуальной машины Состояние возврата Состояние метода Состояние нити Ссылочные типы Статическая проверка типов Стек вычислений
Секция в PE-файле Семафор Системы с неоднородным доступом к памяти Смещение элемента в файле Событие
Предметный указатель
AssemblyRef table TypeRef table Method table Module table TypeDef table Assembly table MemberRef table Metadata table Timer Typed references Value types Interface types Pointers Unary operations Boxing Managed data Memory leaks Physical page External fragmentation Jump target Strongly checked languages Locations
Assignment compatibility Virtual machine state Return state handler Method state Thread state Reference types Static type checking Evaluation stack
File offset Event
PE Section Semaphore NUMA, cc-NUMA
38 240-241 186, 230-231 38 238-239, 266-268 16 21-23 25 22, 23-25 22-23 12, 14-16 11 14, 24, 25-27, 85, 122, 129, 151, 182 71 72 71 70 70-71 69-70 72 68-70 243, 271-272 115 12 16 19-20 98 20 8 8 34 8 86 11 13, 16
315
Отпечатано с готовых диапозитивов на ФГУП ордена «Знак Почета» Смоленская областная типография им. В.И. Смирнова. Адрес: 214000, г. Смоленск, проспект им.Ю. Гагарина, д. 2.
ООО «ИНТУИТ.ру» Интернет-Университет Информационных Технологий, www.intuit.ru 123056, Москва, Электрический пер., 8, стр. 3.
Санитарно-эпидемиологическое заключение о соответствии санитарным правилам №77.99.02.953.Д.006052.08.03 от 12.08.2003
Формат 60x90 1/16. Усл. печ. л. 20,5. Бумага офсетная. Подписано в печать 20.10.2005. Тираж 2000 экз. Заказ № .
Литературный редактор С. Перепелкина Корректор Ю. Голомазова Компьютерная верстка Ю. Волшмид Обложка М. Автономова
А.В. Макаров, С.Ю. Скоробогатов, А.М. Чеповский Common Intermediate Language и системное программирование в Microsoft .NET
ОСНОВЫ ИНФОРМАТИКИ И МАТЕМАТИКИ