Структура исполняемых файлов Win32 и Win64

Ю. С. Лукач

Предисловие

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

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

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

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

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

1. PE-файлы: общее описание

1.1. Стандарты COFF и PE

Во всех 32-разрядных ветках ОС Windows объектные (.OBJ), библиотечные (.LIB) и исполняемые (.EXE и .DLL) файлы хранятся в едином формате COFF (Common Object File Format), который используется некоторыми системами семейства Unix и ОС VMS.

Формат PE (Portable Executable) является специализацией COFF для хранения исполняемых модулей. Он был стандартизован Tool Interface Standard Committee (Microsoft, Intel, IBM, Borland, Watcom и др.) в 1993 г., а затем понемногу обновлялся (последнее известное мне обновление было проведено в феврале 1999 г., но оно не учитывает поддержки метаданных для .NET, добавленной в 2000 г.). Название Portable Executable связано с тем, что данный формат не зависит от архитектуры процессора, для которого построен исполняемый файл.

На сегодняшний день существует два формата PE-файлов: PE32 и PE32+. Оба они ограничивают адресное пространство программы размеров в 4 Гб (0xFFFFFFFF), но PE32 использует 32-битовые адреса (архитектура Win32), а PE32+ – 64-битовые адреса (архитектура Win64).

Большинство описанных ниже структур и констант содержатся в стандартном заголовочном файле Windows WINNT.H. Я буду ниже использовать названия и обозначения, принятые в этом файле.

1.2. Общая структура файла

Любой PE-файл состоит из нескольких заголовков и нескольких (от 1 до 96) секций. Заголовки содержат служебную информацию, описывающую различные свойства исполняемого файла и его структуру. Секции содержат данные, которые размещаются в адресном пространстве процесса во время загрузки исполняемого файла в память.

PE-файлы являются файлами с относительной загрузкой, т. е. теоретически могут размещаться в пространстве адресов 0x00000000 - 0xFFFFFFFF с любого адреса, называемого базовым адресом. Поскольку базовый адрес заранее неизвестен, структура PE-файлов основана на понятия RVA (relative virtual address, относительный виртуальный адрес). RVA представляет собой смещение от базового адреса исполняемого файла до данного адреса. Иными словами, для получения линейного адреса в виртуальной памяти процесса нужно сложить RVA с базовым адресом.

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

Общая структура PE-файла такова:

Заголовок DOS
Заглушка DOS
Заголовок PE
Заголовки секций
Секции

Подробно каждая из этих структур описана в следующих разделах.

1.3. Проецирование файла в память

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

#include <windows.h>

LPBYTE OpenPEFile(LPCTSTR lpszFileName) {
  HANDLE hFile = CreateFile(lpszFileName, GENERIC_READ, FILE_SHARE_READ, NULL,
    OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
  if (hFile == INVALID_HANDLE_VALUE)
    return NULL;
  HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
  CloseHandle(hFile);
  LPBYTE pBase = NULL;
  if (hMapping != NULL) {
    pBase = (LPBYTE)MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
    CloseHandle(hMapping);
  }
  return pBase;
}

void ClosePEFile(LPBYTE pBase) {
  if (pBase != NULL)
    UnmapViewOfFile(pBase);
}

1.4. Общее описание загрузки PE-файла

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

  1. Загрузчик Windows считывает заголовок файла и определяет необходимый для него объем виртуальной памяти. Если возможно, то файл будет загружен с предпочтительного адреса, который в нем прописан. Если нет, то загрузчик находит в виртуальной памяти свободное место достаточного размера и загружает файл с этого адреса. Подробнее см. описание заголовка PE.
  2. Сам процесс загрузки состоит в следующем. Для каждой секции файла вычисляется ее адрес в виртуальной памяти относительно базового адреса файла и размер в памяти. Все страницы памяти будущей секции получают атрибуты, соответствующие характеристикам секции. Затем содержимое секции копируется в эти страницы и, при необходимости, дополняется нулями до нужной длины. Подробнее см. описание секций.
  3. Если базовый адрес загрузки отличается от предпочтительного адреса, то выполняется настройка адресов.
  4. Теперь загрузчик анализирует таблицу импорта. Для каждой указанной в ней динамической библиотеки производится загрузка соответствующего DLL-файла (т. е. выполняются все перечисленные здесь этапы). Затем выполняется связывание импортируемых символов.
© 2005 Юрий Лукач