В статье вы узнаете как сделать маленькие программы для MS-DOS на ассемблере, я покажу как рисовать 2D графику напрямую в видео-буфер. Может быть, вы даже вдохновитесь на создание собственного демо, которое будет ставить рекорды по размерам исполняемого файла.
INIT
Демосцена удивляет не только эффектными программами выжимающими максимум из маломощных платформ, но и ультра-маленькими исполняемыми файлами. На сайте Pouet.net можно найти программы размером не более 32 байт и большинство из них написаны под ОС MS-DOS, В некоторых демках даже играет звук!
На гифке выше изображён океан с движущимися волнами и бликами на воде. Вся эта красота занимает всего 32 байта. Это настолько маленькая программа, что эта гифка весит в 30 раз больше неё и из этой демки даже нельзя выйти, потому что автор демки убрал такую возможность, ибо это заняло бы ещё несколько лишних байтиков на диске. Также есть бонусная версия этой программы под названием OCESC64, она весит 64 байта и из неё даже можно выйти нажав Escape, а также в неё добавлен шум моря.
Для запуска этих программ вам понадобится любой эмулятор Доса, подойдёт даже DOSbox. В этом гайде я расскажу как компилировать программы для этой системы используя Windows/Linux или как сделать это прямо внутри DOS. Эксперименты можно ставить даже на телефоне с эмулятором.
Почему эти программы такие маленькие?
DOS умеет запускать .exe файлы и .com файлы, во вторых-то и кроется секрет малого размера. Дело в том, что бинарники .exe (или elf в Linux) содержат в себе не только код программы но и хедер с дополнительными функциями, без которых программа не сможет запускаться и корректно завершаться. Хотя exe или elf файл можно написать вручную, побайтово заполнив хедер по всем канонам и добиться минимального размера программы, но это всё будет долго, больно и всё равно выйдет больше по размеру чем .com программа для Доса. Формат .com программ настолько прост, что не содержит в себе ничего кроме инструкций самой программы - всё будет весить ровно столько, сколько весят сами ассемблерные команды и даже здесь есть трюки, которые позволят уменьшить размер бинарника, но об этом позже.
Начинаем кодить на ассемблере под DOS в 2023
Создать программу можно прямо в Windows/Linux, открываете текстовый редактор и пишите код ниже.
org 100h
ret
Этот код ничего не делает, будем использовать его для тестирования возможности компиляции. Сохраните код в файл main.asm
и скомпилируйте командой: yasm -wall -fbin main.asm -o PROG.COM
Yasm это ассемблер, можно использовать и Nasm, гуглите и качаете. Получившийся файл PROG.COM
запускаем в эмуляторе. Для досбокса достаточно перенести этот файл на ярлык программы и всё запустится само. Итак, если вы уже видите работающую консоль Доса, значит всё работает и ничего не произошло (ведь программа просто вышла из самой себя). Убедитесь что файл PROG.COM
создался без ошибок.
Программу можно собрать и из под Доски, для этого вам понадобится компилятор A86. Команда для компиляции следующая: a83 main.asm prog.com
Какие подводные?
Если вы сделаете что-то не так в вашей программе, то всё взорвётся - любой вечный цикл застопорит всю систему и вы не сможете выйти даже если будете нажимать Esc или Ctrl+С.
Некоторые графические программы будут выглядеть по-разному на настоящем MS-DOS, установленном на компьютере и на эмулируемом в DOSbox.
Тут не будут описаны азы ассемблера и если вы не знаете что такое регистр памяти и статус выполнения команды, то вы вряд ли поймёте всё что я тут понаписал. Погуглите или посмотрите видосы по теме.
Программировать .exe файлы для Доса можно и на Си, но этот гайд не об этом - чем меньше программа тем лучше, а в .exe файле есть лишние данные, которые не являются самой программой.
Ассемблер действительно сложный язык, поэтому запаситесь справочными материалами. Например скачайте таблицу со всеми командами процессора i8086 и коды системных прерываний Доса. Вы должны понимать, что DOS это 16-и битная ОС и даже если ваш процессор поддерживает x64 регистры, то в Досе вы всё равно будете работать с двухбайтовыми значения (аналог short из Си).
Наша первая программа - цветная эпилепсия
Сейчас вы узнаете как сделать полноэкранное мерцание разными цветами, буду описывать каждую строчку кода.
Весь код программы (разъяснения ниже)
MX equ 320
MY equ 200
VID_SZ equ MX * MY
VID_BUF equ 0A000h ; start pointer to video mem (for ES)
segment .bss
fill_color db ?
segment .code
org 100h
;set video mode:
; 13h 320x200 256/256K VGA,MCGA,ATI VIP
mov ax, 13h
int 10h
redraw:
mov ax, VID_BUF ; set video pointer
mov es, ax
xor di, di ; clear addr
mov cx, VID_SZ
mov al, byte [fill_color]
inc al
mov byte [fill_color], al
fill:
stosb ; ES:DI = fill_color value (al)
loop fill
; wait ESC
push ax
in al, 60h
dec al
pop ax
jnz redraw ; if !ESC key, goto main
; return 2 text mode:
mov ax, 3h
int 10h
; exit:
mov ah, 4Ch
int 21h
ret
Для начала, в коде описываются константы через команду equ (аналог #define из си):
MX equ 320
MY equ 200
VID_SZ equ MX * MY
VID_BUF equ 0A000h
MX
и MY
- разрешение экрана в видео-режиме, который будет выбран далее. При старте, MS-DOS настраивает биос на вывод символьной графики, но режим можно переключить и рисовать цветные пиксели в любом месте экрана. Для рисования точек на экране в биос предусмотрена отдельная функция, но я не буду её использовать, потому что она медленно работает, а пикселей нам придётся нарисовать очень много. Я буду сразу записывать значения цветов в видео-буфер, так гораздо быстрее. Все изменения в видео-буфере сразу отображаются на экран, никакой вертикальной синхронизации в коде не прописано. Код для VSync я покажу позже.
VID_SZ
- вес всех пикселей в видео-буфере. Я буду использовать палитровую графику, поэтому размер каждого пикселя будет равен одному байту. Цвета будут в диапазонах от 0 до 255, а какой цвет к какому числу относится я не помню, но это и не важно - программа покажет их все.
VID_BUF
- адрес начала видео-буфера. Число 0A000h
записано в шестнадцатеричном виде, об этом нам говорит буковка h
в конце (hexadecimal), 0 в начале числа обязателен, так как нельзя начинать число с буквы, такое уж правило у компилятора. Всё что будет записано в оперативку начиная с этого адреса (0A000h)
будет автоматически отображено на экране.
segment .bss
fill_color db ?
строка segment .bss
намекает компилятору что описывается расположение переменных в памяти. На ассемблере вы должны всегда держать в уме сколько байтов занимают ваши переменные, чтобы случайно не заскочить на данные других соседних переменных. И код и переменные - всё грузится в оперативную память, а значит вы можете изменить код своей уже загруженной в программы просто перезаписав данные. MS-DOS никак не защищает код вашей программы от изменения (свои системные данные тоже не защищает). Что же значит dw
и db
? Так обозначается сколько байт нужно выделить под переменную:
-
db
- 1 байт, int8_t, BYTE; -
dw
- 2 байта, uint16_t, short, WORD; -
dd
- 4 байта, uint32_t, long, DOUBLE WORD; -
dq
- 8 байт, uint64_t, long long, QUAD WORD; -
dq, dd, dt
можно использовать чтобы хранить числа с плавающей запятой, но в i8086 нет FPU чтобы с ними работать.
Символ ?
обозначает данные без начального значения.
Переменные x/y
нужны будут для итерации по всем пикселям буфера, а переменная fill_color
будет хранить код цвета для заливки экрана.
segment .code
org 100h
segment .code
подсказывает компилятору что начинается сегмент с кодом программы. Сегменты имеют большую роль в .exe файлах, но в .com это не особо важно.
org 100h
указывает компилятору что код программы должен грузиться в оперативную память с отступом в 256 байт (число 100 в hex) от нулевого адреса, чтобы не задеть данные ОС Дос. В принципе, вы можете перезаписывать память Доса или полностью её стирать - от этого ваш комп не взорвётся, но потом вы не сможете вернуться обратно в систему после завершения программы, так как возвращаться будет уже некуда...
mov ax, 13h
int 10h
А это уже началась интересная часть. Тут выбирается видео-режим под номером 13h. Многие материнские платы обеспечивают работу данного режима, он позволяет выводить графику в разрешении 320x200 пикселей и с использованием палитры (цвета фиксированы в таблицу, RGB и прозрачности нет в этом режиме нет). Есть много других режимов позволяющих рисовать четырёхцветную графику или ЧБ, а также есть экзотические коды видео-режимов, которые включают большие разрешения и большую глубину цветоа, но у вас скорее всего не получится их использовать, потому что их не реализовали в эмуляторе и на вашей материнской плате.
Команда mov
перемещает данные из одного места в другое. Нельзя перемещать напрямую данные из одной ячейки оперативки в другую, нужно сначала загрузить ячейку из памяти в регистр, а потом в другую ячейку (кому-то было лень добавить ещё одну перегрузку для mov).
mov ax, 13h
обозначает что мы записывает константу с числом 13h
в регистр ax
. Так как ax
это 16-и битный регистр, то в нём будет лежать число 0013h
.
int 10h
вызывает программное прерывание из биоса отвечающее за работу с видео. Прерывание, это остановка вашей программы и передача управления обработчику прерываний в подпрограмме биоса. Когда подпрограмма отработает, ваша программа возобновит свою работу в месте, где она прервалась. Через int 10h
можно выводить буквенные символы в консоль, рисовать пиксели или менять видео-режим. Действие, которое будет выбрано зависит от того, что находится в регистре ah
- это старшая часть двухбайтового регистра ax
, а в него мы ранее записывали 0013h, то есть в ah
будет 0, а в al
(младшая часть ax
) будет записан 13h. Если вам трудно это представить, то посмотрите на схему:
В прерывании 10h выбирается функция под номером 0 (команда выбора видео-режима) и выставляется видео-режим под номером 13h
(VGA 320x200). О том, какие видео-режимы есть и какие числа вам надо вписать в ah/al
вы можете узнать из инфы по прерыванию 10h.
; Можно было бы написать более понятнее
mov ah, 0 ; set video mode
mov al, 13h ; VGA 320x200 plt
int 10h
; но я сэкономил одну инструкцию для уменьшения веса бинарника
mov ax, 13h
int 10h
redraw:
mov ax, VID_BUF
mov es, ax
xor di, di
mov cx, VID_SZ
mov al, byte [fill_color]
inc al
mov byte [fill_color], al
redraw:
- метка, она ничего не весит и не делает, но на неё можно ссылаться чтобы "прыгать" на участок кода стоящий после метки. Работает аналогично меткам для goto из языка Си.
В ax
записывается адрес начала видео-буфера, потом это перекидывается в es
, потому что сразу константу нельзя туда закинуть (опять кому-то было лень реализовывать это аппаратно в процессоре). Можно ещё закинуть значение ax
в стек и загрузить оттуда значение в es
через команды push/pop
, но это уже лишние инструкции.
xor di, di
- смысл команды в том, чтобы записать в di
число 0. Почему же нельзя просто сделать mov di, 0
? Потому что опять придётся записывать 0 в какой-нибудь регистр, а оттуда перекидывать 0 в di
. XOR это побитовая операция "отрицательное или", а как известно, если применить операцию xor
к одному и тому же числу, всегда получится ноль. Зачем же так извращаться? Дело в том, что xor X,X
будет кодироваться в один байт, а это экономия размера, такой вот фокус.
mov cx, VID_SZ
- в cx
записывается размер видео-буфера.
mov al, byte [fill_color]
- грузим значение цвета заливки в al
. byte [fill_color]
обозначает что мы работает с байтом по ссылке указанной в fill_color
. Компилятор сам подменяет fill_color
на адрес ячейки в памяти, fill_color
это лишь алиас цифрового значения и при компиляции в ассемблерном коде он будет именно числом.
inc al
- инкремент регистра al
, просто прибавляем к нему 1. Это нужно для прокрутки цветов по палитре. Если надо прибавить 1 к регистру, используйте всегда инкремент заместо add
, потому что инкремент займёт меньше памяти.
mov byte [fill_color], al
- новое значение цвета заливки грузим обратно в fill_color
. В регистре al
всё ещё остаётся значение пикселя, которое будет использоваться далее.
fill:
stosb ; ES:DI = fill_color value (al)
loop fill
loop
повторяет переход к метке fill:
до тех пор, пока cx
не станет равен нулю, а в cx
я ранее записал размер видео-буфера, то есть этим кодом я пробегаюсь по всем пикселям экрана и записываю в них значение цвета заливки (оно в al
). Для организации цикла можно было бы написать метку, операцию сравнения и условный переход на метку, но это бы заняло больше места.
stosb
- команда не имеющая параметров, но каждый её вызов записывает новый пиксель в видео-буфер. Значение пикселя должно быть в al
, в регистре es
хранится адрес начала массива данных, то есть начало видео-буфера, а di
служит в роли индекса элемента и автоматически увеличивается на 1 при каждом вызове stosb
. Можете считать эту конструкцию аналогом memset
из Cи, но работающим пошагово. Видео-буфер находится по далёкому адресу и поэтому доступ к нему такой мудрёный.
Как заметил @pfemidi, можно просто написать rep stosb.
Команда rep
повторяет операцию cx
раз.
push ax
in al, 60h
dec al
pop ax
jnz redraw
смысл конструкции выше заключается в проверке на нажатие клавиши Esc.
push ax
- бэкап значения из ax
в стек.
in al, 60h
- чтение сканкода из порта 60h в al
. На порту 60h располагается клавиатура.
dec al
- отнимаем от al
1, если в результате будет 0, то значит сканкод был равен Escape (01h). Нужно получить именно 0, чтобы далее было легче по флагу Z
произвести условный переход.
pop ax
- возвращаем из стека старое значение ax
.
jnz redraw
- jnz
значит jump if not zero, выполнить переход к метке redraw
, если после декремента al
не было нулевого результата. Проверяем что код клавиши не был равен 1 (Esc) и переходим к началу отрисовки чтобы поменять цвет. Таким образом программа будет работать пока мы не нажмём Esc.
Если же мы нажали Escape, то jnz
пропустит нас к коду ниже.
mov ax, 3h
int 10h
вызываем выбор видео-режима в биосе с параметрами al = 3
и ah = 0
, это позволяет вернуться в текстовый режим Доса. Если этого не сделать, то по завершению вашей программы, вы не сможете нормально работать с консолью. Можно ещё попробовать написать команду очистки консоли cls
(это в консоли Доса писать) и тогда режим консоли сам восстановится.
mov ah, 4Ch
int 21h
ret
Это код корректного закрытия приложения.
int 21h
вызовет системное прерывание Дос, а параметр 4Ch
в ah
укажет что приложение завершило работу.
ret
- возвращает нас из программы обратно в систему
Вторая программа - кислотная анимешка
Программа работает по принципу верхней, но содержит в своём коде целую картинку, которая рисуется на экран и переливается разными цветами
Код
MX equ 320
MY equ 200
VID_SZ equ MX * MY
VID_BUF equ 0A000h ; start pointer to video mem (for ES)
segment .bss
state dw ?
segment .code
org 100h
;set video mode:
; 13h 320x200 256/256K VGA,MCGA,ATI VIP
mov ax, 13h
int 10h
; print start screen:
; set to video pointer
mov ax, VID_BUF
mov es, ax
xor di, di
mov cx, VID_SZ
mov bx, img
copy_img:
mov al, byte [bx]
inc bx
stosb ; ES:DI = pixel value (al)
loop copy_img
redraw:
; set to video pointer
mov ax, VID_BUF
mov es, ax ; es:di 4 write
xor di, di
mov ds, ax ; ds:si 4 read
xor si, si
mov cx, VID_SZ
col_rot:
lodsb ; pixel = DS:SI
inc al
stosb ; ES:DI = pixel value (al)
loop col_rot
; wait ESC
push ax
in al, 60h
dec al
pop ax
jnz redraw ; if !ESC key, goto main
; return 2 text mode:
mov ax, 3h
int 10h
; exit:
mov ah, 4Ch
int 21h
ret
img: ; 320x200 bytes of picture
incbin "anime.dat"
Компилятор встраивает файл anime.dat
прямо в исполняемый файл благодаря команде incbin
, сам же файл картинки представляет из себя сырые данные пикселей без сжатия - 320x200 байт. Программа копирует картинку на экран и прибавляет к цветам единицу, что заставляет цвета картинки перекручиваться в радужные узоры. Вам нужен специальный софт для преобразования картинок в палитровый формат, я такой не знаю и использовал свою программу написанную на C++, а вы погуглите, если не лень. Можно ещё сгенерировать картинку через FFmpeg и заменить название файла в конце кода на ваш. Вот пример команды для генерации картинки для встраивания в код: ffmpeg -y -i "название исходной картинки" -vf "scale=320:200:flags=lanczos, format=gray" -f rawvideo "название выходного файла картинки.dat"
Прочее
Все эти узоры действительно умещаются в 32 байта, а некоторые даже в 21. Я сам не представляю как авторы демок до такого додумались. В следующем разделе я подскажу как уменьшить размер программ.
Вы можете реверс-инжинирить любую программу и даже эти демки, для этого можно использовать утилиту ojbdump поставляемую вместе с компилятором gcc. Команда для просмотра кода .com файла: objdump -D -M intel -b binary -m i8086 "ваш .com файл"
Программировать можно не только с набором команд i8086, в FreeDOS можно даже задействовать XMM регистры. Я просто взял i8086 в качестве минимальной поддерживаемой Досом платформы.
Для тех кому интересно, показываю как организовать задержку вертикальной синхронизации, если вы хотите избавиться от рваных кадров
wait_sync:
mov dx,03DAh
wait_end:
in al,dx
test al,8
jnz wait_end
wait_start:
in al,dx
test al,8
jz wait_start
ret
Советы по уменьшению размера программы
-
На ассемблере можно писать свои функции, вызывать их через
call
и возвращаться из них черезret
, это удобно, но затратно по размеру, так что заместо этих функций используйте метки и условные/безусловные переходы на них. Используя функции вам бы ещё пришлось пушить регистры и подготавливать аргументы перед вызовом - это бы заняло десятки байт, а с джампами всё просто. -
Если хотите занулить регистр, то делайте это через
xor
. Применение операцииxor
для одинаковых чисел всегда заканчивается результатом 0 и бонусом вы получаете выигрыш в размере программы. -
Предпочитайте
loop
для циклов заместо перехода на метку по условию. -
При записи значений в массивы используйте
stosb (для байтов)
илиstosw
(он перемещает 16-и битные данные) -
Деление и умножение может оказаться медленным, но если число делится или умножается на 2, то можно использовать побитовой сдвиг влево и вправо соответственно, это даст эквивалентный результат делению и умножению на 2.
-
В моей программе картинка загружена без сжатия, но вы можете смастерить свой алгоритм сжатия, например сжатие повторений. Можете сделать картинку чёрно белой и хранить цвета в виде битов, а остальные свободные 7 бит от байта, вы можете отдать под хранение количества повторений пикселя от 1 до 128 раз.
-
Рисование графики функциями - всегда экономнее чем хранение кадров прямо в программе (гуглите фрактальное сжатие изображений), но если вы собрались запихнуть в бинарник видео с Рикроллом, то уменьшите частоту кадров, глубину цвета и храните полностью только первый кадр, а остальные кадры храните в виде разницы между кадрами, так вы сэкономите данные храня только изменения в кадре - так сделано в .gif формате.
-
Используйте свободные регистры по максимуму. Несмотря на то что у регистров есть своё назначение, например
ax
- аккумулятор и т.д., вы должны игнорировать эти условности и плотно паковать промежуточные данные по всем свободным регистрам. -
Если вы будете хранить данные в оперативке, то вам придётся их оттуда грузить, а это уже лишняя инструкция. А если вы ещё и захотите обмениваться данными между двумя ячейками памяти, то вам придётся их грузить в промежуточный регистр, потому что отдельной команды делающей это за 1 раз просто нету, так что меньше обращайтесь к памяти.
-
Стек тоже старайтесь не использовать, так как на любой вызов
push
вы наверняка ещё и вызоветеpop
. -
Забудьте про правильный выход из программы, даже про ret в конце забудьте. Вам же важно показать шоу и при малом размере, поэтому сосредоточьтесь именно на коде демки, даже если её невозможно будет закрыть.
-
Чтение с порта клавиатуры вы можете использовать в качестве источника зёрен рандом-генераторов, так сделано во многих демках. Поэтому вы можете не писать громоздкие алгоритмы получения псевдослучайных чисел.
-
Системный вызов
setpixel
не используйте, он медленный. Напрямую пишите в видео-буфер предварительно погуглив с какого адреса он там начинается в вашем режиме.
С учётом всех премудростей, мне удалось сократить размер первой программы-мигалки до размеров 22 байт
MX equ 320
MY equ 200
VID_SZ equ MX * MY
VID_BUF equ 0A000h
mov ax, 13h
int 10h
redraw:
mov dx, VID_BUF
mov es, dx
xor di, di
mov cx, VID_SZ
inc al
fill:
stosb
loop fill
jmp redraw
RET
Всё описанное здесь имеет мало практической пользы в наше-то время, но зато теперь вы сможете хвастаться о своих навыках ужимания бинарников. На ассемблере проще всего программировать именно под старые системы, потому что железо раньше было проще и инструкций в процессоре было меньше. Процессоры intel обратно совместимы с i8086 и в них присутствуют все регистры из 16-битных предков, будь у вас хоть кор2дуо, хоть i9, вы можете ставить свои эксперименты даже на современном железе. Также легко рисовать графику как в Досе вы не сможете в Linux или Windows, потому что они переводят процессор в защищённый режим, который запрещает вам большинство прерываний, вы не будете иметь прямого доступа к видео-буферу и не сможете стирать данные системы из оперативки как в Дос. Некоторые трюки, как например xor-зануление, используют и сейчас - компиляторы gcc и clang могут заменять mov X,0
на xor X,X
для оптимизации производительности и размера программы.
Если вы являетесь бородатым динозавром и эта статья напомнила вам вас в молодые годы, то поделитесь в комментариях своими трюками и хитростями хардкодинга, а если же вы ничего не поняли и у вас что-то не получилось, то задавайте вопросы.
Автор: HPW-dev