프로그램이 실행 가능한 형태가 되기까지의 전체 과정을 먼저 이해하는 것이 중요하다.
소스 코드 (.c)
│
▼
┌─────────────────────────┐
│ Translators (번역기) │
│ ┌───────────────────┐ │
│ │ C Preprocessor │ │ ← cpp: #include, #define 등 전처리
│ │ (cpp) │ │
│ ├───────────────────┤ │
│ │ C Compiler │ │ ← cc1: C 코드 → 어셈블리 코드로 변환
│ │ (cc1) │ │
│ ├───────────────────┤ │
│ │ Assembler │ │ ← as: 어셈블리 코드 → 기계어(오브젝트 파일)로 변환
│ │ (as) │ │
│ └───────────────────┘ │
└─────────────────────────┘
│
▼
오브젝트 파일 (.o) ← 재배치 가능 오브젝트 파일 (Relocatable Object File)
│
▼
┌─────────────────────────┐
│ Linker (링커, ld) │ ← 여러 .o 파일을 하나로 합침
└─────────────────────────┘
│
▼
실행 파일 (executable) ← 완전히 링크된 실행 가능 오브젝트 파일
│
▼
┌─────────────────────────┐
│ Loader (로더) │ ← 실행 파일을 메모리에 적재
└─────────────────────────┘
│
▼
프로세스 이미지 (Process Image, 메모리에 올라간 상태)
예시:
unix> gcc -O2 -g -o myprog m.c a.c # 컴파일 + 링킹
unix> ./myprog # 로딩 + 실행
이 명령은 내부적으로 m.c → m.o, a.c → a.o로 각각 컴파일한 뒤, 링커(ld)가 m.o와 a.o를 합쳐서 실행 파일 myprog를 만든다.

오브젝트 파일(.o)은 컴파일러와 어셈블러가 생성하는 중간 결과물이다. 다음 내용을 포함한다:
gcc -g 옵션으로 생성되는 디버깅용 정보오브젝트 파일의 내용을 어떤 구조로 정리하느냐를 오브젝트 포맷(Object Format)이라 한다.
| 포맷 | 설명 |
|---|---|
| COFF (Common Object File Format) | 과거 Unix 시스템에서 사용 |
| ELF (Executable and Linking Format) | 현재 Linux/Unix 표준 |
| PE (Portable Executable) | Windows에서 사용 (.exe, .dll) |
중요: 서로 다른 포맷은 호환되지 않는다. ELF 포맷의 오브젝트 파일을 PE 포맷의 링커로 링크할 수 없다.

ELF는 오브젝트 파일의 표준 바이너리 포맷이다. AT&T의 System V Unix에서 유래했으며, 이후 BSD Unix, Linux 등에 널리 채택되었다.
ELF는 하나의 통합 포맷으로 세 종류의 파일을 모두 표현한다:
| 파일 종류 | 확장자 | 설명 |
|---|---|---|
| 재배치 가능 오브젝트 파일 | .o | 컴파일 결과, 아직 주소 미확정 |
| 실행 가능 오브젝트 파일 | (없음) | 링킹 완료, 바로 실행 가능 |
| 공유 오브젝트 파일 | .so | 동적 라이브러리 (Windows의 .dll에 해당) |

같은 ELF 파일이라도 누가 읽느냐에 따라 해석이 다르다:
┌────────────────────┐ ┌────────────────────┐
│ Linkable File │ │ Executable File │
│ (링커가 보는 관점) │ │ (로더가 보는 관점) │
├────────────────────┤ ├────────────────────┤
│ ELF Header │ │ ELF Header │
├────────────────────┤ ├────────────────────┤
│ Program Header │ │ Program Header │
│ Table (optional) │ │ Table (필수!) │
├────────────────────┤ ├────────────────────┤
│ Section 1 data │ │ Segment 1 data │
│ Section 2 data │ │ ... │
│ ... │ │ Segment n data │
│ Section n data │ ├────────────────────┤
├────────────────────┤ │ Section Header │
│ Section Header │ │ Table (optional) │
│ Table (필수!) │ └────────────────────┘
└────────────────────┘

핵심 차이: Section은 링킹을 위한 논리적 단위이고, Segment는 로딩을 위한 물리적 단위이다. 하나의 세그먼트에 여러 섹션이 합쳐질 수 있다.

┌──────────────────────────┐
│ ELF Header │ ← 매직넘버, 파일 타입, 아키텍처, 바이트 순서 등
├──────────────────────────┤
│ Program Header Table │ ← 실행 파일에 필수. 세그먼트 정보 (페이지 크기, 가상주소 등)
├──────────────────────────┤
│ .text section │ ← 기계어 코드 (실행할 명령어들)
├──────────────────────────┤
│ .data section │ ← 초기화된 전역/정적 변수 (예: int x = 15;)
├──────────────────────────┤
│ .bss section │ ← 초기화되지 않은 전역/정적 변수 (예: int y;)
├──────────────────────────┤
│ .symtab │ ← 심볼 테이블 (함수명, 변수명의 이름과 위치)
├──────────────────────────┤
│ .rel.text │ ← .text 섹션의 재배치 정보
├──────────────────────────┤
│ .rel.data │ ← .data 섹션의 재배치 정보
├──────────────────────────┤
│ .debug │ ← 디버깅 정보 (gcc -g 옵션 사용 시)
├──────────────────────────┤
│ Section Header Table │ ← 재배치 가능 파일에 필수.각 섹션의 위치/크기 정보
└──────────────────────────┘
.text 섹션 (코드)
컴파일된 기계어 명령어가 저장되는 곳이다. CPU가 실제로 실행하는 바이너리 코드이다.
.data 섹션 (초기화된 데이터)
초기값이 있는 전역 변수와 정적 변수가 저장된다.
int e = 7; // .data에 저장 (초기값 7이 실제로 파일에 기록됨)
int x = 15; // .data에 저장
.bss 섹션 (초기화되지 않은 데이터)
초기화되지 않은 전역/정적 변수가 여기에 속한다.
int y; // .bss에 저장
핵심: .bss 섹션은 디스크의 오브젝트 파일에서 실제 공간을 차지하지 않는다. 어차피 초기값이 없으므로 "여기에 변수가 이만큼 있다"는 크기 정보만 기록하면 된다 Section Haeder Table각 section에 대한 엔트리를 가지고 있는데 .bss 엔트리에는 이 섹션의 크기는 몇 byte라는 sh_size 필드가 있다. sh_offset이 가리키는 곳에 실제 데이터는 없다.프로그램이 메모리에 로드될 때 OS가 0으로 초기화해준다. 이것이 "Save Space"의 의미이다.

.symtab 섹션 (심볼 테이블)
함수와 전역/정적 변수의 이름, 타입, 크기, 위치(오프셋) 정보를 담고 있다. 링커가 심볼 해석(symbol resolution)을 할 때 사용한다.
.rel.text 섹션 (코드 재배치 정보)
.text 섹션에서 외부 함수를 호출하거나 전역 변수를 참조하는 명령어의 위치를 기록한다. 링커가 최종 주소를 결정한 후, 이 위치의 주소값을 수정해야 한다.
예: call a() 명령어에서 a의 주소가 아직 모르므로 임시 값을 넣어두고, 재배치 엔트리에 "여기를 나중에 고쳐라"고 기록한다.
.rel.data 섹션 (데이터 재배치 정보)
.data 섹션에서 다른 심볼의 주소를 초기값으로 사용하는 전역 변수의 위치를 기록한다.
extern int e;
int *ep = &e; // ep의 초기값이 e의 주소 → e의 최종 주소가 정해져야 ep 값도 결정됨
이 경우 ep가 .data 섹션에 저장되는데, 그 값(&e)은 링킹 전에는 알 수 없으므로 재배치 엔트리가 필요하다.
.debug 섹션
gcc -g 옵션으로 컴파일할 때 생성된다. 소스 코드의 줄 번호와 기계어 주소의 매핑 등 디버거(gdb 등)가 사용하는 정보가 들어있다.


여러 개의 재배치 가능 오브젝트 파일(.o)을 하나의 실행 파일로 합치는 과정이다. 각 오브젝트 파일의 같은 타입 섹션들이 합쳐진다.
object file A object file B object file C Output
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────ㄴ──────┐
│ .text │ │ .text │ │ .text │ │ .text │ ← A + B + C의 코드 합침
├──────────┤ ├──────────┤ ├──────────┤ ├──────────────┤
│ .data │ │ .data │ │ .data │ │ .data │ ← A + B + C의 데이터 합침
├──────────┤ ├──────────┤ ├──────────┤ ├──────────────┤
│ .bss │ │ .bss │ │ .bss │ │ .bss │ ← A + B + C의 bss 합침
└──────────┘ └──────────┘ └──────────┘ └──────────────┘

두 소스 파일 m.c와 a.c:
// m.c // a.c
int e = 7; extern int e;
int f = 10; int *ep = &e;
int main() { int x = 15;
int g; int y;
int r = a(); int a() {
g = r * f; return *ep + x + y;
exit(0); }
}

이 두 파일이 각각 m.o와 a.o로 컴파일된 후 링커에 의해 합쳐지면:
m.o a.o 실행 파일 (Executable)
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│ .text: │ │ .text: │ │ system code │
│ main() │ │ a() │ │ main() │ ← m.o의 .text
├──────────┤ ├──────────┤ │ a() │ ← a.o의 .text
│ .data: │ │ .data: │ ├──────────────────┤
│ int e=7 │ │ *ep=&e │ │ system data │
│ │ │ int x=15 │ │ int e=7 │ ← m.o의 .data
└──────────┘ ├──────────┤ │ int *ep=&e │ ← a.o의 .data
│ .bss: │ │ int x=15 │
│ int y │ ├──────────────────┤
└──────────┘ │ int y │ ← a.o의 .bss
└──────────────────┘



링커의 작업은 크게 2단계로 나뉜다.

심볼(Symbol)이란 함수와 변수의 이름이다. 각 심볼은 값(보통 메모리 주소)을 가진다.
코드에는 두 종류의 심볼 관계가 존재한다:
// m.c // a.c
int e = 7; ← e의 정의 extern int e; ← e의 외부 참조
int main() { ← main의 정의 int *ep = &e; ← ep의 정의, e의 외부 참조
int r = a(); ← a의 외부 참조 int x = 15; ← x의 정의
exit(0); ← exit의 외부 참조 int y; ← y의 정의
} int a() { ← a의 정의
return *ep + x + y; ← ep, x, y의 로컬 참조
}
심볼 해석이란: 링커가 각 심볼 참조를 정확히 하나의 심볼 정의와 연결하는 과정이다.
예를 들어:
m.c에서 a()를 호출하는데, a의 정의는 a.c에 있다 → 링커가 이 두 개를 연결a.c에서 extern int e로 참조하는 e는 m.c에서 정의되어 있다 → 링커가 연결exit()는 C 표준 라이브러리(libc)에 정의되어 있다 → 링커가 라이브러리에서 찾아서 연결심볼 정의 정보는 컴파일러가 .symtab 섹션에 저장한다. 구조체 배열 형태이다.
m.o의 심볼 테이블 (readelf -s m.o로 확인):
| Num | Value | Size | Type | Bind | Name |
|---|---|---|---|---|---|
| 7 | 0000 | 4 | OBJECT | GLOBAL | e |
| 8 | 0000 | 52 | FUNC | GLOBAL | main |
| 9 | 0000 | 0 | NOTYPE | GLOBAL | a (UND) |
| 10 | 0000 | 0 | NOTYPE | GLOBAL | exit (UND) |
a.o의 심볼 테이블:
| Num | Value | Size | Type | Bind | Name |
|---|---|---|---|---|---|
| 7 | 0000 | 4 | OBJECT | GLOBAL | ep |
| 8 | 0000 | 0 | NOTYPE | GLOBAL | e (UND) |
| 9 | 0004 | 4 | OBJECT | GLOBAL | x |
| 10 | 0000 | 27 | FUNC | GLOBAL | a |
| 11 | 0000 | 4 | OBJECT | GLOBAL | y (COM → .bss) |
주목할 점: e는 m.o에서 정의(GLOBAL, 섹션 3)이고, a.o에서 미정의(UND)이다. 링커는 a.o의 e 참조를 m.o의 e 정의와 매칭시킨다.


재배치는 두 가지 작업을 수행한다:
(1) 섹션 병합: 각 오브젝트 파일의 같은 타입 섹션들을 하나로 합친다.
(2) 주소 재배치: 각 오브젝트 파일은 주소 0부터 시작하므로, 합치면 주소 범위가 겹친다. 링커가 모든 심볼에 겹치지 않는 최종 절대 주소를 할당하고, 코드 내의 모든 참조를 새 주소로 수정한다.
링킹 전 (unlinked modules) 링킹 후 (linked modules)
모듈 A: 0~275 0~275: 모듈 A
주소 100에 call 100 (모듈A 내부) call 100 → 그대로 (내부 참조)
주소 200에 call 200 (모듈A 내부) call 200 → 그대로
모듈 B: 0~175 275~450: 모듈 B
주소 100에 call 100 (모듈B 내부) call 100 → call 375 (275+100)
모듈 C: 0~200 450~650: 모듈 C
주소 150에 call 150 (모듈C 내부) call 150 → call 600 (450+150)
핵심: 각 모듈이 주소 0부터 시작하던 것이, 합쳐지면서 모듈 B는 275부터, 모듈 C는 450부터 시작하게 된다. 모듈 B 내부의 call 100은 원래 모듈 B의 주소 100을 의미했으므로, 절대 주소로는 275 + 100 = 375가 된다.

어셈블러는 링커에게 "이 위치의 주소를 나중에 수정해라"라고 알려주는 재배치 엔트리를 생성한다.
typedef struct {
int offset; // 수정해야 할 위치 (섹션 내 오프셋)
int symbol; // 어떤 심볼의 주소로 수정할 것인지
int type; // 재배치 타입 (절대주소 vs 상대주소)
} Elf32_Rel;

// m.c
int e = 7;
int main() {
int r = a(); // ← a의 주소 필요
exit(0); // ← exit의 주소 필요
}
디스어셈블한 결과:
00000000 <main>:
0: 55 pushl %ebp
1: 89 e5 movl %esp, %ebp
3: e8 fc ff ff ff call 4 <main+0x4> ← a()를 호출하는 명령어
4: R_386_PC32 a ← 재배치 엔트리: 오프셋 0x4에 a의 주소를 넣어라
8: 6a 00 pushl $0x0
a: e8 fc ff ff ff call b <main+0xb> ← exit()을 호출하는 명령어
b: R_386_PC32 exit ← 재배치 엔트리: 오프셋 0xb에 exit의 주소를 넣어라
f: 90 nop
e8은 call 명령의 opcode(1바이트)이고, 뒤따르는 4바이트가 호출 대상의 주소 오프셋이다. 현재 fc ff ff ff는 임시 값이며, 링커가 최종 주소로 교체한다.
재배치 타입:
R_386_PC32: PC 상대 주소(relative) — call 명령어 등에서 사용. 현재 PC 위치 기준으로 상대적 거리를 계산R_386_32: 절대 주소(absolute) — 전역 변수 참조 등에서 사용. 심볼의 최종 절대 주소를 직접 삽입
// a.c
extern int e;
int *ep = &e;
int x = 15;
int y;
int a() {
return *ep + x + y;
}
.text 섹션의 재배치 정보:

00000000 <a>:
1: 8b 15 00 00 00 00 movl 0x0, %edx ← ep의 주소 필요
3: R_386_32 ep ← 절대 주소로 ep 위치 채워야 함
7: a1 00 00 00 00 movl 0x0, %eax ← x의 주소 필요
8: R_386_32 x
...
12: 03 05 00 00 00 00 addl 0x0, %eax ← y의 주소 필요
14: R_386_32 y
현재 모든 주소가 00 00 00 00으로 비어있다. 링커가 ep, x, y의 최종 주소를 결정한 후 이 위치에 채워 넣는다.
.data 섹션의 재배치 정보:

00000000 <ep>:
0: 00 00 00 00 ← e의 주소가 들어갈 자리
0: R_386_32 e ← ep의 초기값으로 e의 절대 주소 필요
00000004 <x>:
4: 0f 00 00 00 ← 15 (0x0f)가 이미 들어있음 (x = 15는 상수이므로 재배치 불필요)
ep의 값은 &e인데, e의 최종 주소는 링킹 전까지 모르므로 임시로 0을 넣어둔다.

링커가 모든 재배치를 완료한 실행 파일의 .text 섹션:
08048530 <main>:
8048533: e8 08 00 00 00 call 8048540 <a> ← a()의 최종 주소 0x8048540
계산: 0x8048538 + 0x08 = 0x8048540
804853a: e8 35 ff ff ff call 8048474 ← exit()의 최종 주소
08048540 <a>:
8048541: 8b 15 1c a0 04 08 movl 0x804a01c, %edx ← ep의 최종 주소
8048547: a1 20 a0 04 08 movl 0x804a020, %eax ← x의 최종 주소
8048552: 03 05 d0 a3 04 08 addl 0x804a3d0, %eax ← y의 최종 주소
00 00 00 00이었던 자리가 모두 실제 주소로 채워졌다.

여러 오브젝트 파일을 합칠 때, 각 오브젝트 파일은 모두 주소 0부터 시작한다. 그대로 합치면 코드와 데이터의 주소 범위가 겹친다. 재배치는 모든 코드와 데이터에 겹치지 않는 주소를 할당하는 과정이다.
프로그램이 여러 서브프로그램으로 나뉘어 작성되면, 한 서브프로그램에서 다른 서브프로그램의 함수나 변수를 이름(심볼)으로 참조한다. 심볼 해석은 링커가 이 이름을 최종 실제 주소로 교체하는 과정이다.

링킹이 완료된 실행 파일이 실행될 때, 로더(Loader)가 파일을 메모리에 적재한다.
실행 파일 (디스크) 프로세스 이미지 (메모리)
┌──────────────────┐ ┌──────────────────┐
│ ELF Header │ │ │ 0x080483e0
│ Program Header │ │ init and shared │
│ .text section │ ─────────► │ lib segments │
│ .data section │ ├──────────────────┤ 0x08048494
│ .bss section │ │ .text segment │ (read-only)
│ .symtab │ ├──────────────────┤ 0x0804a010
│ .rel.text │ │ .data segment │ (initialized, r/w)
│ .rel.data │ ├──────────────────┤ 0x0804a3b0
│ .debug │ │ .bss segment │ (uninitialized, r/w)
│ Section Header │ └──────────────────┘
└──────────────────┘
Program Header Table이 "어떤 섹션들을 어떤 가상 주소에 로드할 것인가"를 지정한다. 로더는 이 정보를 읽고 각 세그먼트를 지정된 가상 주소에 매핑한다.
참고: .symtab, .rel.text, .rel.data, .debug 등의 섹션은 메모리에 로드되지 않는다. 이들은 링킹이나 디버깅에만 필요한 정보이다.

정적 라이브러리(.a 파일)는 링킹 시점에 실행 파일에 통째로 복사된다.
단점 3가지:
디스크 낭비: 모든 프로그램이 C 표준 라이브러리를 필요로 하는데, 각 실행 파일마다 동일한 라이브러리 코드가 복사되어 저장된다.
메모리 낭비: 여러 프로세스가 동시에 실행되면, 각 프로세스의 가상 메모리 공간에 동일한 라이브러리 코드가 중복으로 올라간다.
업데이트 어려움: 라이브러리에 버그 수정이 있으면, 그 라이브러리를 사용하는 모든 프로그램을 다시 링킹해야 한다.

공유 라이브러리(.so 파일, Windows에서는 .dll)는 이러한 단점을 해결한다.
정적 링킹: 동적 링킹:
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Program X │ │ Program Y │ │ Program X │ │ Program Y │
├───────────┤ ├───────────┤ └─────┬─────┘ └─────┬─────┘
│ Static │ │ Static │ │ │
│ Libraries │ │ Libraries │ └──────┬───────┘
│ (*.a) │ │ (*.a) │ │
└───────────┘ └───────────┘ ┌────────┴────────┐
각각 라이브러리 복사본 보유 │ Shared Libraries │
│ (*.so) │
└─────────────────┘
하나만 공유해서 사용

공유 라이브러리는 런타임(실행 시점)에 메모리에 로드되고 링크된다.
m.c ──► m.o ──┐
│ Linker (ld) Partially linked
a.c ──► a.o ──┤──────────────────► executable p ──────┐
│ (디스크에 저장) │
libc.so ──────┘ │
(공유 라이브러리 ▼
참조 정보만 기록) Loader / Dynamic Linker
(ld-linux.so)
│
▼
Fully linked executable p'
(메모리에서 완성)
핵심 과정:
컴파일 타임: 링커는 공유 라이브러리의 코드를 실행 파일에 복사하지 않는다. "이 프로그램이 libc.so의 함수를 사용한다"는 참조 정보만 기록한다. 따라서 실행 파일은 부분적으로만 링크(partially linked)된 상태이다.
로드 타임: 프로그램을 실행하면 로더가 실행 파일을 메모리에 올리면서, 동적 링커(ld-linux.so)가 필요한 공유 라이브러리를 메모리에 로드하고 최종 링킹을 완료한다.
런타임 동적 링킹: dlopen() 함수를 사용하면 프로그램 실행 도중에도 공유 라이브러리를 로드할 수 있다. 이는 플러그인 시스템이나 고성능 웹 서버 등에 활용된다.
unix> ld -shared -fPIC libc.so atoi.c printf.c ... random.c
-shared: 공유 라이브러리로 생성-fPIC: Position Independent Code (위치 독립 코드) 생성 — 어느 가상 주소에 로드되어도 동작하도록 만든다메모리 공유: 공유 라이브러리의 코드(.text)는 물리 메모리에 한 번만 로드되고, 여러 프로세스가 이를 공유한다. 각 프로세스의 페이지 테이블이 같은 물리 프레임을 가리키는 방식이다.

컴파일 → 링킹 단계 상세:
m.c ──► Translators ──► m.o ──┐
│
├──► Linker (ld) ──► 실행 파일 p
│
a.c ──► Translators ──► a.o ──┘
링커 내부에서 일어나는 일: