Assembler для Windows в Visual Studio

—

от автора

В этой статье я покажу как написать приложение для windows на ассемблере. В качестве IDE будет привычная Visual Studio 2019 со своими плюшками — подсветка кода, отладка и привычный просмотр локальных переменных и регистров. Собирать приложение будет MASM, а значит, у нас будут и чисто масмовские плюшки. Приложением будет игра в пятнашки. С одной стороны это просто, но не так чтобы совсем хелловорлд (впрочем хелловорлд мы тоже увидим во время настройки VS). С другой стороны это будет полноценное оконное приложение с меню, иконкой, отрисовкой, выводом текста и обработкой мыши с клавиатурой. Изначально у меня была мысль сделать что-нибудь поинтереснее пятнашек, но я быстро передумал. Сложность и размер статьи увеличивается значительно, а она и так получилась немаленькая. Новой же информации сильно больше не становится. Статья рассчитана на тех, кто имеет хотя бы начальные знания ассемблера, поэтому здесь не будет всяких мелочей из разряда как поделить одно число на другое или что делает команда mov. Иначе объем пришлось бы увеличить раза в три.

Заранее постараюсь ответить на вопрос — зачем это нужно, если на ассемблере сейчас уже никто не пишет? Есть пара объективных причин и одна субъективная. Из объективного — написание подобных программ позволяет понять внутреннее устройство windows и как в конечном итоге наш код исполняется на процессоре. Далеко не всем это действительно надо, но для некоторых вещей это обязательное знание. Вторая причина это то, что позволяет взглянуть на разработку немного под другим углом. Примерно так же как попробовать функциональное программирование полезно даже если не писать ничего в функциональном стиле. К примеру я слушал лекции Мартина Одерски вовсе не потому что решил перейти с C# на Scala. Полезно посмотреть на привычную разработку под другим углом. Субъективная же причина — для меня это было просто интересно, отойти от коммерческой разработки, этого цикла задач, спринтов, митингов, сроков и заняться тем, что интересно именно тебе.

Так получилось что у меня появилось много свободного времени, часть из которого я потратил на то, что называется пет-проектами. Это не стало какими-то production-ready вещами, скорее какие-то идеи интересные лично мне, что-то на что вечно не хватало времени. Одна из этих идей это ассемблер в современной IDE. Давно хотел этим заняться, но все не было времени. Мне было очень интересно со всем этим разбираться, надеюсь читателям тоже понравится.

Шаг первый — настраиваем VS

Тут я немного схитрил. Точнее так уж получилось, что здесь все уже сделано за нас. Есть пошаговая инструкция и даже готовый пустой проект. Можно воспользоваться пошаговой инструкцией, а я просто скачал пустой проект и переименовал SampleASM в FifteenAsm. Единственное, что надо сделать помимо переименования, это установить SubSystem : Windows в свойствах проекта (properties > Linker > System > SubSystem : Windows). Далее выбираем x86, нажимаем F5 (либо кликаем мышкой) и видим вот такое сообщение:

Hello world asm

Hello world asm

Теперь о подсветке синтаксиса. Тут есть разные пути, и я решил поискать что есть готового. Готового оказалось немного, я установил Asm-Dude. Также попробовал ChAsm, но внешний вид меня не порадовал. Впрочем внешний вид это дело вкуса, я остановился на Asm-Dude. Тут правда есть такой нюанс — Asm-Dude не поддерживает VS 2022, самая старшая версия VS 2019. Вот так выглядит все в сборе — дебаг, просмотр переменных, в т.ч. нормальное отображение структур, мнемоника для ассемблера.

Update: AsmDude для 2022 существует, но его нет в студийном менеджере расширений (автор не стал публиковать), доступен по ссылке. Вторая версия расширения в процессе разработки, и 1 октября автор расширения ее опубликовал. Во второй версии заявлено много всяких фич, не все они готовы, но пользоваться уже можно. Спасибо @NN1 и @aroman313 за уточнение.

Теперь еще одна вещь, о которой хочется рассказать, прежде чем приступить к основной части. Это MASM SDK. Это совсем необязательная вещь, но очень полезная. Там есть готовые inc файлы для WinAPI, а еще есть много примеров самых разных приложений на ассемблере. Но проект из этой статьи будет работать и без него.

Шаг второй — оконное приложение

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

О вызове функций вообще и WinAPI в частности

Чтобы вызвать функцию, ее надо объявить. Ниже разные способы это сделать.

extern MessageBoxA@16 : PROC MessageBoxA PROTO, hWnd:DWORD, pText:PTR BYTE, pCaption:PTR BYTE, style:DWORD MessageBoxW PROTO, :DWORD,:DWORD,:DWORD,:DWORD 

Объявление со списком параметров более понятно. Хотя именовать параметры и необязательно. Объявлением с extern я пользоваться не буду, оставим это для любителей разгадывать ребусы. Что такое A(или W) в имени функции? Это указание на тип строк, A — ANSI, W — Unicode. Для простоты дела я решил не связываться с юникодом и везде использовал ANSI версии. Обычно же применяют дефайн примерно такого вида:

#ifdef UNICODE #define SetWindowText SetWindowTextW #else #define SetWindowText SetWindowTextA #endif 

Теперь о вызовах функций, «стандартный» для ассемблера вызов выглядит так

push 0 push offset caption push offset text push 0 call MessageBoxA 

Существует мнемоническое правило для порядка аргументов — слева направо — снизу вверх. Иными словами первый аргумент в объявлении функции (здесь это хендл окна hWnd:DWORD) будет в самом нижнем push. К счастью в MASM есть очень удобная вещь — invoke. Вот так выглядит вызов той же самой функции.

invoke MessageBoxA, 0, offset howToText, offset caption, 0 

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

int MessageBoxA(   [in, optional] HWND   hWnd,   [in, optional] LPCSTR lpText,   [in, optional] LPCSTR lpCaption,   [in]           UINT   uType ); 

Осталось перевести все эти HWND и LPCSTR в соответствующие типы для ассемблера. Тип данных LPCSTR будет DWORD, ведь это просто ссылка. Олдфаги с легкостью узнают венгерскую нотацию, а название типа расшифровывается как Long Pointer Const String. HWND тоже будет просто DWORD, ведь HWND, как и LPCSTR по своей сути просто ссылка. Ну а UINT это DWORD просто по определению. В некотором роде сигнатура функций на ассемблере даже проще, ссылка здесь это просто ссылка, нет кучи разных типов.
Отсюда следует важный вывод — нет никаких специальных «ассемблерных» функций, это то же самое WinAPI !. Нам достаточно знать как решается нужная нам задача средствами WinAPI, неважно на каком языке они будут вызываться. Поэтому задача «вывести текст в окно средствами ассемблера» на самом деле будет «вывести текст в окно средствами WinAPI», а уж информации по WinAPI полно. Обратное тоже верно, зная как что-то сделать средствами WinAPI это можно сделать на практически любом языке. А это уже часто бывает полезно при написании скриптов.

Создаем простое окно

Перед созданием окна я сделал три inc-файла. Один с прототипами WinAPI, другой с константами приложения (ширина окна, заголовок, цвет заливки и все в таком же духе) и третий, со структурами WinAPI и целой кучей винапишных констант. Теперь можно писать NULL или TRUE/FALSE. Или MB_OK вместо 0, как в примере выше с MessageBoxA. Никаких специфических действий не нужно, просто Add — New Item — Text File и не забываем include filename. Файлики назвал WinApiProto.inc, WinApiConstants.inc, AppConstants.inc. Пример содержимого показан ниже.

Вот так теперь выглядит наш код

.386 .model flat, stdcall .stack 4096  include WinApiConstants.inc include WinApiProto.inc  .data  include AppConstants.inc  .code main PROC ;...more code 

Небольшое отступление про строки. Вот пример строковых констант

szClassName             db "Fifteen_Class", 0 howToText               db "The first line", CR , LF , "The second.", 0 

Запятая означает конкатенацию, db это define byte, CR LF определены в WinApiConstants.inc (13 и 10 соответственно), ноль на конце это null-terminated строка. В итоге строки это никакой не специальный тип данных, а просто массивы байт с нулем на конце. В случае с юникодом возни было бы больше, но я решил не усложнять себе жизнь и использовать везде ANSI строки.

Вот мы и добрались до создания окна. Для этого нам надо

  • заполнить структуру WNDCLASSEX (объявлена в WinApiConstants)

  • зарегистрировать класс окна

  • создать процедуру главного окна

  • создать окно

Кода вышло уже почти на 200 строк, поэтому я покажу самые интересные куски, целиком можно посмотреть на гитхабе.
Объявление и заполнение WNDCLASSEX, как видим все как в языках высокого уровня. Ну, почти все — автодополнения со списком полей структуры нет.

WNDCLASSEX STRUCT   cbSize            DWORD ?   style             DWORD ?   lpfnWndProc       DWORD ? WNDCLASSEX ENDS mov wc.cbSize,         sizeof WNDCLASSEX mov wc.style,          CS_BYTEALIGNWINDOW mov wc.lpfnWndProc,    offset WndProc 

При создании окна весьма важный параметр WS_EX_COMPOSITED. Без него при перерисовке будет мерзкий flickering. Очень хорошо что это работает — реализовывать двойную буферизацию самостоятельно желания не было.

push WS_EX_OVERLAPPEDWINDOW or WS_EX_COMPOSITED call CreateWindowExA 

Теперь немного чудесных директив MASM. Вот так вот просто организован цикл обработки сообщений

; Loop until PostQuitMessage is sent .WHILE TRUE     invoke GetMessageA, ADDR msg, NULL, 0, 0     .BREAK .IF (!eax)     invoke TranslateMessage, ADDR msg     invoke DispatchMessageA, ADDR msg .ENDW 

А вот так без них

StartLoop:   push 0   push 0   push 0   lea eax, msg   push eax   call GetMessageA    cmp eax, 0   je ExitLoop    lea eax, msg   push eax   call TranslateMessage    lea eax, msg   push eax   call DispatchMessageA    jmp StartLoop ExitLoop: 

А вот как все просто в оконной процедуре. Никаких тебе cmp uMsg, WM_DESTROY, кучи меток, простой IF

.IF uMsg == WM_DESTROY     invoke PostQuitMessage, NULL     xor eax, eax     ret .ENDIF 

Вот как делается подтверждение на закрытие окна

.IF uMsg == WM_CLOSE     invoke MessageBoxA, hwin, ADDR exitConfirmationText, ADDR caption, MB_YESNO     .IF eax == IDNO         xor eax, eax         ret     .ENDIF .ENDIF 

Обещанный хелловорлд готов.

Добавляем иконку и меню

Иконка и меню в мире windows относятся к ресурсам. Поэтому добавляем к нашему проекту файл ресурсов — Add — Resource — Menu. Дальше можно воспользоваться встроенным редактором VS, я просто взял и отредактировал свежий файл FifteenAsm.rc в блокноте. Получилось вот так

500 ICON MOVEABLE PURE LOADONCALL DISCARDABLE "FIFTEENICON.ICO"  600 MENUEX MOVEABLE IMPURE LOADONCALL DISCARDABLE BEGIN     POPUP "&File", , , 0         BEGIN         MENUITEM "&New Game", 1100         MENUITEM "&Exit", 1000     END     POPUP "&Help", , , 0     BEGIN         MENUITEM "&How to play", 1800         MENUITEM "&About", 1900     END END 

Обратите внимание на магические числа 500 и 600. Это идентификаторы ресурсов, совсем скоро мы увидим зачем они нужны. Также обратите внимание на магические числа 1000, 1100, 1800, 1900. Это идентификаторы команд, мы тоже увидим зачем они нужны, но чуть позже. Чуть не забыл про сам файл иконки, нарисовал я ее в каком-то онлайн редакторе. Дизайнер из меня так себе, поэтому что получилось, то получилось. Добавляем в проект под именем Fifteenicon.ico, тут главное назвать точно как в файле ресурсов. Дальше все просто. Иконка добавляется на этапе заполнения структуры WNDCLASSEX, тут у нас магическое число 500

push 500 push hInst call LoadIconA mov wc.hIcon, eax 

Меню добавляется после создания окна, здесь магическое число 600

call CreateWindowExA  mov hWnd,eax  push 600 push hInst call LoadMenuA  push eax push hWnd call SetMenu 

А вот так обрабатываются команды меню, тут остальные магические числа 1000, 1100, 1800, 1900. Вообще с использованием MASM код не особо отличается от кода на тех же плюсах.

.IF uMsg == WM_COMMAND      .IF wParam == 1000         invoke SendMessageA, hwin, WM_SYSCOMMAND, SC_CLOSE, NULL     .ENDIF      .IF wParam == 1100         invoke MessageBoxA, hwin, ADDR newGameConfirmationText, ADDR caption, MB_YESNO         .IF eax == IDYES             ;call InitTilesData         .ELSEIF eax == IDNO             xor eax, eax             ret         .ENDIF     .ENDIF      .IF wParam == 1800         invoke MessageBoxA, hwin, ADDR howToText, ADDR caption, MB_OK     .ENDIF      .IF wParam == 1900         invoke MessageBoxA, hwin, ADDR aboutText, ADDR caption, MB_OK     .ENDIF  .ENDIF 

У приложения появилась иконка и есть меню. Ради интереса посмотрел на размер исполняемого файла, всего 6656 байт.

Нажали на New Game

Нажали на New Game

Шаг третий — игра

Создали окно, пора заняться самой игрой. Здесь я тоже покажу только самые интересные места.

Инициализация данных и начальная перетасовка

Данные о положении тайлов будут храниться в массиве из 16 байт. Ноль будет положением пустого тайла, значения от 1 до 15 соответствующие тайлы. Нумерация индексов тайлов слева направо, сверху вниз. Теперь надо их перетасовать и тут встает вопрос, откуда брать случайные числа? RDRAND и RDSEED появились достаточно поздно, а мне хотелось сделать код в «классическом» стиле. Сгоряча я даже думал реализовать Вихрь Мерсенна, но потом решил что это перебор. Поэтому честно нашел простенький ГПСЧ буквально в десяток команд, для seed использовал системное время. Идея начальной перетасовки простая, сначала заполняем массив по порядку (приводим в конечное состояние), а потом случайным образом двигаем тайлы. Тайлы двигаются по правилам, значит их всегда можно будет собрать в конечное положение. Если заполнять тайлы совсем рандомно, то надо проверять можно ли вообще собрать такую комбинацию. По опыту уже 100 итераций перемешивает тайлы вполне нормально.

    local randSeed : DWORD      invoke GetTickCount     mov randSeed, eax      xor eax, eax     xor ebx, ebx      xor ebx, ebx     .WHILE ebx < initialSwapCount                  mov eax, 4; random numbers count, i.e. from 0 to 3         push edx         imul edx, randSeed, prndMagicNumber         inc edx         mov randSeed, edx         mul edx         mov eax, edx         pop edx          add al, VK_LEFT         push ebx         invoke ProcessArrow, NULL, al; move a tile         pop ebx          inc ebx     .ENDW      ret 

Отрисовка

Добавляем обработку WM_PAINT в оконной процедуре

LOCAL Ps     :PAINTSTRUCT LOCAL hDC    :DWORD  .IF uMsg == WM_PAINT     invoke BeginPaint, hWin, ADDR Ps       mov hDC, eax       invoke PaintProc, hWin, hDC     invoke EndPaint, hWin, ADDR Ps .ENDIF 

Отрисовка тайлов. Из интересного здесь организация двойного цикла с использованием директив MASM WHILE и передача указателя на RECT в процедуре CalculateTileRect.

    LOCAL Rct       : RECT     invoke CreateSolidBrush, tileBackgroundColor     mov hBrush, eax     invoke SelectObject, hDC, hBrush      ;fill tiles with background color     mov vert, 0     .WHILE vert < 4         mov hor, 0         .WHILE hor < 4                          invoke CalculateTileRect, ADDR Rct, hor, vert             invoke RoundRect, hDC, Rct.left, Rct.top, Rct.right, Rct.bottom,  tileRoundedEllipseSize, tileRoundedEllipseSize                      inc hor         .ENDW         inc vert     .ENDW      invoke DeleteObject, hBrush  CalculateTileRect proc rct :DWORD, hor:BYTE, vert:BYTE      mov edx, rct      invoke CalculateTileRectPos, hor, 0     mov (RECT PTR [edx]).left, eax      ret CalculateTileRect endp 

Обратите внимание на эту строчку. Структура передана по ссылке, смещение на left вычисляется автоматически.

mov (RECT PTR [edx]).left, eax 

А вот как работает IntToStr (почти что честный) на ассемблере. Писать честный IntToStr мне не хотелось, поэтому я тут схитрил. Завел массив из 3 байт под строку, второй и третий байты сразу обнуляются. Числа бывают от 1 до 15, поэтому если число было меньше 10, то к значению прибавляем магическое число 48 (ASCII код для нуля) и получаем нужный первый байт буфера. Получается тоже самое что и на Си, когда пишем c = ‘0’ + i. Поскольку второй байт уже нулевой у нас получается готовая null-terminated строка, неважно что буфер из 3 байт. Если число больше 9, то первая цифра всегда 1, а вторая это остаток от деления на 10. Тут уже третий байт играет роль конца строки.

mov [buffer+1], 0 mov [buffer+2], 0  .IF bl < 10   add bl, asciiShift   mov [buffer], bl   sub bl, asciiShift .ELSEIF bl > 9   mov al, asciiShift   inc al   mov [buffer], al    xor ax, ax   mov al, bl   mov cl, 10   div cl   add ah, asciiShift   mov [buffer+1], ah .ENDIF 

Вот так выглядит игровое поле

Добавляем интерактив

Для управления можно пользоваться курсором или кликать мышкой по тайлу, который надо переместить, благо вариант перемещения только один. Перемещение сводится к тому чтобы в массиве тайлов поменять местами перемещаемый и нулевой тайл. Смещение нулевого тайла будет +1/-1 для перемещений вправо/влево и +4/-4 для перемещения вверх/вниз. Путь у тайла только один, поэтому надо только проверить выход за диапазон и поменять местами два элемента в массиве тайлов. Если тайл переместился, то перерисовать окно. Добавим вот такие обработчики в нашу оконную процедуру.

.IF uMsg == WM_KEYDOWN     .if wParam == VK_LEFT         invoke ProcessArrow, hWin, wParam     .elseif wParam == VK_RIGHT         invoke ProcessArrow, hWin, wParam     .elseif wParam == VK_UP         invoke ProcessArrow, hWin, wParam     .elseif wParam == VK_DOWN         invoke ProcessArrow, hWin, wParam     .endif .ENDIF  .IF uMsg == WM_LBUTTONUP     invoke ProcessClick, hWin, lParam .ENDIF 

Сначала посмотрим как реализовано перемещение тайлов курсором. Вот немного укороченная версия процедуры ProcessArrow. FindEmptyTileIndex возвращает в регистре eax индекс пустого тайла . В зависимости от нажатой клавиши проверяем выход за границы диапазона, т.е. можно ли переместить тайл в данной позиции в данном направлении. Если нельзя, уходим на метку pass в конец процедуры, если можно, то вызываем последовательно SwapTiles, RedrawWindow и ProcessPossibleWin.

ProcessArrow proc hWin:DWORD, key:DWORD      call FindEmptyTileIndex      .IF key == VK_UP         cmp eax, 12         ja pass          ;when tile goes up, new empty tile index (ETI) will be ETI+4,         mov ebx, eax         add ebx, 4     .ENDIF      .IF key == VK_RIGHT         ;empty tile shouldnt be on 0, 4, 8, 12 indexes         cmp eax, 0         je pass         cmp eax, 4         je pass         cmp eax, 8         je pass         cmp eax, 12         je pass          ;when tile goes right, new empty tile index (ETI) will be ETI-1,         mov ebx, eax         dec ebx     .ENDIF      invoke SwapTiles, eax, ebx     .IF hWin != NULL ;little trick to simplify initial random data         invoke RedrawWindow, hWin, NULL, NULL, RDW_INVALIDATE         invoke ProcessPossibleWin, hWin     .ENDIF      pass:     ret ProcessArrow endp 

Для перемещения тайла от кликов мышью нужно понять по какому тайлу кликнули и проверить, можно ли его перемещать. Для этого в цикле (двойной цикл организован через директиву MASM .WHILE) вызываем CalculateTileRect и проверяем находится ли курсор мыши внутри прямоугольника. Принцип проверки тот же, что и в ProcessArrow — cmp в ряд, только команды условного перехода другие. Внутри ProcessArrow je (jump equal), а тут ja jb (jump above jump below). Дальше все тоже самое что и с курсором, только наоборот. Смотрим разницу между индексами пустого и кликнутого тайла и вызываем процедуру ProcessArrow (наверное не самое удачное название) с нужными аргументами. Сокращенная версия процедуры.

ProcessClick proc hWin:DWORD, lParam:DWORD     local rct : RECT      movsx ebx, WORD PTR [ebp+12] ; x coordinate     movsx ecx, WORD PTR [ebp+14] ; y coordinate      mov vert, 0     .WHILE vert < 4         mov hor, 0         .WHILE hor < 4                          invoke CalculateTileRect, ADDR Rct, hor, vert                          cmp ebx, Rct.left             jb next             cmp ebx, Rct.right             ja next             cmp ecx, Rct.top             jb next             cmp ecx, Rct.bottom             ja next                  ; the idea is that tile can be moved only if there is a particular diff ; between its index and empty tile index ; -1, +1 ,-4, +4 for different directions, similar to ProcessArrow proc                 call FindEmptyTileIndex                  .IF index > al                     sub index, al                     .IF index == 1                         invoke ProcessArrow, hWin, VK_LEFT                     .ELSEIF index == 4                         invoke ProcessArrow, hWin, VK_UP                     .ENDIF                 .ENDIF          next:          inc hor         .ENDW         inc vert     .ENDW      ret ProcessClick endp 

Вспомогательные процедуры типа проверки на окончание игры, или смены местами значений в массиве я приводить не буду, т.к. они банальны, а статья и так разрослась. Теперь, когда все готово, в итоге получилось 587 строк в Main.asm и 8192 байта исполняемый файл. Размер екзешника меня приятно порадовал — 8 килобайт это и для прежних времен немного, а сейчас и подавно. Полный код приложения можно увидеть в гитхабе.

В итоге получилась вот такая красота

В итоге получилась вот такая красота

Заключение

Наша игра готова. Мы увидели как это делается в привычной IDE, узнали откуда брать сигнатуры и как вызывать функции WinAPI, поняли что надо сделать чтобы создать полноценное оконное приложение, использовали директивы MASM для упрощения кода. Хоть я никогда и не использовал ассемблер в коммерческой разработке, но интерес к нему был с юных лет. Начиная с изучения ассемблера для Z80, знаменитого Спектрума и его многочисленных клонов. Писать пусть и очень простую, но полноценную игру на ассемблере мне по-настоящему понравилось. Надеюсь читателям тоже было интересно!


ссылка на оригинал статьи https://habr.com/ru/articles/763636/