Консольный hello, world на MASM32. Трактат с подробным разбором, лирическими отступлениями и дополнениями

Преамбула

Итак, обещал рассказать, как все-таки написать консольный Hello, world на ассемблере, и выбрал для этого MASM, потому что TASM’овский компилятор довольно давно полумертв, и не умеет из коробки некоторых базовых вещей, например, не умеет указывать подсистему (это изменение одного поля в заголовке PE-файла), в которой должна работать программа под Win32. Подсистем есть несколько, но нас пока интересуют две: WINDOWS и CONSOLE. Первая, для оконных приложений, а вторая — для консольных. Если ОС будет видеть, что приложение консольное, она проверит, открыта ли для него консоль уже (если мы запускаем приложение через cmd, или Far manager, например), и откроет для него консоль, если мы запускаем приложение из оконной среды, щелкая по экзешнику мышкой. Это избавляет нас от геморроя, вручную контролировать консоль, открывать ее, закрывать, освобождать. Хотя, принципиальных ограничений нет — консольное приложение спокойно может открыть, как стандартное диалоговое окно, так и вообще создать полноценную форму, а оконное приложение — открыть консоль.

Простейший консольный hello, world на MASM

В принципе, для простейшего случая, где не используются всякие дополнительные плюшки 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

Заморочился, специально перекомпилировал экзешник с другими параметрами, чтобы показать, как срабатывает единственный условный переход и проверка результата, возвращаемого 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

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

Конечно, даже макросы, предопределенные структуры и специфический синтаксис 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

Вот, что получилось:

Исходник целиком (на GitHub)

Компилируем со следующими параметрами

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

...

Исходник целиком на GitHub

Вот, что получилось:

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

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