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

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

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

Формат исполняемых файлов

Исполняемый файл (executable file) - это файл, который может быть загружен в память загрузчиком операционной системы и затем исполнен. В операционной системе Windows исполняемые файлы, как правило, имеют расширения ".exe" и ".dll". Расширение ".exe" имеют программы, которые могут быть непосредственно запущены пользователем. Расширение ".dll" имеют так называемые динамически связываемые библиотеки (dynamic link libraries). Эти библиотеки экспортируют функции, используемые другими программами.

Для того чтобы загрузчик операционной системы мог правильно загрузить исполняемый файл в память, содержимое этого файла должно соответствовать принятому в данной операционной системе формату исполняемых файлов. В разных операционных системах в разное время существовало и до сих пор существует множество различных форматов. В этой главе мы рассмотрим формат Portable Executable (PE). Формат PE - это основной формат для хранения исполняемых файлов в операционной системе Windows. Сборки .NET тоже хранятся в этом формате.

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

А теперь - немного истории. Формат PE был создан разработчиками Windows NT. До этого в операционной системе Windows использовались форматы New Executable (NE) и Linear Executable (LE) для представления исполняемых файлов, а для хранения объектных файлов использовался Object Module Format (OMF). Формат NE предназначался для 16-разрядных приложений Windows, а формат LE, изначально разработанный для OS/2, был уже 32-разрядным. Возникает вопрос: почему разработчики Windows NT решили отказаться от существующих форматов? Ответ становится очевидным, если обратить внимание на то, что большая часть команды, работавшей над созданием Windows NT, ранее работала в Digital Equipment Corporation. Они занимались в DEC разработкой инструментария для операционной системы VAX/VMS, и у них уже были навыки и готовый код для работы с исполняемыми файлами, представленными в формате Common Object File Format (COFF). Соответственно, формат COFF в слегка модифицированном виде был перенесен в Windows NT и получил название PE.

В ".NET Framework Glossary" сказано, что PE - это реализация Microsoft формата COFF. В то же время в [5] утверждается, что PE - это формат исполняемых файлов, а COFF - это формат объектных файлов. Вообще, мы можем наблюдать путаницу в документации Microsoft относительно названия формата. В некоторых местах они называют его COFF, а в некоторых - PE. Правда, можно заметить, что в новых текстах название COFF используется все меньше и меньше. Более того, формат PE постоянно эволюционирует. Например, несколько лет назад в Microsoft отказались от хранения отладочной информации внутри исполняемого файла, и поэтому теперь многие поля в структурах формата COFF просто не используются. Кроме того, формат COFF - 32-разрядный, а последняя редакция формата PE (она называется PE32+) может использоваться на 64-разрядных аппаратных платформах. Поэтому, видимо, дело идет к тому, что название COFF вообще перестанут использовать.

Интересно отметить, что исполняемые файлы в устаревших форматах NE и LE до сих пор поддерживаются Windows. Исполняемые файлы в формате NE можно запускать под управлением NTVDM (NT Virtual DOS Machine), а формат LE используется для виртуальных драйверов устройств (VxD).

Почему в названии формата PE присутствует слово "portable" ("переносимый")? Дело в том, что Windows NT была реализована не только для платформы Intel x86, но и для платформ MIPS R4000, DEC Alpha и PowerPC. И во всех реализациях для хранения исполняемых файлов использовался формат PE. При этом речь не шла о достижении двоичной совместимости между этими платформами, то есть exe-файл, предназначенный для выполнения на платформе Intel x86, нельзя было запустить на PowerPC. Важно понимать, что переносимость формата еще не означает переносимость исполняемых файлов, записанных в этом формате. Формат PE переносим в том смысле, что он слабо зависит от типа процессора и поэтому подходит для разных платформ (в том числе и для платформы .NET).

Далее в этой главе мы не будем затрагивать 64-разрядный вариант формата PE, потому что в настоящее время сборки .NET хранятся в прежнем 32-разрядном формате. Однако отметим, что 64-разрядный PE очень слабо отличается от 32-разрядного. Основное отличие касается разрядности полей структур PE-файла.

Управление памятью в Windows

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

Управление памятью в Windows NT/2k/XP/2k3 осуществляет менеджер виртуальной памяти (virtual-memory manager). Он использует страничную схему управления памятью, при которой вся физическая память делится на одинаковые отрезки размером в 4096 байт, называемые физическими страницами. Если физических страниц не хватает для работы системы, редко используемые страницы могут вытесняться на жесткий диск, в один или несколько файлов подкачки (pagefiles). Вытесненные страницы затем могут быть загружены обратно в память, если возникнет необходимость. Таким образом, программы могут использовать значительно большее количество памяти, чем реально присутствует в системе.

Виртуальное адресное пространство процесса

Каждый процесс в Windows запускается в своем виртуальном адресном пространстве размером в 4 Гб. При этом первые 2 Гб адресного пространства могут непосредственно использоваться процессом, а остальные 2 Гб резервируются операционной системой для своих нужд (рис. 2.1).

(рис 2.1) Виртуальное адресное пространство процесса

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

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

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

(рис 2.2) Перевод виртуального адреса в физический адрес

Рассмотрим на примере, как осуществляется перевод некоторого виртуального адреса vx в физический адрес px (рис 2.2). Сначала вычисляется номер vnum виртуальной страницы, соответствующий виртуальному адресу vx, а также смещение delta виртуального адреса относительно начала этой виртуальной страницы:

vnum := vx div 4096;
delta := vx mod 4096;

Далее возможны три варианта развития событий:

  • Виртуальная страница vnum недоступна. В этом случае перевод виртуального адреса vx в физический адрес невозможен, и процесс завершается с сообщением "Access Violation";
  • Виртуальная страница находится в файле страничной подкачки, и ее надо сначала загрузить в память. Тогда пусть pnum будет номером физической страницы, в которую мы загружаем нашу виртуальную страницу;
  • Виртуальная страница уже находится в памяти, и ей соответствует некоторая физическая страница. В этом случае pnum - номер этой физической страницы.
  • После чего адрес px вычисляется следующим образом:

    px := pnum*4096 + delta;

    Такая организация памяти процесса обладает следующими свойствами:

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

    Отображаемые в память файлы (memory-mapped files) - это мощная возможность операционной системы. Она позволяет приложениям осуществлять доступ к файлам на диске тем же самым способом, каким осуществляется доступ к динамической памяти, то есть через указатели. Смысл отображения файла в память заключается в том, что содержимое файла (или часть содержимого) отображается в некоторый диапазон виртуального адресного пространства процесса, после чего обращение по какому-либо адресу из этого диапазона означает обращение к файлу на диске. Естественно, не каждое обращение к отображенному в память файлу вызывает операцию чтения/записи. Менеджер виртуальной памяти кэширует обращения к диску и тем самым обеспечивает высокую эффективность работы с отображенными файлами.

    Обзор структуры PE-файла

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

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

    На рис. 2.3 изображена схема PE-файла. Слева показана структура файла на диске, а справа - его образ в памяти. Мы видим, что PE-файл начинается с заголовков, за которыми располагаются несколько секций.

    (рис 2.3) PE-файл на диске и в оперативной памяти

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

    Так как расположение элементов PE-файла в памяти и на диске отличаются, для их локализации приходится вводить два понятия: относительный виртуальный адрес элемента в памяти (Relative Virtual Address - RVA) и смещение элемента в файле (file offset).

    RVA некоторого элемента PE-файла - это разность виртуального адреса данного элемента и базового адреса, по которому PE-файл загружен в память. Например, если файл загружен по адресу 0x400000, и некоторый элемент в нем располагается по адресу 0x402000, то RVA этого элемента равен (0x402000 - 0x400000) = 0x2000.

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

    Загрузчик формирует образ PE-файла в памяти таким образом, что соблюдается следующее правило: пусть ox и oy - смещения каких-то элементов в файле, а rx и ry - RVA этих элементов, тогда если ox < oy, то rx < ry.

    Секции

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

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

    Каждая секция имеет имя. Оно не используется загрузчиком и предназначено главным образом для удобства человека. Разные компиляторы и компоновщики дают секциям различные имена. Например, компоновщик от Microsoft размещает код в секции ".text", константы - в секции ".rdata", таблицы импорта и экспорта - в секциях ".idata" и ".edata", таблицу релокаций - в секции ".reloc", ресурсы - в секции ".rsrc". В то же время компоновщик фирмы Borland использует имена "CODE" для секций, содержащих код, и "DATA" для секций с данными.

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

    Выбор базового адреса образа PE-файла в памяти

    Давайте обсудим, каким образом загрузчик определяет базовый адрес, по которому нужно загрузить PE-файл. Для exe-файлов это тривиальная задача: в заголовках файла присутствует поле ImageBase, содержащее значение базового адреса. Так как для выполнения exe-файла создается отдельный процесс со своим виртуальным адресным пространством, то обычно не возникает никаких проблем с тем чтобы отобразить файл в это адресное пространство по заданному адресу. Как правило, все exe-файлы содержат в поле ImageBase значение 0x400000.

    А вот выбор базового адреса для dll-файла куда сложнее. Дело в том, что динамическая библиотека, как правило, загружается в адресное пространство уже существующего процесса, и хотя dll-файл тоже содержит некоторое значение в поле ImageBase, очень часто может так получиться, что этот адрес уже занят чем-то другим (например, по нему уже загружена другая динамическая библиотека). Что же делать загрузчику, если он не может загрузить dll-файл по заданному адресу? Ему ничего не остается, как загрузить этот файл по какому-то другому адресу. Но тут возникает новая проблема - в файле могут содержаться инструкции с абсолютными адресами (это, в основном, инструкции абсолютных переходов, инструкции вызова подпрограмм, а также инструкции для работы с глобальными данными). При загрузке динамической библиотеки по другому адресу все адреса, содержащиеся в этих инструкциях, становятся неправильными, и загрузчик вынужден их поправить. Для того, чтобы загрузчик мог это сделать, в файле содержится таблица релокаций, в которой прописаны RVA всех абсолютных адресов.

    Импорт функций

    В PE-файле существует специальная секция ".idata", описывающая функции, который этот файл импортирует из динамических библиотек. Описание импортируемых функций в секции ".idata" приводит к тому, что библиотеки загружаются загрузчиком операционной системы еще до запуска программы. В принципе, необязательно описывать каждую импортируемую функцию в этой секции, так как динамические библиотеки можно загружать с помощью функции LoadLibrary из Win32 API прямо во время выполнения программы.

    В процессе загрузки программы осуществляется связывание (binding) функций, импортируемых из динамических библиотек. Связывание подразумевает загрузку соответствующих динамических библиотек и составление таблицы адресов импорта (Import Address Table - IAT). Адрес каждой импортируемой функции заносится в эту таблицу и в дальнейшем используется для вызова данной функции.

    Секция ".idata" в сборках .NET в некотором смысле носит вспомогательный характер, так как импортируемые сборкой динамические библиотеки описываются в метаданных. Задача этой секции - обеспечить запуск среды выполнения .NET, поэтому в ней описывается только одна импортируемая из mscoree.dll функция ( _CorExeMain для exe-файлов и _CorDllMain - для dll-файлов). При запуске сборки .NET управление сразу же передается этой функции, которая запускает Common Language Runtime, осуществляющий JIT-компиляцию программы и контролирующий в дальнейшем ее выполнение.

    Экспорт функций

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

    Необходимость в экспорте функций возникает только тогда, когда сборка .NET должна использоваться обычной программой Windows, код которой не управляется средой выполнения .NET.

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

    Заголовки

    Заголовок MS-DOS

    Каждый PE-файл начинается с небольшой (128 байт) программы, записанной в формате исполняемых файлов MS-DOS. Эта программа выводит на экран сообщение "This program cannot be run in DOS mode". В настоящее время наличие такого "заголовка" вряд ли имеет смысл, но во время повсеместного использования операционной системы MS-DOS люди зачастую случайно пытались запускать PE-файлы из ДОСовской командной строки, и эта маленькая программа в начале файла давала им возможность осознать свою ошибку, выбросить MS-DOS на помойку и установить наконец-то Windows NT!

    Рассмотрим шестнадцатеричный дамп заголовка MS-DOS, представленный на рис. 2.4.

    (рис 2.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.

    Особые секции PE-файла

    Секции PE-файла, как правило, содержат исполняемый код и данные, которые не имеют специального смысла для загрузчика. Но из всякого правила бывают исключения, поэтому далее мы рассмотрим структуру секции импорта ".idata", а также особенности хранения таблицы релокаций в секции ".reloc". В состав PE-файла могут входить и другие особые секции, но мы не будем их обсуждать, так как они не встречаются в сборках .NET.

    Секция импорта

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

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

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

    Схема секции импорта приведена на рис. 2.5. Ключевым элементом этой секции является таблица импорта (Import Directory Table), представляющая собой массив так называемых входов в таблицу импорта (Import Directory Entry). При этом самый последний вход в таблицу импорта заполнен нулями и сигнализирует о конце массива.

    (рис 2.5) Схема секции импорта

    Каждому dll-файлу, используемому программой, соответствует ровно один вход в таблицу импорта. Этот вход содержит указатели (в форме RVA) на два идентичных массива, которые называются таблицей адресов импорта (Import Address Table, далее IAT) и таблицей имен импорта (Import Lookup Table, далее ILT). Элементы этих массивов описывают символы, импортируемые из данного dll-файла. При этом каждый массив заканчивается нулевым элементом.

    Директория таблицы адресов импорта в дополнительном заголовке PE-файла должна указывать на таблицу адресов импорта (IAT).

    У тех, кто внимательно прочитал предыдущий абзац, обязательно должен возникнуть вопрос: а зачем нужно хранить в секции импорта два идентичных массива ILT и IAT?

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

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

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

    Вход в таблицу импорта представляет собой структуру, состоящую из нескольких полей:

    long ImportLookupTableRVA;

    RVA таблицы имен импорта (ILT). Ранее, до изобретения предварительного связывания, это поле называлось Characteristics.

    long TimeDateStamp;

    Это поле изначально равно нулю (в PE-файле на диске), но после загрузки dll-файла в него (уже в памяти) записывается время загрузки. (В предварительно связанном PE-файле в поле TimeDateStamp должно быть записано значение -1.)

    long ForwarderChain;

    Должно быть равно -1.

    long NameRVA;

    RVA ASCIIZ-строки, содержащей имя dll-файла.

    long ImportAddressTableRVA;

    RVA таблицы адресов импорта (IAT).

    Теперь рассмотрим, как организованы наши идентичные массивы ILT и IAT. Их элементами являются 32-разрядные целые числа. Если старший бит (31-й) такого 32-разрядного числа установлен в 1, то оставшиеся 31 бит обозначают порядковый номер импортируемого символа. Если же старший бит равен 0, то это 32-разрядное число обозначает RVA структуры Hint/Name, в которой хранится имя импортируемого символа.

    Структура Hint/Name состоит из трех полей:

    short Hint;

    Это поле является подсказкой для загрузчика. Оно содержит предполагаемый номер импортируемого символа. Загрузчик сначала ищет этот символ по указанному номеру. В случае неудачи он выполняет бинарный поиск символа по имени.

    char Name[x];

    Имя импортируемого символа в виде ASCIIZ-строки.

    char Pad;

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

    Секция релокаций

    В секции релокаций (".reloc") содержится таблица исправлений (Fix-up Table), в которой перечислены все абсолютные адреса в PE-файле, которые надо исправить, если файл загружается по адресу, отличному от указанного в поле ImageBase.

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

    Таблица исправлений разбита на блоки. Каждый блок описывает исправления, которые нужно внести в определенную страницу (4K байт) загруженного в память PE-файла. Каждый блок должен начинаться на 32-битовой границе.

    В начале каждого блока располагается заголовок, состоящий из следующих полей:

    long PageRVA;

    Это поле содержит RVA страницы PE-файла, исправления в которой описываются данным блоком.

    long BlockSize;

    Суммарный размер блока в байтах, включая заголовок.

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

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

    Рассмотрим, как загрузчик выполняет исправление образа PE-файла. Пусть ActualAddress - это адрес, по которому загружен PE-файл. И пусть delta - это смещение исправляемого 32-разрядного значения относительно начала страницы. Тогда адрес исправляемого значения вычисляется следующим образом:

    FixupAddress = ActualAddress + PageRVA + delta;

    Внесение исправления в 32-разрядное значение, которое находится по адресу FixupAddress, выполняется так (преобразования типов для простоты не указаны):

    *FixupAddress += ActualAddress - ImageBase;

    Заголовок CLI

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

    Заголовок CLI состоит из следующих полей:

    long Cb;

    Размер заголовка в байтах.

    short MajorRuntimeVersion;
    short MinorRuntimeVersion;

    Эти два поля содержат информацию о версии CLR, для которой предназначена данная сборка. В настоящее время эти поля должны содержать значения 2 и 0 соответственно.

    struct { long RVA, Size; } Metadata;

    В этом поле указываются RVA и размер в байтах метаданных в образе PE-файла.

    long Flags;

    Это поле описывает свойства сборки. Для обычных сборок .NET равно 1.

    long EntryPointToken;

    Токен метаданных, указывающий на точку входа в сборку.

    struct { long RVA, Size; } Resources;

    В этом поле указываются RVA и размер в байтах ресурсов сборки.

    struct { long RVA, Size; } StrongNameSignature;

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

    long CodeManagerTable[2];

    Это поле не используется и всегда заполнено нулями.

    struct { long RVA, Size; } VTableFixups;

    В этом поле указываются RVA и размер данных, используемых загрузчиком для исправления таблиц виртуальных методов. Так как эти таблицы, вообще говоря, порождаются только некоторыми "экзотическими" компиляторами (предположительно, Visual C++ With Managed Extensions), мы их рассматривать не будем.

    long ExportAddressTableJumps[2];

    Это поле не используется и всегда заполнено нулями.

    long ManagedNativeHeader[2];

    Это поле не используется и всегда заполнено нулями.

    Пример генерации PE-файла

    В приложении A приведен исходный код программы pegen, демонстрирующей генерацию PE-файла. Эта программа создает сборку hello.exe, работа которой заключается в дублировании строки, введенной пользователем с клавиатуры. Несмотря на то, что генерируемая сборка столь примитивна, программа pegen может служить основой для разработки реального генератора исполняемых файлов .NET.

    Программа pegen написана на языке C и состоит из двух частей:

  • модуль генерации PE-файла, оформленный как отдельная библиотека;
  • главный модуль, использующий модуль генерации для создания простейшей сборки .NET.
  • В модуле генерации определена функция make_file, которая принимает блок входных параметров и дескриптор выходного файла:

    void make_file (FILE* file, PINPUT_PARAMETERS inP)
    {
      make_headers	 		(file, inP); // 1 этап
      make_text_section	(file, inP); // 2 этап
      make_cli_section 	(file, inP); // 3 этап
      make_reloc_section	(file, inP); // 4 этап
    };

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

    Блок входных параметров описывается структурой INPUT_PARAMETERS:

    unsigned long		Type;

    Тип исполняемого файла: exe или dll. Поле может принимать значения:

    EXE_TYPE - выходной файл-exe;

    DLL_TYPE - выходной файл-dll.

    unsigned char*		metadata;

    Это поле содержит указатель на область памяти, где находятся метаданные в бинарном виде.

    unsigned char*		cilcode;

    Указатель на область памяти, где лежит CIL-код методов в бинарном виде.

    unsigned long		SizeOfMetadata;

    Размер метаданных.

    unsigned long		SizeOfCilCode;

    Размер CIL-кода методов.

    unsigned long		ImageBase;

    Базовый адрес загрузки.

    unsigned long		FileAlignment;

    Выравнивание секций в файле.

    unsigned long		EntryPointToken;

    Точка входа в сборку (токен метаданных, соответствующий некоторому статическому методу).

    unsigned short		Subsystem;

    Тип подсистемы Console или Windows GUI. Поле может принимать значения:

    IMAGE_SUBSYSTEM_WINDOWS_GUI - подсистема Windows GUI;

    IMAGE_SUBSYSTEM_WINDOWS_CUI - подсистема Windows CUI.

    Этих входных данных достаточно для генерации сборки .NET.

    Подробно рассмотрим каждый этап выполнения программы.

    Этап 1. Заполнение заголовка PE-файла

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

    void make_headers (FILE* file, PINPUT_PARAMETERS inP);

    Структура HEADERS включает в себя заголовок MS-DOS, сигнатуру PE, заголовок PE, дополнительный заголовок PE, директории данных и заголовки секций. Формат структур IMAGE_DATA_DIRECTORY и IMAGE_SECTION_HEADER, которые входят в структуру HEADERS, можно найти дальше:

    struct HEADERS {
      char ms_dos_header[128]; // заголовок MS-DOS
      unsigned long signature; // сигнатура PE
    
      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 секций:

  • Секция ".text" (содержит тела методов и метаданные);
  • Секция ".cli" (содержит точку входа, заголовок cli, таблицу импорта);
  • Секция ".reloc" (секция релокаций).
  • Следовательно, после дополнительного заголовка в структуре HEADERS будут находиться 3 заголовка секций.

    Для сборок .NET необходимы 4 директории данных:

  • Директория импорта;
  • Директория релокации;
  • Директория заголовка CLI;
  • Директория таблицы адресов импорта.
  • На основе блока входных параметров вычисляется расположение секций в памяти. Вычисления осуществляются внутри набора макросов (см. таблицу 2.1).

    Описание макросов

    Макрос:

    RVA_OF_TEXT

    Описание:

    RVA секции ".text"

    Подстановка:

    align (sizeof(struct (HEADERS), SECTION_ALIGNMENT)

    Код функции align приведен в конце таблицы.

    align - округляет первый аргумент в большую сторону до числа, кратного значению SECTION_ALIGNMENT.

    SECTION_ALIGNMENT - фиксированное выравнивание секций 0x2000

    Макрос:

    RVA_OF_CLI (params)

    Описание:

    RVA секции ".cli". Принимает в качестве аргумента блок входных параметров ( INPUT_PARAMETERS )

    Подстановка:

    RVA_OF_TEXT + align(params->SizeOfMetadata, SECTION_ALIGNMENT)

    Макрос:

    RVA_OF_RELOC (params)

    Описание:

    RVA секции ".reloc". Принимает в качестве аргумента блок входных параметров ( INPUT_PARAMETERS )

    Подстановка:

    RVA_OF_CLI (params) + SIZEOF_CLI_M

    В свою очередь макрос SIZEOF_CLI_M определен как:

    align (sizeof(struct (CLI_SECTION_IMAGE), SECTION_ALIGNMENT)

    Формат и назначение CLI_SECTION_IMAGE описано далее, в "Этапе 3".

    Код функции 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, следовательно разница дописывается нулями.

    Этап 2. Генерация секции ".text"

    Функция, выполняющая работу на этом этапе - make_text_section. Прототип функции:

    void make_text_section (FILE* file, PINPUT_PARAMETERS inP);

    В секции ".text" находятся метаданные и тела методов. Сначала в памяти выделяется массив, кратный выравниванию в файле. Размер массива задается макросом SIZEOF_TEXT(params), который определен следующим образом:

    #define SIZEOF_TEXT(params) 				\
     align(params->SizeOfMetadata+params->SizeOfCilCode,	\
     params->FileAlignment)

    Макрос принимает в качестве аргумента блок входных параметров.

    В выделенную память записываются метаданные из массива metadata и тела методов из массива cilcode, адреса которых передаются в функцию через поля inP->metadata и inP->cilcode блока входных параметров. Затем этот массив записывается в выходной файл сразу после заголовка HEADERS. Если размер метаданных и CIL-кода не кратен inP->FileAlignment, то разница дописывается нулями.

    Этап 3. Генерация секции ".cli"

    Всю работу на этом этапе выполняет функция make_cli_section. Прототип функции:

    void make_cli_section (FILE* file, PINPUT_PARAMETERS inP);

    В секции ".cli" содержится структура CLI_SECTION_IMAGE, в которой находится точка входа в приложение, заголовок CLI, таблица импорта и таблица адресов импорта:

    struct CLI_SECTION_IMAGE {
      struct _JMP_STUB {		// Точка входа
        unsigned short	JmpInstruction;
        unsigned long	JmpAddress;
      }JMP_STUB;
      struct _CLI_HEADER {	// Заголовок CLI
        unsigned long      cb;       
        unsigned short     MajorRuntimeVersion;
        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" прибавляется смещение поля Hint в структуре CLI_SECTION_IMAGE.

    Сразу за точкой входа находится заголовок CLI, который служит для определения положения метаданных в PE-файле. В заголовке находится информация об RVA и размере метаданных, а также информация о версии CLR, для которой предназначена сборка и токен метаданных, указывающий на точку входа в сборку. У DLL токен точки входа равен 0, т.к. DLL не может сама выполнять какие-либо действия.

    В конце работы функции структура CLI_SECTION_IMAGE пишется в выходной файл, сразу после секции ".text". Записывается количество байт, равное значению макроса SIZEOF_CLI, который имеет следующий вид:

    #define SIZEOF_CLI(params)		\
    align(sizeof(struct CLI_SECTION_IMAGE), params->FileAlignment)

    Если структура CLI_SECTION_IMAGE не кратна inP->FileAlignment, то разница дописывается нулями.

    Этап 4. Генерация секции ".reloc"

    Функция, ответственная за этот этап - make_reloc_section. Прототип данной функции:

    void make_reloc_section (FILE* file, PINPUT_PARAMETERS inP);

    Заключительная секция релокации содержит исправления для единственного абсолютного адреса в сборке, который находится в точке входа jmp dword ptr ds:[x] в секции ".cli". Адрес x надо исправить, если сборка грузится по адресу, отличному от базового. Сгенерированная секция ".reloc" содержит единственную структуру RELOC_SECTION, в которой есть все необходимые поля для исправления.

    Поле PageRVA содержит адрес страницы, в которой надо произвести исправление. Заполняется значением макроса RVA_OF_CLI. Поле BlockSize заполняется значением макроса SIZEOF_RELOC_NOTALIGNED, который определен так:

    #define SIZEOF_RELOC_NOTALIGNED sizeof(struct RELOC_SECTION).

    В сборках .NET в качестве типа исправления используется значение 3. Смещение адреса x на странице равно 2, т.к. расположение секций в памяти выровнено по страницам:

    struct RELOC_SECTION
    {
      unsigned long PageRVA;    	// адрес страницы
      unsigned long BlockSize;  	// размер блока
      unsigned short TypeOffset;	// тип исправления и 
    		                // смещение на странице
      unsigned short Padding;   	// завершающие нули
    };

    Структура записывается в конец файла после секции ".cli". Чтобы размер файла был кратен inP->FileAlignment, в него дописывается определенное количество нулей.

    Метаданные и методы

    Если описать метаданные и методы сгенерированной сборки на 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
    }

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

    unsigned char metadata[] = {
      0x42, 0x53, 0x4A, 0x42, 0x01, 0x00, 0x01, 0x00, 
      0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 
      . . . . . . . . . . . . . . . . . . . . . . . .
      0x00, 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 
      0x00, 0x00, 0x00, 0x00
    };

    В такой же форме в программе находится CIL-код методов:

    unsigned char cilcode[] = {
      0x56, 0x72, 0x01, 0x00, 0x00, 0x70, 0x28, 0x02, 
      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
    Страницы:

    Формат исполняемых файлов

    Исполняемый файл (executable file) - это файл, который может быть загружен в память загрузчиком операционной системы и затем исполнен. В операционной системе Windows исполняемые файлы, как правило, имеют расширения ".exe" и ".dll". Расширение ".exe" имеют программы, которые могут быть непосредственно запущены пользователем. Расширение ".dll" имеют так называемые динамически связываемые библиотеки (dynamic link libraries). Эти библиотеки экспортируют функции, используемые другими программами.

    Для того чтобы загрузчик операционной системы мог правильно загрузить исполняемый файл в память, содержимое этого файла должно соответствовать принятому в данной операционной системе формату исполняемых файлов. В разных операционных системах в разное время существовало и до сих пор существует множество различных форматов. В этой главе мы рассмотрим формат Portable Executable (PE). Формат PE - это основной формат для хранения исполняемых файлов в операционной системе Windows. Сборки .NET тоже хранятся в этом формате.

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

    А теперь - немного истории. Формат PE был создан разработчиками Windows NT. До этого в операционной системе Windows использовались форматы New Executable (NE) и Linear Executable (LE) для представления исполняемых файлов, а для хранения объектных файлов использовался Object Module Format (OMF). Формат NE предназначался для 16-разрядных приложений Windows, а формат LE, изначально разработанный для OS/2, был уже 32-разрядным. Возникает вопрос: почему разработчики Windows NT решили отказаться от существующих форматов? Ответ становится очевидным, если обратить внимание на то, что большая часть команды, работавшей над созданием Windows NT, ранее работала в Digital Equipment Corporation. Они занимались в DEC разработкой инструментария для операционной системы VAX/VMS, и у них уже были навыки и готовый код для работы с исполняемыми файлами, представленными в формате Common Object File Format (COFF). Соответственно, формат COFF в слегка модифицированном виде был перенесен в Windows NT и получил название PE.

    В ".NET Framework Glossary" сказано, что PE - это реализация Microsoft формата COFF. В то же время в [5] утверждается, что PE - это формат исполняемых файлов, а COFF - это формат объектных файлов. Вообще, мы можем наблюдать путаницу в документации Microsoft относительно названия формата. В некоторых местах они называют его COFF, а в некоторых - PE. Правда, можно заметить, что в новых текстах название COFF используется все меньше и меньше. Более того, формат PE постоянно эволюционирует. Например, несколько лет назад в Microsoft отказались от хранения отладочной информации внутри исполняемого файла, и поэтому теперь многие поля в структурах формата COFF просто не используются. Кроме того, формат COFF - 32-разрядный, а последняя редакция формата PE (она называется PE32+) может использоваться на 64-разрядных аппаратных платформах. Поэтому, видимо, дело идет к тому, что название COFF вообще перестанут использовать.

    Интересно отметить, что исполняемые файлы в устаревших форматах NE и LE до сих пор поддерживаются Windows. Исполняемые файлы в формате NE можно запускать под управлением NTVDM (NT Virtual DOS Machine), а формат LE используется для виртуальных драйверов устройств (VxD).

    Почему в названии формата PE присутствует слово "portable" ("переносимый")? Дело в том, что Windows NT была реализована не только для платформы Intel x86, но и для платформ MIPS R4000, DEC Alpha и PowerPC. И во всех реализациях для хранения исполняемых файлов использовался формат PE. При этом речь не шла о достижении двоичной совместимости между этими платформами, то есть exe-файл, предназначенный для выполнения на платформе Intel x86, нельзя было запустить на PowerPC. Важно понимать, что переносимость формата еще не означает переносимость исполняемых файлов, записанных в этом формате. Формат PE переносим в том смысле, что он слабо зависит от типа процессора и поэтому подходит для разных платформ (в том числе и для платформы .NET).

    Далее в этой главе мы не будем затрагивать 64-разрядный вариант формата PE, потому что в настоящее время сборки .NET хранятся в прежнем 32-разрядном формате. Однако отметим, что 64-разрядный PE очень слабо отличается от 32-разрядного. Основное отличие касается разрядности полей структур PE-файла.

    Управление памятью в Windows

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

    Управление памятью в Windows NT/2k/XP/2k3 осуществляет менеджер виртуальной памяти (virtual-memory manager). Он использует страничную схему управления памятью, при которой вся физическая память делится на одинаковые отрезки размером в 4096 байт, называемые физическими страницами. Если физических страниц не хватает для работы системы, редко используемые страницы могут вытесняться на жесткий диск, в один или несколько файлов подкачки (pagefiles). Вытесненные страницы затем могут быть загружены обратно в память, если возникнет необходимость. Таким образом, программы могут использовать значительно большее количество памяти, чем реально присутствует в системе.

    Виртуальное адресное пространство процесса

    Каждый процесс в Windows запускается в своем виртуальном адресном пространстве размером в 4 Гб. При этом первые 2 Гб адресного пространства могут непосредственно использоваться процессом, а остальные 2 Гб резервируются операционной системой для своих нужд (рис. 2.1).

    (рис 2.1) Виртуальное адресное пространство процесса

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

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

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

    (рис 2.2) Перевод виртуального адреса в физический адрес

    Рассмотрим на примере, как осуществляется перевод некоторого виртуального адреса vx в физический адрес px (рис 2.2). Сначала вычисляется номер vnum виртуальной страницы, соответствующий виртуальному адресу vx, а также смещение delta виртуального адреса относительно начала этой виртуальной страницы:

    vnum := vx div 4096;
    delta := vx mod 4096;

    Далее возможны три варианта развития событий:

  • Виртуальная страница vnum недоступна. В этом случае перевод виртуального адреса vx в физический адрес невозможен, и процесс завершается с сообщением "Access Violation";
  • Виртуальная страница находится в файле страничной подкачки, и ее надо сначала загрузить в память. Тогда пусть pnum будет номером физической страницы, в которую мы загружаем нашу виртуальную страницу;
  • Виртуальная страница уже находится в памяти, и ей соответствует некоторая физическая страница. В этом случае pnum - номер этой физической страницы.
  • После чего адрес px вычисляется следующим образом:

    px := pnum*4096 + delta;

    Такая организация памяти процесса обладает следующими свойствами:

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

    Отображаемые в память файлы (memory-mapped files) - это мощная возможность операционной системы. Она позволяет приложениям осуществлять доступ к файлам на диске тем же самым способом, каким осуществляется доступ к динамической памяти, то есть через указатели. Смысл отображения файла в память заключается в том, что содержимое файла (или часть содержимого) отображается в некоторый диапазон виртуального адресного пространства процесса, после чего обращение по какому-либо адресу из этого диапазона означает обращение к файлу на диске. Естественно, не каждое обращение к отображенному в память файлу вызывает операцию чтения/записи. Менеджер виртуальной памяти кэширует обращения к диску и тем самым обеспечивает высокую эффективность работы с отображенными файлами.

    Обзор структуры PE-файла

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

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

    На рис. 2.3 изображена схема PE-файла. Слева показана структура файла на диске, а справа - его образ в памяти. Мы видим, что PE-файл начинается с заголовков, за которыми располагаются несколько секций.

    (рис 2.3) PE-файл на диске и в оперативной памяти

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

    Так как расположение элементов PE-файла в памяти и на диске отличаются, для их локализации приходится вводить два понятия: относительный виртуальный адрес элемента в памяти (Relative Virtual Address - RVA) и смещение элемента в файле (file offset).

    RVA некоторого элемента PE-файла - это разность виртуального адреса данного элемента и базового адреса, по которому PE-файл загружен в память. Например, если файл загружен по адресу 0x400000, и некоторый элемент в нем располагается по адресу 0x402000, то RVA этого элемента равен (0x402000 - 0x400000) = 0x2000.

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

    Загрузчик формирует образ PE-файла в памяти таким образом, что соблюдается следующее правило: пусть ox и oy - смещения каких-то элементов в файле, а rx и ry - RVA этих элементов, тогда если ox < oy, то rx < ry.

    Секции

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

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

    Каждая секция имеет имя. Оно не используется загрузчиком и предназначено главным образом для удобства человека. Разные компиляторы и компоновщики дают секциям различные имена. Например, компоновщик от Microsoft размещает код в секции ".text", константы - в секции ".rdata", таблицы импорта и экспорта - в секциях ".idata" и ".edata", таблицу релокаций - в секции ".reloc", ресурсы - в секции ".rsrc". В то же время компоновщик фирмы Borland использует имена "CODE" для секций, содержащих код, и "DATA" для секций с данными.

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

    Выбор базового адреса образа PE-файла в памяти

    Давайте обсудим, каким образом загрузчик определяет базовый адрес, по которому нужно загрузить PE-файл. Для exe-файлов это тривиальная задача: в заголовках файла присутствует поле ImageBase, содержащее значение базового адреса. Так как для выполнения exe-файла создается отдельный процесс со своим виртуальным адресным пространством, то обычно не возникает никаких проблем с тем чтобы отобразить файл в это адресное пространство по заданному адресу. Как правило, все exe-файлы содержат в поле ImageBase значение 0x400000.

    А вот выбор базового адреса для dll-файла куда сложнее. Дело в том, что динамическая библиотека, как правило, загружается в адресное пространство уже существующего процесса, и хотя dll-файл тоже содержит некоторое значение в поле ImageBase, очень часто может так получиться, что этот адрес уже занят чем-то другим (например, по нему уже загружена другая динамическая библиотека). Что же делать загрузчику, если он не может загрузить dll-файл по заданному адресу? Ему ничего не остается, как загрузить этот файл по какому-то другому адресу. Но тут возникает новая проблема - в файле могут содержаться инструкции с абсолютными адресами (это, в основном, инструкции абсолютных переходов, инструкции вызова подпрограмм, а также инструкции для работы с глобальными данными). При загрузке динамической библиотеки по другому адресу все адреса, содержащиеся в этих инструкциях, становятся неправильными, и загрузчик вынужден их поправить. Для того, чтобы загрузчик мог это сделать, в файле содержится таблица релокаций, в которой прописаны RVA всех абсолютных адресов.

    Импорт функций

    В PE-файле существует специальная секция ".idata", описывающая функции, который этот файл импортирует из динамических библиотек. Описание импортируемых функций в секции ".idata" приводит к тому, что библиотеки загружаются загрузчиком операционной системы еще до запуска программы. В принципе, необязательно описывать каждую импортируемую функцию в этой секции, так как динамические библиотеки можно загружать с помощью функции LoadLibrary из Win32 API прямо во время выполнения программы.

    В процессе загрузки программы осуществляется связывание (binding) функций, импортируемых из динамических библиотек. Связывание подразумевает загрузку соответствующих динамических библиотек и составление таблицы адресов импорта (Import Address Table - IAT). Адрес каждой импортируемой функции заносится в эту таблицу и в дальнейшем используется для вызова данной функции.

    Секция ".idata" в сборках .NET в некотором смысле носит вспомогательный характер, так как импортируемые сборкой динамические библиотеки описываются в метаданных. Задача этой секции - обеспечить запуск среды выполнения .NET, поэтому в ней описывается только одна импортируемая из mscoree.dll функция ( _CorExeMain для exe-файлов и _CorDllMain - для dll-файлов). При запуске сборки .NET управление сразу же передается этой функции, которая запускает Common Language Runtime, осуществляющий JIT-компиляцию программы и контролирующий в дальнейшем ее выполнение.

    Экспорт функций

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

    Необходимость в экспорте функций возникает только тогда, когда сборка .NET должна использоваться обычной программой Windows, код которой не управляется средой выполнения .NET.

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

    Заголовки

    Заголовок MS-DOS

    Каждый PE-файл начинается с небольшой (128 байт) программы, записанной в формате исполняемых файлов MS-DOS. Эта программа выводит на экран сообщение "This program cannot be run in DOS mode". В настоящее время наличие такого "заголовка" вряд ли имеет смысл, но во время повсеместного использования операционной системы MS-DOS люди зачастую случайно пытались запускать PE-файлы из ДОСовской командной строки, и эта маленькая программа в начале файла давала им возможность осознать свою ошибку, выбросить MS-DOS на помойку и установить наконец-то Windows NT!

    Рассмотрим шестнадцатеричный дамп заголовка MS-DOS, представленный на рис. 2.4.

    (рис 2.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.

    Особые секции PE-файла

    Секции PE-файла, как правило, содержат исполняемый код и данные, которые не имеют специального смысла для загрузчика. Но из всякого правила бывают исключения, поэтому далее мы рассмотрим структуру секции импорта ".idata", а также особенности хранения таблицы релокаций в секции ".reloc". В состав PE-файла могут входить и другие особые секции, но мы не будем их обсуждать, так как они не встречаются в сборках .NET.

    Секция импорта

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

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

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

    Схема секции импорта приведена на рис. 2.5. Ключевым элементом этой секции является таблица импорта (Import Directory Table), представляющая собой массив так называемых входов в таблицу импорта (Import Directory Entry). При этом самый последний вход в таблицу импорта заполнен нулями и сигнализирует о конце массива.

    (рис 2.5) Схема секции импорта

    Каждому dll-файлу, используемому программой, соответствует ровно один вход в таблицу импорта. Этот вход содержит указатели (в форме RVA) на два идентичных массива, которые называются таблицей адресов импорта (Import Address Table, далее IAT) и таблицей имен импорта (Import Lookup Table, далее ILT). Элементы этих массивов описывают символы, импортируемые из данного dll-файла. При этом каждый массив заканчивается нулевым элементом.

    Директория таблицы адресов импорта в дополнительном заголовке PE-файла должна указывать на таблицу адресов импорта (IAT).

    У тех, кто внимательно прочитал предыдущий абзац, обязательно должен возникнуть вопрос: а зачем нужно хранить в секции импорта два идентичных массива ILT и IAT?

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

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

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

    Вход в таблицу импорта представляет собой структуру, состоящую из нескольких полей:

    long ImportLookupTableRVA;

    RVA таблицы имен импорта (ILT). Ранее, до изобретения предварительного связывания, это поле называлось Characteristics.

    long TimeDateStamp;

    Это поле изначально равно нулю (в PE-файле на диске), но после загрузки dll-файла в него (уже в памяти) записывается время загрузки. (В предварительно связанном PE-файле в поле TimeDateStamp должно быть записано значение -1.)

    long ForwarderChain;

    Должно быть равно -1.

    long NameRVA;

    RVA ASCIIZ-строки, содержащей имя dll-файла.

    long ImportAddressTableRVA;

    RVA таблицы адресов импорта (IAT).

    Теперь рассмотрим, как организованы наши идентичные массивы ILT и IAT. Их элементами являются 32-разрядные целые числа. Если старший бит (31-й) такого 32-разрядного числа установлен в 1, то оставшиеся 31 бит обозначают порядковый номер импортируемого символа. Если же старший бит равен 0, то это 32-разрядное число обозначает RVA структуры Hint/Name, в которой хранится имя импортируемого символа.

    Структура Hint/Name состоит из трех полей:

    short Hint;

    Это поле является подсказкой для загрузчика. Оно содержит предполагаемый номер импортируемого символа. Загрузчик сначала ищет этот символ по указанному номеру. В случае неудачи он выполняет бинарный поиск символа по имени.

    char Name[x];

    Имя импортируемого символа в виде ASCIIZ-строки.

    char Pad;

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

    Секция релокаций

    В секции релокаций (".reloc") содержится таблица исправлений (Fix-up Table), в которой перечислены все абсолютные адреса в PE-файле, которые надо исправить, если файл загружается по адресу, отличному от указанного в поле ImageBase.

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

    Таблица исправлений разбита на блоки. Каждый блок описывает исправления, которые нужно внести в определенную страницу (4K байт) загруженного в память PE-файла. Каждый блок должен начинаться на 32-битовой границе.

    В начале каждого блока располагается заголовок, состоящий из следующих полей:

    long PageRVA;

    Это поле содержит RVA страницы PE-файла, исправления в которой описываются данным блоком.

    long BlockSize;

    Суммарный размер блока в байтах, включая заголовок.

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

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

    Рассмотрим, как загрузчик выполняет исправление образа PE-файла. Пусть ActualAddress - это адрес, по которому загружен PE-файл. И пусть delta - это смещение исправляемого 32-разрядного значения относительно начала страницы. Тогда адрес исправляемого значения вычисляется следующим образом:

    FixupAddress = ActualAddress + PageRVA + delta;

    Внесение исправления в 32-разрядное значение, которое находится по адресу FixupAddress, выполняется так (преобразования типов для простоты не указаны):

    *FixupAddress += ActualAddress - ImageBase;

    Заголовок CLI

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

    Заголовок CLI состоит из следующих полей:

    long Cb;

    Размер заголовка в байтах.

    short MajorRuntimeVersion;
    short MinorRuntimeVersion;

    Эти два поля содержат информацию о версии CLR, для которой предназначена данная сборка. В настоящее время эти поля должны содержать значения 2 и 0 соответственно.

    struct { long RVA, Size; } Metadata;

    В этом поле указываются RVA и размер в байтах метаданных в образе PE-файла.

    long Flags;

    Это поле описывает свойства сборки. Для обычных сборок .NET равно 1.

    long EntryPointToken;

    Токен метаданных, указывающий на точку входа в сборку.

    struct { long RVA, Size; } Resources;

    В этом поле указываются RVA и размер в байтах ресурсов сборки.

    struct { long RVA, Size; } StrongNameSignature;

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

    long CodeManagerTable[2];

    Это поле не используется и всегда заполнено нулями.

    struct { long RVA, Size; } VTableFixups;

    В этом поле указываются RVA и размер данных, используемых загрузчиком для исправления таблиц виртуальных методов. Так как эти таблицы, вообще говоря, порождаются только некоторыми "экзотическими" компиляторами (предположительно, Visual C++ With Managed Extensions), мы их рассматривать не будем.

    long ExportAddressTableJumps[2];

    Это поле не используется и всегда заполнено нулями.

    long ManagedNativeHeader[2];

    Это поле не используется и всегда заполнено нулями.

    Пример генерации PE-файла

    В приложении A приведен исходный код программы pegen, демонстрирующей генерацию PE-файла. Эта программа создает сборку hello.exe, работа которой заключается в дублировании строки, введенной пользователем с клавиатуры. Несмотря на то, что генерируемая сборка столь примитивна, программа pegen может служить основой для разработки реального генератора исполняемых файлов .NET.

    Программа pegen написана на языке C и состоит из двух частей:

  • модуль генерации PE-файла, оформленный как отдельная библиотека;
  • главный модуль, использующий модуль генерации для создания простейшей сборки .NET.
  • В модуле генерации определена функция make_file, которая принимает блок входных параметров и дескриптор выходного файла:

    void make_file (FILE* file, PINPUT_PARAMETERS inP)
    {
      make_headers	 		(file, inP); // 1 этап
      make_text_section	(file, inP); // 2 этап
      make_cli_section 	(file, inP); // 3 этап
      make_reloc_section	(file, inP); // 4 этап
    };

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

    Блок входных параметров описывается структурой INPUT_PARAMETERS:

    unsigned long		Type;

    Тип исполняемого файла: exe или dll. Поле может принимать значения:

    EXE_TYPE - выходной файл-exe;

    DLL_TYPE - выходной файл-dll.

    unsigned char*		metadata;

    Это поле содержит указатель на область памяти, где находятся метаданные в бинарном виде.

    unsigned char*		cilcode;

    Указатель на область памяти, где лежит CIL-код методов в бинарном виде.

    unsigned long		SizeOfMetadata;

    Размер метаданных.

    unsigned long		SizeOfCilCode;

    Размер CIL-кода методов.

    unsigned long		ImageBase;

    Базовый адрес загрузки.

    unsigned long		FileAlignment;

    Выравнивание секций в файле.

    unsigned long		EntryPointToken;

    Точка входа в сборку (токен метаданных, соответствующий некоторому статическому методу).

    unsigned short		Subsystem;

    Тип подсистемы Console или Windows GUI. Поле может принимать значения:

    IMAGE_SUBSYSTEM_WINDOWS_GUI - подсистема Windows GUI;

    IMAGE_SUBSYSTEM_WINDOWS_CUI - подсистема Windows CUI.

    Этих входных данных достаточно для генерации сборки .NET.

    Подробно рассмотрим каждый этап выполнения программы.

    Этап 1. Заполнение заголовка PE-файла

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

    void make_headers (FILE* file, PINPUT_PARAMETERS inP);

    Структура HEADERS включает в себя заголовок MS-DOS, сигнатуру PE, заголовок PE, дополнительный заголовок PE, директории данных и заголовки секций. Формат структур IMAGE_DATA_DIRECTORY и IMAGE_SECTION_HEADER, которые входят в структуру HEADERS, можно найти дальше:

    struct HEADERS {
      char ms_dos_header[128]; // заголовок MS-DOS
      unsigned long signature; // сигнатура PE
    
      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 секций:

  • Секция ".text" (содержит тела методов и метаданные);
  • Секция ".cli" (содержит точку входа, заголовок cli, таблицу импорта);
  • Секция ".reloc" (секция релокаций).
  • Следовательно, после дополнительного заголовка в структуре HEADERS будут находиться 3 заголовка секций.

    Для сборок .NET необходимы 4 директории данных:

  • Директория импорта;
  • Директория релокации;
  • Директория заголовка CLI;
  • Директория таблицы адресов импорта.
  • На основе блока входных параметров вычисляется расположение секций в памяти. Вычисления осуществляются внутри набора макросов (см. таблицу 2.1).

    Описание макросов

    Макрос:

    RVA_OF_TEXT

    Описание:

    RVA секции ".text"

    Подстановка:

    align (sizeof(struct (HEADERS), SECTION_ALIGNMENT)

    Код функции align приведен в конце таблицы.

    align - округляет первый аргумент в большую сторону до числа, кратного значению SECTION_ALIGNMENT.

    SECTION_ALIGNMENT - фиксированное выравнивание секций 0x2000

    Макрос:

    RVA_OF_CLI (params)

    Описание:

    RVA секции ".cli". Принимает в качестве аргумента блок входных параметров ( INPUT_PARAMETERS )

    Подстановка:

    RVA_OF_TEXT + align(params->SizeOfMetadata, SECTION_ALIGNMENT)

    Макрос:

    RVA_OF_RELOC (params)

    Описание:

    RVA секции ".reloc". Принимает в качестве аргумента блок входных параметров ( INPUT_PARAMETERS )

    Подстановка:

    RVA_OF_CLI (params) + SIZEOF_CLI_M

    В свою очередь макрос SIZEOF_CLI_M определен как:

    align (sizeof(struct (CLI_SECTION_IMAGE), SECTION_ALIGNMENT)

    Формат и назначение CLI_SECTION_IMAGE описано далее, в "Этапе 3".

    Код функции 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, следовательно разница дописывается нулями.

    Этап 2. Генерация секции ".text"

    Функция, выполняющая работу на этом этапе - make_text_section. Прототип функции:

    void make_text_section (FILE* file, PINPUT_PARAMETERS inP);

    В секции ".text" находятся метаданные и тела методов. Сначала в памяти выделяется массив, кратный выравниванию в файле. Размер массива задается макросом SIZEOF_TEXT(params), который определен следующим образом:

    #define SIZEOF_TEXT(params) 				\
     align(params->SizeOfMetadata+params->SizeOfCilCode,	\
     params->FileAlignment)

    Макрос принимает в качестве аргумента блок входных параметров.

    В выделенную память записываются метаданные из массива metadata и тела методов из массива cilcode, адреса которых передаются в функцию через поля inP->metadata и inP->cilcode блока входных параметров. Затем этот массив записывается в выходной файл сразу после заголовка HEADERS. Если размер метаданных и CIL-кода не кратен inP->FileAlignment, то разница дописывается нулями.

    Этап 3. Генерация секции ".cli"

    Всю работу на этом этапе выполняет функция make_cli_section. Прототип функции:

    void make_cli_section (FILE* file, PINPUT_PARAMETERS inP);

    В секции ".cli" содержится структура CLI_SECTION_IMAGE, в которой находится точка входа в приложение, заголовок CLI, таблица импорта и таблица адресов импорта:

    struct CLI_SECTION_IMAGE {
      struct _JMP_STUB {		// Точка входа
        unsigned short	JmpInstruction;
        unsigned long	JmpAddress;
      }JMP_STUB;
      struct _CLI_HEADER {	// Заголовок CLI
        unsigned long      cb;       
        unsigned short     MajorRuntimeVersion;
        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" прибавляется смещение поля Hint в структуре CLI_SECTION_IMAGE.

    Сразу за точкой входа находится заголовок CLI, который служит для определения положения метаданных в PE-файле. В заголовке находится информация об RVA и размере метаданных, а также информация о версии CLR, для которой предназначена сборка и токен метаданных, указывающий на точку входа в сборку. У DLL токен точки входа равен 0, т.к. DLL не может сама выполнять какие-либо действия.

    В конце работы функции структура CLI_SECTION_IMAGE пишется в выходной файл, сразу после секции ".text". Записывается количество байт, равное значению макроса SIZEOF_CLI, который имеет следующий вид:

    #define SIZEOF_CLI(params)		\
    align(sizeof(struct CLI_SECTION_IMAGE), params->FileAlignment)

    Если структура CLI_SECTION_IMAGE не кратна inP->FileAlignment, то разница дописывается нулями.

    Этап 4. Генерация секции ".reloc"

    Функция, ответственная за этот этап - make_reloc_section. Прототип данной функции:

    void make_reloc_section (FILE* file, PINPUT_PARAMETERS inP);

    Заключительная секция релокации содержит исправления для единственного абсолютного адреса в сборке, который находится в точке входа jmp dword ptr ds:[x] в секции ".cli". Адрес x надо исправить, если сборка грузится по адресу, отличному от базового. Сгенерированная секция ".reloc" содержит единственную структуру RELOC_SECTION, в которой есть все необходимые поля для исправления.

    Поле PageRVA содержит адрес страницы, в которой надо произвести исправление. Заполняется значением макроса RVA_OF_CLI. Поле BlockSize заполняется значением макроса SIZEOF_RELOC_NOTALIGNED, который определен так:

    #define SIZEOF_RELOC_NOTALIGNED sizeof(struct RELOC_SECTION).

    В сборках .NET в качестве типа исправления используется значение 3. Смещение адреса x на странице равно 2, т.к. расположение секций в памяти выровнено по страницам:

    struct RELOC_SECTION
    {
      unsigned long PageRVA;    	// адрес страницы
      unsigned long BlockSize;  	// размер блока
      unsigned short TypeOffset;	// тип исправления и 
    		                // смещение на странице
      unsigned short Padding;   	// завершающие нули
    };

    Структура записывается в конец файла после секции ".cli". Чтобы размер файла был кратен inP->FileAlignment, в него дописывается определенное количество нулей.

    Метаданные и методы

    Если описать метаданные и методы сгенерированной сборки на 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
    }

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

    unsigned char metadata[] = {
      0x42, 0x53, 0x4A, 0x42, 0x01, 0x00, 0x01, 0x00, 
      0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 
      . . . . . . . . . . . . . . . . . . . . . . . .
      0x00, 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 
      0x00, 0x00, 0x00, 0x00
    };

    В такой же форме в программе находится CIL-код методов:

    unsigned char cilcode[] = {
      0x56, 0x72, 0x01, 0x00, 0x00, 0x70, 0x28, 0x02, 
      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
    Вернуться к учебному плану