Шаблон оконного приложения на ассемблере

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

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

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

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

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

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

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

Программирование под Windows - это, в основе своей, программирование обработчиков сообщений.

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

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

Заголовок

Рассмотрение исходного кода начнем мы с заголовка, или, если выразиться более точно - "так называемого заголовка". Я возьму на себя смелость подобным образом именовать область исходного кода, начинающуюся непосредственно с первого символа и идущую до директивы объявления первой секции. Начало области не обозначается специальными директивами, это просто начальная часть листинга программы. В этом месте может использоваться директива 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-подобных систем
ARM формат исполняемых файлов под 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 на стадии подготовки образа к выполнению.
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), под которую создается наш исполняемый модуль.

Дополнительные ключевые слова, такие как DLL и WDM, могут указываться для сборки динамической библиотеки и WDM-драйвера, соответственно.

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

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

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

Как Вы уже наверное заметили, в параметре директивы include используется переменная пути %include%. В моем случае я задал её для удобства указания пути к поддиректории \INCLUDE основной директории FASM. Вы тоже можете задать полный путь к этому каталогу в разделах Переменные среды пользователя / Системные переменные настройки Переменные среды.

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

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

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

Секции

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

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

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

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

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

Наименование Обозначение FASM Описание
Секция кода code Секция, в которой предписывается размещать исполняемый код приложения. Обычно в данную секцию включается весь ассемблерный код, фактически реализующий логику работы приложения.
Секция данных data В данной секции предписывается размещать все динамические (изменяемые) данные (локальные/глобальные переменные, строки, структуры и т.п.), которые активно используются в коде приложения.
Секция импорта import data Распространенное название: Таблица импорта. В данной секции размещаются строковые литералы (наименования) библиотек и таблицы виртуальных функций, которые требуются нашей программе для выполнения.
Секция ресурсов resource data Данная секция содержит данные, которые преобразуются в исполняемом файле в многоуровневое двоичное дерево (индексированный массив), построенное определенным образом для ускорения доступа к данным. Эти данные называются ресурсами, доступны из кода через специальные идентификаторы, статичны, описывают различные используемые в программе объекты: меню, диалоги, иконки, курсоры, картинки, звуки и прочее.
Таблица перемещений fixups data Данная секция содержит базовые смещения в исполняемом файле. Представляет из себя таблицу указателей на абсолютные адреса в коде, которые должны быть скорректированы при размещении секций исполняемого файла в памяти процесса по нестандартному смещению (динамическое перемещение). Обычно заполняется линковщиком/компилятором на этапе создания исполняемого модуля.
Таблица экспорта export data Секция описывает экспортируемые функции нашей программы. Обычно используется при создании библиотек DLL.
Обычно тип секции предписывает размещать внутри неё код/данные требуемого назначения.

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

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

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

  • Получаем дескриптор экземпляра текущего процесса (в контексте которого и выполняется наш код);
  • Регистрируем класс окна. Регистрация собственного класса требуется во всех случаях за исключением тех, когда Вы используете стандартные (предопределенные, предоставляемые системой) типы окон;
  • Создаем окно на основе только что зарегистрированного класса;
  • Отображаем окно на экране (в нашем случае не используется);
  • Обновляем клиентскую область окна (в нашем случае не используется);
  • Входим в бесконечный цикл обработки сообщений для всех окон, принадлежащих нашему процессу. В данном примере обрабатываются сообщения только к одному основному окну;
  • Сообщения, поступающие для любого из контролируемых нами окон обрабатываются специальной функцией;
  • Выходим из программы по нажатию пользователем кнопки Закрыть (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 в пределах текущего приложения. Не использовать подобные дескрипторы мы не можем, поскольку тогда лишимся критически важного механизма связывания различных структур системы друг с другом.

Далее у нас следует блок кода, который отвечает за регистрацию класса окна. Тут у нас впервые в коде появляется структура 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, обеспечивающий взаимодействие между различными процессами и объектами в пределах операционной системы.

Как уже отмечалось, процессы в операционной системе обмениваются между собой сообщениями, которые представляют из себя предопределенные константы, однозначно характеризующие произошедшее событие. Однако, в действительности система не имеет возможности вот так вот запросто взять и вызвать произвольную функцию вашего приложения, вместо этого системой предоставляется механизм, посредством которого она может вызывать строго предопределенные функции обратного вызова в вашем пользовательском коде. Забегая вперед скажу, что подобная функция обратного вызова называется оконной (функцией) процедурой (обычно носящей имя WndProc, в нашем случае 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++.
В начале цикла функция GetMessage проверяет, есть ли какие-либо сообщения от операционной системы. Особенностью данной функции является то, что она не возвращает управление в вызывающую программу, пока не появится какое-нибудь сообщение, затем извлекает сообщение из очереди сообщений потока и помещает его в структуру 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, ведь можно было обойтись одной, зачем нам нужно вызывать еще отдельную процедура обработки сообщений окна, когда можно обработать сообщение в основном цикле? Так то оно так, но коррективы вносит тот факт, что сообщения могут быть синхронными (queued), и асинхронными (nonqueued). Синхронные сообщения помещаются в очередь сообщений процесса (приложения), соответственно извлекаются и диспетчеризируются они при помощи GetMessage в цикле обработки сообщений. Асинхронные сообщения передаются напрямую окну путем вызова оконной процедуры, соответственно, обслуживаются они внутри связки DispatchMessage+WindowProc. Именно поэтому цикл обработки сообщений у нас выглядит так и никак иначе.
Ну и, наконец, вся эта логика обработки сообщений завершается в строке 40 командой jmp, которая зацикливает прием и обработку сообщений. Таким образом, если сообщений на данный момент нет, то функция все равно ожидает появления сообщения в бесконечном цикле, выходом из которого является лишь действие по закрытию окна.

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

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

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

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

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

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

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

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

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

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

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

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

Затем в строке 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. Она так же содержится в файлах пакета FASM: \INCLUDE\EQUATES\USER32.INC и \INCLUDE\EQUATES\USER64.INC, да и в некоторых других. Структура выглядит так:

При помощи этой структуры система посылает сообщение к процедуре окна. В структуре сообщения используется несколько параметров. Разберем их подробнее:

Наименование Тип в 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)

Секцию импорта наш шаблон создает при помощи макроса library, который имеется в стандартных библиотечных файлах пакета FASM. На деле, макрос ответственен за создание в выходном исполняемом .exe-файле специального блока данных (секции), в самом начале которого записываются смещения имен библиотек DLL, а затем смещения, с которых начинаются имена импортируемых из этих библиотек функций. Как уже отмечалось, наша программа использует несколько системных функций (GetModuleHandle, LoadIcon, LoadCursor, RegisterClass, CreateWindowEx и прочие), размещенных во внешних системных библиотеках (KERNEL32.DLL, USER32.DLL). Так вот, чтобы функции, используемые в нашем коде, могли корректно вызываться, динамический компоновщик на этапе подготовки образа к выполнению, производит связывание действительных адресов, по которым расположены функции библиотек в виртуальном пространстве нашего процесса с полями таблицы импорта. Только после этого внешние функции доступны для вызова. Фактически директива include указывает на подключение библиотеки импорта, используемой в нашей программе, фактически все библиотеки, функции которых использованы в коде приложения, должны быть подключены.
В старых версиях пакета FASM использование только лишь макроса library было недостаточным, в дополнение к нему приходилось описывать все используемые в приложении функции. В новых версиях всю работу по определению используемых в приложении функций и их включению в выходной исполняемый модуль берёт на себя компилятор, что, несомненно, удобно.
Ранее надо было использовать такие вот конструкции:

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

  • Поделиться:

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

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