![]() |
Работа с WinAPI |
![]() |
Режимы работы процессора
Большинство микропроцессорных архитектур поддерживают возможность выполнения кода в различных режимах. Эти режимы отличаются уровнем привилегий, которыми обладает выполняемый код. Конкретные названия режимов и их особенности зависят от рассматриваемой архитектуры, однако, условно говоря, можно выделить следующие режимы:
- Режим ядра (kernel mode).
- Пользовательский режим (user mode).
Возможность выполнять код в разных режимах используется операционными системами
для ограничения действий, производимых выполняющимися программами. Примером
такого действия может быть попытка доступа к определенной ячейке памяти.
Скажем, инструкция mov [123], eax
помещает содержимое регистра eax в ячейку
оперативной памяти с адресом 123. Однако если выполнить эту инструкцию в одной
программе, а затем попытаться прочитать значение по этому адресу из другой
программы, мы не получим исходное значение eax. Это происходит потому, что
большинство программ выполняются в режиме пользователя, в котором доступ к
памяти контролируется операционной системой. ОС, в свою очередь, работает в
режиме ядра, и при запросе приложения на доступ к ячейке 123
, производит
перерасчет реального адреса ячейки. Этот реальный адрес зависит от
обращающейся программы, и поэтому отличается для двух различных процессов.
Таким образом, обеспечивается необходимое свойство любой современной ОС - ограничение произвольного доступа приложения к памяти других программ. Аналогичным образом выполнение кода в пользовательском режиме ограничивает доступ программы к другим ресурсам компьютера - файлам на диске, сетевым подключениям, внешним устройствам и пр.
В идеальном случае, программа пользовательского режима не может производить никаких действий (кроме чистых вычислений и чтения/записи в доступную память), не обращаясь к ядру ОС.
Системные вызовы ОС
Механизмом, позволяющим программам пользовательского режима обращаться к ядру, являются системные вызовы (system calls, или syscalls). С точки зрения программиста, системный вызов представляет собой обычную процедуру (с особым соглашением вызова), принимающую различные аргументы, производящую определенные действия, и возвращающую результат. Каждая ОС имеет набор системных вызовов, который формирует минимальный функционал, доступный программе пользовательского режима. Обычно этот функционал включает в себя операции выделения/освобождения памяти, ввода/вывода в стандартные потоки, файловые операции (чтения/записи), работу с файловой системой, сетью и пр.
Как уже было отмечено, системные вызовы имеют особое соглашение вызова,
специфичное не только для каждой ОС, но и для различных версий одной и той же
ОС. Размещение аргументов системного вызова происходит подобно соглашениям
вызова, рассмотренным в предыдущей лабораторной работе. В данной работе
остановимся лишь на способе непосредственного вызова системной процедуры. Т.к.
код системного вызова выполняется в другом режиме (ядра), мы не можем
использовать инструкции jmp
и call
. Вместо них используются инструкции,
переключающие режим процессора:
- В ОС Windows 2000 вызывалось прерывание 0x2E (инструкция
int 0x2E
). - В старых версиях ОС Linux использовалась похожая схема - прерывание
0x80
. - Начиная с Windows XP, а так же в новых версиях Linux, для перехода в режим
ядра использовалась инструкция
sysenter
. - На 64-разрядных системах Windows и Linux используется инструкция
syscall
.
На практике, системные вызовы из программ пользовательского режима редко производятся с помощью описанных выше инструкций. Это обуславливается многими причинами - номера системных вызовов могут отличаться между системами разных версий, соглашения вызова могут быть различными, для вызова могут требоваться дополнительные действия со стороны программиста и т.д. Ко всему прочему, ОС семейства Windows традиционно не предоставляют информацию о своих системных вызовах. Ввиду этих причин, современные ОС предоставляют “процедуры-обёртки” для своих системных вызовов. Эти процедуры, с одной стороны, имеют одно из стандартных соглашений вызова (cdecl, stdcall), а с другой - скрывают внутри себя непосредственно переключение в режим ядра. Такой подход позволяет программисту абстрагироваться от факта того, что он производит системный вызов, а не обычный.
На данный момент можно сказать, что сложилось два набора обёрток системных вызовов:
- WinAPI. Программный интерфейс ОС Windows, позиционирующийся компанией
Microsoft как основной способ взаимодействия пользовательских программ с
ядром. Код, реализующий функции WinAPI, расположен в системных библиотеках
kernel32.dll
,user32.dll
,advapi32.dll
,ws2_32.dll
и др. Функции WinAPI имеют соглашение вызова stdcall. - POSIX. Представляет собой не обёртку, а стандарт на программный интерфейс ОС, которому в той или иной мере пытаются удовлетворять Unix-подобные ОС. Linux и FreeBSD реализуют большую часть POSIX. Код, реализующий функции POSIX обычно находится в стандартной библиотеке языка C (libc). POSIX не устанавливает ограничения на соглашения вызова, однако в большинстве совместимых систем используется cdecl.
Макросы ассемблера FASM
Познакомившись с WinAPI, мы уже можем вызывать интересующие нас функции из ассемблера. Для этого необходимо произвести следующие действия:
- Импортировать функцию, которую мы хотим вызвать;
- Найти документацию к интересующей нас функции и определить ее параметры. В этом разделе мы будем рассматривать функцию MessageBox, выводящую на экран окно с некоторой информацией и кнопкой. Документация для этой функции находится в библиотеке MSDN, на странице которой можно увидеть, что она принимает 4 параметра - идентификатор родительского окна, текст сообщения, текст заголовка, и тип создаваемого окна;
- Расположить аргументы вызываемой функции как того требует соглашение вызова.
Т.к. все функции WinAPI имеют соглашение stdcall, эти 4 аргумента
необходимо разместить на стеке с помощью инструкций
push
; - Произвести непосредственный вызов функции с помощью инструкции
call
.
format PE GUI
entry start
include 'win32ax.inc'
db "hi", 0
hi1 db "hi hi", 0
hi2
start:
push MB_OK
push hi1
push hi2
push HWND_DESKTOP
call [MessageBox]
ret
section '.idata' import data readable writeable
, 'User32.DLL'
library userdll,\
import userdll, 'MessageBoxA' MessageBox
В приведенном выше коде нет никаких новых языковых конструкций, однако стоит отметить следующие изменения:
- Строчка
include 'win32a.inc'
была изменена наinclude 'win32ax.inc'
. Это еще один заголовочный файл FASM, содержащий макрокоманды, которые мы используем позднее; - Строки
"hi1"
и"hi2"
оканчиваются нуль-терминатором - специальным значением, сигнализирующем о конце строки; - Имя импортируемой функции -
MessageBoxA
. Многие функции WinAPI, на самом деле, имеют два варианта реализации, в зависимости от используемой кодировки текста. Суффикс “A” означает ASCII, а “W” - wide char (т.н. широкий символ, имеющий размер более 8 бит).
Недостатки рассмотренного примера следующие:
- Каждый раз, когда в процессе написания программы, необходимо вызвать некоторую функцию, приходится добавлять ее в список импортов;
- Если бы соглашение вызова отличалось от stdcall, необходимо было бы писать код, очищающий стек после каждого вызова;
- Строковые константы, передаваемые в качестве аргументов, нужно объявлять в отдельной секции кода и дополнять нуль-терминатором.
Для устранения этих неудобств при написании кода, использующего большое число
системных вызовов, в ассемблере FASM была введена макроинструкция invoke
. Она
значительно упрощает вызовы функций, имеющих соглашение stdcall. Пример
использования макроса invoke
приведен ниже:
format PE GUI
include 'win32ax.inc'
.codestart:
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
:
format PE GUI
создает приложение для подсистемы Windows;format PE console
создает приложение для подсистемы Console.
Приведем пример кода, считывающего 3 символа с консоли в некоторую переменную:
format PE console
include 'win32ax.inc'
db 4 dup(0)
input dd 0
bytesRead
.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
db 4 dup(0)
input dd 0
bytesRead
.code
start:
invoke ReadConsole, <invoke GetStdHandle, STD_INPUT_HANDLE>, input, 3, bytesRead, 0
, input
cinvoke atoiret
section '.idata' import data readable writeable
, 'KERNEL32.DLL',\
library kernel32, 'USER32.DLL',\
user32, 'msvcrt.dll'
msvcrt
, ReadConsole, 'ReadConsoleA',\
import kernel32, 'GetStdHandle'
GetStdHandle, MessageBox, 'MessageBoxA'
import user32, atoi, 'atoi' import msvcrt
Из-за того, что нам потребовалось импортировать дополнительную библиотеку, мы
убрали директиву .end start
, и вручную перечислили необходимые библиотеки и
функции. Обратите внимание, что для вызова atoi
был использован макрос
cinvoke
, т.к. atoi
имеет соглашение вызова cdecl. Еще одним важным
обстоятельством является тот факт, что функция atoi
была импортирована из
библиотеки msvcrt.dll, реализации стандартной библиотеки языка C.
Контрольные вопросы
- Режимы работы процессора.
- Инструкции для вызова системных функций.
- Функции WinAPI для консольного ввода/вывода.
- Макросы FASM для вызова функций.
Задание на лабораторную работу
Дополнить программу из лабораторной работы №2, соответствующую своему варианту,
добавив в неё ввод и вывод данных через консоль. Указания о размещении начальных
значений игнорировать. Вместо этого, начальные значения должны передаваться
приложению с помощью функции scanf
, а выводиться на печать - с помощью
функции printf
. Эти функции являются частью стандартной библиотеки языка C,
документация по ним доступна в интернете.
