- Un experimento para reducir el tamaño del binario
./a.out generado solo con GCC parte de estas condiciones: ejecución exitosa, código de salida 0 y sin posprocesamiento
- El
int main(){ return 0; } básico pesaba 15,816 bytes, y al quitar la información de depuración con -s se redujo a 14,352 bytes
- Con
-nostartfiles se omite el código de arranque previo a main, y con -nostdlib -static -no-pie más una llamada directa al syscall SYS_exit se elimina la estructura basada en enlace dinámico
- Se eliminan
.comment, .eh_frame y .note.gnu.property con -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables y -Wa,-mx86-used-note=no, respectivamente, para reducir la sobrecarga de secciones
- El binario final, usando
-Wl,--nmagic para reducir el padding por alineación de 0x1000, queda en 400 bytes, y el posprocesamiento con herramientas como objcopy queda fuera del alcance
Objetivo y condiciones básicas
- El objetivo es generar el binario
./a.out más pequeño posible
- El programa debe cumplir tres condiciones
./a.out debe ejecutarse correctamente
$? debe ser determinísticamente 0
- El binario debe generarse solo con GCC, sin posprocesamiento con
objcopy, editores hexadecimales ni parches manuales
- El punto de partida es el programa más simple posible
// compiled with gcc empty.c
int main() {
return 0;
}
- El tamaño de este programa base es de 15,816 bytes según
stat, y se usa la comparación de que almacenar un binario que no hace nada requiere el equivalente a cuatro bloques de RAM de la Apollo guidance computer
- La salida de
file a.out muestra ELF 64-bit LSB pie executable, dynamically linked, la ruta del intérprete y el estado not stripped
- Para reducir el estado
not stripped, se usa la bandera -s de GCC para compilar sin conservar información de depuración, y el tamaño baja a 14,352 bytes
Evitar el código de arranque y eliminar el enlace dinámico
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
- Después de este cambio, el tamaño es de 13,632 bytes, así que la reducción no es muy grande
- La salida de
objdump -x a.out muestra, junto con la sección dinámica, NEEDED libc.so.6, la ruta del intérprete, la tabla de símbolos dinámicos, metadatos de relocación, la estructura PLT/GOT y referencias a bibliotecas compartidas
- Como el objetivo del programa es solo terminar de inmediato, se eliminan grandes componentes con tres banderas
-nostdlib: no enlaza la biblioteca estándar
-static: evita la estructura de enlace dinámico
-no-pie: genera un ejecutable de dirección fija en lugar de uno independiente de posición
// compiled with gcc -static -nostdlib -no-pie -s empty.c
extern "C" __attribute__((noreturn)) void _start() {
__asm__ volatile(
"mov $60, %%eax\n" // SYS_exit
"xor %%edi, %%edi\n" // exit status 0
"syscall\n" ::
: "rax", "rdi");
__builtin_unreachable();
}
- Tras cambiar a una llamada directa al syscall
SYS_exit, el tamaño queda en 8,704 bytes
Eliminar las secciones restantes
- La salida de
objdump -D a.out todavía muestra secciones como .note.gnu.property, .text, .eh_frame y .comment
- La sección
.comment guarda información sobre el compilador que creó el binario y, en este caso, incluye la cadena GCC: (GNU) 15.2.0
objdump interpreta estos datos como ensamblador y los muestra como si fueran instrucciones extrañas
- Al agregar
-fno-ident, la sección .comment desaparece y el tamaño baja a 8,616 bytes
- La sección
.eh_frame se usa para desenrollado de pila, y en un programa que no hace nada no hace falta para manejo de errores
- Con
-fno-exceptions -fno-asynchronous-unwind-tables, el tamaño baja al rango de 4 KB
- Lo último que queda por quitar es la sección
.note.gnu.property
readelf -n a.out muestra las propiedades x86 feature used: x86 y x86 ISA used: x86-64-baseline
- GNU deja notas en esta sección para que otras herramientas puedan leerlas, y en este caso es el ensamblador quien las añade
- Al agregar
-Wa,-mx86-used-note=no, el tamaño pasa a 4,320 bytes
- En este punto,
objdump -D a.out ya solo muestra instrucciones de la sección .text
401000: 55 push %rbp
401001: 48 89 e5 mov %rsp,%rbp
401004: b8 3c 00 00 00 mov $0x3c,%eax
401009: 31 ff xor %edi,%edi
40100b: 0f 05 syscall
Padding por alineación y la estructura de 400 bytes
- La salida de
readelf -a a.out en el estado de 4,320 bytes muestra la cabecera ELF, 3 program headers, 3 section headers y la estructura .text, .shstrtab
- Los program headers son la tabla que le indica al cargador del sistema operativo cómo mapear el archivo en segmentos de memoria al iniciar el programa
- Los 232 bytes de
LOAD en esa salida corresponden a la cabecera ELF de 64 bytes y a 3 program headers de 56 bytes cada uno
- El requisito de alineación del elemento
LOAD es 0x1000, así que el linker coloca .text después del padding
- Si se pasa
-Wl,--nmagic al linker para que no haga esa suposición, puede mapear juntos los metadatos ELF y la sección .text, queda un solo LOAD y el tamaño baja a 400 bytes
- La composición del binario de 400 bytes es la siguiente
| Componente |
Tamaño |
| ELF header |
64 B |
Program header: PT_LOAD |
56 B |
Program header: PT_GNU_STACK |
56 B |
Contenido de la sección .text |
11 B |
Contenido de la sección .shstrtab, "\0.shstrtab\0.text\0" |
17 B |
| Padding para section headers |
4 B |
Section header [0]: NULL |
64 B |
Section header [1]: .text |
64 B |
Section header [2]: .shstrtab |
64 B |
PT_LOAD es necesario para cargar las instrucciones, y PT_GNU_STACK GCC lo genera siempre
.shstrtab no se puede eliminar usando solo GCC
- La primera entrada de section header debe quedar reservada para el índice de sección no definido
SHN_UNDEF, con valor 0, tal como exige la System V ABI ELF specification
- En la práctica, esta entrada es de tipo
SHT_NULL, así que las herramientas la muestran como la sección NULL
- Herramientas como
objcopy pueden recortar un poco más algunos elementos, pero ese enfoque queda fuera del alcance
Tamaño por etapa y código final
| Etapa |
Banderas / cambio |
Tamaño |
main normal |
gcc empty.c |
15,816 bytes |
| Eliminación de símbolos |
-s |
14,352 bytes |
| Freestanding |
-nostartfiles |
13,632 bytes |
| Sin libc / enlace estático / no PIE |
-nostdlib -static -no-pie |
8,704 bytes |
Eliminación de la sección .comment |
-fno-ident |
8,616 bytes |
| Eliminación de información de unwind |
-fno-asynchronous-unwind-tables -fno-exceptions |
4,400 bytes |
| Eliminación de GNU property note |
-Wa,-mx86-used-note=no |
4,320 bytes |
| Reducción de alineación |
-Wl,--nmagic / -Wl,-n |
400 bytes |
- El comando de compilación final y el código son los siguientes
// gcc -Wl,--nmagic -Wa,-mx86-used-note=no -static -nostdlib -no-pie -s -fno-ident -fno-exceptions -fno-asynchronous-unwind-tables empty.c
extern "C" __attribute__((noreturn)) void _start() {
__asm__ volatile(
"mov $60, %%eax\n" // SYS_exit
"xor %%edi, %%edi\n" // exit status 0
"syscall\n" ::
: "rax", "rdi");
__builtin_unreachable();
}
- Fue un ejercicio de aprendizaje usando
objdump y ld por primera vez, y -fno-asynchronous-unwind-tables -fno-exceptions le indica a GCC que no hace falta procesar desenrollado de pila en caso de error
ld también tiene la bandera --no-eh-frame-hdr
- En reddit hay un caso que lo reduce hasta 124 bytes
1 comentarios
Comentarios de Lobste.rs
Si de todos modos vas a usar solo ensamblador, no entiendo por qué usar un compilador de C
Es solo un experimento por diversión :)
El ensamblador es un muy buen punto de partida. Tengo desde ahí un binario hello world compilado de 231 bytes:
https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp
Empecé con un tutorial parecido hace unos años, y después fui construyendo gradualmente tecnología alrededor mientras separaba mejor el código y mantenía lo más bajo posible la sobrecarga en los casos simples. Mantener los 231 bytes es importante, así que hasta puse una prueba de CI para garantizarlo
Edit: recién me di cuenta de que había dejado un include innecesario. Tendría que corregirlo
De acuerdo. Aun así, hay bastantes trucos específicos de C, y sin un poco de ensamblador no creo que se hubiera completado el panorama general
Enlace relacionado: https://www.muppetlabs.com/~breadbox/software/tiny/
De hecho, aquí hay un binario de 45 bytes. Llevándolo al extremo, parecería posible codificarlo en ensamblador solo con una lista de
db, y luego hacer quegcclo ensamble de nuevo como un archivo “crudo” de 45 bytesCasualidad que sería un ELF, pero
gccno tendría por qué saberlo. Quizá así se cumplirían las reglas del texto originalPero bajo la mayoría de las definiciones razonables, ya sería difícil llamarlo un binario en C
La respuesta probablemente depende del compilador. Aun así, no estoy seguro de que porque algunos compiladores de C lo acepten haya que dar por válido apoyarse en código que no es C 😉
Entre un programa en C++ que llama a
exit(3)y una llamada en ensamblador aSYS_exithay un punto intermedio. Como se puede ver por el número de sección del manual,exit(3)es una función de biblioteca, así que arrastra bastante delibc, como el mecanismoatexit(3)La forma estándar de invocar la llamada al sistema exit sin procesar es
_exit(2), y si la pones en_start()y haces enlace estático, debería salir algo bastante pequeño. Si lo escribes en C en vez de C++, también puedes reducir la invocación del compilador y el tamaño del código fuenteJusto hice eso
#include <stdlib.h>void _start(void){_Exit(0); /* Función de C99 para llamar a SYS_exit() */}Al compilar con
gcc -Os -nostdlib -static -o x x.c -lc, el ejecutable strippeado pesaba 8912 bytes, pero el código realmente generado era de solo 96 bytes. Eso fue porque se incluyó la función generalsyscall()para_Exit()