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

Теперь еще одна вещь, о которой хочется рассказать, прежде чем приступить к основной части. Это 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, знаменитого Спектрума и его многочисленных клонов. Писать пусть и очень простую, но полноценную игру на ассемблере мне по-настоящему понравилось. Надеюсь читателям тоже было интересно!

Комментарии (58)


  1. CoolCmd
    26.09.2023 19:32
    +1

    в 23-м году писать 32-битную прогу, да без юникода...


    1. develmax
      26.09.2023 19:32
      +1

      да в VS 2019


    1. lorc
      26.09.2023 19:32
      +1

      И только под x86...


      1. CoolCmd
        26.09.2023 19:32
        -4

        я не против написания кода на ассемблере, всякое бывает. просто человек потратил немало времени на написание статьи, которая фактически уже устарела.


        1. PuerteMuerte
          26.09.2023 19:32
          +14

          просто человек потратил немало времени на написание статьи, которая фактически уже устарела

          А почему устарела? Да, в реальной жизни вам скорее всего не придётся писать пятнашки на ассемблере под винду, но тем не менее, и сейчас есть немало ИТшных направлений, где хотя бы базовое понимание ассемблера поможет решать сложные проблемы. Не все же на тайпскрипте и пайтоне пишут :)


          1. unreal_undead2
            26.09.2023 19:32
            +2

            базовое понимание ассемблера

            Это действительно полезно. Но статья о конкретике кодинга в VS2019 под 32-бинтую винду, а по работе может понадобиться сделать, скажем, маленькую вставку в gcc на Aarch64 )


            1. Gorthauer87
              26.09.2023 19:32
              +2

              Так а в чем проблема? Базовые знания общие, как раз асм проще учить на старом добром x86 cisc


              1. unreal_undead2
                26.09.2023 19:32
                +4

                В статье больше про IDE и работу с Win32 API. Реально на ассемблере сейчас скорее пишется самодостаточный код, который ничего не вызывает. И чтобы поиграться - на мой взгляд проще скомпилировать сишный код через gcc -S и дальше модифицировать сгенерённый ассемблер.

                Полезно знать про регистры, calling convention, режимы адресации, атомики и разные подходы к ним на разных архитектурах, SIMD - но ничего этого нет.


        1. Arip_1990
          26.09.2023 19:32
          +1

          не в курсе насколько она устарела, но статья хорошая. Как раз хотел изучить ассемблер, а тут прога с интерфейсом и думаю не я один такой))


          1. zartarn
            26.09.2023 19:32
            +2

            1. Arip_1990
              26.09.2023 19:32

              Спасибо, буду знать))


        1. piton_nsk Автор
          26.09.2023 19:32
          +10

          То, что тут показано, это, конечно, не bleeding edge, но совсем устарелым я б не назвал. Если бы я написал что-нибудь про TASM, far/near и прочий int 21h, тогда да. Про юникод отступление я сделал в статье, наверное надо было подробнее написать, но объем статьи и так вышел намного больше изначального замысла, чем-то пришлось пожертвовать. Но если кратко, то использование юникода ничего принципиально не меняет.

          MOP_NABLA                   EQU 2207h
          MOP_DOT                     EQU 22c5h
          CAP_BETA                    EQU 0392h
          
          englishCaption              db "Hello, world", 0
          russianCaption              dw 0041fh, 00440h, 00438h, 00432h, 00435h, 00442h, 0002ch, 00020h, 0043ch, 00438h, 00440h, 0; Привет, мир
          maxwellEquationText         dw MOP_NABLA,MOP_DOT,CAP_BETA," ", "=", " ","0",0
          maxwellCaption              dw "M", "A", "X", "W", "E", "L", "L", 0

          Вот и вся разница. Как именно конвертировать строки, вот на эту тему можно написать статью.

          Почему использовал 32-х разрядный ассемблер. Одной из целей было показать как использование директив MASM упрощает жизнь. А в 64-х разрядном MASM нету ни invoke, ни if, ни while. Вообще говоря это проблема решаемая, но это опять же можно целую статью написать только про это. Также calling convention изменился, нужны еще определенные приседания с прологом/эпилогом. Тут опять для простоты пришлось срезать углы. Я хотел показать как можно писать обычные windows-приложения без всяких лишних приседаний, установок и прочего и делать это в привычной многим IDE.


          1. CoolCmd
            26.09.2023 19:32

            об этом я и говорил. захочет кто-нибудь на основании этой статьи написать полезный код под актуальную конфигурацию железо/ПО, и не сможет это сделать, потому что в masm не директив (чем fasm не угодил?), эпилог как писать не знает, параметры в функции передаются по-другому, и т.д. да, будет сложнее, но люди сразу будут понимать объем работы, который им придется проделать.


          1. domix32
            26.09.2023 19:32

            Почему не взять тогда fasm? Тот имеет возможность писать platform agnostic код и имеет кучку пахожих плюшек. Ещё и строки паскалевы.


            1. piton_nsk Автор
              26.09.2023 19:32
              +2

              Ассемблеров много есть разных, это один из вариантов. Здесь фишка в том, что используется привычная IDE. С привычным дебагом в том числе. И все это идет из коробки и работает без проблем.

              А если взять фасм, там с одним редактором приключений хватит. Фасмовский родной, ну это такое. В masm sdk куча примеров, причем нормально оформленных, а в фасме чуть больше десятка.

              Разумеется и на фасме кто-то пишет, но статья не про фасм и не про ассемблер вообще, а про конкретный вариант использования.


    1. SurdLen
      26.09.2023 19:32

      Обратная совместимость AMD64 <- IA32? Нет, не слышали.


  1. NN1
    26.09.2023 19:32
    +5

    Уже есть для 2022: AsmDude 2022


    1. piton_nsk Автор
      26.09.2023 19:32
      +2

      Спасибо! Я занимался настройками IDE где-то в начале лета, тогда еще не было.


  1. KILYAV
    26.09.2023 19:32

    MASM лже пророк, только в ARM наше спасение.


  1. Myclass
    26.09.2023 19:32
    +1

    Спасибо за статью! В обучающих целях - самое то. Для своих студентов обязательно возьму кое-какие инфы из вашей статьи. В ассемблере в 90-х писал, но не под Виндовсом. С интересом открыл для себя передачу параметров для вызываемой функции через стек. Никогда об этом раньше не думал. Только через регистры, а через стек удобнее, хоть и с риском для ошибок.

    Смена перспектив всегда нужна. Когда человек разносторонне развит - и возникшие проблемы решает по-разному.


    1. dyadyaSerezha
      26.09.2023 19:32
      +8

      Как раз через стек, это классика. А через регистры, это уже оптимизация.


      1. Myclass
        26.09.2023 19:32

        Как раз через стек, это классика. А через регистры, это уже оптимизация.

        Может быть вы и правы. Всё таки почти 30 лет прошло. Видать настолько всё забыл, что воспринимаю как новую вещь.


      1. unreal_undead2
        26.09.2023 19:32
        -1

        Сейчас скорее регистры (когда хватает) - стандарт, а стек - legacy на древних платформах типа 32-битного Интела )


        1. dyadyaSerezha
          26.09.2023 19:32

          Любой внешний API — только через стек. Включая тот же WinAPI.


          1. pfemidi
            26.09.2023 19:32
            +2

            Это справедливо для WinAPI x86, а в WinAPI x86-64 немного по другому:


            The Microsoft x64 calling convention is followed on Windows and pre-boot UEFI (for long mode on x86-64). The first four arguments are placed onto the registers. That means RCX, RDX, R8, R9 (in that order) for integer, struct or pointer arguments, and XMM0, XMM1, XMM2, XMM3 for floating point arguments. Additional arguments are pushed onto the stack (right to left). Integer return values (similar to x86) are returned in RAX if 64 bits or less. Floating point return values are returned in XMM0. Parameters less than 64 bits long are not zero extended; the high bits are not zeroed.


    1. moooV
      26.09.2023 19:32
      +1

      Под досом в реальном режиме всегда через стек было ????


      1. pfemidi
        26.09.2023 19:32
        +2

        mov ah,4ch
        mov al,[exitCode]
        int 21h


        Это разве через стек? Да, дос, да, реальный режим.


        1. rwscar
          26.09.2023 19:32
          +3

          Думаю, имелся в виду вызов обычной функции (call), а не прерывание


        1. moooV
          26.09.2023 19:32
          +1

          Это сисколл, не вызов функции.

          Обычно делали примерно так:

          lea dx, msg
          push dx
          push 0
          call printMessage
          

          А внутри printMessage уже форматирование и вызов инт сисколла с передачей через AX как вы описали.


          1. pfemidi
            26.09.2023 19:32
            +1

            В досе? Нет, в досе, да и в биосе так никогда не делали если программа писалась именно на ассемблере, а не на каком-нибудь языке высокого уровня. Языки высокого уровня передавали через стек, да, а на ассемблере это выглядело бы как-то так:


            mov si, offset msg
            call printMessage
            ...
            printMessage:
            cld
            loop:
            lodsb
            test al,al
            jz exitLoop
            call printChar
            jmp loop
            exitLoop:
            ret
            ...
            printChar:
            mov ah,0eh
            int 10h
            ret


            То есть всё через регистры. Вот если регистров нехватало, тогда да, передавали через стек или через какие-нибудь локальные переменные.


            Я с 1987 года по 1993 год программировал преимущественно на ассемблере и исключительно под дос, так что знаю о чём говорю.


            1. moooV
              26.09.2023 19:32

              В универе нас учили именно через стек передавать. Передача через регистры - это уже более новое и идет рука об руку с flat моделью, 32 битным защищенным режимом и виндой.

              Ну и писали пару лет мы портянки со стенами пушей, после чего считали смещение и делали ret N

              Но у меня нет опыта именно коммерческого программирования на асме (за исключением асм вставок на первой работе) - именно всякое во время универа (и это середина нулевых была).


              1. pfemidi
                26.09.2023 19:32
                +2

                Честно скажу, не знаю как и чему сейчас учат в универе :-) Я получал свою практику программирования на ассемблере в своё время путём изучения дизассемблированых BIOS, DOS и первых вирусов для DOS, и везде там передача была именно через регистры. Через стек параметры передавались только в языках высокого уровня и много позже когда появились первые Windows (ещё не как самостоятельные OS, а как оболочки для DOS) и соответственно WinAPI. Так что я перенял способ передачи параметров через регистры из "взрослых" программ и меня удивляло и поражало в первых Windows — зачем передавать параметры через стек, когда всю жизнь передавали через регистры? Ведь когда пишешь на ассемблере передача параметров через регистры и быстрее, и удобнее! Только после дошло что это для совместимости с HLL того времени в которых никаких __fastcall в то время ещё не было, всё шло тупо через стек, в C справа налево и с очисткой стека после вызывателем, а в паскале слева направо и с очисткой стека вызываемым.


                В середине нулевых да, передача параметров была уже преимущественно через стек, на flat модели, 32 битах и винде. А на голом DOS, на голом ассемблере параметры старались по возможности всегда передавать в регистрах.


              1. pvvv
                26.09.2023 19:32

                https://en.wikipedia.org/wiki/X86_calling_conventions#List_of_x86_calling_conventions

                даже для 8086 есть варианты с передачей через регистры, но это лишь некие соглашения.

                никто же не мешает упороться и для вызова своей функции, например, выделить где-нибудь память, сложить туда аргументы и адрес возврата, а указатель положить по какому-нибудь абсолютному адресу 0xDEADBEEF не задействуя для этого ни регистры ни стэк.


  1. malishich
    26.09.2023 19:32

    FASM гораздо лучше, и главное IDE монструозную не надо ставить.


    1. piton_nsk Автор
      26.09.2023 19:32

      Ассемблеров много хороших и всяких разных, тут, конечно, частный случай.


    1. piton_nsk Автор
      26.09.2023 19:32

      Кстати говоря, что используете в качестве IDE для FASM? Раньше там в комплекте шло что-то типа блокнота, но это была жуть.


  1. dyadyaSerezha
    26.09.2023 19:32
    +2

    Интересно бы сравнить размер проги на ASM и на С. Сдаётся мне, что будет одинаковый.

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


    1. Leetc0deMonkey
      26.09.2023 19:32
      +2

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

      Так вся функциональность там это чисто вызовы Win32 API. Если не тащить за собой всякие CRT, поставить свой EntryPoint и т.д., то очень хорошо размер сокращался.


      1. dyadyaSerezha
        26.09.2023 19:32

        Это хорошо пишется словами, но трудно делается в реале. Одни графики в реальном времени чего стоят.


        1. LeetcodeMonkey
          26.09.2023 19:32
          +1

          Что трудно делается в реале? Вызовы Win32 API? Это элементарно. Другое дело - что вызывать и как вызывать. Плюс было задействовано много недокументированных секретов. За что и ценили.

          Графики в реальном времени что? Я сам студентом во времена когда ещё назывались ntinternals рисовал график скорости передачи данных по COM-порту.


          1. dyadyaSerezha
            26.09.2023 19:32

            Все сами рисовали графики. Я про то, что там очень много функционала для такого маленького размера утилит. И далеко не только вызовы WinAPI.


            1. LeetcodeMonkey
              26.09.2023 19:32
              +1

              Да нет, бинарный код непосредственно функционала никогда много не занимал. Экзешник всегда раздували сторонние либы, графические ресурсы (один БМПшник несжатый для красивой кнопки чего мог стоить). И даже если поиграться просто с выравниванием порой можно было много сэкономить. Типичному разрабу всё это нафиг не нужно было конечно. Если только целенаправленно заниматься, со знанием дела.


  1. aroman313
    26.09.2023 19:32
    +1

    AsmDude Visual Studio 2022 -

    https://github.com/HJLebbink/asm-dude/files/7822110/AsmDude-vs2022.zip

    Некоторое может не работать. Есть очень давно. Скомпилировано автором

    Как по мне, работает отлично


    1. piton_nsk Автор
      26.09.2023 19:32

      Через студийный менеджер расширений недоступно, видимо для 2022 только руками. Надо поразбираться.


  1. acordell
    26.09.2023 19:32
    +2

    Спасибо огромное! Шикарная статья! Последний раз на языке ассемблера писал лет двадцать назад, но самые мои приятные воспоминания от профессии связаны именно с ним.


  1. Jianke
    26.09.2023 19:32
    +1

    А в 64-биное приложение на C/C++ как встроить ассемблер?


    1. unreal_undead2
      26.09.2023 19:32
      +3

      Если пользоваться микрософтовскими тулами - то отдельный исходник на MASM, реализующий вызываемые из C/C++ функции; насколько помню, поддержку инлайн ассемблера при компиляции под Win64 убрали. В gcc/clang можно и инлайн вставки делать.


    1. AndreyDmitriev
      26.09.2023 19:32
      +1

      Ну если в самом простейшем случае, то через DLL. Надо знать соглашение о вызовах, чтобы передать параметры, разумеется.

      минимальный C++ будет где-то так:

      #include <print>
      
      #pragma comment( lib, "ASM" )
      extern "C" int fnAsm(int a, int b);
      
      int main(int argc, char* argv[])
      {
      	int res = fnAsm(2, 3);
      	std::println("2 + 3 = {}", res);
      }

      А код на ассемблере для библиотеки ASM.dll примерно такой:

      EXPORT fnAsm
      fnAsm PROC ; Calculate a + b
      	mov eax, ecx ;eax = a
      	add eax, edx ;eax = a + b
      	ret 	     ;return result to caller
      ENDP fnAsm

      Я, кстати, "открыл" для себя EuroAssembler, горячо рекомендую - штука простая как пять копеек, но очень удобная и легковесная, на мой взгляд.

      Ещё есть книжка - Даниэль Куссвюрм - Профессиональное программирование на ассемблере x64 с расширениями AVX, AVX2 и AVX-512:

      Там не всё идеально, но для начала - очень неплохо. В принципе можно и не покупать, а просто примерами с гитхаба обойтись.


  1. HemulGM
    26.09.2023 19:32
    +5

    А если писать на Делфи, будет считаться?)


    1. unreal_undead2
      26.09.2023 19:32
      +3

      В начале 90х многие знакомые писали под ДОС на Турбо Паскале в таком стиле )


      1. toxicdream
        26.09.2023 19:32
        +1

        Можно ещё вспомнить библиотеку Turbo Vision, которая оборачивала вызовы к Windows 16-bit.

        Можно было получить 16-битные программы с окошком для Windows 1 - Windows 3.11, но из-за обратной совместимости запускались и в 32-битных Windows и выглядели как родные.


        1. unreal_undead2
          26.09.2023 19:32
          +1

          Когда я видел Turbo Vision, она работала в текстовом режиме MS DOS.


          1. toxicdream
            26.09.2023 19:32

            А, память подводит.

            Смешались вспоминания про Turbo Pascal for Windows и Turbo Assembler, и почему-то в памяти всплыл Turbo Vision


  1. mokhin-denis
    26.09.2023 19:32
    +1

    Спасибо за интересную статью. +1 статье, +1 звезда проекту на гитхабе


  1. splasher
    26.09.2023 19:32

    Про ассемблер всегда интересно, но вот писать на нем прогу с графическим интерфейсом для виндов - это за рамками его практического применения совсем. Какой-нить более прагматичный кейс был бы уместен... Плюсую все равно)


    1. piton_nsk Автор
      26.09.2023 19:32
      +1

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


  1. kmatveev
    26.09.2023 19:32
    +2

    Ну неплохо. Я вброшу пару советов:

    1. Искать клетку, в которую кликнули мышкой, путём вычисления координат прямоугольников для клеток в цикле - сойдёт для начала, но как-то неэлегантно, что ли. Я бы сделал наоборот, преобразованием в клеточные координаты, и потом вычислнением индекса клетки.

    2. В коде совместно (вперемешку) используются макросы .IF и инструкции cmp, выглядит странно. Вы в начале статьи задаётесь вопросом: зачем писать на ассемблере, и даёте на него риторические ответы. Мой ответ другой: иногда нужно хакнуть или отреверсить программу, и для этого приходится читать дизассемблированный код, и чтобы его понимать, нужно натренировать глаз читать ассемблер. Для такой тренировки лучше не пользоваться макросами.

    3. Я поглядел код на гитхабе, есть странности. В процедуре отрисовки зачем-то два цикла по клеткам, достаточно было бы одного. И точно не нужно для каждой клетки вызывать CreateFontIndirectA с последующим DeleteObject, шрифт лучше проинициализровать на старте программы. С brush-ами я бы тоже посоветовал так же поступить.

    4. Процедура CalculateTileRect сделана не очень. Я бы из CalculateTileRectPos убрал бы второй параметр additionalTileSize. Тогда из CalculateTileRect можно будет сделать только два вызова CalculateTileRect, а не четыре, как сейчас, и добавлять вот этот additionalTileSize только для нижней и правой граней.


    1. piton_nsk Автор
      26.09.2023 19:32

      С советами соглашусь, код особо не вылизывал, тем более что на ассемблере что-то писал больше 10 лет назад, да и то для развлечения. Там в паре мест регистры портятся в процедурах, массив для тайлов было бы проще сделать из двойных слов вместо байт, проталкивания hwnd по всем процедурам можно избежать, именование не причесано к единому виду, всякие about-окна сделаны через MessageBox, а не полноценные окна. Для улучшений место есть.

      Момент со смешиванием .IF и cmp есть, я прямо долго думал и никак не мог решить в каком стиле все это делать. С одной стороны я хотел показать как масмовские директивы упрощают жизнь, с другой стороны если все писать с их помощью, как наглядно показать разницу? Поэтому кое-где осталось в смешаном стиле.