- Eksperimen untuk mengecilkan ukuran biner
./a.out yang dibuat hanya dengan GCC dimulai dengan syarat program harus berhasil dijalankan, mengembalikan kode keluar 0, dan tanpa pascapemrosesan
- Program dasar
int main(){ return 0; } berukuran 15.816 byte, lalu diperkecil menjadi 14.352 byte dengan -s untuk menghapus informasi debug
- Dengan
-nostartfiles untuk melewati kode startup sebelum main, lalu -nostdlib -static -no-pie dan syscall SYS_exit langsung, struktur berbasis dynamic linking dihapus
.comment, .eh_frame, dan .note.gnu.property masing-masing dihapus dengan -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables, dan -Wa,-mx86-used-note=no untuk mengurangi overhead section
- Biner akhir dengan
-Wl,--nmagic yang mengurangi padding alignment 0x1000 berukuran 400 byte, dan pascapemrosesan seperti objcopy berada di luar cakupan
Tujuan dan kondisi dasar
- Tujuannya adalah membuat biner
./a.out sekecil mungkin
- Program harus memenuhi tiga syarat
./a.out harus bisa dijalankan dengan sukses
$? harus secara deterministik bernilai 0
- Biner harus dibuat hanya dengan GCC, tanpa pascapemrosesan seperti
objcopy, editor heksadesimal, atau patch manual
- Titik awalnya adalah program paling sederhana
// compiled with gcc empty.c
int main() {
return 0;
}
- Ukuran file program dasar ini menurut
stat adalah 15.816 byte, dan dibandingkan dengan fakta bahwa untuk menyimpan biner yang tidak melakukan apa pun ini dibutuhkan RAM setara empat unit Apollo guidance computer
- Output
file a.out menampilkan status ELF 64-bit LSB pie executable, dynamically linked, jalur interpreter, dan not stripped
- Untuk mengurangi status
not stripped, flag -s dari GCC digunakan agar kompilasi tidak menyimpan informasi debug, sehingga ukurannya turun menjadi 14.352 byte
Melewati kode startup dan menghapus dynamic linking
- Dari saat
./a.out dijalankan sampai mencapai int main(), ada banyak proses yang terjadi, dan topik ini juga dibahas dalam presentasi 1 jam Matt Godbolt di CppCon
- Dengan
-nostartfiles dan _start(), konfigurasi diubah menjadi biner freestanding yang melewati proses sebelum int main()
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
- Setelah perubahan ini, ukurannya menjadi 13.632 byte, jadi pengurangannya tidak terlalu besar
- Output
objdump -x a.out menunjukkan section dinamis bersama NEEDED libc.so.6, jalur interpreter, tabel simbol dinamis, metadata relokasi, struktur PLT/GOT, dan referensi shared library
- Karena tujuan program hanya keluar seketika, tiga flag berikut digunakan untuk membuang komponen besar
-nostdlib: tidak me-link standard library
-static: menghindari struktur dynamic linking
-no-pie: membuat executable beralamat tetap alih-alih position-independent executable
// 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();
}
- Setelah diubah menjadi pemanggilan syscall
SYS_exit secara langsung, ukurannya menjadi 8.704 byte
Menghapus section yang tersisa
- Output
objdump -D a.out masih memperlihatkan section seperti .note.gnu.property, .text, .eh_frame, dan .comment
- Section
.comment menyimpan informasi compiler yang membuat biner, dan dalam kasus ini berisi string GCC: (GNU) 15.2.0
objdump menafsirkan data ini sebagai assembly sehingga terlihat seperti instruksi aneh
- Dengan menambahkan
-fno-ident, section .comment dihapus dan ukuran turun menjadi 8.616 byte
- Section
.eh_frame dipakai untuk stack unwinding, dan untuk program yang tidak melakukan apa pun section ini tidak diperlukan untuk penanganan error
- Dengan
-fno-exceptions -fno-asynchronous-unwind-tables, ukuran turun ke kisaran 4 KB
- Target terakhir yang dihapus adalah section
.note.gnu.property
readelf -n a.out menampilkan properti x86 feature used: x86 dan x86 ISA used: x86-64-baseline
- GNU meninggalkan note di section ini agar bisa dibaca tool lain, dan dalam kasus ini assembler yang menambahkannya
- Dengan
-Wa,-mx86-used-note=no, ukurannya menjadi 4.320 byte
- Pada titik ini,
objdump -D a.out hanya menampilkan instruksi di section .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 alignment dan struktur 400 byte
- Output
readelf -a a.out pada ukuran 4.320 byte memperlihatkan ELF header, 3 program header, 3 section header, serta struktur .text dan .shstrtab
- Program header adalah tabel yang memberi tahu OS loader bagaimana memetakan file ke segmen memori saat program dimulai
- Nilai
LOAD sebesar 232 byte pada output tersebut setara dengan 64 byte ELF header dan 3 program header masing-masing 56 byte
- Karena syarat alignment pada entri
LOAD adalah 0x1000, linker menempatkan .text setelah padding
- Dengan
-Wl,--nmagic, linker diberi tahu agar tidak membuat asumsi itu, sehingga metadata ELF dan section .text bisa dipetakan bersama, hanya menyisakan satu LOAD dan menurunkan ukuran menjadi 400 byte
- Komposisi biner 400 byte adalah sebagai berikut
| Komponen |
Ukuran |
| ELF header |
64 B |
Program header: PT_LOAD |
56 B |
Program header: PT_GNU_STACK |
56 B |
Isi section .text |
11 B |
Isi section .shstrtab, "\0.shstrtab\0.text\0" |
17 B |
| padding untuk section header |
4 B |
Section header [0]: NULL |
64 B |
Section header [1]: .text |
64 B |
Section header [2]: .shstrtab |
64 B |
PT_LOAD diperlukan untuk memuat instruksi, dan PT_GNU_STACK selalu dibuat oleh GCC
.shstrtab tidak bisa dihapus hanya dengan GCC
- Entri section header pertama diwajibkan oleh System V ABI ELF specification untuk dicadangkan bagi indeks section tak terdefinisi
SHN_UNDEF yang bernilai 0
- Dalam praktiknya, entri ini bertipe
SHT_NULL sehingga di tool ditampilkan sebagai section NULL
- Tool seperti
objcopy bisa memangkas beberapa entri lagi, tetapi pendekatan itu berada di luar cakupan
Ukuran per tahap dan kode akhir
| Tahap |
Flag / perubahan |
Ukuran |
main biasa |
gcc empty.c |
15.816 byte |
| Menghapus simbol |
-s |
14.352 byte |
| Freestanding |
-nostartfiles |
13.632 byte |
| Menghapus libc / static link / no PIE |
-nostdlib -static -no-pie |
8.704 byte |
Menghapus section .comment |
-fno-ident |
8.616 byte |
| Menghapus informasi unwind |
-fno-asynchronous-unwind-tables -fno-exceptions |
4.400 byte |
| Menghapus GNU property note |
-Wa,-mx86-used-note=no |
4.320 byte |
| Mengurangi alignment |
-Wl,--nmagic / -Wl,-n |
400 byte |
- Perintah kompilasi akhir dan kodenya adalah sebagai berikut
// 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();
}
- Ini adalah latihan pertama penulis menggunakan
objdump dan ld, dan -fno-asynchronous-unwind-tables -fno-exceptions memberi tahu GCC bahwa penanganan stack unwinding saat error tidak diperlukan
ld juga memiliki flag --no-eh-frame-hdr
- Di reddit ada contoh yang berhasil menurunkannya sampai 124 byte
1 komentar
Komentar Lobste.rs
Kalau pada akhirnya cuma akan pakai assembly, saya tidak paham kenapa harus memakai kompiler C
Ini cuma eksperimen iseng untuk seru-seruan :)
Assembly adalah titik awal yang sangat bagus. Dari situ saya punya binary hello world hasil kompilasi berukuran 231 byte:
https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp
Saya memulai dari tutorial serupa beberapa tahun lalu, lalu setelahnya menumpuk teknik-teknik di sekitarnya secara bertahap sambil memisahkan kode dengan lebih baik dan menjaga overhead untuk kasus sederhana tetap serendah mungkin. Menjaga ukuran 231 byte itu penting, sampai-sampai saya menambahkan pengujian CI untuk menjaminnya
Edit: baru sadar saya masih menyisakan satu include yang tidak perlu. Harus diperbaiki
Setuju. Meski begitu, ada cukup banyak trik khusus C, dan tanpa sedikit assembly rasanya gambaran besarnya tidak akan lengkap
Tautan terkait: https://www.muppetlabs.com/~breadbox/software/tiny/
Di sana memang ada binary 45 byte. Dalam bentuk paling ekstrem, sepertinya bisa juga dienkode ke assembly hanya dengan deretan
db, lalu dibuat agargccmerakitnya kembali menjadi file “mentah” 45 byteItu kebetulan akan menjadi ELF, tetapi
gcctidak perlu tahu soal itu. Dengan begitu, mungkin saja aturan pada tulisan aslinya tetap terpenuhiNamun, menurut sebagian besar definisi yang masuk akal, itu sudah sulit disebut sebagai binary C lagi
Jawabannya tampaknya bergantung pada kompiler. Hanya saja, saya tidak yakin apakah masuk akal menerima ketergantungan pada kode non-C hanya karena beberapa kompiler C mau menerimanya 😉
Di antara program C++ yang memanggil
exit(3)dan pemanggilan assemblerSYS_exit, ada tahap perantara. Seperti terlihat dari nomor seksi manualnya,exit(3)adalah fungsi pustaka, jadi ia menarik banyaklibc, termasuk mekanismeatexit(3)Cara standar untuk memanggil system call exit mentah adalah
_exit(2), dan jika itu ditaruh di_start()lalu di-link statis, hasilnya seharusnya bisa cukup kecil. Jika ditulis dalam C, bukan C++, pemanggilan kompiler dan ukuran source code juga bisa lebih kecilSaya mencoba persis seperti itu
#include <stdlib.h>void _start(void){_Exit(0); /* C99 function to call SYS_exit() */}Saya mengompilasinya dengan
gcc -Os -nostdlib -static -o x x.c -lc, dan ukuran executable setelah di-strip adalah 8912 byte, tetapi kode yang benar-benar dihasilkan hanya 96 byte. Itu karena ikut disertakannya fungsisyscall()generik untuk_Exit()