C

Как уменьшить размер исполняемого файла C#?

Как человеку, выросшему во времена дискет и 56 Кбит модемов, мне всегда нравились небольшие программы. Я мог поместить много небольших программ на дискету, которую носил с собой. Если программа не помещалась на моем гибком диске, я начинал думать, почему: много графики? Музыка? Программа сложная или просто раздулась?

В наши дни дисковое пространство стало настолько дешевым (а огромные флешки настолько вездесущими), что люди отказались от оптимизации размера.

Единственная область, где размер еще имеет значение  —  это передача: при передаче программы по проводу мегабайты приравниваются к секундам. Быстрое соединение на 100 Мбит может пропускать только 12 мегабайт в секунду в лучшем случае. Когда на другом конце провода находится человек, ожидающий завершения загрузки, разница между пятью и одной секундами может оказать существенное влияние на восприятие. Человек может зависеть от времени передачи либо напрямую: он загружает программу по сети, либо косвенно  —  бессерверная служба развертывается для ответа на веб-запрос.

Люди обычно воспринимают что-то быстрее 0,1 секунды как мгновенное. 3 секунды  —  примерно предел непрерывности потока пользователя, и вам было бы трудно удержать пользователя после 10 секунд.

Хотя меньший размер больше не является существенным, он всё равно лучше.

Эта статья вышла как эксперимент, чтобы выяснить, насколько маленьким может быть полезный автономный исполняемый файл C#. Могут ли приложения C# достичь размеров, при которых пользователи посчитают время загрузки мгновенным? Возможно ли использовать C# в тех местах, где язык не используется сейчас?

Что такое автономность?

Автономное приложение  —  это приложение, включающее в себя все необходимое для запуска на операционной системе. Компилятор C# принадлежит к группе компиляторов, нацеленных на виртуальную машину (Java и Kotlin  —  другие заметные члены группы). Выходные данные компилятора C#  —  это исполняемый файл, требующий выполнения некоторой виртуальной машины. Нельзя просто установить чистую операционную систему и ожидать, что на ней можно запускать программы, созданные компилятором C#.

По крайней мере Windows  —  тот случай, когда можно полагаться на машинную установку .NET Framework для запуска выходных данных компилятора C#. В настоящее время есть много инструментов без этого ограничения: IoT Nano Server, ARM64. Платформа .NET Framework также не поддерживает последние усовершенствования языка C#.

Для автономности приложение на C# должно включать среду выполнения и все используемые библиотеки классов. Это много, чтобы вписаться в планируемые 8 Кб!

Игра меньше 8 Кб

Мы создадим клон змейки:

Не интересна игровая механика? Не стесняйтесь переходить к интересным частям, где мы сжимаем игру с 65 мегабайт до 8 килобайт за 9 шагов, прокрутите до графика.

Игра будет работать в текстовом режиме, и мы используем поле рисования символов, чтобы нарисовать змею. Я уверен, что Vulcan или DirectX намного веселее, но мы справимся и с System.Console.

Игра без выделения памяти

Мы собираемся создать игру без выделения памяти, и под этим я не имею в виду “не выделять память в цикле игры”, распространённое среди разработчиков игр C#. Я имею в виду запрет ключевого слова new со ссылочными типами во всей кодовой базе. Причины станут очевидны на заключительном шаге сжатия игры.

При таком ограничении можно задаться вопросом, есть ли вообще смысл в использовании C#: без new мы не будем использовать сборщик мусора, не сможем выбрасывать исключения и т.д. То есть язык С будет работать так же хорошо.

Одна из причин использования C#  —  “потому что это возможно”. Другая причина  —  тестируемость и общий доступ к коду. Хотя игра не выделяет память для ссылочных типов, это не означает, что ее части не могут повторно использоваться в другом проекте, не имеющем таких ограничений. Например, части игры могут быть включены в проекта xUnit, чтобы покрыть приложение юнит-тестами. Если кто-то выбирает C для сборки игры, всё ограничено возможностями C, даже если код используется повторно. Но поскольку C# обеспечивает хорошее сочетание конструкций высокого и низкого уровня абстракций, мы можем использовать высокий уровень по умолчанию, а низкий уровень при необходимости. Для достижения размера развертывания в 8 Кб потребуется низкоуровневая часть.

Структура игры

Начнем со структуры буфера кадров. Буфер кадров  —  это компонент, содержащий пиксели (или в данном случае  —  символы), отображаемые на экране:

unsafe struct FrameBuffer
{
    public const int Width = 40;
    public const int Height = 20;
    public const int Area = Width * Height;

    fixed char _chars[Area];

    public void SetPixel(int x, int y, char character)
    {
        _chars[y * Width + x] = character;
    }

    public void Clear()
    {
        for (int i = 0; i < Area; i++)
            _chars[i] = ' ';
    }

    public readonly void Render()
    {
        Console.SetCursorPosition(0, 0);

        const ConsoleColor snakeColor = ConsoleColor.Green;

        Console.ForegroundColor = snakeColor;

        for (int i = 1; i <= Area; i++)
        {
            char c = _chars[i - 1];

            if (c == '*' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
            {
                Console.ForegroundColor = c == '*' ? ConsoleColor.Red : ConsoleColor.White;
                Console.Write(c);
                Console.ForegroundColor = snakeColor;
            }
            else
                Console.Write(c);

            if (i % Width == 0)
            {
                Console.SetCursorPosition(0, i / Width - 1);
            }
        }
    }
}

Мы предоставляем методы установки отдельных пикселей, очистки и визуализации содержимого буфера кадров System.Console. Шаг рендеринга особых случаев несколько символов, так что мы получаем красочный вывод без необходимости отслеживать цвет каждого пикселя буфера кадров.

Одна интересная вещь  —  поле fixed char _chars[Area]: это синтаксис C# для объявления фиксированного массива . Фиксированный массив  —  это массив, отдельные элементы которого являются частью структуры. Вы можете думать об этом как о ярлыке для набора полей char _char_0, _char_1, _char_2, _char_3,... _char_Area, к которым можно получить доступ как в массиве. Размер этого массива должен быть постоянной времени компиляции, чтобы размер всей структуры был фиксированным.

Мы не можем переборщить с размером фиксированного массива, потому что как часть структуры массив должен жить в стеке, а стеки, как правило, ограничены небольшим количеством байтов (обычно 1 Мб на поток) и 40*20*2 width*height* sizeof(char)  —  допустимое число.

Следующее, что нам нужно  —  генератор случайных чисел. Поставляемый с .NET генератор является ссылочным типом по уважительным причинам, а мы запретили себе new. Но простую struct сделаем:

struct Random
{
    private uint _val;

    public Random(uint seed)
    {
        _val = seed;
    }

    public uint Next() => _val = (1103515245 * _val + 12345) % 2147483648;
}

Этот генератор не слишком хорош, но нам не нужно ничего сложного. Теперь пишем обёртку для логики игры:

struct Snake
{
    public const int MaxLength = 30;

    private int _length;

  
/* 
_body - упакованное целое число для координат и символ для тела змеи. Только примитивные типы могут быть использованы с fixed, следовательно, это int. */
    private unsafe fixed int _body[MaxLength];

    private Direction _direction;
    private Direction _oldDirection;

    public Direction Course
    {
        set
        {
            if (_oldDirection != _direction)
                _oldDirection = _direction;

            if (_direction - value != 2 && value - _direction != 2)
                _direction = value;
        }
    }

    public unsafe Snake(byte x, byte y, Direction direction)
    {
        _body[0] = new Part(x, y, DirectionToChar(direction, direction)).Pack();
        _direction = direction;
        _oldDirection = direction;
        _length = 1;
    }

    public unsafe bool Update()
    {
        Part oldHead = Part.Unpack(_body[0]);
        Part newHead = new Part(
            (byte)(_direction switch
            {
                Direction.Left => oldHead.X == 0 ? FrameBuffer.Width - 1 : oldHead.X - 1,
                Direction.Right => (oldHead.X + 1) % FrameBuffer.Width,
                _ => oldHead.X,
            }),
            (byte)(_direction switch
            {
                Direction.Up => oldHead.Y == 0 ? FrameBuffer.Height - 1 : oldHead.Y - 1,
                Direction.Down => (oldHead.Y + 1) % FrameBuffer.Height,
                _ => oldHead.Y,
            }),
            DirectionToChar(_direction, _direction)
            );

        oldHead = new Part(oldHead.X, oldHead.Y, DirectionToChar(_oldDirection, _direction));

        bool result = true;

        for (int i = 0; i < _length - 1; i++)
        {
            Part current = Part.Unpack(_body[i]);
            if (current.X == newHead.X && current.Y == newHead.Y)
                result = false;
        }

        _body[0] = oldHead.Pack();

        for (int i = _length - 2; i >= 0; i--)
        {
            _body[i + 1] = _body[i];
        }

        _body[0] = newHead.Pack();

        _oldDirection = _direction;

        return result;
    }

    public unsafe readonly void Draw(ref FrameBuffer fb)
    {
        for (int i = 0; i < _length; i++)
        {
            Part p = Part.Unpack(_body[i]);
            fb.SetPixel(p.X, p.Y, p.Character);
        }
    }

    public bool Extend()
    {
        if (_length < MaxLength)
        {
            _length += 1;
            return true;
        }
        return false;
    }

    public unsafe readonly bool HitTest(int x, int y)
    {
        for (int i = 0; i < _length; i++)
        {
            Part current = Part.Unpack(_body[i]);
            if (current.X == x && current.Y == y)
                return true;
        }

        return false;
    }

    private static char DirectionToChar(Direction oldDirection, Direction newDirection)
    {
        const string DirectionChangeToChar = "│┌?┐┘─┐??└│┘└?┌─";
        return DirectionChangeToChar[(int)oldDirection * 4 + (int)newDirection];
    }

    // Вспомогательная структура для работы с координатами тела.
    readonly struct Part
    {
        public readonly byte X, Y;
        public readonly char Character;

        public Part(byte x, byte y, char c)
        {
            X = x;
            Y = y;
            Character = c;
        }

        public int Pack() => X << 24 | Y << 16 | Character;
        public static Part Unpack(int packed) => new Part((byte)(packed >> 24), (byte)(packed >> 16), (char)packed);
    }

    public enum Direction
    {
        Up, Right, Down, Left
    }
}

Состояния, которые должна отслеживать змейка:

  • Координаты каждого пикселя тела.
  • Длина змейки.
  • Текущее направление движения.
  • Прошлое направление для случая, когда нужно нарисовать символ изгиба вместо прямой линии.

Структура предоставляет метод расширения змеи на один элемент (возвращает false, если змея уже находится на полной длине), метод HitTest для теста столкновений пикселя тела, отрисовки змейки во FrameBuffer и метод обновления положения змеи как ответ на игровой тик (возвращает false, если змея съела себя). Существует также свойство, чтобы установить направление змеи.

Мы используем тот же трюк с фиксированным массивом, что и в буфере кадров, чтобы не использовать new. Это означает, что максимальная длина змеи должна быть постоянной времени компиляции. Последнее, что нам нужно  —  игровой цикл:

struct Game
{
    enum Result
    {
        Win, Loss
    }

    private Random _random;

    private Game(uint randomSeed)
    {
        _random = new Random(randomSeed);
    }

    private Result Run(ref FrameBuffer fb)
    {
        Snake s = new Snake(
            (byte)(_random.Next() % FrameBuffer.Width),
            (byte)(_random.Next() % FrameBuffer.Height),
            (Snake.Direction)(_random.Next() % 4));

        MakeFood(s, out byte foodX, out byte foodY);

        long gameTime = Environment.TickCount64;

        while (true)
        {
            fb.Clear();

            if (!s.Update())
            {
                s.Draw(ref fb);
                return Result.Loss;
            }

            s.Draw(ref fb);

            if (Console.KeyAvailable)
            {
                ConsoleKeyInfo ki = Console.ReadKey(intercept: true);
                switch (ki.Key)
                {
                    case ConsoleKey.UpArrow:
                        s.Course = Snake.Direction.Up; break;
                    case ConsoleKey.DownArrow:
                        s.Course = Snake.Direction.Down; break;
                    case ConsoleKey.LeftArrow:
                        s.Course = Snake.Direction.Left; break;
                    case ConsoleKey.RightArrow:
                        s.Course = Snake.Direction.Right; break;
                }
            }

            if (s.HitTest(foodX, foodY))
            {
                if (s.Extend())
                    MakeFood(s, out foodX, out foodY);
                else
                    return Result.Win;
            }

            fb.SetPixel(foodX, foodY, '*');

            fb.Render();

            gameTime += 100;

            long delay = gameTime - Environment.TickCount64;
            if (delay >= 0)
                Thread.Sleep((int)delay);
            else
                gameTime = Environment.TickCount64;
        }
    }

    void MakeFood(in Snake snake, out byte foodX, out byte foodY)
    {
        do
        {
            foodX = (byte)(_random.Next() % FrameBuffer.Width);
            foodY = (byte)(_random.Next() % FrameBuffer.Height);
        }
        while (snake.HitTest(foodX, foodY));
    }

    static void Main()
    {
        Console.SetWindowSize(FrameBuffer.Width, FrameBuffer.Height);
        Console.SetBufferSize(FrameBuffer.Width, FrameBuffer.Height);
        Console.Title = "See Sharp Snake";
        Console.CursorVisible = false;

        FrameBuffer fb = new FrameBuffer();

        while (true)
        {
            Game g = new Game((uint)Environment.TickCount64);
            Result result = g.Run(ref fb);

            string message = result == Result.Win ? "You win" : "You lose";

            int position = (FrameBuffer.Width - message.Length) / 2;
            for (int i = 0; i < message.Length; i++)
            {
                fb.SetPixel(position + i, FrameBuffer.Height / 2, message[i]);
            }

            fb.Render();

            Console.ReadKey(intercept: true);
        }
    }
}

Мы используем генератор случайных чисел для генерации случайного положения и направления змеи. Мы случайным образом размещаем еду на игровой поверхности так, чтобы она не перекрывала змею, и запускаем цикл игры. Внутри игрового цикла мы просим змею обновить свое положение и проверить, съела ли она сама себя. Затем рисуем змею, проверяем клавиатуру на ввод, тестируем змею с едой и отображаем всё на консоль. Давайте посмотрим, где мы находимся с точки зрения размера.

Размер змейки в .NET Core 3.0 по умолчанию

Я поместил игру в репозиторий GitHub, чтобы вы могли следить за ней. Файл проекта собирает игру в различных конфигурациях в зависимости от переданного при публикации свойства Mode . Чтобы создать конфигурацию по умолчанию с помощью CoreCLR, выполните:

dotnet publish -r win-x64 -c Release

Это приведет к созданию одного EXE-файла, имеющего колоссальный размер в 65 МБ. Этот файл включает в себя игру, рантайм .NET и библиотеки базовых классов, являющиеся стандартной частью .NET. Вы можете сказать: “Лучше, чем Electron”, но давайте посмотрим, сможем ли мы уменьшить размер.

Il Linker

IL Linker  —  это инструмент, поставляемый с .NET Core 3.0. Он удаляет неиспользуемые сборки. Чтобы включить его в проект, передайте свойство PublishTrimmed при публикации:

dotnet publish -r win-x64 -c Release /p:PublishTrimmed=true

С этой настройкой игра сжимается до 25 МБ. Хорошее сокращение, но оно далеко от нашей цели.

IL Linker имеет более агрессивные настройки, не выставляемые публично, и они могут работать дальше, но в конце концов, мы ограничимся размером самой среды выполнения CoreCLR coreclr.dll в 5,3 Мбайт. Возможно, мы зашли в тупик на пути к игре на 8 Кб?

Моно

Mono  —  еще одна среда выполнения .NET, для многих  —  синоним Xamarin. Чтобы создать исполняемый файл C#, мы используем mkbundle, поставляемый с Mono:

mkbundle SeeSharpSnake.dll --simple -o SeeSharpSnake.exe

Команда создаст исполняемый файл размером 12,3 МБ, зависящий от mono-2.0-sgen.dll, а она сама по себе весит 5,9 МБ, так что мы получили 18,2 MB в общей сложности. При попытке запустить его я получил сообщение Error mapping file: mono_file_map_error failed, но это ожидаемый баг. За исключением этой ошибки, все работает и наш результат  —  18,2 МБ.

В отличие от CoreCLR, Mono также зависит от распространяемой библиотеки среды выполнения Visual C++, недоступной в установке Windows по умолчанию: чтобы сохранить автономность приложения, нам нужно зашить эту библиотеку в приложение. Это увеличивает объем ещё на один мегабайт или около того.

Мы, вероятно, сможем сделать приложение меньше, добавив Il Linker, но тогда столкнемся с той же проблемой, что и с CoreCLR  —  размером среды выполнения mono-2.0-sgen.dll, он составляет 5,9 МБ. Плюс размер библиотек времени выполнения C++ поверх него. Это предел оптимизаций уровня IL.

Среда выполнения

Чтобы получить размер 8 Кб, нам нужно изъять из приложения среду выполнения или её часть. Единственная среда выполнения .NET, где это возможно  —  CoreRT. Хотя обычно CoreRT называют “средой выполнения”, она ближе к тому, чтобы быть “библиотекой среды выполнения”. Это не виртуальная машина, как CoreCLR или Mono. Среда выполнения CoreRT  —  это просто набор функций, поддерживающих заранее созданный компилятором Core RT машинный код.

CoreRT поставляется с библиотеками, делающими CoreRT похожим на любую другую среду выполнения .NET: есть библиотека, добавляющая GC, библиотека поддержки рефлексии, библиотека, добавляющая JIT, библиотека, добавляющая интерпретатор и т.д. Но все они необязательны, включаяGC.

Давайте посмотрим, где мы находимся теперь с конфигурацией CoreRT по умолчанию:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT

4,7 МБ. Файл пока самый маленький, но этого недостаточно.

Умеренная экономия размера в CoreRT

Компилятор CoreRT предлагает огромное количество настроек, влияющих на генерацию кода. По умолчанию компилятор пытается максимизировать скорость сгенерированного кода и совместимость с другими средами выполнения .NET за счет размера сгенерированного исполняемого файла.

Компилятор имеет встроенный компоновщик, удаляющий неиспользуемый код. Параметр “CoreRT-Moderate”, определяемый в проекте Snake, ослабляет одно из ограничений на удаление неиспользуемого кода, что позволяет удалить ещё больше. Мы также просим компилятор обменять скорость программы на дополнительные байты. Большинство программ .NET работает просто отлично в этом режиме.

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-Moderate

Сейчас мы на уровне 4,3 МБ.

Включение сильной экономии в CoreRT

Я сгруппировал еще несколько вариантов компиляции в режим “сильной экономии”. Режим удаляет поддержку возможностей, важных для многих приложений, но не для нашей змейки. Мы удаляем:

  • Данные трассировки стека для деталей реализации фреймворка.
  • Сообщения об исключениях в рамках фреймворка.
  • Поддержку неанглийских языков.
  • Инструментарий EventSource.
dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-High

Мы достигли 3,0 МБ, это 5% от начального размера, но у CoreRT есть еще один трюк.

Отключение рефлексии

Существенная часть библиотек времени выполнения CoreRT посвящена реализации рефлексии .NET. Поскольку CoreRT  —  это заранее скомпилированная реализация .NET на основе библиотеки времени выполнения, она не нуждается в большинстве структур данных, необходимых типичной среде выполнения на основе виртуальной машины. Эти данные включают такие вещи, как имена типов, методы, подписи, базовые типы и т.д. CoreRT внедряет эти данные, потому что они нужны программам, использующим рефлексию .NET, но не потому, что это необходимо для работы среды выполнения. Я называю эти данные “налогом на рефлексию”, потому что это то, что нужно для выполнения.

CoreRT поддерживает режим без рефлексии, позволяющий избежать оверхеда. Вы можете чувствовать, что много кода .NET не работает без рефлексии, и вы правы, но удивительное количество вещей всё-таки работает: Gui.cs, System.IO.Pipelines или даже базовое приложение WinForms. Змейка точно заработает, так что включаем этот режим:

dotnet publish -r win-x64 -c Release /p:Mode=CoreRT-ReflectionFree

Сейчас мы на уровне 1,2 МБ. Оверхед на рефлексию довольно значителен.

Пачкаем руки

Мы достигли предела возможностей .NET SDK, и теперь нам нужно запачкать руки. То, что мы собираемся сделать сейчас, начинает быть смешным, и я бы не ожидал, что кто-то еще это сделает. Мы будем полагаться на детали реализации компилятора CoreRT и среды выполнения.

Как мы уже видели ранее, CoreRT — это набор библиотек времени выполнения в сочетании с опережающим компилятором. Что делать, если мы заменим библиотеки времени выполнения с минимальным переопределением? Мы решили не использовать сборщик мусора, и это делает работу намного более выполнимой. Начнём с простого:

namespace System.Threading
{
    static class Thread
    {
        [DllImport("api-ms-win-core-synch-l1-2-0")]
        public static extern void Sleep(int delayMs);
    }
}

namespace System
{
    static class Environment
    {
        [DllImport("api-ms-win-core-sysinfo-l1-1-0")]
        private static extern long GetTickCount64();

        public static long TickCount64 => GetTickCount64();
    }
}

Мы просто переопределили Thread.SleepиEnvironment.TickCount64(для Windows), избегая всех зависимостей от существующей библиотеки времени выполнения. Делаем то же самое для подмножества System.Console, используемого игрой:

namespace System
{
    static class Console
    {
        private enum BOOL : int
        {
            FALSE = 0,
            TRUE = 1,
        }

        [DllImport("api-ms-win-core-processenvironment-l1-1-0")]
        private static unsafe extern IntPtr GetStdHandle(int c);

        private readonly static IntPtr s_outputHandle = GetStdHandle(-11);

        private readonly static IntPtr s_inputHandle = GetStdHandle(-10);

        [DllImport("api-ms-win-core-console-l2-1-0.dll", EntryPoint = "SetConsoleTitleW")]
        private static unsafe extern BOOL SetConsoleTitle(char* c);
        public static unsafe string Title
        {
            set
            {
                fixed (char* c = value)
                    SetConsoleTitle(c);
            }
        }

        [StructLayout(LayoutKind.Sequential)]
        struct CONSOLE_CURSOR_INFO
        {
            public uint Size;
            public BOOL Visible;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleCursorInfo(IntPtr handle, CONSOLE_CURSOR_INFO* cursorInfo);

        public static unsafe bool CursorVisible
        {
            set
            {
                CONSOLE_CURSOR_INFO cursorInfo = new CONSOLE_CURSOR_INFO
                {
                    Size = 1,
                    Visible = value ? BOOL.TRUE : BOOL.FALSE
                };
                SetConsoleCursorInfo(s_outputHandle, &cursorInfo);
            }
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleTextAttribute(IntPtr handle, ushort attribute);

        public static ConsoleColor ForegroundColor
        {
            set
            {
                SetConsoleTextAttribute(s_outputHandle, (ushort)value);
            }
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct KEY_EVENT_RECORD
        {
            public BOOL KeyDown;
            public short RepeatCount;
            public short VirtualKeyCode;
            public short VirtualScanCode;
            public short UChar;
            public int ControlKeyState;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct INPUT_RECORD
        {
            public short EventType;
            public KEY_EVENT_RECORD KeyEvent;
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "PeekConsoleInputW", CharSet = CharSet.Unicode)]
        private static unsafe extern BOOL PeekConsoleInput(IntPtr hConsoleInput, INPUT_RECORD* lpBuffer, uint nLength, uint* lpNumberOfEventsRead);

        public static unsafe bool KeyAvailable
        {
            get
            {
                uint nRead;
                INPUT_RECORD buffer;
                while (true)
                {
                    PeekConsoleInput(s_inputHandle, &buffer, 1, &nRead);

                    if (nRead == 0)
                        return false;

                    if (buffer.EventType == 1 && buffer.KeyEvent.KeyDown != BOOL.FALSE)
                        return true;

                    ReadConsoleInput(s_inputHandle, &buffer, 1, &nRead);
                }
            }
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "ReadConsoleInputW", CharSet = CharSet.Unicode)]
        private static unsafe extern BOOL ReadConsoleInput(IntPtr hConsoleInput, INPUT_RECORD* lpBuffer, uint nLength, uint* lpNumberOfEventsRead);

        public static unsafe ConsoleKeyInfo ReadKey(bool intercept)
        {
            uint nRead;
            INPUT_RECORD buffer;
            do
            {
                ReadConsoleInput(s_inputHandle, &buffer, 1, &nRead);
            }
            while (buffer.EventType != 1 || buffer.KeyEvent.KeyDown == BOOL.FALSE);

            return new ConsoleKeyInfo((char)buffer.KeyEvent.UChar, (ConsoleKey)buffer.KeyEvent.VirtualKeyCode, false, false, false);
        }

        struct SMALL_RECT
        {
            public short Left, Top, Right, Bottom;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleWindowInfo(IntPtr handle, BOOL absolute, SMALL_RECT* consoleWindow);

        public static unsafe void SetWindowSize(int x, int y)
        {
            SMALL_RECT rect = new SMALL_RECT
            {
                Left = 0,
                Top = 0,
                Right = (short)(x - 1),
                Bottom = (short)(y - 1),
            };
            SetConsoleWindowInfo(s_outputHandle, BOOL.TRUE, &rect);
        }

        [StructLayout(LayoutKind.Sequential)]
        struct COORD
        {
            public short X, Y;
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleScreenBufferSize(IntPtr handle, COORD size);

        public static void SetBufferSize(int x, int y)
        {
            SetConsoleScreenBufferSize(s_outputHandle, new COORD { X = (short)x, Y = (short)y });
        }

        [DllImport("api-ms-win-core-console-l2-1-0")]
        private static unsafe extern BOOL SetConsoleCursorPosition(IntPtr handle, COORD position);

        public static void SetCursorPosition(int x, int y)
        {
            SetConsoleCursorPosition(s_outputHandle, new COORD { X = (short)x, Y = (short)y });
        }

        [DllImport("api-ms-win-core-console-l1-2-0", EntryPoint = "WriteConsoleW")]
        private static unsafe extern BOOL WriteConsole(IntPtr handle, void* buffer, int numChars, int* charsWritten, void* reserved);

        public static unsafe void Write(char c)
        {
            int dummy;
            WriteConsole(s_outputHandle, &c, 1, &dummy, null);
        }
    }
}

Пересоберём игру с заменой фреймворка:

dotnet publish-r win-x64-C Release / p: Mode=CoreRT-ReflectionFree /p: IncludePal=true

Неудивительно, что это не слишком эффективно. Заменяемые API уже относительно легки, переписывание только добавляет пару килобайт, о которых не стоит упоминать. Но это важная ступенька к последнему шагу нашего путешествия.

Замена библиотек среды выполнения

Оставшиеся 1,2 МБ кода и данных в игре  —  это поддержка вещей, которые мы не видим, но они есть, они готовы, если нам понадобятся. Есть сборщик мусора, поддержка обработки исключений, код для форматирования и печати трассировок стека на консоль, когда происходит необработанное исключение, и многие другие вещи под капотом.

Компилятор может обнаружить, что ничего этого не требуется, и избежать их генерации, но то, что мы пытаемся сделать, настолько странно, что неплохо добавить функции компилятора для его поддержки. Способ избежать этого  —  просто предоставить альтернативную библиотеку времени выполнения. Начнем с переопределения минимальной версии базовых типов:

namespace System
{
    public class Object
    {
        // Слой объекта - контракт с компилятором.
        public IntPtr m_pEEType;
    }
    public struct Void { }

    /* Типам ниже для работы не нужны подя. */ 
    public struct Boolean { }
    public struct Char { }
    public struct SByte { }
    public struct Byte { }
    public struct Int16 { }
    public struct UInt16 { }
    public struct Int32 { }
    public struct UInt32 { }
    public struct Int64 { }
    public struct UInt64 { }
    public struct IntPtr { }
    public struct UIntPtr { }
    public struct Single { }
    public struct Double { }

    public abstract class ValueType { }
    public abstract class Enum : ValueType { }

    public struct Nullable<T> where T : struct { }
    
    public sealed class String
    {
        // Слой строки - контракт с компилятором.
        public readonly int Length;
        public char _firstChar;

        public unsafe char this[int index]
        {
            [System.Runtime.CompilerServices.Intrinsic]
            get
            {
                return Internal.Runtime.CompilerServices.Unsafe.Add(ref _firstChar, index);
            }
        }
    }
    public abstract class Array { }
    public abstract class Delegate { }
    public abstract class MulticastDelegate : Delegate { }

    public struct RuntimeTypeHandle { }
    public struct RuntimeMethodHandle { }
    public struct RuntimeFieldHandle { }

    public class Attribute { }
}

namespace System.Runtime.CompilerServices
{
    internal sealed class IntrinsicAttribute : Attribute { }

    public class RuntimeHelpers
    {
        public static unsafe int OffsetToStringData => sizeof(IntPtr) + sizeof(int);
    }
}

namespace System.Runtime.InteropServices
{
    public enum CharSet
    {
        None = 1,
        Ansi = 2,
        Unicode = 3,
        Auto = 4,
    }

    public sealed class DllImportAttribute : Attribute
    {
        public string EntryPoint;
        public CharSet CharSet;
        public DllImportAttribute(string dllName) { }
    }

    public enum LayoutKind
    {
        Sequential = 0,
        Explicit = 2,
        Auto = 3,
    }

    public sealed class StructLayoutAttribute : Attribute
    {
        public StructLayoutAttribute(LayoutKind layoutKind) { }
    }
}
namespace Internal.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        // Тело метода генерирует компилятор.
        // Делает то же, что Unsafe.Add.
        [System.Runtime.CompilerServices.Intrinsic]
        public static extern ref T Add<T>(ref T source, int elementOffset);
    }
}

Теперь откажемся от файла проекта и dotnet CLI, запустим отдельные инструменты напрямую. Мы начинаем с запуска компилятора C# (CSC). Я рекомендую запускать эти команды из “x64 Native Tools Command Prompt for VS 2019” — он находится в меню Пуск, если у вас установлена Visual Studio.

/noconfig,/nostdlib, и /runtimemetadataversion  —  волшебные параметры, необходимыми для компиляции чего-то, определяющего System.Object. Я выбрал расширение .ilexe, потому что .exe мы используем для готового продукта.

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafe

Это позволит успешно скомпилировать версию байт-кода IL игры с компилятором C#. Нам все еще нужна какая-то среда выполнения, чтобы выполнить приложение. Попробуем передать это в CoreRT для создания нативного кода из IL. Если вы выполнили описанные выше шаги, вы найдете ilc.exe, компилятор CoreRT, в вашем кэше пакетов NuGet где-то в %USERPROFILE%\.nuget\packages\runtime.win-x64.microsoft.dotnet.ilcompiler\1.0.0-alpha-27402–01\Tools.

ilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

Произойдет сбой Expected type ‘Internal.Runtime.CompilerHelpers.StartupCodeHelpers’ not found in module ‘zerosnake’. Оказывается, помимо очевидного минимума, ожидаемого разработчиком управляемого кода, есть также минимум, в котором компилятор CoreRT нуждается для компиляции ввода. Добавим необходимое:

namespace Internal.Runtime.CompilerHelpers
{
/* Искомый компилятором класс, имеющий помощников для инициализации процесса. Компилятор может изящно обращаться с отсутствующими помощниками, но сам отсутствующий класс остается необработанным. Давайте добавим пустой класс. */

    class StartupCodeHelpers
    {
    }
}

namespace System
{
/* Специальный тип, используемый компилятором для реализации дженериков, например, IEnumerable<T> массивов. Наши массивы не будут реализовывать дженерики. */
    class Array<T> : Array { }
}

namespace System.Runtime.InteropServices
{
/* Пользовательский атрибут, помечающий класс как имеющий специальные встроенные функции "вызова". У компилятора специальная логика обработки с этим атрибутом. */
    internal class McgIntrinsicsAttribute : Attribute { }
}

namespace System.Runtime.CompilerServices
{
/* Ответственный за выполнение статических конструкторов класс. Код  для запуска статических конструкторов и их выполнения единожды. */
    [System.Runtime.InteropServices.McgIntrinsics]
    internal static class ClassConstructorRunner
    {
        private static unsafe IntPtr CheckStaticClassConstructionReturnNonGCStaticBase(ref StaticClassConstructionContext context, IntPtr nonGcStaticBase)
        {
            CheckStaticClassConstruction(ref context);
            return nonGcStaticBase;
        }

        private static unsafe void CheckStaticClassConstruction(ref StaticClassConstructionContext context)
        {
/* Очень упрощенный исполнитель конструктора класса. В реальном мире ему необходимо будет иметь возможность работать с потенциально несколькими потоками, участвующими в гонке инициализации класса, и возможность справиться с потенциальными блокировками между конструкторами. */
// Если класс инициализирован, мы закончили.
            if (context.initialized == 1)
                return;

            // Помечает класс как инициализированный.
            context.initialized = 1;

            // Выполняет конструктор класса.
            Call<int>(context.cctorMethodAddress);
        }

        // Специальная функция, вызывающая метод, указывающий на pfn.
        // Компилятор генерирует код. Мы можем просто сделать пометку extern.
        // Это станет не нужно, когда язык будет поддерживать указатель на функцию. Планируется в C# 9.
        [System.Runtime.CompilerServices.Intrinsic]
        private static extern T Call<T>(System.IntPtr pfn);
    }

/* Эта структура данных - контракт с компилятором. Она содержит адрес статического конструктора и флаг, определяющий, был ли конструктор выполнен. */
    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    public struct StaticClassConstructionContext
    {
        //   Указатель на код для метода конструктора статического класса. Инициализируется binder/runtime.
        public IntPtr cctorMethodAddress;
/*  Состояние инициализации класса. Этот параметр инициализируется значением 0. Каждый раз, когда управляемый код проверяет состояние конструктора, среда выполнения вызывает CheckStaticClassConstruction с этим контекстом. Структура без инициализации == 1. Эта проверка нужна, чтобы разрешить хранить больше, чем бинарное состояние для каждого конструктора. */
        public int initialized;
    }
}

Перестроим IL с добавленным кодом и повторно запустим ILC.

csc.exe /debug /O /noconfig /nostdlib /runtimemetadataversion:v4.0.30319 MiniRuntime.cs MiniBCL.cs Game\FrameBuffer.cs Game\Random.cs Game\Game.cs Game\Snake.cs Pal\Thread.Windows.cs Pal\Environment.Windows.cs Pal\Console.Windows.cs /out:zerosnake.ilexe /langversion:latest /unsafeilc.exe zerosnake.ilexe -o zerosnake.obj --systemmodule zerosnake --Os -g

Теперь у нас есть zerosnake.obj  —  стандартный объектный файл, который ничем не отличается от объектных файлов, создаваемых другими нативными компиляторами, такими как C или C++. Последний шаг  —  связать его. Воспользуемся link.exe, он должен быть в “x64 Native Tools Command Prompt”. Возможно, вам потребуется установить средства разработки C/C++ в Visual Studio.

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main

Имя символа __managed__Main является контрактом с компилятором — это имя управляемой точки входа программы, созданной ILC. Но команда не работает:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol SetConsoleTextAttribute
error LNK2001: unresolved external symbol WriteConsoleW
error LNK2001: unresolved external symbol GetStdHandle
...
fatal error LNK1120: 17 unresolved externals

Некоторые из этих символов кажутся знакомыми: компоновщик не знает, где искать вызываемые API Windows. Добавим библиотеки для них:

link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib

Выглядит лучше, всего 4 неразрешенных символа:

error LNK2001: unresolved external symbol RhpPInvoke
error LNK2001: unresolved external symbol RhpPInvokeReturn
error LNK2001: unresolved external symbol RhpReversePInvoke2
error LNK2001: unresolved external symbol RhpReversePInvokeReturn2
fatal error LNK1120: 4 unresolved externals

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

Помощники добавляют и удаляют кадры стека, когда машинный код вызывается управляемым или управляемый  —  машинным. Это необходимо, чтобы работала сборка мусора. Поскольку у нас нет GC, давайте заглушим их кодом C# и другим волшебным атрибутом, который поймёт наш компилятор:

namespace System.Runtime
{
  /* Пользовательский атрибут для экспорта метода под заданным символьным именем. */
    internal sealed class RuntimeExportAttribute : Attribute
    {
        public RuntimeExportAttribute(string entry) { }
    }
}

namespace Internal.Runtime.CompilerHelpers
{
    class StartupCodeHelpers
    {
        // Тип содержимого для этих методов не важен.
        
        [System.Runtime.RuntimeExport("RhpReversePInvoke2")]
        static void RhpReversePInvoke2(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpReversePInvokeReturn2")]
        static void RhpReversePInvokeReturn2(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpPInvoke")]
        static void RhpPinvoke(System.IntPtr frame) { }
        [System.Runtime.RuntimeExport("RhpPInvokeReturn")]
        static void RhpPinvokeReturn(System.IntPtr frame) { }
    }
}

После перестроения исходного кода с этими изменениями и повторного запуска ILC, связывание, наконец, будет успешным. Мы сейчас на 27 килобайтах. Игра работает!

Возня с линкером

Оставшиеся килобайты можно получить с помощью трюков, которые используют разработчики нативного кода, чтобы уменьшить свои приложения. Мы собираемся:

  • Отключить инкрементное связывание.
  • Обрезать информацию о релокации.
  • Объединить похожие разделы внутри исполняемого файла.
  • Установить внутреннее выравнивание в небольшое значение
link.exe /debug:full /subsystem:console zerosnake.obj /entry:__managed__Main kernel32.lib ucrt.lib /merge:.modules=.rdata /merge:.pdata=.rdata /incremental:no /DYNAMICBASE:NO /filealign:16 /align:16

8176 байт! Игра все еще работает и, что интересно, она полностью отлаживаема. Вы можете отключить оптимизацию в ILC, чтобы сделать исполняемый файл еще более отладочным: просто удалите аргумент --Os.

Ещё меньше?

Исполняемый файл ещё содержит несущественные данные  —  компилятор ILC просто не предоставляет параметры командной строки, отключающие их генерацию.

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

Поскольку у нас нет сборщика мусора, эти данные не нужны. Другие среды выполнения  —  например Mono  —  используют консервативный сборщик, не требующий этих данных. Он просто предполагает, что любая часть стека и регистров процессора может быть ссылкой GC. Консервативный сборщик торгует производительностью GC ради экономии размера. Точный сборщик CoreRT также может работать в консервативном режиме, но он еще не подключен. Это потенциальное будущее дополнение, которое мы могли бы использовать, чтобы сделать программу ещё меньше. Может, однажды мы сможем сделать упрощенную версию нашей игры в 512 байт загрузочного сектора. А до тех пор  —  счастливого кода!

Читайте также:


Перевод статьи Michal Strehovský: Building a self-contained game in C# under 8 kilobytes