1 điểm bởi GN⁺ 5 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Thử nghiệm giảm kích thước binary ./a.out được tạo chỉ bằng GCC bắt đầu với các điều kiện: chạy thành công, mã thoát là 0, và không được hậu xử lý
  • Chương trình mặc định int main(){ return 0; } có kích thước 15.816 byte, và giảm xuống 14.352 byte khi dùng -s để loại bỏ thông tin gỡ lỗi
  • Dùng -nostartfiles để bỏ qua mã khởi động trước main, rồi loại bỏ cấu trúc dựa trên liên kết động bằng -nostdlib -static -no-pie và gọi trực tiếp syscall SYS_exit
  • Loại bỏ .comment, .eh_frame, .note.gnu.property lần lượt bằng -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables, -Wa,-mx86-used-note=no để giảm overhead của section
  • Binary cuối cùng với -Wl,--nmagic giúp giảm phần đệm căn chỉnh 0x1000 có kích thước 400 byte, và các bước hậu xử lý như objcopy không nằm trong phạm vi bài viết

Mục tiêu và các điều kiện cơ bản

  • Mục tiêu là tạo ra binary ./a.out nhỏ nhất có thể
  • Chương trình phải thỏa ba điều kiện
    • Cần chạy ./a.out thành công
    • $? phải luôn xác định là 0
    • Binary phải được tạo chỉ bằng GCC, cấm hậu xử lý như objcopy, trình sửa hex, hoặc vá thủ công
  • Điểm xuất phát là chương trình đơn giản nhất
// compiled with gcc empty.c
int main() {
return 0;
}
  • Kích thước tệp của chương trình cơ bản này theo stat là 15.816 byte, và bài viết so sánh rằng để chứa một binary không làm gì cả thì cần lượng RAM tương đương bốn khối của Apollo guidance computer
  • Kết quả file a.out hiển thị ELF 64-bit LSB pie executable, dynamically linked, đường dẫn interpreter, và trạng thái not stripped
  • Để giảm trạng thái not stripped, có thể dùng cờ -s của GCC để biên dịch mà không giữ thông tin gỡ lỗi, đưa kích thước xuống 14.352 byte
Quảng cáo

Bỏ qua mã khởi động và loại bỏ liên kết động

// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
  • Sau thay đổi này, kích thước là 13.632 byte, mức giảm không lớn
  • Kết quả objdump -x a.out cho thấy cùng với dynamic section còn có NEEDED libc.so.6, đường dẫn interpreter, bảng ký hiệu động, metadata relocation, cấu trúc PLT/GOT, và tham chiếu tới shared library
  • Vì mục tiêu của chương trình chỉ là thoát ngay lập tức, bài viết loại bỏ các thành phần lớn bằng ba cờ
    • -nostdlib: không liên kết thư viện chuẩn
    • -static: tránh cấu trúc liên kết động
    • -no-pie: tạo executable địa chỉ cố định thay vì vị trí độc lập
// 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();
}
  • Sau khi chuyển sang cách gọi trực tiếp syscall SYS_exit, kích thước còn 8.704 byte
Quảng cáo

Loại bỏ các section còn lại

  • Kết quả objdump -D a.out cho thấy vẫn còn các section như .note.gnu.property, .text, .eh_frame, .comment
  • Section .comment lưu thông tin về trình biên dịch tạo ra binary, trong trường hợp này có chuỗi GCC: (GNU) 15.2.0
    • objdump diễn giải dữ liệu này như assembly nên hiển thị thành các lệnh kỳ lạ
    • Thêm -fno-ident sẽ loại bỏ section .comment, đưa kích thước xuống 8.616 byte
  • Section .eh_frame được dùng cho stack unwinding và không cần cho chương trình không làm gì ngoài xử lý lỗi
    • Dùng -fno-exceptions -fno-asynchronous-unwind-tables giúp kích thước giảm xuống mức 4 KB
  • Mục tiêu cuối cùng cần loại bỏ là section .note.gnu.property
    • readelf -n a.out hiển thị các thuộc tính x86 feature used: x86, x86 ISA used: x86-64-baseline
    • GNU để lại các note trong section này để công cụ khác có thể đọc được, và trong trường hợp này assembler đã thêm note đó
    • Thêm -Wa,-mx86-used-note=no thì kích thước còn 4.320 byte
  • Ở thời điểm này, objdump -D a.out chỉ còn hiển thị lệnh trong 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
Quảng cáo

Phần đệm căn chỉnh và cấu trúc 400 byte

  • Kết quả readelf -a a.out ở trạng thái 4.320 byte cho thấy ELF header, 3 program header, 3 section header, cùng cấu trúc .text, .shstrtab
  • Program header là bảng cho biết OS loader sẽ ánh xạ tệp vào các segment bộ nhớ như thế nào khi chương trình khởi động
  • Mục LOAD 232 byte trong kết quả này tương ứng với ELF header 64 byte và 3 program header, mỗi cái 56 byte
  • Yêu cầu căn chỉnh của mục LOAD0x1000, nên linker đặt .text sau một vùng padding
  • Truyền -Wl,--nmagic để linker không giả định như vậy sẽ cho phép ánh xạ metadata ELF và section .text cùng nhau, chỉ còn một LOAD và kích thước giảm xuống 400 byte
  • Cấu trúc của binary 400 byte như sau
Thành phần Kích thước
ELF header 64 B
Program header: PT_LOAD 56 B
Program header: PT_GNU_STACK 56 B
Nội dung section .text 11 B
Nội dung section .shstrtab, "\0.shstrtab\0.text\0" 17 B
padding cho section header 4 B
Section header [0]: NULL 64 B
Section header [1]: .text 64 B
Section header [2]: .shstrtab 64 B
  • PT_LOAD là cần thiết để nạp lệnh, còn PT_GNU_STACK luôn được GCC tạo ra
  • Không thể loại bỏ .shstrtab chỉ bằng GCC
  • Mục section header đầu tiên được đặc tả System V ABI ELF yêu cầu dành riêng cho chỉ số section chưa xác định SHN_UNDEF có giá trị bằng 0
  • Thực tế mục này có kiểu SHT_NULL, nên trong công cụ nó hiện thành section NULL
  • Các công cụ như objcopy có thể cắt bỏ thêm một số mục, nhưng cách đó nằm ngoài phạm vi bài viết

Kích thước theo từng bước và mã cuối cùng

Bước Cờ / thay đổi Kích thước
main thông thường gcc empty.c 15.816 byte
Loại bỏ symbol -s 14.352 byte
Freestanding -nostartfiles 13.632 byte
Loại bỏ libc / liên kết tĩnh / no PIE -nostdlib -static -no-pie 8.704 byte
Loại bỏ section .comment -fno-ident 8.616 byte
Loại bỏ thông tin unwind -fno-asynchronous-unwind-tables -fno-exceptions 4.400 byte
Loại bỏ GNU property note -Wa,-mx86-used-note=no 4.320 byte
Giảm căn chỉnh -Wl,--nmagic / -Wl,-n 400 byte
  • Lệnh biên dịch và mã cuối cùng như sau
// 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();
}
  • Đây là lần thực hành đầu tiên với objdumpld, và -fno-asynchronous-unwind-tables -fno-exceptions có nghĩa là báo cho GCC rằng không cần xử lý stack unwinding khi có lỗi
  • ld cũng có cờ --no-eh-frame-hdr
  • Trên reddit có trường hợp giảm xuống tới 124 byte

1 bình luận

 
Ý kiến trên Lobste.rs
  • Nếu cuối cùng đằng nào cũng chỉ dùng assembly, thì tôi không hiểu tại sao lại dùng trình biên dịch C

    • Chỉ là một thử nghiệm làm cho vui thôi :)

    • Assembly là một điểm khởi đầu rất tốt. Từ đó tôi có một binary hello world 231 byte được biên dịch ở đây:
      https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp

      Tôi bắt đầu từ một hướng dẫn tương tự vài năm trước, rồi sau đó tách mã tốt hơn và dần xây thêm các kỹ thuật xung quanh trong khi vẫn giữ overhead ở mức thấp nhất có thể cho các trường hợp đơn giản. Việc giữ ở 231 byte quan trọng đến mức tôi còn đặt cả kiểm thử CI để đảm bảo điều đó

      Sửa: giờ tôi mới nhận ra mình để sót một include không cần thiết. Phải sửa thôi

    • Đồng ý. Dù vậy cũng có khá nhiều mẹo chỉ dành cho C, và nếu không có một chút assembly thì có lẽ bức tranh tổng thể sẽ không hoàn chỉnh

  • Liên kết liên quan: https://www.muppetlabs.com/~breadbox/software/tiny/

    • Thực ra ở đây có một binary 45 byte. Cực đoan hơn thì có lẽ còn có thể mã hóa sang assembly chỉ bằng cách liệt kê db, rồi dùng gcc để assemble lại thành một file “thô” 45 byte
      Tình cờ nó sẽ là ELF, nhưng gcc không nhất thiết phải biết điều đó. Làm vậy có thể vẫn thỏa mãn quy tắc của bài gốc

      Tuy nhiên, theo đa số định nghĩa hợp lý thì lúc đó sẽ khó mà gọi nó là binary C nữa

  • Có lẽ câu trả lời còn phụ thuộc vào trình biên dịch. Nhưng tôi cũng không chắc có thể chấp nhận việc dựa vào mã không phải C chỉ vì một số trình biên dịch C vẫn chấp nhận nó 😉

  • Giữa một chương trình C++ gọi exit(3) và một lệnh gọi assembler SYS_exit vẫn còn một bước trung gian. Như số mục trong manual cho thấy, exit(3) là một hàm thư viện, nên nó kéo theo rất nhiều libc như cơ chế atexit(3)
    Cách tiêu chuẩn để gọi raw exit system call là _exit(2), và nếu đặt nó trong _start() rồi liên kết tĩnh thì kết quả sẽ khá nhỏ. Nếu viết bằng C thay vì C++ thì cả lệnh gọi trình biên dịch lẫn kích thước mã nguồn cũng có thể giảm bớt

    • Tôi đã thử đúng như vậy

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

      Tôi biên dịch bằng gcc -Os -nostdlib -static -o x x.c -lc, và file thực thi sau khi strip có kích thước 8912 byte, nhưng phần mã thực sự được tạo ra chỉ có 96 byte. Lý do là nó bao gồm hàm syscall() chung cho _Exit()