Эххх... 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

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


  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. Timick
    26.06.2025 19:34

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


    1. horribile
      26.06.2025 19:34

      org 100; ret;

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


  1. Siemargl
    26.06.2025 19:34

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


  1. Zara6502
    26.06.2025 19:34

    а еще есть UPX


  1. jnzeax
    26.06.2025 19:34

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