2007 г.
Win32 в машинных кодах
Рустэм Галеев aka Roustem
Содержание
1. Введение в машинные коды для Win32
2. Инструменты
3. Исполняемые файлы Windows
4. Простейшее приложение
5. Консольное приложение
6. Окна Windows
7. Сообщения Windows
8. Класс окна
9. Главная функция окна
10. Шаблон оконного приложения
1. Введение в машинные коды для Win32
Мир машинных кодов для процессоров Intel IA-32 захватывающий и фантастический. Он предоставляет такие богатые возможности для творчества, что будет неудивительно, если через некоторое время станут проводить чемпионаты по спортивному программированию в машинных кодах, а лучшие творения кодеров представлять на выставках, как произведения искусства. Множество интересных находок было накоплено за прошедшие годы кодокопателями, среди которых есть как законные системные программисты, так и подпольные авторы вирусов, хакеры и кракеры.
Как когда-то великие путешественники-первопроходцы открывали новые земли, кодеры исследуют бурно разросшееся виртуальное пространство информационных технологий. Несмотря на то, что ее создавали сами люди, эта матрица нашего времени стремительно развивается по каким-то своим законам. Накопились огромные пласты неосвоенных знаний. Развилась целая философия "быстрой разработки приложений" - своего рода "информационный фастфуд". Но разве может забегаловка заменить собой изысканный ресторан?
Можно сказать, информационные технологии проходят сейчас период массового производства, как когда-то автомобильная и другие виды промышленности. Конвейер штампует однотипные универсальные изделия. Но посмотрите на исторические тенденции. Сначала автомобили собирали поштучно. Потом появился конвейер. Но сейчас самые дорогие и качественные машины опять собирают вручную! А разве механические часы исчезли с появлением электронных? Напротив, стали только качественнее и дороже. А когда их сравнивают с электронными, последние презрительно именуют "штамповкой". И как сравнить массовую бижутерию с синтетическими камнями с филигранной ювелирной работой?..
Как бы то ни было, но и в компьютерной индустрии постепенно развилась особая субкультура низкоуровневого программирования. Долгое время она варилась в собственном соку, оставаясь достоянием узкого круга посвященных, интенсивно осмысливавших накопленные знания. Вероятно, был пройден некий порог, и мы вплотную приблизились к моменту, когда начинает зарождаться элитарное штучное ручное производство и в данной высокотехнологичной области. И делать это, естественно, могут лишь специалисты высочайшей квалификации, понимающие значение каждого используемого байта. Однако для дальнейшего развития в этом направлении нужно не только ознакомить более широкую аудиторию с накопленным в узких кругах опытом, но и развенчать некоторые уже устаревшие стереотипы наподобие того, что современные системы программировать на низком уровне невозможно вообще.
Вот с этой целью и появилась задумка систематически рассмотреть с уровня машинных кодов работу наиболее популярной ОС - Windows, чтобы это оказалось доступным самому широкому кругу заинтересовавшихся читателей - от простых пользователей до искушенных программистов. Это и программирование, и изучение работы ОС "изнутри", причем проводимое без всяких посредников в виде языков программирования, вспомогательных библиотек и сред разработки, напрямую, "как есть" в самой ОС. Для работы специально будут использоваться простейшие и даже примитивные инструменты, входящие в состав любой версии Windows от 95 до XP и даже 2003 Server - любой, кто захочет, сможет повторить описываемые эксперименты на самом обычном компьютере.
Хочу добавить пару слов о пользователях, никогда до этого не программировавших. Идея научить их программировать - причем сразу в машинных кодах и сразу под Windows - может, и несколько авантюрная (даже многие низкоуровневики отнеслись к ней скептически), тем не менее, мне кажется, это вполне посильная задача. Особенно если учесть, сколько сил и времени надо затратить, чтобы научиться работать в интегрированной среде разработки, скажем, в том же VisualBasic'е, не говоря уже о том, что надо еще выучить язык. А если, не приведи господи, в набранном из самоучителя тексте окажется опечатка и система выдаст кучу сообщений об ошибках - для новичка продраться через это, по моему глубокому убеждению, гораздо более нереально, чем построить собственными руками подобное же, но работоспособное приложение в машинных кодах.
Не надо бояться окунуться в джунгли машинных кодов. На самом деле, здесь уже есть и проторенные дороги, и тайные заветные тропинки - надо всего лишь их знать и уметь по ним ходить. И я хочу просто показать, как это можно сделать; а уж каждый пусть сам сравнивает, оценивает и решает, сложно это или элементарно, нужно это ему или нет - это будет осознанный выбор, основанный на его собственных знаниях и опыте, а не на чьих-то стереотипах из прошлого.
Что ж, пора перейти от вступлений к сути. Архитектура процессоров Intel IA-32 относится к CISC-модели (с усложненным набором инструкций). Одна из самых примечательных особенностей этих процессоров - формат команды с переменным размером. Команда процессора может быть от 1 до 15 байтов длиной (включая возможные префиксы). Любители комбинаторики могут подсчитать количество возможных инструкций при такой схеме. Но и без подсчетов ясно, что число астрономическое. Команда может иметь один или несколько так называемых префиксов; собственно код операции (он называется опкодом) состоит из 1 или 2 байтов, а дальше идут байты, описывающие операнды - данные (или ссылки на данные), над которыми производится соответствующая операция. Даже если считать командой лишь байты опкода, то возможны 255 однобайтных команд и столько же двухбайтных (в двухбайтных опкодах первый байт всегда одинаков и равен 0Fh). Т.е. получаем свыше 500 команд процессора (на самом деле, не все возможные опкоды используются в настоящее время; кроме того, некоторые опкоды могут иметь дополнительные поля в байтах для операндов и т.п., но это уже тонкости, которые мы можем пока опустить).
Пугаться этого не следует. На самом деле, для программирования под Windows требуется весьма ограниченный набор инструкций, и скоро мы сможем в этом убедиться. Мы будем изучать нужные нам инструкции по мере необходимости. А сейчас кратко рассмотрим "суть" программирования в машинных кодах, а она довольно проста.
Компьютер - это машина для обработки информации. Для этой цели вся информация, которую нужно обработать, делится на более-менее элементарные "кусочки". Необходимая обработка тоже подразделяется на более-менее элементарные действия. Элементарный "кусочек" обрабатываемой информации называется операндом, а элементарное действие - командой. Таким образом, инструкция процессора представляет собой команду и связанные с ней операнды (которые, кстати, могут подразумеваться, а не быть явно заданными в инструкции). А сама программа представляет собой набор инструкций.
Все уже знают, что информация в компьютере представлена в виде двоичных чисел. Обычно в этом месте положено рассказывать об основах двоичного и шестнадцатеричного счислений и способах перевода чисел из одной формы представления в другую, но мы этого делать не будем. Во-первых, это несколько отвлекает от нашей непосредственной темы; во-вторых, кому надо, без труда найдет соответствующие сведения; а в-третьих, все это и так запомнится при практической работе. А если на первых порах будут проблемы, в Windows есть стандартное приложение - калькулятор, который можно использовать для перевода чисел из одной формы в другую. Только в меню "Вид" калькулятора установите "Научный", и в верхнем ряду слева увидите 4 кнопки-переключателя "Hex", "Dec", "Oct", "Bin", которыми и нужно пользоваться.
Windows сильно упрощает программирование - это относится к машинным кодам в значительно большей степени, чем к любому языку программирования на высоком уровне (обстоятельство, которое упускают из виду противники низкоуровневого программирования). Для программирования под Windows нам вполне достаточно рассматривать процессор, как обычный калькулятор. В свое время был такой программируемый калькулятор - Б3-34. Он имел 14 регистров для хранения чисел. В процессоре тоже есть набор 32-разрядных регистров общего пользования, и их всего 8. На ассемблере их обозначают как EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI. Понятное дело, в машинных кодах никаких букв нет, и регистры кодируются тремя битами (в указанном выше порядке - от 000 до 111). Но в разговоре для удобства мы будем использовать и их "названия".
Еще одна особенность интеловских процессоров - они несут на себе "печать своего детства": когда-то регистры были 16-разрядными, и именовались соответственно как AX, CX, DX, BX, SP, BP, SI, DI (причем с такими же кодами, как для 32-разрядных регистров). Еще раньше микропроцессоры были 8-разрядными, и регистров у них было поменьше; очевидно, это тоже оставило свой след, поскольку к четырем 16-разрядным регистрам (AX, CX, DX, BX) можно обращаться побайтно, т.е. отдельно к старшему и младшему байтам. Эти отдельно взятые байты четырех общих регистров обозначаются как AL (младший байт AX), CL, DL, BL, AH (старший байт AX), CH, DH, BH; а коды их тоже соответственно от 000 до 111 (совпадают со значениями для "полных" регистров.
На рисунке показано взаимоотношение адресуемых частей для регистра EAX; регистры ECX, EDX и EBX имеют подобную же схему. Регистры ESP, EBP, ESI и EDI "включают" в свой состав лишь 16-разрядные SP, BP, SI, DI и не допускают обращения к отдельным байтам.
Как же узнать, к какой именно части регистра происходит обращение, тем более, если коды регистров одни и те же (как в случае EAX, AX и AL)? Эта информация заложена в саму инструкцию. Многие опкоды имеют так называемый бит w, который указывает на размер используемого регистра (или операнда в целом): если он равен 0, это байт, если 1, "полный" регистр. В 16-разрядном режиме бит w обозначает размер операнда 8 или 16 бит. Но современная Windows работает в 32-разрядном режиме, и состояние бита w обозначает размер операнда 8 или 32 бита. Обращение к 16 младшим битам регистра тоже возможно, но для этого используется другая схема с применением префиксов (об этом поговорим в другой раз).
Есть еще два регистра, с которыми придется иметь дело: это регистр флагов EFLAGS и указатель инструкций EIP. Состояние регистра флагов может меняться после каждой инструкции в зависимости от полученного результата; подробнее об этом поговорим в другой раз. Регистр EIP содержит адрес начала следующей инструкции в памяти. Его значение увеличивается каждый раз, когда из памяти извлекается для исполнения очередная инструкция, на величину размера этой инструкции.
Обрабатываемые инструкцией данные могут находиться не только в регистре, но и в памяти, а также входить в состав самой инструкции. При обращении к памяти в инструкции указывается адрес, по которому расположены данные. Рассмотрим различные способы доступа к данным на примере инструкции (а вернее, группы инструкций) перемещения данных, которыми мы будем очень активно пользоваться. На ассемблере группа данных инструкций обозначается мнемоникой MOV.
Начнем с команды, которая перемещает непосредственное значение (являющееся частью самой инструкции) в регистр общего назначения. Формат команды следующий:
1011 w reg <байты данных>
В зависимости от значения бита w за опкодом следует либо 1, либо 4 байта, содержащих непосредственное значение (и это значение попадет соответственно либо в 1-байтную часть регистра, либо заполнит регистр целиком). В архитектуре IA-32 используется так называемый "little-endian" порядок следования байтов (его называют обратным): сначала (по младшим адресам в памяти) размещаются младшие байты числа. Т.е. 16-ричное ABCDh будет представлено как байты CDh ABh, а 12345678h - как 78h 56h 34h 12h. Подробнее об этом поговорим в следующей статье, а пока пример: загрузим в регистр EAX единицу. Регистр 000, бит w=1 (полный регистр), а данные - внимание - 4 байта для одной единицы!
10111000 00000001 00000000 00000000 00000000
Или в 16-ричном виде: B8 01 00 00 00. А вот как то же значение передается в младший байт регистра EAX (т.е. AL): регистр тот же - 000, бит w=0 (1 байт), а вот данные уже - 1 байт - 01:
10110000 00000001 (B0 01)
Обратите внимание - если в регистре EAX до этого содержался 0, последняя инструкция будет равносильна первой. Но в общем случае это не так.
Теперь эту же единицу загрузим в старший байт регистра AX (2-й байт EAX): тоже один байт (w=0), но код регистра AH уже другой (100):
10110100 00000001 (B4 01)
Удовольствие составления различных инструкций с данным опкодом оставим вам для самостоятельных упражнений и перейдем к другой команде, которая перемещает данные между памятью и регистром EAX (AX, AL):
101000 d w <байты адреса>
Этот опкод содержит бит w, но не содержит кодов регистров, поскольку он предполагает работу лишь с регистром EAX (или его частью). Зато есть другой характерный бит - d (direction), указывающий направление перемещения данных - из памяти в регистр (0) или из регистра в память (1).
В этом примере мы видим одну важную особенность обращения к данным в памяти: размер операнда и размер его адреса в памяти - разные вещи. В данном случае операнд находится в памяти и может занимать 1, 2 или 4 байта, тогда как адрес (входящий в состав самой инструкции) в любом случае занимает 4 байта. Составим инструкцию для перемещения в регистр EAX значения, которое хранится по адресу 1. Используется полный регистр (w=1), направление - из памяти в регистр (d=0):
10100001 00000001 00000000 00000000 00000000 (A1 01 00 00 00)
А теперь то же значение загрузим в регистр AL (w=0, d=0):
10100000 00000001 00000000 00000000 00000000 (A0 01 00 00 00)
Изменился всего один бит инструкции! Между тем результат операции будет разительно отличаться: в первом случае в регистр EAX будут скопированы четыре (!) байта, начиная с адреса 1, тогда как во втором случае - в регистр AL будет скопирован лишь один байт по тому же адресу, остальные 3 байта регистра EAX останутся без изменений.
Архитектура IA-32 предоставляет очень богатый набор способов адресации памяти. Сейчас отметим лишь, что возможна еще и косвенная адресация, когда адрес операнда в памяти находится в регистре, а инструкция ссылается на соответствующий регистр. Для работы с такими случаями, а также для перемещения данных между регистрами используется так называемый байт способа адресации (ModR/M). Этот байт следует непосредственно за опкодом, который предполагает его использование, и содержит следующие поля:
2 бита MOD - 3 бита REG - 3 бита R/M
Байт ModR/M предполагает, что имеются два операнда, причем один из них всегда находится в регистре (код которого содержится в поле REG), а второй может находиться (в зависимости от значения поля MOD) либо тоже в регистре (при MOD = 11; при этом поле R/M содержит код регистра), либо в памяти (R/M="register or memory"). В последнем случае адрес памяти, по которому находится операнд, вычисляется следующим образом (см. табл.):
R/M | MOD=00 | MOD=01 | MOD=10 |
000 | [EAX] | [EAX] + 1 байт смещения | [EAX] + 4 байта смещения |
001 | [ECX] | [ECX] + 1 байт смещения | [ECX] + 4 байта смещения |
010 | [EDX] | [EDX] + 1 байт смещения | [EDX] + 4 байта смещения |
011 | [EBX] | [EBX] + 1 байт смещения | [EBX] + 4 байта смещения |
100 | SIB | SIB + 1 байт смещения | SIB + 4 байта смещения |
101 | 4 байта смещения | [EBP] + 1 байт смещения | [EBP] + 4 байта смещения |
110 | [ESI] | [ESI] + 1 байт смещения | [ESI] + 4 байта смещения |
111 | [EDI] | [EDI] + 1 байт смещения | [EDI] + 4 байта смещения |
SIB означает, что после байта ModR/M следует еще один байт способа адресации (Scale-Index-Base - SIB), который мы рассматривать не будем. При MOD=00 нужный адрес памяти находится в соответствующем регистре, кроме R/M=101, когда 4 байта адреса следуют непосредственно после опкода и байта ModR/M (как в случае команды 101000dw). В ассемблере для указания того, что в регистре содержится адрес операнда, а не его значение, регистр заключают в квадратные скобки.
Если MOD=01, за байтом ModR/M следует байт, значение которого добавляется к значению соответствующего регистра и таким образом вычисляется адрес операнда. При MOD=10 за ModR/M следуют уже 4 байта; значение этого числа тоже суммируются со значением соответствующего регистра для вычисления адреса.
Присутствие байта ModR/M обычно требует также наличия битов d и w. Рассмотрим еще одну команду:
100010 d w
При d=0 данные перемещаются из регистра, закодированного в REG, в регистр или память, определяемые по R/M. При d=1 наоборот - из R/M в REG. Составим, например, инструкцию для копирования данных из EAX в EBX. Сначала "составим" байт ModR/M: оба операнда в регистрах, поэтому MOD=11; 1-й операнд в EAX - REG=000; 2-й операнд в EBX - R/M=011; итого - 11000011 (C3). Опкод: полные регистры - w=1; копирование от REG к R/M - d=0. Итоговая инструкция - 10001001 11000011 (89 C3).
Теперь фишка: 1-й операнд в EBX (REG=011), 2-й - в EAX (MOD=11, R/M=000), бит d установим (1). Итог: 10001011 11011000 (8B D8) - но эта инструкция делает абсолютно то же самое, что и предыдущая! На ассемблере обе инструкции записываются одинаково: MOV EBX, EAX. Аналогичные примеры можно привести с инструкциями (A1 78 56 34 12) и (8B 05 78 56 34 12), (89 D7) и (8B FA) и т.д. Проверьте! Да и сами вы теперь сможете составить кучу таких же. А что делают инструкции (88 E4) и (8A C9)?
Это характерная особенность работы с машинными кодами. Подобные этим трюки могут использоваться для создания защит и антиотладочных приемов. Между тем даже ассемблер генерирует для подобных команд лишь один вид кода, тем самым значительно вас обкрадывая, не говоря уже о компиляторах с языков высокого уровня.
Только не надо пугаться и думать, что при программировании в машинных кодах все время придется делать выбор из сотен возможных вариантов. На самом деле в Win32-программировании постоянно будут встречаться одни и те же инструкции, так что мы их помимо своей воли выучим наизусть. Хотя в этой статье оказалось много разнообразного материала, вы можете считать его одой свободе и богатству выбора, которую несут с собой машинные коды. В будущих статьях мы непременно сможем убедиться, насколько простым может быть программирование под Windows в машинных кодах, особенно если вы сумели уловить логику построения инструкций.
Содержание Вперёд