Оконное приложение на ассемблере

Метки:  , , , , , , , , , ,

Надумал я тут написать небольшую утилиту.. и понял, что писать-то я и не умею. Смеялись всем селом!! :) Если рассматривать вопрос по существу, то сегодня мы раскроем некоторые особенности синтаксиса языка ассемблер в свете использования компилятора FASM, и приведем типовой шаблон оконного приложения на ассемблере, а так же выполним разбор структуры для дальнейшего использования в качестве базиса в различного рода проектах. Быть может, когда-то статья и станет звеном в цикле по изучению программирования на языке Ассемблер под Windows, но на данный момент она представляет собой обособленный материал.

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

Я попытался до определенной степени детализировать небольшой накопленный опыт, дабы читатель любого уровня подготовки смог увидеть весь диапазон направлений, требуемых для более глубокого изучения особенностей языка Ассемблера при разработке приложений под операционную систему Windows, если появится желание дальнейшего продвижения. Будут рассмотрены основные (базовые) директивы ассемблера FASM, которые позволяют существенно влиять на структуру исполняемого файла программы. Некоторые из приведенных разделов вполне могли бы дорасти до размера самостоятельной статьи, однако пока подобная структура не создана, информация будет приводиться здесь. Темой данной статьи станет создание простейшего графического оконного приложения на ассемблере для Windows, потому как данная категория приложений является наиболее распространенной, соответственно и востребованной.

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

За основу для изучения я взял стандартный шаблон 32-битного оконного приложения на ассемблере с именем template.asm, поставляемый в составе пакета FASM и размещающийся в поддиректории \EXAMPLES\TEMPLATE\, и слегка модифицировал его для некоторой наглядности. Для начала представим исходный код программы:

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

Идеология программирования под Windows

В операционной системе MS-DOS исполняемая программа (читать: код) имела монопольный (бесконтрольный) доступ к большинству аппаратных ресурсов системы, поэтому говорить о полноценной многозадачности не приходилось. Другой особенностью программ, работающих под MS-DOS, была необходимость самостоятельно инициировать взаимодействие с операционной системой: делать вызовы функций [программных прерываний] с целью организации обмена данными с пользователем/операционной системой/устройствами (например: ожидание ввода с клавиатуры). Но вот в Windows все изменилось, мало того, что в системе могут уже одновременно функционировать несколько процессов (читать: программ), но и для взаимодействия с пользователем (обмена данными), коду пользовательского приложения уже вовсе не обязательно делать вызовов каких бы то ни было специализированных функций, ожидающих ввода (нажатия клавиш клавиатуры/мыши, ввод символов в поле ввода). Объясняется всё это сменой парадигмы программирования на событийно-ориентированное программирование.

Событиийно-ориентиированное программиирование (англ.: event-driven programming) — подход к программированию, при котором ход выполнения программы определяется [внешними/внутренними] событиями: действиями пользователя (клавиатура, мышь, сенсорный экран и прч.), сообщениями других программ и потоков, событиями операционной системы (например, поступлением сетевого пакета).

Соответственно:

Приложение в Windows "пассивно", поскольку в ходе функционирования оно ждет когда операционная система уделит ей внимание.

Подход к программированию в среде Windows существенно изменился и теперь основная концепция программирования ориентирована на так называемые события. Это означает, что ядро системы постоянно следит за аппаратной/программной активностью, и в ответ [на эту активность] генерирует специальные сообщения, которые затем, в зависимости от необходимости, передает (через специальные системные механизмы) приложениям, ожидающим наступления этих событий. Таким образом, можно утверждать что приложения в Windows управляются событиями. С событиями ассоциируется любая пользовательская/системная активность: перемещение окон, нажатие клавиш клавиатуры/мыши, изменения состояния буфера обмена, изменения в аппаратурной конфигурации, изменение статуса энергопотребления, изменение значений таймеров и другая. Поэтому, когда происходит какое-либо событие (к коим относятся и любые действия пользователя), система сама "предоставляет" для пользовательского приложения входные данные, и делает она это посредством передачи сообщений. А прикладная программа, в свою очередь, должна содержать обработчики этих самых поступающих в неё сообщений, которые требуемым образом реагируют на данные сообщения.

Таким образом приложение в Windows должно обеспечивать ту или иную обработку при поступлении разного рода сообщений. Какие именно события обрабатывать и как именно - решает разработчик приложения.

При завершении программой обработки события, управление возвращается [обратно] ядру системы. Вы уже поняли, что все это коренным образом меняет подход к написанию программ, поскольку в MSDOS программа контролировала собственные действия/события, теперь же сама операционная система Windows проводит большинство работы, а пользовательской программе передает лишь управляющие сообщения, которые могут активизировать те или иные процедуры обработки (которые посчитал нужным добавить в код автор) в программе.

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

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

API (Интерфейс прикладного программирования, Application Programming Interface) - многообразие системных функций, посредством которых приложение (процесс) может взаимодействовать с операционной системой Windows.

Заголовок

Рассмотрение исходного кода начнем мы с заголовка, или, если выразиться более точно - "так называемого заголовка". Я возьму на себя смелость подобным образом именовать область исходного кода, начинающуюся непосредственно с первого символа и идущую до директивы объявления первой секции. Начало области не обозначается специальными директивами, это просто начальная часть листинга программы. В этом месте (у нас строка 1) может использоваться директива format, которая предназначается для указания формата результирующего исполняемого файла, получаемого на выходе после компиляции. Форматы можно указать следующие:

Имя Расшифровка Описание
MZ Mark Zbikowski формат 16-битных исполняемых файлов с расширением .exe для ОС MSDOS
PE PE64 Portable Executable формат 32/64-битных исполняемых файлов с расширением .exe для ОС Windows
COFF MS COFF MS64 COFF Common Object File Format формат объектного файла, содержащий промежуточное представление кода программы, предназначенный для объединения с другими объектными файлами (проектами/ресурсами) с целью получения готового исполнимого модуля.
ELF ELF64 Executable and Linkable Format формат исполняемых файлов систем семейства UNIX. Объектный файл (.obj) для компилятора gcc.
ARM Advanced RISC Machine формат исполняемых файлов под архитектуру ARM (?)
Binary файлы бинарной структуры. Что зададите, то и соберется. Например, выставив смещение 100h (org 100h) от начала, можно получить старый-добрый .com-файл под MSDOS. Формат имеет ряд аналогичных применений для создания произвольных бинарных приложений или файлов данных.

Вторым параметром (после указания формата исполняемого файла) директивы format может указываться тип подсистемы для создаваемого приложения:

Имя Описание
GUI Графическое (оконное) приложение. Выходной исполняемый файл, который подразумевает создание типовых оконных приложений и инициализацию на начальной стадии всех соответствующих библиотек Win32 API. Выходной исполняемый файл, у которого в структуре PE-заголовка IMAGE_NT_HEADERS, подструктуре OptionalHeader, значение поля Subsystem = 2 (оно же IMAGE_SUBSYSTEM_WINDOWS_GUI).
console Консольное приложение. Выходной исполняемый файл, подразумевающий выполнения кода в консоли, без участия оконного интерфейса. Выходной исполняемый файл, у которого в структуре PE-заголовка IMAGE_NT_HEADERS, подструктуре OptionalHeader, значение поля Subsystem = 3 (оно же IMAGE_SUBSYSTEM_WINDOWS_CUI).
native Родное/нативное приложение. Выходной исполняемый файл, у которого в структуре PE-заголовка IMAGE_NT_HEADERS, подструктуре OptionalHeader, значение поля Subsystem = 1 (оно же IMAGE_SUBSYSTEM_NATIVE). Подобное значение поля обычно характерно для драйверов, библиотек и приложений режима ядра, которым не требуется инициализация подсистемы Win32 на стадии подготовки образа к выполнению.
DLL Динамическая библиотека. Особый формат выходного исполняемого файла, предназначающийся для экспорта (предоставления) функций сторонним приложениям, у которого в структуре PE-заголовка IMAGE_NT_HEADERS, подструктуре IMAGE_FILE_HEADER, в поле Characteristics включен флаг IMAGE_FILE_DLL (2000h).
WDM Системный драйвер, построенный на основе модели WDM (Windows Driver Model).
EFI EFIboot EFIruntime UEFI-приложение. Выходной исполняемый файл, у которого в структуре PE-заголовка IMAGE_NT_HEADERS, подструктуре OptionalHeader, значение поля Subsystem = 10 | 11 | 12 | 13 (оно же IMAGE_SUBSYSTEM_EFI_APPLICATION, IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER, IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER, IMAGE_SUBSYSTEM_EFI_ROM). Подобное значение поля требуется для создания UEFI-приложений различных стадии/типа: загрузки, выполнения и драйвера.

Роль данного параметра достаточно велика, поскольку именно он определяет, какая именно подсистема будет вызываться для запуска исполняемого файла, то есть фактически определяет программное окружение при запуске процесса. Если используется тип приложения GUI, необходимо уточнять минимальную версию системы (у нас: 4.0), под которую создается наш исполняемый модуль.
Затем, в строке под номером 2 в нашем исходном коде располагается директива с именем entry, которая определяет точку входа в программу.

Точка входа - адрес первой инструкции (в адресном пространстве процесса), с которой начинается выполнение кода приложения.

В качестве аргумента директивы entry указывается метка в коде, с которой у нас начнется выполнение кода скомпилированной программы. Становится очевидным, что именно на основе данной директивы компилятор формирует значения соответствующих полей результирующего исполняемого PE-файла. При запуске .exe-файла, загрузчик образов (динамический компоновщик) создаст адресное пространство процесса нашего приложения, подгрузит и разберет исполняемый образ, сопоставив все необходимые сегменты с регионами памяти, сформировав иные необходимые структуры, передаст управление именно по адресу, где будет располагаться инструкция, описанная в исходном коде меткой, указанной в директиве entry. В нашем случае точку входа определяет метка start, располагающаяся в сегменте кода в строке 11.
В строке 3 мы обнаруживаем директиву компилятора include, при помощи которой в исходный код нашей программы (в позицию нахождения директивы) включается текст внешнего модуля (файла), указанного в ней в качестве параметра.

Включение позволяет подключать необходимые программе структуры данных из внешних файлов. Основной файл у нас содержит код программы, а специфичные внешние данные, такие как системные константы, переменные и определения макросов, размещаются в отдельных заголовочных файлах. Заголовочные файлы FASM несколько отличаются от привычных нам по языку C/C++ тем, что не описывают прототипов процедур/функций.

В нашем случае подключается файл %fasminc%\win32a.inc, который, в свою очередь, содержит ссылки на другие подключаемые файлы, содержащие определения ключевых структур, требуемых для компиляции нашей программы: макросов, типов данных, констант, системных структур. Без включения этого файла у нас попросту не пройдет процесс компиляции нашего исходного кода, то есть исполняемый файл не будет создан (не соберется).

Как Вы уже наверное заметили, в параметре директивы include используется переменная пути %fasminc%. В общем случае она создается для удобства указания пути к поддиректории \INCLUDE основной директории FASM в исходниках. Вам тоже необходимо задать полный путь к каталогу дистрибутива в настройках операционной системы: окно Свойства системы -> вкладка Дополнительно -> раздел Переменные среды -> добавить новый параметр с именем FASMINC, имеющий значение пути (например: C:\YandexDisk\_Project\ASM\FASM\INCLUDE) в области Переменные среды пользователя или Системные переменные.

Непосредственно за подключением внешнего файла, в строке 5 у нас располагается объявление внутренней константы _style, которая используется в нашем коде и принимает значение WS_VISIBLE+WS_DLGFRAME+WS_SYSMENU, определяющее внешний вид окна. Ключевые слова WS_VISIBLE, WS_DLGFRAME, WS_SYSMENU являются не чем иным, как символическими именами глобальных констант, или битовых флагов (содержащихся во внешних файлах включений, подключаемых на этапе компиляции), определенных в системе Windows и иначе именуемых стилями окна.

Заметьте, что константы объединяются операцией + (логическое/побитовое ИЛИ, or), с целью получить сумму значений нескольких свойств, то есть применить их совокупность.

С возможными вариантами стилей можно ознакомиться на соответствующей странице, описывающей стили окна.

Секции

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

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

Использование секций регламентировано структурой формата исполняемых PE-файлов, используемых в системе Windows. Именно спецификация формата PE определяет требования к наличию определенных структур в исполняемых файлах и предписывает использование тех или иных секции для разделения информационных блоков. Сразу после директивы section в одинарных кавычках (апостроф) задается имя (название) секции и ряд параметров: тип секции, флаги (атрибуты) секции.

Наименование секции может быть произвольным (но не более 8 символов) либо отсутствовать вовсе, это никак не сказывается на процессе компиляции и исполнения программы, поскольку ключевым для компилятора является тип секции. Исключением является, разве что, секция ресурсов с именем .rsrc.

Флаги могут принимать следующие значения: code, data, readable, writeable, executable, shareable, discardable, notpageable, в дополнение к ним могут использоваться спецификаторы секции данных, такие как export, import, resource, fixups, которые определяют структуру (строение) секции. Типы секций, флаги и их комбинации я свел в таблицу:

Наименование Обозначение FASM Описание
Секция кода code Секция, в которой предписывается размещать исполняемый код приложения. Обычно в данную секцию включается весь ассемблерный код, фактически реализующий логику работы приложения.
Секция данных data В данной секции предписывается размещать все динамические (изменяемые) данные (локальные/глобальные переменные, строки, структуры и т.п.), которые активно используются в коде приложения.
Секция импорта import Расхожее название: Таблица импорта. В данной секции размещаются строковые литералы (наименования) библиотек и таблицы подключаемых (импортируемых) из этих библиотек виртуальных функций, которые требуются нашей программе для работы. Функции могут импортировать по наименованию (символическое имя) или по ординалу (числовой идентификатор).
Секция ресурсов resource Данная секция содержит данные, которые преобразуются в исполняемом файле в многоуровневое двоичное дерево (индексированный массив), построенное определенным образом для ускорения доступа к данным. Эти данные называются ресурсами, доступны из кода через специальные идентификаторы, статичны, описывают различные используемые в программе объекты: меню, диалоги, иконки, курсоры, картинки, звуки и прочее.
Таблица перемещений (таблица настроек адресов, релокации) fixups Релокации - набор таблиц (fixup blocks) со смещениями (Relevant Virtual Addresses, RVA) от базового адреса загрузки образа (фактически указателями на абсолютные адреса в коде), которые загрузчик образа должен скорректировать (исправить) в памяти процесса, если образ загружается по адресу, отличному от предпочитаемого. Иначе (проще) можно представить как список ячеек памяти, которые нуждаются в корректировке при загрузке образа в памяти процесса по произвольному адресу. Таблица перемещений применяется только для фиксированных адресов в коде приложения, то есть адресов тех инструкций, которые компилятор задал в явном виде (например: mov al, [01698745]).
Таблица экспорта export Секция описывает экспортируемые нашей программой функции. Обычно используется при создании библиотек DLL.
Обычно тип секции предписывает размещать внутри неё код/данные требуемого назначения.

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

Секция кода (code)

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

  • Получаем дескриптор экземпляра текущего процесса (в контексте которого и выполняется наш код);
  • Регистрируем класс окна. Регистрация собственного класса требуется во всех случаях за исключением тех, когда Вы используете стандартные (предопределенные, предоставляемые системой) типы окон;
  • Создаем главное (и единственное) окно на основе только что зарегистрированного класса и сообщаем Windows адрес функции обработки событий для этого окна;
  • В нашем примере не используется: отображение основного окна (функция ShowWindow) и обновление клиентской области окна (функция UpdateWindow) в промежутке между созданием окна и началом очереди обработки сообщений программы. В примерах кода от MS часто можно встретить связку данных функций, вероятно они используются в случаях, когда: 1) требуется дополнительная перерисовка (в случае манипуляций с видимой частью окна) клиентской области в промежутке между созданием окна и началом обработки очереди сообщений программы, 2) когда окно создается с классическими стилями, не отображающими окно. В нашем же случае мы используем дополнительные стили, которые сразу делают окно видимым.
  • Входим в бесконечный цикл обработки сообщений для всех окон, принадлежащих нашему процессу. В данном примере обрабатываются сообщения к единственному [основному] окну;
  • (специальной функцией) обрабатываем сообщения, поступающие для любого из контролируемых нами окон;
  • Выходим из программы по нажатию пользователем кнопки Закрыть [X] или комбинации клавиш Alt+F4;

Визуальным результатом работы нашей программы является вывод на рабочий стол обычного окна с единственной системной кнопкой (закрыть) в правом верхнем углу.

win32 template window

Соответственно, вся логика нашей программы укладывается в создание окна и обработку нажатия в нем одной-единственной кнопки: выход. Так же, в окне можно увидеть выбранную нами типовую иконку (левый верхний угол) и окно имеет заданные нами размеры.
Ну а теперь самое время разобраться с алгоритмом работы. Перво-наперво мы получаем дескриптор (handle) нашего модуля при помощи вызова функции GetModuleHandle. Немного оторвемся от изучения логики и обратим внимание на строку 12 вызова данной функции, тут мы впервые встречаемся с ключевым словом invoke. Во с этого самого момента для новичков начинается знакомство с реалиями современного программирования под Windows на языке ассемблер. Для людей, которые разбираются с языком даже на начальном уровне, очевидно, что такой команды в ассемблере нет, но это и не команда, это макрос. Макрос invoke содержится в файлах определения макросов \INCLUDE\MACRO\PROC32.INC и \INCLUDE\MACRO\PROC64.INC пакета FASM и вот его объявление:

Из алгоритма макроса видно, что при наличии аргументов функции, выполняется помещение их в стек в обратном порядке, следом уже вызывается сама функция. И зачем нам это всё нужно? Для облегчения процесса разработки приложения! В принципе, Вы можете и не использовать макрос invoke, но тогда от Вас потребуется масса дополнительной работы: Вам придется самостоятельно заносить входные аргументы в стек в порядке, определенном для той или иной функции. Дело в том, что порядок занесения определяется так называемым соглашением о вызовах, которое повсеместно применяется в программах Windows и которое предписывает заносить параметры в стек строго в требуемом порядке.
Вернемся обратно к основному алгоритму. В официальной документации сказано, что функция GetModuleHandle возвращает дескриптор приложения. Если в качестве входного параметра функции GetModuleHandle используется значение 0, то функция возвращает дескриптор для исполняемого образа, участвующего в создании вызывающего её процесса, то есть (проще) для того процесса, из которого она вызвана. Возвращаемый функцией дескриптор не глобальный и не наследуемый, он актуален в контексте только текущего процесса.

Дескриптор (описатель, handle) - косвенный (абстрактный, виртуальный, локальный) указатель на системный ресурс (окно, элемент управления, курсор, иконка, адрес памяти, открытый файл, канал и тому подобное). В большинстве случаев это всего-лишь абстракция, позволяющая предотвратить непосредственный доступ приложений к системным структурам данных. Она скрывает от разработчика некий физический ресурс, позволяя ядру реорганизовать аппаратные ресурсы удобным ему образом, абсолютно прозрачно для приложений. Фактически это своеобразный индекс (используемый на входе/выходе функций), преобразуемый внутри ядра на основе специальных системных таблиц соответствия во внутреннее представление - указатель непосредственно на физический объект. Поэтому дескриптор должен рассматриваться исключительно в качестве локального значения, имеющего смысл в контексте API в пределах текущего приложения. Не использовать подобный механизм мы не можем, поскольку тогда лишимся связывания различных структур системы друг с другом. Все объекты (окна, файлы, процессы, потоки, события и т.д.) и системные ресурсы в Windows описываются с помощью дескрипторов.

Далее у нас следует блок кода, который отвечает за регистрацию класса окна. Тут у нас впервые в коде появляется структура wc (будет подробно описана в секции данных), которая описывает все необходимые параметры будущего окна, поэтому все члены (поля) этой структуры должны быть предварительно инициализированы. Например, функция GetModuleHandle возвращает дескриптор приложения (процесса) в регистре eax, и мы сохраняем его в wc.hInstance (строка 13), тем самым инициализируя член hInstance структуры wc. Затем мы загружаем иконку при помощи функции LoadIcon и инициализируем дескриптор иконки для будущего окна wc.hIcon. Можно использовать пользовательскую иконку, определённую в секции ресурсов, но обычно, для сокращения кода и упрощения логики, используют один из типовых значков, именно так и сделано в нашей программе. Затем загружаем курсор при помощи функции LoadCursor и инициализируем дескриптор курсора wc.hCursor будущего окна. Опять же, тут каждый волен задавать собственный пользовательский курсор, либо использовать стандартный предопределенный. Обратите внимание на то, что в структуре wc имеется член lpfnWndProc, в который мы записываем адрес начала процедуры обработки событий окна WindowProc, о которой будет рассказано далее. Еще инициализируется дескриптор кисти фона окна hbrBackground. Вызовы всех этих процедур нам необходимы для заполнения структуры wc, используемой в дальнейшем для регистрации класса нашего окна при помощи функции RegisterClass.

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

В нашем примере используется собственный (пользовательский) класс окна. Однако, в операционной системе Windows для разработчика предлагается и несколько предопределенных классов окон, которые могут быть использованы в приложении через определенные ключевые слова. Для чего вообще нужна регистрация некоего класса окна, почему нельзя создать сразу непосредственно само окно? Ответ дает концепция объектно-ориентированного программирования, и класс в рамках концепции существует для того, чтобы задать все параметры будущего окна в одной точке кода, ведь если Вы на основе одного-единственного класса будете создавать множество окон, то подобная стратегия полностью себя оправдает. Отсюда следует вывод:

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

После регистрации класса окна, при помощи системной функции CreateWindowEx, мы создаём экземпляр окна зарегистрированного ранее класса. Как Вы уже заметили, в нашей программе вместо типовой функции CreateWindow используется расширенная версия функции по имени CreateWindowEx, отличающаяся от типовой поддержкой дополнительных стилей окна (параметр dwExStyle). В качестве входных параметров для функции Вы должны указывать некоторое множество параметров:

Наименование Тип в ASM Тип в C Описание
dwExStyle DD DWORD Расширенный стиль описания создаваемого окна. Содержит разнообразные украшательства, которые не входят в основное описание стиля dwStyle.Список возможных значений можно посмотреть в Extended Window Styles.
lpClassName DD LPCTSTR Указатель (адрес) на строку с именем класса окна. В нашем случае используется строка _class.
lpWindowName DD LPCTSTR Указатель на строку с именем окна, отображаемым в заголовке окна. В нашем случае используется строка _title.
dwStyle DD DWORD Константа, определяющая стиль окна. В нашем случае используется константа _style, содержащая битовые флаги.
x DD int Горизонтальная координата (X) левого верхнего угла окна. В координатах экрана.
y DD int Вертикальная координата (Y) левого верхнего угла окна. В координатах экрана.
nWidth DD int Ширина окна в пикселях.
nHeight DD HCURSOR Высота окна в пикселях.
hWndParent DD HWND Дескриптор родительского (порождающего) окна.
hMenu DD HMENU Дескриптор меню, используемого окном. В случае, если окно основано на предопределенном системном классе окна, оно не может содержать меню, тогда параметр используется как идентификатор дочернего элемента управления.
hInstance DD HINSTANCE Дескриптор приложения (модуля), создающего данное окно.
lpParam DD LPVOID Опциональный указатель на дополнительную структуру данных (CREATESTRUCT), посылаемых окну. Если параметр задан, то в первом сообщении WM_CREATE параметр lParam указывает на требуемую структуру. Или же данный указатель может принимать значение NULL, сообщая, что никаких данных посредством функции CreateWindow не передается.

Непосредственно после вызова функции CreateWindowEx наше окно появляется на экране. Всё, окно выведено. Но сам по себе факт отрисовки окна нам мало что дает, окно должно функционировать. Для этого нам необходимо обеспечить получение информации от пользователя/системы (пользовательский/системный ввод) и делается это при помощи очереди сообщений.

Очередь сообщений

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

Сообщения - один из основных механизмов операционной системы Windows, обеспечивающий взаимодействие между различными процессами и объектами в пределах операционной системы.

Как уже отмечалось, процессы в операционной системе обмениваются между собой сообщениями, которые представляют из себя предопределенные константы, однозначно характеризующие произошедшее событие. Каждый раз, когда для нашего процесса имеются входные данные (информация, действия пользователя: сообщения от клавиатуры/мыши и прч.), ядро передает ей эти данные в виде сообщений, помещая их в очередь сообщений (message queue) того или иного потока (зачастую единственного) в рамках процесса.

Операционная система Windows поддерживает очередь сообщений для каждой программы (функционирующей в данный момент в виде процесса).

То есть ядро передает события приложению в форме сообщения, помещая их в очередь того программного потока, которому принадлежит окно, над которым в данный момент проводятся те или иные действия, являющиеся источниками событий. Ядро может генерировать сообщения не только в ответ на какие-то глобальные изменения в системе, инициированные данным приложением (изменение пула ресурсов системных шрифтов, изменение размера одного из своих окон и прч), но и по действиям над объектами оконного интерфейса (окна, дочерние элементы), принадлежащими процессу приложения. Чтобы не было путаницы с пониманием типов и целей сообщений, надо уяснить, что сообщения бывают:

  • Синхронные (поставленные в очередь, queued): сообщения, поступающие в основную очередь сообщений потока (которому принадлежит окно), а затем уже, в зависимости от назначения, обслуживаемые в основной очереди, либо диспетчеризируемые в соответствующую процедуру обработки сообщений окна (оконную процедуру);
  • Асинхронные (не поставленные в очередь, nonqueued): сообщения, поступающие напрямую в процедуру WindowProc соответствующего окна, минуя очередь сообщений потока;

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

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

Например, существует сообщение WM_PAINT, которое предписывает целевому окну отрисовать собственное содержимое. Как видно из наименования WM_PAINT, символическое значение содержит в своем имени префикс (WM), указывающий на его категорию. Символьные значения сообщений (WM_CREATE, WM_DESTROY, WM_PAINT и прч.) определены в файлах стандартных включений \include\equates\user32.inc / \include\equates\user64.inc пакета FASM. Это сделано по аналогии со стандартными файлах заголовков Windows (windows.h и прочие), которые включаются в программы C/C++.
В каждой программе определяется цикл обработки сообщений (message loop), который призван обрабатывать (выбирать/транслировать/диспетчеризировать) поступающие приложению сообщения. В нашем примере, в начале этого цикла функция GetMessage проверяет, есть ли какие-либо сообщения от операционной системы. Особенностью данной функции является то, что она не возвращает управление в вызывающую программу, пока не появится какое-нибудь сообщение, затем извлекает сообщение из очереди сообщений потока и помещает его в структуру с именем msg типа MSG. Как мы видим, параметр hWnd (второй параметр) для функции установлен в ноль (NULL), поэтому извлекаются все сообщения, адресованные любому окну, ассоциированного с текущим потоком, и любые сообщения для текущего потока, чьи hwnd равны нулю (NULL). Таким образом, если hWnd равно нулю, и оконные сообщения и сообщения потока обрабатываются.

Традиционно для Windows-функций, результат выполнения кода функции возвращается в регистре eax.

Поэтому в нашем коде (строка 35) мы анализируем содержимое данного регистра и если оно равно 0, то это значит, что пришло сообщение WM_QUIT, в случае чего переходим на метку end_loop с последующим выходом. Во всех остальных случаях подразумевается, что пришло сообщение, отличное от WM_QUIT, и его требуется обработать. Обработка начинается с вызова вспомогательной функции TranslateMessage (с аргументом в виде структуры msg), которая предназначена для дополнения (расширения) сообщений клавиатуры WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN и WM_SYSKEYUP сообщениями WM_CHAR, WM_DEADCHAR, WM_SYSCHAR, WM_SYSDEADCHAR, содержащими ASCII-значения нажатых клавиш. Согласитесь, что иметь дело с ASCII-значениями проще, нежели со scan-кодами. Если её исключить из цикла, то вероятно мы не получим символических значений, а будем довольствоваться лишь скан-кодами нажатых в окне клавиш. Затем у нас вызывается функция DispatchMessage (аргументом которой все так же является ссылка на нашу структуру msg), которая отправляет сообщение в процедуру окна, поскольку главное её предназначение разбирать сообщения, извлеченные функцией GetMessage.

Функция DispatchMessage в коде обработки очереди сообщений потока проверяет, для какого именно класса окна предназначено сообщение и вызывает соответствующую оконную процедуру.

Обратите внимание, что тут возникает один тонкий момент: зачем нам фактически две логики разбора очереди через GetMessage и через WindowProc, ведь можно было обойтись одной, зачем нам нужно вызывать еще отдельную процедура обработки сообщений окна, когда можно обработать сообщение в основном цикле? Так то оно так, но как мы уже упоминали, сообщения могут быть синхронными и асинхронными. Синхронные сообщения помещаются в очередь сообщений потока, соответственно извлекаются и диспетчеризируются они в основном цикле обработки сообщений: при помощи GetMessage, а затем могут быть отправлены в оконную процедуру через связку DispatchMessage+WindowProc. Асинхронные сообщения передаются непосредственно окну путем прямого вызова оконной процедуры. Это как, неужели ядро что-то напрямую вызывает в пользовательском коде? Я думаю, что тут всё несколько иначе и "прямой" передачей (асинхронных сообщений) занимается исключительно функция DispatchMessage, потому как данные сообщения не извлекаются из очереди функцией GetMessage? В любом случае, оконная процедура принимает все типы сообщений, адресованные заданному окну: синхронные и асинхронные. Именно поэтому цикл обработки сообщений у нас выглядит так а не иначе.
Ну и, наконец, вся эта логика обработки сообщений завершается в строке 40 командой jmp, которая зацикливает прием и обработку сообщений. Таким образом, если сообщений на данный момент нет, то функция все равно ожидает появления сообщения в бесконечном цикле, выходом из которого является лишь действие по закрытию окна.

Основная логика работы большинства Windows-программы с оконным интерфейсом сводится к работе с сообщениями окна.

Код под меткой error, на которую осуществляет переход из нескольких мест обработки ошибочных ситуаций в нашей программе, служит для обработки критической ситуации, когда функции у нас по каким-либо причинам возвращают ошибку и дальнейшее выполнение кода программы становится бессмысленным. В этом случае мы выдаем окно с ошибкой при помощи функции MessageBox, а затем вызывается функция ExitProcess с аргументом msg.wParam, который содержит код выхода.

В случае [внезапного] завершения цикла обработки сообщений, код выхода хранится в члене wParam структуры msg. Этот код выхода необходимо вернуть ядру операционной системы посредством вызова функции ExitProcess с входным параметром, равным значению msg.wParam.

Тут возникает резонный вопрос, почему метка error располагается в основном цикле обработки сообщений, ведь логичнее было бы её вообще вынести в другое место кода. Да, это действительно так, но тогда бы нам пришлось дублировать выход из программы при помощи функции ExitProcess, а так мы можем использовать уже существующую точку выхода (используемую в цикле), не прибегая к дублированию кода. Исключительно с этой целью логика обработки ошибки встроена в цикл обработки сообщений.

Процедура обработки сообщений окна

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

Оконная (процедура) функция обеспечивает непосредственную обработку нужных нам сообщений: сравнивает идентификаторы и если они относятся к обрабатываемым её классам, то выполняет необходимую работу.

В отличии от основного цикла выборки, тут у нас происходит обработка сообщений, посылаемых окну нашей программы системой Windows. Дело в том, что окно это не просто некая область на экране, посредством которой приложение может предоставить на всеобщее обозрение свои данные, это еще и адресат (цель) событий и сообщений в операционной системе Windows.

Оконная (процедура) функция – функция обратного вызова. Ядро Windows само посылает сообщения окну, что в свою очередь, инициирует вызов сопоставленной оконной процедуры.

Фактически, вызов (опосредовано или напрямую) кодом ядра системы оконной процедуры соответствующего окна, называют "обратным вызовом" или (по-английски) callback'ом. И что такое функция обратного вызова? Это функция, которая вызывается ядром при наступлении определенных условий. В действительности система не может так вот запросто взять и вызвать любую произвольную функцию вашего приложения, вместо этого она предоставляет специальный механизм, посредством которого может вызывать только заранее определенную функцию в вашем пользовательском коде. Вот именно эта функция обратного вызова называется оконной (функцией) процедурой (обычно носящей имя WndProc, в нашем случае WindowProc) и ассоциируется со всеми графическими окнами процесса. Задается адрес функции обратного вызова через специальный член структуры класса окна lpfnWndProc, на этапе регистрации класса. Каждый раз, когда для какого-либо окна нашего процесса или его дочерних элементов (элементы меню, поля, кнопки, радиокнопки, элементы управления и прочее) имеются входные данные (информация, действия пользователя: сообщения от клавиатуры/мыши и прч.), ядро сначала передает сообщение в цикл обработки сообщений, а затем через функцию DispatchMessage опосредованно вызывает соответствующую оконную процедуру и передает ей данные в виде сообщений, которые поступают через входные параметры процедуры. На каждое событие (например, набор пользователем символов с клавиатуры, движение курсора мыши в пределах границ окна, щелканье по элементам управления (кнопка, скролл-бар и прч.)), относящееся к окну, ядро генерирует определенное сообщение. В конкретном примере алгоритм процедуры обрабатывает всего два сообщения: WM_DESTROY и WM_CREATE. Процедура WindowProc предваряется у нас в коде неким ключевым словом proc и получает четыре входных параметра (hWnd,wMsg,wParam,lParam), но что это за proc? А это ни что иное как, опять же, макрос, настраивающий пролог/эпилог вызываемой процедуры. Давайте немного отступим от основной линии повествования и познакомимся с макросом proc поближе:

Структура данного макроса лишний раз говорит за то, что в FASM реализован очень продвинутый макроязык. Ведь данным макросом, компилятор фактически "настраивает" любую процедуру, описываемую при помощи ключевого слова proc. И настройка эта состоит в автоматическом создании пролога и эпилога процедуры, настройке стекового фрейма (кадра), резервации места в стеке под локальные переменные, восстановлении стека в эпилоге, сохранении/восстановление регистров общего назначения. Все эти подготовительные действия при входе в процедуру и выходе из неё, считаются типовыми и используются уже давно в компиляторах языков различных уровней. В отсутствии данного макроса программисту пришлось бы писать весь "обвес" процедуры самостоятельно, затрачивая на это драгоценное время, либо тратя его на то, чтобы самостоятельно создавать подобные облегчающие программирование макросы. Поверьте, совокупность подобных (с виду незначительных) автоматизаций серьёзно облегчает работу разработчика. Поэтому, хочу отдельно отметить неоспоримые достоинства макроязыка FASM, поскольку именно с его помощью Вы можете создавать поистине грандиозные конструкции, которые могут кардинально изменить синтаксис языка.
Оконная процедура занимает в исходном тексте строки с 48 по 66. В начале процедуры проверяем идентификатор входящего сообщения wMsg на стандартную константу WM_DESTROY. Данное сообщение посылается окну в случае его закрытия.

WM_DESTROY - единственное сообщение, которое непременно (всегда, в любом случае) должно быть обработано в вашей оконной процедуре!

Если сообщение WM_DESTROY поступило, то прыгаем на локальную метку .wmdestroy, в которой у нас располагается функция PostQuitMessage, фактически посылающая в очередь сообщений сообщение WM_QUIT, которое затем обрабатывается уже в основном цикле функцией GetMessage и предписывает ей вернуть 0 (в регистре eax), что в итоге ведет к выходу из приложения (строка 36).

В строках 58 и 62 у нас присутствует команда xor eax,eax, которая обнуляет регистр eax. Интересно, для чего нам вдруг понадобилось его обнулять? Дело в том, что это регламентируется общим правилом API: функция должна возвращать в регистре eax либо код завершения, либо один из результатов своей работы. Соответственно, если оконная процедура WindowProc обрабатывает какое-либо сообщение, то она должна возвратить 0 в случае успешного завершения, либо любое другое значение в случае ошибочного. Вот именно по этой причине у нас тут и располагается команда обнуления регистра, поскольку подразумевается, что все обрабатываемые нашей процедурой сообщения обрабатываются успешно.

Затем в строке 52 сравниваем значение поля wMsg со значением WM_CREATE, фактически этим мы проверяем, не поступило ли сообщение о создании окна? У нас данная ветка кода пустует, мы как бы реагируем на это сообщение в очереди, но в действительности ничего не делаем. Далее, для всех сообщений, которые не обрабатываются нашей оконной процедурой, мы должны вызвать функцию DefWindowProc, как того предписывает Microsoft. Фактически функция DefWindowProc является функцией обработки по умолчанию и гарантирует, что каждое поступающее в очередь сообщение (даже то, которое нас не интересует), будет обработано. Далее следует локальная метка .finish, которая восстанавливает сохраненные на входе оконной процедуры регистры и выходит из неё.

Секция данных (data)

Как уже следовало из описания секции, в данном разделе помещаются данные, необходимые исполняемому коду, отсюда происходит и название секции. Ключевыми данными тут являются две структуры: wc и msg, на которых стоит остановиться подробнее. Структура wc имеет прототип структуры WNDCLASS. А уже сама структура WNDCLASS является стандартной для библиотек Win32 API и описана в файлах \INCLUDE\EQUATES\USER32.INC и \INCLUDE\EQUATES\USER64.INC пакета FASM. Давайте подробнее её изучим, я просто скопирую содержимое:

Поля требуют пояснения, поэтому я свел их в небольшую таблицу:

Наименование Тип в ASM Тип в C Описание
style DD UINT Определяет стиль окна.
lpfnWndProc DD WNDPROC Адрес процедуры обработки событий окна.
cbClsExtra DD int Информация о дополнительных байтах для структуры класса окна.
cbWndExtra DD int Информация о дополнительных байтах для структуры экземпляра окна.
hInstance DD HINSTANCE Дескриптор экземпляра приложения, которое содержит оконную процедуру для класса окна.
hIcon DD HICON Дескриптор загруженной иконки окна. Может принимать значение дескриптора иконки приложения, отображаемой в верхнем левом углу окна.
hCursor DD HCURSOR Дескриптор загруженного курсора окна. Может принимать значение дескриптора курсора, используемого в пределах окна.
hbrBackground DD HBRUSH Дескриптор загруженной кисти фона окна. Этот член может принимать значение дескриптора кисти, используемой для отрисовки фона, или принимать значение цвета. Цвет должен быть одним из определенных стандартных цветов (правило: к значению цвета должна добавляться 1).
lpszMenuName DD LPCTSTR Имя (идентификатор) ресурса для класса меню. Это имя должно быть определено в секции/файле ресурсов. 0 означает, что меню отсутствует.
lpszClassName DD LPCTSTR Определяет имя класса окна. Это обычная текстовая строка с завершающим нулем. Имеются предопределенные классы, однако можно задавать и собственный класс.

Непосредственно перед регистрацией класса, структура WNDCLASS должна быть заполнена. Наиболее важные значениями являются: дескриптор приложения hInstance, дескриптор иконки для окна hIcon, дескриптор курсора окна hCursor, дескриптор кисти фона окна hbrBackground. В случае с кистью можно оставить значение по умолчанию, либо использовать значение стандартного системного цвета? Во многих исходниках это значение не используется, но я думаю, что шаблон будет более универсальным и готовым к широкому применению, если в нем описать наибольшее количество параметров.
Затем у нас описывается еще одна структура msg, которая имеет прототип системной структуры MSG. Это критически важная структура, поскольку сообщения, передаваемые в приложение, имеют структуру MSG, включающую 6 полей. Описание этой структуры содержится в файлах \INCLUDE\EQUATES\USER32.INC и \INCLUDE\EQUATES\USER64.INC пакета FASM. Проведем детальное рассмотрение данной структуры:

Так же, при помощи сообщений подобной структуры, система посылает сообщения в процедуру окна. Как мы видим, в структуре сообщения используется несколько параметров, которые хорошо бы разобрать подробнее:

Наименование Тип в ASM Тип в C Описание
hwnd DD HWND Дескриптор окна, оконная процедура которого получила данное сообщение. Поле равно нулю (NULL) когда сообщение принадлежит потоку (thread message).
message DD UINT Идентификатор сообщения. Представляет из себя именованную (символическую) константу, которая определяет назначение сообщения. Когда оконная процедура получает сообщение, код использует данный идентификатор для определения перехода на конкретный обработчик. В нашем примере, идентификатор WM_DESTROY определяет переход на метку .wmdestroy, код которой инициирует закрытие окна. Приложения могут использовать только младшее слово двойного слова message, поскольку старшее слово зарезервировано за системой.
wParam DD WPARAM Дополнительные данные сообщения. Параметр, уточняющий смысл сообщения: сами данные или расположение данных. Смысл и значение этого параметра зависит исключительно от типа сообщения, в совокупности с которым этот параметр рассматривается. Параметр может содержать целочисленное значение, флаги, указатель на структуру, содержащую дополнительные данные и тому подобное. Когда сообщение не использует дополнительных параметров, значение обычно устанавливается в 0 (NULL). Код оконной процедуры обычно проверяет идентификатор сообщения message для уточнения, как именно интерпретировать этот параметр.
lParam DD LPARAM Дополнительные данные сообщения. Параметр, уточняющий смысл сообщения: сами данные или расположение данных. Смысл и значение этого параметра зависит исключительно от типа сообщения, в совокупности с которым этот параметр рассматривается. Параметр может содержать целочисленное значение, флаги, указатель на структуру, содержащую дополнительные данные и тому подобное. Когда сообщение не использует дополнительных параметров, значение обычно устанавливается в 0 (NULL). Код оконной процедуры обычно проверяет идентификатор сообщения message для уточнения, как именно интерпретировать этот параметр.
time DD DWORD Время, в которое данное сообщение было помещено в очередь. Формат?
pt DD,DD POINT Позиция курсора, где сообщение было опубликовано. В координатах экрана.

Секция импорта (import data)

Для чего вообще нужна секция импорта? Если вы заметили, наша программа в своей работе использует разнообразные системные функции Win32 API. Все эти функции распределены в системе по различным библиотекам, которые располагаются в соответствующих .DLL-файлах. Например, наша программа использует несколько системных функций (GetModuleHandle, LoadIcon, LoadCursor, RegisterClass, CreateWindowEx и прочие), размещенных во внешних системных библиотеках (KERNEL32.DLL, USER32.DLL). Все эти функции вызываются в коде нашего приложения через таблицу импорта: к примеру, когда в процессе выполнения кода встречается вызов функции GetModuleHandle, на самом деле в исходном коде исполняемого файла вызов выглядит как:

call [адрес_в_таблице_импорта]

То есть производится вызов функции через адрес, указанный в конкретном поле таблицы импорта. Так вот, чтобы функции, используемые в нашем коде, могли корректно вызываться в процессе исполнения приложения, динамический компоновщик на этапе подготовки образа к выполнению, производит связывание действительных адресов функций (по которым расположены функции библиотек в виртуальном адресном пространстве процесса нашего приложения) с полями таблицы импорта. Только после выполнения вышеописанной процедуры внешние функции доступны для вызова.
Секцию импорта в нашем примере компилятор создает при помощи макроса library, который имеется в стандартных библиотечных файлах пакета FASM. На деле, макрос ответственен за создание в выходном исполняемом .exe-файле специального блока данных (секции), в самом начале которого записываются смещения имен библиотек (DLL), а затем смещения, по которым располагаются имена (или ординалы) импортируемых из этих библиотек функций. Директива import (строки 86,90) предписывает компилятору подключить перечисленные функции (используемые в нашей программе) из библиотек. Зачем на этом? Дело в том, что в диалекте FASM использование одного лишь макроса library в секции импорта недостаточно, в дополнение к нему есть два основных подхода:

  • Описание имен библиотек через макрос library + указание через директиву import списка используемых функций. Этот метод более универсальный, поскольку позволяет работать со всеми имеющимися в системе библиотеками.
  • Описание имен библиотек через макрос library + указание через директиву include файлов-включений для используемых библиотек. Это метод более простой, однако имеет существенный недостаток: .inc-файлы в комплекте FASM имеются только к основным системным библиотекам. Поэтому, если у вас нет .inc-файла к какой-либо используемой вами библиотеке - используйте первый метод!!

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

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

Комментарии: 2

  1. Владимир

    Спасибо огромное за такую подробную статью! Очень доходчиво и детально описано! Супер ! Спасибо большое!

    1. einaare

      :) старался.. в меру способностей.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *