Исполняемые файлы Windows
Как сделать, чтобы программа заработала? Работа приложения начинается с того, что операционная система создает процесс. Это не просто загруженная в память программа пользователя; процесс предполагает создание множества внутренних системных структур для обеспечения работы программы и предоставления ей различных ресурсов, таких как память, процессорное время, доступ к установленному в системе оборудованию и т.д.
Важнейшим ресурсом являетcя виртуальная память. Каждый процесс получает в свое распоряжение собственное виртуальное адресное пространство памяти размером 4 Гб. Это значит, что он может обращаться по любому из адресов памяти от 0 до FFFFFFFFh. Но это значит также и то, что различные процессы могут использовать одни и те же адреса, не мешая друг другу. Система работает с памятью в виде блоков фиксированного размера, называемых страницами (обычно по 4 Кб; на современных процессорах могут быть страницы также по 2 Мб) и использует страничную переадресацию для отображения одних и тех же виртуальных адресов различных процессов в разные области физической памяти. Кроме того, при недостатке физической памяти временно неиспользуемые данные могут сохраняться на диске, освобождая физическую память для других виртуальных адресов (это называется подкачкой).
В адресном пространстве процесса резервируются области для динамически выделяемой памяти ("кучи") и стека (о нем мы подробнее поговорим в следующей статье). Затем образ программы загружается из файла на диск по базовому адресу загрузки. Образ программы состоит из одной или нескольких секций. Для каждой секции выделяется несколько страниц памяти, имеющих одинаковые атрибуты. Например, это могут быть исполняемые страницы, страницы только для чтения или для чтения и записи. Это сделано для уменьшения количества возможных ошибок; например, случайный запуск на исполнение страницы, содержащей не код, а данные, может привести к непредсказуемым результатом. Если же в атрибутах страницы не указана возможность исполнения, это приведет к сообщению об ошибке. Точно так же атрибут "только для чтения" позволяет перехватить попытку случайной или преднамеренной записи на страницу, содержание которой не должно изменяться (допустим, если она содержит константы).
Расширение "exe" осталось в наследство от старых досовских исполняемых (executable) файлов. Используемый в настоящее время формат исполняемых файлов Windows называется "Portable Executable" (PE), поскольку один и тот же формат используется для разных платформ. Более того, он построен на основе шаблонов, являющихся общими и для объектных файлов формата COFF (используемых в том числе в мире Unix), а также построенных на их основе библиотечных файлов и файлов импорта (.lib). Формат PE в системе Win32 является универсальным: его используют не только исполняемые файлы (exe), но и динамические библиотеки (dll) и их особые разновидности -элементы ActiveX (ocx) и системные драйверы (sys и drv).
Как и старый формат exe для DOS, PE-файл состоит из заголовка и собственно образа исполняемой программы. Образ программы, как уже отмечалось, может быть составлен из одной или нескольких секций. Заголовок же можно условно разделить на "старый" и "новый" (см. рис.)
"Старый" заголовок, в свою очередь, составлен из слегка модифицированного DOS-заголовка и т.н. программы-заглушки, и фактически представляет собой небольшую программу DOS, выводящую простое текстовое сообщение наподобие "This program cannot be run in DOS mode". Это сделано для того, чтобы при ошибочной попытке запуска программы Windows под DOS она могла сообщить об ошибке. Модификация заголовка DOS заключается в том, что по смещению 3Ch от начала файла расположено 32-разрядное смещение PE-заголовка.
"Новый" заголовок составлен из собственно PE-заголовка и таблицы секций, которая фактически является картой отображения записанных в файле секций образа программы в память. В PE-заголовке выделяют также несколько составных частей, но для нашего рассмотрения они несущественны. Отметим лишь каталог смещений-размеров, который указывает на расположение и размеры специальных служебных таблиц. Для размещения последних могут быть выделены отдельные секции в образе программы, но это не является обязательным; в принципе, для всей программы можно использовать одну единственную секцию, разместив в ней и данные, и код, и все необходимые вспомогательные структуры.
Теперь рассмотрим все подробнее. Поскольку попытка запуска создаваемых нами программ под DOS маловероятна, можно без особых проблем обойтись без программы-заглушки DOS. PE-заголовок в этом случае будет следовать сразу за старым заголовком DOS, а именно - непосредственно после 4-байтного поля со смещением 3Ch, т.е. по смещению 40h (само поле 3Ch будет содержать в данном случае это же значение). Единственное, что нужно еще оставить в старом заголовке - это сигнатуру в виде 2 ASCII-символов 'MZ' в начале файла (байты 4Dh 5Ah). Остальные поля могут содержать нули - загрузчик Windows их не использует.
Поля PE-заголовка приведены в таблице 1. Смещения указаны относительно начала заголовка, а жирным шрифтом выделены те поля, при неверных значениях которых Windows откажется загружать программу. Остальные поля либо содержат необязательные данные (например, указатель на размещение и размер отладочных данных), либо для них предусмотрены значения по умолчанию (как для размеров кучи и стека), либо используются лишь для определенных видов файлов (например, флаги dll или контрольная сумма).
Таблица 1. PE-заголовок
СмещениеРазмер, байтПолеТипичное значение
0 | 4 | Сигнатура 'PE' | 50h 45h 00 00 |
4 | 2 | Тип процессора | 14Ch |
6 | 2 | Число секций в образе программы | - |
8 | 4 | Время/дата создания файла | - |
0Ch | 4 | Указатель на таблицу символов | 0 |
10h | 4 | Количество отладочных символов | 0 |
14h | 2 | Размер дополнительного заголовка | E0h |
16h | 2 | Тип файла | 10Fh |
18h | 2 | "Магическое" значение | 10Bh |
1Ah | 1 | Старшая версия компоновщика | - |
1Bh | 1 | Младшая версия компоновщика | - |
1Ch | 4 | Размер кода | - |
20h | 4 | Размер инициализированных данных | - |
24h | 4 | Размер неинициализированных данных | - |
28h | 4 | Смещение точки входа | - |
2Ch | 4 | Смещение секции кода в памяти | - |
30h | 4 | Смещение секции данных в памяти | - |
34h | 4 | Адрес загрузки образа в память | 400000h |
38h | 4 | Выравнивание секций в памяти | 1000h |
3Ch | 4 | Выравнивание в файле | 200h |
40h | 2 | Старшая версия Windows | 4 |
42h | 2 | Младшая версия Windows | 0 |
44h | 2 | Старшая версия образа | - |
46h | 2 | Младшая версия образа | - |
48h | 2 | Старшая версия подсистемы | 4 |
4Ah | 2 | Младшая версия подсистемы | 0 |
4Ch | 4 | Зарезервировано | 0 |
50h | 4 | Размер загруженного файла в памяти | - |
54h | 4 | Размер всех заголовков в файле | - |
58h | 4 | Контрольная сумма | 0 |
5Ch | 2 | Подсистема | 2 или 3 |
5Eh | 2 | Флаги dll | 0 |
60h | 4 | Зарезервированный размер стека | 100000h |
64h | 4 | Выделенный размер стека | 1000h |
68h | 4 | Зарезервированный размер кучи | 100000h |
6Ch | 4 | Выделенный размер кучи | 1000h |
70h | 4 | Устарело | 0 |
74h | 4 | Число элементов в каталоге смещений | 10h |
Далее в PE- заголовке следует каталог размещения вспомогательных таблиц: первые 4 байта для каждого элемента являются смещением начала соответствующих данных относительно базового адреса загрузки, следующие 4 байта - размером этих данных. Хотя число элементов в каталоге указывается в поле PE-заголовка, Windows 9* не допускает значения, меньшего 10h. Структура каталога фиксирована; указатели на соответствующие данные должны следовать в следующем порядке:
- таблица экспорта;
- таблица импорта;
- таблица ресурсов;
- таблица исключений;
- таблица сертификатов;
- таблица настроек;
- отладочные данные;
- специфичные для архитектуры данные;
- глобальный указатель;
- таблица локального хранилища потоков (TLS);
- таблица конфигурирования загрузки;
- таблица связанного импорта;
- таблица импортируемых адресов (IAT);
- дескриптор отложенного импорта;
- зарезервировано;
- зарезервировано.
Это не значит, что все перечисленные данные должны присутствовать. Если те или иные данные отсутствуют, соответствующие поля каталога содержат нули. Мы будем рассматривать эти структуры по мере того, как начнем с ними работать.
Таблица секций следует непосредственно после PE-заголовка (после каталога смещений). Каждый вход таблицы имееет следующий формат (см. табл. 2).
Таблица 2. Строка таблицы секций
СмещениеРазмер, байтПоле
0 | 8 | Произвольное имя секции |
8 | 4 | Размер секции в памяти |
0Ch | 4 | Смещение секции в памяти относительно адреса загрузки |
10h | 4 | Размер данных секции в файле |
14h | 4 | Смещение начала данных секции в файле |
18h | 12 | Используется лишь в объектных файлах |
24h | 4 | Флаги секции |
Таблица секций имеет столько входов, сколько секций в образе программы. Расположение секций в файле и в виртуальной памяти созданного процесса может не совпадать. Данные различных секций как в файле, так и в памяти располагаются не вплотную друг к другу - они должны быть соответствующим образом выровнены. Например, если код занимает всего 2 байта, следующая за ним секция (допустим, данных) располагается не по смещению +2 байта, а на границе следующей страницы, т.е. как минимум через 4 Кб, если это образ в памяти, и минимум через 512 байт для образа в файле. Значения для выравнивания в файле и в памяти указаны в PE-заголовке, причем они обязательны.
Секция может содержать т.н. неинициализированные данные. Фактически, это просто резервирование определенных адресов памяти под будущие переменные. Для таких данных место в файле не отводится; память резервируется лишь при загрузке на исполнение. Если вся секция содержит лишь неинициализированные данные, поля размера данных секции в файле и смещения начала данных секции в файле равны нулю. В любом случае, когда размер секции в файле меньше указанного размера секции в памяти, остаток заполняется до нужного размера нулями.
Поле флагов секции - то самое, где задаются атрибуты страниц памяти, отводимых под секцию. Возможно использование до 32 флагов (по одному на каждый бит 4-байтного значения), но часть из них зарезервирована, другая часть используется лишь в объектных файлах. Биты нумеруются от младшего к старшему, начиная от 0 (самый младший бит - 0, самый старший - 31). Наиболее употребительные для исполняемых файлов следующие:
бит 5 - секция кода;
бит 6 - инициализированные данные;
бит 7 - неинициализированные данные;
бит 28 - секция может быть общей (разделяемой - shared);
бит 29 - разрешено исполнение;
бит 30 - разрешено чтение;
бит 31 - разрешена запись.
Например, в секции кода с разрешениями на чтение и исполнение установлены следующие флаги:
01100000 00000000 00000000 00100000 (60 00 00 20 h)
Секция с инициализированными данными с разрешениями на чтение и запись:
11000000 00000000 00000000 01000000 (C0 00 00 40 h)
Та же секция, но с разрешением только для чтения:
01000000 00000000 00000000 01000000 (40 00 00 40 h)
Перейдем, наконец, к практике и составим шаблон заголовка PE-файла, имеющего 3 секции с минимальными размерами. Тогда в памяти каждая будет занимать 1000h (1 страница - отвести меньше памяти невозможно), а в файле - 200h байт (1 сектор диска). Такими же будут и значения выравнивания. Первой пусть идет секция кода; назовем ее '.code' (см. рис.) Она будет располагаться по смещению 200h от начала файла, а в памяти - по смещению 1000h от адреса загрузки (первую страницу памяти и первые 200h байтов файла занимает заголовок). Секция кода будет иметь флаги, которые мы вычислили ранее (60000020h)
Секции исполняемого файла
Следующей будет секция с данными только для чтения; назовем ее '.rdata'. Она будет расположена в файле по смещению 400h, а в памяти - по смещению 2000h. Флаги: 40000040h. За ней - секция данных с разрешениями на чтение и запись: '.data', расположение в файле - 600h, в памяти - 3000h; флаги: C0000040h.
Теперь составим командный файл для отладчика debug. Имеет смысл сначала создать специальную папку "Шаблоны". В ней сохраним этот файл для использования в дальнейшем. Открываем Блокнот и набираем:
n Header.bin r cx 200 f 0 l 200 0 e 0 'MZ' e 3C 40 e 40 'PE' e 44 4C 01
Бинарный файл с заголовом будет называться 'Header.bin', его размер - 200h байт. Сначала очищаем "область сборки" - первые 200h байт, затем набираем стандартные сигнатуры. Программы-заглушки у нас не будет - PE-заголовок следует непосредственно за DOS-заголовком; заодно это сэкономит размер заголовка.
А вот дальше пойдут поля PE-заголовка, которые нужно будет настраивать для каждого отдельного exe-файла. Чтобы было удобнее редактировать этот файл в дальнейшем, оставим здесь комментарии - а для этого нам придется изменить способ ввода и перейти в режим ассемблирования.
a 46 ; Здесь должно быть число секций (2 байта) ***** db 03 00 <пустая строка>
Режим ассемблирования начинается с команды 'a', за которой следует смещение, по которому нужно вводить данные. В нашем случае, PE-заголовок начинается со смещения 40h от начала файла, поэтому к значениям смещения в таблице 1 нужно добавлять 40h. Близко отстоящие друг от друга поля можно набирать "в один заход"; когда же разрыв большой, можно выйти из режима ассемблирования (оставив для этого пустую строку) и вновь набрать 'a' уже с новым смещением. В "разрыве" при этом останутся нули. Учтите, что комментарии можно оставлять лишь "внутри" режима ассемблирования - вне его отладчик выдаст ошибку.
Имеет смысл также выделить те участки, которые нужно будет в дальнейшем редактировать (как этот случай - число секций может каждый раз быть разным); для этого удобно выделять каким-либо способом строку с комментарием, чтобы она сразу бросалась в глаза. Оставшуюся часть файла для debug приведем, как есть; она не должна вызвать проблем (обратите внимание на пустые строки - их нельзя удалять; и помните про обратный порядок байтов в числах, требующих более 1 байта):
a 54 ; Размер дополнительного заголовка db e0 00 ; Тип файла db 0F 01 ; "Магическое" значение db 0B 01
a 68 ; Здесь должно быть смещение точки входа ; относительно адреса загрузки (4 байта) ***** db 00 10 00 00
a 74 ; Начальный адрес загрузки (4 байта) ***** db 00 00 40 00 ; Выравнивание секций (4 байта) db 00 10 00 00 ; Выравнивание в файле (4 байта) db 00 02 00 00 ; Старшая версия Windows (2 байта) db 04 00
a 88 ; Старшая версия подсистемы (2 байта) db 04 00
a 90 ; Здесь должен быть размер загруженного файла ; в памяти (4 байта) ***** db 00 40 00 00 ; Размер всех заголовков в файле (4 байта) db 00 02 00 00
a 9C ; Подсистема: 02 - графическая, 03 - консольная (2 байта) db 02 00
a A0 ; Зарезервированный размер стека (4 байта) db 00 00 10 00 ; Выделенный размер стека (4 байта) db 00 10 00 00 ; Зарезервированный размер кучи (4 байта) db 00 00 10 00 ; Выделенный размер кучи (4 байта) db 00 10 00 00
a B4 ; Число элементов каталога смещений ( 4 байта) db 10 00 00 00 ;************ ; Здесь начинается первый элемент каталога: ; но у нас пока ничего нет - оставляем нули
a 138 ; Начало таблицы секций ; ; имя первой секции (8 символов) db '.code' 0 0 0 ; размер секции в памяти (4 байта) db 00 10 00 00 ; смещение секции относительно адреса загрузки (4 байта) db 00 10 00 00 ; размер данных секции в файле (4 байта) db 00 02 00 00 ; смещение начала данных секции в файле (4 байта) db 00 02 00 00 ; Пропускаем 12 байтов db 0 0 0 0 0 0 0 0 0 0 0 0 ; атрибуты первой секции (4 байта): ; код, разрешено исполнение и чтение db 20 00 00 60 ; ; данные второй секции - аналогично: db '.rdata' 0 0 db 00 10 00 00 db 00 20 00 00 db 00 02 00 00 db 00 04 00 00 db 0 0 0 0 0 0 0 0 0 0 0 0 db 40 00 00 40 ; ; данные третьей секции db '.data' 0 0 0 db 00 10 00 00 db 00 30 00 00 db 00 02 00 00 db 00 06 00 00 db 0 0 0 0 0 0 0 0 0 0 0 0 db 40 00 00 C0
m 0 l 200 100 w q
Перед записью созданного "образа заголовка" сдвигаем его на 100h байт, чтобы все записалось правильно. Сохраним этот текст в файле "Header.txt".
Теперь у нас есть шаблон, который можно вставлять в начало exe-файла с 3 секциями, размеры которых не превышают 200h байт каждая. Чтобы протестировать его, нужно собрать "настоящий" exe-файл с его использованием. Для этого немного схитрим: вставим две пустые секции (содержащие лишь нули) в качестве секций данных; а в секции кода используем всего 2 байта: EB FE. Это инструкция, передающая управление на себя (как мы узнаем в дальнейшем). Т.е. наша программа просто зацикливается; но пока нам большего и не надо.
В блокноте создадим еще 2 простых файла. Первый - "s1.txt" (содержит наш "код"):
n s1.bin r cx 200 f 100 l 200 0 e 100 eb fe w q
Второй - "s2.txt" (секция в 200h байт, заполненная нулями):
n s2.bin r cx 200 f 100 l 200 0 w q
А теперь в том же Блокноте создаем файл "make.bat":
@echo off debug < header.txt > report.lst debug < s1.txt >> report.lst debug < s2.txt >> report.lst copy /b header.bin+s1.bin+s2.bin+s2.bin nil.exe
Первый вызов debug исполняет команды, записанные в файле header.txt (при этом создается файл header.bin). Отчет выводится в файл report.lst; это необходимо для того, чтобы можно было проверить, не были ли допущены ошибки.
Второй вызов debug исполняет команды в файле s1.txt, создавая файл s1.bin с нашей "секцией кода". Перенаправление с двумя знаками >> означает, что отчет записывается не с начала указанного файла (затирая его содержимое), а добавляется в его конец. Третий вызов debug выполняет s2.txt, создавая пустую секцию в файле s2.bin. Наконец, мы объединяем эти секции в единый файл с расширением exe, причем заметьте - файл s2.bin использован дважды (2 пустые секции).
Теперь полученный файл можно попытаться запустить. Но перед этим неплохо бы еще раз тщательно проверить все исходные файлы - вероятность допущенной ошибки довольно велика. Просмотрите файл report.lst - нет ли сообщений отладчика об ошибках. В частности, типичной ошибкой является случайное использование в командах вместо латинских букв кириллицы (особенно одинаковых - c и с, e и е и т.д.) Если файл создан правильно, ничего не произойдет - сообщения Windows будут лишь при наличии ошибки. Зато нажав Ctl-Alt-Del, вы увидите исполняющуюся задачу 'nil'. Выделите ее и нажмите кнопку "Завершить процесс" - пока мы можем закрыть эту программу только таким способом.
Содержание раздела