CS:APP 한글 노트
제7장 · 링킹
CHAPTER 07

링킹

Linking

우리가 짠 main.c 한 파일이 printf를 부르는 순간, 뒤에서는 수십 개의 오브젝트 파일과 라이브러리가 조용히 합쳐진다. 이 장은 그 "합쳐지는 마법"의 메커니즘을 풀어 본다 — 심볼이 어떻게 매칭되고, 주소가 어떻게 메워지고, 실행 시에 또 어떤 일이 벌어지는지까지. 링커를 안다는 건 빌드 에러의 절반을 5초 만에 해결할 수 있다는 뜻이다.

7.1컴파일러 드라이버

평소 gcc hello.c -o hello 한 줄로 끝나는 작업이지만, gcc는 사실 드라이버(driver)일 뿐이다. 진짜 일은 네 명의 일꾼이 분담해서 한다. 호기심이 든다면 -v 옵션을 붙여 보자 — 안 보이던 무대 뒤가 보인다.

$ gcc -v -o hello hello.c (출력 일부 발췌)cpp ... hello.c    -> /tmp/hello.i      # 1) 전처리: #include, #define 풀기
cc1 ... hello.i    -> /tmp/hello.s      # 2) 컴파일: C → 어셈블리
as  ... hello.s    -> /tmp/hello.o      # 3) 어셈블: 어셈블리 → 기계어 (재배치 가능 오브젝트)
ld  ... hello.o ... -lc -o hello        # 4) 링크: 라이브러리와 합쳐 실행 파일

네 단계가 모두 따로 호출 가능한 독립 도구라는 점이 중요하다. 평소엔 한 줄에 다 묶이지만, 디버깅이나 리버싱에서는 각 단계의 중간 결과물(.i, .s, .o)을 들여다보는 일이 일상이다.

단계도구입력출력역할
전처리cpp.c.i매크로 치환·헤더 삽입
컴파일cc1.i.s최적화·어셈블리 생성
어셈블as.s.o기계어로 인코딩
링크ld.o·.a·.so실행 파일심볼 매칭·주소 결정
선배 한마디

"빌드가 안 돼요"의 90%는 컴파일이 아니라 링크에서 터진다. undefined reference to 'foo'를 만났다면 그건 컴파일러의 비명이 아니라 링커의 비명이다.

7.2정적 링킹

링커가 하는 일을 한 줄로 줄이면, 여러 입력 모듈을 받아서 하나의 실행 가능한 모듈을 만드는 것이다. 이때 두 가지 큰 작업이 진행된다.

  • 심볼 해석(symbol resolution) — 코드 안의 모든 심볼 참조를 정확히 한 개의 정의(definition)에 묶는다.
  • 재배치(relocation) — 컴파일 시 임시로 0번 주소부터 시작했던 코드와 데이터에 실제 메모리 주소를 박아 넣는다.

정적 링킹은 이 두 일을 빌드 타임에 모두 끝내고, 라이브러리 코드까지 실행 파일 안으로 복사해 넣는 방식이다. 실행 파일 하나만 들고 다니면 된다는 단순함이 매력이지만, 같은 라이브러리를 쓰는 100개 프로그램이 라이브러리 코드를 100번 복제해서 디스크와 메모리를 잡아먹는다는 단점이 있다. 나중에 동적 링킹이 등장한 이유가 여기에 있다.

7.3오브젝트 파일 (세 종류)

오브젝트 파일은 시점에 따라 세 모습 중 하나를 띤다. Linux에서는 모두 ELF(Executable and Linkable Format)라는 같은 포맷의 변형이다.

종류확장자설명
재배치 가능
relocatable
.o 다른 .o들과 합쳐져 실행 파일 또는 공유 객체가 되기를 기다리는 중간 산물. 주소가 아직 안 정해져 있다.
실행 가능
executable
(없음) 로더가 메모리에 올리면 곧장 실행할 수 있는 형태. 모든 주소가 박혀 있다.
공유 객체
shared object
.so (Windows: .dll) 로드 타임 또는 런타임에 다른 프로그램과 동적으로 링크되는 라이브러리. 위치 독립 코드(PIC).

7.4재배치 가능 오브젝트 파일 (ELF)

.o 파일을 열어 보면, 맨 앞에 ELF 헤더가 있고 그 뒤로 여러 섹션(section)이 차곡차곡 쌓여 있다. 헤더는 "이 파일은 ELF다, 64비트다, 리틀 엔디안이다, 섹션 헤더 테이블은 어디다…" 같은 메타 정보를 담는다.

섹션들어 있는 것
.text컴파일된 기계어 명령어들 (실행 코드)
.rodata읽기 전용 데이터: 문자열 리터럴, switch 점프 테이블
.data초기화된 전역/정적 변수
.bss초기화 안 된 전역/정적 변수. 자리만 잡고 디스크 공간은 안 먹는다 ("Better Save Space")
.symtab심볼 테이블 — 이 모듈이 정의·참조하는 함수와 전역 변수 목록
.rel.text.text 안에서 링크 시 고쳐야 할 위치 목록
.rel.data.data 안에서 링크 시 고쳐야 할 위치 목록
.debug디버깅 정보 (-g로 컴파일했을 때만)
.line소스 라인 번호 ↔ 기계어 주소 매핑
.strtab심볼 이름과 섹션 이름 등 모든 문자열의 풀(pool)
왜 .bss는 자리만 잡나?

초기화 안 된 변수는 어차피 0으로 채워진다. 0을 디스크에 잔뜩 쓸 이유가 없으니, ELF는 "여기에 N바이트 0이 있다고 쳐"라는 메모만 적어 둔다. 1MB짜리 전역 배열도 디스크에선 거의 공짜.

7.5심볼과 심볼 테이블

링커가 보는 세계에서 모든 함수와 전역 변수는 심볼(symbol)이다. 한 모듈의 심볼은 세 부류로 나뉜다.

  • 전역 심볼(global) — 이 모듈이 정의하고, 다른 모듈도 가져다 쓸 수 있는 것. static이 안 붙은 함수와 전역 변수.
  • 외부 심볼(external) — 다른 모듈이 정의했고, 이 모듈은 빌려 쓰는 것. 코드에서 extern으로 선언했거나, 단순히 함수 호출만 한 경우.
  • 지역 심볼(local)static 키워드가 붙은 함수/변수. 같은 파일 안에서만 보인다. 다른 모듈에서 같은 이름의 static이 있어도 충돌하지 않는다.
주의

함수 안에 든 static 지역 변수는 스택에 안 올라간다. 컴파일러가 .data.bss에 자리를 잡아 주고, 컴파일 단계에서 이름을 살짝 바꿔 충돌을 피한다 (예: foo.counter.1234). 그래서 같은 함수가 여러 번 호출돼도 변수 값이 유지되는 것.

심볼 테이블의 각 엔트리는 대략 이런 정보를 갖는다: 이름(.strtab 인덱스), 값(섹션 안에서의 오프셋), 크기, 어떤 섹션에 속하는지, 바인딩(local/global/weak), 타입(함수/객체).

7.6심볼 해석

심볼 해석(symbol resolution)은 참조를 정확히 하나의 정의에 묶는 작업이다. 지역 심볼이라면 같은 파일 안에서 짝을 찾으면 끝이지만, 전역 심볼은 이름이 같은 후보가 여러 모듈에 있을 수 있어서 규칙이 필요하다.

7.6.1중복 심볼 해결 규칙 (strong / weak)

링커는 전역 심볼을 두 등급으로 나눈다.

  • 강한 심볼(strong) — 함수, 그리고 초기화된 전역 변수.
  • 약한 심볼(weak)초기화 안 된 전역 변수.

같은 이름이 여러 모듈에 등장할 때 다음 세 규칙으로 정리한다.

  1. 강 + 강 = 에러. "multiple definition of `foo'"가 그 익숙한 외침.
  2. 강 + 약 = 강을 채택. 약은 조용히 묻힌다.
  3. 약 + 약 = 임의로 하나 채택. 더 무서운 경우. 컴파일러는 경고도 안 낸다.
파일 a.cint x = 100;          // 강한 심볼 (초기화됨)
void foo(void);
int main() { foo(); printf("x=%d\n", x); return 0; }
파일 b.cdouble x;             // 약한 심볼 (초기화 안 됨, 8바이트짜리!)
void foo(void) { x = 1.0; }

링커는 규칙 2에 따라 a.cint x(4바이트)를 채택한다. 그런데 fooxdouble(8바이트)로 알고 8바이트를 쓴다. 결과: x 옆에 있던 다른 변수가 조용히 박살난다. 빌드는 성공하고, 실행은 미친 듯이 이상하게 동작한다. 이게 약한 심볼이 위험한 이유다.

실전 팁

큰 프로젝트에서는 전역 변수를 static으로 묶거나, 여러 파일에서 공유할 거면 한 곳에서만 정의하고 다른 곳에선 extern으로 선언하는 습관이 약한 심볼 지옥을 막아 준다. gcc -fno-common이나 -Wl,--no-common 옵션으로 약한 심볼 자체를 금지할 수도 있다.

7.6.2정적 라이브러리와 링킹 (.a 아카이브)

관련된 .o 파일들을 매번 명령줄에 늘어놓는 건 사람의 일이 아니다. 아카이브(.a)는 여러 .o를 한 파일로 묶은 것이다. ar rcs libmath.a sin.o cos.o tan.o 같이 만든다. 관습적으로 이름은 lib이름.a 형태고, 링크 시엔 -l이름으로 끌어 쓴다.

핵심 차이: .o를 명령줄에 직접 주면 무조건 실행 파일에 들어가지만, .a 안의 .o필요한 것만 골라 들어간다. 그래서 libc.a에 수천 개 함수가 있어도 내가 안 쓴 함수는 실행 파일에 안 따라온다.

7.6.3정적 라이브러리로 참조 해결하는 방법 (왼→오 스캔)

링커는 명령줄에 적힌 파일을 왼쪽에서 오른쪽으로 한 번만 훑는다. 머리 속에 두 개의 집합을 들고 다닌다.

  • E: 지금까지 실행 파일에 포함하기로 결정한 .o 모음
  • U: 아직 정의를 못 찾은 미해결 심볼들
  • D: E 안에서 이미 정의된 심볼들

입력 파일을 하나씩 만나면서 다음과 같이 처리한다.

  1. 입력이 .o면 → E에 추가, 이 .o의 정의/참조를 보고 D·U 갱신.
  2. 입력이 .a면 → 내부의 각 멤버 .o를 보고, 이 멤버가 정의하는 심볼이 U에 있으면 E에 추가. 없으면 패스. 더 이상 변화가 없을 때까지 반복.
  3. 마지막에 U가 비어 있지 않으면 undefined reference 에러.
# 잘못된 순서: libfoo.a가 main.o의 foo() 참조를 만나기 전에 스캔됨
$ gcc -static -lfoo main.c          # 에러: undefined reference to `foo'

# 올바른 순서: main.o가 U에 foo를 추가한 뒤 libfoo.a에서 찾음
$ gcc -static main.c -lfoo          # OK

상호 참조가 있을 땐 같은 라이브러리를 두 번 쓰거나 -Wl,--start-group ... -Wl,--end-group으로 묶어 반복 스캔을 시킬 수도 있다. 처음 만난 사람의 90%는 이 순서 때문에 한 번쯤은 헤맨다.

7.7재배치 (Relocation)

심볼 해석이 끝나면, 링커는 모든 입력 모듈의 .text·.data 섹션을 모아 큰 덩어리로 합치고, 합친 덩어리에 실제 메모리 주소를 부여한다. 이제 코드 안에서 "여기서 foo를 부른다", "여기서 전역 변수 x를 읽는다" 같은 자리에 진짜 주소를 메워 넣어야 한다. 이 작업이 재배치다.

7.7.1재배치 항목 (relocation entries)

어셈블러는 어떤 명령이 외부 심볼을 참조하는지 알지만, 그 심볼이 최종적으로 어디 놓일지는 모른다. 그래서 일단 0이나 임시값으로 채워 두고, 나중에 링커가 채워 넣어 달라고 재배치 항목을 남긴다. .rel.text·.rel.data(또는 .rela.*)에 저장되며 각 항목에는 오프셋, 대상 심볼, 재배치 타입, 그리고 더할 상수(addend)가 들어 있다.

타입의미쓰임
R_X86_64_PC3232비트 PC-상대(PC-relative) 주소call foo, jmp label — 다음 명령 기준 상대 변위
R_X86_64_3232비트 절대 주소작은 코드 모델에서 전역 변수 직접 참조
R_X86_64_6464비트 절대 주소큰 코드 모델·포인터 테이블
R_X86_64_GOTPCRELGOT 엔트리에 대한 PC-상대PIC에서 전역 변수 접근 (7.12 참고)
R_X86_64_PLT32PLT 엔트리에 대한 PC-상대PIC에서 외부 함수 호출

7.7.2심볼 참조 재배치 알고리즘

PC-상대 재배치(R_X86_64_PC32)의 핵심 공식은 다음 한 줄이다. 여기서 r은 재배치 항목, s는 그 항목이 속한 섹션, refptr은 명령어 안의 메워야 할 4바이트 자리다.

refaddr = ADDR(s) + r.offset                      // 메울 위치의 최종 주소
*refptr = (ADDR(r.symbol) + r.addend) - refaddr   // PC-상대 변위

왜 빼주는가? call 명령은 다음 명령의 주소(=PC)에다 변위를 더해 점프하기 때문이다. 따라서 변위 = 대상 - PC가 된다. r.addend는 보통 -4 같은 작은 보정값으로, 명령어 길이 차이를 맞추는 용도다.

절대 주소 재배치(R_X86_64_32)는 더 단순하다.

*refptr = ADDR(r.symbol) + r.addend

링커는 모든 재배치 항목을 순회하며 이 두 공식을 실행한다. 끝나면 .text의 모든 명령이 진짜 주소를 가리키는 진짜 기계어가 된다. 이제 OS의 로더한테 넘기기만 하면 된다.

머릿속 그림

"링커는 빈칸 채우기 시험 채점관이다. 어셈블러가 답안지에 빈칸 뚫어 두고 옆에 '여긴 foo의 주소 - 4'라고 메모해 두면, 링커가 최종 좌석 배치표를 보고 빈칸을 채워 넣는다."

7.8실행 가능 오브젝트 파일

실행 가능 ELF 파일은 재배치 가능 파일과 모양이 비슷하지만 결정적인 차이가 있다. 프로그램 헤더 테이블(program header table)이 추가되고, 이게 로더 관점에서 파일을 메모리에 어떻게 배치할지를 알려 준다. 단위는 섹션이 아니라 세그먼트(segment)다. 여러 섹션이 같은 보호 속성을 공유하면 한 세그먼트로 묶인다.

  • 코드 세그먼트(읽기·실행) — .text, .rodata, .init 등.
  • 데이터 세그먼트(읽기·쓰기) — .data, .bss 등.

각 세그먼트 헤더는 "이 세그먼트는 파일 오프셋 X부터 Y바이트를, 가상 주소 V에 매핑하라. 메모리에서는 W바이트로 늘려라(.bss 때문에). 권한은 R-X다." 같은 정보를 담는다. 실제 파일 크기 < 메모리 크기인 부분이 바로 BSS다.

7.9실행 파일 로딩 (loader, execve)

실행 파일을 ./hello로 띄우면 셸은 fork 후 자식에서 execve 시스템 콜을 부른다. 커널의 로더(loader)가 등장하는 순간이다.

  1. 현재 프로세스의 가상 메모리(주소 공간)를 거의 모두 비운다.
  2. 실행 파일의 프로그램 헤더를 보고, 각 세그먼트를 가상 주소 공간에 매핑한다 (실제 디스크 → 페이지 테이블 엔트리. 9장 참고).
  3. 스택과 힙 영역도 만들어 둔다. 스택 꼭대기에 argc, argv, envp를 쌓는다.
  4. 엔트리 포인트(보통 _start)로 점프한다. _start가 런타임을 초기화하고 결국 main을 호출한다.

매핑은 실제 페이지를 그 자리에 갖다 놓지 않는다. 처음에는 페이지 테이블 엔트리만 만들어 두고, 실제 접근이 일어나는 순간 페이지 폴트로 디스크에서 끌어온다. 이걸 demand paging이라 부른다 (9장 주제). 그래서 GB짜리 실행 파일도 execve 자체는 빠르게 끝난다.

7.10공유 라이브러리와 동적 링킹

정적 링킹의 단점은 분명하다. libc가 1MB라면, 그걸 쓰는 100개 프로그램이 100MB를 잡아먹는다. 보안 패치라도 나오면 100개 모두 다시 빌드·재배포해야 한다. 해법은 라이브러리 코드를 실행 시점에 합치는 것 — 동적 링킹이다.

Linux의 공유 라이브러리는 .so 확장자를 갖는다 (Windows는 .dll). 빌드 시점에는 .so가 어떤 심볼들을 제공하는지만 기록해 두고, 실제 코드 복사는 하지 않는다. 실행 시점에 동적 링커(dynamic linker) — Linux에선 ld-linux.so — 가 필요한 .so들을 메모리에 올리고 마지막 재배치를 마무리한다.

장점단점
디스크 절약 (라이브러리 한 카피)실행 파일 시작 시 살짝 오버헤드
메모리 절약 (여러 프로세스가 같은 코드 페이지 공유)의존성 버전 지옥 ("DLL hell")
보안 패치 한 번에 전체 적용실행 환경에 라이브러리 없으면 안 돎

7.11애플리케이션에서 공유 라이브러리 로드/링크

보통은 동적 링커가 시작 시 모든 .so를 자동으로 처리하지만, 실행 도중에 라이브러리를 능동적으로 골라 부르고 싶을 때가 있다. 플러그인 시스템, 핫 리로딩, 게임 모드 시스템 같은 경우. 이때 <dlfcn.h> API를 쓴다.

#include <dlfcn.h>

void *handle = dlopen("./libplugin.so", RTLD_LAZY);
if (!handle) { fputs(dlerror(), stderr); exit(1); }

// 함수 포인터 형으로 심볼을 찾아 받는다
double (*cosine)(double) = dlsym(handle, "cos");
char *err = dlerror();
if (err) { fputs(err, stderr); exit(1); }

printf("cos(0) = %f\n", cosine(0.0));
dlclose(handle);

dlopenRTLD_LAZY는 실제로 심볼이 호출될 때까지 결합을 미루라는 뜻, RTLD_NOW는 즉시 모두 연결하라는 뜻이다. 링크할 땐 -ldl을 잊지 말 것.

7.12위치 독립 코드 (PIC)

같은 .so가 프로세스 A에서는 0x7f00... 주소에, 프로세스 B에서는 0x7f88... 주소에 매핑될 수 있다. 그렇다면 .so 안의 코드는 어디에 적재되든 그대로 동작해야 한다. 이를 위해 코드 안에 절대 주소를 박지 않는 위치 독립 코드(Position-Independent Code, PIC)로 컴파일한다 (-fPIC).

전략은 두 단계다.

GOT (Global Offset Table) — 데이터 간접

전역 변수에 직접 접근하는 대신, 데이터 세그먼트에 들어 있는 GOT 엔트리를 통해 한 번 우회한다. GOT는 데이터 영역에 있어서 코드와의 거리가 컴파일 타임에 알려진 상수다. 따라서 PC-상대로 GOT 엔트리에 접근할 수 있고, GOT 엔트리에는 동적 링커가 적재 시점에 진짜 주소를 채워 넣는다.

// "전역 변수 g 읽기" 의 PIC 버전 어셈블리 (개념)
movq    g@GOTPCREL(%rip), %rax   ; rax = &g  (GOT에서 주소 가져오기)
movq    (%rax), %rax             ; rax = g

PLT (Procedure Linkage Table) — 함수 간접 + 지연 바인딩

외부 함수 호출은 PLT 스텁(stub)을 거쳐 간다. PLT는 코드 영역에 있고, 각 외부 함수마다 몇 줄짜리 스텁이 있다. 처음 호출 때 스텁이 동적 링커에 일을 시키고, 동적 링커가 GOT 엔트리에 진짜 함수 주소를 적어 둔다. 다음 호출부터는 스텁이 GOT만 읽고 곧장 점프한다. 이게 지연 바인딩(lazy binding)이다.

왜 lazy?

큰 라이브러리가 수천 함수를 export 해도, 실행 중 실제로 호출되는 건 일부다. 시작할 때 모두 해석하면 시작이 느려지니, 처음 호출하는 함수만 그때그때 해석하자는 게 lazy binding의 발상이다. 보안에 민감한 환경에선 LD_BIND_NOW=1로 끄기도 한다 (Full RELRO).

7.13라이브러리 인터포지셔닝 (Library Interpositioning)

인터포지셔닝은 어떤 함수의 호출을 가로채서 내가 만든 다른 버전으로 바꿔치기하는 기술이다. 디버깅, 프로파일링, 메모리 누수 추적, 보안 후킹 — 쓸모는 끝이 없다. 시점에 따라 세 방식이 있다.

7.13.1컴파일 시 인터포지셔닝

헤더에 매크로를 박아서 전처리기 단계에 함수 호출을 wrapper로 바꾸는 방법. 소스를 다시 컴파일해야 한다.

malloc.h — 우리 헤더로 가로채기#define malloc(size) my_malloc(size)
#define free(ptr)    my_free(ptr)

void *my_malloc(size_t size);
void  my_free(void *ptr);
// 사용자 코드는 변함 없이 malloc(100); 호출하면
//  -> 전처리기가 my_malloc(100); 으로 바꿔서 우리 wrapper로 들어옴

간단하지만, 사용자 코드를 다시 컴파일할 수 있을 때만 가능하다. 그리고 매크로 치환이라 함수 포인터로 우회 호출하는 코드는 못 잡는다.

7.13.2링크 시 인터포지셔닝 (--wrap)

GNU ld가 제공하는 --wrap=foo 옵션을 쓰면, 링커는 foo에 대한 모든 참조를 __wrap_foo로 다시 묶고, 원래 foo__real_foo라는 새 이름으로 노출한다.

mymalloc.c — 링커가 wrapper로 붙여 줄 함수#include <stdio.h>
#include <stdlib.h>

void *__real_malloc(size_t size);              // 진짜 malloc

void *__wrap_malloc(size_t size) {             // 링커가 호출자를 여기로 돌림
    void *p = __real_malloc(size);
    fprintf(stderr, "malloc(%zu) = %p\n", size, p);
    return p;
}
$ gcc -c main.c mymalloc.c
$ gcc -o app main.o mymalloc.o -Wl,--wrap=malloc

소스 수정이 필요 없고, 라이브러리 정적 링크에도 잘 통한다. 단, 우리가 직접 링크하는 모듈이어야 효과가 있다.

7.13.3런타임 인터포지셔닝 (LD_PRELOAD)

가장 강력하고 무서운 방법. 환경 변수 LD_PRELOAD에 우리 .so를 박아 두면, 동적 링커가 모든 다른 라이브러리보다 먼저.so를 로드한다. 같은 이름의 심볼이 있으면 우리 것이 먼저 찾혀서 채택된다. 진짜 함수가 필요하면 dlsym(RTLD_NEXT, "malloc")으로 다음 후보(=원본)를 꺼내 쓴다.

mymalloc_preload.c — malloc 가로채기#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

void *malloc(size_t size) {
    static void *(*real_malloc)(size_t) = NULL;
    if (!real_malloc)
        real_malloc = dlsym(RTLD_NEXT, "malloc");      // 진짜 malloc 찾기

    void *p = real_malloc(size);
    fprintf(stderr, "[trace] malloc(%zu) = %p\n", size, p);
    return p;
}
$ gcc -fPIC -shared -o mymalloc.so mymalloc_preload.c -ldl
$ LD_PRELOAD=./mymalloc.so ls
[trace] malloc(120) = 0x55f...
[trace] malloc(72)  = 0x55f...
... (ls 출력)

소스도, 빌드도 안 건드린다. 임의의 기존 바이너리에 적용된다. 그래서 디버깅 도구(예: tcmalloc, jemalloc의 일부 기능)와 악성코드가 동시에 좋아하는 기술이기도 하다. setuid 바이너리에는 보안상 무시된다.

왜 dlsym에 RTLD_NEXT를?

우리 .so 안에서 real_malloc(...)를 직접 호출하면 우리 자신을 다시 부르는 무한 재귀가 된다. RTLD_NEXT는 "현재 위치 이후의 검색 순서에서 그 심볼을 찾아라"라는 의미라, 안전하게 진짜 malloc에 도달한다.

7.14오브젝트 파일 도구

리눅스 시스템에 이미 깔려 있는 도구들. 한 번씩 만져 보면 ELF가 더 이상 검은 상자가 아니게 된다.

명령용도한마디
ar정적 라이브러리(.a) 만들고 풀기ar rcs libfoo.a *.o
strings바이너리에서 사람이 읽을 만한 문자열 추출리버싱 첫걸음
strip심볼 테이블·디버그 정보 제거배포 바이너리 다이어트
nm심볼 테이블 출력"왜 undefined?" 디버깅의 단골
size섹션 크기 표시text/data/bss가 한눈에
readelfELF 헤더·섹션·세그먼트·심볼 등 거의 모든 것가장 자세한 ELF 검진 도구
objdump역어셈블, 섹션 덤프-d로 disassemble, -r로 재배치 항목
ldd실행 파일이 어떤 .so에 의존하는지"왜 안 떠?" 1차 진단
실전 워크플로

nm -u app.o로 미해결 심볼만 본다 → nm libfoo.a | grep foo로 라이브러리에 진짜 있는지 확인 → 없으면 readelf -d app | grep NEEDED로 동적 의존성을 추적. 이 세 단계만 익혀도 빌드 에러 99%는 해결된다.

7.15요약

링커가 하는 일을 한 호흡에 정리해 보자. 컴파일러 드라이버는 전처리·컴파일·어셈블·링크의 4단계를 묶어 호출한다. 앞 세 단계가 만들어 낸 재배치 가능 오브젝트 파일들을 받아, 링커는 모든 심볼 참조를 정의에 묶고(심볼 해석), 섹션을 모아 합친 뒤 코드 안의 모든 주소 자리를 채워 넣는다(재배치). 강한·약한 심볼 규칙은 무서운 미묘한 버그를 만들 수 있으니 전역 변수는 한 곳에서만 정의하는 습관을 들이는 게 좋다. 정적 라이브러리는 명령줄 순서가 중요하고, 라이브러리는 보통 끝에 둔다.

실행 시점이 되면 로더가 ELF 프로그램 헤더를 보고 가상 주소 공간에 세그먼트를 매핑하고, 동적 링커가 공유 라이브러리들을 채워 넣는다. 공유 라이브러리는 어디에 적재되든 동작하도록 PIC로 빌드되며, GOT와 PLT를 통해 데이터·함수 접근을 한 단계 우회한다. 이 우회 지점은 인터포지셔닝의 핵심 통로가 되어, 컴파일·링크·런타임 어느 시점에서든 함수 호출을 가로챌 수 있게 해 준다.

링커를 알면 빌드 에러의 절반은 메시지만 봐도 풀린다. undefined reference는 심볼 해석 실패, multiple definition은 강한 심볼 충돌, 의문의 메모리 깨짐은 약한 심볼 의심, 동적 의존성 누락은 ldd 한 방. 도구가 손에 익으면 자신감이 따라온다. 다음 장에선 흐름이 갑자기 다른 곳으로 점프하는 모든 상황 — 인터럽트, 시스템 콜, 시그널, 프로세스 — 을 다룬다.

CHECK

스스로 점검

  • R_X86_64_PC32 재배치의 공식이 왜 대상 - PC 형태인지 한 줄로 설명할 수 있는가?
  • gcc main.c -lfoogcc -lfoo main.c의 차이는 무엇인가?
  • 같은 이름의 약한 심볼이 두 파일에 있을 때 컴파일러는 경고를 줄까?
  • PIC가 GOT를 거쳐 전역 변수에 접근하는 이유는?
  • LD_PRELOAD로 만든 wrapper에서 진짜 함수를 어떻게 호출하는가?