Вот вам тупой вопрос, который вы сами, наверное, никогда себе не задавали. Каково минимальное количество байт, которые необходимо сохранить в исполняемом .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 КБ:
Как-то это многовато… Разумеется, можно было бы сделать лучше.
Удаляем аннотации ссылок, допускающих null
Изучив приложение в декомпиляторе .NET, начинаем лучше понимать, что же тут происходит. Начиная с версии 8.0 в C# существует такая концепция: ссылочные типы, допускающие null. Это специальные аннотации, по которым компилятор C# может судить о потенциально нежелательных нулевых ссылках, которые могут передаваться функциям, переменным и параметрам. Недостаток таких аннотаций в том, что они реализуются в виде пользовательских атрибутов, а такие атрибуты статически связываются в исполняемом файле и поэтому известны своей огромностью:
Давайте отключим их в нашем файле .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>
, который выглядит практически пустым:
Можно было бы попытаться выяснить, как отключить в компиляторе генерацию всех этих метаданных, но я рассудил, что, коль скоро мы стремимся к абсолютному минимуму, мы вполне могли бы сами собрать с нуля исполняемый файл .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");
Мы уже славно поработали, смогли ужать файл наполовину:
Но можно сделать ещё лучше…
Избавляемся от импортов и перемещения адресов
Если рассмотреть получившийся у нас исполняемый файл в таком инструменте как CFF Explorer, то в этом файле заметим два раздела: .text и .reloc. Кроме того, в нём ещё содержится два очень больших каталога с данными, которые называются Imports и Base Relocations.
Такая ситуация весьма типична для любого 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;
Действительно, мы дошли до теоретически возможного минимального размера в 1 КБ, и у нас по-прежнему рабочий файл .NET PE:
Избавляемся от имён метаданных
На этом можно было бы закончить, но я решил немного глубже закопаться в задачу и посмотреть, что ещё можно было бы отсечь от бинарника и ещё сильнее минифицировать исполняемый файл hello world для .NET. Начиная с этого шага, мы не будем обращать внимания на то, каков размер файла по мнению Windows Explorer. Вместо этого обратимся к hex-редактору, разберёмся, где именно хранятся все до единого ненулевые байты, и уже тогда сможем поговорить об окончательном размере нашего файла. Применительно к тому файлу, который мы исследуем сейчас, уже видно, что нам удалось ужать его до 991 байта (0x3DF):
Из чего же складывается остающийся у нас на настоящий момент набор байт? Вновь заглянув в дизассемблер, можем заметить, что куча #Strings в двоичном файле .NET — это второй по размеру набор метаданных, сохранённый в файле. В нём содержатся все имена, применяемые в табличном потоке (#~), а в этом потоке обычно хранят все типы и методы, которые определяет и использует наше приложение. Оказывается, во время выполнения многие из этих имён, в сущности, неважны:
Следовательно, если установим их все в null, то действительно получим приложение, которое имеет примерно следующий вид:
Хотите — верьте, хотите — нет, но приложение по-прежнему нормально работает и с готовностью выводит“Hello World”, даже если такой метод выглядит некрасиво. Самое приятное, что таким маневром мы сбрили с нашего файла ещё целых 32 байта:
Избавляемся ещё от некоторых ненужных метаданных
Какие тут ещё есть ненужные метаданные, по поводу которых CLR особенно не беспокоится? Наша следующая цель — избавиться от потока #GUID. Этот поток присутствует практически в любом исполняемом файле .NET и, как понятно из названия, содержит список GUID. Правда, единственный тип метаданных, на которые он ссылается — это таблица Module. В этой таблице есть столбец Mvid; в нём должна стоять ссылка на тот GUID, который служит уникальным идентификатором модуля, позволяя находить его среди различных версий скомпилированных бинарников.
Версионирование нас не волнует, нам ведь нужен просто минимально возможный бинарник. Соответственно, и от этого компонента можно избавиться и сэкономить ещё 16 байт, которые исходно занимал Mvid. Правда, после такой манипуляции поток #GUID оказывается пуст, и поэтому тоже больше не нужен. Удалив весь этот поток, мы выигрываем ещё 16 байт, из которых состоял его заголовок; суммарно нам удалось выручить на этом шаге 32 байта.
Кроме того, есть метод Console::WriteLine, вызываемый нами в функции Main, он определяется в mscorlib. Как правило, ссылки на сборки BCL аннотируются публичным ключом — это токен размером 8 байт.
Оказывается, что, если в этой ссылке не будет токена публичного ключа, среда CLR просто не станет проверять токен этой сборки на подлинность. Поскольку в рамках этого эксперимента вопросы безопасности нас никак не волнуют, от этого фрагмента также можно избавиться.
Вот у нас получился файл, в котором всего 918 байт:
Избавляемся от Console.WriteLine
Если посмотреть другие потоки метаданных, определяемые в нашей сборке, выяснится, что строка "Hello, World!" хранится у нас довольно неэффективным образом. В .NET все пользовательские строки кладутся в поток метаданных #US в виде массива с префиксом, указывающим его длину; этот массив состоит из символов по 16 разрядов в ширину, а за этими символами следует дополнительный нулевой байт. Такой подход избран ради поддержки широкого набора символов UNICODE. Однако, все символы в той строке, которую мы хотим вывести на экран, обозначаются кодовым значением с точкой, размер которого менее 255 (0xFF), это максимальное значение, укладываемое в 1 байт. Зачем же нам тогда использовать по 2 байта на символ? Более того, это единственная пользовательская строка, которая понадобится нам в нашем бинарнике. Иметь полновесный 12-байтовый заголовок потока всего для одной строки кажется довольно избыточным:
К сожалению, не существует способа превратить эту широкосимвольную строку потока #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
:
Вот как получается: мы избавились не только от широкосимвольной строки, но и от всего потока #US, а также от ссылки на System.Console::WriteLine, которая также увеличивала размер нашего файла на несколько байт. Правда, несколько байт нам вернулось обратно, так как появилось определение нового обязательного метода puts и связанные с ним метаданные P/Invoke, но это, конечно же, для большой экономии.
И вот мы добрались до 889 байт (0x379):
Другие микро-оптимизации
Есть ещё несколько вещей, которые вполне можно сделать.
Наше определение puts имеет каноническую форму, заданную в библиотеке исполнения C. Таким образом, функция определена в расчёте на возврат числа int32, означающего количество символов, которые были записаны в стандартный вывод. Однако нас это ограничение не интересует. Действительно, в методе main мы выталкиваем это значение прямо после вызова, только чтобы удовлетворить CLR:
Поскольку это, так или иначе, 64-разрядный файл PE, функция puts будет использовать принятые в x64 соглашения о вызовах, описанное Microsoft. Проще говоря, это означает, что во время выполнения возвращаемое значение на самом деле не проталкивается в стек, как происходило бы при вызовах обычных методов .NET. Напротив, оно записывается в регистр RAX. Поскольку мы никоим образом не используем это значение, мы можем просто превратить определение в void, фактически, закрывая глаза на что угодно, записываемое в этот регистр. Поскольку теперь эта функция больше ничего не возвращает, никакие значения также не задвигаются в стек интерпретации нашего метода main. Соответственно, можем избавиться и от инструкции pop в методе main:
Также можно немного рациональнее разместить ту строку ASCII, которую мы передаём функции puts
. Файлы в формате PE содержат множество сегментов, выравниваемых по определённой байтовой границе. В частности, как уже упоминалось ранее, разделы выравниваются по ближайшему кратному 0x200 (1 КБ). Это касается и первой секции. Однако, заголовки формата PE, содержащиеся в нашем файле, занимают менее 0x200 байт, поэтому оказывается, что между нашими заголовками и первой секцией файла есть участок, занятый заполнителем:
Оказывается, что загрузчик Windows PE всегда отображает заголовки PE как участок памяти, доступной для чтения. Нам повезло: сюда же относится и вышеупомянутый заполнитель.
Давайте же переместим туда нашу строку!
Переместив туда нашу строку, мы фактически ужимаем наш файл ещё на 13 байт.
Поскольку мы также больше не ссылаемся на Console::WriteLine, нам также не требуется хранить в нашем бинарнике ссылку на mscorlib
. Это позволит выручить ещё немного пространства, поскольку в таком случае нам придётся хранить в табличном потоке (#~) на одну таблицу меньше. Кроме того, можно удалить и имя mscorlib
из потока #Strings
.
На закуску можно попробовать что-то совсем странное. В директории с метаданными .NET содержится поле VersionString, в котором указана минимально необходимая версия .NET Framework, на которой сможет работать этот исполняемый файл. По умолчанию для бинарников .NET 4.0+ здесь содержится строка "v4.0.30319", заполненная нулевыми байтами до ближайшего кратного четвёрки (всего 12 bytes). Однако мы можем обрезать эту строку, оставив только v4.0., то есть убрав ещё 4 байта после заполнителя. Так мы обманом заставим .NET продолжать грузиться до версии CLR 4.0 и сможем успешно запустить программу.
Обратите внимание: по какой-то причине замыкающая точка оказывается важной. Не знаю, почему, но, если удалить из программы хотя бы ещё одну любую мелочь, то она не загрузится.
Итоговый размер получившегося у нас файла — 834 байта (0x342):
При помощи ZIP его можно заархивировать всего до 476 байт (сравните с 582 байтами – таков был результат архивации, если бы мы не занимались никакой оптимизацией по достижении предела в 1 КБ). Вот на этом я решил закругляться.
Наконец, в доказательство того, что программа по-прежнему работает нормально — вот вам скриншот:
Заключение
Вот таким тупым способом я скоротал субботу.
Хотя этот проект и получился весьма бесполезным, но мне всё равно нравится время от времени прыгать в бездонные кроличьи норы. Всегда интересно тестировать границы «вполне сложившихся» систем, даже если результат труда кажется бесполезным.
Резюмируем: мы смогли пройти от файла Hello World размером 4,6 КБ, собранного компилятором C#, до вручную полученного файла PE размером 834 Б, не считая замыкающих нулевых байт. Не думаю, что можно было бы ужать его ещё сильнее, но рад буду ошибиться!
Как и говорил выше, весь исходный код для получения такого бинарника выложен у меня на GitHub.
Комментарии (66)
lorc
16.07.2023 22:26+16Мне кажется, что грузить dll и дергать puts - это уже читерство. Вполне может оказаться что в одной из стандартных библиотек уже есть строка "Hello, world", например.
Nurked
16.07.2023 22:26+1Да, так и есть. Я как раз пришёл пожаловаться. Я в такое количество бит мог уложить игру 2048 написанную на асьме. Но при том что я статически линковал все эти библиотеки, размер файла неизбежно выростал до 76 килобайт.
vagon333
16.07.2023 22:26+2Плюсую нещадно.
Оптимизация лишней не бывает, и это относится к коду, бинарнику, базам, запросам.
В какой-то момент за неряшливый код приходится платить.
Да и эстетическое удовольствие - читать красивый код.aegoroff
16.07.2023 22:26+22Такое скорее нужно именно вирусописателям, но никак не при нормальной инженерной практике. Хотя с познавательной точки зрения статья огонь!
HemulGM
16.07.2023 22:26+1Вирусописателям? Не дотнете? Серьезно?
aegoroff
16.07.2023 22:26+4Их полно. Вирус это не только что-то работающее в режиме ядра, - вирус может быть многокомпонентным, например взаимодействующим с сетью - а это поверьте удобнее на дотнете делать, скрывая процесс тем же драйвером ядра, или даже внедрять свой дотнет код в существующий процесс
vabka
16.07.2023 22:26+1Sweet summer child...
Даже на хабре помню, что была как минимум одна статья, где разбирался вирус на .NET
unclejocker
16.07.2023 22:26Мы когда то ботоводили в игре одной, а чтобы античит не спалил - все обертки были написаны на батниках, а взаимодействие - на autoit, никаких эксплоитов и "подозрительных" процессов. И много лет все работало.
javalin
16.07.2023 22:26+19Оптимизация лишней не бывает
Оптимизация всегда лишняя, пока не появилась потребность в этой самой оптимизации. Лучше простой, стабильный, понятный код, чем ускорение запуска на несколько миллисекунд.
Начинаем холивар )
vagon333
16.07.2023 22:26+3Начинаем холивар )
Зачем?
Мне удовольствие, вам излишество. Уважаю ваше мнение, но остаюсь при своем.
За свободу мнений и вариативность!
Кто-то выше заминусил мой коммент. Мне не понятно, но если челу в кайф, ок. :)
vabka
16.07.2023 22:26+1Оптимизация всегда лишняя, пока не появилась потребность в этой самой оптимизации.
Лишняя оптимизация - такая, которая ничем не обоснована.
Есть такие случаи, где заранее известно, что что-то нужно, хотя момент, когда это необходимо ещё не наступил.
"Обоснована" - значит есть осмысленный ответ на вопрос "чтобы что?"
AndrewBond
16.07.2023 22:26+2Статья и обсуждение "это нужно вирусописателям" напомнили давние рассказы программистов игровых консолей с жестко ограниченным количеством памяти, отсутствием виртуальной памяти и тп.
CrashLogger
16.07.2023 22:26+3Вирусы сейчас тоже жирные ) Потому что грузят свое тело по сети. А такие хаки с уменьшением размера весьма вероятно триггернут антивирус.
aegoroff
16.07.2023 22:26Ну на дотнете обычно не пишут там где есть жесткие ограничения по памяти, учитывая вес самого рантайма и минимальный набор рабочего процесса в мегабайт эдак 20-30 :)
yung6lean9
16.07.2023 22:26+1Если уж на то пошло, то оптимизацию лучше делать в сторону уменьшения потребления оперативки, hello world с.net framework472 занимает 1.5мб оперативки при 4кб бинарнике, с .net core чуть больше, 2.5мб
Nipheris
16.07.2023 22:26+4Шикарная статья. Всем, кто пишет про "зачем", предлагаю ознакомиться с жанром 64k intro и теми высотами, которых он достиг на современных графических API. Я к тому, что эта статья примерно из той же категории. Спасибо за перевод!
HemulGM
16.07.2023 22:26-2Только в данном случае, это равнозначно скрипту на питоне. Он тоже весит килобайты, а если получить байт-код - ещё меньше. Только от этого, он программой не становится
Nipheris
16.07.2023 22:26А что насчёт этого?) http://demojs.org/ Не имеет права на жизнь?)
HemulGM
16.07.2023 22:26Они и не претендуют называться исполнительным файлом ехе. По сути, дотнет приложения, которые не слинкованы статически вообще могут использовать не ехе расширение, потому что смысла в нем нет, пока нет среды которая бы позволила работать "программе".
Nipheris
16.07.2023 22:26+5Согласен, расширение exe и загрузчик в начале файла тут только ради удобства запуска. Дотнет-сборкам вообще не особо нужно прикидываться PE-файлами.
Я про другое. .NET Framework, по крайней мере определённых версий, считается компонентом Windows. С некоторой натяжкой это в общем-то системное API. Равно как и Direct3D, например. Вопрос - где проходит граница между "программой" и "скриптом на питоне"?) Что должно/не должно быть в бинаре?HemulGM
16.07.2023 22:26-2Раньше и так граница была размыта, а сейчас ещё больше. Но всё же есть некоторые зацепки. К примеру возьмем любой компилируемый язык с кроссплатформенными возможностями. В зависимости от реализации, мы можем получить либо отсутствие работы углубленно с API (самим фреймворком), т.е. без нативного интерфейса (или имитация), либо реализация нативного интерфейса для всех целевых платформ. Однако, в любом из этих случаев мы имеем зависимость только от самой ОС (на каждой из платформ), а не от чего-то, что было создано для этого языка.
Т.е. ДотНет - это часть именно Windows, а не Линукс или МакОС. Однако, "программа" на C# на Линукс всё ещё зависит именно от ДотНета (части Windows).
slonopotamus
16.07.2023 22:26+3.NET Framework, по крайней мере определённых версий, считается компонентом Windows
Нюанс в том что в разных версиях Windows разная версия .NET Framework. А запускать софт, требующий другую версию, оно не может, надо ставить больше фреймворков.
HemulGM
16.07.2023 22:26+3Не забудьте посчитать размер самого дотнета. Или, слинкуйте статический "программу".
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 Кб становились, но уже работали в любой системе одинаково, удобно же, я под линукса писать совсем не умею, а тут тяп-ляп и готово).
HemulGM
16.07.2023 22:26+1Устанавливаемые зависимости из вашего примера не являются обязательными для каждой программы. Ты можешь их не использовать или использовать их в качестве исходников и собрать бинарь. А в случае с C# ДотНетом(ДотКором) мы зависим от него всегда.
Zara6502
16.07.2023 22:26+1ну лет 25 назад когда я пользовался FreeBSD я собирал каждую программу из исходников и действительно получал "чистый" бинарник. Но сегодня самые популярные дистрибутивы базируются на репозиторных сборках, где либы собраны и под них бинарники, поэтому проще 1 раз собрать либу и софт под неё, но да, качать при установке софта придется и софтину и либу. Так что ваш вариант в среде линукса практически неживой и рассматривать его нет смысла.
А в случае с дотнетом можно тоже собрать один исполнимый файл, либы будут внутри.
aegoroff
16.07.2023 22:26А в случае с дотнетом можно тоже собрать один исполнимый файл, либы будут внутри.
Ага, можно правда его вес 50 или 60 мегабайт минимум будет (цифры для линукс). Так называемый selfhosted :)
Zara6502
16.07.2023 22:26когда я в 2023 году скачиваю драйвер nvidia размером в 1 гиг или смотрю на телефоне размеры клиента сбера тоже в гиг, то я как-то не вспоминаю свою программу 1999 года, которая делала для радио медийные планы рекламы, воспроизводила музыку в эфире и занимала 400 кб. хотим мы того или не хотим, но весь софт увеличился в размерах КРАТНО. Я лично считаю это плохой тенденцией, но явно на это я повлиять не могу.
Из тех 50-60 мег о которых вы пишете это в основном библиотеки, интегрированные в файл, вы расплачиваетесь за "однофайловость", за отсутствие необходимости ставить что-то еще. Я наоборот собираю свои файлы опираясь на то, что в системе будет стоять нужная версия фрейморка. У меня всегда стоит. Файл 10240 байт всегда, пишу я что-то еще или нет. Видимо контейнер не может быть меньше этого размера. Знаю кучу софта из 90-00-х написанного под WinAPI, которые занимали единицы килобайт (будильники, часы, утилиты по расчёту сопротивлений, для работы с COM портом и т.п.).
Уже в 2001-2002 я на делфи писал программы которые использовали кучу DLL, тот же BDE, и весили уже сильно больше чем одна дискетка. Тогда стал выручать UPX.
Так что - селяви.
slonopotamus
16.07.2023 22:26-3Чем хуже винда?
Тем что в ней нет
apt-get
! И нет, ответ "зато есть WinGet" не принимается, потому что его нет в Windows Server. А ещё WinGet не умеет в зависимости.vabka
16.07.2023 22:26+2Но ведь и apt-get собственно есть не в каждом линуксе. Есть дистрибутивы, где в принципе никакого пакетного менеджера.
Так что я просто скажу про choco, который умеет в зависимости и его можно установить на windows server
aegoroff
16.07.2023 22:26Все верно - apt это пакетный менеджер debian и его производных (ubuntu, linux mint etc), в той же федоре и производных его нет но вместо него dnf, в arch based дистрибутивах - pacman вроде все основные перечислил :)
Kanut
16.07.2023 22:26+3Было бы интересно посмотреть на минимальный файл при компиляции с учётом тэга "self-contained". То есть с тем чтобы все необходимые библиотеки были запакованы в сам экзешник.
impwx
16.07.2023 22:26+1Примерно так:
Все версии содержат одну единственную строку
Console.WriteLine("Hello world")
. Используется .NET 8, публикация подwin-x64
в режиме self-contained (т.е. кроме экзешника ничего больше не нужно, сам рантайм не требуется).
Source
16.07.2023 22:26+1О, я примерно так же развлекался 18 лет назад. Только это был компилятор Delphi, Windows XP и программа выводила текущее системное время в диалоговом окне. А размер получился такой же, в районе 840 байт.
SlavaHU
16.07.2023 22:26+1А я примерно так же развлекался году этак в 95-м, писал самый маленькую по размеру программу, которая перезапустит комп. Дольше всего было сокращение с 8 до 7 байт. Конечно, COM файл.
Source
16.07.2023 22:26Ну, COM с PE не совсем честно сравнивать)
С перезагрузкой я развлекался при помощи shutdown и опций для отложенной перезагрузки, когда по очереди за одним компом работаешь. Ещё с автозапуском на CD тоже весёлые приколы были.
SlavaHU
16.07.2023 22:26Да я ж и не сравниваю. Я тоже прошел по цепочке от Паскаля (или Дельфи), через С++, С, ассемблер, до машинного кода. В 8 байтах я использовал стандартное прерывание, а в 7 байтах - какой-то малодокументированный переход по какому-то адресу. А потом запихнул это еще в свой TSR "под себя".
Xeldos
16.07.2023 22:265 байт было. Я не помню, откуда я взял этот бинарник, но он весил пять байт. Что-то вроде F0 FF FE FF FF. При дизассемблировании вылезало jmp непонятно куда.
SlavaHU
16.07.2023 22:26Потом мне уже попадалось версия с 2 байтами, сейчас поискал: "
fa f4
"cli ; disable all maskable interrupts
hlt ; stop the processor until an interrupt or hard reset happenssappience
16.07.2023 22:26Но ведь это завесит компьтер, а не перезагрузит его. Да и завесит, вероятно, не все IBM PC-compatible машины. Остается ведь немаскируемое прерывание NMI, а его по-разному в разных клонах задействовали. Где-то ошибки чётности ловили, где-то на таймер заводили. Вероятно и другие варианты были.
SlavaHU
16.07.2023 22:26Да, это вроде как завесит, но якобы в реальности все перезапскалось по ошибке. Сейчас проверить уже проблемно.
"Моя" версия с джампом действительно перезапускала.
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 байт.
Joysi
16.07.2023 22:26+1Вот они, реинкарнации ушедших душ из fido.demo.design, даешь 256 байт + 64К интро, процедурные текст
уры и real time ray raytracing
yung6lean9
16.07.2023 22:26-1Кстати, ради обсуждения, кто-нибудь понимает разницу между тем, как работает компилятор раста, и .net рантаймом? Верно ли понимаю, что раст фактически делает всё то, что делает .net, но на этапе компиляции + оперативки ест ~в 10-100 раз меньше, в несколько раз быстрее, и в несколько раз меньше весит?
vabka
16.07.2023 22:26+3Их сравнивать некорректно.
Компилятор раста и .NET Runtime делают принципиально разные вещи.
Вот как минимум ряд вещей, которых в расте изначально нет by design, но они есть в .NET Runtime:
JIT-компилятор
Рефлексия
Сборка мусора
System.Reflection.Emit и всё что с ним связано - генерация кода в рантайме
Динамическая загрузка библиотек (часто используется для реализации всяких плагинов для программы)
(Может ещё что-то забыл)
В несколько раз меньше вести
Скорее на несколько порядков меньше весит :)
На счёт "в несколько раз быстрее" - это зависит от конкретного сценария. Вполне могут быть случаи из реального мира, где разница не такая значительная.
yung6lean9
16.07.2023 22:26Гм, выходит из его плюсов вырастают и все его минусы. Совместить бы раст и шарп, чтобы получить прекрасный язык, и прекрасный компилятор...
А то сейчас слишком уж много весит и сам .net, и его рантайм. Да и производительность страдает :(
Я подозреваю, что не только я так думаю, не зря в последнее время .net идёт семимильными шагами в aot
aegoroff
16.07.2023 22:26aot не совсем то что надо - это не избавляет от необходимости наличия жирного рантайма и сборки мусора, которая находится в фундаменте .net, Rust этим принципиально отличается - у него нет никакого рантайма и автоматической сборки мусора с отслеживанием достижимости объектов.
yung6lean9
16.07.2023 22:26Да вот я и надеюсь, что после aot появится какой-нибудь компилятор, который позаимствует у раста часть фишек. Или хотя бы даст возможность некоторые участки кода полноценно оптимизировать.
vabka
16.07.2023 22:26Что вам сейчас мешает некоторые участки кода "полноценно оптимизировать"?
Уже же и так есть unsafe, stackalloc, span&memory, AOT, unmamanged memory, и даже ключевые слова, чтобы контролировать время жизни параметров функций, почти как в Rust.Что мешает сделать этот самый гибрид на основе Rust?
Сборку мусора можно втащить через crossbeam::epoch, рефлексия эмулируется при помощи макросов, jit, кодогенерацию, и подгрузку произвольного платформонезависимого кода можно легко реализовать через wasm.Ну и ещё как нечто среднее между C# и Rust можно считать Go:
Есть сборка мусора, есть рефлексия, но при этом полностью AOT.
aegoroff
16.07.2023 22:26Верно ли понимаю, что раст фактически делает всё то, что делает .net
не совсем - Rust использует достаточно уникальную модель владения (owning) и одалживания (borrowing) сущностей и следит за этим на этапе компиляции, чтобы не нарушались правила. Если все нормально, компилятор сам где надо вставляет методы drop, очищающие память после использования, т.е. фактически вызов C++ деструкторов или free из Си. Ну и да, нет никакого рантайма - практически используется ABI от С/С++, т.к. внутри все преобразуется в машинный код с использованием LLVM
yung6lean9
16.07.2023 22:26Тут скорее даже смысл в том, что изначально GC был добавлен для отслеживания утечек памяти, а раст всю эту кашу решает и без него. Поэтому .net тут явно проигрывает, но это пока не сильно заметно, раст ещё не на пике своей популярности.
В 17 году майки исследовали вопрос "Non-blocking safe manual memory management in .NET", но с тех пор что-то ничего не слышноvabka
16.07.2023 22:26а раст всю эту кашу решает и без него
Решает, да не всю.
1. Утечка памяти не считается UB, а по тому вполне возможна в Rust даже без unsafe.
2. Если у тебя есть циклические ссылки - тебе придётся включить unsafe и работать с сырыми указателями.Серебреных пуль нет.
Eltaron
16.07.2023 22:26Спасибо, увлекательно!
Сейчас ещё интересно такое проделать с бинарями, в которые компилирует дотнет с включенным AOT - ну то есть когда он выдает сразу нативный код, а не IL. По последним тестам у такого хелловролда на шарпе размер аж 1.2 мегабайта, есть простор для оптимизаций.
XaBoK
anonymous
НЛО прилетело и опубликовало эту надпись здесь
KvanTTT
Трактуйте это как предмет искусства.