Итак, обещал рассказать, как все-таки написать консольный Hello, world на ассемблере, и выбрал для этого MASM, потому что TASM’овский компилятор довольно давно полумертв, и не умеет из коробки некоторых базовых вещей, например, не умеет указывать подсистему (это изменение одного поля в заголовке PE-файла), в которой должна работать программа под Win32. Подсистем есть несколько, но нас пока интересуют две: WINDOWS
и CONSOLE
. Первая, для оконных приложений, а вторая — для консольных. Если ОС будет видеть, что приложение консольное, она проверит, открыта ли для него консоль уже (если мы запускаем приложение через cmd
, или Far manager, например), и откроет для него консоль, если мы запускаем приложение из оконной среды, щелкая по экзешнику мышкой. Это избавляет нас от геморроя, вручную контролировать консоль, открывать ее, закрывать, освобождать. Хотя, принципиальных ограничений нет — консольное приложение спокойно может открыть, как стандартное диалоговое окно, так и вообще создать полноценную форму, а оконное приложение — открыть консоль.
В принципе, для простейшего случая, где не используются всякие дополнительные плюшки MASM (мы обязательно их посмотрим, но ниже), можно писать, как и на TASM, как мы писали оконный helloworld (копия).
1. Указываем набор инструкций процессора и используемую модель памяти flat
.
.386
.model flat
2. Подключаем необходимые функции WinAPI:
extern _ExitProcess@4:near extern _GetStdHandle@4:near extern _WriteConsoleA@20:near
Имена функций в библиотеках MASM выглядят иначе, более громоздко, но дают больше информации о самой функции и ее параметрах: знак подчеркивания (_
) указывает программисту, что функция не относится к его программе, а вызывается из внешней библиотеки, а @<число>
указывает на размер параметров (в байтах), принимаемых функцией. Каждый параметр функции равен DWORD
(4 байта), и фактически это либо число размерности DWORD
, либо указатель на данные (тоже 4 байта). Из такой записи сразу становится понятно, что ExitProcess
(@4 ==> 4/4 = 1
) принимает один параметр, а WriteConsoleA
— 5 параметров (@20 ==> 20/4 = 5
).
Ключевое слово near указывает ассемблеру, что функция находится в том же сегменте памяти, что и сама программа. Поскольку, для Win32 используется модель flat
, для которой сегментирование вообще не предусмотрено, а за то, чтобы подгружать внешние библиотеки (DLL
), заниматься менеджментом памяти и давать доступ к API отвечает ОС, то для WinAPI практически всегда указывается near
.
3. Подключаем нужные библиотеки (LIB
):
includelib kernel32.lib
Нам для helloworld’а понадобится только одна, kernel32.lib
. в MASM тут опять же все немного иначе, чем в TASM, во-первых, внешнюю библиотеку (.LIB
) можно подключать с помощью директивы includelib
прямо в исходнике, а во-вторых, в комплекте их множество, практически на все стандартные WinAPI, по штуке на реальную библиотеку (.DLL
) Windows, а не одна, как в TASM. Главное, указать линковщику, где их искать, но до этого мы еще дойдем.
Лирическое отступление: Библиотеки (.LIB
) это набор объектных файлов,объединенных в один файл особого формата, которые содержат код для вызова функций из динамических библиотек (.DLL
), входящих в состав ОС или программ. Обычно все это называется библиотекой, если что в скобках буду пояснять, какая именно имеется ввиду — DLL
или LIB
.
4. Определяем секцию данных:
.data message db 'Hello, world!',0Dh,0Ah handle dd ? written dd ? STD_INPUT_HANDLE equ -10 STD_OUTPUT_HANDLE equ -11 STD_ERROR_HANDLE equ -12
message
— наше сообщение, которое будет выведено на консоль, строку определим, как байтовую (db
) переменную, потому что функцию вывода будет интересовать не сама строка, а адрес на ее начало в памяти. И да, строка для консольного вывода в WinAPI не заканчивается ,0
, тому ще функция WriteConsoleA
не умеет сама определять количество символов, которые ей надо вывести (см. ниже). 0Dh,0Ah
— символы перевода строки в Windows, т.е. CR+LF
handle
— сюда будем сохрвнять хэндл (идентификатор) стандартного устройства ввода-вывода (подробнее см. ниже).
written
— количество фактически выведенных символов, это место в памяти нужно функции WriteConsoleA
.
STD_INPUT_HANDLE
, STD_OUTPUT_HANDLE
, STD_ERROR_HANDLE
— WinAPI-константы (передранные из описания WinAPI), которые будут указывать функции GetStdHandle
, какое устройство нам следует получить (подробнее см. ниже). Вообще для простейшего helloworld’а нам нужен только STD_OUTPUT_HANDLE
, но напишем пока все три, понадобятся.
5. Определяем секцию кода и точку входа в программу.
.code
_main:
;тут будет код :)
end _main
Название точки входа _main
рекомендуется некоторыми руководствами по MASM, впрочем, можно назвать точку входа как угодно, главное, чтоб она начиналась со знака подчеркивания (_
), иначе получим предупреждение при компиляции warning A4023: with /coff switch, leading underscore required for start address : main
, т.е. требуется начальное подчеркивание для указания стартового адреса, а при линковке — ошибку: error LNK2001: unresolved external symbol _main
.
В конце избавимся от обязательного знака подчеркивания, но пока так.
6. Теперь нужно получить хендл стандартного потока вывода, для чего используется функция WinAPI GetStdHandle
, принимающая на вход один параметр — идентификатор, указывающий, хендл какого потока надо получить (STDIN
, STDOUT
или STDERR
), идентификатор у нас указан в константе, которую и надо положить в стек перед вызовом функции:
push STD_OUTPUT_HANDLE
call _GetStdHandle@4
Функция возвращает (в регистре EAX
) хэндл, значение 0
, если хэндл не получен, (происходит, чаще всего, если консоль недоступна, при ошибочной сборке консольного приложения под оконную подсистему), или -1
, если произошла другая ошибка. Хорошим тоном считается проверить после вызова функции, не получили ли мы ошибку:
cmp eax,0
jle exiterr
mov handle, eax
Сравниваем eax
с 0, если меньше или равно нулю, переходим на метку exiterr
, иначе, сохраняем хэндл из регистра eax
в переменную handle
(определили выше, в секции данных). Подробнее про условные переходы операторы cmp
(копия).
При переходе на метку exiterr
выполнится следующий код:
exiterr: push 1 call _ExitProcess@4
Т.е. программа завершится с кодом возврата 1
. Про функцию ExitProcess
я рассказывал в предыдущем примере, повторяться не буду.
7. Выводим строку на консоль. Для этого используется функция WinAPI WriteConsoleA
(или WriteConsoleW
, если выводится строка в Юникоде). Вот ее описание в стиле C из MSDN:
BOOL WINAPI WriteConsole( _In_ HANDLE hConsoleOutput, _In_ const VOID *lpBuffer, _In_ DWORD nNumberOfCharsToWrite, _Out_opt_ LPDWORD lpNumberOfCharsWritten, _Reserved_ LPVOID lpReserved );
Как и в примере про оконный helloworld, пока не используем никаких удобных фич MASM, а вызовем ее так, как это делается обычно, т.е. сами положим в стек параметры (в последовательности, обратной описанию):
push 0 ;зарезервировано, всегда 0
push offset written ;количество выведенных на консоль символов (установит функция)
push 15 ;количество выводимых символов (длина строки)
push offset message ;адрес начала выводимой строки
push handle ;хэндл устройства вывода
call _WriteConsoleA@20
Обратите внимание, функция MessageBoxA
определяет строку для вывода заголовка или текста в окне по завершающему байту с кодом 0
, а WriteConsoleA
надо указать количество символов, в нашем случае это длина строки Hello, world!
и два символа с кодами 0Dh
(13
) и 0Ah
(10
) для перевода строки и возврата каретки. Всего 15 символов. Если указать меньше, например 10, то будет выведена только часть строки (Hello, wor
), а если больше, например 20, то будут выведены пустые символы.
8. Далее совершаем безусловный переход к метке exit
:
jmp exit
где вызовем функцию ExitProcess
, но передадим ей код завершения 0 (нормальное завершение работы).
exit: push 0 call _ExitProcess@4
Пример целиком на GitHub
На PasteBin
Компиляция:
G:\masm32\bin\ml.exe /c /coff helloc1.asm
где:
G:\masm32\bin\
— путь к компилятору.
/c
— компилировать без линковки.
/coff
— указать формат объектного файла COFF (по умолчанию OMF).
helloc1.asm
— имя файла с исходником.
О разнице между форматами COFF и OMF (копия)
Линковка (сборка):
G:\masm32\bin\link.exe helloc1.obj /SUBSYSTEM:CONSOLE /LIBPATH:G:\masm32\lib\
где:
G:\masm32\bin\link.exe
— путь к линковщику
helloc1.obj
— имя объектного файла
/SUBSYSTEM:CONSOLE
— указание подсистемы (в заголовке PE-файла), в данном случае CONSOLE
, т.е. консольное приложение, еще есть WINDOWS
— оконное приложение, NATIVE
— приложение, запускающееся до загрузки основной части ОС, как, например chkdsk
, и другие подсистемы, не особо интересные.
/LIBPATH:G:\masm32\lib\
— путь к библиотекам (.LIB
)
Замените пути к файлам и каталогам на свои.
Заморочился, специально перекомпилировал экзешник с другими параметрами, чтобы показать, как срабатывает единственный условный переход и проверка результата, возвращаемого GetStdHandle
.
Перекомпилировал исходник, изменив подсистему на WINDOWS
:
G:\masm32\bin\link.exe helloc1.obj /SUBSYSTEM:WINDOWS /LIBPATH:G:\masm32\lib\
Теперь напишем простой bat-файл, чтобы проверить, какие коды возврата окажутся в стандартной переменной окружения ERRORLEVEL
:
good.exe
@echo Exit code: %ERRORLEVEL%
start /wait bad1.exe
@echo Exit code: %ERRORLEVEL%
good.exe — оригинальный экзешник
bad1.exe
— перекомпилированный с ключом /SUBSYSTEM:WINDOWS
Вывод:
G:\test>testcode.bat
G:\test\>good.exe
Hello, world!
Exit code: 0
G:\test\>start /wait bad1.exe
Exit code: 1
Если запустить откомпилированный helloworld не из консоли, то будет не очень красиво — окно откроется, текст будет выведен, и моментально закроется. Некрасиво, хочется, чтоб окно закрывалось, скажем, по нажатию ENTER.
1. Добавляем функцию ReadConsoleA
из той же kernel32
, предназначенную, как должно быть понятно из названия, для чтения из консоли. Читает она символы, конец ввода определяется нажатием клавиши ENTER, что нам и надо.
extern _ReadConsoleA@20:near
2. В секции данных изменим оригинальное сообщение, добавим еще один перевод строки (CR+LF
):
message db 'Hello, world!',0Dh,0Ah,0Dh,0Ah
Сообщение пользователю:
msgexit db 'Press ENTER to exit...'
И переменные, необходимые для функции ReadConsoleA
: хэндл стандартного ввода, количество прочитанных символов, буфер, куда будут помещены прочитанные символы:
hInput dd ?
readed dd ?
readbuf db ?
Т.к. читать мы ничего не собираемся, а формально буфер нужен, просто выделим один байт (db
).
3. Добавим в код, после вывода Hello, world!
вывод сообщения о том, что надо нажать ENTER для выхода:
;write exit message
push 0
push offset written
push 22
push offset msgexit
push handle
call _WriteConsoleA@20
4. Теперь, с помощью функции GetStdHandle
надо получить хэндл стандартного вывода, все делается также, как и со стандартным вводом, только меняем константу STD_OUTPUT_HANDLE
на STD_INPUT_HANDLE
, а потом, как и в предыдущем случае, делаем проверку, получен хендл или нет:
push STD_INPUT_HANDLE
call _GetStdHandle@4
cmp eax,0
jle exiterr
mov hInput, eax
5. Вызываем функцию ReadConsoleA
.
Вот ее заголовок из MSDN:
BOOL WINAPI ReadConsole( _In_ HANDLE hConsoleInput, _Out_ LPVOID lpBuffer, _In_ DWORD nNumberOfCharsToRead, _Out_ LPDWORD lpNumberOfCharsRead, _In_opt_ LPVOID pInputControl );
Опять же, кладем в стек параметры в обратном порядке и вызываем функцию
push 0 ;адрес структуры READ_CONSOLE_CONTROL push offset readed ;кол-во прочитанных символов push 0 ;сколько надо прочитать push offset readbuf ;буфер для прочитанных символов push hInput ;хэндл стандартного ввода call _ReadConsoleA@20
Структура READ_CONSOLE_CONTROL
нам не нужна, т.к. не собираемся организовывать какой-то сложный ввод, но вот ссылка на ее описание в MSDN.
6. Далее, идем к метке exit для корректного завершения работы программы:
jmp exit
Собираем с теми же параметрами, что и первый пример.
Вот, что получилось:
Исходник целиком и скомпилированный пример на GitHub
Исходник на PasteBin
На самом деле, писать что-то крупное и сложное без всяких улучшающих жизнь конструкций, т.е. на «классическом» ассемблере довольно проблематично. Во втором примере мы сделали буквально ничего, т.е. вызвали три простейших функции (одну два раза) и учинили три проверки, а получили простыню кода на два экрана с гаком.
Конечно, даже макросы, предопределенные структуры и специфический синтаксис MASM не избавит вас от некоторых особенностей ассемблера, ну потому что это ассемблер, а не Java или C#, но существенно улучшит читаемость кода и, как минимум, избавит от километров push
‘ей при вызове WinAPI.
Подправляем директиву .model
:
.model flat, stdcall
Если непонятно, что это значит — вот подробный разбор: MASM32: .386 .model, STDCALL, что это такое. (копия)
Кстати, указание соглашения вызова функций STDCALL
отключает обязательное подчеркивание (_
) перед меткой точки входа. Обещал же разобраться с этим.
Добавляем опцию:
option casemap:none
Эта опция говоpит MASM сделать имена (функций, меток, констант и т.д)чувствительными к pегистpам, например, ExitProcess и exitprocess — это pазличные имена.
Если ее не указать, в дальнейшем может быть ошибка (копия)
Подключаем внешние файлы:
include windows.inc
include kernel32.inc
includelib kernel32.lib
include — директива, позволяющая подключать .inc
-файлы, в которых хранится дополнительный код: функции, код для вызова внешних функций из .dll
, переменные, структуры и константы. Не смотрите на огромный размер некоторых .inc
-файлов, все это компилятор не будет вставлять в наш экзешник, компилятор умный — вставит только то, что мы будем использовать в своем коде.
windows.inc
— файл, содержащий константы и структуры для WinAPI, поскольку, WinAPI обновляется вместе с версиями Windows, на masm32.com за ним следят и обновляют по надобности.
kernel32.inc
— файл, содержащий прототипы функций для библиотеки kernel32.dll
Прототип функции — специальная синтаксическая конструкция MASM.
Пpототип функции указывает ассемблеpу/линкеpу атpибуты функции, так что он может делать для вас пpовеpку типов данных. Фоpмат пpототипа функции следующий:
ИмяФункции PROTO [СОГЛАШЕНИЕ_О_ВЫЗОВЕ] [ИмяПаpаметpа]:ТипДанных,[ИмяПаpаметpа]:ТипДанных,...
Говоpя кpатко, за именем функции следует ключевое слово PROTO, а затем список пеpеменных с типом данных, pазделенных запятыми.(из мануала Iczelion’а)
Например, для функции ExitProcess
, прототип будет выглядеть так:
ExitProcess PROTO STDCALL :DWORD
что значит, что функция ExitProcess
поддерживает соглашение STDCALL
(копия) и требует один параметр типа DWORD
(dd
), т.е. двойное слово.
Секцию данных оставляем без изменений:
.data message db 'Hello, world!',0Dh,0Ah,0Dh,0Ah handle dd ? written dd ? msgexit db 'Press ENTER to exit...' hInput dd ? readed dd ? readbuf db ?
А в секции кода — изменяем вызов функций из kernel32.dll
на более удобочитаемый («высокоуровневый») синтаксис с помощью ключевого слова invoke
, характерного для MASM:
invoke
позволяет осуществить вызов функции по ее прототипу, с контролем передаваемых ей параметров. Для программиста это означает, что вагон и тележку push
‘ей можно заменить одной строкой в человекочитаемом формате, да и компилятор будет за нас проверять, не забыли ли мы параметры вызываемой функции. Т.е вызов, например WriteConsoleA
из
push 0
push offset written
push 17
push offset message
push handle
call _WriteConsoleA@20
превращается, превращается в элегантные шорты в более удобочитаемую конструкцию:
invoke WriteConsoleA, handle, addr message, 17, addr written, 0
Впрочем, ничего страшного компилятор за нас не делает, если дизассемблировать экзешник, то мы найдем там те же push
‘ы, которые мы видели в предыдущем исходнике:
Спрашивают, а как изменить заголовок окна консоли, как это сделал дизассемблер на предыдущей картинке. Да без проблем, можно! С помощью того же WinAPI. В предыдущем примере не показывал, чтобы не утомлять простынями push
‘ей. Теперь, вполне легко добавить. Добавляем в секцию данных строку заголовка:
conshdr db 'Hello, MASM32 World!',0
А меняется заголовок консоли с помощью функции SetConsoleTitleA
:
invoke SetConsoleTitleA, addr conshdr
Вот, что получилось:
G:\masm32\bin\ml.exe /c /coff /IG:\masm32\include\ helloc3.asm
G:\masm32\bin\link.exe helloc3.obj /SUBSYSTEM:CONSOLE /LIBPATH:G:\masm32\lib\
т.е. компилятору указываем в параметре /I
путь к каталогу (без пробела) с .inc
-файлами, остальное также.
Ну обещал показать, что б не сделать-то?
Подключаем библиотеку с оконными функциями:
User32.dll
— реализует Windows User API. Позволяет работать со стандартными элементами пользовательского интерфейса Windows: рабочий стол, окна, меню и так далее. Позволяет реализовывать программ графический пользовательский интерфейс Windows. Создавать и управлять окнами Windows, обрабатывать системные сообщения, команды с устройств ввода (клавиатуры, мыши и т.д.).
include user32.inc
includelib user32.lib
Меняем секцию данных:
.data message db 'Hello, world!',0Dh,0Ah handle dd ? written dd ? conshdr db 'Hello, MASM32 World!',0 mboxhdr db 'Console call MessageBox',0 mboxtxt db 'Press OK to exit.',0
msgexit
не нужна, т.к. выводить сообщение Press ENTER to exit...
не будем, вместо него выведем сообщение в стандартном диалоговом окне.
Функция WriteConsoleA
тоже не нужна, не нужны и переменные hInput
, readed
и readbuf
.
mboxhdr
и mboxtxt
— переменные, содержащие текст заголовка и текст в окне.
Функцию MessageBoxA
я уже описывал здесь (копия), так что просто переделаю ее вызов под invoke
:
_main: ;get StdOut handle invoke GetStdHandle, STD_OUTPUT_HANDLE cmp eax,0 jle exiterr mov handle, eax invoke SetConsoleTitleA, addr conshdr invoke WriteConsoleA, handle, addr message, 15, addr written, 0 invoke MessageBoxA, 0, addr mboxtxt, addr mboxhdr, MB_ICONINFORMATION jmp exit ...
Вот, что получилось: