본문으로 건너뛰기

© 2026 Molayo

GeekNews헤드라인2026. 06. 07. 09:17

가장 작은 C++ 바이너리

요약

GCC 컴파일러만을 사용하여 후처리 없이 C++ 바이너리 크기를 최소화하는 실험 과정을 다룹니다. 표준 라이브러리 제거, 시스템 콜 직접 호출, 불필요한 섹션 제거를 통해 15KB 이상의 기본 바이너리를 400바이트까지 줄이는 최적화 기법을 소개합니다.

핵심 포인트

  • -s 플래그로 디버그 정보 제거
  • -nostdlib 및 -static을 통한 동적 링크 구조 제거
  • 직접적인 SYS_exit 시스템 콜 호출로 시작 코드 우회
  • 컴파일러 플래그를 이용한 섹션 오버헤드 및 패딩 최소화
  • GCC만으로 생성한
    ./a.out

바이너리의 크기를 줄이는 실험은 실행 성공, 종료 코드 0

, 후처리 금지라는 조건에서 시작함

  • 기본
    int main(){ return 0; }

은 15,816바이트였고, -s

디버그 정보를 제거해 14,352바이트로 감소함
-nostartfiles

main

이전 시작 코드를 건너뛰고, -nostdlib -static -no-pie

와 직접 SYS_exit

시스템콜을 사용해 동적 링크 기반 구조를 제거함
.comment

, .eh_frame

, .note.gnu.property

를 각각 -fno-ident

, -fno-exceptions -fno-asynchronous-unwind-tables

, -Wa,-mx86-used-note=no

로 제거해 섹션 오버헤드를 줄임
-Wl,--nmagic

로 0x1000 정렬 패딩을 줄인 최종 바이너리는 400바이트이며, objcopy

같은 후처리는 범위 밖임

목표와 기본 조건

  • 목표는 가능한 가장 작은 크기의
    ./a.out

바이너리 생성임

  • 프로그램 조건은 세 가지임
    ./a.out

실행 성공 필요
$?

가 결정적으로 0

이어야 함

  • 바이너리는 GCC만으로 생성해야 하며,
    objcopy

, 헥스 에디터, 수동 패치 같은 후처리 금지

  • 시작점은 가장 단순한 프로그램임
// compiled with gcc empty.c
int main() {
return 0;
}
  • 이 기본 프로그램의 파일 크기는
    stat

기준 15,816바이트이며, 아무것도 하지 않는 바이너리를 담는 데 Apollo guidance computer의 RAM 네 개 분량이 필요하다는 비교 사용
file a.out

출력은 ELF 64-bit LSB pie executable

, dynamically linked

, 인터프리터 경로, not stripped

상태를 표시함
not stripped

상태를 줄이기 위해 GCC의 -s

플래그를 사용하면 디버그 정보를 유지하지 않고 컴파일하며, 크기는 14,352바이트로 감소함

시작 코드 우회와 동적 링크 제거

// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
  • 이 변경 뒤 크기는 13,632바이트이며, 감소 폭은 크지 않음
    objdump -x a.out

출력은 동적 섹션과 함께 NEEDED libc.so.6

, 인터프리터 경로, 동적 심볼 테이블, 재배치 메타데이터, PLT/GOT 구조, 공유 라이브러리 참조를 보여줌

  • 프로그램의 목표가 즉시 종료뿐이므로 세 가지 플래그로 큰 구성요소를 제거함
    -nostdlib

: 표준 라이브러리를 링크하지 않음
-static

: 동적 링크 구조 회피
-no-pie

: 위치 독립 실행 파일 대신 고정 주소 실행 파일 생성

// 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();
}
  • 직접
    SYS_exit

시스템콜을 호출하는 방식으로 변경한 뒤 크기는 8,704바이트임

남은 섹션 제거

objdump -D a.out

출력에는 .note.gnu.property

, .text

, .eh_frame

, .comment

같은 섹션이 남아 있음
.comment

섹션은 바이너리를 만든 컴파일러 정보를 저장하며, 해당 경우 GCC: (GNU) 15.2.0

문자열을 포함함
objdump

는 이 데이터를 어셈블리로 해석해 이상한 명령처럼 표시함
-fno-ident

를 추가하면 .comment

섹션이 제거되고 크기는 8,616바이트로 감소함

.eh_frame

섹션은 스택 언와인딩에 쓰이며, 아무것도 하지 않는 프로그램에는 오류 처리용으로 필요하지 않음
-fno-exceptions -fno-asynchronous-unwind-tables

를 사용해 크기가 4KB대로 감소함

  • 마지막으로 제거할 대상은
    .note.gnu.property

섹션임
readelf -n a.out

x86 feature used: x86

, x86 ISA used: x86-64-baseline

속성을 표시함

  • GNU는 다른 도구가 읽을 수 있도록 이 섹션에 노트를 남기며, 이 경우 어셈블러가 노트를 추가함
    -Wa,-mx86-used-note=no

를 추가하면 크기는 4,320바이트가 됨

  • 이 시점의
    objdump -D a.out

.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

정렬 패딩과 400바이트 구조

  • 4,320바이트 상태의
    readelf -a a.out

출력은 ELF 헤더, 프로그램 헤더 3개, 섹션 헤더 3개, .text

, .shstrtab

구조를 보여줌

  • 프로그램 헤더는 OS 로더가 프로그램 시작 시 파일을 메모리 세그먼트로 매핑하는 방법을 알려주는 테이블임
  • 해당 출력의
    LOAD

232바이트는 64바이트 ELF 헤더와 56바이트 프로그램 헤더 3개에 해당함
LOAD

항목의 정렬 요구사항이 0x1000

이어서 링커가 .text

를 패딩 뒤에 배치함
-Wl,--nmagic

로 링커에 이 가정을 하지 않도록 전달하면 ELF 메타데이터와 .text

섹션을 함께 매핑할 수 있어 LOAD

가 하나만 남고 크기는 400바이트로 감소함

  • 400바이트 바이너리 구성은 다음과 같음

| 구성 |
크기 |
| ELF header |
64 B |
Program header: PT_LOAD |
56 B |
Program header: PT_GNU_STACK |
56 B |
.text section contents |
11 B |
.shstrtab section contents, "\0.shstrtab\0.text\0" |
17 B |
| section header용 padding |
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만으로 제거할 수 없음

  • 첫 번째 섹션 헤더 엔트리는 System V ABI ELF specification이 값 0인 정의되지 않은 섹션 인덱스
    SHN_UNDEF

용으로 예약하도록 요구함

  • 실제로 이 엔트리는
    SHT_NULL

타입이어서 도구에서는 NULL

섹션으로 표시함
objcopy

같은 도구는 일부 항목을 더 잘라낼 수 있지만, 해당 방식은 범위 밖임

단계별 크기와 최종 코드

| 단계 |
플래그 / 변경 |
크기 |
일반 main |
gcc empty.c |
15,816바이트 |
| 심볼 제거 |
-s |
14,352바이트 |
| Freestanding |
-nostartfiles |
13,632바이트 |
| libc 제거 / 정적 링크 / no PIE |
-nostdlib -static -no-pie |
8,704바이트 |
.comment 섹션 제거 |
-fno-ident |
8,616바이트 |
| 언와인드 정보 제거 |
-fno-asynchronous-unwind-tables -fno-exceptions |
4,400바이트 |
| GNU property note 제거 |
-Wa,-mx86-used-note=no |
4,320바이트 |
| 정렬 축소 |
-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에 전달함
ld

에는 --no-eh-frame-hdr

플래그도 있음

  • reddit에서 124바이트까지 줄인 사례가 있음

AI 자동 생성 콘텐츠

본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0