Исполняемый файл (
Для того чтобы загрузчик операционной системы мог правильно загрузить исполняемый файл в память, содержимое этого файла должно соответствовать принятому в данной операционной системе формату исполняемых файлов. В разных операционных системах в разное время существовало и до сих пор существует множество различных форматов. В этой главе мы рассмотрим формат Portable Executable (PE). Формат PE - это основной формат для хранения исполняемых файлов в операционной системе Windows. Сборки .NET тоже хранятся в этом формате.
Кроме того, формат PE может использоваться для представления
А теперь - немного истории. Формат PE был создан разработчиками Windows NT. До этого в операционной системе Windows использовались форматы New Executable (NE) и Linear Executable (LE) для представления исполняемых файлов, а для хранения
В ".NET Framework
Интересно отметить, что исполняемые файлы в устаревших форматах NE и LE до сих пор поддерживаются Windows. Исполняемые файлы в формате NE можно запускать под управлением NTVDM (NT Virtual DOS Machine), а формат LE используется для виртуальных драйверов устройств (
Почему в названии формата PE присутствует слово "portable" ("переносимый")? Дело в том, что Windows NT была реализована не только для платформы Intel x86, но и для платформ
Далее в этой главе мы не будем затрагивать 64-разрядный вариант формата PE, потому что в настоящее время сборки .NET хранятся в прежнем 32-разрядном формате. Однако отметим, что 64-разрядный PE очень слабо отличается от 32-разрядного. Основное отличие касается разрядности полей структур PE-файла.
Прежде чем перейти к рассмотрению формата PE, необходимо поговорить об особенностях управления памятью в Windows, так как без знания этих особенностей невозможно понять некоторые существенные детали формата.
Управление памятью в Windows NT/2k/XP/2k3 осуществляет менеджер виртуальной памяти (virtual-
Каждый процесс в Windows запускается в своем виртуальном адресном пространстве размером в 4 Гб. При этом первые 2 Гб адресного пространства могут непосредственно использоваться процессом, а остальные 2 Гб резервируются операционной системой для своих нужд (рис. 2.1).
(рис 2.1) Виртуальное адресное пространство процесса
Виртуальное адресное пространство также делится на виртуальные страницы размером в 4096 байт. При этом процессу выделяется только то количество виртуальных страниц, которое ему реально нужно. Поэтому тот факт, что процесс может адресовать 4 Гб виртуального адресного пространства, еще не означает, что каждому процессу выделяется по 4 Гб оперативной памяти. Как правило, процесс использует только малую часть своего адресного пространства, хотя стремительное удешевление модулей памяти способно в самое ближайшее время существенно изменить картину и вызвать повсеместно переход к 64-разрядным архитектурам.
Виртуальные страницы могут отображаться операционной системой в страницы физической памяти, могут храниться в файле подкачки, а также могут быть вообще недоступны процессу. Обращение к недоступной виртуальной странице вызывает аварийное завершение процесса с сообщением "
(рис 2.2) Перевод виртуального адреса в физический адрес
Рассмотрим на примере, как осуществляется перевод некоторого vx в физический адрес px (рис 2.2). Сначала вычисляется номер vnum виртуальной страницы, соответствующий vx, а также смещение delta
vnum := vx div 4096; delta := vx mod 4096;
Далее возможны три варианта развития событий:
vnum недоступна. В этом случае перевод vx в физический адрес невозможен, и процесс завершается с сообщением "pnum будет номером физической страницы, в которую мы загружаем нашу pnum - номер этой физической страницы.После чего адрес px вычисляется следующим образом:
px := pnum*4096 + delta;
Такая организация памяти процесса обладает следующими свойствами:
Отображаемые в память файлы (memory-mapped files) - это мощная возможность операционной системы. Она позволяет приложениям осуществлять доступ к файлам на диске тем же самым способом, каким осуществляется доступ к динамической памяти, то есть через указатели. Смысл отображения файла в память заключается в том, что содержимое файла (или часть содержимого) отображается в некоторый диапазон виртуального адресного пространства процесса, после чего обращение по какому-либо адресу из этого диапазона означает обращение к файлу на диске. Естественно, не каждое обращение к отображенному в память файлу вызывает операцию чтения/записи. Менеджер виртуальной памяти кэширует обращения к диску и тем самым обеспечивает высокую эффективность работы с отображенными файлами.
Исполняемые файлы в формате PE, кроме всего прочего, обладают одной приятной особенностью - PE-файл, загруженный в оперативную память для исполнения, почти ничем не отличается от своего представления на диске. PE-файл сравнивается со сборным домом: стоит привезти его на место, свинтить отдельные детали, подключить электричество и водопровод, и все - можно жить.
Благодаря этой особенности загрузчик операционной системы должен просто отобразить отдельные части PE-файла в
На рис. 2.3 изображена схема PE-файла. Слева показана структура файла на диске, а справа - его образ в памяти. Мы видим, что PE-файл начинается с заголовков, за которыми располагаются несколько секций.
(рис 2.3) PE-файл на диске и в оперативной памяти
В секциях размещаются код и данные исполняемого файла, а также служебная информация, необходимая загрузчику (например, секция ".
Так как расположение элементов PE-файла в памяти и на диске отличаются, для их локализации приходится вводить два понятия: относительный виртуальный адрес элемента в памяти (Relative
Загрузчик формирует образ PE-файла в памяти таким образом, что соблюдается следующее правило: пусть ox и oy - смещения каких-то элементов в файле, а rx и ry - ox < oy, то rx < ry.
Секция в PE-файле представляет либо код, либо некоторые данные (глобальные переменные, таблицы импорта и экспорта, ресурсы, таблица релокаций). Каждая секция имеет набор атрибутов, задающий ее свойства. Атрибуты секции определяют, доступна ли секция для чтения и записи, содержит ли она исполняемый код, должна ли она оставаться в памяти после загрузки исполняемого файла, могут ли различные процессы использовать один экземпляр этой секции и т.д.
Исполняемый файл всегда содержит, по крайней мере, одну секцию, в которой помещен исполняемый код. Кроме этого, как правило, в исполняемом файле содержится секция с данными, а динамические библиотеки обязательно включают отдельную секцию с таблицей релокаций.
Каждая секция имеет имя. Оно не используется загрузчиком и предназначено главным образом для удобства человека. Разные компиляторы и компоновщики дают секциям различные имена. Например, компоновщик от Microsoft размещает код в секции ".text", константы - в секции ".rdata", таблицы импорта и экспорта - в секциях ".idata" и ".edata", таблицу релокаций - в секции ".
Выравнивание секций в исполняемом файле на диске и в образе файла в памяти чаще всего отличается. В памяти они, как правило, выровнены по границам страниц. В принципе, возможно сгенерировать PE-файл с одинаковым выравниванием секций как на диске, так и в памяти. Смещения элементов в таком файле будут совпадать с их
Давайте обсудим, каким образом загрузчик определяет базовый адрес, по которому нужно загрузить PE-файл. Для exe-файлов это тривиальная задача: в заголовках файла присутствует поле ImageBase, содержащее значение базового адреса. Так как для выполнения exe-файла создается отдельный процесс со своим виртуальным адресным пространством, то обычно не возникает никаких проблем с тем чтобы отобразить файл в это адресное пространство по заданному адресу. Как правило, все exe-файлы содержат в поле ImageBase значение 0x400000.
А вот выбор базового адреса для dll-файла куда сложнее. Дело в том, что динамическая библиотека, как правило, загружается в адресное пространство уже существующего процесса, и хотя dll-файл тоже содержит некоторое значение в поле ImageBase, очень часто может так получиться, что этот адрес уже занят чем-то другим (например, по нему уже загружена другая динамическая библиотека). Что же делать загрузчику, если он не может загрузить dll-файл по заданному адресу? Ему ничего не остается, как загрузить этот файл по какому-то другому адресу. Но тут возникает новая проблема - в файле могут содержаться инструкции с абсолютными адресами (это, в основном, инструкции абсолютных переходов, инструкции вызова подпрограмм, а также инструкции для работы с глобальными данными). При загрузке динамической библиотеки по другому адресу все адреса, содержащиеся в этих инструкциях, становятся неправильными, и загрузчик вынужден их поправить. Для того, чтобы загрузчик мог это сделать, в файле содержится таблица релокаций, в которой прописаны
В PE-файле существует специальная секция ".idata", описывающая функции, который этот файл импортирует из динамических библиотек. Описание импортируемых функций в секции ".idata" приводит к тому, что библиотеки загружаются загрузчиком операционной системы еще до запуска программы. В принципе, необязательно описывать каждую импортируемую функцию в этой секции, так как динамические библиотеки можно загружать с помощью функции LoadLibrary из Win32 API прямо во время выполнения программы.
В процессе загрузки программы осуществляется связывание (binding) функций, импортируемых из динамических библиотек. Связывание подразумевает загрузку соответствующих динамических библиотек и составление таблицы адресов импорта (Import Address Table - IAT). Адрес каждой импортируемой функции заносится в эту таблицу и в дальнейшем используется для вызова данной функции.
Секция ".idata" в сборках .NET в некотором смысле носит вспомогательный характер, так как импортируемые сборкой динамические библиотеки описываются в метаданных. Задача этой секции - обеспечить запуск среды выполнения .NET, поэтому в ней описывается только одна импортируемая из mscoree.dll функция ( _CorExeMain для exe-файлов и _CorDllMain - для dll-файлов). При запуске сборки .NET управление сразу же передается этой функции, которая запускает
Экспорт функций из сборки .NET осуществляется достаточно редко. Дело в том, что наличие метаданных в сборках позволяет нам экспортировать любые элементы сборки, такие как классы, методы, поля, свойства и т.д. Таким образом, обычный механизм экспорта функций становится ненужным.
Необходимость в экспорте функций возникает только тогда, когда сборка .NET должна использоваться обычной программой Windows, код которой не управляется средой выполнения .NET.
Информация об экспортируемых функциях хранится внутри PE-файла в специальной секции ".edata". При этом каждой функции присваивается уникальный номер, и с этим номером связывается
Каждый PE-файл начинается с небольшой (128 байт) программы, записанной в формате исполняемых файлов MS-DOS. Эта программа выводит на экран сообщение "This program cannot be run in DOS mode". В настоящее время наличие такого "заголовка" вряд ли имеет смысл, но во время повсеместного использования операционной системы MS-DOS люди зачастую случайно пытались запускать PE-файлы из ДОСовской командной строки, и эта маленькая программа в начале файла давала им возможность осознать свою ошибку, выбросить MS-DOS на помойку и установить наконец-то Windows NT!
Рассмотрим шестнадцатеричный
(рис 2.4) Шестнадцатеричный дамп заголовка MS-DOS
Заголовок начинается с сигнатуры "MZ". Она представляет собой инициалы одного из разработчиков операционной системы MS-DOS 2.0 Марка Збиковски и знаменита тем, что ни одна инструкция процессоров семейства Intel x86 с нее не начинается. В свое время эта ее особенность давала загрузчику исполняемых файлов MS-DOS возможность отличать exe-файлы, которые появились только во второй версии MS-DOS, от com-файлов.
Исполняемые com-файлы пришли в MS-DOS из операционной системы CP/M. Их формат был настолько примитивным, что вряд ли заслуживает того, чтобы вообще называться форматом исполняемых файлов. Загрузчик должен был попросту загрузить com-файл в память, и после нехитрых манипуляций, не вдаваясь в подробности внутренней структуры файла, передать управление на его начало.
В принципе, PE-файл не обязан начинаться именно с такого заголовка. Вы можете поместить в его начало любой exe-файл, работающий в MS-DOS. При этом 32-разрядное слово, расположенное по смещению 0x3c в этом exe-файле, должно содержать его размер. Для стандартного заголовка это значение равно 0x80000000 (подчеркнуто в дампе).
Сразу после заголовка MS-DOS следует сигнатура PE-файла, состоящая из четырех байт: 0x50, 0x45, 0x00 и 0x00 (в строковом представлении она выглядит как "PE\0\0"). Поэтому при просмотре дампа PE-файла очень просто понять, где заканчивается заголовок MS-DOS - достаточно поискать глазами две буквы "PE".
При разработке программного обеспечения, выполняющего чтение PE-файлов, важно не забыть осуществить проверку сигнатуры. Дело в том, что исполняемые файлы в устаревших форматах также начинаются с похожего заголовка MS-DOS, после которого располагаются другие сигнатуры: "NE" для 16-разрядных приложений Windows, "LE" для виртуальных драйверов устройств, и даже "LX" для исполняемых файлов OS/2.
Заголовок PE-файла непосредственно следует за сигнатурой PE-файла. В современной документации он называется "PE File Header", но в более старых текстах можно встретить название "
Заголовок PE-файла состоит из следующих полей:
unsigned short Machine;
Это поле содержит идентификатор процессора, для которого предназначен исполняемый файл. Для сборок .NET всегда используется значение 0x14c.
unsigned short NumberOfSections;
Задает количество секций в PE-файле. Массив заголовков секций следует сразу после всех заголовков, и это поле, таким образом, определяет размер этого массива.
long TimeDateStamp;
Время создания файла. Отсчитывается в секундах от начала 1 января 1970 года по Гринвичу. Самый простой способ получения времени в этом формате - вызов функции time() из стандартной библиотеки языка C.
long PointerToSymbolTable;
long NumberOfSymbols;
Эти два поля использовались раньше для организации хранения отладочной информации внутри
unsigned short OptionalHeaderSize;
Задает размер дополнительного заголовка PE-файла, который следует непосредственно за заголовком PE-файла. Сборки .NET, как правило, содержат значение 0xE0 в этом поле. Вообще, наличие этого поля позволяет расширять формат путем добавления новых полей в дополнительный заголовок PE-файла.
unsigned short Characteristics;
Представляет собой комбинацию флагов, задающую характеристики исполняемого файла. Для сборок .NET требуется установить следующий набор флагов:
0x0002 - файл является исполняемым;
0x0004 - файл не содержит информации о номерах строк исходной программы;
0x0008 - файл не содержит информации о символах исходной программы;
0x0100 - файл предназначен для исполнения на 32-разрядной машине.
Если сборка представляет собой динамическую библиотеку, то дополнительно нужно установить флаг 0x2000.
Таким образом, значение поля для exe-файлов - 0x010E, а для dll-файлов - 0x210E.
Если исполняемый файл не содержит таблицы релокаций, то дополнительно нужно установить флаг 0x0001.
Дополнительный заголовок PE-файла следует сразу за основным заголовком. В современной документации он называется "PE Optional Header". Строго говоря, "optional" означает "необязательный", а не "дополнительный". Дело в том, что в
Поля дополнительного заголовка можно разделить на три группы:
Группа стандартных полей пришла в PE из формата
Эти поля специально предназначены для загрузчика Windows NT. В формате
Местонахождение некоторых важных структур данных в образе загруженного в память PE-файла задается в так называемых директориях данных (Data Directories). Каждая директория содержит
В состав дополнительного заголовка PE-файла входят следующие стандартные поля:
unsigned short Magic;
Константа, задающая тип PE-файла:
0x010B - 32-разрядный файл;
0x020B - 64-разрядный файл.
Для сборок .NET должно быть установлено значение 0x010B.
char LMajor;
Старшее число версии компоновщика. Для сборок .NET - 6.
char LMinor;
Младшее число версии компоновщика. Для сборок .NET - 0.
long CodeSize;
Суммарный размер всех кодовых секций, всегда выровнен по значению SectionAlignment (см. далее).
long InitializedDataSize;
Суммарный размер всех секций, содержащих инициализированные данные. Выровнен по значению SectionAlignment (см. далее). Для сборок .NET характерно то, что в состав секций, содержащих инициализированные данные, включают секции с метаданными и CIL-кодом.
long UninitializedDataSize;
Суммарный размер всех секций, содержащих неинициализированные данные. В сборках .NET, как правило, это поле содержит значение 0 (нет таких секций).
long EntryPointRVA;
Передаче управления на точку входа всегда предшествует корректировка абсолютных адресов (в соответствии с таблицей релокаций), а также формирование таблицы адресов импорта.
Для сборок .NET (как exe, так и dll) значение этого поля всегда указывает на 6 байт, расположенных в кодовой секции PE-файла. Эти 6 байт начинаются с двух байтов 0xFF 0x25, за которыми следует некий абсолютный адрес x. Тем самым кодируется следующая инструкция:
jmp dword ptr ds:[x]
Для exe-файлов адрес x представляет собой сумму значения поля ImageBase (как правило, это 0x400000) и _CorExeMain, импортируемой из динамической библиотеки mscoree.dll.
Для dll-файлов адрес x представляет собой сумму значения поля ImageBase (как правило, это либо 0x400000, либо 0x10000000, либо 0x11000000) и _CorDllMain, импортируемой из динамической библиотеки mscoree.dll.
Интересно, что описание этого поля в [2] явно не соответствует действительности: "
long BaseOfCode;
Описание этого поля в [2] абсолютно неправильное: "
long BaseOfData;
В сборках .NET, не содержащих секций с данными, принято записывать в это поле
Следующие поля специфичны для 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, для которой предназначена сборка. Это поле игнорируется загрузчиком, и в случае сборок .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-файла в памяти. Это поле равно SectionAlignment.
long HeaderSize;
Суммарный размер всех заголовков, включая заголовок MS-DOS, заголовок PE-файла, дополнительный заголовок PE-файла и массив заголовков секций. Суммарный размер кратен значению из поля FileAlignment.
long FileChecksum;
Контрольная сумма PE-файла. Для сборок .NET - всегда 0.
unsigned short SubSystem;
Идентифицирует подсистему для запуска PE-файла. Для сборок .NET допустимы следующие значения:
0x2 - необходим графический пользовательский интерфейс Windows;
0x3 - запускается в консольном режиме;
0x9 - необходим графический пользовательский интерфейс
В [2] приводится неверная информация об этом поле: "
unsigned short DLLFlags;
В [2] сказано, что это поле всегда равно 0. На практике оно иногда содержит значение 0x400 ("No
long StackReserveSize;
Количество виртуальной памяти, резервируемое под стек. Как правило, содержит 0x100000.
long StackCommitSize;
Начальный размер стека. Как правило, равен 0x1000.
long HeapReserveSize;
Количество виртуальной памяти, резервируемое под кучу. Как правило, содержит 0x100000.
long HeapCommitSize;
Начальный размер кучи. Как правило, равен 0x1000.
long LoaderFlags;
Не используется и всегда содержит 0.
long NumberOfDataDirectories;
Количество директорий данных в дополнительном заголовке. Для сборок .NET обязательно равно 16.
В конце дополнительного заголовка размещается массив из 16 директорий данных. Каждая директория данных состоит из двух полей:
long RVA;
long size;
Размер структуры. Для отсутствующей структуры размер равен 0.
Для сборок .NET важны 4 из 16 директорий данных (остальные 12 директорий, как правило, могут быть обнулены):
Непосредственно после дополнительного заголовка следует массив заголовков секций. Количество секций и, соответственно, размер этого массива задается полем NumberOfSections заголовка PE-файла. Секции в массиве отсортированы по их начальным адресам (по
Заголовок каждой секции состоит из следующих полей:
char Name[8];
Имя секции представляет собой ASCIIZ-строку, содержащую не более 8 символов. Если имя содержит ровно 8 символов, то оно не оканчивается на 0.
long VirtualSize;
Размер секции, когда она загружена в память. Значение этого поля не нужно выравнивать.
Если размер секции в памяти превышает размер той же секции в PE-файле (см. далее SizeOfRawData ), то разница заполняется нулями.
long VirtualAddress;
long SizeOfRawData;
Размер секции в PE-файле, выровненный по значению FileAlignment из дополнительного заголовка PE-файла.
Если секция содержит только неинициализированные данные, значение этого поля должно быть равно 0.
long PointerToRawData;
Смещение секции относительно начала PE-файла. Значение этого поля всегда выровнено по значению FileAlignment из дополнительного заголовка PE-файла.
long PointerToRelocations;
Смещение таблицы релокаций для данной секции. Используется только в
long PointerToLinenumbers;
Смещение информации о номерах строк. В сборках .NET всегда равно 0.
short NumberOfRelocations;
Количество релокаций для этой секции. В исполняемых файлах всегда равно 0.
short NumberOfLinenumbers;
Количество номеров строк. В сборках .NET всегда равно 0.
long Characteristics;
Комбинация флагов, задающая свойства секции:
0x00000020 - секция содержит исполняемый код;
0x00000040 - секция содержит инициализированные данные;
0x00000080 - секция содержит неинициализированные данные;
0x02000000 - секция может быть удалена из исполняемого файла (этот флаг установлен для секции ".
0x20000000 - код секции может быть исполнен;
0x40000000 - секция доступна для чтения;
0x80000000 - секция доступна для записи.
Для секций, содержащих метаданные и CIL-код, необходимо использовать значение флагов 0x60000020.
Секции PE-файла, как правило, содержат исполняемый код и данные, которые не имеют специального смысла для загрузчика. Но из всякого правила бывают исключения, поэтому далее мы рассмотрим структуру секции импорта ".idata", а также особенности хранения таблицы релокаций в секции ".
В секции импорта перечисляются все dll-файлы, используемые программой, а также все функции и глобальные переменные, импортируемые из этих файлов. Для краткости будем называть такие функции и глобальные переменные символами.
Директория импорта в дополнительном заголовке PE-файла должна указывать на данные, расположенные в секции импорта.
Секция импорта содержит названия dll-файлов и имена импортируемых символов, представленные в виде ASCIIZ-строк. При этом используется непрямая схема хранения этих строк, потому что вся основная информация в секции импорта организована в виде таблиц, а строковые данные, в силу их произвольного размера, в таблицах хранить неудобно. Поэтому названия dll-файлов и имена символов компактно хранятся где-то внутри PE-файла (чаще всего - в каком-нибудь свободном месте секции импорта), и вместо них в таблицах записываются их
Схема секции импорта приведена на рис. 2.5. Ключевым элементом этой секции является таблица импорта (Import Directory Table), представляющая собой массив так называемых входов в таблицу импорта (Import
(рис 2.5) Схема секции импорта
Каждому dll-файлу, используемому программой, соответствует ровно один вход в таблицу импорта. Этот вход содержит указатели (в форме
Директория таблицы адресов импорта в дополнительном заголовке PE-файла должна указывать на таблицу адресов импорта (IAT).
У тех, кто внимательно прочитал предыдущий абзац, обязательно должен возникнуть вопрос: а зачем нужно хранить в секции импорта два идентичных массива ILT и IAT?
Дело в том, что раньше никакого ILT не было и секция импорта содержала только массив IAT. При загрузке программы происходило так называемое связывание, при котором информация из IAT использовалась для определения адресов импортируемых символов. Эти адреса записывались загрузчиком прямо в IAT (естественно, в образе PE-файла в памяти, а не на диске) поверх той информации, которая там содержалась.
Необходимость в дополнительном массиве ILT возникла после изобретения предварительного связывания, при котором таблица адресов импорта заранее заполняется адресами импортируемых символов. Предварительное связывание осуществляется утилитой BIND, которая вычисляет эти адреса и записывает их прямо в PE-файл на диске. Это позволяет несколько ускорить загрузку программы, но при этом возникают новые проблемы. А что если предварительно связанный dll-файл вдруг изменится? Ведь тогда все адреса могут поменяться? Увы, это так. Правда, загрузчик способен определить этот факт и вычислить новые адреса, и для этого ему как раз и нужна копия таблицы адресов импорта, которая находится в ILT.
Мы не будем рассматривать детали организации секции импорта, относящиеся к механизму предварительного связывания.
Вход в таблицу импорта представляет собой структуру, состоящую из нескольких полей:
long ImportLookupTableRVA;
.
long TimeDateStamp;
Это поле изначально равно нулю (в PE-файле на диске), но после загрузки dll-файла в него (уже в памяти) записывается время загрузки. (В предварительно связанном PE-файле в поле TimeDateStamp должно быть записано значение -1.)
long ForwarderChain;
Должно быть равно -1.
long NameRVA;
long ImportAddressTableRVA;
Теперь рассмотрим, как организованы наши идентичные массивы ILT и IAT. Их элементами являются 32-разрядные целые числа. Если старший бит (31-й) такого 32-разрядного числа установлен в 1, то оставшиеся 31 бит обозначают порядковый номер импортируемого символа. Если же старший бит равен 0, то это 32-разрядное число обозначает
Структура
short Hint;
Это поле является подсказкой для загрузчика. Оно содержит предполагаемый номер импортируемого символа. Загрузчик сначала ищет этот символ по указанному номеру. В случае неудачи он выполняет
char Name[x];
Имя импортируемого символа в виде ASCIIZ-строки.
char Pad;
Это поле служит для выравнивания структуры по четной границе, то есть оно присутствует только тогда, когда структура имеет нечетный размер. Всегда равно нулю.
В секции релокаций (".ImageBase.
Директория релокаций в дополнительном заголовке PE-файла должна указывать на таблицу исправлений.
Таблица исправлений разбита на блоки. Каждый блок описывает исправления, которые нужно внести в определенную страницу (4K байт) загруженного в память PE-файла. Каждый блок должен начинаться на 32-битовой границе.
В начале каждого блока располагается заголовок, состоящий из следующих полей:
long PageRVA;
Это поле содержит
long BlockSize;
Суммарный размер блока в байтах, включая заголовок.
После заголовка следует массив 16-разрядных слов, каждое из которых описывает одно исправление. При этом старшие четыре бита каждого из этих слов задают тип исправления, а остальные 12 бит обозначают смещение относительно начала страницы, соответствующей данному блоку.
В сборках .NET в качестве типа исправления используется значение 3. Этот тип означает, что по заданному смещению относительно начала описываемой блоком страницы PE-файла находится 32-разрядное значение, которое необходимо исправить.
Рассмотрим, как загрузчик выполняет исправление образа PE-файла. Пусть ActualAddress - это адрес, по которому загружен PE-файл. И пусть delta - это смещение исправляемого 32-разрядного значения относительно начала страницы. Тогда адрес исправляемого значения вычисляется следующим образом:
FixupAddress = ActualAddress + PageRVA + delta;
Внесение исправления в 32-разрядное значение, которое находится по адресу FixupAddress, выполняется так (преобразования типов для простоты не указаны):
*FixupAddress += ActualAddress - ImageBase;
Директория заголовка CLI в дополнительном заголовке PE-файла должна указывать на заголовок CLI, который служит главным образом для локализации метаданных в PE-файле.
Заголовок CLI состоит из следующих полей:
long Cb;
Размер заголовка в байтах.
short MajorRuntimeVersion; short MinorRuntimeVersion;
Эти два поля содержат информацию о версии CLR, для которой предназначена данная сборка. В настоящее время эти поля должны содержать значения 2 и 0 соответственно.
struct { long RVA, Size; } Metadata;
В этом поле указываются
long Flags;
Это поле описывает свойства сборки. Для обычных сборок .NET равно 1.
long EntryPointToken;
struct { long RVA, Size; } Resources;
В этом поле указываются
struct { long RVA, Size; } StrongNameSignature;
В этом поле указываются
long CodeManagerTable[2];
Это поле не используется и всегда заполнено нулями.
struct { long RVA, Size; } VTableFixups;
В этом поле указываются
long ExportAddressTableJumps[2];
Это поле не используется и всегда заполнено нулями.
long ManagedNativeHeader[2];
Это поле не используется и всегда заполнено нулями.
В приложении A приведен исходный код программы pegen, демонстрирующей генерацию PE-файла. Эта программа создает сборку hello.exe, работа которой заключается в дублировании строки, введенной пользователем с клавиатуры. Несмотря на то, что генерируемая сборка столь примитивна, программа pegen может служить основой для разработки реального генератора исполняемых файлов .NET.
Программа pegen написана на языке C и состоит из двух частей:
В модуле генерации определена функция 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
Этих входных данных достаточно для генерации сборки .NET.
Подробно рассмотрим каждый этап выполнения программы.
Первый этап включает заполнение структуры 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
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;
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;
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;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB1;
// Директория импорта
struct IMAGE_DATA_DIRECTORY IMPORT_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB2[3];
// Директория релокации
struct IMAGE_DATA_DIRECTORY BASE_RELOC_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB3[6];
// Директория таблицы адресов импорта
struct IMAGE_DATA_DIRECTORY IAT_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB4;
// Директория заголовка CLI
struct IMAGE_DATA_DIRECTORY CLI_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB5;
// Заголовок .text секции
struct IMAGE_SECTION_HEADER TEXT_SECTION;
// Заголовок .cli секции
struct IMAGE_SECTION_HEADER CLI_SECTION;
// Заголовок .reloc секции
struct IMAGE_SECTION_HEADER RELOC_SECTION;
};
struct IMAGE_DATA_DIRECTORY { // Директория данных
unsigned long RVA;
unsigned long Size;
};
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.
(рис 2.6) Схематичное расположение секций и заголовков
Как можно заметить, сгенерированная сборка .NET состоит из 3 секций:
Следовательно, после дополнительного заголовка в структуре HEADERS будут находиться 3 заголовка секций.
Для сборок .NET необходимы 4 директории данных:
На основе блока входных параметров вычисляется расположение секций в памяти. Вычисления осуществляются внутри набора макросов (см. таблицу 2.1).
Макрос: RVA_OF_TEXT Описание: Подстановка: align (sizeof(struct (HEADERS), SECTION_ALIGNMENT) Код функции
|
Макрос: RVA_OF_CLI (params) Описание:
Подстановка: RVA_OF_TEXT + align(params->SizeOfMetadata, SECTION_ALIGNMENT) |
Макрос: RVA_OF_RELOC (params) Описание:
Подстановка: RVA_OF_CLI (params) + SIZEOF_CLI_M В свою очередь макрос align (sizeof(struct (CLI_SECTION_IMAGE), SECTION_ALIGNMENT) Формат и назначение |
Код функции 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;
};
В заключение структура HEADERS пишется в начало выходного файла, причем записывается количество байт, равное значению макроса SIZE_OF_HEADERS(params), который объявлен следующим образом:
#define SIZEOF_HEADERS(params) \ align(sizeof(struct HEADERS), params->FileAlignment)
Обычно размер структуры HEADERS не кратен i nP->FileAlignment, следовательно разница дописывается нулями.
Функция, выполняющая работу на этом этапе - 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)
Макрос принимает в качестве аргумента блок входных параметров.
В выделенную память записываются метаданные из массива и cilcode, адреса которых передаются в функцию через поля inP-> и inP->cilcode блока входных параметров. Затем этот массив записывается в выходной файл сразу после заголовка HEADERS. Если размер метаданных и CIL-кода не кратен inP->FileAlignment, то разница дописывается нулями.
Всю работу на этом этапе выполняет функция 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;
unsigned short MinorRuntimeVersion;
struct IMAGE_DATA_DIRECTORY MetaData;
unsigned long Flags;
unsigned long EntryPointToken;
struct IMAGE_DATA_DIRECTORY NotUsed[6];
}CLI_HEADER;
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;
};
Поле 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" прибавляется смещение поля в структуре CLI_SECTION_IMAGE.
Сразу за точкой входа находится заголовок CLI, который служит для определения положения метаданных в PE-файле. В заголовке находится информация об
В конце работы функции структура CLI_SECTION_IMAGE пишется в выходной файл, сразу после секции ".text". Записывается количество байт, равное значению макроса SIZEOF_CLI, который имеет следующий вид:
#define SIZEOF_CLI(params) \ align(sizeof(struct CLI_SECTION_IMAGE), params->FileAlignment)
Если структура CLI_SECTION_IMAGE не кратна inP->FileAlignment, то разница дописывается нулями.
Функция, ответственная за этот этап - make_reloc_section. Прототип данной функции:
void make_reloc_section (FILE* file, PINPUT_PARAMETERS inP);
Заключительная секция релокации содержит исправления для единственного абсолютного адреса в сборке, который находится в точке входа jmp dword в секции ".cli". Адрес x надо исправить, если сборка грузится по адресу, отличному от базового. Сгенерированная секция ".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, в него дописывается определенное количество нулей.
Если описать метаданные и методы сгенерированной сборки на CIL с использованием синтаксиса ILASM, то получится следующая IL-программа:
.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
}
Метаданные, используемые при генерации сборки, находятся в массиве , который в программе описан следующим образом (полное описание не приводится из-за его большого размера, полностью листинг массива
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,
0x00, 0x00, 0x0A, 0x28, 0x01, 0x00, 0x00, 0x0A,
0x28, 0x02, 0x00, 0x00, 0x0A, 0x2A
};
Итак, попробуем запустить нашу программу, набрав в консоли pegen.exe (так будет называться наша программа):
C:\>Pegen.exe
Если все прошло успешно, то на экране мы увидим сообщение об успешной генерации сборки hello.exe:
File: hello.exe generated
Запустим сгенерированную сборку:
C:\>hello.exe
Программа распечатает на экране строку "Hello" и попросит ввести произвольный текст. Введем, например, строку:
Hello Programm
В результате программа распечатает на экране строку, введенную ранее, и закончит свою работу:
Hello Programm
Исполняемый файл (
Для того чтобы загрузчик операционной системы мог правильно загрузить исполняемый файл в память, содержимое этого файла должно соответствовать принятому в данной операционной системе формату исполняемых файлов. В разных операционных системах в разное время существовало и до сих пор существует множество различных форматов. В этой главе мы рассмотрим формат Portable Executable (PE). Формат PE - это основной формат для хранения исполняемых файлов в операционной системе Windows. Сборки .NET тоже хранятся в этом формате.
Кроме того, формат PE может использоваться для представления
А теперь - немного истории. Формат PE был создан разработчиками Windows NT. До этого в операционной системе Windows использовались форматы New Executable (NE) и Linear Executable (LE) для представления исполняемых файлов, а для хранения
В ".NET Framework
Интересно отметить, что исполняемые файлы в устаревших форматах NE и LE до сих пор поддерживаются Windows. Исполняемые файлы в формате NE можно запускать под управлением NTVDM (NT Virtual DOS Machine), а формат LE используется для виртуальных драйверов устройств (
Почему в названии формата PE присутствует слово "portable" ("переносимый")? Дело в том, что Windows NT была реализована не только для платформы Intel x86, но и для платформ
Далее в этой главе мы не будем затрагивать 64-разрядный вариант формата PE, потому что в настоящее время сборки .NET хранятся в прежнем 32-разрядном формате. Однако отметим, что 64-разрядный PE очень слабо отличается от 32-разрядного. Основное отличие касается разрядности полей структур PE-файла.
Прежде чем перейти к рассмотрению формата PE, необходимо поговорить об особенностях управления памятью в Windows, так как без знания этих особенностей невозможно понять некоторые существенные детали формата.
Управление памятью в Windows NT/2k/XP/2k3 осуществляет менеджер виртуальной памяти (virtual-
Каждый процесс в Windows запускается в своем виртуальном адресном пространстве размером в 4 Гб. При этом первые 2 Гб адресного пространства могут непосредственно использоваться процессом, а остальные 2 Гб резервируются операционной системой для своих нужд (рис. 2.1).
(рис 2.1) Виртуальное адресное пространство процесса
Виртуальное адресное пространство также делится на виртуальные страницы размером в 4096 байт. При этом процессу выделяется только то количество виртуальных страниц, которое ему реально нужно. Поэтому тот факт, что процесс может адресовать 4 Гб виртуального адресного пространства, еще не означает, что каждому процессу выделяется по 4 Гб оперативной памяти. Как правило, процесс использует только малую часть своего адресного пространства, хотя стремительное удешевление модулей памяти способно в самое ближайшее время существенно изменить картину и вызвать повсеместно переход к 64-разрядным архитектурам.
Виртуальные страницы могут отображаться операционной системой в страницы физической памяти, могут храниться в файле подкачки, а также могут быть вообще недоступны процессу. Обращение к недоступной виртуальной странице вызывает аварийное завершение процесса с сообщением "
(рис 2.2) Перевод виртуального адреса в физический адрес
Рассмотрим на примере, как осуществляется перевод некоторого vx в физический адрес px (рис 2.2). Сначала вычисляется номер vnum виртуальной страницы, соответствующий vx, а также смещение delta
vnum := vx div 4096; delta := vx mod 4096;
Далее возможны три варианта развития событий:
vnum недоступна. В этом случае перевод vx в физический адрес невозможен, и процесс завершается с сообщением "pnum будет номером физической страницы, в которую мы загружаем нашу pnum - номер этой физической страницы.После чего адрес px вычисляется следующим образом:
px := pnum*4096 + delta;
Такая организация памяти процесса обладает следующими свойствами:
Отображаемые в память файлы (memory-mapped files) - это мощная возможность операционной системы. Она позволяет приложениям осуществлять доступ к файлам на диске тем же самым способом, каким осуществляется доступ к динамической памяти, то есть через указатели. Смысл отображения файла в память заключается в том, что содержимое файла (или часть содержимого) отображается в некоторый диапазон виртуального адресного пространства процесса, после чего обращение по какому-либо адресу из этого диапазона означает обращение к файлу на диске. Естественно, не каждое обращение к отображенному в память файлу вызывает операцию чтения/записи. Менеджер виртуальной памяти кэширует обращения к диску и тем самым обеспечивает высокую эффективность работы с отображенными файлами.
Исполняемые файлы в формате PE, кроме всего прочего, обладают одной приятной особенностью - PE-файл, загруженный в оперативную память для исполнения, почти ничем не отличается от своего представления на диске. PE-файл сравнивается со сборным домом: стоит привезти его на место, свинтить отдельные детали, подключить электричество и водопровод, и все - можно жить.
Благодаря этой особенности загрузчик операционной системы должен просто отобразить отдельные части PE-файла в
На рис. 2.3 изображена схема PE-файла. Слева показана структура файла на диске, а справа - его образ в памяти. Мы видим, что PE-файл начинается с заголовков, за которыми располагаются несколько секций.
(рис 2.3) PE-файл на диске и в оперативной памяти
В секциях размещаются код и данные исполняемого файла, а также служебная информация, необходимая загрузчику (например, секция ".
Так как расположение элементов PE-файла в памяти и на диске отличаются, для их локализации приходится вводить два понятия: относительный виртуальный адрес элемента в памяти (Relative
Загрузчик формирует образ PE-файла в памяти таким образом, что соблюдается следующее правило: пусть ox и oy - смещения каких-то элементов в файле, а rx и ry - ox < oy, то rx < ry.
Секция в PE-файле представляет либо код, либо некоторые данные (глобальные переменные, таблицы импорта и экспорта, ресурсы, таблица релокаций). Каждая секция имеет набор атрибутов, задающий ее свойства. Атрибуты секции определяют, доступна ли секция для чтения и записи, содержит ли она исполняемый код, должна ли она оставаться в памяти после загрузки исполняемого файла, могут ли различные процессы использовать один экземпляр этой секции и т.д.
Исполняемый файл всегда содержит, по крайней мере, одну секцию, в которой помещен исполняемый код. Кроме этого, как правило, в исполняемом файле содержится секция с данными, а динамические библиотеки обязательно включают отдельную секцию с таблицей релокаций.
Каждая секция имеет имя. Оно не используется загрузчиком и предназначено главным образом для удобства человека. Разные компиляторы и компоновщики дают секциям различные имена. Например, компоновщик от Microsoft размещает код в секции ".text", константы - в секции ".rdata", таблицы импорта и экспорта - в секциях ".idata" и ".edata", таблицу релокаций - в секции ".
Выравнивание секций в исполняемом файле на диске и в образе файла в памяти чаще всего отличается. В памяти они, как правило, выровнены по границам страниц. В принципе, возможно сгенерировать PE-файл с одинаковым выравниванием секций как на диске, так и в памяти. Смещения элементов в таком файле будут совпадать с их
Давайте обсудим, каким образом загрузчик определяет базовый адрес, по которому нужно загрузить PE-файл. Для exe-файлов это тривиальная задача: в заголовках файла присутствует поле ImageBase, содержащее значение базового адреса. Так как для выполнения exe-файла создается отдельный процесс со своим виртуальным адресным пространством, то обычно не возникает никаких проблем с тем чтобы отобразить файл в это адресное пространство по заданному адресу. Как правило, все exe-файлы содержат в поле ImageBase значение 0x400000.
А вот выбор базового адреса для dll-файла куда сложнее. Дело в том, что динамическая библиотека, как правило, загружается в адресное пространство уже существующего процесса, и хотя dll-файл тоже содержит некоторое значение в поле ImageBase, очень часто может так получиться, что этот адрес уже занят чем-то другим (например, по нему уже загружена другая динамическая библиотека). Что же делать загрузчику, если он не может загрузить dll-файл по заданному адресу? Ему ничего не остается, как загрузить этот файл по какому-то другому адресу. Но тут возникает новая проблема - в файле могут содержаться инструкции с абсолютными адресами (это, в основном, инструкции абсолютных переходов, инструкции вызова подпрограмм, а также инструкции для работы с глобальными данными). При загрузке динамической библиотеки по другому адресу все адреса, содержащиеся в этих инструкциях, становятся неправильными, и загрузчик вынужден их поправить. Для того, чтобы загрузчик мог это сделать, в файле содержится таблица релокаций, в которой прописаны
В PE-файле существует специальная секция ".idata", описывающая функции, который этот файл импортирует из динамических библиотек. Описание импортируемых функций в секции ".idata" приводит к тому, что библиотеки загружаются загрузчиком операционной системы еще до запуска программы. В принципе, необязательно описывать каждую импортируемую функцию в этой секции, так как динамические библиотеки можно загружать с помощью функции LoadLibrary из Win32 API прямо во время выполнения программы.
В процессе загрузки программы осуществляется связывание (binding) функций, импортируемых из динамических библиотек. Связывание подразумевает загрузку соответствующих динамических библиотек и составление таблицы адресов импорта (Import Address Table - IAT). Адрес каждой импортируемой функции заносится в эту таблицу и в дальнейшем используется для вызова данной функции.
Секция ".idata" в сборках .NET в некотором смысле носит вспомогательный характер, так как импортируемые сборкой динамические библиотеки описываются в метаданных. Задача этой секции - обеспечить запуск среды выполнения .NET, поэтому в ней описывается только одна импортируемая из mscoree.dll функция ( _CorExeMain для exe-файлов и _CorDllMain - для dll-файлов). При запуске сборки .NET управление сразу же передается этой функции, которая запускает
Экспорт функций из сборки .NET осуществляется достаточно редко. Дело в том, что наличие метаданных в сборках позволяет нам экспортировать любые элементы сборки, такие как классы, методы, поля, свойства и т.д. Таким образом, обычный механизм экспорта функций становится ненужным.
Необходимость в экспорте функций возникает только тогда, когда сборка .NET должна использоваться обычной программой Windows, код которой не управляется средой выполнения .NET.
Информация об экспортируемых функциях хранится внутри PE-файла в специальной секции ".edata". При этом каждой функции присваивается уникальный номер, и с этим номером связывается
Каждый PE-файл начинается с небольшой (128 байт) программы, записанной в формате исполняемых файлов MS-DOS. Эта программа выводит на экран сообщение "This program cannot be run in DOS mode". В настоящее время наличие такого "заголовка" вряд ли имеет смысл, но во время повсеместного использования операционной системы MS-DOS люди зачастую случайно пытались запускать PE-файлы из ДОСовской командной строки, и эта маленькая программа в начале файла давала им возможность осознать свою ошибку, выбросить MS-DOS на помойку и установить наконец-то Windows NT!
Рассмотрим шестнадцатеричный
(рис 2.4) Шестнадцатеричный дамп заголовка MS-DOS
Заголовок начинается с сигнатуры "MZ". Она представляет собой инициалы одного из разработчиков операционной системы MS-DOS 2.0 Марка Збиковски и знаменита тем, что ни одна инструкция процессоров семейства Intel x86 с нее не начинается. В свое время эта ее особенность давала загрузчику исполняемых файлов MS-DOS возможность отличать exe-файлы, которые появились только во второй версии MS-DOS, от com-файлов.
Исполняемые com-файлы пришли в MS-DOS из операционной системы CP/M. Их формат был настолько примитивным, что вряд ли заслуживает того, чтобы вообще называться форматом исполняемых файлов. Загрузчик должен был попросту загрузить com-файл в память, и после нехитрых манипуляций, не вдаваясь в подробности внутренней структуры файла, передать управление на его начало.
В принципе, PE-файл не обязан начинаться именно с такого заголовка. Вы можете поместить в его начало любой exe-файл, работающий в MS-DOS. При этом 32-разрядное слово, расположенное по смещению 0x3c в этом exe-файле, должно содержать его размер. Для стандартного заголовка это значение равно 0x80000000 (подчеркнуто в дампе).
Сразу после заголовка MS-DOS следует сигнатура PE-файла, состоящая из четырех байт: 0x50, 0x45, 0x00 и 0x00 (в строковом представлении она выглядит как "PE\0\0"). Поэтому при просмотре дампа PE-файла очень просто понять, где заканчивается заголовок MS-DOS - достаточно поискать глазами две буквы "PE".
При разработке программного обеспечения, выполняющего чтение PE-файлов, важно не забыть осуществить проверку сигнатуры. Дело в том, что исполняемые файлы в устаревших форматах также начинаются с похожего заголовка MS-DOS, после которого располагаются другие сигнатуры: "NE" для 16-разрядных приложений Windows, "LE" для виртуальных драйверов устройств, и даже "LX" для исполняемых файлов OS/2.
Заголовок PE-файла непосредственно следует за сигнатурой PE-файла. В современной документации он называется "PE File Header", но в более старых текстах можно встретить название "
Заголовок PE-файла состоит из следующих полей:
unsigned short Machine;
Это поле содержит идентификатор процессора, для которого предназначен исполняемый файл. Для сборок .NET всегда используется значение 0x14c.
unsigned short NumberOfSections;
Задает количество секций в PE-файле. Массив заголовков секций следует сразу после всех заголовков, и это поле, таким образом, определяет размер этого массива.
long TimeDateStamp;
Время создания файла. Отсчитывается в секундах от начала 1 января 1970 года по Гринвичу. Самый простой способ получения времени в этом формате - вызов функции time() из стандартной библиотеки языка C.
long PointerToSymbolTable;
long NumberOfSymbols;
Эти два поля использовались раньше для организации хранения отладочной информации внутри
unsigned short OptionalHeaderSize;
Задает размер дополнительного заголовка PE-файла, который следует непосредственно за заголовком PE-файла. Сборки .NET, как правило, содержат значение 0xE0 в этом поле. Вообще, наличие этого поля позволяет расширять формат путем добавления новых полей в дополнительный заголовок PE-файла.
unsigned short Characteristics;
Представляет собой комбинацию флагов, задающую характеристики исполняемого файла. Для сборок .NET требуется установить следующий набор флагов:
0x0002 - файл является исполняемым;
0x0004 - файл не содержит информации о номерах строк исходной программы;
0x0008 - файл не содержит информации о символах исходной программы;
0x0100 - файл предназначен для исполнения на 32-разрядной машине.
Если сборка представляет собой динамическую библиотеку, то дополнительно нужно установить флаг 0x2000.
Таким образом, значение поля для exe-файлов - 0x010E, а для dll-файлов - 0x210E.
Если исполняемый файл не содержит таблицы релокаций, то дополнительно нужно установить флаг 0x0001.
Дополнительный заголовок PE-файла следует сразу за основным заголовком. В современной документации он называется "PE Optional Header". Строго говоря, "optional" означает "необязательный", а не "дополнительный". Дело в том, что в
Поля дополнительного заголовка можно разделить на три группы:
Группа стандартных полей пришла в PE из формата
Эти поля специально предназначены для загрузчика Windows NT. В формате
Местонахождение некоторых важных структур данных в образе загруженного в память PE-файла задается в так называемых директориях данных (Data Directories). Каждая директория содержит
В состав дополнительного заголовка PE-файла входят следующие стандартные поля:
unsigned short Magic;
Константа, задающая тип PE-файла:
0x010B - 32-разрядный файл;
0x020B - 64-разрядный файл.
Для сборок .NET должно быть установлено значение 0x010B.
char LMajor;
Старшее число версии компоновщика. Для сборок .NET - 6.
char LMinor;
Младшее число версии компоновщика. Для сборок .NET - 0.
long CodeSize;
Суммарный размер всех кодовых секций, всегда выровнен по значению SectionAlignment (см. далее).
long InitializedDataSize;
Суммарный размер всех секций, содержащих инициализированные данные. Выровнен по значению SectionAlignment (см. далее). Для сборок .NET характерно то, что в состав секций, содержащих инициализированные данные, включают секции с метаданными и CIL-кодом.
long UninitializedDataSize;
Суммарный размер всех секций, содержащих неинициализированные данные. В сборках .NET, как правило, это поле содержит значение 0 (нет таких секций).
long EntryPointRVA;
Передаче управления на точку входа всегда предшествует корректировка абсолютных адресов (в соответствии с таблицей релокаций), а также формирование таблицы адресов импорта.
Для сборок .NET (как exe, так и dll) значение этого поля всегда указывает на 6 байт, расположенных в кодовой секции PE-файла. Эти 6 байт начинаются с двух байтов 0xFF 0x25, за которыми следует некий абсолютный адрес x. Тем самым кодируется следующая инструкция:
jmp dword ptr ds:[x]
Для exe-файлов адрес x представляет собой сумму значения поля ImageBase (как правило, это 0x400000) и _CorExeMain, импортируемой из динамической библиотеки mscoree.dll.
Для dll-файлов адрес x представляет собой сумму значения поля ImageBase (как правило, это либо 0x400000, либо 0x10000000, либо 0x11000000) и _CorDllMain, импортируемой из динамической библиотеки mscoree.dll.
Интересно, что описание этого поля в [2] явно не соответствует действительности: "
long BaseOfCode;
Описание этого поля в [2] абсолютно неправильное: "
long BaseOfData;
В сборках .NET, не содержащих секций с данными, принято записывать в это поле
Следующие поля специфичны для 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, для которой предназначена сборка. Это поле игнорируется загрузчиком, и в случае сборок .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-файла в памяти. Это поле равно SectionAlignment.
long HeaderSize;
Суммарный размер всех заголовков, включая заголовок MS-DOS, заголовок PE-файла, дополнительный заголовок PE-файла и массив заголовков секций. Суммарный размер кратен значению из поля FileAlignment.
long FileChecksum;
Контрольная сумма PE-файла. Для сборок .NET - всегда 0.
unsigned short SubSystem;
Идентифицирует подсистему для запуска PE-файла. Для сборок .NET допустимы следующие значения:
0x2 - необходим графический пользовательский интерфейс Windows;
0x3 - запускается в консольном режиме;
0x9 - необходим графический пользовательский интерфейс
В [2] приводится неверная информация об этом поле: "
unsigned short DLLFlags;
В [2] сказано, что это поле всегда равно 0. На практике оно иногда содержит значение 0x400 ("No
long StackReserveSize;
Количество виртуальной памяти, резервируемое под стек. Как правило, содержит 0x100000.
long StackCommitSize;
Начальный размер стека. Как правило, равен 0x1000.
long HeapReserveSize;
Количество виртуальной памяти, резервируемое под кучу. Как правило, содержит 0x100000.
long HeapCommitSize;
Начальный размер кучи. Как правило, равен 0x1000.
long LoaderFlags;
Не используется и всегда содержит 0.
long NumberOfDataDirectories;
Количество директорий данных в дополнительном заголовке. Для сборок .NET обязательно равно 16.
В конце дополнительного заголовка размещается массив из 16 директорий данных. Каждая директория данных состоит из двух полей:
long RVA;
long size;
Размер структуры. Для отсутствующей структуры размер равен 0.
Для сборок .NET важны 4 из 16 директорий данных (остальные 12 директорий, как правило, могут быть обнулены):
Непосредственно после дополнительного заголовка следует массив заголовков секций. Количество секций и, соответственно, размер этого массива задается полем NumberOfSections заголовка PE-файла. Секции в массиве отсортированы по их начальным адресам (по
Заголовок каждой секции состоит из следующих полей:
char Name[8];
Имя секции представляет собой ASCIIZ-строку, содержащую не более 8 символов. Если имя содержит ровно 8 символов, то оно не оканчивается на 0.
long VirtualSize;
Размер секции, когда она загружена в память. Значение этого поля не нужно выравнивать.
Если размер секции в памяти превышает размер той же секции в PE-файле (см. далее SizeOfRawData ), то разница заполняется нулями.
long VirtualAddress;
long SizeOfRawData;
Размер секции в PE-файле, выровненный по значению FileAlignment из дополнительного заголовка PE-файла.
Если секция содержит только неинициализированные данные, значение этого поля должно быть равно 0.
long PointerToRawData;
Смещение секции относительно начала PE-файла. Значение этого поля всегда выровнено по значению FileAlignment из дополнительного заголовка PE-файла.
long PointerToRelocations;
Смещение таблицы релокаций для данной секции. Используется только в
long PointerToLinenumbers;
Смещение информации о номерах строк. В сборках .NET всегда равно 0.
short NumberOfRelocations;
Количество релокаций для этой секции. В исполняемых файлах всегда равно 0.
short NumberOfLinenumbers;
Количество номеров строк. В сборках .NET всегда равно 0.
long Characteristics;
Комбинация флагов, задающая свойства секции:
0x00000020 - секция содержит исполняемый код;
0x00000040 - секция содержит инициализированные данные;
0x00000080 - секция содержит неинициализированные данные;
0x02000000 - секция может быть удалена из исполняемого файла (этот флаг установлен для секции ".
0x20000000 - код секции может быть исполнен;
0x40000000 - секция доступна для чтения;
0x80000000 - секция доступна для записи.
Для секций, содержащих метаданные и CIL-код, необходимо использовать значение флагов 0x60000020.
Секции PE-файла, как правило, содержат исполняемый код и данные, которые не имеют специального смысла для загрузчика. Но из всякого правила бывают исключения, поэтому далее мы рассмотрим структуру секции импорта ".idata", а также особенности хранения таблицы релокаций в секции ".
В секции импорта перечисляются все dll-файлы, используемые программой, а также все функции и глобальные переменные, импортируемые из этих файлов. Для краткости будем называть такие функции и глобальные переменные символами.
Директория импорта в дополнительном заголовке PE-файла должна указывать на данные, расположенные в секции импорта.
Секция импорта содержит названия dll-файлов и имена импортируемых символов, представленные в виде ASCIIZ-строк. При этом используется непрямая схема хранения этих строк, потому что вся основная информация в секции импорта организована в виде таблиц, а строковые данные, в силу их произвольного размера, в таблицах хранить неудобно. Поэтому названия dll-файлов и имена символов компактно хранятся где-то внутри PE-файла (чаще всего - в каком-нибудь свободном месте секции импорта), и вместо них в таблицах записываются их
Схема секции импорта приведена на рис. 2.5. Ключевым элементом этой секции является таблица импорта (Import Directory Table), представляющая собой массив так называемых входов в таблицу импорта (Import
(рис 2.5) Схема секции импорта
Каждому dll-файлу, используемому программой, соответствует ровно один вход в таблицу импорта. Этот вход содержит указатели (в форме
Директория таблицы адресов импорта в дополнительном заголовке PE-файла должна указывать на таблицу адресов импорта (IAT).
У тех, кто внимательно прочитал предыдущий абзац, обязательно должен возникнуть вопрос: а зачем нужно хранить в секции импорта два идентичных массива ILT и IAT?
Дело в том, что раньше никакого ILT не было и секция импорта содержала только массив IAT. При загрузке программы происходило так называемое связывание, при котором информация из IAT использовалась для определения адресов импортируемых символов. Эти адреса записывались загрузчиком прямо в IAT (естественно, в образе PE-файла в памяти, а не на диске) поверх той информации, которая там содержалась.
Необходимость в дополнительном массиве ILT возникла после изобретения предварительного связывания, при котором таблица адресов импорта заранее заполняется адресами импортируемых символов. Предварительное связывание осуществляется утилитой BIND, которая вычисляет эти адреса и записывает их прямо в PE-файл на диске. Это позволяет несколько ускорить загрузку программы, но при этом возникают новые проблемы. А что если предварительно связанный dll-файл вдруг изменится? Ведь тогда все адреса могут поменяться? Увы, это так. Правда, загрузчик способен определить этот факт и вычислить новые адреса, и для этого ему как раз и нужна копия таблицы адресов импорта, которая находится в ILT.
Мы не будем рассматривать детали организации секции импорта, относящиеся к механизму предварительного связывания.
Вход в таблицу импорта представляет собой структуру, состоящую из нескольких полей:
long ImportLookupTableRVA;
.
long TimeDateStamp;
Это поле изначально равно нулю (в PE-файле на диске), но после загрузки dll-файла в него (уже в памяти) записывается время загрузки. (В предварительно связанном PE-файле в поле TimeDateStamp должно быть записано значение -1.)
long ForwarderChain;
Должно быть равно -1.
long NameRVA;
long ImportAddressTableRVA;
Теперь рассмотрим, как организованы наши идентичные массивы ILT и IAT. Их элементами являются 32-разрядные целые числа. Если старший бит (31-й) такого 32-разрядного числа установлен в 1, то оставшиеся 31 бит обозначают порядковый номер импортируемого символа. Если же старший бит равен 0, то это 32-разрядное число обозначает
Структура
short Hint;
Это поле является подсказкой для загрузчика. Оно содержит предполагаемый номер импортируемого символа. Загрузчик сначала ищет этот символ по указанному номеру. В случае неудачи он выполняет
char Name[x];
Имя импортируемого символа в виде ASCIIZ-строки.
char Pad;
Это поле служит для выравнивания структуры по четной границе, то есть оно присутствует только тогда, когда структура имеет нечетный размер. Всегда равно нулю.
В секции релокаций (".ImageBase.
Директория релокаций в дополнительном заголовке PE-файла должна указывать на таблицу исправлений.
Таблица исправлений разбита на блоки. Каждый блок описывает исправления, которые нужно внести в определенную страницу (4K байт) загруженного в память PE-файла. Каждый блок должен начинаться на 32-битовой границе.
В начале каждого блока располагается заголовок, состоящий из следующих полей:
long PageRVA;
Это поле содержит
long BlockSize;
Суммарный размер блока в байтах, включая заголовок.
После заголовка следует массив 16-разрядных слов, каждое из которых описывает одно исправление. При этом старшие четыре бита каждого из этих слов задают тип исправления, а остальные 12 бит обозначают смещение относительно начала страницы, соответствующей данному блоку.
В сборках .NET в качестве типа исправления используется значение 3. Этот тип означает, что по заданному смещению относительно начала описываемой блоком страницы PE-файла находится 32-разрядное значение, которое необходимо исправить.
Рассмотрим, как загрузчик выполняет исправление образа PE-файла. Пусть ActualAddress - это адрес, по которому загружен PE-файл. И пусть delta - это смещение исправляемого 32-разрядного значения относительно начала страницы. Тогда адрес исправляемого значения вычисляется следующим образом:
FixupAddress = ActualAddress + PageRVA + delta;
Внесение исправления в 32-разрядное значение, которое находится по адресу FixupAddress, выполняется так (преобразования типов для простоты не указаны):
*FixupAddress += ActualAddress - ImageBase;
Директория заголовка CLI в дополнительном заголовке PE-файла должна указывать на заголовок CLI, который служит главным образом для локализации метаданных в PE-файле.
Заголовок CLI состоит из следующих полей:
long Cb;
Размер заголовка в байтах.
short MajorRuntimeVersion; short MinorRuntimeVersion;
Эти два поля содержат информацию о версии CLR, для которой предназначена данная сборка. В настоящее время эти поля должны содержать значения 2 и 0 соответственно.
struct { long RVA, Size; } Metadata;
В этом поле указываются
long Flags;
Это поле описывает свойства сборки. Для обычных сборок .NET равно 1.
long EntryPointToken;
struct { long RVA, Size; } Resources;
В этом поле указываются
struct { long RVA, Size; } StrongNameSignature;
В этом поле указываются
long CodeManagerTable[2];
Это поле не используется и всегда заполнено нулями.
struct { long RVA, Size; } VTableFixups;
В этом поле указываются
long ExportAddressTableJumps[2];
Это поле не используется и всегда заполнено нулями.
long ManagedNativeHeader[2];
Это поле не используется и всегда заполнено нулями.
В приложении A приведен исходный код программы pegen, демонстрирующей генерацию PE-файла. Эта программа создает сборку hello.exe, работа которой заключается в дублировании строки, введенной пользователем с клавиатуры. Несмотря на то, что генерируемая сборка столь примитивна, программа pegen может служить основой для разработки реального генератора исполняемых файлов .NET.
Программа pegen написана на языке C и состоит из двух частей:
В модуле генерации определена функция 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
Этих входных данных достаточно для генерации сборки .NET.
Подробно рассмотрим каждый этап выполнения программы.
Первый этап включает заполнение структуры 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
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;
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;
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;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB1;
// Директория импорта
struct IMAGE_DATA_DIRECTORY IMPORT_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB2[3];
// Директория релокации
struct IMAGE_DATA_DIRECTORY BASE_RELOC_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB3[6];
// Директория таблицы адресов импорта
struct IMAGE_DATA_DIRECTORY IAT_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB4;
// Директория заголовка CLI
struct IMAGE_DATA_DIRECTORY CLI_DIRECTORY;
// Поле не используется в сборках. Заполняется нулями
struct IMAGE_DATA_DIRECTORY STUB5;
// Заголовок .text секции
struct IMAGE_SECTION_HEADER TEXT_SECTION;
// Заголовок .cli секции
struct IMAGE_SECTION_HEADER CLI_SECTION;
// Заголовок .reloc секции
struct IMAGE_SECTION_HEADER RELOC_SECTION;
};
struct IMAGE_DATA_DIRECTORY { // Директория данных
unsigned long RVA;
unsigned long Size;
};
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.
(рис 2.6) Схематичное расположение секций и заголовков
Как можно заметить, сгенерированная сборка .NET состоит из 3 секций:
Следовательно, после дополнительного заголовка в структуре HEADERS будут находиться 3 заголовка секций.
Для сборок .NET необходимы 4 директории данных:
На основе блока входных параметров вычисляется расположение секций в памяти. Вычисления осуществляются внутри набора макросов (см. таблицу 2.1).
Макрос: RVA_OF_TEXT Описание: Подстановка: align (sizeof(struct (HEADERS), SECTION_ALIGNMENT) Код функции
|
Макрос: RVA_OF_CLI (params) Описание:
Подстановка: RVA_OF_TEXT + align(params->SizeOfMetadata, SECTION_ALIGNMENT) |
Макрос: RVA_OF_RELOC (params) Описание:
Подстановка: RVA_OF_CLI (params) + SIZEOF_CLI_M В свою очередь макрос align (sizeof(struct (CLI_SECTION_IMAGE), SECTION_ALIGNMENT) Формат и назначение |
Код функции 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;
};
В заключение структура HEADERS пишется в начало выходного файла, причем записывается количество байт, равное значению макроса SIZE_OF_HEADERS(params), который объявлен следующим образом:
#define SIZEOF_HEADERS(params) \ align(sizeof(struct HEADERS), params->FileAlignment)
Обычно размер структуры HEADERS не кратен i nP->FileAlignment, следовательно разница дописывается нулями.
Функция, выполняющая работу на этом этапе - 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)
Макрос принимает в качестве аргумента блок входных параметров.
В выделенную память записываются метаданные из массива и cilcode, адреса которых передаются в функцию через поля inP-> и inP->cilcode блока входных параметров. Затем этот массив записывается в выходной файл сразу после заголовка HEADERS. Если размер метаданных и CIL-кода не кратен inP->FileAlignment, то разница дописывается нулями.
Всю работу на этом этапе выполняет функция 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;
unsigned short MinorRuntimeVersion;
struct IMAGE_DATA_DIRECTORY MetaData;
unsigned long Flags;
unsigned long EntryPointToken;
struct IMAGE_DATA_DIRECTORY NotUsed[6];
}CLI_HEADER;
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;
};
Поле 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" прибавляется смещение поля в структуре CLI_SECTION_IMAGE.
Сразу за точкой входа находится заголовок CLI, который служит для определения положения метаданных в PE-файле. В заголовке находится информация об
В конце работы функции структура CLI_SECTION_IMAGE пишется в выходной файл, сразу после секции ".text". Записывается количество байт, равное значению макроса SIZEOF_CLI, который имеет следующий вид:
#define SIZEOF_CLI(params) \ align(sizeof(struct CLI_SECTION_IMAGE), params->FileAlignment)
Если структура CLI_SECTION_IMAGE не кратна inP->FileAlignment, то разница дописывается нулями.
Функция, ответственная за этот этап - make_reloc_section. Прототип данной функции:
void make_reloc_section (FILE* file, PINPUT_PARAMETERS inP);
Заключительная секция релокации содержит исправления для единственного абсолютного адреса в сборке, который находится в точке входа jmp dword в секции ".cli". Адрес x надо исправить, если сборка грузится по адресу, отличному от базового. Сгенерированная секция ".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, в него дописывается определенное количество нулей.
Если описать метаданные и методы сгенерированной сборки на CIL с использованием синтаксиса ILASM, то получится следующая IL-программа:
.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
}
Метаданные, используемые при генерации сборки, находятся в массиве , который в программе описан следующим образом (полное описание не приводится из-за его большого размера, полностью листинг массива
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,
0x00, 0x00, 0x0A, 0x28, 0x01, 0x00, 0x00, 0x0A,
0x28, 0x02, 0x00, 0x00, 0x0A, 0x2A
};
Итак, попробуем запустить нашу программу, набрав в консоли pegen.exe (так будет называться наша программа):
C:\>Pegen.exe
Если все прошло успешно, то на экране мы увидим сообщение об успешной генерации сборки hello.exe:
File: hello.exe generated
Запустим сгенерированную сборку:
C:\>hello.exe
Программа распечатает на экране строку "Hello" и попросит ввести произвольный текст. Введем, например, строку:
Hello Programm
В результате программа распечатает на экране строку, введенную ранее, и закончит свою работу:
Hello Programm
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.