1 puntos por GN⁺ 5 시간 전 | 1 comentarios | Compartir por WhatsApp
  • 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
Publicidad

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
Publicidad

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
Publicidad

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

 
GN⁺ 5 시간 전
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 que gcc lo ensamble de nuevo como un archivo “crudo” de 45 bytes
      Casualidad que sería un ELF, pero gcc no tendría por qué saberlo. Quizá así se cumplirían las reglas del texto original

      Pero 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 a SYS_exit hay 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 de libc, como el mecanismo atexit(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 fuente

    • Justo 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 general syscall() para _Exit()