Опубликован: 28.06.2006 | Уровень: специалист | Доступ: платный | ВУЗ: Московский государственный технический университет им. Н.Э. Баумана
Лекция 3:

Структура программных компонентов

Заголовки

Заголовок 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.

Шестнадцатеричный дамп заголовка MS-DOS

Рис. 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-файла. В современной документации он называется "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 - файл не содержит информации о номерах строк исходной программы;

0x0008 - файл не содержит информации о символах исходной программы;

0x0100 - файл предназначен для исполнения на 32-разрядной машине.

Если сборка представляет собой динамическую библиотеку, то дополнительно нужно установить флаг 0x2000.

Таким образом, значение поля Characteristics для exe-файлов - 0x010E, а для dll-файлов - 0x210E.

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

Дополнительный заголовок 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.

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 execute/read for EXEs or 0 for DLLs". Налицо две ошибки: лишний ноль в адресе (0x4000000), а также информация о том, что для 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, для которой предназначена сборка. Это поле игнорируется загрузчиком, и в случае сборок .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;

Суммарный размер всех заголовков, включая заголовок MS-DOS, заголовок 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_SUBSYSTEM_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.
Заголовки секций

Непосредственно после дополнительного заголовка следует массив заголовков секций. Количество секций и, соответственно, размер этого массива задается полем 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 - секция содержит инициализированные данные;

0x00000080 - секция содержит неинициализированные данные;

0x02000000 - секция может быть удалена из исполняемого файла (этот флаг установлен для секции ".reloc", содержащей таблицу релокаций);

0x20000000 - код секции может быть исполнен;

0x40000000 - секция доступна для чтения;

0x80000000 - секция доступна для записи.

Для секций, содержащих метаданные и CIL-код, необходимо использовать значение флагов 0x60000020.

Анастасия Булинкова
Анастасия Булинкова
Рабочим названием платформы .NET было
Bogdan Drumov
Bogdan Drumov
Молдова, Республика
Azamat Nurmanbetov
Azamat Nurmanbetov
Киргизия, Bishkek