- 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
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
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
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
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
dbe fazer ogccmontar isso de volta como um arquivo “bruto” de 45 bytesViraria um ELF por acaso, mas o
gccnão precisaria saber disso. Talvez assim ainda satisfizesse as regras do texto originalMas, 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 paraSYS_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 dalibc, como o mecanismoatexit(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-fonteFoi 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éricasyscall()para_Exit()foi incluída