Моё детство пришлось на эпоху 1,44-мегабайтных дискет и 56-килобитных модемов, поэтому я всегда любил маленькие программы. Раньше можно было записать на дискету кучу мелких игр и таскать её с собой. Если программа не помещалась на дискету, я задумывался, почему — в ней много графики? Есть музыка? Возможно, она выполняет много сложных операций? Или она просто раздута?
В наши дни дисковое пространство стало настолько дешёвым, что люди отказались от оптимизации по размеру.
Размер важен только при передаче: если вы передаёте программу по проводам, мегабайты равны секундам. По быстрому соединению на 100 Мбит в лучшем случае можно передать 12 МБ в секунду. Если на другом конце провода находится человек, ожидающий завершения скачивания, то разница между пятью и одной секундой может существенно повлиять на его ощущения.
Человек может зависеть от времени передачи как напрямую (пользователь, скачивающий программу по сети), так и косвенно (serverless-сервис, отвечающий на веб-запрос).
Люди обычно воспринимают всё, что длится меньше 0,1 секунды, как мгновенное, 3 секунды — это примерно тот предел, после которого прерывается состояние потока пользователя; а уж 10 секунд удержать внимание пользователя очень сложно.
Хотя уменьшение сегодня уже необязательно, оно всё равно лучше.
Эта статья задумывалась как эксперимент, позволяющий выяснить, каким может быть минимальный размер полезного автономного исполняемого файла C#. Могут ли приложения на C# достичь размеров, при которых пользователи будут ощущать их скачивание как мгновенное? Позволит ли это использовать C# там, где он не используется сейчас?
▍ Что же означает «автономный»?
Автономное приложение (self-contained application) — это приложение, содержащее в себе всё необходимое для запуска в «ванильной» операционной системе.
Компилятор C# относится к группе компиляторов, целевой платформой которых является виртуальная машина (к этой группе также относятся Java и Kotlin): результатом работы компилятора C# становится исполняемый файл, требующий для своего исполнения некой виртуальной машины (VM). Нельзя просто установить голую операционную систему и ожидать, что она сможет запускать программы, созданные компилятором C#.
По крайней мере, в Windows раньше для запуска созданных компилятором C# файлов требовалась установка .NET Framework. Сегодня есть множество версий Windows, в которых больше нет этого фреймворка (IoT, Nano Server, ARM64,…). Кроме того, .NET Framework не поддерживает новые улучшения языка C#. Он постепенно выводится из обращения.
Для обеспечения автономности приложения на C# оно должно содержать среду исполнения и все используемые им библиотеки классов. А всё это не так просто будет уместить в 2 КБ!
▍ Игра на 2 КБ
Мы создадим графическую игру с лабиринтом. Вот, как выглядит готовый продукт:
▍ Структура игры
Нам нужно начать с оснастки, позволяющей выводить пиксели на экран. Можно выбрать что-то наподобие WinForms, но избавление от зависимости от WinForms будет нашим первым шагом по уменьшению программы, так что я не буду их использовать. Для этого я воспользуюсь хорошо известными техниками sizecoding (искусства создания очень маленьких программ). Эта статья не будет введением в программирование GUI; я буду пользоваться им не по правилам. Выбранная техника sizecoding не позволит нам сэкономить особо много в начале, но будет необходима на последних этапах.
Я буду выполнять сборку с API Win32, чтобы код был портируемым и работал в Linux (Win32 — единственный стабильный ABI в Linux).
Мы начнём с создания окна. Обычно для создания окна верхнего уровня при помощи Win32 необходимо зарегистрировать класс с оконной процедурой для обработки сообщений. Мы опустим это и воспользуемся классом EDIT, который определяется системой и обычно применяется для виджетов текстовых полей.
// Это может быть и "edit"u8, но вывод в виде числовой константы little-endian занимает меньше места
long className = 'e' | 'd' << 8 | 'i' << 16 | 't' << 24;
IntPtr hwnd = CreateWindowExA(WS_EX_APPWINDOW | WS_EX_WINDOWEDGE, (byte*)&className, null,
WS_VISIBLE | WS_CAPTION | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
0, 0, Width, Height, 0, 0, 0, 0);
Теперь нам нужен только основной цикл. Каждый поток, владеющий окном, должен выполнять подкачку сообщений, чтобы окно могло отрисовывать себя или реагировать на действия, например, на перетаскивание.
bool done = false;
while (!done)
{
MSG msg;
while (PeekMessageA(&msg, 0, 0, 0, PM_REMOVE) != BOOL.FALSE)
{
done |= GetAsyncKeyState(VK_ESCAPE) != 0;
DispatchMessageA(&msg);
}
}
Теперь программу можно запускать, и при запуске мы видим белое окно с мерцающим курсором:
Окно можно закрыть, нажав на клавишу ESC.
Теперь давайте в нём что-нибудь нарисуем. Сразу после вызова CreateWindowExA
добавим строку для получения контекста устройства окна:
IntPtr hdc = GetDC(hwnd);
Затем объявим переменную для хранения буфера кадров размером Width * Height
пикселей. Мы сделаем это немного непривычным образом, чтобы позже, на этапе оптимизации, эту область можно было распределить в сегменте данных исполняемого файла. Для хранения каждого компонента (байтов Red, Green, Blue и Reserved ) мы умножаем количество байтов на четыре.
class Screen
{
internal static ScreenBuffer s_buffer;
}
struct ScreenBuffer
{
fixed byte _pixel[Width * Height * 4];
}
Здесь стоит отметить поле fixed byte _pixel[Width * Height * 4]
: это синтаксис C# для объявления фиксированного массива. Фиксированный массив (fixed array) — это массив, отдельные элементы которого являются частью struct. Можно воспринимать его как дополнительную синтаксическую конструкцию для множества полей byte _pixel_0, _pixel_1, _pixel_2, _pixel_3,... _pixel_N
, к которой можно получать доступ, как к массиву. Размер этого массива должен быть константой во время компиляции, чтобы был фиксированным размер всей struct.
Нам нужно подготовить ещё одну структуру: BITMAPINFO
, сообщающую Win32 свойства нашего экранного буфера. Я помещу её в переменную static
по той же причине, что и со ScreenBuffer
— чтобы позже она оказалась в сегменте данных исполняемого файла как блоб инициализированных/литеральных данных (благодаря этому код не должен будет инициализировать поля по отдельности).
class BitmapInfo
{
internal static BITMAPINFO bmi = new BITMAPINFO
{
bmiHeader = new BITMAPINFOHEADER
{
biSize = (uint)sizeof(BITMAPINFOHEADER),
biWidth = Width,
biHeight = -Height,
biPlanes = 1,
biBitCount = 32,
biCompression = BI.RGB,
biSizeImage = 0,
biXPelsPerMeter = 0,
biYPelsPerMeter = 0,
biClrUsed = 0,
biClrImportant = 0,
},
bmiColors = default
};
}
Теперь мы можем отрисовать на экран содержимое буфера. Добавим под цикл PeekMessage
следующий код:
fixed (BITMAPINFO* pBmi = &BitmapInfo.bmi)
fixed (ScreenBuffer* pBuffer = &Screen.s_buffer)
{
StretchDIBits(hdc, 0, 0, Width, Height, 0, 0, Width, Height, pBuffer, pBmi, DIB_RGB_COLORS, SRCCOPY);
}
Если запустить программу сейчас, то мы увидим чёрное окно, потому что при инициализации все пиксели получают нулевое значение.
Если вам интересна сама логика отрисовки лабиринта, то изучите любой из следующих ресурсов:
Я просто взял код Lode на C++ и преобразовал его в C#. Тут особо говорить не о чем. Единственное моё изменение заключалось в наблюдении, что движение вперёд противоположно движению назад (то же для движения влево и вправо). В коде Lode есть дополнительная обработка всех четырёх направлений, но я сократил её до двух, просто умножая -1 для получения противоположного направления.
Вот, собственно, и всё. Давайте посмотрим, какой у нас получился размер.
▍ Размер лабиринта в .NET 8 по умолчанию
Я сохранил игру в репозиторий GitHub, чтобы вы могли двигаться вместе со мной. Для создания конфигурации по умолчанию (из одного файла) при помощи CoreCLR нужно выполнить следующее:
$ dotnet publish -p:PublishSingleFile=true
Так мы получим единый файл EXE размером аж целых 64 МБ. В полученном EXE хранится игра, .NET Runtime и базовые библиотеки классов, являющиеся стандартной частью .NET. Можно заявить, что это всё равно лучше, чем Electron, и закончить на этом, но давайте посмотрим, можно ли что-то усовершенствовать.
Пока мы находимся в начальной точке
▍ Сжатие в один файл
Исполняемые единые файлы .NET опционально можно сжимать. Программа останется полностью такой же, только сжатой. Давайте включим сжатие:
$ dotnet publish -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true
Размер уменьшился до 35,2 МБ, в два раза меньше изначального, но всё равно намного больше, чем 2 КБ.
▍ Тримминг IL
Тримминг удаляет из приложения неиспользуемый код, сканируя всю программу и вырезая код, на который ничто не ссылается. Тримминг может поломать некоторые программы .NET, использующие в среде исполнения интроспекцию для изучения структуры программы. Мы этого не делаем, так что тримминг не вызовет проблем. Чтобы применить к проекту тримминг, добавим свойство PublishTrimmed
. Вот так:
$ dotnet publish -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:PublishTrimmed=true
После включения этого параметра размер игры ужимается до 10 МБ. Отлично, но по-прежнему далеко от нашей цели.
▍ Компиляция native AOT
Ещё одна возможность, которой мы можем воспользоваться — использование развёртывания native AOT. Развёртывание native AOT создаёт полностью нативные исполняемые файлы, среда исполнения которых адаптирована к тому, что требуется приложению. От среды исполнения нам требуется не очень много. Развёртывание native AOT подразумевает тримминг и единый файл, так что мы можем исключить их из командной строки. Также для native AOT отсутствует встроенное сжатие. Командная строка будет простой:
$ dotnet publish -p:PublishAot=true
1,13 МБ. Всё становится намного интереснее.
▍ Вырезаем неиспользуемые функции фреймворка
Подвергнутые триммингу и скомпилированные под native AOT приложения имеют опцию удаления необязательных функций фреймворка или оптимизации результатов по размеру.
Мы выполним следующее:
- Оптимизируем по размеру
- Отключим поддержку красивых строк трассировки стека
- Включим глобализацию инвариантов
- Удалим строки сообщений об исключениях фреймворка
$ dotnet publish -p:PublishAot=true -p:OptimizationPreference=Size -p:StackTraceSupport=false -p:InvariantGlobalization=true -p:UseSystemResourceKeys=true
923 КБ. На этом этапе мы исчерпали официально поддерживаемые опции .NET, но всё равно на 921 КБ превышаем требуемый бюджет.
▍ bflat
bflat — это ранний (ahead of time) компилятор для C#, собранный из частей официального .NET SDK. По своей сути это форк репозитория dotnet/runtime с парой изменений. Он встроен в bflat CLI, использующий компилятор C#, целевой платформой которого может быть и IL, и нативный код.
Чтобы повторять за мной, можете установить его командой winget install bflat
.
Так как bflat собран поверх реального .NET, продолжим с того, на чём закончили — мы удалили строки трассировки стека, отключили глобализацию и вырезали сообщения фреймворка об исключениях:
$ bflat build -Os --no-stacktrace-data --no-globalization --no-exception-messages
882 КБ. Файл стал немного меньше благодаря изменениям, внесённым bflat.
▍ bflat с zerolib
Компилятор bflat позволяет выбрать одну из трёх опций относительно библиотек среды исполнения — можно или использовать полную библиотеку среды исполнения, поставляемую с .NET, или собственную минимальную реализацию bflat под названием zerolib, или вообще никаких стандартных библиотек.
DotNet | Zero | Без библиотек | |
---|---|---|---|
Примитивные типы | ✓ | ✓ | ✗ |
Rich standard library | ✓ | ✗ | ✗ |
p/invoke без маршализации | ✓ | ✓ | ✓ |
Полный p/invoke | ✓ | ✗ | ✗ |
Сборщик мусора | ✓ | ✗ | ✗ |
Обработка исключений | ✓ | ✗ | ✗ |
Сборка приложений и библиотек | ✓ | ✓ | ✓ |
Целевые платформы Linux, Windows, Android | ✓ | ✓ | ✓ |
Целевая платформа UEFI | ✗ | ✓ | ✓ |
Начальный размер приложения | 600 КБ | 4 КБ | ~4 КБ |
Мы тщательно подготовили игру к совместимости с ограничениями zerolib, так что давайте перейдём на неё.
$ bflat build -Os --stdlib:zero
9 КБ! Мы уже очень близки к цели!
▍ Прямой pinvoke
Если открыть полученный исполняемый файл в шестнадцатеричном редакторе, то можно заметить, что он выполняет вызовы LoadLibrary
и GetProcAddress
, которых не было в изначальной программе. Так происходит потому, что по умолчанию bflat резолвит вызовы p/invoke gdi32.dll и user32.dll ленивым образом. Давайте прикажем bflat резолвить их статически:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32
Ой-ёй, это сработало не совсем правильно:
lld: error: undefined symbol: StretchDIBits
>>> referenced by D:gitminimazeProgram.cs:262
>>> D:gitminimazeminimaze.obj:(minimaze_Program__Main)
Это вызвано тем, что bflat имеет библиотеки импорта не для всей Windows, а только необходимое ему подмножество.
Это можно исправить, указав компоновщику bflat библиотеки импорта в Windows SDK:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:Progra~2WI3CF2~110Lib10.0.22621.0umx64gdi32.lib
Отлично! Мы снизили размер до 8 КБ.
▍ Поддержка отладки и перемещение
Снова изучив получившийся исполняемый файл в шестнадцатеричном редакторе, мы заметим ещё два аспекта:
- Раздел
.reloc
. Этот раздел содержит информацию, необходимую для исправления исполняемого файла, если он не загружен по предпочтительному базовому адресу (например, из-за ASLR) - Путь к файлу PDB. Он используется отладчиком для нахождения файла.
Ни то ни другое нам не нужно. У bflat есть параметры, чтобы избавиться от них:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:Progra~2WI3CF2~110Lib10.0.22621.0umx64gdi32.lib --no-pie --no-debug-info
Мы добрались до 7 КБ.
▍ Сборка для x86
Пока мы выполняли сборки для архитектуры x86-64. Она является совместимым двоичным расширением архитектуры x86. Из-за того, что это расширение, его кодировка команд больше, как и указатели. Давайте перейдём на x86.
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:Progra~2WI3CF2~110Lib10.0.22621.0umx86gdi32.lib --no-pie --no-debug-info -arch x86
(Обратите внимание, что я заменил путь к файлу gdi32.lib так, чтобы он указывал на версию для x86.)
6,5 КБ. На этом этапе у нас закончились возможные параметры компилятора bflat.
▍ Crinkler
Сборка нативных исполняемых файлов обычно состоит из двух этапов: генерация объектного файла, содержащего машинный код, который пока невозможно запустить, и запуск компоновщика для создания исполняемого файла из объектного.
Пока мы использовали компоновщик bflat (который на самом деле является упакованным LLD — компоновщиком LLVM).
Однако существует специализированный компоновщик для людей, занимающихся sizecoding: crinkler. Crinkler — это сжимающий компоновщик для Windows, специально рассчитанный на создание исполняемых файлов размером несколько килобайтов. Давайте попробуем воспользоваться им.
Для начала нужно найти параметры командной строки для вызова компоновщика. Чтобы увидеть, как bflat запускает компоновщик LLVM, добавим к bflat build
параметр командной строки -x
:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:Progra~2WI3CF2~110Lib10.0.22621.0umx64gdi32.lib --no-pie --no-debug-info -arch x86 -x
После этого будет выведена следующая строка:
C:UsersmichalsAppDataLocalMicrosoftWinGetPackagesMichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwebinlld.exe -flavor link "D:gitminimazeminimaze.obj" /out:"D:gitminimazeminimaze.exe" /libpath:"C:UsersmichalsAppDataLocalMicrosoftWinGetPackagesMichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwelibwindowsx86" /libpath:"C:UsersmichalsAppDataLocalMicrosoftWinGetPackagesMichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwelibwindows" /libpath:"C:UsersmichalsAppDataLocalMicrosoftWinGetPackagesMichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwelib" /subsystem:console /entry:__managed__Main /fixed /incremental:no /merge:.modules=.rdata /merge:.managedcode=.text advapi32.lib bcrypt.lib crypt32.lib iphlpapi.lib kernel32.lib mswsock.lib ncrypt.lib normaliz.lib ntdll.lib ole32.lib oleaut32.lib user32.lib version.lib ws2_32.lib shell32.lib Secur32.Lib /opt:ref,icf /nodefaultlib:libcpmt.lib C:Progra~2WI3CF2~110Lib10.0.22621.0umx86gdi32.lib C:Progra~2WI3CF2~110Lib10.0.22621.0umx86user32.lib C:UsersmichalsAppDataLocalMicrosoftWinGetPackagesMichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwelibwindowsx86zerolibnative.obj
Она пригодится нам в дальнейшем.
Теперь нам нужен объектный файл. Обычно после создания файла EXE bflat удаляет объектный файл, но мы можем приказать ему остановиться после генерации файла obj, добавив к bflat build
параметр -c
:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:Progra~2WI3CF2~110Lib10.0.22621.0umx64gdi32.lib --no-pie --no-debug-info -arch x86 -c
Теперь у нас есть minimaze.obj. Давайте запустим crinkler. Мы передадим ему пару аргументов, полученных этапом ранее:
- имя входного объектного файла
- имя выходного исполняемого файла
- имя символа точки входа (
__managed__Main
) - пути к kernel32.lib, user32.lib, gdi32.lib
- путь к zerolibnative.obj (подробность реализации bflat zerolib)
$ crinkler minimaze.obj /out:minimaze-crinkled.exe /entry:__managed__Main C:Progra~2WI3CF2~110Lib10.0.22621.0umx86user32.lib C:Progra~2WI3CF2~110Lib10.0.22621.0umx86kernel32.lib C:Progra~2WI3CF2~110Lib10.0.22621.0umx86gdi32.lib C:UsersmichalsAppDataLocalMicrosoftWinGetPackagesMichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwelibwindowsx86zerolibnative.obj /subsystem:windows
1936 байтов! Файл стал таким маленьким, что мы можем закодировать содержимое его EXE в QR-код (с достаточным объёмом коррекции ошибок):
qrencode -r minimaze-crinkled.exe -l M -o minimaze-crinkled.png -8
Если отсканировать этот QR-код считывателем, то мы получим данные исполняемого файла игры. Можете попробовать отсканировать его и запустить игру.
Если хотите больше узнать о sizecoding, то рекомендую изучить сайт in4k; несмотря на то, что большинство ресурсов там написано на C и C++, код легко преобразовать в C#. Какую игру вы сможете уместить в 4 КБ? А что сможете сделать с 8 КБ?
Автор:
ru_vds