# 가장 작은 C++ 바이너리

> Clean Markdown view of GeekNews topic #30240. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=30240](https://news.hada.io/topic?id=30240)
- GeekNews Markdown: [https://news.hada.io/topic/30240.md](https://news.hada.io/topic/30240.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-06-07T09:12:05+09:00
- Updated: 2026-06-07T09:12:05+09:00
- Original source: [blog.weineng.me](https://blog.weineng.me/posts/smallest_c/)
- Points: 1
- Comments: 1

## Topic Body

- 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`, 헥스 에디터, 수동 패치 같은 후처리 금지
- 시작점은 가장 단순한 프로그램임

```c
// compiled with gcc empty.c
int main() {
return 0;
}
```

- 이 기본 프로그램의 파일 크기는 `stat` 기준 15,816바이트이며, 아무것도 하지 않는 바이너리를 담는 데 [Apollo guidance computer](https://en.wikipedia.org/wiki/Apollo_Guidance_Computer)의 RAM 네 개 분량이 필요하다는 비교 사용
- `file a.out` 출력은 `ELF 64-bit LSB pie executable`, `dynamically linked`, 인터프리터 경로, `not stripped` 상태를 표시함
- `not stripped` 상태를 줄이기 위해 GCC의 `-s` 플래그를 사용하면 디버그 정보를 유지하지 않고 컴파일하며, 크기는 14,352바이트로 감소함

### 시작 코드 우회와 동적 링크 제거
- `./a.out` 실행부터 `int main()` 도달 전까지 많은 동작이 있으며, 이 주제는 [Matt Godbolt의 CppCon 1시간 발표](https://www.youtube.com/watch?v=dOfucXtyEsU)로도 다뤄진 내용임
- `-nostartfiles`와 `_start()`를 사용해 `int main()` 이전 과정을 건너뛰는 [freestanding](https://en.cppreference.com/cpp/freestanding) 바이너리 구성으로 변경함

```cpp
// compiled with gcc empty.c -s -nostartfiles
#include &lt;cstdlib&gt;
extern "C" __attribute((noreturn)) void _start() { exit(0); }
```

- 이 변경 뒤 크기는 13,632바이트이며, 감소 폭은 크지 않음
- `objdump -x a.out` 출력은 동적 섹션과 함께 `NEEDED libc.so.6`, 인터프리터 경로, 동적 심볼 테이블, 재배치 메타데이터, PLT/GOT 구조, 공유 라이브러리 참조를 보여줌
- 프로그램의 목표가 즉시 종료뿐이므로 세 가지 플래그로 큰 구성요소를 제거함
  - `-nostdlib`: 표준 라이브러리를 링크하지 않음
  - `-static`: 동적 링크 구조 회피
  - `-no-pie`: 위치 독립 실행 파일 대신 고정 주소 실행 파일 생성

```cpp
// 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` 섹션의 명령만 표시함

```asm
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](https://refspecs.linuxbase.org/elf/gabi4%2B/ch4.sheader.html)이 값 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바이트 |

- 최종 컴파일 명령과 코드는 다음과 같음

```cpp
// 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](https://www.reddit.com/r/cpp/comments/1ty5p9h/comment/oq18bfo/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)에서 124바이트까지 줄인 사례가 있음

## Comments



### Comment 59054

- Author: neo
- Created: 2026-06-07T09:12:06+09:00
- Points: 1

###### [Lobste.rs 의견들](https://lobste.rs/s/eetcxi/smallest_c_binary) 
- 어차피 어셈블리만 쓸 거라면 왜 **C 컴파일러**를 쓰는지 모르겠음
  - 그냥 재미로 해보는 실험임 :)
  - **어셈블리**는 출발점으로 아주 좋음. 여기서 컴파일한 231바이트 hello world 바이너리를 갖고 있음:  
    https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp
    
    몇 년 전 비슷한 튜토리얼에서 시작했고, 이후 코드를 더 잘 분리하면서 단순한 경우의 오버헤드는 최대한 낮게 유지한 채 주변 기술을 점진적으로 쌓아 올렸음. 231바이트를 유지하는 게 중요해서 이를 보장하는 **CI 테스트**까지 둠
    
    수정: 불필요한 include를 하나 남겨놨던 걸 이제 봄. 고쳐야겠음
  - 동의함. 그래도 **C 전용 트릭**들이 꽤 있고, 약간의 어셈블리가 없었다면 전체 그림이 완성되지 않았을 것 같음

- 관련 링크: https://www.muppetlabs.com/~breadbox/software/tiny/
  - 실제로 여기에는 **45바이트 바이너리**가 있음. 극단적으로는 `db` 나열만으로도 어셈블리에 인코딩하고, `gcc`로 다시 45바이트 “원시” 파일로 어셈블하게 만들 수 있을 것 같음  
    우연히 ELF가 되겠지만 `gcc`가 그걸 알 필요는 없음. 이러면 원문의 규칙을 만족할지도 모름
    
    다만 합리적인 정의 대부분에서는 더 이상 **C 바이너리**라고 부르기 어려워짐

- 답은 **컴파일러에 따라** 달라질 것 같음. 다만 일부 C 컴파일러가 받아준다고 해서 C가 아닌 코드에 기대는 걸 인정할 수 있는지는 잘 모르겠음 😉

- `exit(3)`을 호출하는 C++ 프로그램과 `SYS_exit` 어셈블러 호출 사이에는 중간 단계가 있음. 매뉴얼 섹션 번호에서 알 수 있듯이 `exit(3)`은 **라이브러리 함수**라서 `atexit(3)` 장치 등 많은 `libc`를 끌어들임  
  원시 exit 시스템 호출을 부르는 표준 방식은 `_exit(2)`이고, 그걸 `_start()`에 넣고 정적 링크하면 꽤 작은 결과가 나와야 함. 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바이트뿐이었음. `_Exit()`용 일반 `syscall()` 함수가 포함됐기 때문임
