Вот вам тупой вопрос, который вы сами, наверное, никогда себе не задавали. Каково минимальное количество байт, которые необходимо сохранить в исполняемом .NET-файле, чтобы CLR напечатала "Hello, World!" в консоли стандартного вывода?

Насколько сможем уменьшиться?
Насколько сможем уменьшиться?

В этом посте мы исследуем пределы файлового формата модулей .NET. Ужмём модуль, насколько это вообще возможно, но чтобы при этом он остался функционален и работал как обычный исполняемый файл на типичной машине с Windows, где установлен .NET Framework.

Окончательный вариант исходного кода к этому посту выложен на GitHub:

Полный исходный код

Правила

Вот правила, которые я сам для себя установил:

  • В приложении должна применяться управляемая входная точка, реализованная на C# или CIL. Эта входная точка должна отвечать за печать "Hello, World!" в стандартном выводе. Таким образом, мы не вправе проделывать с нативной входной точкой никаких фокусов, подобных тем, что показаны в этом посте. Сам процесс, как именно будет выполняться вывод текста, полностью определяется в теле этого метода.

  • Приложение работает на .NET Framework 4.x.x. Это делается, чтобы немного развязать себе руки, а также позволяет обойтись единственным исполняемым файлом и использовать некоторые возможности загрузчика Windows PE. Кроме того, приятно иметь исполняемый файл, который можно запустить простым двойным щелчком.

  • Никаких сторонних зависимостей. Разрешено ссылаться только на BCL (напр., mscorlib) и/или другие библиотеки, устанавливаемые на типичной машине с Windows. В противном случае было бы можно заменить в нашем маленьком приложении весь код, просто вызвав специально предусмотренную зависимость, а это было бы жульничество!  

  • Игнорируем нулевые байты в конце файла. Формат файлов PE, равно как и сама CLR, жёстко ограничивает выравнивание со сдвигом для каждой секции, хранимой в PE. На практике это означает, что минимально возможный файл .NET PE, который мог бы работать на Windows 10 или выше, не ужать менее чем до 1KB. Как видим, всё это весьма легко достижимо. Чтобы немного усложнить себе задачу, постараемся сделать «абсолютно минимальное описание» PE-файла «Hello World» для .NET, где будем считать, что никаких нулевых байт в хвосте у нас нет.

Итак, приступим!

К чему стремиться

Чтобы сформулировать базис, которого мы постараемся достичь, давайте для начала скомпилируем следующее приложение Hello World. Для этого воспользуемся новейшей (на момент создания этого поста) версией компилятора C#.

	using System;

namespace ConsoleApp1;

internal static class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine("Hello, World!");
    }
}

Присовокупим к этому следующий файл .csproj:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
        <LangVersion>10</LangVersion>
        <Nullable>enable</Nullable>
    </PropertyGroup>

</Project>

В результате у нас получается бинарник размером целых 4,6 КБ:

Размер стандартного приложения hello world.
Размер стандартного приложения hello world.

Как-то это многовато… Разумеется, можно было бы сделать лучше.

Удаляем аннотации ссылок, допускающих null

Изучив приложение в декомпиляторе .NET, начинаем лучше понимать, что же тут происходит. Начиная с версии 8.0 в C# существует такая концепция: ссылочные типы, допускающие null. Это специальные аннотации, по которым компилятор C# может судить о потенциально нежелательных нулевых ссылках, которые могут передаваться функциям, переменным и параметрам. Недостаток таких аннотаций в том, что они реализуются в виде пользовательских атрибутов, а такие атрибуты статически связываются в исполняемом файле и поэтому известны своей огромностью:

Ссылочные типы, допускающие null, привносят в образ .NET множество пользовательских атрибутов
Ссылочные типы, допускающие null, привносят в образ .NET множество пользовательских атрибутов

Давайте отключим их в нашем файле .csproj при помощи одной опции:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
        <LangVersion>10</LangVersion>
        
         <!-- отключаем проверку на ссылочные типы, допускающие null. -->
        <Nullable>disable</Nullable>
    </PropertyGroup>

</Project>

Притом, что нам удалось избавиться от всех атрибутов, но всё равно имеем дело с бинарником в 4,6 КБ. Всё дело в выравнивании файлов в PE.

Вручную создаём модуль .NET

Изучаем вывод декомпилятора далее и видим, что даже с отключёнными ссылками, допускающими null, компилятор C# всё равно выдаёт множество ссылок типов на пользовательские атрибуты в нашем приложении. В частности, они содержат множество атрибутов, присваиваемых самой сборке (таковы, например, метаданные о версии файла и информацию об авторских правах). Кроме того, наряду с классом Program у нас есть скрытый тип <Module>, который выглядит практически пустым:

Компилятор C# по-прежнему выдаёт множество ненужных метаданных
Компилятор C# по-прежнему выдаёт множество ненужных метаданных

Можно было бы попытаться выяснить, как отключить в компиляторе генерацию всех этих метаданных, но я рассудил, что, коль скоро мы стремимся к абсолютному минимуму, мы вполне могли бы сами собрать с нуля исполняемый файл .NET. Так мы полнее контролировали бы итоговый вывод, и компилятор мог бы выдавать только тот минимум, что нужен для печати "Hello World", обходясь без всех этих избыточных файловых метаданных. Ещё: можем просто поместить нашу функцию main в типе <Module>, таким образом также избавившись от класса Program. Ниже в качестве примера дана реализация, в которой мы собираем маленькое приложение Hello World при помощи AsmResolver:

// определяем новую сборку и модуль
var assembly = new AssemblyDefinition("assembly", new Version(1, 0, 0, 0));
var module = new ModuleDefinition("module.exe");
assembly.Modules.Add(module);

// получаем тип <Module>.
var moduleType = module.GetOrCreateModuleType();

// Напишем новый метод Main
var factory = module.CorLibTypeFactory;
var main = new MethodDefinition("main", MethodAttributes.Static, MethodSignature.CreateStatic(factory.Void));
main.CilMethodBody = new CilMethodBody(main)
{
    Instructions =
    {
        {Ldstr, "Hello, World!"},
        {Call, factory.CorLibScope
            .CreateTypeReference("System","Console")
            .CreateMemberReference("WriteLine", MethodSignature.CreateStatic(factory.Void, factory.String))
            .ImportWith(module.DefaultImporter)
        },
        Ret
    }
};

// Добавим main в <Module>
moduleType.Methods.Add(main);

// Зарегистрируем main как входную точку этого модуля:
module.ManagedEntryPointMethod = main;

// Запишем на диск.
module.Write("output.exe");

Мы уже славно поработали, смогли ужать файл наполовину:

Размер приложения hello world, выданного нам AsmResolver
Размер приложения hello world, выданного нам AsmResolver

Но можно сделать ещё лучше…

Избавляемся от импортов и перемещения адресов

Если рассмотреть получившийся у нас исполняемый файл в таком инструменте как CFF Explorer, то в этом файле заметим два раздела: .text и .reloc. Кроме того, в нём ещё содержится два очень больших каталога с данными, которые называются Imports и Base Relocations.

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

Такая ситуация весьма типична для любого AnyCPU или 32-разрядного исполняемого файла .NET. Директория с импортами необходима потому, что 32-разрядные исполняемые файлы .NET требуют неуправляемой входной точки под названием mscoree!_CorExeMain, что было рассмотрено в другом посте автора. Более того, по умолчанию исполняемые файлы .NET переносимы, то есть загрузчик Windows PE волен отобразить исполняемый файл на любой подходящий адрес в памяти. Таким образом, каждый 32-разрядный исполняемый файл .NET должен уметь обращаться с перемещением адресов, чтобы вызов к этой импортированной функции мог быть зарегистрирован в директории перемещения. Это проблематично, так как по умолчанию он укладывается в совершенно отдельный раздел. Этот раздел, как и любой другой, должен выравниваться по границе мельчайшего возможного раздела, равного 0x200 байт (1 КБ), поэтому мы раздуваем наш файл, как минимум, на это количество байт.  

К счастью для нас, 64-разрядные исполняемые файлы .NET больше не нуждаются в такой неуправляемой входной точке. Добавив в вышеприведённый скрипт всего две строки, мы можем избавиться от обеих директорий и всего раздела PE и, следовательно, счесать с нашего бинарника ещё целый килобайт:

// Вывод 64-разрядного модуля.
module.PEKind = OptionalHeaderMagic.PE64;
module.MachineType = MachineType.Amd64;
64-разрядные образы .NET не нуждаются ни в импортах, ни в перемещениях адресов
64-разрядные образы .NET не нуждаются ни в импортах, ни в перемещениях адресов

Действительно, мы дошли до теоретически возможного минимального размера в 1 КБ, и у нас по-прежнему рабочий файл .NET PE:

Достигнут минимальный размер PE-файла.
Достигнут минимальный размер PE-файла.

Избавляемся от имён метаданных

На этом можно было бы закончить, но я решил немного глубже закопаться в задачу и посмотреть, что ещё можно было бы отсечь от бинарника и ещё сильнее минифицировать исполняемый файл hello world для .NET. Начиная с этого шага, мы не будем обращать внимания на то, каков размер файла по мнению Windows Explorer. Вместо этого обратимся к hex-редактору, разберёмся, где именно хранятся все до единого ненулевые байты, и уже тогда сможем поговорить об окончательном размере нашего файла. Применительно к тому файлу, который мы исследуем сейчас, уже видно, что нам удалось ужать его до 991 байта (0x3DF):

Интересующий нас размер равен индексу того байта, который следует за последним ненулевым байтом в файле.
Интересующий нас размер равен индексу того байта, который следует за последним ненулевым байтом в файле.

Из чего же складывается остающийся у нас на настоящий момент набор байт? Вновь заглянув в дизассемблер, можем заметить, что куча #Strings в двоичном файле .NET — это второй по размеру набор метаданных, сохранённый в файле. В нём содержатся все имена, применяемые в табличном потоке (#~), а в этом потоке обычно хранят все типы и методы, которые определяет и использует наше приложение. Оказывается, во время выполнения многие из этих имён, в сущности, неважны:

Имена занимают много места
Имена занимают много места

Следовательно, если установим их все в null, то действительно получим приложение, которое имеет примерно следующий вид:

Усечение имён
Усечение имён

Хотите — верьте, хотите — нет, но приложение по-прежнему нормально работает и с готовностью выводит“Hello World”, даже если такой метод выглядит некрасиво. Самое приятное, что таким маневром мы сбрили с нашего файла ещё целых 32 байта:

Размер файла после усечения имён
Размер файла после усечения имён

Избавляемся ещё от некоторых ненужных метаданных

Какие тут ещё есть ненужные метаданные, по поводу которых CLR особенно не беспокоится? Наша следующая цель — избавиться от потока #GUID. Этот поток присутствует практически в любом исполняемом файле .NET и, как понятно из названия, содержит список GUID. Правда, единственный тип метаданных, на которые он ссылается — это таблица Module. В этой таблице есть столбец Mvid; в нём должна стоять ссылка на тот GUID, который служит уникальным идентификатором модуля, позволяя находить его среди различных версий скомпилированных бинарников.

В модуле содержится опциональный MVID, представляющий собой GUID из 16 байт.
В модуле содержится опциональный MVID, представляющий собой GUID из 16 байт.

Версионирование нас не волнует, нам ведь нужен просто минимально возможный бинарник. Соответственно, и от этого компонента можно избавиться и сэкономить ещё 16 байт, которые исходно занимал Mvid. Правда, после такой манипуляции поток #GUID оказывается пуст, и поэтому тоже больше не нужен. Удалив весь этот поток, мы выигрываем ещё 16 байт, из которых состоял его заголовок; суммарно нам удалось выручить на этом шаге 32 байта.

Кроме того, есть метод Console::WriteLine, вызываемый нами в функции Main, он определяется в mscorlib. Как правило, ссылки на сборки BCL аннотируются публичным ключом — это токен размером 8 байт.

Ссылка на mscorlib содержит длинный токен публичного ключа.
Ссылка на mscorlib содержит длинный токен публичного ключа.

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

Вот у нас получился файл, в котором всего 918 байт:

Размер после удаления GUID и токенов публичного ключа
Размер после удаления GUID и токенов публичного ключа

Избавляемся от Console.WriteLine

Если посмотреть другие потоки метаданных, определяемые в нашей сборке, выяснится, что строка "Hello, World!" хранится у нас довольно неэффективным образом. В .NET все пользовательские строки кладутся в поток метаданных #US в виде массива с префиксом, указывающим его длину; этот массив состоит из символов по 16 разрядов в ширину, а за этими символами следует дополнительный нулевой байт. Такой подход  избран ради поддержки широкого набора символов UNICODE. Однако, все символы в той строке, которую мы хотим вывести на экран, обозначаются кодовым значением с точкой, размер которого менее 255 (0xFF), это максимальное значение, укладываемое в 1 байт. Зачем же нам тогда использовать по 2 байта на символ? Более того, это единственная пользовательская строка, которая понадобится нам в нашем бинарнике. Иметь полновесный 12-байтовый заголовок потока всего для одной строки кажется довольно избыточным:

Пользовательские строки в .NET всегда используют широкую символьную кодировку
Пользовательские строки в .NET всегда используют широкую символьную кодировку

К сожалению, не существует способа превратить эту широкосимвольную строку потока #US в однобайтовую строку ASCII, а также сообщить CLR, чтобы она соответствующим образом её интерпретировала.

Время проявить изобретательность!

Если бы мы хотели ввести на экран строку ASCII там, где предполагается широкосимвольная строка, нам бы понадобилась функция, принимающая строки такого типа. Функция Console::WriteLine не соответствует этому критерию, поэтому от неё нужно избавиться. Зато соответствует неуправляемая функция ucrtbase!puts. В .NET для вызова неуправляемых функций предусмотрена возможность под названием Platform Invoke (P/Invoke). Воспользовавшись P/Invoke, можно определить puts using P/Invoke в C#  следующим образом:

[DllImport("ucrtbase")]
static extern int puts(nint str);

Но здесь есть проблема. Функция puts принимает указатель на строку. Этот указатель должен быть действительным адресом времени исполнения и указывать на строку ASCII, завершаемую нулём, ту, которую мы хотим напечатать. Как нам узнать, где наша строка была сохранена во время компиляции, так, чтобы мы могли задвинуть её в наш метод main?

Оказывается, это решается так: достаточно снять флаг DynamicBase в поле DllCharacteristics опционального заголовка PE. Так мы сможем зафиксировать тот базовый адрес, на который модуль будет отображаться во время выполнения. После этого можем сами определить произвольный базовый адрес, поместить строку ASCII где угодно в нашем разделе .text и вычислить адрес времени выполнения по формуле module_base_address + rva_ascii_string.

var image = module.ToPEImage();

image.ImageBase = 0x00000000004e0000;
image.DllCharacteristics &= ~DllCharacteristics.DynamicBase;

Чтобы заставить CLR соблюдать этот флаг, также нужно убрать флаг ILOnly в директории с данными .NET:

image.DotNetDirectory!.Flags &= ~DotNetDirectoryFlags.ILOnly;

После этого можно просто передать вычисленный адрес как обычное целое число прямо в вызов нашей функции puts:

Заменить Console::WriteLine на ucrtbase!puts, чтобы в данном случае можно было пользоваться строкой ASCII.
Заменить Console::WriteLine на ucrtbase!puts, чтобы в данном случае можно было пользоваться строкой ASCII.

Вот как получается: мы избавились не только от широкосимвольной строки, но и от всего потока #US, а также от ссылки на System.Console::WriteLine, которая также увеличивала размер нашего файла на несколько байт. Правда, несколько байт нам вернулось обратно, так как появилось определение нового обязательного метода puts и связанные с ним метаданные P/Invoke, но это, конечно же, для большой экономии.

И вот мы добрались до 889 байт (0x379):

Размер файла после удаления Console::WriteLine и с использованием ASCII-строк
Размер файла после удаления Console::WriteLine и с использованием ASCII-строк

Другие микро-оптимизации

Есть ещё несколько вещей, которые вполне можно сделать.

Наше определение puts имеет каноническую форму, заданную в библиотеке исполнения C. Таким образом, функция определена в расчёте на возврат числа int32, означающего количество символов, которые были записаны в стандартный вывод. Однако нас это ограничение не интересует. Действительно, в методе main мы выталкиваем это значение прямо после вызова, только чтобы удовлетворить CLR:

Возврат int32 означает, что значение должно вновь всплыть из стека интерпретации.
Возврат int32 означает, что значение должно вновь всплыть из стека интерпретации.

Поскольку это, так или иначе, 64-разрядный файл PE, функция puts будет использовать принятые в x64 соглашения о вызовах, описанное Microsoft. Проще говоря, это означает, что во время выполнения возвращаемое значение на самом деле не проталкивается в стек, как происходило бы при вызовах обычных методов .NET. Напротив, оно записывается в регистр RAX. Поскольку мы никоим образом не используем это значение, мы можем просто превратить определение в void, фактически, закрывая глаза на что угодно, записываемое в этот регистр. Поскольку теперь эта функция больше ничего не возвращает, никакие значения также не задвигаются в стек интерпретации нашего метода main. Соответственно, можем избавиться и от инструкции pop в методе main:

При изменении типа на void инструкция pop нам больше не требуется
При изменении типа на void инструкция pop нам больше не требуется

Также можно немного рациональнее разместить ту строку ASCII, которую мы передаём функции puts. Файлы в формате PE содержат множество сегментов, выравниваемых по определённой байтовой границе. В частности, как уже упоминалось ранее, разделы выравниваются по ближайшему кратному 0x200 (1 КБ). Это касается и первой секции. Однако, заголовки формата PE, содержащиеся в нашем файле, занимают менее 0x200 байт, поэтому оказывается, что между нашими заголовками и первой секцией файла есть участок, занятый заполнителем:

В образах PE присутствует заполнитель — пустые данные, расположенные между заголовками и первой секцией
В образах PE присутствует заполнитель — пустые данные, расположенные между заголовками и первой секцией

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

Давайте же переместим туда нашу строку!

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

Переместив туда нашу строку, мы фактически ужимаем наш файл ещё на 13 байт.

Поскольку мы также больше не ссылаемся на Console::WriteLine, нам также не требуется хранить в нашем бинарнике ссылку на mscorlib. Это позволит выручить ещё немного пространства, поскольку в таком случае нам придётся хранить в табличном потоке (#~) на одну таблицу меньше. Кроме того, можно удалить и имя mscorlib из потока #Strings.

Мы больше не зависим от "mscorlib", следовательно, и ссылаться на неё нам больше не нужно.
Мы больше не зависим от "mscorlib", следовательно, и ссылаться на неё нам больше не нужно.

На закуску можно попробовать что-то совсем странное. В директории с метаданными .NET содержится поле VersionString, в котором указана минимально необходимая версия .NET Framework, на которой сможет работать этот исполняемый файл. По умолчанию для бинарников .NET 4.0+ здесь содержится строка "v4.0.30319", заполненная нулевыми байтами до ближайшего кратного четвёрки (всего 12 bytes). Однако мы можем обрезать эту строку, оставив только v4.0., то есть убрав ещё 4 байта после заполнителя. Так мы обманом заставим .NET продолжать грузиться до версии CLR 4.0 и сможем успешно запустить программу.

В директории с метаданными .NET содержится строка с указанием версии, в которой записана требуемая версия среды исполнения — эту строку также можно обрезать
В директории с метаданными .NET содержится строка с указанием версии, в которой записана требуемая версия среды исполнения — эту строку также можно обрезать

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

Итоговый размер получившегося у нас файла — 834 байта (0x342):

Окончательный вид получившегося у нас файла
Окончательный вид получившегося у нас файла

При помощи ZIP его можно заархивировать всего до 476 байт (сравните с 582 байтами – таков был результат архивации, если бы мы не занимались никакой оптимизацией по достижении предела в 1 КБ). Вот на этом я решил закругляться.

Наконец, в доказательство того, что программа по-прежнему работает нормально — вот вам скриншот:

Всё равно работает!
Всё равно работает!

Заключение

Вот таким тупым способом я скоротал субботу.

Хотя этот проект и получился весьма бесполезным, но мне всё равно нравится время от времени прыгать в бездонные кроличьи норы. Всегда интересно тестировать границы «вполне сложившихся» систем, даже если результат труда кажется бесполезным.

Резюмируем: мы смогли пройти от файла Hello World размером 4,6 КБ, собранного компилятором C#, до вручную полученного файла PE размером 834 Б, не считая замыкающих нулевых байт. Не думаю, что можно было бы ужать его ещё сильнее, но рад буду ошибиться!

Как и говорил выше, весь исходный код для получения такого бинарника выложен у меня на GitHub.

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


  1. XaBoK
    16.07.2023 22:26
    +23

    Всё равно дочитал до конца - понравилось.
    Всё равно дочитал до конца - понравилось.


    1. anonymous
      16.07.2023 22:26

      НЛО прилетело и опубликовало эту надпись здесь


    1. KvanTTT
      16.07.2023 22:26
      +1

      Трактуйте это как предмет искусства.


  1. lorc
    16.07.2023 22:26
    +16

    Мне кажется, что грузить dll и дергать puts - это уже читерство. Вполне может оказаться что в одной из стандартных библиотек уже есть строка "Hello, world", например.


    1. Nurked
      16.07.2023 22:26
      +1

      Да, так и есть. Я как раз пришёл пожаловаться. Я в такое количество бит мог уложить игру 2048 написанную на асьме. Но при том что я статически линковал все эти библиотеки, размер файла неизбежно выростал до 76 килобайт.


  1. vagon333
    16.07.2023 22:26
    +2

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


    1. aegoroff
      16.07.2023 22:26
      +22

      Такое скорее нужно именно вирусописателям, но никак не при нормальной инженерной практике. Хотя с познавательной точки зрения статья огонь!


      1. Nurked
        16.07.2023 22:26
        +2

        Вот именно что с познавательной точки зрения.


      1. HemulGM
        16.07.2023 22:26
        +1

        Вирусописателям? Не дотнете? Серьезно?


        1. aegoroff
          16.07.2023 22:26
          +4

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


        1. vabka
          16.07.2023 22:26
          +1

          Sweet summer child...
          Даже на хабре помню, что была как минимум одна статья, где разбирался вирус на .NET


        1. unclejocker
          16.07.2023 22:26

          Мы когда то ботоводили в игре одной, а чтобы античит не спалил - все обертки были написаны на батниках, а взаимодействие - на autoit, никаких эксплоитов и "подозрительных" процессов. И много лет все работало.


    1. javalin
      16.07.2023 22:26
      +19

      Оптимизация лишней не бывает

      Оптимизация всегда лишняя, пока не появилась потребность в этой самой оптимизации. Лучше простой, стабильный, понятный код, чем ускорение запуска на несколько миллисекунд.

      Начинаем холивар )


      1. vagon333
        16.07.2023 22:26
        +3

        Начинаем холивар )

        Зачем?
        Мне удовольствие, вам излишество. Уважаю ваше мнение, но остаюсь при своем.
        За свободу мнений и вариативность!
        Кто-то выше заминусил мой коммент. Мне не понятно, но если челу в кайф, ок. :)


      1. vabka
        16.07.2023 22:26
        +1

        Оптимизация всегда лишняя, пока не появилась потребность в этой самой оптимизации.

        Лишняя оптимизация - такая, которая ничем не обоснована.
        Есть такие случаи, где заранее известно, что что-то нужно, хотя момент, когда это необходимо ещё не наступил.

        "Обоснована" - значит есть осмысленный ответ на вопрос "чтобы что?"


  1. AndrewBond
    16.07.2023 22:26
    +2

    Статья и обсуждение "это нужно вирусописателям" напомнили давние рассказы программистов игровых консолей с жестко ограниченным количеством памяти, отсутствием виртуальной памяти и тп.


    1. CrashLogger
      16.07.2023 22:26
      +3

      Вирусы сейчас тоже жирные ) Потому что грузят свое тело по сети. А такие хаки с уменьшением размера весьма вероятно триггернут антивирус.


    1. aegoroff
      16.07.2023 22:26

      Ну на дотнете обычно не пишут там где есть жесткие ограничения по памяти, учитывая вес самого рантайма и минимальный набор рабочего процесса в мегабайт эдак 20-30 :)


  1. DBalashov
    16.07.2023 22:26
    +2

    Странно, что в 2023-м году и net472.
    Ну и да, троллейбус_из_хлеба.жпг


    1. yung6lean9
      16.07.2023 22:26
      +2

      Потому что с .net core изначальный размер бинарника вырастает до ~150кб


  1. OMR_Kiruha
    16.07.2023 22:26
    +2

    А ещё про разработчиков на C++ говорят что они "байтослесари"...


    1. vabka
      16.07.2023 22:26

      Только вместо слесарей должно быть другое слово.


  1. yung6lean9
    16.07.2023 22:26
    +1

    Если уж на то пошло, то оптимизацию лучше делать в сторону уменьшения потребления оперативки, hello world с.net framework472 занимает 1.5мб оперативки при 4кб бинарнике, с .net core чуть больше, 2.5мб


  1. Nipheris
    16.07.2023 22:26
    +4

    Шикарная статья. Всем, кто пишет про "зачем", предлагаю ознакомиться с жанром 64k intro и теми высотами, которых он достиг на современных графических API. Я к тому, что эта статья примерно из той же категории. Спасибо за перевод!


    1. HemulGM
      16.07.2023 22:26
      -2

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


      1. Nipheris
        16.07.2023 22:26

        А что насчёт этого?) http://demojs.org/ Не имеет права на жизнь?)


        1. HemulGM
          16.07.2023 22:26

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


          1. Nipheris
            16.07.2023 22:26
            +5

            Согласен, расширение exe и загрузчик в начале файла тут только ради удобства запуска. Дотнет-сборкам вообще не особо нужно прикидываться PE-файлами.
            Я про другое. .NET Framework, по крайней мере определённых версий, считается компонентом Windows. С некоторой натяжкой это в общем-то системное API. Равно как и Direct3D, например. Вопрос - где проходит граница между "программой" и "скриптом на питоне"?) Что должно/не должно быть в бинаре?


            1. HemulGM
              16.07.2023 22:26
              -2

              Раньше и так граница была размыта, а сейчас ещё больше. Но всё же есть некоторые зацепки. К примеру возьмем любой компилируемый язык с кроссплатформенными возможностями. В зависимости от реализации, мы можем получить либо отсутствие работы углубленно с API (самим фреймворком), т.е. без нативного интерфейса (или имитация), либо реализация нативного интерфейса для всех целевых платформ. Однако, в любом из этих случаев мы имеем зависимость только от самой ОС (на каждой из платформ), а не от чего-то, что было создано для этого языка.

              Т.е. ДотНет - это часть именно Windows, а не Линукс или МакОС. Однако, "программа" на C# на Линукс всё ещё зависит именно от ДотНета (части Windows).


            1. slonopotamus
              16.07.2023 22:26
              +3

              .NET Framework, по крайней мере определённых версий, считается компонентом Windows

              Нюанс в том что в разных версиях Windows разная версия .NET Framework. А запускать софт, требующий другую версию, оно не может, надо ставить больше фреймворков.


  1. HemulGM
    16.07.2023 22:26
    +3

    Не забудьте посчитать размер самого дотнета. Или, слинкуйте статический "программу".


    1. Zara6502
      16.07.2023 22:26
      +3

      зачем? 99% программ в линуксе например использует те или иные библиотеки, которые качаются в систему целиком, нажимаешь apt-get install megaprogram И получаешь 200 зависимостей которые качаются к тебе на ПК. Чем хуже винда? Это или VS C++ либы, либо .NET. Учитывая что .NET являются частью любой современной системы MS (отдельно можно поставить и на старые), то для пишущего ПО это только плюс, а уж плюсы сетевой дистрибьюции какие.

      Например у меня есть две утилиты консольные, обе 10240 байт EXE-шники, но с очень разным размером кода, одна на 3-4 страницы, а вторая на 15. То есть программа лежит видимо в минимальном однофайловом контейнере. Кстати обе утилиты работают в неизменном виде на линуксе тоже (а, не, вру. сейчас они под 4.8, а для кроссплатформенности я пересобирал их, кажется по 18 Кб становились, но уже работали в любой системе одинаково, удобно же, я под линукса писать совсем не умею, а тут тяп-ляп и готово).


      1. HemulGM
        16.07.2023 22:26
        +1

        Устанавливаемые зависимости из вашего примера не являются обязательными для каждой программы. Ты можешь их не использовать или использовать их в качестве исходников и собрать бинарь. А в случае с C# ДотНетом(ДотКором) мы зависим от него всегда.


        1. Zara6502
          16.07.2023 22:26
          +1

          ну лет 25 назад когда я пользовался FreeBSD я собирал каждую программу из исходников и действительно получал "чистый" бинарник. Но сегодня самые популярные дистрибутивы базируются на репозиторных сборках, где либы собраны и под них бинарники, поэтому проще 1 раз собрать либу и софт под неё, но да, качать при установке софта придется и софтину и либу. Так что ваш вариант в среде линукса практически неживой и рассматривать его нет смысла.

          А в случае с дотнетом можно тоже собрать один исполнимый файл, либы будут внутри.


          1. aegoroff
            16.07.2023 22:26

            Остались еще и в линукс такие - Gentoo! :)


            1. Zara6502
              16.07.2023 22:26

              доля линукса 1-3%, какая там доля Gentoo? Что-то думается мне SUSE и RedHat "немного" больше (про попсовые дистрибутивы вообще молчу).


          1. aegoroff
            16.07.2023 22:26

            А в случае с дотнетом можно тоже собрать один исполнимый файл, либы будут внутри.

            Ага, можно правда его вес 50 или 60 мегабайт минимум будет (цифры для линукс). Так называемый selfhosted :)


            1. Zara6502
              16.07.2023 22:26

              когда я в 2023 году скачиваю драйвер nvidia размером в 1 гиг или смотрю на телефоне размеры клиента сбера тоже в гиг, то я как-то не вспоминаю свою программу 1999 года, которая делала для радио медийные планы рекламы, воспроизводила музыку в эфире и занимала 400 кб. хотим мы того или не хотим, но весь софт увеличился в размерах КРАТНО. Я лично считаю это плохой тенденцией, но явно на это я повлиять не могу.

              Из тех 50-60 мег о которых вы пишете это в основном библиотеки, интегрированные в файл, вы расплачиваетесь за "однофайловость", за отсутствие необходимости ставить что-то еще. Я наоборот собираю свои файлы опираясь на то, что в системе будет стоять нужная версия фрейморка. У меня всегда стоит. Файл 10240 байт всегда, пишу я что-то еще или нет. Видимо контейнер не может быть меньше этого размера. Знаю кучу софта из 90-00-х написанного под WinAPI, которые занимали единицы килобайт (будильники, часы, утилиты по расчёту сопротивлений, для работы с COM портом и т.п.).

              Уже в 2001-2002 я на делфи писал программы которые использовали кучу DLL, тот же BDE, и весили уже сильно больше чем одна дискетка. Тогда стал выручать UPX.

              Так что - селяви.


      1. slonopotamus
        16.07.2023 22:26
        -3

        Чем хуже винда?

        Тем что в ней нет apt-get! И нет, ответ "зато есть WinGet" не принимается, потому что его нет в Windows Server. А ещё WinGet не умеет в зависимости.


        1. vabka
          16.07.2023 22:26
          +2

          Но ведь и apt-get собственно есть не в каждом линуксе. Есть дистрибутивы, где в принципе никакого пакетного менеджера.

          Так что я просто скажу про choco, который умеет в зависимости и его можно установить на windows server


          1. aegoroff
            16.07.2023 22:26

            Все верно - apt это пакетный менеджер debian и его производных (ubuntu, linux mint etc), в той же федоре и производных его нет но вместо него dnf, в arch based дистрибутивах - pacman вроде все основные перечислил :)


  1. Kanut
    16.07.2023 22:26
    +3

    Было бы интересно посмотреть на минимальный файл при компиляции с учётом тэга "self-contained". То есть с тем чтобы все необходимые библиотеки были запакованы в сам экзешник.


    1. impwx
      16.07.2023 22:26
      +1

      Примерно так:
      image


      Все версии содержат одну единственную строку Console.WriteLine("Hello world"). Используется .NET 8, публикация под win-x64 в режиме self-contained (т.е. кроме экзешника ничего больше не нужно, сам рантайм не требуется).


  1. Source
    16.07.2023 22:26
    +1

    О, я примерно так же развлекался 18 лет назад. Только это был компилятор Delphi, Windows XP и программа выводила текущее системное время в диалоговом окне. А размер получился такой же, в районе 840 байт.


    1. SlavaHU
      16.07.2023 22:26
      +1

      А я примерно так же развлекался году этак в 95-м, писал самый маленькую по размеру программу, которая перезапустит комп. Дольше всего было сокращение с 8 до 7 байт. Конечно, COM файл.


      1. Source
        16.07.2023 22:26

        Ну, COM с PE не совсем честно сравнивать)

        С перезагрузкой я развлекался при помощи shutdown и опций для отложенной перезагрузки, когда по очереди за одним компом работаешь. Ещё с автозапуском на CD тоже весёлые приколы были.


        1. SlavaHU
          16.07.2023 22:26

          Да я ж и не сравниваю. Я тоже прошел по цепочке от Паскаля (или Дельфи), через С++, С, ассемблер, до машинного кода. В 8 байтах я использовал стандартное прерывание, а в 7 байтах - какой-то малодокументированный переход по какому-то адресу. А потом запихнул это еще в свой TSR "под себя".


      1. Xeldos
        16.07.2023 22:26

        5 байт было. Я не помню, откуда я взял этот бинарник, но он весил пять байт. Что-то вроде F0 FF FE FF FF. При дизассемблировании вылезало jmp непонятно куда.


        1. SlavaHU
          16.07.2023 22:26

          Потом мне уже попадалось версия с 2 байтами, сейчас поискал: "fa f4"
          cli ; disable all maskable interrupts
          hlt ; stop the processor until an interrupt or hard reset happens


          1. sappience
            16.07.2023 22:26

            Но ведь это завесит компьтер, а не перезагрузит его. Да и завесит, вероятно, не все IBM PC-compatible машины. Остается ведь немаскируемое прерывание NMI, а его по-разному в разных клонах задействовали. Где-то ошибки чётности ловили, где-то на таймер заводили. Вероятно и другие варианты были.


            1. SlavaHU
              16.07.2023 22:26

              Да, это вроде как завесит, но якобы в реальности все перезапскалось по ошибке. Сейчас проверить уже проблемно.

              "Моя" версия с джампом действительно перезапускала.


        1. SlavaHU
          16.07.2023 22:26

          Погуглил, "EA0000FFFF" JMP FFFF:0000

          "When the computer boots, the x86 microprocessor starts in real mode and executes the instruction at FFFF:0000"

          Хммм... Похоже, и у меня было не 7, а 5 байт.


  1. devspec
    16.07.2023 22:26
    +1

    А если каким-нибудь UPX еще это дело обработать? Или не возьмет?


    1. Zara6502
      16.07.2023 22:26
      +1

      Hidden text


  1. Joysi
    16.07.2023 22:26
    +1

    Вот они, реинкарнации ушедших душ из fido.demo.design, даешь 256 байт + 64К интро, процедурные текстуры и real time ray raytracing


  1. yung6lean9
    16.07.2023 22:26
    -1

    Кстати, ради обсуждения, кто-нибудь понимает разницу между тем, как работает компилятор раста, и .net рантаймом? Верно ли понимаю, что раст фактически делает всё то, что делает .net, но на этапе компиляции + оперативки ест ~в 10-100 раз меньше, в несколько раз быстрее, и в несколько раз меньше весит?


    1. vabka
      16.07.2023 22:26
      +3

      Их сравнивать некорректно.

      Компилятор раста и .NET Runtime делают принципиально разные вещи.

      Вот как минимум ряд вещей, которых в расте изначально нет by design, но они есть в .NET Runtime:

      1. JIT-компилятор

      2. Рефлексия

      3. Сборка мусора

      4. System.Reflection.Emit и всё что с ним связано - генерация кода в рантайме

      5. Динамическая загрузка библиотек (часто используется для реализации всяких плагинов для программы)

      (Может ещё что-то забыл)

      В несколько раз меньше вести

      Скорее на несколько порядков меньше весит :)

      На счёт "в несколько раз быстрее" - это зависит от конкретного сценария. Вполне могут быть случаи из реального мира, где разница не такая значительная.


      1. yung6lean9
        16.07.2023 22:26

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

        А то сейчас слишком уж много весит и сам .net, и его рантайм. Да и производительность страдает :(

        Я подозреваю, что не только я так думаю, не зря в последнее время .net идёт семимильными шагами в aot


        1. aegoroff
          16.07.2023 22:26

          aot не совсем то что надо - это не избавляет от необходимости наличия жирного рантайма и сборки мусора, которая находится в фундаменте .net, Rust этим принципиально отличается - у него нет никакого рантайма и автоматической сборки мусора с отслеживанием достижимости объектов.


          1. yung6lean9
            16.07.2023 22:26

            Да вот я и надеюсь, что после aot появится какой-нибудь компилятор, который позаимствует у раста часть фишек. Или хотя бы даст возможность некоторые участки кода полноценно оптимизировать.


            1. vabka
              16.07.2023 22:26

              Что вам сейчас мешает некоторые участки кода "полноценно оптимизировать"?
              Уже же и так есть unsafe, stackalloc, span&memory, AOT, unmamanged memory, и даже ключевые слова, чтобы контролировать время жизни параметров функций, почти как в Rust.

              Что мешает сделать этот самый гибрид на основе Rust?
              Сборку мусора можно втащить через crossbeam::epoch, рефлексия эмулируется при помощи макросов, jit, кодогенерацию, и подгрузку произвольного платформонезависимого кода можно легко реализовать через wasm.

              Ну и ещё как нечто среднее между C# и Rust можно считать Go:
              Есть сборка мусора, есть рефлексия, но при этом полностью AOT.


    1. aegoroff
      16.07.2023 22:26

      Верно ли понимаю, что раст фактически делает всё то, что делает .net

      не совсем - Rust использует достаточно уникальную модель владения (owning) и одалживания (borrowing) сущностей и следит за этим на этапе компиляции, чтобы не нарушались правила. Если все нормально, компилятор сам где надо вставляет методы drop, очищающие память после использования, т.е. фактически вызов C++ деструкторов или free из Си. Ну и да, нет никакого рантайма - практически используется ABI от С/С++, т.к. внутри все преобразуется в машинный код с использованием LLVM


      1. yung6lean9
        16.07.2023 22:26

        В целом это и имел в виду


      1. yung6lean9
        16.07.2023 22:26

        Тут скорее даже смысл в том, что изначально GC был добавлен для отслеживания утечек памяти, а раст всю эту кашу решает и без него. Поэтому .net тут явно проигрывает, но это пока не сильно заметно, раст ещё не на пике своей популярности.

        В 17 году майки исследовали вопрос "Non-blocking safe manual memory management in .NET", но с тех пор что-то ничего не слышно

        https://www.microsoft.com/en-us/research/publication/project-snowflake-non-blocking-safe-manual-memory-management-net/


        1. vabka
          16.07.2023 22:26

          а раст всю эту кашу решает и без него

          Решает, да не всю.
          1. Утечка памяти не считается UB, а по тому вполне возможна в Rust даже без unsafe.
          2. Если у тебя есть циклические ссылки - тебе придётся включить unsafe и работать с сырыми указателями.

          Серебреных пуль нет.


  1. Eltaron
    16.07.2023 22:26

    Спасибо, увлекательно!

    Сейчас ещё интересно такое проделать с бинарями, в которые компилирует дотнет с включенным AOT - ну то есть когда он выдает сразу нативный код, а не IL. По последним тестам у такого хелловролда на шарпе размер аж 1.2 мегабайта, есть простор для оптимизаций.