Split View: 컴파일러와 링커 내부 완전 정복 — #include부터 실행 파일이 되기까지 ELF, 심볼, 정적/동적 링킹, 재배치, LTO (2025)
컴파일러와 링커 내부 완전 정복 — #include부터 실행 파일이 되기까지 ELF, 심볼, 정적/동적 링킹, 재배치, LTO (2025)
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).
Compiler and Linker Internals — From #include to an Executable: ELF, Symbols, Static/Dynamic Linking, Relocation, LTO (2025)
0. Before We Start — The Mystery of "gcc hello.c -o hello"
#include <stdio.h>
int main(void) {
printf("Hello\n");
return 0;
}
This 11-line source becomes an executable with a single gcc hello.c -o hello. It feels so natural that we write it off as "the compiler handles it." But let's throw some odd questions at it:
- Where does the actual code for
printflive? Inside my program? Or fetched at runtime? - Why does
hello.cfirst becomehello.oand thenhello? - In C++, why don't the overloads
void f(int)andvoid f(double)collide as names? - Why does deleting
libc.so.6take down the entire Linux system? - Why does
stripmake the binary smaller but break the debugger?
This article digs into the full journey from source to running program for C/C++ code, along with the internals. The target is Linux + ELF + GCC/Clang, but the principles apply similarly on Windows (PE/COFF) and macOS (Mach-O).
1. The Four Phases of a Build — Preprocess, Compile, Assemble, Link
1.1 The Stages Hidden Behind One gcc Command
hello.c --[cpp: preprocess]--> hello.i (expanded C source)
hello.i --[cc1: compile]-----> hello.s (assembly)
hello.s --[as: assemble]-----> hello.o (object file)
hello.o --[ld: link]---------> hello (executable)
Each stage is independently runnable. To actually see the intermediate outputs:
gcc -E hello.c -o hello.i # preprocess only
gcc -S hello.i -o hello.s # compile only
gcc -c hello.s -o hello.o # assemble only
gcc hello.o -o hello # link only
1.2 The Preprocessor — A Simple Text Substitution Engine
cpp (the C preprocessor) surprisingly knows nothing about C. It just substitutes text:
#include <stdio.h>- insert the contents of stdio.h in place.#define PI 3.14- replace everyPIwith3.14.#ifdef DEBUG- conditionally include/exclude text.
This is the root of C macros being dangerous. For example:
#define SQUARE(x) x*x
SQUARE(1+2) // -> 1+2*1+2 = 5 (intended 9)
This is why constexpr and templates in C++ replaced macros. But the preprocessor is still essential for header guards (#ifndef HEADER_H), platform branches (#ifdef _WIN32), conditional logging, and the like.
Look at gcc -E output and the original 11-line hello.c expands into tens of thousands of lines. stdio.h pulls in dozens of system headers transitively.
1.3 The Compiler — Where Real "Understanding" Begins
What the compiler frontend (Clang, GCC cc1) does:
hello.i
-> Lexer (tokenize)
-> Parser (build AST)
-> Semantic Analyzer (type check, name resolution)
-> IR generation (GIMPLE / LLVM IR)
-> Optimization passes (dozens)
-> Backend (emit target-CPU assembly)
hello.s
LLVM IR example (compiling 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*, ...)
Interesting bits at the IR level:
@printfis only declared, with no definition - a symbol to be resolved later at link time.@.stris a global for a constant string. It will land in the.rodatasection ofhello.o.
1.4 The Assembler — Turning Text Into Binary
hello.s (x86-64 example):
.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 (the GNU assembler) converts this into machine-code bytes and packs them into an ELF object file (hello.o). What's interesting:
call puts@PLTis left as a relocation entry that says "fill in the real address of puts here." The actual address is unknown.leaq .LC0(%rip)references.LC0, a symbol inside this file - the offset can be computed internally.
1.5 The Linker — Combining Pieces Into a Whole Executable
Core responsibilities of the linker:
- Merge multiple
.ofiles: concatenate sections of the same kind (.text,.data). - Symbol resolution: connect external symbols like
printfto their actual definitions. - Relocation: once final addresses are fixed, fill in the address fields of instructions.
- Executable header generation: produce an ELF header the kernel can read.
We'll cover why each of these matters in detail from the next chapter.
2. ELF — The Lingua Franca of Linux Binaries
2.1 Two Views of ELF
Since Linux adopted it as standard in 1999, ELF (Executable and Linkable Format) has become the de facto standard across Unix-like systems. The same file was designed to be seen in two ways:
+------------------+------------------+
| ELF Header | ELF Header |
+------------------+------------------+
| Program Header | |
| (execution view) | |
+------------------+ Sections: |
| | .text |
| Segments: | .rodata |
| LOAD (r-x) | .data |
| LOAD (rw-) | .bss |
| INTERP | .symtab |
| DYNAMIC | .strtab |
| | .rela.text |
+------------------+------------------+
| Section Header | Section Header |
| (linking view) | (linking view) |
+------------------+------------------+
runtime view link-time view
- Section: what the linker deals with. Finely split into
.text,.data,.bss,.symtab, etc. - Segment (Program Header): what the kernel deals with. Groups sections by permissions (r-x, rw-) and maps them into memory.
2.2 Key Sections
| Section | Content | Memory permissions |
|---|---|---|
.text | Executable code | r-x |
.rodata | Read-only constants (string literals, etc.) | r-- |
.data | Initialized global/static variables | rw- |
.bss | Zero-initialized global/static variables | rw- |
.symtab | Symbol table (function/variable name -> address) | (file only) |
.strtab | String pool of symbol names | (file only) |
.rela.text | Relocation entries for .text | (file only) |
.dynsym | Dynamic symbols (used by the linker) | r-- |
.plt, .got | Jump tables for dynamic linking | r-x / rw- |
2.3 The Magic of .bss — Why Zero-Initialized Variables Don't Take File Space
int zeros[1000000]; // 4MB
int ones[1000000] = {1, ...}; // 4MB
oneslands in.data- the executable grows by 4MB.zeroslands in.bss- the executable size barely changes.
Reason: .bss records "just the size" in the file. When the kernel loads the program, it maps a zero page (the global zero page). Storing 4MB of zeros on disk would be waste.
On first write, copy-on-write allocates a real page. So "zero-initialized" actually has a performance edge over "uninitialized" (the CPU caches the zero page well anyway).
2.4 Seeing it Live — The World of 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 # section headers
$ readelf -l hello # program headers (segments)
$ readelf -s hello # symbol table
$ objdump -d hello # disassembly
Run these on a small program like hello and the structure of ELF comes into focus. The best way to learn systems programming.
3. Symbols — The Link Between Names and Addresses
3.1 What Is a Symbol
A symbol is a "name -> address" mapping. At compile time, the real addresses of functions and globals are not yet fixed, but the names exist. The linker merges all .o files and assigns addresses.
Symbol table of 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: defined in this file (Bind=GLOBAL, section 1 =.text, Size=22).printf: undefined (UND). Must be found externally at link time.
3.2 Binding — Local, Global, Weak
| Binding | Meaning |
|---|---|
| LOCAL | File-internal (static functions/variables). Not visible to other files. |
| GLOBAL | Externally exposed. Multiple files can link against this symbol. |
| WEAK | A default that gets overridden by any STRONG definition. |
The WEAK trick: a library provides the default implementation as WEAK, so users can easily override it with a STRONG definition of the same name. Symbols like pthread's pthread_mutex_lock work this way.
// default implementation (inside libc)
__attribute__((weak)) void my_log(const char* msg) {
fprintf(stderr, "LOG: %s\n", msg);
}
// user override
void my_log(const char* msg) {
write_to_elasticsearch(msg);
}
3.3 Duplicate Symbols — The "multiple definition of foo" Error
If the same GLOBAL symbol exists in multiple .o files, the linker errors out:
/usr/bin/ld: b.o: multiple definition of `foo'; a.o: first defined here
Most common causes:
- Putting a function definition (not just a declaration) in a header so multiple
.cfiles include it. Useinlineorstatic, or keep only the declaration in the header and put the definition in one.c. - Putting a global variable definition (
int counter = 0;) in a header. Useextern int counter;to declare in the header and define it in exactly one.c.
C++ inline functions get special treatment - the linker tolerates duplicates and keeps only one (COMDAT sections).
4. Relocation — A World Where Addresses Are Fixed Late
4.1 Why Relocation Is Needed
We saw call printf@PLT in main of hello.o. This call instruction uses a relative address (x86-64 call rel32). That is, "how far to jump from the current PC." The problem:
- At
hello.oassembly time, we don't knowprintf's final address. - We don't even know
main's final address (another.omight come before it).
The assembler fills the instruction's address field with 0 and records a relocation entry in .rela.text saying "write (printf's address - current address) here":
$ 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 The Variety of Relocation Types
x86-64 alone has dozens of relocation types:
| Type | Meaning |
|---|---|
| R_X86_64_64 | Absolute 64-bit address |
| R_X86_64_PC32 | PC-relative 32-bit (call, jmp) |
| R_X86_64_PLT32 | Call via PLT |
| R_X86_64_GOTPCREL | Reference relative to GOT entry |
| R_X86_64_TPOFF32 | TLS (thread-local storage) |
Each tells the linker "how to compute the value." This is a core part of the ABI.
4.3 -fPIC — Position Independent Code
A shared library (.so) may be loaded at a different address in each process. So it can't use absolute addresses.
When compiled with -fPIC (Position Independent Code):
- Global variable access goes through the GOT (Global Offset Table).
- Function calls go through the PLT (Procedure Linkage Table).
This is how libc can be loaded into memory once and shared across every process. A major memory-saver.
5. Static Linking vs Dynamic Linking
5.1 Static Linking — Everything Inside My Binary
gcc hello.c -o hello -static
- libc's
printfcode is copied into the executable. - Executable size: 10KB -> 800KB.
- Pros: runs on systems without libc. No version-compatibility issues.
- Cons: big. Each process carries its own copy of libc in memory.
The Structure of a Static Library
libfoo.a is actually just an archive:
$ ar t /usr/lib/x86_64-linux-gnu/libc.a | head
init-first.o
libc-start.o
sysdep.o
...
A bundle of .o files made with ar (archiver). The linker pulls in only the .o files whose symbols my program requires (lazy linking).
"Requires" is decided at the file granularity of globals - an unused function in the same .o gets dragged in. This is why -ffunction-sections -fdata-sections -Wl,--gc-sections exists.
5.2 Dynamic Linking — Load on Demand
gcc hello.c -o hello # default: dynamic linking
- The executable only records a reference like "load libc.so.6 and pull printf from there."
- At run time the dynamic linker (
/lib64/ld-linux-x86-64.so.2) maps libc into memory and resolves symbols.
The INTERP Section — Name of the Dynamic Linker
$ readelf -l hello | grep -A1 INTERP
INTERP ...
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
The executable names an "interpreter" that executes it. The kernel actually runs ld.so first, and ld.so then loads my program.
5.3 The Secret of 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 shows "which shared libraries this program requires." Internally, however, it actually runs the program but stops before main and reads the map the dynamic linker resolved. That's why running ldd on a malicious binary is dangerous - use objdump -p or readelf -d instead.
5.4 The Magic of GOT/PLT
Flow of a dynamic call:
main calls printf
->
call printf@plt (jump to the PLT entry)
->
PLT[printf]:
jmp *GOT[printf] ; read the address from GOT and jump
; initially GOT is set to "call the resolver"
-> (on first call)
resolver invokes ld.so -> finds printf's real address -> updates GOT[printf]
->
subsequent calls jump directly through GOT[printf] (lazy binding)
Lazy binding: resolve only on the first call. Unused functions never get resolved. Faster startup.
LD_BIND_NOW=1: resolve every symbol at startup. No per-call delay but slow startup. Security win (GOT becomes read-only -> RELRO).
5.5 RELRO — A Small Security Revolution
GOT was one of the targets of Return-Oriented Programming attacks. Keeping GOT writable lets an attacker write a malicious address into it to hijack the next call.
Partial RELRO: .got.plt stays writable (needed for lazy binding); only .got is read-only.
Full RELRO (-Wl,-z,relro,-z,now): resolve every symbol at startup, then make .got.plt read-only too. Same effect as LD_BIND_NOW=1.
Modern distros default to Full RELRO.
6. Versioning of Shared Libraries — The Numbers After .so
libc.so.6 -> libc-2.35.so
libc.so -> libc.so.6
Three tiers:
libc-2.35.so: the actual file, a specific version.libc.so.6: theSONAME, the ABI version. This is what gets recorded in the executable at link time.libc.so: thelinker name, a symlink for developer convenience.gcc -lclooks this up.
6.1 Symbol Versioning — Same Name, Different Versions
In glibc, the same function can exist in multiple versions:
$ 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
In 2011, memcpy got a bug-fix version that broke the wrong behavior Adobe Flash depended on. Old binaries use the @GLIBC_2.2.5 version; freshly compiled code uses @@GLIBC_2.14 - coexisting inside the same libc.so.6.
This is why 10-year-old Linux binaries still run.
6.2 SO Version Incompatibility — "GLIBC_2.35 not found"
./myapp: /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.35 not found
A common sight when running a binary built on Ubuntu 22.04 on Ubuntu 20.04. Fixes:
- Build on the old system: reproduce the old distro in Docker.
- Static linking: no compatibility issue (but PAM, NSS, etc. still need dynamic loading).
- Rust/Go: statically link their own runtime - mitigates this problem.
7. C++ Name Mangling — The Secret of Overloading
7.1 Why Mangling Is Needed
In C, function name = symbol name:
void foo(int) { } // symbol: foo
In C++, multiple overloads share the same name:
void foo(int);
void foo(double);
void foo(int, int);
If they shared one symbol, the linker couldn't tell them apart. Solution: name mangling.
void foo(int) -> _Z3fooi
void foo(double) -> _Z3food
void foo(int, int) -> _Z3fooii
7.2 Itanium ABI Mangling Rules
The Itanium C++ ABI mangling used by GCC/Clang:
_Z : prefix (mangling)
N ... E : namespace
3foo : name "foo" (length 3)
i : int
d : double
Pi : pointer to int
PKc : pointer to const char
A complex example:
namespace ns {
class C {
void method(const std::string& s, int n);
};
}
// mangled symbol:
_ZN2ns1C6methodERKSsi
7.3 c++filt — Make It Human-Readable
$ c++filt _ZN2ns1C6methodERKSsi
ns::C::method(std::string const&, int)
When a debugger, stack trace, or linker error shows a mangled name, decode it with c++filt.
7.4 extern "C" — Disable Mangling
To use a C library from C++, turn off mangling:
extern "C" {
#include <stdio.h>
}
extern "C" void my_c_api(int x); // symbol: my_c_api (no mangling)
Essential for plugin architectures and dynamic loading (dlsym).
7.5 The Nightmare of ABI Compatibility
In C++, not only names but also class layouts are part of the ABI:
- Change the layout of
std::stringand binaries compiled with the old and new compilers can no longer mix. - The famous "dual ABI" event happened in GCC 5 when
std::stringandstd::listwere moved to a C++11-compatible layout. - Selectable via
_GLIBCXX_USE_CXX11_ABI=0/1, but mixing them produces subtle crashes.
Takeaway: when shipping a C++ library, exposing an abstract C API is safer. Qt and GTK take this route.
8. LTO (Link-Time Optimization) — Whole-Program Optimization
8.1 The Limits of a Translation Unit
In the traditional model, the compiler sees only one file (translation unit):
// foo.c
int add(int a, int b) { return a + b; }
// bar.c
extern int add(int, int);
int main() { return add(2, 3); }
The compiler can't know add "always returns the constant 5" - so replacing the call with the constant 5 is impossible.
8.2 The LTO Fix
The -flto option: at compile time, store the IR (LLVM bitcode or GCC GIMPLE) inside .o as well. At link time, collect all this IR and re-optimize it.
- Cross-module inlining: inline
addintomain-> constant folding ->return 5. - Dead code elimination: remove functions nobody calls.
- Devirtualization (C++): convert virtual calls to direct calls.
8.3 Thin LTO — Better Scalability
Classic LTO loads all IR at once - large projects need tens of GB of RAM.
Thin LTO (LLVM): compile each module independently but exchange summaries. Perform only the cross-module optimizations that matter. Firefox and Chrome use this.
8.4 The Cost of LTO
- Longer build times: the link stage gets heavy.
- Harder debugging: inlined functions make stack traces tricky.
- Incremental-build complexity: a single-file edit triggers a long relink.
Still, release builds typically see a 5-15% performance lift - worth it.
9. PGO — Profile-Guided Optimization
9.1 "Make the Hot Path Fast"
Static analysis alone can't tell "which arm of an if-else runs more often." PGO:
- Instrumented build: compile with
-fprofile-generate- inserts code to record how many times each branch/loop runs. - Run a representative workload: run with actual production scenarios. Counters are written to
*.gcdafiles. - Optimized build: recompile with
-fprofile-use- counter-driven optimizations:- Split hot code into
.text.hotfor i-cache efficiency. - Move cold code elsewhere.
- Put frequently taken branches on the fallthrough path.
- Prioritize inlining frequently called functions.
- Split hot code into
9.2 Impact in Practice
Chrome, Firefox, and Clang itself are built with PGO. Typically 10-30% performance improvement. Especially effective for interpreter loops such as JITs and VMs.
10. Rust, Go — A Different Path From Classical Linking
10.1 Rust — Monomorphization and LTO
Rust monomorphizes generics (instantiate-on-use):
fn foo<T>(x: T) { ... }
foo::<i32>(1);
foo::<String>("a".into());
// the compiler emits foo twice, for i32 and for String -> distinct symbols
Similar to C++ templates. Result:
- No runtime cost.
- Binary size blow-up (the same function copied N times).
- Longer compile times.
Rust defaults to static linking (libstd included). To use a .so you must explicitly set crate-type = ["cdylib"].
10.2 Go — A Single Binary That Includes the Runtime
Go defaults to static linking and bundles the runtime (GC, scheduler) too:
- "The simplest hello world" is 2MB+.
- But deployment ends with one line:
scp binary user@server:/usr/local/bin/- Go's killer feature since before Docker. - Using cgo switches to dynamic linking (needs
libc) - a common cause of Docker distroless breakage.
10.3 Zig — The Gospel of Cross-Compilation
Zig ships libc source inside its toolchain:
zig cc -target x86_64-linux-gnu hello.c -o hello # for Linux
zig cc -target aarch64-macos hello.c -o hello # for ARM Mac
Go made cross-compilation easy but cgo made it complicated. Zig solves cross-compiling C too. Adopted by Bun, Uber, and others.
11. Collection of Practical Pitfalls
11.1 "undefined reference to func" — The Most Common Linker Error
Five causes:
- Missing
.oor.a: forgetting foo.o ingcc main.o foo.o -o main. - Missing library name: in
gcc main.c -o main -lm, without-lm(math library),sin/cosstay unresolved. - Library order: in
gcc main.o -lfoo -lbar, if bar depends on foo, the correct order is-lbar -lfoo. GNU ld scans left-to-right only once. - C++ mangling of C functions: including the header without
extern "C". - Symbol not exported: missing
__declspec(dllexport)in Windows DLLs, or hiding everything with-fvisibility=hidden.
11.2 The Traps of strip
strip hello # remove all symbols -> no crash stack traces
strip --strip-debug hello # remove only debug info -> appropriate for prod
objcopy --only-keep-debug hello hello.debug
objcopy --strip-debug hello
objcopy --add-gnu-debuglink=hello.debug hello # keep them separate
Typically production gets stripped, debug symbols are stored separately, and on a crash you combine them under gdb.
11.3 rpath vs runpath
To make the executable find libfoo.so in /opt/myapp/lib:
-Wl,-rpath=/opt/myapp/lib: hard-coded. The$ORIGINvariable designates "the directory of the executable."LD_LIBRARY_PATH: env var. Ignored by setuid for security./etc/ld.so.conf.d/: system-wide.
$ORIGIN/../lib is especially useful: a portable deployment loads libraries from lib/ next to bin/.
11.4 LD_PRELOAD — Great and Dangerous
LD_PRELOAD=./mymalloc.so ./myapp
- Intercept every
malloccall with my implementation. - jemalloc, tcmalloc are injected into existing binaries this way.
- The underlying mechanism of performance profilers (memory leak detectors).
- Security risk: the kernel blocks malicious LD_PRELOAD from hijacking setuid binaries.
12. Practical Checklist
Debugging:
readelf -a binary- scan the whole structure.objdump -d binary- disassembly.nm binary- symbol list.nm -Dfor dynamic symbols.ldd binary- dependency libraries (only on trusted binaries).strace -e openat binary- which files it opens at runtime.
Performance builds:
-O2or-O3+-flto+-fno-plt+-Wl,-O1,--as-needed.- For hotspots that need profile guidance, add
-fprofile-generate/use.
Security builds:
-fstack-protector-strong- stack canary.-D_FORTIFY_SOURCE=2- buffer-overflow guard.-fPIE -pie- position-independent executable.-Wl,-z,relro,-z,now- Full RELRO.-Wl,-z,noexecstack- NX bit.
Shrinking:
-ffunction-sections -fdata-sections -Wl,--gc-sections- drop unused functions/data.strip --strip-debug.-Osor-Oz- size-first optimization.
13. Closing — The 40 Years Hidden Behind gcc hello.c
During the 8 seconds hello.c becomes hello:
- The descendant of the 1970s Unix v6 ed.o + as.o pipeline runs.
- The ELF format Sun defined along with SVR4 in 1988 writes the headers.
- 30 years of symbol versioning since glibc 2.0 (adopted by Linux in 1995) gets resolved.
- The IR proposed by LLVM in the early 2000s becomes the intermediate representation (if you use clang).
- Thin LTO and PGO from the 2010s accelerate release builds.
- In the 2020s, Zig and Rust are rewriting the common sense of cross-compilation.
A single line of gcc hello.c operates at the intersection of all that history. Today's executable stands on yesterday's wisdom.
In the next post I plan to dig into how the OS kernel actually loads this binary into memory - the execve syscall, page table creation, mmap, the layout of a process's address space, vdso, and the fast path of system calls. The journey continues even after the executable is loaded into memory.
References
- John R. Levine - "Linkers and Loaders" (Morgan Kaufmann, 1999) - the classic.
- Ian Lance Taylor - "Linkers" blog series (2007) - commentary from the author of the gold linker.
- Ulrich Drepper - "How To Write Shared Libraries" (2011) - the definitive doc by a glibc maintainer.
- System V ABI AMD64 Supplement - the official x86-64 ABI document.
- Itanium C++ ABI - the source of the mangling rules.
- LLVM Thin LTO paper (Apple, 2016).
- Mike Pall - LuaJIT's PGO/LTO notes.
- "Computer Systems: A Programmer's Perspective" - Bryant and O'Hallaron, 3rd ed - Chapter 7 (Linking).