Skip to content

✍️ 필사 모드: 컴파일러와 링커 내부 완전 정복 — #include부터 실행 파일이 되기까지 ELF, 심볼, 정적/동적 링킹, 재배치, LTO (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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).

현재 단락 (1/373)

int main(void) {

작성 글자: 0원문 글자: 13,903작성 단락: 0/373