Эххх... 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
включает в себя сложное форматирование, что тоже занимает кучу байт.

К счастью у нас есть альтернатива: 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
— минус еще один килобайт!
А теперь давайте глянем что у нас происходит с секциями.

Их много, причем тех, которые не использованны непосредственно в нашей программе: .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 с лишним ГБ памяти...
Комментарии (8)
jnzeax
26.06.2025 19:34Если и дальше бороться за истинный размер файла, то далее будет препарирование непосредственно самого бинарника, т.к можно выкинуть DOS-stub, поместить библиотеку импорта и данных в одну секцию рядом, и также оставить alignment маленьким, и в итоге получить что-то абсолютно крошечное.
MountainGoat
Я хотел сделать скринсейвер, но я графикой никогда не занимался. И у меня был пунктик - нужна поддержка многих мониторов сразу. Кое как вник в Vulkan, шейдеры, вся фигня. Нормально заработало на одном экране. Но при отображении сразу на несколько экранов у меня полезли ошибки, которые никто на форумах объяснить не мог, и тексты в логах, при гуглении которых находятся только исходники той библиотеки ,что их пишет.
В итоге появилось ощущение, что мне сейчас нужно потратить год на изучение теории этого всего, или идти другим путём.
Теперь мой скринсейвер открывает Internet Explorer на весь экран без элементов интерфейса, в нём грузится страница, а на ней моя анимация на TypeScript. Всё плавно и сочно, никаких подтормаживаний. Стартует секунды две, в основном из-за антивируса. Система сообщает, что на два экрана готова рисовать до 5 000 FPS, но конечно она VSync-нута. EXEшник весит 5 Мб, жор памяти ОС показывает 3 Мб.
Так что может ну их нафиг уже, эти примитивы...