Skip to content

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 → 모든 PI3.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 HeaderELF Header├──────────────────┼──────────────────┤
Program Header   │                  │
 (실행용 뷰)      │                  │
├──────────────────┤ Sections:│                  │  .textSegments:.rodataLOAD (r-x).dataLOAD (rw-).bssINTERP.symtabDYNAMIC.strtab│                  │  .rela.text├──────────────────┼──────────────────┤
Section HeaderSection Header (링커용 뷰)       (링커용 뷰)└──────────────────┴──────────────────┘
    실행시 뷰          링크시 뷰
  • 섹션 (Section): 링커가 다룬다. 세밀하게 .text, .data, .bss, .symtab 등으로 분할.
  • 세그먼트 (Segment, Program Header): 커널이 다룬다. 섹션을 권한 (r-x, rw-) 별로 묶어서 메모리에 매핑.

2.2 주요 섹션

섹션내용메모리 권한
.text실행 코드r-x
.rodata읽기 전용 상수 (문자열 리터럴 등)r--
.data초기화된 전역/static 변수rw-
.bss0 으로 초기화된 전역/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.omain 함수에서 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_PC32PC 상대 32비트 (call, jmp)
R_X86_64_PLT32PLT 경유 call
R_X86_64_GOTPCRELGOT 엔트리 상대 참조
R_X86_64_TPOFF32TLS (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 -preadelf -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::stringstd::list 를 C++11 호환 레이아웃으로 바꾼 "dual ABI" 이벤트가 유명.
  • _GLIBCXX_USE_CXX11_ABI=0/1 로 선택 가능하지만 혼재 시 미묘한 크래시.

결론: C++ 라이브러리 배포 시 추상 C API 를 노출하는 게 안전. QT, GTK 가 이 방식.

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 옵션: 컴파일 시 .oIR (LLVM bitcode 또는 GCC GIMPLE) 도 같이 저장한다. 링크 타임에 이 IR 을 모두 모아서 다시 최적화한다.

  • Cross-module inlining: addmain 에 인라인 → 상수 폴딩 → 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).

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 printf live? Inside my program? Or fetched at runtime?
  • Why does hello.c first become hello.o and then hello?
  • In C++, why don't the overloads void f(int) and void f(double) collide as names?
  • Why does deleting libc.so.6 take down the entire Linux system?
  • Why does strip make 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.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 every PI with 3.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:

  • @printf is only declared, with no definition - a symbol to be resolved later at link time.
  • @.str is a global for a constant string. It will land in the .rodata section of hello.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@PLT is 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:

  1. Merge multiple .o files: concatenate sections of the same kind (.text, .data).
  2. Symbol resolution: connect external symbols like printf to their actual definitions.
  3. Relocation: once final addresses are fixed, fill in the address fields of instructions.
  4. 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

SectionContentMemory permissions
.textExecutable coder-x
.rodataRead-only constants (string literals, etc.)r--
.dataInitialized global/static variablesrw-
.bssZero-initialized global/static variablesrw-
.symtabSymbol table (function/variable name -> address)(file only)
.strtabString pool of symbol names(file only)
.rela.textRelocation entries for .text(file only)
.dynsymDynamic symbols (used by the linker)r--
.plt, .gotJump tables for dynamic linkingr-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
  • ones lands in .data - the executable grows by 4MB.
  • zeros lands 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.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

BindingMeaning
LOCALFile-internal (static functions/variables). Not visible to other files.
GLOBALExternally exposed. Multiple files can link against this symbol.
WEAKA 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 .c files include it. Use inline or static, 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. Use extern 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.o assembly time, we don't know printf's final address.
  • We don't even know main's final address (another .o might 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:

TypeMeaning
R_X86_64_64Absolute 64-bit address
R_X86_64_PC32PC-relative 32-bit (call, jmp)
R_X86_64_PLT32Call via PLT
R_X86_64_GOTPCRELReference relative to GOT entry
R_X86_64_TPOFF32TLS (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 printf code 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:

  1. libc-2.35.so: the actual file, a specific version.
  2. libc.so.6: the SONAME, the ABI version. This is what gets recorded in the executable at link time.
  3. libc.so: the linker name, a symlink for developer convenience. gcc -lc looks 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::string and binaries compiled with the old and new compilers can no longer mix.
  • The famous "dual ABI" event happened in GCC 5 when std::string and std::list were 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.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 add into main -> 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:

  1. Instrumented build: compile with -fprofile-generate - inserts code to record how many times each branch/loop runs.
  2. Run a representative workload: run with actual production scenarios. Counters are written to *.gcda files.
  3. Optimized build: recompile with -fprofile-use - counter-driven optimizations:
    • Split hot code into .text.hot for i-cache efficiency.
    • Move cold code elsewhere.
    • Put frequently taken branches on the fallthrough path.
    • Prioritize inlining frequently called functions.

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:

  1. Missing .o or .a: forgetting foo.o in gcc main.o foo.o -o main.
  2. Missing library name: in gcc main.c -o main -lm, without -lm (math library), sin/cos stay unresolved.
  3. 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.
  4. C++ mangling of C functions: including the header without extern "C".
  5. 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 $ORIGIN variable 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 malloc call 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 -D for dynamic symbols.
  • ldd binary - dependency libraries (only on trusted binaries).
  • strace -e openat binary - which files it opens at runtime.

Performance builds:

  • -O2 or -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.
  • -Os or -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).