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 &ltбайты данных&gt
В зависимости от значения бита 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 &ltбайты адреса&gt
Этот опкод содержит бит 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/MMOD=00MOD=01MOD=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 байта смещения 1014 байта смещения [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 в машинных кодах, особенно если вы сумели уловить логику построения инструкций.

Содержание раздела