¿Qué tan pequeño puede ser un binario mínimo de .NET Hello World?
(blog.washi.dev)-. Para un ejecutable que solo imprime "Hello World" en .NET Framework 4.x, el resultado normal de compilación en C# se redujo manualmente de 4.6 KB a 834 bytes armando el PE a mano
- El experimento impuso estas condiciones: punto de entrada administrado, archivo ejecutable único de Windows, uso exclusivo de la BCL o bibliotecas incluidas normalmente en Windows, y sin contar los 0 bytes del final del archivo
- La reducción de tamaño se logró eliminando anotaciones de tipos de referencia anulables y metadatos innecesarios, y cambiando a un PE de 64 bits para quitar imports y base relocations
- En lugar de
Console.WriteLine, se llamó aucrtbase!putspor P/Invoke para eliminar el stream UTF-16#US, y la cadena ASCII se movió al área de padding entre el encabezado PE y la primera sección - El ejecutable final quedó en 834 B (0x342) sin contar los ceros finales, y en 476 B comprimido en ZIP, mostrando el límite mínimo de configuración de un PE de .NET y los metadatos del CLR
Condiciones del experimento y línea base
- El objetivo era meter la menor cantidad posible de bytes dentro de un ejecutable .NET para que el CLR imprimiera
"Hello World"en la salida estándar - Las reglas del experimento fueron las siguientes
- La aplicación debía ejecutar un punto de entrada administrado implementado en C# o CIL
- El runtime objetivo era .NET Framework 4.x.x
- Se prohibían dependencias de terceros; solo se permitían
mscorlib, como parte de la BCL, o bibliotecas incluidas normalmente en Windows - Por las restricciones de alineación del archivo PE y del CLR, el archivo PE teóricamente mínimo de .NET que puede ejecutarse en Windows 10 o superior no puede ser menor a 1 KB, pero los trailing zero bytes no se contaron en el cálculo del tamaño
- Si se compila un programa Hello World normal de C# con
net472, C# 10 y nullable habilitado, el ejecutable pesa 4.6 KB - El código fuente final está en Full Source Code
Qué se puede recortar del resultado compilado de C#
- Los tipos de referencia anulables de C# 8.0 se incluyen estáticamente en el ejecutable en forma de atributos personalizados, y esos atributos aumentan bastante el tamaño
- Si en el
.csprojse desactiva la comprobación de tipos de referencia anulables con<Nullable>disable</Nullable>, los atributos desaparecen, pero por la alineación del archivo PE el tamaño total sigue siendo de 4.6 KB - Al descompilar se ve que el compilador de C# sigue generando metadatos innecesarios incluso con nullable desactivado
- assembly attributes como la versión del archivo del ensamblado o la información de copyright
- El tipo oculto
<Module>además de la claseProgram
- En vez de buscar más opciones extremas del compilador de C#, se decidió crear directamente un ejecutable .NET con AsmResolver
- El módulo creado a mano coloca la función
mainen el tipo<Module>y construye un cuerpo CIL que solo contieneLdstr "Hello World",Console.WriteLineyRet - Con este método, el tamaño del archivo se reduce aproximadamente a la mitad frente al original
Eliminar imports y base relocations con un PE de 64 bits
- El ejecutable generado tiene dos secciones,
.texty.reloc, y los directorios de datos de Imports y Base Relocations ocupan mucho espacio - En un ejecutable .NET AnyCPU o de 32 bits, el punto de entrada no administrado debe llamar a
mscoree!_CorExeMain, por lo que se necesita el directorio de imports - Como el ejecutable .NET predeterminado es relocatable, también se necesita una base relocation para esa llamada a la función importada
- Las base relocations normalmente van en una sección aparte, y cada sección debe alinearse al menos a bloques de
0x200bytes, por lo que el archivo se infla a un mínimo de 1 KB - En un ejecutable .NET de 64 bits no hace falta punto de entrada no administrado, así que con la siguiente configuración se pueden eliminar ambos directorios y la sección
.relocmodule.PEKind = OptionalHeaderMagic.PE64module.MachineType = MachineType.Amd64
- En esta etapa se alcanza el mínimo teórico de 1 KB para un archivo PE válido de .NET
Eliminar nombres de metadatos, GUID y public key token
- A partir de aquí, la medición del tamaño ya no se toma del tamaño de archivo que muestra Windows Explorer, sino de la posición inmediatamente después del último byte distinto de cero en un editor hexadecimal
- Con ese criterio, el archivo PE de 1 KB ya se había reducido a 991 bytes (0x3DF)
- El heap
#Stringses el segundo stream de metadatos más grande en un binario .NET y almacena los nombres de tipos y métodos usados por el stream de tablas#~ - Si se asignan
nulla nombres que no son importantes en tiempo de ejecución, el programa sigue funcionando normalmente, y eso ahorra 32 bytes - El stream
#GUIDcontiene el GUID al que apunta elMvidde la tabla Module, pero en este experimento no hacía falta identificar una versión, así que se eliminó- Se eliminaron los 16 bytes del
Mvid - También se eliminaron el stream vacío
#GUIDcompleto y su encabezado de 16 bytes - En total se ahorraron 32 bytes
- Se eliminaron los 16 bytes del
mscorlib, referenciado para llamar aConsole::WriteLine, normalmente lleva un public key token de 8 bytes- Si no hay public key token, el CLR no verifica la autenticidad de ese assembly token, y en el experimento se eliminó
- Después de esta etapa, el archivo quedó en 918 bytes
Eliminar Console.WriteLine y usar una cadena ASCII
"Hello World"se guarda en el stream de metadatos#USde .NET como un arreglo de caracteres de 16 bits con prefijo de longitud y un byte 0 adicional- Todos los caracteres de la cadena a imprimir tienen puntos de código menores a
0xFF, así que podrían representarse como ASCII de 1 byte, pero no hay forma de hacer que el CLR interprete como ASCII una cadena del stream#US - En lugar de
Console::WriteLine, se llama a la función no administradaucrtbase!putsmediante P/Invoke- La declaración equivalente en C# es
[DllImport("ucrtbase")] static extern int puts(nint str); putsrecibe como argumento un puntero a una cadena ASCII terminada en cero
- La declaración equivalente en C# es
- Para conocer en tiempo de compilación la dirección en runtime de la cadena, se desactiva el flag DynamicBase en
DllCharacteristicsdel encabezado opcional PE- Se fija una image base estática
- La cadena ASCII se coloca dentro de la sección
.text - La dirección en runtime se calcula como
module_base_address + rva_ascii_string
- Para que el CLR respete este flag, también se desactiva el flag ILOnly del directorio de datos de .NET
- Con este cambio se eliminan la cadena wide-character, el stream
#USy la referencia aSystem.Console::WriteLine, y en su lugar solo se agregan la definición deputsy los metadatos de P/Invoke - El resultado baja a 889 bytes (0x379)
Últimas microoptimizaciones
- La definición estándar de
putsdevuelve unint32con la cantidad de caracteres impresos, pero el programa no usa ese valor - En un PE de 64 bits, según la x64 calling convention de Microsoft, el valor de retorno queda en el registro
RAX, no en la pila de evaluación de .NET - Si se cambia la definición P/Invoke a retorno
void, se puede eliminar la instrucciónpopdel métodomainque descartaba el valor de retorno - Como el tamaño del encabezado PE es menor a
0x200, se generan padding data entre el encabezado y la primera sección - El Windows PE Loader mapea el encabezado PE en memoria legible, y ese rango incluye también los padding data
- Si la cadena ASCII se mueve a esa zona de padding, se pueden ahorrar 13 bytes más
- Como ya no se referencia
Console::WriteLine, también se elimina la referencia amscorlib- Se reduce una tabla del stream de tablas
#~ - También se elimina el nombre
mscorlibdel stream#Strings
- Se reduce una tabla del stream de tablas
- El
VersionStringdel directorio de metadatos .NET es por defecto"v4.0.30319", y con el padding a múltiplos de 4 bytes ocupa en total 12 bytes - Si se acorta a
v4.0., el CLR 4.0 sigue arrancando y el programa se ejecuta, con lo que se ahorran 4 bytes adicionales después del padding - El
.final aparentemente es necesario; si se reduce más, el programa ya no arranca correctamente
Resultado final
- El tamaño final del binario, sin contar los trailing zero, es de 834 bytes (0x342)
- Comprimido en ZIP queda en 476 bytes, menos que los 582 bytes del punto donde se alcanzó el límite de 1 KB sin optimizaciones adicionales
- El experimento comenzó con un Hello World de 4.6 KB generado por el compilador de C# y terminó en un archivo PE armado manualmente de 834 B
- El programa final sigue imprimiendo correctamente
"Hello World" - Más que algo práctico, este trabajo se parece a un experimento para explorar los límites mínimos de configuración del formato de módulos .NET y los metadatos del CLR
1 comentarios
Comentarios en Hacker News
Este tipo de experimentos de programa ejecutable mínimo me encantan
Michal Strehovský creó un juego de Snake de menos de 8 KB usando el framework ahora conocido como .NET, sin depender del runtime de .NET
Medium no me gusta mucho y Twitter tampoco sirve demasiado ahora, pero recuerdo que más o menos el "hello world" incluso soportaba Windows 3.11
https://medium.com/@MStrehovsky/building-a-self-contained-ga... [https://web.archive.org/web/20200103110836/https://medium.co... | https://archive.ph/b6qXE]
https://news.ycombinator.com/item?id=22010159 (2020)
https://news.ycombinator.com/item?id=22104734
El trabajo más reciente y el patrocinio se pueden ver en https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"
https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
Según recuerdo, buena parte del tamaño de descarga era la propia biblioteca de .NET
También me pregunto si existe algún framework o motor de juegos 2D orientado al navegador que use C# y produzca binarios pequeños
Este artículo puede resultar confuso. Ese ejecutable requiere un runtime instalado en el sistema y, de hecho, usa .NET Framework 4.7.2 en 2023
Para compararlo de forma justa con este enfoque, probablemente tendría más sentido usar archivos .jar o .py
Si hablamos de ejecutables reales, un binario Native AOT que pueda correr en Windows, Linux y macOS sería un criterio más adecuado
Aun así, es natural que sea mucho más grande porque también tiene que incluir código de soporte como GC, ThreadPool y Console
La lógica para escribir bytes en la consola se resuelve con una llamada pinvoke a código no administrado expuesto por ucrt.dll
El artículo sobre minimizar Snake que compartieron en otro comentario hace lo mismo sobre .NET Core, que es el .NET nuevo no incluido en Windows, y de hecho produce un binario no-.NET
[0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
Comparar imágenes de contenedor parece un buen punto de partida. Usar la imagen base más pequeña posible o, si se puede, "FROM scratch", pero comparando solo dentro de la misma arquitectura
Preferiría 32 bits para eliminar variables como correr 32 bits sobre 64 bits o la compresión de punteros
Después estaría bien comparar el tamaño del tar exportado sin comprimir. No sería totalmente justo, pero al menos reduciría las variables obvias
Pensándolo más, quizá sería más justo comparar el tamaño máximo de imagen comprimida, para reflejar también la compresión dentro del contenedor. Claro, usando el mismo algoritmo y parámetros, o bien, como en los benchmarks de compresión, sumando el tamaño del descompresor al resultado final
Se invoca así:
echo "hello world"En concreto, .NET Framework 4.7.2 debería venir incluido por defecto en las versiones compatibles de Windows
Una instalación reciente de Windows no puede no tener .NET, mientras que Linux y macOS no lo traen por defecto, así que usar eso como criterio de justicia me parece raro
Además, me pregunto si alguien notó que la ubicación del autor es McMurdo Station, Antártida
No esperaba que hubiera alguien con ese stack tecnológico en esa base: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
Podría ser una historia bastante interesante
Lo que sorprende a los externos es que la mayoría de la gente allí es personal de apoyo; los científicos son una fracción pequeña, quizá solo alrededor del 20%
El personal de apoyo viene de todo tipo de oficios necesarios para hacer funcionar una ciudad moderna: bomberos, carpinteros, cocineros, mecánicos, etc.
Quizá por el proceso de selección, casi todos son extremadamente capaces y están entre los mejores en lo suyo. Había una cantidad sorprendente de personas tipo navaja suiza con habilidades muy amplias
Las dificultades particulares de vivir y trabajar allí atraen, como polillas a la luz, a personas únicas y muy motivadas, y mucha gente regresa año tras año. Es una comunidad excelente
¿Qué otra cosa podrías hacer allá?
No aparece en los metadatos de .NET ni en about
Dijeron que el archivo se infla al menos esa cantidad porque todas las secciones deben alinearse al valor mínimo posible de alineación de sección, 0x200 bytes, pero 0x200 son 512 bytes, o sea 0.5 KB
Hice este nivel de optimización del tamaño de PE hace mucho tiempo, pero recuerdo que en Windows 9x el mínimo era 512, mientras que NT aceptaba alineaciones tan pequeñas como 1
Tampoco vi el viejo truco, que antes era de última generación, de superponer los encabezados DOS MZ y PE: http://www.phreedom.org/research/tinype/
Y luego está darse cuenta de que en la demoscene logran hacer esto dentro de un binario de 1 KB:
https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...
Esto de ninguna manera fue una pérdida tonta de tiempo
Meterse en los detalles de los binarios da una comprensión profunda de cómo se construyen, y los conceptos que aprendes ahí enriquecen tu conocimiento
Si te parece interesante y aprendiste algo, siempre vale la pena dedicarle tiempo y esfuerzo
Esto es mucho más relevante e interesante: https://twitter.com/MStrehovsky/status/1669502394827419648
Dice que un Hello World de C# compilado nativamente y completamente autocontenido, "incluyendo GC y todo lo demás", puede reducirse a unos 440 KB
El .NET classic framework es un legado obsoleto de Windows que ya no se recomienda, y las apps nuevas no deberían construirse sobre eso
Esperaba que se hablara de binarios "reales", o sea ejecutables .NET compilados nativamente, como .NET AOT
https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...
Parece que la build AOT compilada con más reducción está a punto de romper la barrera de 1 MB:
https://github.com/AustinWise/SmallestDotnetHelloWorlds
Felicidades. Básicamente descartaste el soporte Unicode e hiciste una llamada en C a
puts¿No habría sido más fácil simplemente compilarlo con un compilador de C?
thats_just_c_with_extra_steps.jpgAcabo de probarlo con el IDE de Oberon+ y este módulo produce un Hello.dll de 2048 bytes
module Hellobeginprintln("hello world")end HelloUsa el generador de ensamblado https://github.com/rochus-keller/Pelib
Eso sentí cuando usé Lazarus/FreePascal por primera vez, y mientras más leo su obra, más creo que estaba en lo correcto
Últimamente pienso que WASM podría ser la salida de esta "reencarnación" de esa forma de pensar, pero no puedo rendirme así con el hardware
Soy demasiado primitivo para aceptar una máquina espiritual que no pueda romper con un martillo sobre mi escritorio
Una computadora global indestructible como Ethereum, que corre usando miles de dólares en tiempo de cómputo en hardware ajeno y es más lenta que una RPi, me resulta tabú de alguna manera fundamental
Todavía quiero poner "Wirth was right" en una camiseta con la cara de Wirth
Hay demasiado residuo del ecosistema de C++
Me recuerda a los proyectos de forja de hosting de software open source que se promocionan como alternativas a GitHub y GitLab. Pero cuando vas a clonar el código o reportar un bug, todo está en GitHub
Parece que debería haber una opción para desactivar eso
Fue revelador ver que se puede reducir un
.exea menos de 1 KBSi alguien pudiera reducir el
.exede Hello World de Flutter hasta 1 MB, sería realmente impresionante