Win32ASM: Минимальное приложение

  Как я и обещал, мы с вами займемся программированием под win32. Выполняю свое обещание. Хотя, честно говоря, нижеследующий кусок текста мне уже совсем не нравится. То есть сначала он мне понравился, но потом некоторые товарищи, чье мнение я весьма уважаю и ценю, его раскритиковали в пух и перья...
  А вообще-то в этом тексте дофига плагиата.
  Portion by svet(R)off

  #1. Однажды студент по имени Дениска Ричи сел изучать абсолютно новый для него язык программирования. Первое, что он сделал - написал программу "Hello, World", и, что самое удивительное, она у него заработала. Когда же он перелистнул следующую страницу своей умной книжки, то ни черта не понял. То есть задним умом своей головы он, конечно же, понимал, что большинство известных ему программ умеют намного больше, чем тупо приветствовать мир, однако понять, каким это извращенным образом такие здоровские штуки можно запрограммировать, он не мог.
  Однако ум Дениска имел пытливый (в школе его часто драли в раздевалке - всех гениев в детстве драли в раздевалке!) и сразу же просек, что если в природе существуют программы, умеющие больше этого, то наверняка должны быть и программы, умеющие меньше.
  И тогда Дениска, опасаясь быть сначала проигнорированным, потом замодерированным, и в конце концов посланным, забросил свой вопрос в одну из эх, приготовившись, если чего вдруг не так, притвориться кроликом и начать жевать морковку.
  Последующие события подняли дыбом все волосы на его тщедушном тельце. Со всех концов бескрайней саванны мгновенно слетелись, сбежались (а некоторые даже и сползлись) бесчисленные гуру, сенсеи и йоды. Возникла крутейшая разборка, поднявшая большое облако пыли, из которого долго доносились рыки, ржание, визги и предсмертные хрипы, а во все стороны разлетались зубы, перья и клоки шерсти... Сначала великие считали и пересчитывали строчки в исходниках, потом буквочки в строчках, а потом даже и байтики в экзешниках, при этом постоянно гоняя получившиеся цифры между двоичным, десятичным и шестнадцатеричным радиксами...
  Страшна и беспощадна была эта битва, пока, наконец, из-под обломков не выполз только что пришедший в себя Бьярни Страус (весь помятый и бледный, как труп; те, кто видели, так и начали его с тех пор обзывать - Страус-труп) и не предложил подсчитать биты, установленные в 1...
  И тогда вопли стихли, ибо на всех вдруг нашло прозрение, по какой это такой причине двери в психушках открываются исключительно вовнутрь.
  Вывалив языки и тяжко дыша, великие расползлись по своим кельям зализывать раны и готовиться к грядущим битвам, а посередине вытоптанной поляны, одинокий, жалкий и забытый, остался остывать трупик несчастного кролика...

  #2. Между тем, вопрос о минимальном приложении - это вам не "кто идет за "Клинским" (конечно же, тот, кого в прошлый раз от "Балтики-медового" мутило), и даже не тот, "кто потом пойдет сдавать бутылки" (конечно же тот, кого систематически дерут в раздевалке!). Минимальное приложение - это есмъ альфа и омега программирования, сцилла и харибда отладки, инь и янь сопровождения и даже (давайте не будем бояться этой правды жизни!) эпос и анус раскрутки программного продукта! И именно поэтому за много-много лет никто так и не усомнился в правоте Кнута, который утверждал, что если:


(где a - это программа, а - операционная система)

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

  Как свидетельствует вышеозначенная формула, вопрос о минимальном приложении (речь идет именно о приложениях, то бишь applications, а вовсе не о каких-нибудь там VxD) - совсем не простейший, и представляет отнюдь не академический интерес. Даже прибалдевшему от морковки кролику должно быть очевидно, что любое приложение обязано выполнить как минимум две не такие уж и простые задачи:

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

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

Ракетница - одно из самых крутых оружий в Quakе. Рокетджамп - это такой прыжок, когда из ракетницы (в прыжке!) стреляешь себе под ноги и из-за этого очень высоко подпрыгиваешь, при этом сжигая определенное количество пунктов здоровья. Мегахелс, однако, тебе их потом с лихвой восстанавливает. Quake - рулез! Каждый низкоуровневый программист должен любить Кваку!!)

  Практически это означает то, что вы берете в руки крутейший инструмент, который не имеет абсолютно никакой защиты от дурака. И если какой-нибудь дельфийский компилятор будет некоторое время удивляться вашим попыткам выстрелить себе в ногу, (а потом возьмет и выстрелит в ногу сам!) то ассемблерный не скажет ни слова, полагая, что имеет дело с профессиональным террористом, который знает что делает...

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

  #3. Итак, с криком "банзай" (восточная школа) либо "мастдай" (западная школа), открываем текстовый редактор и набираем там исходный текст (в обиходе называется сырец) минимального приложения на языке ассемблера.

;Сырец 1. Минимальное приложение на Assembler'e (minimal.asm)

 .386
 .model flat,stdcall

includelib kernel32.lib

ExitProcess PROTO :DWORD

 .code

WinMain PROC
 ;...
 push 0
 call ExitProcess
WinMain ENDP

end WinMain

  Вот она, эта песнь души измученного кролика, из которой, как говорится, ни строчки вырезать, ни матерного слова добавить. Однако, это всего лишь своего рода нотный стан с нарисованным на нем скрипичным ключом, размером и нотами. А разве многие из нас могут наслаждаться музыкой, нарисованной на бумаге? Вот именно! Поэтому давайте сначала загрузим эту партитурку в мясорубку, покрутим ручку и посмотрим, какая бяка появится у нас на выходе.

  Мясорубкой мы будем пользоваться специальной, виндозовской. Называется она MASM32, версию имеет 7.0, весит 5 Мб, а скачать ее можно здесь...  
  Как говорится, клик хере фор доунлоад, а пока вы будете эту мясорубку скачивать и привинчивать к столу своей камеры пыток, я прочту вам некоторые строки из прилагаемого к ней буклетика.

  #4. Фирма Microsoft никогда не документировала, каким это извращенным способом можно писать приложения под Win32 на Ассемблере. Конечно же, в ее Driver Development Kit (DDK) есть парочка соответствующих топиков, но они относятся исключительно к разработке виртуальных драйверов VxD и прочих специфических штук. Ни для кого не секрет, что для программирования в среде Windows необходимо иметь многочисленные ссылки на данные, такие как прототипы функций, структуры, типы и определения констант, макросы... и так далее. Так вот, все вышеперечисленное богатство как раз и поставляется с DDK. Однако, вот беда, "заточено"-то оно под Си :(.
  Естественно, маргинальная часть программирующего сообщества неоднократно задавала мелкософту вопрос на тему "assembly language programming for Win32". Но все, что могла ответить служба поддержки - это "assembly language programming for Win32 is not supported by Microsoft" - на первый бесплатный звонок, и "no, it cannot be done" - на второй. Третий же звонок уже стоил немалых денег, и находились лишь считанные единицы, решавшиеся на это.
  Однако, как говорится, если гора не идет к Магомету, то Магомет идет к горе. Осознав простую народную мудрость, что "спасение утопающих - дело рук самих утопающих", некто Hutch (потом ему начала помогать целая команда во главе с Iczelion'ом), взял все вышеозначенное "богатство" и начал "перезатачивать" его под Ассемблер...
  Подозреваю, что это был весьма тяжелый и тернистый путь, потребовавший от Hutch'a как огромных моральных усилий, так и определенных материальных затрат. Однако он был успешно выполнен, в результате чего мы с вами имеем огромнейшую халяву и собираемся самым бессовестным образом сберечь на этом большое число своих драгоценнейших нервных клеток ;).
  Не правда ли, последнее радует? Но, тем не менее (а как же без ложки дегтя?), необходимо учесть и тот простой факт, что пакет MASM32 делался живыми людьми, и в нем могут содержаться (и они там действительно есть!) ошибки. Поэтому отнюдь не стоит брезговать время от времени проверять соответствующие заголовочные файлы ;).
  Вот такая вот предыстория. Надеюсь вы уже скачали MASM32 и установили его на свой жесткий диск? Тогда давайте вернемся к нашим баранам :).

  #5. Делать EXE'шник, как всегда, мы будем в два этапа. Для начала возьмем исходник minimal.asm и "заведем" нашу мясорубку следующей командной строкой (каталог с исходником должен быть текущим):

c:\masm32\bin\ml /c /coff minimal.asm

  Ключ /c говорит о том, что мы хотим только оттранслировать исходный файл, но не компоновать.
  Ключ /coff означает, что мы хотим создать объектный файл в формате COFF (Common Object File Format), стандартном для "Окон" формате объектных файлов (TASM, в отличие от MASM, создает объектные файлы в интеловском формате OMF (Object Module Format), который мелкософтовский линкер запросто конвертирует в COFF).
  Теперь, если вы правильно набрали исходник, в той же папке, что и minimal.asm, должен появиться объектный файл minimal.obj.
  Далее приступим к компоновке экзешника. Затаиваем дыхание и дергаем за стартер:

c:\masm32\bin\link /SUBSYSTEM:WINDOWS /LIBPATH:c:\masm32\lib minimal.obj

  Ключ /SUBSYSTEM с параметром WINDOWS говорит линкеру о том, что мы собираемся собрать экзешник для подсистемы Windows. Другие возможные значения: CONSOLE - если мы собираемся делать программу с текстовым интерфейсом а-ля ДОС, NATIVE - если драйвер устройства, и POSIX - если мы собираемся писать программы, ориентированные на стандарты POSIX и более или менее переносимые под разные Юниксы (для которых эти стандарты и писались).
  Ключ /LIBPATH указывает путь к библиотекам импорта, которые нам обязательно необходимо прилинковать к программе, если мы хотим использовать возможности, предоставляемые не только процессором, но и операционной системой. Подробнее о том, что из себя представляют библиотеки импорта, мы поговорим чуть позже.
  Если вы все сделали правильно, то в нашей папке, наряду с исходным и объектным, должен появиться еще и третий файл - исполнимый. Возрадуемся же этому и перейдем к следующему пункту повестки дня - разборке исходника. Неужели вам не интересно знать, что означают эти загадочные строчки в minimal.asm?

  #6. .386 - это директива ассемблера, определяющая набор инструкций процессора, которые могут быть использованы в программе (позже мы проведем четкую границу между инструкциями, директивами и командами). По умолчанию транслятор полагает, что программа пишется для процессора 8086 и сопроцессора 8087. Но, посудите сами, какая под него может быть Винда? Для приложений win32 необходимо указывать либо .386, либо выше (486, 586, 686) - в зависимости от того, собираетесь ли вы использовать возможности, предоставляемые процессорами/сопроцессорами последующих поколений или нет. Впоследствии мы будем также использовать p-версию этой директивы (486p, 586p), что даст нам доступ к страшным и ужасным привилегированным командам, которые нехороший "дядька" Микрософт подмял под себя, и выпросить их у "мелкомягких" будет не так уж и просто.

  #7. .model flat, stdcall. Первый параметр - это модель памяти. Например, COM'овские программки, которые мы с вами раньше писали, соответствовали модели tiny, то есть "крошечной" - они запросто помещались в 64 Кб памяти, и никаких проблем с адресацией данных у нас не было. Однако если бы мы захотели написать под ДОС программу во много раз большую, чем 64 Кб, т.е. превышающую размер сегмента, нам пришлось бы познакомиться с таким динозавром, как оффсетно-сегментная адресация, и использовать, в зависимости от навороченности программы, модели small, medium, compact или large. Под Windows же у нас есть одна-единственная "правильная" модель памяти - flat, то бишь "плоская", позволяющая нашей программе благодаря страничной адресации легко и просто работать с 4 Гб виртуальной несегментированной памяти. И это есть хорошо! Ибо теперь, как сказал некто Вал.Ик., нам не нужно смотреть на мир сквозь замочную скважину 64 Кб-сегмента. (C)
  Второй же параметр указывает на так называемое соглашение о вызове процедур. Каждый язык имеет свои "соглашения". Так, второй параметр может принимать значения: c, basic, fortran, pascal... Мы не будем вдаваться в особенности каждого, просто скажем, что при программировании под win32 на макроассемблере нужно использовать соглашение stdcall, ведущее свою родословную в части наименования ] функций - от языка С (плодовитый, однако, мужчина!), а в части передачи аргументов - от языка Pascal (курица, как говорится, не птица, но яйца мы предпочитаем куриные). Подробнее об этом соглашении будет рассказано ниже.

  #8. .includelib kernel32.lib - эта директива передается компоновщику и сообщает, что наша программа должна быть слинкована с указанной библиотекой, в данном случае с kernel32.lib. Библиотека представляет собой энное количество готовых к употреблению процедур, оттранслированных в объектные файлы и собранных в большую кучу под названием "библиотека".
  Если помните, есть такое понятие как программное прерывание, то бишь механизм, при помощи которого можно обратиться за обслуживанием своих запросов к операционной системе. Так вот, в среде Windows пользовательских прерываний как таковых нет, а задействование ресурсов операционной системы производится совершенно иначе - при помощи так называемых функций Windows API. А для того, чтобы получить доступ к этим ресурсам, вы должны обязательно cлинковать свою программу с соответствующей библиотекой импорта, в которой как раз и находится куча маленьких подпрограммок, собственно, эти функции и вызывающих.
  Слово "слинковать" выделено не случайно. Это связано со следующей строчкой нашего исходника.

  #9. Посмотрите внимательно на тело WinMain. Там есть команда call, то есть вызов подпрограммы. А где, спрашивается, сама подпрограмма ExitProcess? Ан нету ее! А почему тогда, спрашивается, транслятор на это не ругается? А вы удалите строчку ExitProcess PROTO :DWORD и попробуйте оттранслировать свой исходник... Ага, хорошо знакомое: "undefined symbol : ExitProcess"? Еще бы, такой подпрограммы в нашем исходнике действительно нет!
  Посмотрите на call ExitProcess - это вызов API'шной функции, завершающей работу нашей программы (виндозный аналог хорошо нам известного INT 20h). Именно подпрограмма с таким именем будет прилинкована к нашему экзешнику, и уж она-то знает способ, как ей обратиться к DLL-ке kernel32.dll и заставить ее прервать работу нашей программы! Еще раз повторюсь, прилинкуется не сама API'шная функция, а только подпрограммка, которая умеет эту внешнюю функцию вызывать.
  Но это на этапе линковки, когда соединяются все "концы" между объектными файлами. А на этапе транслирования ассемблер не имеет никакого понятия о том, что в природе существует какой-то ExitProcess... И чтобы он (транслятор) не ругался, мол, "кто такой, почему не знаю", нужно их "познакомить" - при помощи так называемого прототипа. Другими словами, прототип - это своего рода уведомление транслятора о том, что "немного попозже я собираюсь обращаться к товарищу ExitProcess, и форма вызова для него такая-то". Ну а далее следуют "паспортные данные" на этого товарища - сколько он "переваривает" параметров, и какой они должны быть размерности, дабы тот, не дай бог, не подавился.
  Вообще-то говоря, существует несколько способов подобного "уведомления", однако вышеприведенный, несмотря на свою кажущуюся навороченность, является самым эффективным, так как позволяет проверять соответствие количества и типа аргументов параметрам, тем самым помогая отслеживать целый ряд трудноуловимых ошибок.

  #10. Как известно, каждая программа состоит из кода и данных. Раньше мы их называли сегментами - это было связано с тем, что из-за сегментной адресации приходилось мерить мир "спичками" по 64 Кб. Теперь же мы, слава Богу, имеем единое адресное пространство, и термин сегмент, во избежание терминологической путаницы наподобие "говорим сегмент, подразумеваем 64 Кб", заменим на термин "секция", хотя, по большому счету, это одно и то же.
  Определяются эти секции, а-ля сегменты, следующими упрощенными директивами:
  - .data - определяет секцию инициализированных данных;
  - .data? - определяет секцию неинициализированных данных, для тех случаев, когда необходимо предварительно выделить определенное количество памяти, но инициализировать ее заранее нет необходимости. Фишка заключается вот в чем - сколько бы мы ни определяли неинициализированных элементов данных, размер файла программы на диске остается неизменным. Мы просто таким образом ставим систему в известность: "когда программа загрузится, я хочу иметь в своем распоряжении такой-то объем памяти";
  - .const - определяет секцию констант, то есть элементов данных, которые наша программа не сможет (во всяком случае, не должна) изменять ни в коем разе;
  - .code - собственно код, то есть последовательность инструкций, которые должен выполнить твой компьютер.
  Из всех вышеперечисленных секций в нашем исходнике есть только CODE. Это единственная секция, которая обязательно должна присутствовать в любой программе. А разве может быть иначе?

  #11. В секции кода у нас есть одна-единственная процедурка. На всякий случай напомню, что о ее начале свидетельствует строчка WinMain PROC, где WinMain - это "имя собственное", которое вы можете заменить любым словом, в том числе и своим любимым. А ее конец - это строчка WinMain ENDP. Кстати, настоящим ассемблерщикам должно быть приятно, что, опустив ненужную в связи с этим команду ret, мы сэкономили аж 4 байта кода!
  О конце модуля транслятору сообщает директива end, и в качестве параметра у нее метка, на которую будет передано управление при старте программы. В данном случае - это имя "главной", а в нашем случае еще и единственной, процедуры WinMain.
  ;... - этот комментарий поставлен на месте всякой прочей мелочи, которую вы хотели бы заставить делать ваше приложение в тот примечательный момент, когда решите сделать его немного большим, чем минимальное.

  #12. Как я уже говорил в п.7, вызов функций API из программы на ассемблере подчиняется соглашению "stdcall". Выражаясь официозно, "с точки зрения прикладного программиста, с учетом специфики Windows и MASM", эти соглашения заключаются в следующем:
  1. Регистр символов в имени функции не имеет значения. Например, функции с именами WindowsMustDie и windowsmustdie - это одна и та же функция. Здесь мы наблюдаем отличие от требования языков C и C++, в которых идентификаторы регистро-зависимы. Зато в Паскале регистр имен не имеет значения.
  2. Аргументы передаются вызываемой функции через стек. Если аргумент укладывается в 32-битное значение и не подлежит модификации, вызываемой функцией, он обычно записывается в стек непосредственно. В остальных случаях программист должен разместить значение аргумента в памяти, а в стек записать 32-битный указатель на него. Таким образом, все параметры, передаваемые функции API, представляются 32-битными величинами.
  3. Вызывающая программа загружает аргументы в стек последовательно, начиная с последнего, указанного в описании функции, и кончая первым. После загрузки всех аргументов программа вызывает функцию командой call.
  4. За возвращение стека в исходное состояние после возврата из функции API отвечает сама вызываемая функция. Программисту заботиться о восстановлении указателя стека esp нет необходимости. Эта идея тоже пришла из реализаций Паскаля и прилично экономит на размере кода.
  5. Вызываемая функция API гарантированно сохраняет регистры общего назначения ebp, esi, edi. Регистр eax, как правило, содержит возвращаемое значение. Состояние остальных регистров после возврата из функции API следует считать неопределенным (полный набор соглашений stdcall регламентирует также сохранение системных регистров ds и ss. Однако, для flat-модели памяти, используемой в win32, эти регистры значения не имеют.)
  В применении к нашему примеру это означает следующее:
  - аргументы мы должны передать через стек;
  - сама фунция вызывается командой call;
  - не "парим" себе мозги, задаваясь вопросом, "если мы сделали PUSH, то когда же нам сделать POP?", ибо правильный ответ - никогда. Об этом позаботится сама функция.

  #13. И, напоследок, топик #13 ;)
  Читая эту главу, вы написали свою первую программу для Windows на языке ассемблера. Если вы уже имеете хотя бы небольшой опыт программирования, то даже беглого прочтения этой главы будет достаточно, чтобы сказать: "да, теперь я знаю, как ЭТО делается на асме". Если же вы еще совсем молоды и зелены, то просто "тупо" выполните мои инструкции, а комментарии расцените как своего рода предварительные наброски тех тем, которые мы подробно будем рассматривать впоследствии.
  Более того, это единственная глава, которая действительно необходима для обучения программированию под Windows на ассемблере. Как только программист узнает, как вызывать API, все остальное он станет способен делать самостоятельно, без учебников и подсказок. Потребуется только справочник Platform SDK, да знание двух языков: C и английского (оба - в объеме церковно-приходской школы).
  И да пребудет с вами сила!

  [C] Serrgio / HI-TECH

© 2014-2017 Сергей Воробьев

0.02