- มีการทดลองลดขนาดไบนารี
./a.out ที่สร้างด้วย GCC เพียงอย่างเดียว โดยเริ่มจากเงื่อนไขว่าต้องรันสำเร็จ, จบด้วยรหัสสถานะ 0 และ ห้ามทำ post-processing
int main(){ return 0; } แบบพื้นฐานมีขนาด 15,816 ไบต์ และลดลงเหลือ 14,352 ไบต์ด้วย -s เพื่อเอา ข้อมูลดีบัก ออก
- ใช้
-nostartfiles เพื่อข้ามโค้ดเริ่มต้นก่อน main และลบโครงสร้างที่อิง dynamic linking ด้วย -nostdlib -static -no-pie พร้อมเรียก system call SYS_exit โดยตรง
- ลบ
.comment, .eh_frame, .note.gnu.property ด้วย -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables, -Wa,-mx86-used-note=no ตามลำดับ เพื่อลด section overhead
- ไบนารีสุดท้ายที่ลด padding จากการจัดแนว 0x1000 ด้วย
-Wl,--nmagic มีขนาด 400 ไบต์ และ post-processing แบบ objcopy ไม่อยู่ในขอบเขต
เป้าหมายและเงื่อนไขพื้นฐาน
- เป้าหมายคือสร้างไบนารี
./a.out ที่มีขนาดเล็กที่สุดเท่าที่เป็นไปได้
- เงื่อนไขของโปรแกรมมี 3 ข้อ
- ต้องรัน
./a.out ได้สำเร็จ
- ค่า
$? ต้องเป็น 0 อย่างแน่นอน
- ต้องสร้างไบนารีด้วย GCC เท่านั้น และห้าม post-processing เช่น
objcopy, hex editor หรือการแพตช์ด้วยมือ
- จุดเริ่มต้นคือโปรแกรมที่ง่ายที่สุด
// compiled with gcc empty.c
int main() {
return 0;
}
- ขนาดไฟล์ของโปรแกรมพื้นฐานนี้ตาม
stat คือ 15,816 ไบต์ และมีการเปรียบเทียบว่าแค่เก็บไบนารีที่ไม่ทำอะไรเลยก็ต้องใช้ RAM เทียบเท่า 4 ชุดของ Apollo guidance computer
- ผลลัพธ์ของ
file a.out แสดงสถานะ ELF 64-bit LSB pie executable, dynamically linked, พาธของ interpreter และสถานะ not stripped
- เพื่อลดสถานะ
not stripped สามารถใช้แฟลก -s ของ GCC เพื่อคอมไพล์โดยไม่เก็บข้อมูลดีบัก ทำให้ขนาดลดลงเหลือ 14,352 ไบต์
ข้ามโค้ดเริ่มต้นและลบ dynamic linking
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
- หลังการเปลี่ยนแปลงนี้ ขนาดเหลือ 13,632 ไบต์ ซึ่งไม่ได้ลดลงมากนัก
- ผลลัพธ์ของ
objdump -x a.out แสดง dynamic section พร้อม NEEDED libc.so.6, พาธ interpreter, dynamic symbol table, relocation metadata, โครงสร้าง PLT/GOT และการอ้างอิง shared library
- เนื่องจากเป้าหมายของโปรแกรมคือจบทันที จึงตัดองค์ประกอบขนาดใหญ่ออกด้วย 3 แฟลก
-nostdlib: ไม่ลิงก์ standard library
-static: เลี่ยงโครงสร้าง dynamic linking
-no-pie: สร้างไฟล์รันได้แบบ fixed address แทน 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();
}
- หลังเปลี่ยนมาเรียก system call
SYS_exit โดยตรง ขนาดเหลือ 8,704 ไบต์
ลบ section ที่เหลือ
- ผลลัพธ์
objdump -D a.out ยังมี section อย่าง .note.gnu.property, .text, .eh_frame, .comment เหลืออยู่
- section
.comment ใช้เก็บข้อมูลคอมไพเลอร์ที่สร้างไบนารี และในกรณีนี้มีสตริง GCC: (GNU) 15.2.0
objdump ตีความข้อมูลนี้เป็นแอสเซมบลี จึงแสดงเหมือนคำสั่งแปลก ๆ
- เมื่อเพิ่ม
-fno-ident จะลบ section .comment ออก และขนาดลดเหลือ 8,616 ไบต์
- section
.eh_frame ใช้สำหรับ stack unwinding และสำหรับโปรแกรมที่ไม่ทำอะไรเลยก็ไม่จำเป็นในแง่การจัดการข้อผิดพลาด
- ใช้
-fno-exceptions -fno-asynchronous-unwind-tables แล้วขนาดลดลงมาอยู่ระดับ 4KB
- เป้าหมายสุดท้ายที่ต้องลบคือ section
.note.gnu.property
readelf -n a.out แสดงคุณสมบัติ x86 feature used: x86, x86 ISA used: x86-64-baseline
- GNU จะทิ้งโน้ตไว้ใน section นี้เพื่อให้เครื่องมืออื่นอ่านได้ และในกรณีนี้ assembler เป็นผู้เพิ่มโน้ตดังกล่าว
- เมื่อเพิ่ม
-Wa,-mx86-used-note=no ขนาดจะเหลือ 4,320 ไบต์
- ณ จุดนี้
objdump -D a.out จะแสดงเฉพาะคำสั่งใน 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 และโครงสร้าง 400 ไบต์
- ผลลัพธ์
readelf -a a.out ในสถานะ 4,320 ไบต์ แสดงโครงสร้าง ELF header, program header 3 รายการ, section header 3 รายการ, .text, .shstrtab
- program header คือตารางที่บอก OS loader ว่าควรแมปไฟล์เป็น memory segment อย่างไรตอนเริ่มโปรแกรม
- ค่า
LOAD 232 ไบต์ในผลลัพธ์นั้นสอดคล้องกับ ELF header 64 ไบต์ และ program header 3 รายการ รายการละ 56 ไบต์
- ข้อกำหนด alignment ของรายการ
LOAD คือ 0x1000 ทำให้ linker วาง .text ไว้หลัง padding
- ถ้าส่ง
-Wl,--nmagic ให้ linker เพื่อไม่ให้สมมติแบบนั้น ก็สามารถแมป ELF metadata กับ section .text รวมกันได้ ทำให้เหลือ LOAD เพียงรายการเดียว และขนาดลดลงเหลือ 400 ไบต์
- โครงสร้างของไบนารี 400 ไบต์มีดังนี้
| องค์ประกอบ |
ขนาด |
| ELF header |
64 B |
Program header: PT_LOAD |
56 B |
Program header: PT_GNU_STACK |
56 B |
เนื้อหา section .text |
11 B |
เนื้อหา section .shstrtab, "\0.shstrtab\0.text\0" |
17 B |
| padding สำหรับ section header |
4 B |
Section header [0]: NULL |
64 B |
Section header [1]: .text |
64 B |
Section header [2]: .shstrtab |
64 B |
PT_LOAD จำเป็นสำหรับการโหลดคำสั่ง และ PT_GNU_STACK เป็นสิ่งที่ GCC สร้างเสมอ
.shstrtab ไม่สามารถลบออกได้ด้วย GCC เพียงอย่างเดียว
- รายการ section header แรก System V ABI ELF specification กำหนดให้สงวนไว้สำหรับดัชนี section ที่ยังไม่กำหนด
SHN_UNDEF ซึ่งมีค่าเป็น 0
- ในทางปฏิบัติ รายการนี้เป็นชนิด
SHT_NULL จึงถูกเครื่องมือแสดงเป็น section NULL
- เครื่องมืออย่าง
objcopy สามารถตัดบางรายการออกได้มากกว่านี้ แต่แนวทางนั้นอยู่นอกขอบเขต
ขนาดแต่ละขั้นและโค้ดสุดท้าย
| ขั้น |
แฟลก / การเปลี่ยนแปลง |
ขนาด |
main ปกติ |
gcc empty.c |
15,816 ไบต์ |
| ลบสัญลักษณ์ |
-s |
14,352 ไบต์ |
| Freestanding |
-nostartfiles |
13,632 ไบต์ |
| ลบ libc / static link / no PIE |
-nostdlib -static -no-pie |
8,704 ไบต์ |
ลบ section .comment |
-fno-ident |
8,616 ไบต์ |
| ลบข้อมูล unwind |
-fno-asynchronous-unwind-tables -fno-exceptions |
4,400 ไบต์ |
| ลบ GNU property note |
-Wa,-mx86-used-note=no |
4,320 ไบต์ |
| ลด alignment |
-Wl,--nmagic / -Wl,-n |
400 ไบต์ |
- คำสั่งคอมไพล์และโค้ดสุดท้ายมีดังนี้
// 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();
}
- นี่เป็นการทดลองใช้
objdump และ ld ครั้งแรก และ -fno-asynchronous-unwind-tables -fno-exceptions คือการบอก GCC ว่าไม่จำเป็นต้องจัดการ stack unwinding เมื่อเกิดข้อผิดพลาด
ld ยังมีแฟลก --no-eh-frame-hdr ด้วย
- มีกรณีใน reddit ที่ลดได้ถึง 124 ไบต์
1 ความคิดเห็น
ความเห็นจาก Lobste.rs
ถ้าท้ายที่สุดจะใช้แค่แอสเซมบลีอยู่แล้ว ก็ไม่เข้าใจว่าทำไมต้องใช้ คอมไพเลอร์ C
ก็เป็นการทดลองทำเล่น ๆ เพื่อความสนุก :)
แอสเซมบลีเป็นจุดเริ่มต้นที่ดีมาก ผมมีไบนารี hello world ขนาด 231 ไบต์ที่คอมไพล์จากตรงนี้:
https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp
ผมเริ่มจากบทสอนที่คล้ายกันเมื่อหลายปีก่อน แล้วหลังจากนั้นก็ค่อย ๆ สั่งสมเทคนิคแวดล้อมเพิ่มขึ้น พร้อมกับแยกโค้ดได้ดีขึ้นโดยยังคงโอเวอร์เฮดของกรณีง่าย ๆ ให้ต่ำที่สุดไว้ ผมถึงกับมี การทดสอบ CI เพื่อรับประกันว่ายังรักษาขนาด 231 ไบต์ไว้ได้
แก้ไข: เพิ่งเห็นว่าผมเผลอทิ้ง include ที่ไม่จำเป็นไว้อันหนึ่ง ต้องแก้แล้ว
เห็นด้วย ถึงอย่างนั้นก็ยังมี ลูกเล่นเฉพาะของ C อยู่พอสมควร และถ้าไม่มีแอสเซมบลีอยู่บ้าง ก็คงทำให้ภาพรวมไม่สมบูรณ์
ลิงก์ที่เกี่ยวข้อง: https://www.muppetlabs.com/~breadbox/software/tiny/
ที่นี่มี ไบนารี 45 ไบต์ อยู่จริง ๆ ถ้าจะสุดทางก็คงเข้ารหัสเป็นแอสเซมบลีด้วยการไล่
dbอย่างเดียว แล้วให้gccแอสเซมบล์กลับมาเป็นไฟล์ “ดิบ” 45 ไบต์ได้มันคงกลายเป็น ELF โดยบังเอิญ แต่
gccไม่จำเป็นต้องรู้เรื่องนั้น แบบนี้อาจจะยังเข้าเงื่อนไขตามกติกาของต้นฉบับแต่ภายใต้คำนิยามที่สมเหตุสมผลส่วนใหญ่ ก็คงยากจะเรียกมันว่าเป็น C binary อีกต่อไป
คำตอบน่าจะขึ้นอยู่กับ คอมไพเลอร์ แต่ผมก็ไม่แน่ใจนักว่าควรยอมรับการพึ่งพาโค้ดที่ไม่ใช่ C เพียงเพราะมีคอมไพเลอร์ C บางตัวรับมันได้ 😉
ระหว่างโปรแกรม C++ ที่เรียก
exit(3)กับการเรียกแอสเซมบลีSYS_exitยังมีขั้นกลางอยู่อีก อย่างที่ดูได้จากหมายเลข section ในคู่มือexit(3)เป็น ฟังก์ชันไลบรารี จึงดึงlibcเข้ามาเยอะ รวมถึงกลไกatexit(3)วิธีมาตรฐานในการเรียก raw exit system call คือ
_exit(2)และถ้าใส่มันไว้ใน_start()แล้วลิงก์แบบ static ก็น่าจะได้ผลลัพธ์ที่เล็กพอสมควร ถ้าเขียนเป็น C แทน C++ ก็ยังลดทั้งคำสั่งคอมไพล์และขนาดซอร์สโค้ดได้ด้วยผมลองทำแบบนั้นตรง ๆ เลย
#include <stdlib.h>void _start(void){_Exit(0); /* C99 function to call SYS_exit() */}คอมไพล์ด้วย
gcc -Os -nostdlib -static -o x x.c -lcแล้วได้ไฟล์ปฏิบัติการหลัง strip ขนาด 8912 ไบต์ แต่โค้ดที่ถูกสร้างจริงมีแค่ 96 ไบต์ เพราะมีการรวมฟังก์ชันsyscall()ทั่วไปสำหรับ_Exit()มาด้วย