0. 시작하기 전에 — "gcc hello.c -o hello" 한 줄의 신비
#include <stdio.h>
int main(void) {
printf("Hello\n");
return 0;
}
이 11 줄짜리 소스가 `gcc hello.c -o hello` 한 번으로 실행 파일이 된다. 너무 자연스러워서 "컴파일러가 알아서 해주는 일" 로 넘어가게 된다. 그런데 몇 가지 이상한 질문을 던져보자:
- `printf` 의 실제 코드는 어디 있나? 내 프로그램 안? 아니면 실행할 때 가져오나?
- 왜 `hello.c` 를 먼저 `hello.o` 로 만들고 다시 `hello` 로 만드는가?
- C++ 에서 오버로딩된 `void f(int)` 와 `void f(double)` 이 왜 **이름 충돌이 안** 나는가?
- `libc.so.6` 를 삭제하면 리눅스 전체가 죽는 이유가 뭔가?
- `strip` 으로 심볼을 지우면 왜 바이너리가 작아지지만 디버거가 먹통이 되는가?
이 글은 **C / C++ 프로그램이 소스에서 실행되기까지** 의 전 과정을 내부 구조와 함께 파헤친다. 대상은 Linux + ELF + GCC/Clang 이지만 원리는 Windows (PE/COFF) 와 macOS (Mach-O) 에도 비슷하게 적용된다.
1. 빌드의 4단계 — 전처리, 컴파일, 어셈블, 링크
1.1 한 줄의 gcc 명령 뒤에 숨은 단계들
hello.c ──[cpp: 전처리]──> hello.i (확장된 C 소스)
hello.i ──[cc1: 컴파일]──> hello.s (어셈블리)
hello.s ──[as: 어셈블]───> hello.o (오브젝트 파일)
hello.o ──[ld: 링크]────> hello (실행 파일)
각 단계는 독립 실행 가능하다. 실제로 중간 산출물을 보려면:
gcc -E hello.c -o hello.i # 전처리까지
gcc -S hello.i -o hello.s # 컴파일까지
gcc -c hello.s -o hello.o # 어셈블까지
gcc hello.o -o hello # 링크까지
1.2 전처리기 — 단순한 텍스트 치환기
`cpp` (C preprocessor) 는 놀랍게도 **C 를 전혀 모른다**. 그저 텍스트를 치환한다:
- `#include <stdio.h>` → stdio.h 파일 내용을 그 자리에 삽입.
- `#define PI 3.14` → 모든 `PI` 를 `3.14` 로 치환.
- `#ifdef DEBUG` → 조건부로 텍스트 포함/제외.
이게 C 매크로의 위험함의 근원이다. 예:
#define SQUARE(x) x*x
SQUARE(1+2) // → 1+2*1+2 = 5 (의도는 9)
C++ 에서 `constexpr` 와 템플릿이 매크로를 대체하는 이유가 여기 있다. 하지만 전처리기는 헤더 가드 (`#ifndef HEADER_H`), 플랫폼 분기 (`#ifdef _WIN32`), 조건부 로깅 같은 용도로 여전히 필수다.
`gcc -E` 출력을 보면 원본 11줄 hello.c 가 **수만 줄** 로 확장된다. stdio.h 가 다시 수십 개의 시스템 헤더를 포함하기 때문이다.
1.3 컴파일러 — 진짜 "이해" 가 시작되는 곳
컴파일러 프론트엔드 (Clang, GCC cc1) 의 작업:
hello.i
↓ Lexer (토큰 분해)
↓ Parser (AST 생성)
↓ Semantic Analyzer (타입 체크, 이름 해석)
↓ IR 생성 (GIMPLE / LLVM IR)
↓ 최적화 패스들 (수십 개)
↓ Backend (타겟 CPU 어셈블리 생성)
hello.s
**LLVM IR** 예시 (hello.c 컴파일):
@.str = private constant [7 x i8] c"Hello\0A\00"
define i32 @main() {
%1 = call i32 @printf(i8* getelementptr ([7 x i8], [7 x i8]* @.str, i32 0, i32 0))
ret i32 0
}
declare i32 @printf(i8*, ...)
IR 레벨에서 인상적인 부분:
- `@printf` 는 **선언만** 있고 정의가 없다 → 이후 링크 타임에 해결될 심볼.
- `@.str` 은 상수 문자열을 위한 전역 변수. `hello.o` 의 `.rodata` 섹션에 들어갈 것.
1.4 어셈블러 — 텍스트를 바이너리로
`hello.s` (x86-64 예시):
.section .rodata
.LC0:
.string "Hello"
.text
.globl main
main:
pushq %rbp
movq %rsp, %rbp
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
popq %rbp
ret
`as` (GNU assembler) 는 이걸 **기계어 바이트** 로 변환해서 ELF 오브젝트 파일 (`hello.o`) 에 담는다. 흥미로운 건:
- `call puts@PLT` 는 지금은 "이 위치에 puts 의 실제 주소를 채워라" 라는 **재배치 엔트리** 로만 남는다. 실제 주소는 모름.
- `leaq .LC0(%rip)` 의 `.LC0` 는 이 파일 안의 심볼 → 내부에서 offset 계산 가능.
1.5 링커 — 조각들을 실행 가능한 전체로
링커의 핵심 책임:
1. **여러 `.o` 파일 병합**: 같은 섹션 (`.text`, `.data`) 들을 이어붙인다.
2. **심볼 해결**: `printf` 같은 외부 심볼을 실제 정의와 연결.
3. **재배치**: 최종 주소가 정해지면 명령어의 주소 필드를 채워넣는다.
4. **실행 파일 헤더 생성**: 커널이 읽을 수 있는 ELF 헤더를 만든다.
이 4단계가 왜 중요한지는 다음 장부터 자세히.
2. ELF — Linux 바이너리의 공용어
2.1 ELF의 두 가지 관점
ELF (Executable and Linkable Format) 는 1999년 Linux 가 표준으로 채택한 이후 Unix 계열의 사실상 표준이 됐다. 같은 파일을 **두 가지 방식** 으로 볼 수 있게 설계됐다:
┌──────────────────┬──────────────────┐
│ ELF Header │ ELF Header │
├──────────────────┼──────────────────┤
│ Program Header │ │
│ (실행용 뷰) │ │
├──────────────────┤ Sections: │
│ │ .text │
│ Segments: │ .rodata │
│ LOAD (r-x) │ .data │
│ LOAD (rw-) │ .bss │
│ INTERP │ .symtab │
│ DYNAMIC │ .strtab │
│ │ .rela.text │
├──────────────────┼──────────────────┤
│ Section Header │ Section Header │
│ (링커용 뷰) │ (링커용 뷰) │
└──────────────────┴──────────────────┘
실행시 뷰 링크시 뷰
- **섹션 (Section)**: 링커가 다룬다. 세밀하게 `.text`, `.data`, `.bss`, `.symtab` 등으로 분할.
- **세그먼트 (Segment, Program Header)**: 커널이 다룬다. 섹션을 권한 (r-x, rw-) 별로 묶어서 메모리에 매핑.
2.2 주요 섹션
| 섹션 | 내용 | 메모리 권한 |
| --- | --- | --- |
| `.text` | 실행 코드 | r-x |
| `.rodata` | 읽기 전용 상수 (문자열 리터럴 등) | r-- |
| `.data` | 초기화된 전역/static 변수 | rw- |
| `.bss` | 0 으로 초기화된 전역/static 변수 | rw- |
| `.symtab` | 심볼 테이블 (함수/변수 이름 → 주소) | (파일에만) |
| `.strtab` | 심볼 이름 문자열 풀 | (파일에만) |
| `.rela.text` | `.text` 에 대한 재배치 엔트리 | (파일에만) |
| `.dynsym` | 동적 심볼 (링커가 쓰는) | r-- |
| `.plt`, `.got` | 동적 링킹용 점프 테이블 | r-x / rw- |
2.3 `.bss` 의 마법 — 0으로 초기화된 변수는 왜 파일을 차지하지 않는가
int zeros[1000000]; // 4MB
int ones[1000000] = {1, ...}; // 4MB
- `ones` 는 `.data` 에 들어감 → 실행 파일이 4MB 커짐.
- `zeros` 는 `.bss` 에 들어감 → 실행 파일 크기 **거의 변화 없음**.
이유: `.bss` 는 "크기 정보만" 파일에 기록한다. 커널이 프로그램을 로드할 때 **0 페이지** (전역 zero page) 를 매핑한다. 디스크에 0 을 4MB 저장하는 건 낭비이기 때문.
처음 쓰기가 일어나면 copy-on-write 로 실제 페이지가 할당된다. 그래서 "0 으로 초기화" 가 "초기화 안 함" 보다 성능 이득이 있다 (CPU 가 이미 zero page 를 잘 캐싱).
2.4 실제로 보기 — `readelf` 의 세계
$ readelf -h hello
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 ...
Class: ELF64
Data: 2's complement, little endian
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x401050
$ readelf -S hello # 섹션 헤더
$ readelf -l hello # 프로그램 헤더 (세그먼트)
$ readelf -s hello # 심볼 테이블
$ objdump -d hello # 디스어셈블
이 명령들을 hello 같은 작은 프로그램에 실행해 보면 ELF 의 구조가 눈에 들어온다. 시스템 프로그래밍을 배우는 최고의 방법.
3. 심볼 — 이름과 주소의 연결 고리
3.1 심볼이란 무엇인가
심볼은 "이름 → 주소" 매핑이다. 컴파일 타임에는 함수와 전역 변수의 실제 주소가 정해지지 않았지만, 이름은 있다. 링커가 모든 `.o` 파일을 병합하면서 주소를 부여한다.
`hello.o` 의 심볼 테이블:
$ readelf -s hello.o
Num: Value Size Type Bind Vis Name
5: 0000000000000000 22 FUNC GLOBAL DEFAULT 1 main
6: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
- `main`: 이 파일 안에 **정의** (Bind=GLOBAL, section 1 = `.text`, Size=22).
- `printf`: **정의되지 않음** (UND = undefined). 링크 타임에 외부에서 찾아야 함.
3.2 Binding — Local, Global, Weak
| Binding | 의미 |
| --- | --- |
| LOCAL | 파일 내부용 (`static` 함수/변수). 다른 파일에서 안 보임. |
| GLOBAL | 외부에 노출. 여러 파일에서 이 심볼로 링크 가능. |
| WEAK | 기본값이지만 다른 STRONG 정의가 있으면 덮어씀. |
**WEAK 의 트릭**: 라이브러리에서 기본 구현을 WEAK 로 제공하면 사용자가 같은 이름의 STRONG 정의로 쉽게 오버라이드할 수 있다. pthread 의 `pthread_mutex_lock` 같은 심볼이 그렇다.
// 기본 구현 (libc 안)
__attribute__((weak)) void my_log(const char* msg) {
fprintf(stderr, "LOG: %s\n", msg);
}
// 사용자가 오버라이드
void my_log(const char* msg) {
write_to_elasticsearch(msg);
}
3.3 중복 심볼 — "multiple definition of `foo`" 에러
같은 이름의 GLOBAL 심볼이 여러 `.o` 에 있으면 링커가 에러를 낸다:
/usr/bin/ld: b.o: multiple definition of `foo'; a.o: first defined here
가장 흔한 원인:
- 헤더에 함수 정의 (선언이 아닌) 를 써서 여러 `.c` 가 포함했을 때. → `inline` 또는 `static` 을 쓰거나, 선언만 헤더에 두고 정의는 `.c` 에.
- 헤더에 전역 변수 정의 (`int counter = 0;`) 를 썼을 때. → `extern int counter;` 로 선언만 하고 `.c` 중 한 곳에 정의.
C++ 의 `inline` 함수는 특별 취급된다 — 링커가 중복을 허용하고 하나만 남긴다 (COMDAT 섹션).
4. 재배치 — 주소가 늦게 정해지는 세상
4.1 재배치가 필요한 이유
`hello.o` 의 `main` 함수에서 `call printf@PLT` 를 봤다. 이 `call` 명령은 **상대 주소** 를 쓴다 (x86-64 의 `call rel32`). 즉 "현재 PC 에서 얼마나 멀리 점프할지" 인데, 문제는:
- `hello.o` 컴파일 시점엔 `printf` 의 최종 주소를 모른다.
- 심지어 `main` 자체의 최종 주소도 모른다 (다른 `.o` 가 앞에 올 수 있음).
어셈블러는 명령어의 주소 필드를 0 으로 채우고, "여기에 printf 주소 - 현재 주소를 써넣으라" 는 **재배치 엔트리** 를 `.rela.text` 에 기록한다:
$ readelf -r hello.o
Relocation section '.rela.text':
Offset Info Type Sym.Value Sym. Name + Addend
0000000c ... R_X86_64_PLT32 0 .rodata - 4
00000015 ... R_X86_64_PLT32 0 puts - 4
4.2 재배치 타입의 다양성
x86-64 에만도 수십 가지 재배치 타입이 있다:
| 타입 | 의미 |
| --- | --- |
| R_X86_64_64 | 절대 64비트 주소 |
| R_X86_64_PC32 | PC 상대 32비트 (call, jmp) |
| R_X86_64_PLT32 | PLT 경유 call |
| R_X86_64_GOTPCREL | GOT 엔트리 상대 참조 |
| R_X86_64_TPOFF32 | TLS (Thread-local storage) |
각각은 "링커가 어떻게 계산해야 하는지" 를 알려준다. 이게 ABI 의 핵심 부분.
4.3 `-fPIC` — Position Independent Code
공유 라이브러리 (`.so`) 는 프로세스마다 다른 주소에 로드될 수 있다. 따라서 절대 주소를 쓸 수 없다.
`-fPIC` (Position Independent Code) 로 컴파일하면:
- 전역 변수 접근 → **GOT** (Global Offset Table) 경유.
- 함수 호출 → **PLT** (Procedure Linkage Table) 경유.
이 덕분에 libc 는 한 번 메모리에 로드되고 모든 프로세스가 공유할 수 있다. 메모리 절약의 주역.
5. 정적 링킹 vs 동적 링킹
5.1 정적 링킹 — 모든 걸 내 바이너리에
gcc hello.c -o hello -static
- libc 의 `printf` 코드가 실행 파일에 복사된다.
- 실행 파일 크기: 10KB → 800KB.
- 장점: libc 가 없는 시스템에서도 실행. 버전 호환성 문제 없음.
- 단점: 크다. 여러 프로세스가 각자 libc 사본을 메모리에 가짐.
정적 라이브러리의 구조
`libfoo.a` 는 사실 그냥 **아카이브** 다:
$ ar t /usr/lib/x86_64-linux-gnu/libc.a | head
init-first.o
libc-start.o
sysdep.o
...
`ar` (archiver) 로 `.o` 파일들을 묶은 것. 링커는 내 프로그램이 **필요로 하는 심볼** 을 정의한 `.o` 만 골라 포함한다 (lazy linking).
"필요" 는 전역 변수의 **파일 단위** 로 판단된다 → 쓰지 않는 함수가 같은 `.o` 에 있으면 끌려들어옴 → 이게 `-ffunction-sections -fdata-sections -Wl,--gc-sections` 가 필요한 이유.
5.2 동적 링킹 — 필요할 때 불러오기
gcc hello.c -o hello # 기본값: 동적 링킹
- 실행 파일에 "libc.so.6 을 로드하고 거기서 printf 를 가져와" 라는 **참조** 만 기록.
- 실행 시 동적 링커 (`/lib64/ld-linux-x86-64.so.2`) 가 libc 를 메모리에 매핑하고 심볼을 해결.
INTERP 섹션 — 동적 링커 이름
$ readelf -l hello | grep -A1 INTERP
INTERP ...
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
실행 파일은 자기 자신을 실행할 "인터프리터" 를 지정한다. 커널은 실제로 `ld.so` 를 먼저 실행하고, `ld.so` 가 다시 내 프로그램을 로드한다.
5.3 ldd 의 비밀
$ ldd hello
linux-vdso.so.1 (0x00007ffd...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
`ldd` 는 "이 프로그램이 어떤 공유 라이브러리를 요구하는지" 보여준다. 하지만 내부적으로는 **프로그램을 실제로 실행하되 main 호출 전에 중단** 해서 동적 링커가 해결한 맵을 읽는다. 그래서 악성 바이너리에 `ldd` 를 쓰면 위험 — `objdump -p` 나 `readelf -d` 를 쓰는 게 안전.
5.4 GOT/PLT 의 마법
동적 호출의 흐름:
main 이 printf 호출
↓
call printf@plt (PLT 엔트리로 점프)
↓
PLT[printf]:
jmp *GOT[printf] ; GOT 에서 주소를 읽어 점프
; 최초에는 GOT 가 "리졸버 호출" 로 설정됨
↓ (최초 호출 시)
리졸버가 ld.so 호출 → printf 실제 주소 찾음 → GOT[printf] 업데이트
↓
이후 호출은 바로 GOT[printf] 로 점프 (lazy binding)
**Lazy binding**: 처음 호출될 때만 리졸브. 쓰지 않는 함수는 리졸브 안 함. 시작 속도 ↑.
**LD_BIND_NOW=1**: 시작할 때 모든 심볼 리졸브. 지연 없지만 시작이 느림. 보안 강화 (GOT 가 쓰기 금지 영역이 됨 → RELRO).
5.5 RELRO — 보안의 작은 혁명
Return-Oriented Programming 공격의 표적 중 하나가 GOT 였다. GOT 를 쓰기 가능 상태로 두면 공격자가 여기에 악성 주소를 써서 다음 호출을 hijack 할 수 있다.
**Partial RELRO**: `.got.plt` 는 쓰기 가능 (lazy binding 필요), `.got` 만 읽기 전용.
**Full RELRO** (`-Wl,-z,relro,-z,now`): 시작 시 모든 심볼 리졸브 후 `.got.plt` 도 읽기 전용. `LD_BIND_NOW=1` 과 동일 효과.
최신 배포판은 Full RELRO 가 기본.
6. 공유 라이브러리의 버전 관리 — .so 뒤의 숫자들
libc.so.6 -> libc-2.35.so
libc.so -> libc.so.6
세 단계:
1. **`libc-2.35.so`**: 실제 파일, 특정 버전.
2. **`libc.so.6`**: `SONAME`, ABI 버전. 링크 시 실행 파일에 이게 기록됨.
3. **`libc.so`**: `linker name`, 개발 편의용 심볼릭 링크. `gcc -lc` 가 이걸 찾음.
6.1 Symbol Versioning — 같은 이름, 다른 버전
glibc 는 같은 함수가 여러 버전으로 존재할 수 있다:
$ objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep memcpy
0000000000000000 DF *UND* memcpy@GLIBC_2.14
0000000000000000 DF *UND* memcpy@@GLIBC_2.17
2011 년에 `memcpy` 가 버그 수정을 포함한 신버전으로 바뀌었다 (flash adobe 가 의존하던 잘못된 동작이 깨짐). 구 바이너리는 `@GLIBC_2.2.5` 버전을 쓰고, 신규 컴파일은 `@@GLIBC_2.14` 를 쓴다 — **같은 `libc.so.6` 안에서 공존**.
이게 Linux 가 10년된 바이너리도 여전히 실행되는 이유.
6.2 SO 버전 비호환 — "GLIBC_2.35 not found"
./myapp: /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.35 not found
Ubuntu 22.04 에서 빌드한 바이너리를 Ubuntu 20.04 에서 실행하면 흔히 본다. 해결:
- **구 시스템에서 빌드**: Docker 로 구 배포판 재현.
- **정적 링킹**: 호환성 문제 없음 (하지만 PAM, NSS 등 일부는 여전히 동적 로드 필요).
- **Rust/Go**: 자체 런타임을 정적 링크 → 이 문제 완화.
7. C++ 이름 맹글링 — 오버로딩의 비밀
7.1 왜 맹글링이 필요한가
C 에서는 함수 이름 = 심볼 이름:
void foo(int) { } // 심볼: foo
C++ 에서 같은 이름의 여러 오버로드:
void foo(int);
void foo(double);
void foo(int, int);
이 셋이 같은 심볼이면 링커가 구분 못함. 해결책: **이름 맹글링 (name mangling)**.
void foo(int) → _Z3fooi
void foo(double) → _Z3food
void foo(int, int) → _Z3fooii
7.2 Itanium ABI 맹글링 규칙
GCC/Clang 이 쓰는 Itanium C++ ABI 의 맹글링:
_Z : 접두사 (mangling)
N ... E : 네임스페이스
3foo : 이름 "foo" (길이 3)
i : int
d : double
Pi : pointer to int
PKc : pointer to const char
복잡한 예시:
namespace ns {
class C {
void method(const std::string& s, int n);
};
}
// 맹글된 심볼:
_ZN2ns1C6methodERKSsi
7.3 `c++filt` — 사람이 읽을 수 있게
$ c++filt _ZN2ns1C6methodERKSsi
ns::C::method(std::string const&, int)
디버거, 스택 트레이스, 링커 에러에서 맹글된 이름이 나오면 `c++filt` 로 풀어 읽는다.
7.4 `extern "C"` — 맹글링 비활성화
C 라이브러리를 C++ 에서 쓰려면 맹글링을 꺼야 한다:
extern "C" {
#include <stdio.h>
}
extern "C" void my_c_api(int x); // 심볼: my_c_api (맹글링 X)
플러그인 아키텍처, 동적 로딩 (`dlsym`) 에서 필수.
7.5 ABI 호환성의 악몽
C++ 은 이름뿐만 아니라 **클래스 레이아웃** 도 ABI 의 일부:
- `std::string` 구조가 바뀌면 구 컴파일 바이너리와 신 컴파일 바이너리가 섞일 수 없다.
- GCC 5 에서 `std::string` 과 `std::list` 를 C++11 호환 레이아웃으로 바꾼 "dual ABI" 이벤트가 유명.
- `_GLIBCXX_USE_CXX11_ABI=0/1` 로 선택 가능하지만 혼재 시 미묘한 크래시.
**결론**: C++ 라이브러리 배포 시 **추상 C API** 를 노출하는 게 안전. QT, GTK 가 이 방식.
8. LTO (Link-Time Optimization) — 전체 프로그램 최적화
8.1 컴파일 단위의 한계
전통적 모델에서 컴파일러는 한 파일 (번역 단위) 만 본다:
// foo.c
int add(int a, int b) { return a + b; }
// bar.c
extern int add(int, int);
int main() { return add(2, 3); }
`add` 가 "상수 5 를 항상 반환" 임을 컴파일러는 알 수 없다 → `add` 호출을 상수 5 로 대체하는 최적화가 불가능.
8.2 LTO 의 해법
`-flto` 옵션: 컴파일 시 `.o` 에 **IR (LLVM bitcode 또는 GCC GIMPLE)** 도 같이 저장한다. 링크 타임에 이 IR 을 모두 모아서 다시 최적화한다.
- **Cross-module inlining**: `add` 를 `main` 에 인라인 → 상수 폴딩 → `return 5`.
- **Dead code elimination**: 아무도 호출 안 하는 함수 제거.
- **Devirtualization** (C++): 가상 함수 호출을 직접 호출로 변환.
8.3 Thin LTO — 확장성 개선
전통 LTO 는 모든 IR 을 한 번에 로드 → 대형 프로젝트에서 RAM 수십 GB 필요.
Thin LTO (LLVM): 각 모듈을 독립 컴파일하되 **요약 정보** (summary) 를 교환. 크로스 모듈 최적화 기회만 골라서 수행. Firefox, Chrome 이 이걸로 빌드.
8.4 LTO 의 대가
- **빌드 시간 증가**: 링크 단계가 무거워짐.
- **디버깅 어려움**: 함수가 인라인되어 스택 트레이스가 어렵다.
- **증분 빌드 복잡성**: 한 파일 수정 시 재링크가 오래 걸림.
그래도 릴리스 빌드에서는 보통 5-15% 성능 향상 → 가치 있음.
9. PGO — 프로필 기반 최적화
9.1 "실제로 자주 실행되는 경로를 빠르게"
정적 분석만으로는 "if-else 중 어느 쪽이 더 자주 실행되는지" 를 알 수 없다. PGO 는:
1. **계측 빌드**: `-fprofile-generate` 로 컴파일 → 각 분기/루프가 몇 번 실행되는지 기록하는 코드 삽입.
2. **대표 워크로드 실행**: 실제 프로덕션 시나리오로 실행. `*.gcda` 파일에 카운터 기록.
3. **최적화 빌드**: `-fprofile-use` 로 재컴파일 → 카운터 기반 최적화:
- Hot code 를 `.text.hot` 으로 분리 → i-cache 효율.
- Cold code 를 다른 곳으로.
- 자주 분기되는 방향을 폴스루 경로로.
- 자주 호출되는 함수 인라인 우선.
9.2 실무 효과
Chrome, Firefox, Clang 자체가 PGO 로 빌드된다. 일반적으로 10-30% 성능 향상. 특히 JIT, VM 같은 인터프리터 루프에서 효과 큼.
10. Rust, Go — 고전 링킹과 다른 길
10.1 Rust — 모노모피제이션과 LTO
Rust 는 **제네릭** 을 모노모피제이션 (instantiate-on-use) 한다:
fn foo<T>(x: T) { ... }
foo::<i32>(1);
foo::<String>("a".into());
// 컴파일러는 foo 를 i32 용과 String 용 두 번 생성 → 각각 다른 심볼
C++ 템플릿과 유사. 결과:
- **런타임 비용 없음**.
- **바이너리 크기 폭발** (같은 함수가 N 번 복제).
- **컴파일 시간 증가**.
Rust 는 기본적으로 정적 링킹 (libstd 포함). `.so` 를 쓰려면 `crate-type = ["cdylib"]` 로 명시.
10.2 Go — 런타임까지 포함한 단일 바이너리
Go 는 기본 정적 링킹 + 런타임까지 통합 (가비지 컬렉터, 스케줄러 포함):
- "가장 간단한 hello world" 가 2MB+.
- 하지만 배포가 `scp binary user@server:/usr/local/bin/` 한 줄로 끝남 → Docker 등장 이전부터 Go 의 킬러 앱.
- cgo 를 쓰면 동적 링킹으로 바뀜 (`libc` 필요) → Docker distroless 에서 문제 원인.
10.3 Zig — 크로스 컴파일의 복음
Zig 는 **자체 툴체인에 libc 소스 포함**:
zig cc -target x86_64-linux-gnu hello.c -o hello # 리눅스용
zig cc -target aarch64-macos hello.c -o hello # ARM 맥용
Go 의 크로스 컴파일이 편하지만 cgo 가 걸리면 복잡했던 것을, Zig 가 **C 크로스 컴파일까지** 해결. Bun, Uber 등이 채택.
11. 실전 함정 모음
11.1 "undefined reference to `func`" — 가장 흔한 링커 에러
원인 5가지:
1. **`.o` 또는 `.a` 를 안 넘김**: `gcc main.o foo.o -o main` 에서 foo.o 누락.
2. **라이브러리 이름 빠짐**: `gcc main.c -o main -lm` 에서 `-lm` (math 라이브러리) 없을 때 `sin`, `cos` 미해결.
3. **라이브러리 순서**: `gcc main.o -lfoo -lbar` 에서 bar 가 foo 에 의존하면 `-lbar -lfoo` 가 맞음. GNU ld 는 왼쪽→오른쪽 한 번만 스캔.
4. **C++ 에서 C 함수 이름 맹글링**: `extern "C"` 없이 헤더 포함.
5. **심볼 export 안 됨**: Windows DLL 에서 `__declspec(dllexport)` 누락, 또는 `-fvisibility=hidden` 으로 다 숨김.
11.2 `strip` 의 함정
strip hello # 모든 심볼 제거 → 크래시 스택 트레이스 불가
strip --strip-debug hello # 디버그 정보만 제거 → 프로덕션에 적절
objcopy --only-keep-debug hello hello.debug
objcopy --strip-debug hello
objcopy --add-gnu-debuglink=hello.debug hello # 분리 보관
보통 프로덕션은 strip 하되 debug 심볼을 별도 저장해서 크래시 시 gdb 로 조합한다.
11.3 rpath vs runpath
실행 파일이 libfoo.so 를 `/opt/myapp/lib` 에서 찾게 하려면:
- **`-Wl,-rpath=/opt/myapp/lib`**: 하드코딩. `$ORIGIN` 변수로 "실행 파일과 같은 디렉터리" 지정 가능.
- **`LD_LIBRARY_PATH`**: 환경변수. 보안상 setuid 에서는 무시됨.
- **`/etc/ld.so.conf.d/`**: 시스템 전역.
`$ORIGIN/../lib` 가 특히 유용: 포터블 배포 시 `bin/` 옆 `lib/` 에서 라이브러리 로드.
11.4 LD_PRELOAD — 위대하고 위험한 도구
LD_PRELOAD=./mymalloc.so ./myapp
- 모든 `malloc` 호출을 내 구현으로 가로챔.
- **jemalloc, tcmalloc 을 이 방식으로** 기존 바이너리에 주입.
- **성능 프로파일러** (memory leak detector) 의 원리.
- **보안 위험**: 악성 LD_PRELOAD 가 setuid 실행파일을 hijack 하지 못하게 커널이 차단.
12. 실무 체크리스트
**디버깅**:
- `readelf -a binary` — 전체 구조 스캔.
- `objdump -d binary` — 디스어셈블.
- `nm binary` — 심볼 리스트. `nm -D` 는 동적 심볼.
- `ldd binary` — 의존 라이브러리 (신뢰 가능한 바이너리에만).
- `strace -e openat binary` — 런타임에 어떤 파일을 여는지.
**성능 빌드**:
- `-O2` 또는 `-O3` + `-flto` + `-fno-plt` + `-Wl,-O1,--as-needed`.
- PGO 를 반영해야 하는 핫스팟이면 `-fprofile-generate/use`.
**보안 빌드**:
- `-fstack-protector-strong` — 스택 카나리.
- `-D_FORTIFY_SOURCE=2` — 버퍼 오버플로 가드.
- `-fPIE -pie` — 위치 독립 실행.
- `-Wl,-z,relro,-z,now` — Full RELRO.
- `-Wl,-z,noexecstack` — NX bit.
**크기 줄이기**:
- `-ffunction-sections -fdata-sections -Wl,--gc-sections` — 안 쓰이는 함수/데이터 제거.
- `strip --strip-debug`.
- `-Os` 또는 `-Oz` — 크기 우선 최적화.
13. 마치며 — `gcc hello.c` 뒤에 숨은 40년
hello.c 를 hello 로 바꾸는 8초 동안 :
- 1970년대 Unix v6 의 ed.o + as.o 파이프라인의 후예가 작동한다.
- 1988년 Sun 이 SVR4 와 함께 정의한 ELF 포맷이 헤더를 쓴다.
- 1995년 Linux 가 채택한 glibc 의 2.0 이후 30년 심볼 버전이 해결된다.
- 2000년대 초 LLVM 이 제안한 IR 이 (clang 을 쓰면) 중간 표현이 된다.
- 2010년대 Thin LTO 와 PGO 가 릴리스 빌드를 가속한다.
- 2020년대 Zig, Rust 가 크로스 컴파일의 상식을 바꾸고 있다.
한 줄의 `gcc hello.c` 는 이 모든 역사의 교집합에서 작동한다. 오늘의 실행 파일은 어제의 지혜 위에 올라가 있다.
다음 글에서는 **OS 커널이 이 바이너리를 실제로 메모리에 올리는 과정** — `execve` 시스템 콜, 페이지 테이블 생성, mmap, 프로세스 주소 공간의 구조, vdso, 시스템 콜의 빠른 경로 — 을 파볼 예정이다. 실행 파일이 메모리에 올라간 후에도 여정은 계속된다.
참고 자료
- John R. Levine — "Linkers and Loaders" (Morgan Kaufmann, 1999) — 클래식.
- Ian Lance Taylor — "Linkers" 블로그 시리즈 (2007) — gold 링커 저자의 해설.
- Ulrich Drepper — "How To Write Shared Libraries" (2011) — glibc 유지보수자의 결정판 문서.
- System V ABI AMD64 Supplement — x86-64 ABI 공식 문서.
- Itanium C++ ABI — 맹글링 규칙 출처.
- LLVM Thin LTO 논문 (Apple, 2016).
- Mike Pall — LuaJIT 의 PGO/LTO 메모들.
- "Computer Systems: A Programmer's Perspective" — Bryant & O'Hallaron, 3rd ed — Chapter 7 (Linking).
현재 단락 (1/373)
int main(void) {