- Authors

- Name
- Youngju Kim
- @fjvbn20031
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 링커 — 조각들을 실행 가능한 전체로
링커의 핵심 책임:
- 여러
.o파일 병합: 같은 섹션 (.text,.data) 들을 이어붙인다. - 심볼 해결:
printf같은 외부 심볼을 실제 정의와 연결. - 재배치: 최종 주소가 정해지면 명령어의 주소 필드를 채워넣는다.
- 실행 파일 헤더 생성: 커널이 읽을 수 있는 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
세 단계:
libc-2.35.so: 실제 파일, 특정 버전.libc.so.6:SONAME, ABI 버전. 링크 시 실행 파일에 이게 기록됨.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 는:
- 계측 빌드:
-fprofile-generate로 컴파일 → 각 분기/루프가 몇 번 실행되는지 기록하는 코드 삽입. - 대표 워크로드 실행: 실제 프로덕션 시나리오로 실행.
*.gcda파일에 카운터 기록. - 최적화 빌드:
-fprofile-use로 재컴파일 → 카운터 기반 최적화:- Hot code 를
.text.hot으로 분리 → i-cache 효율. - Cold code 를 다른 곳으로.
- 자주 분기되는 방향을 폴스루 경로로.
- 자주 호출되는 함수 인라인 우선.
- Hot 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가지:
.o또는.a를 안 넘김:gcc main.o foo.o -o main에서 foo.o 누락.- 라이브러리 이름 빠짐:
gcc main.c -o main -lm에서-lm(math 라이브러리) 없을 때sin,cos미해결. - 라이브러리 순서:
gcc main.o -lfoo -lbar에서 bar 가 foo 에 의존하면-lbar -lfoo가 맞음. GNU ld 는 왼쪽→오른쪽 한 번만 스캔. - C++ 에서 C 함수 이름 맹글링:
extern "C"없이 헤더 포함. - 심볼 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).