1 pontos por GN⁺ 5 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • O experimento para reduzir o tamanho do binário ./a.out gerado apenas com GCC começa com as condições de executar com sucesso, retornar código de saída 0 e sem pós-processamento
  • O int main(){ return 0; } básico tinha 15.816 bytes, e caiu para 14.352 bytes ao remover as informações de depuração com -s
  • Com -nostartfiles, o código de inicialização anterior a main é ignorado e, com -nostdlib -static -no-pie e uma chamada direta de sistema SYS_exit, a estrutura baseada em linkagem dinâmica é removida
  • As seções .comment, .eh_frame e .note.gnu.property foram removidas respectivamente com -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables e -Wa,-mx86-used-note=no, reduzindo o overhead de seções
  • O binário final, com padding de alinhamento reduzido por -Wl,--nmagic, tem 400 bytes, e pós-processamentos como objcopy ficam fora do escopo

Objetivo e condições básicas

  • O objetivo é gerar o menor binário ./a.out possível
  • O programa precisa cumprir três condições
    • ./a.out deve executar com sucesso
    • $? deve ser deterministicamente 0
    • O binário deve ser gerado apenas com GCC, sem pós-processamento com objcopy, editor hexadecimal ou patch manual
  • O ponto de partida é o programa mais simples possível
// compiled with gcc empty.c
int main() {
return 0;
}
  • O tamanho desse programa básico é de 15.816 bytes segundo stat, e o texto faz a comparação de que são necessários quatro blocos de RAM do Apollo guidance computer para armazenar um binário que não faz nada
  • A saída de file a.out mostra ELF 64-bit LSB pie executable, dynamically linked, o caminho do interpretador e o estado not stripped
  • Para reduzir o estado not stripped, usa-se a flag -s do GCC, que compila sem manter informações de depuração, e o tamanho cai para 14.352 bytes
Publicidade

Desvio do código de inicialização e remoção da linkagem dinâmica

  • Entre a execução de ./a.out e a chegada em int main(), muita coisa acontece, e esse tema também aparece em uma palestra de 1 hora de Matt Godbolt na CppCon
  • A configuração é alterada para um binário freestanding, usando -nostartfiles e _start() para pular o processo anterior a int main()
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
  • Depois dessa mudança, o tamanho passa para 13.632 bytes, uma redução ainda pequena
  • A saída de objdump -x a.out mostra, junto da seção dinâmica, NEEDED libc.so.6, o caminho do interpretador, a tabela dinâmica de símbolos, metadados de realocação, a estrutura PLT/GOT e referências a bibliotecas compartilhadas
  • Como o objetivo do programa é apenas encerrar imediatamente, três flags removem componentes grandes
    • -nostdlib: não linka a biblioteca padrão
    • -static: evita a estrutura de linkagem dinâmica
    • -no-pie: gera um executável de endereço fixo em vez de um executável independente de posição
// 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();
}
  • Após mudar para uma chamada direta de sistema SYS_exit, o tamanho passa a 8.704 bytes
Publicidade

Remoção das seções restantes

  • A saída de objdump -D a.out ainda mostra seções como .note.gnu.property, .text, .eh_frame e .comment
  • A seção .comment armazena informações sobre o compilador que criou o binário e, neste caso, inclui a string GCC: (GNU) 15.2.0
    • O objdump interpreta esses dados como assembly e os exibe como se fossem instruções estranhas
    • Ao adicionar -fno-ident, a seção .comment é removida e o tamanho cai para 8.616 bytes
  • A seção .eh_frame é usada para desenrolamento de pilha e não é necessária, para tratamento de erros, em um programa que não faz nada
    • Com -fno-exceptions -fno-asynchronous-unwind-tables, o tamanho cai para a faixa de 4 KB
  • Por fim, resta remover a seção .note.gnu.property
    • readelf -n a.out mostra propriedades como x86 feature used: x86 e x86 ISA used: x86-64-baseline
    • O GNU deixa notas nessa seção para que outras ferramentas possam lê-las, e neste caso quem adiciona a nota é o assembler
    • Ao adicionar -Wa,-mx86-used-note=no, o tamanho passa a 4.320 bytes
  • Nesse ponto, objdump -D a.out mostra apenas as instruções da seção .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
Publicidade

Padding de alinhamento e a estrutura de 400 bytes

  • A saída de readelf -a a.out no estado de 4.320 bytes mostra o cabeçalho ELF, 3 cabeçalhos de programa, 3 cabeçalhos de seção, e as estruturas .text e .shstrtab
  • O cabeçalho de programa é a tabela que informa ao loader do sistema operacional como mapear o arquivo em segmentos de memória ao iniciar o programa
  • Os 232 bytes de LOAD nessa saída correspondem ao cabeçalho ELF de 64 bytes e aos 3 cabeçalhos de programa de 56 bytes cada
  • Como a exigência de alinhamento da entrada LOAD é 0x1000, o linker posiciona .text depois de um padding
  • Ao passar -Wl,--nmagic, o linker deixa de assumir isso e pode mapear juntos os metadados ELF e a seção .text, restando apenas um LOAD e reduzindo o tamanho para 400 bytes
  • A composição do binário de 400 bytes é a seguinte
Componente Tamanho
ELF header 64 B
Program header: PT_LOAD 56 B
Program header: PT_GNU_STACK 56 B
Conteúdo da seção .text 11 B
Conteúdo da seção .shstrtab, "\0.shstrtab\0.text\0" 17 B
padding para section header 4 B
Section header [0]: NULL 64 B
Section header [1]: .text 64 B
Section header [2]: .shstrtab 64 B
  • PT_LOAD é necessário para carregar as instruções, e PT_GNU_STACK é sempre gerado pelo GCC
  • .shstrtab não pode ser removida usando apenas GCC
  • A primeira entrada de section header precisa, segundo a especificação ELF ABI System V, ficar reservada para o índice de seção indefinido SHN_UNDEF, de valor 0
  • Na prática, essa entrada tem tipo SHT_NULL, por isso ferramentas a mostram como a seção NULL
  • Ferramentas como objcopy conseguem cortar ainda mais alguns itens, mas esse método fica fora do escopo

Tamanhos por etapa e código final

Etapa Flags / mudança Tamanho
main normal gcc empty.c 15.816 bytes
Remoção de símbolos -s 14.352 bytes
Freestanding -nostartfiles 13.632 bytes
Remoção da libc / linkagem estática / sem PIE -nostdlib -static -no-pie 8.704 bytes
Remoção da seção .comment -fno-ident 8.616 bytes
Remoção de informação de unwind -fno-asynchronous-unwind-tables -fno-exceptions 4.400 bytes
Remoção da GNU property note -Wa,-mx86-used-note=no 4.320 bytes
Redução de alinhamento -Wl,--nmagic / -Wl,-n 400 bytes
  • O comando final de compilação e o código são os seguintes
// 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();
}
  • Foi um exercício inicial usando objdump e ld, e -fno-asynchronous-unwind-tables -fno-exceptions informa ao GCC que não é necessário tratamento de desenrolamento de pilha em caso de erro
  • O ld também tem a flag --no-eh-frame-hdr
  • No reddit, há um caso que reduziu isso até 124 bytes

1 comentários

 
GN⁺ 5 시간 전
Comentários no Lobste.rs
  • Se no fim das contas vai usar só assembly, não entendo por que usar um compilador C

    • É só um experimento por diversão :)

    • Assembly é um ótimo ponto de partida. Tenho um binário hello world de 231 bytes compilado a partir daqui:
      https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp

      Comecei com um tutorial parecido anos atrás e, depois disso, fui separando melhor o código e construindo gradualmente as tecnologias em volta, mantendo o overhead o mais baixo possível nos casos simples. Manter os 231 bytes é importante, então até coloquei um teste de CI para garantir isso

      Edit: só agora percebi que deixei um include desnecessário. Preciso corrigir isso

    • Concordo. Ainda assim, existem vários truques específicos de C, e sem um pouco de assembly acho que o quadro geral não ficaria completo

  • Link relacionado: https://www.muppetlabs.com/~breadbox/software/tiny/

    • Na prática, aqui tem um binário de 45 bytes. Num caso extremo, talvez desse até para codificar em assembly só com uma lista de db e fazer o gcc montar isso de volta como um arquivo “bruto” de 45 bytes
      Viraria um ELF por acaso, mas o gcc não precisaria saber disso. Talvez assim ainda satisfizesse as regras do texto original

      Mas, na maioria das definições razoáveis, aí já ficaria difícil chamar isso de um binário C

  • Acho que a resposta depende do compilador. Só não sei se dá para aceitar depender de código que não é C só porque alguns compiladores C deixam passar 😉

  • Entre um programa em C++ que chama exit(3) e uma chamada em assembly para SYS_exit, existe um meio-termo. Como dá para ver pelo número da seção do manual, exit(3) é uma função de biblioteca, então ele puxa bastante coisa da libc, como o mecanismo atexit(3)
    A forma padrão de chamar a system call de exit “crua” é _exit(2), e colocando isso em _start() com linkedição estática deve sair algo bem pequeno. Se você escrever em C em vez de C++, também pode reduzir o comando do compilador e o tamanho do código-fonte

    • Foi exatamente isso que eu testei

      #include <stdlib.h>
      void _start(void)
      {
      _Exit(0); /* C99 function to call SYS_exit() */
      }

      Compilei com gcc -Os -nostdlib -static -o x x.c -lc, e o executável stripped ficou com 8912 bytes, mas o código realmente gerado tinha só 96 bytes. Isso aconteceu porque a função genérica syscall() para _Exit() foi incluída