Работа с WinAPI

Режимы работы процессора

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

Возможность выполнять код в разных режимах используется операционными системами для ограничения действий, производимых выполняющимися программами. Примером такого действия может быть попытка доступа к определенной ячейке памяти. Скажем, инструкция mov [123], eax помещает содержимое регистра eax в ячейку оперативной памяти с адресом 123. Однако если выполнить эту инструкцию в одной программе, а затем попытаться прочитать значение по этому адресу из другой программы, мы не получим исходное значение eax. Это происходит потому, что большинство программ выполняются в режиме пользователя, в котором доступ к памяти контролируется операционной системой. ОС, в свою очередь, работает в режиме ядра, и при запросе приложения на доступ к ячейке 123, производит перерасчет реального адреса ячейки. Этот реальный адрес зависит от обращающейся программы, и поэтому отличается для двух различных процессов.

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

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

Системные вызовы ОС

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

Как уже было отмечено, системные вызовы имеют особое соглашение вызова, специфичное не только для каждой ОС, но и для различных версий одной и той же ОС. Размещение аргументов системного вызова происходит подобно соглашениям вызова, рассмотренным в предыдущей лабораторной работе. В данной работе остановимся лишь на способе непосредственного вызова системной процедуры. Т.к. код системного вызова выполняется в другом режиме (ядра), мы не можем использовать инструкции jmp и call. Вместо них используются инструкции, переключающие режим процессора:

На практике, системные вызовы из программ пользовательского режима редко производятся с помощью описанных выше инструкций. Это обуславливается многими причинами - номера системных вызовов могут отличаться между системами разных версий, соглашения вызова могут быть различными, для вызова могут требоваться дополнительные действия со стороны программиста и т.д. Ко всему прочему, ОС семейства Windows традиционно не предоставляют информацию о своих системных вызовах. Ввиду этих причин, современные ОС предоставляют “процедуры-обёртки” для своих системных вызовов. Эти процедуры, с одной стороны, имеют одно из стандартных соглашений вызова (cdecl, stdcall), а с другой - скрывают внутри себя непосредственно переключение в режим ядра. Такой подход позволяет программисту абстрагироваться от факта того, что он производит системный вызов, а не обычный.

На данный момент можно сказать, что сложилось два набора обёрток системных вызовов:

Макросы ассемблера FASM

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

  1. Импортировать функцию, которую мы хотим вызвать;
  2. Найти документацию к интересующей нас функции и определить ее параметры. В этом разделе мы будем рассматривать функцию MessageBox, выводящую на экран окно с некоторой информацией и кнопкой. Документация для этой функции находится в библиотеке MSDN, на странице которой можно увидеть, что она принимает 4 параметра - идентификатор родительского окна, текст сообщения, текст заголовка, и тип создаваемого окна;
  3. Расположить аргументы вызываемой функции как того требует соглашение вызова. Т.к. все функции WinAPI имеют соглашение stdcall, эти 4 аргумента необходимо разместить на стеке с помощью инструкций push;
  4. Произвести непосредственный вызов функции с помощью инструкции call.
format PE GUI
entry start
include 'win32ax.inc'

hi1 db "hi", 0
hi2 db "hi hi", 0

start:
push MB_OK
push hi1
push hi2
push HWND_DESKTOP
call [MessageBox]
ret

section '.idata' import data readable writeable
library userdll, 'User32.DLL'
import userdll,\
       MessageBox, 'MessageBoxA'

В приведенном выше коде нет никаких новых языковых конструкций, однако стоит отметить следующие изменения:

Недостатки рассмотренного примера следующие:

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

format PE GUI

include 'win32ax.inc'

.code
start:
invoke MessageBox, HWND_DESKTOP, "hi", "hihi", MB_OK
ret

.end start

Синтаксис макрокоманды invoke следующий. Первым аргументом указывается имя вызываемой процедуры. Суффиксы “A” или “W” не указываются, а выбираются автоматически, в зависимости от присоединенного заголовочного файла (win32ax.inc или win32wx.inc). После имени функции следуют её аргументы, причем в их качестве можно использовать не только числовые, но и строковые константы, и даже вложенные вызовы invoke, обрамляя их символами “<” и “>”.

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

Ввод и вывод на консоль средствами WinAPI

В отличие от Unix-подобных ОС, в Windows существуют подсистемы (subsystems), в рамках которых могут выполняться приложения. Наиболее распространенными подсистемами являются Windows и Console. Для приложений, работающих в подсистеме Console автоматически создается окно консоли, подобное интерфейсу утилиты cmd.exe. Такие приложения имеют возможность производить чтение вводимых в консоль символов, а так же печать символов на экран консоли. Приложения подсистемы “Windows” (которые мы использовали до сих пор) не имеют такой возможности, и при запуске не создают видимых окон по умолчанию.

В ассемблере FASM используемая подсистема задается в директиве format:

Приведем пример кода, считывающего 3 символа с консоли в некоторую переменную:

format PE console

include 'win32ax.inc'

input db 4 dup(0)
bytesRead dd 0

.code

start:
invoke ReadConsole, <invoke GetStdHandle, STD_INPUT_HANDLE>, input, 3, bytesRead, 0
ret

.end start

Здесь функция GetStdHandle возвращает дескриптор стандартного потока ввода, который передается в функцию ReadConsole. Эта функция, в свою очередь, считывает из указанного потока заданное количество байт (3) и помещает их по адресу input. В переменную bytesRead заносится количество прочитанных байт.

Чтобы использовать строку, введенную с клавиатуры, как целое число, необходимо выполнить соответствующее преобразование. Функция, преобразующая строки в числа, называется atoi, и находится не среди функций WinAPI, а в библиотеке времени выполнения C, msvcrt. Поэтому, чтобы вызывать atoi, изменим приведенный выше код следующим образом:

format PE console

include 'win32ax.inc'
entry start

input db 4 dup(0)
bytesRead dd 0

.code

start:
invoke ReadConsole, <invoke GetStdHandle, STD_INPUT_HANDLE>, input, 3, bytesRead, 0
cinvoke atoi, input
ret

section '.idata' import data readable writeable

library kernel32, 'KERNEL32.DLL',\
        user32, 'USER32.DLL',\
        msvcrt, 'msvcrt.dll'

import kernel32, ReadConsole, 'ReadConsoleA',\
                 GetStdHandle, 'GetStdHandle'
import user32, MessageBox, 'MessageBoxA'
import msvcrt, atoi, 'atoi'

Из-за того, что нам потребовалось импортировать дополнительную библиотеку, мы убрали директиву .end start, и вручную перечислили необходимые библиотеки и функции. Обратите внимание, что для вызова atoi был использован макрос cinvoke, т.к. atoi имеет соглашение вызова cdecl. Еще одним важным обстоятельством является тот факт, что функция atoi была импортирована из библиотеки msvcrt.dll, реализации стандартной библиотеки языка C.

Контрольные вопросы

  1. Режимы работы процессора.
  2. Инструкции для вызова системных функций.
  3. Функции WinAPI для консольного ввода/вывода.
  4. Макросы FASM для вызова функций.

Задание на лабораторную работу

Дополнить программу из лабораторной работы №2, соответствующую своему варианту, добавив в неё ввод и вывод данных через консоль. Указания о размещении начальных значений игнорировать. Вместо этого, начальные значения должны передаваться приложению с помощью функции scanf, а выводиться на печать - с помощью функции printf. Эти функции являются частью стандартной библиотеки языка C, документация по ним доступна в интернете.