1 คะแนน โดย GN⁺ 5 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • มีการทดลองลดขนาดไบนารี ./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

  • ตั้งแต่เริ่มรัน ./a.out ไปจนถึงก่อนถึง int main() มีหลายอย่างเกิดขึ้น และประเด็นนี้ก็ถูกพูดถึงใน การบรรยาย CppCon 1 ชั่วโมงของ Matt Godbolt
  • เปลี่ยนไปใช้ไบนารีแบบ freestanding โดยใช้ -nostartfiles และ _start() เพื่อข้ามขั้นตอนก่อน int main()
// 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 ความคิดเห็น

 
GN⁺ 5 시간 전
ความเห็นจาก 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() มาด้วย