Эххх... RDR 2... Проработанный сюжет, большой открытый мир, внимание к деталям — вот что гласили заголовки статей. И всё это умещается в 116 ГБ памяти. Всего лишь? Нет. Много. Ждать, блин, ещё несколько часов пока он загрузится. «Вот никуда это не годится!» — подумал я, и тут же мне пришла идея «утереть нос» этим студиям — создать игру не хуже, которая будет размером с игру для NES — 40 КБ! Это всё фантазии конечно, но вспомнить запылишийся C можно и даже нужно, времени еще полно. Ладно, долой вступление, берем C99, GCC 14.2.0 и вперед.

Начнем с основы основ: "Hello, world!". Создаем main.c:

#include <stdio.h>

void main(void)
{
  printf("Hello, world!");
  return;
}

Ну и сразу, чтобы не заморачиваться, напишем простенький Batch файл build.bat, чтобы не вводить все тысячу раз, и сразу видеть размер файла:

@echo off

rem компилируем файл, изменять будем только эту строчку
gcc main.c -o main.exe

rem простенькая функция для вывода размера файла в байтах
call :size main.exe
goto :eof
:size
echo.
echo.Size: %~z1

Теперь вводим в консоль build и видим катастрофу: Size: 257259.

Целых 252 килобайта, на простейшую программу. Благо, есть решение — флаги gcc. Давайте пока глянем что у нас сейчас по флагам. main.c определяет исходный файл с кодом, а -o main.exe дает название exe'шнику, чтобы он не назывался a.exe.

Сразу же можно докинуть в конец строки флаг -s. Таким образом мы просим линкер выкинуть так называемую таблицу символов и некоторую отладочную информацию из файла, делая невозможным использования gdb. Но, как бы, нам и не нужно ничего дебажить, так что build в консоль и фиксируем профит: Size: 44032 — ровно 43 килобайта, сокращение почти в 6 раз.

Давайте перечитаем программу от начала и до конца и найдем нашего следуещего врага. printf — вот он! Почему? Вот почему:

  • printf линкуется с libc, стандартной библиотекой C. Из-за этого требуется дополнительное место для кода некоторых инициализаций.

  • printf определена как вариадическая (принимает разное количество аргументов), что забирает ещё кучу места.

  • printf включает в себя сложное форматирование, что тоже занимает кучу байт.

Пример MessageBox'а
Пример MessageBox'а

К счастью у нас есть альтернатива: API Windows. Есть такая прекрасная функция MessageBoxA, которая линкуется с библиотеками Windows, не вариадическая и не требует каких-то инициализаций. Так что немного меняем код:

#include <windows.h>

void main(void)
{
  MessageBoxA(0, "Hello, world!", "Hello, world!", 0);
  return;
}

Ещё докинем флаг -mwindows, чтобы нам не высвечивалась консоль. Вот все аргументы компиляции на данном этапе:

gcc main.c -o main.exe -s -mwindows

Прописываем build: Size: 16384 — 16КБ. Неплохой прогресс.

Хорошо, теперь время для флагов немного подлиннее, сейчас они нам сэкономят всего 1 КБ, но если мы их спользовать не будем, то к концу размер exe'шника будет где-то в полтора раза больше чем мог бы быть.

Добавляем -Wl,--gc-sections. Этот флаг используется для удаления ненужных секций, что в свою очередь приводит к сокращению и удалению неиспользуемого кода. И сразу добавим два огромных флага: -fno-unwind-tables и -fno-asynchronous-unwind-tables. Они удалят таблицы раскрутки стэка, которые по сути являются просто метаданными, не влияющими на работоспособность программы. Ну и конечно нужно добавить -Oz — экстремальную оптимизацию кода, правда влияние на размер файла будет не такое экстремальное. Size: 14848 — минус еще один килобайт!

А теперь давайте глянем что у нас происходит с секциями.

objdump main.exe -x
objdump main.exe -x

Их много, причем тех, которые не использованны непосредственно в нашей программе: .reloc, .xdata, .tls и другие. Так же много других функций. Зачем-то программа всё ещё требует malloc, free, strlen. Всё это из-за main'а — дефолтной точки входа в программу. На самом деле void main(void) это не вся программа. До и после main'а происходит много разных фокусов-покусов необходимых для большинства программ. Например, не смотря на то что мы не используем аргументы коммандной строки, они всё равно обрабатываются.

В этом случае нам придется создать свой main. Меняем void main(void) на void program(void). К флагам добавляем: -Wl,-eprogram — для новой точки входа под названием program и -nostartfiles — для того чтобы убрать остальной мусор от main'а. build в консоль: Size: 2560 — 2.5 КБ, снова сокращение в разы.

Вот так сейчас выглядит строчка с флагами (не обращайте внимание на ^, они используются для того чтобы разбить одну строку на несколько):

gcc main.c -o main.exe -s -mwindows ^
  -Wl,--gc-sections -fno-unwind-tables -fno-asynchronous-unwind-tables -Oz ^
  -Wl,-eprogram -nostartfiles
Остались только необходимые секции и одна функция
Остались только необходимые секции и одна функция

По сути, это последнее легальное сокращение. Здесь обычно стопорятся люди прошедшие весь этот путь. Но не мы. Теперь придется немного заморочиться. Нет, мы не будем изменять файл вручную, как делал один человек, получивший exe'шник размером в 1 КБ (834 Б).

Если заглянуть в наш файл на данном этапе, нас ждут громадные пропасти из NUL'ов — заполнителей пространства между секциями. Просто удалить их — не вариант, ничего не сработает, а вот удалить их сложно — можно. По сути все эти нули — результат того что позиция и размер секций выравнены по степеням двойки, причем большим. Поэтому стоит глянуть документацию PE (Portable Executable) файлов, причем именно документацию заголовка.

Там есть два интересных поля:

  • SectionAlignment: отвечает за выравнивание секций в памяти (RAM)

  • FileAlignment: отвечает за выравнивание секций в самом файле, обычно равен 512, собственно такими блоками ОС и переносит данные из exe в память.

Оба значения можно установить через компилятор: -Wl,--file-alignment=0x8,--section-alignment=0x8. Установить их нужно на минимальное значение: 8. Может появиться вопрос: А зачем нужно выравнивание именно в памяти, на размер файла это ведь не влияет? Не знаю. Шаманские фокусы и темная магия. Но без установки section-alignment на то же значение, что и file-alignment, ничего не запустится.

Итак, последний build: Size: 744 — МЕНЬШЕ КИЛОБАЙТА! Празднуем! Вот строка с флагами:

gcc main.c -o main.exe -Oz -s -mwindows ^
  -Wl,--gc-sections -fno-unwind-tables -fno-asynchronous-unwind-tables -Oz ^
  -Wl,-eprogram -nostartfiles
  -Wl,--file-alignment=0x8,--section-alignment=0x8

В смысле «где обещанные 640 байт»? Сейчас всё будет. Полностью очищаем main.c и записываем там только void program(void) { return; }. Компилируем и вот вам 640 Байт. Напомню, оно всё ещё запускается и не вызывает каких либо ошибок от Windows, так что технически является полноценной рабочей программой. (Еще добавлю, что можно докинуть -mwin32, что выкинет еще 8 байт. 632 Б — мой рекорд).

Вы, я думаю, догадываетесь как можно замотивировать меня сделать 3D игру в 40 КБ. А RDR 2, у меня как раз скачался, так что спасибо за прочтение, а я пойду изучать, за что отдал 100 с лишним ГБ памяти...

HelloWorld.exe, Minimal.exe

P. S. Спасибо всем тем, кто поправил мои опечатки

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


  1. MountainGoat
    26.06.2025 19:34

    Я хотел сделать скринсейвер, но я графикой никогда не занимался. И у меня был пунктик - нужна поддержка многих мониторов сразу. Кое как вник в Vulkan, шейдеры, вся фигня. Нормально заработало на одном экране. Но при отображении сразу на несколько экранов у меня полезли ошибки, которые никто на форумах объяснить не мог, и тексты в логах, при гуглении которых находятся только исходники той библиотеки ,что их пишет.

    В итоге появилось ощущение, что мне сейчас нужно потратить год на изучение теории этого всего, или идти другим путём.

    Теперь мой скринсейвер открывает Internet Explorer на весь экран без элементов интерфейса, в нём грузится страница, а на ней моя анимация на TypeScript. Всё плавно и сочно, никаких подтормаживаний. Стартует секунды две, в основном из-за антивируса. Система сообщает, что на два экрана готова рисовать до 5 000 FPS, но конечно она VSync-нута. EXEшник весит 5 Мб, жор памяти ОС показывает 3 Мб.

    Так что может ну их нафиг уже, эти примитивы...


  1. Akuma
    26.06.2025 19:34

    Помню была такая штука как «Демо сцены» - маааленькие экзешники, которые выводили на экран всякие потрясающие штуки


    1. NikkiG
      26.06.2025 19:34

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


    1. Chupaka
      26.06.2025 19:34

      И .kkrieger занимает 96 КБ на диске...


      1. LanMaster
        26.06.2025 19:34

        Ещё была sumotory - она поменьше, а в ней физика и множественные (настраиваемо) источники света.


    1. domix32
      26.06.2025 19:34

      Так они и сейчас есть.


    1. Maccimo
      26.06.2025 19:34

      Помню была такая штука как «Демо сцены»

      У нас тут целых хаб на эту тему: https://habr.com/ru/hubs/demoscene/articles/


    1. ItsYuuta Автор
      26.06.2025 19:34

      Видел такое, даже статья на Хабре есть такая: https://habr.com/ru/articles/713550/. OCEAN32 - вообще крышесносная вещь, правда это .COM фаил а не .EXE, но всеравно круто


  1. Timick
    26.06.2025 19:34

    А как там нынче с .com? Во времена доса когда не нужно было больше 64кб оперативки компилировл в .com. размер получался смехотворный.


    1. horribile
      26.06.2025 19:34

      org 100; ret;

      Работает, но нельзя привязывать библиотеки


      1. n0isy
        26.06.2025 19:34

        Разве работает? Я думал с win10 выпилили.


        1. Panzerschrek
          26.06.2025 19:34

          COM-файлы запускались через виртуальную машину DOS, которую убрали из 64-битных систем. Но никто не мешает использовать Dosbox.


  1. Siemargl
    26.06.2025 19:34

    Где то была статья, для минимального exe брать VC5 (Visual Studio 97)


  1. Zara6502
    26.06.2025 19:34

    а еще есть UPX


    1. ItsYuuta Автор
      26.06.2025 19:34

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


      1. drWhy
        26.06.2025 19:34

        Пакеров на самом деле много есть. Есть с обфускацией.


  1. jnzeax
    26.06.2025 19:34

    Если и дальше бороться за истинный размер файла, то далее будет препарирование непосредственно самого бинарника, т.к можно выкинуть DOS-stub, поместить библиотеку импорта и данных в одну секцию рядом, и также оставить alignment маленьким, и в итоге получить что-то абсолютно крошечное.


    1. jnzeax
      26.06.2025 19:34

      P.S. Ради спортивного интереса, на основе предоставленного минимального файла, используя бинарный редактор, ловкость рук и со скрипом зубов мне удалось сделать минимально работающий вариант в 376 байт. Запускал соответственно на Win10 x64.

      P.P.S. Я пробовал установить поля FileAlignment и SectionAlignment отличные от тех что рисует компилятор (обычно это 0x200 и 0x1000) на другие произвольные значения, но все они давали нерабочий х32 бинарник, что впрочем следует из их документации, так что допустимое значений 0x8 в PE32+ стала в какой-то степени неожиданностью.


      1. ItsYuuta Автор
        26.06.2025 19:34

        Это реально круто. Я глянул файл и в самом деле не нашел привычного: "This program cannot be run in DOS mode". Хорошо было бы самому по подробнее разобраться в документации PE файлов, и написать скрипт по вырезанию всего ненужного...


  1. Panzerschrek
    26.06.2025 19:34

    Я относительно недавно баловался с минимизацией размера исполняемых файлов, вот результаты. Простейший Hello World в 32-битном режиме умещается в 624 байта. Но это с использованием MSVC, а не MinGW. А в 4 килобайта можно уже какую-нибудь графическую демку или игру вроде Тетриса уместить.


  1. NutsUnderline
    26.06.2025 19:34

    Некоторые простейшие приложения требуют под 200-300 МБ,

    можно подумать там .exe такой большой, там же не просто electron а еще и либы, на все случаи жизни

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


  1. Turbo_Pascal_55
    26.06.2025 19:34

    А Turbo Pascal сразу без оптимизаций делал минимальный exe в 16Кб.


    1. pvvv
      26.06.2025 19:34

      #include <windows.h> 
      
      void main(){ 
        MessageBoxA(0, "Hello, world!", "Hello, world!", 0); 
      }

      tcc main.c -luser32

      main.exe: 3584 bytes


    1. drWhy
      26.06.2025 19:34

      Дельфи 3 ЕМНИП около 25 КБ без оптимизации. И 2,4 КБ с нею.
      Ещё можно юзать командный процессор для прямого программирования в машинных кодах:
      copy con reboot.com & [Alt+205, Alt+25] & ^Z
      И всё же по соотношению функциональности к цене почти ничто не может быть лучше
      Бесконечно выгодной программы.


  1. domix32
    26.06.2025 19:34

    Прикол обычно в том, что размер кода в играх - меньшая из проблем, т.к. основной объём занимают звуки и текстуры/материалы к моделям, когда на одну модель приходитсся несколько текстур с разрешением 2k+ - карты высот, теней/освещения, маски прозрачности и прочее в том же духе. Ну и звуки соотвественно на озвучку всех тех сотен часов диалогов, кои присутвуют в том же RDR2, это не считая тысяч SFX, включая озвучку взаимодействия разных же материалов.


  1. AllSoliton
    26.06.2025 19:34

    Я считаю, здесь нужно упомянуть Animal Well, который занимает меньше 40 Мб при наличии весьма красивой графики. А также консольные вещи вроде NetHack, некоторые модификации которого, к моему большому удивлению, весят больше Animal Well.
    И при этом в этих играх — удивительное внимание к деталям.


  1. Antimatter
    26.06.2025 19:34

    Батник получился бы в разы меньше. Особенно сплевывающий в консоль.


    1. nick758
      26.06.2025 19:34

      Напомнило:

      # Guru Hacker
      
      % cat
      Hello, world.
      ^D

      https://www.smart-words.org/jokes/programmer-evolution.html


  1. Maccimo
    26.06.2025 19:34

    Давайте перечитаем программу от начала и до конца и найдем нашего следуещего врага. printf — вот он!

    В этот момент ожидал, что автор вспомнит про puts().


    1. Xiran
      26.06.2025 19:34

      А я про WriteConsoleA


    1. ItsYuuta Автор
      26.06.2025 19:34

      В своих первых попытках уменьшить .EXE, я и использовал puts, но здесь я не стал рассказывать об этой функции из-за того что она требует инициализации stdout, а я её вырезал в одном из последующих шагов

      А вот WriteConsoleA потребует AllocConsole, GetStdHandle или еще чего по хуже, так что о размере меньше 800Б можно было бы забыть


      1. sic
        26.06.2025 19:34

        Ну шеллкодам в 100- байт ничего не мешает и побольше функций системы импортировать.