링커(Linking): 심볼 해석, 재배치, 그리고 로딩
링킹(linking) 은 여러 개의 코드와 데이터를 모아 연결하여, 메모리에 로드되고 실행될 수 있는 하나의 파일로 만드는 작업이다. 링킹은 수행 시점에 따라 나뉜다.
| 수행 시점 | 누가 실행 | 시기 |
|---|---|---|
| 로드 타임 | 로더 | 프로그램이 메모리에 로드될 때 |
| 실행 시 | 응용 프로그램 | 프로그램 실행 중 |
1. 링커의 역할
링커는 독립적인 컴파일을 가능하게 한다. 큰 응용 프로그램을 하나의 소스 파일로 만드는 대신, 관리하기 쉬운 작은 모듈들로 나눠 별도로 컴파일·수정할 수 있게 해준다.
- 모듈 하나를 변경하면, 그 파일만 재컴파일한 뒤 다시 링크하면 된다. 나머지 파일을 재컴파일할 필요가 없다.
링킹을 왜 배워야 할까
- 큰 규모의 프로그램 개발에서 링커가 참조를 어떻게 해결하는지, 라이브러리가 무엇인지 알면 링커 에러를 해결할 수 있다
- 언어의 변수 영역 규칙(전역변수와 지역변수의 차이 등)이 어떻게 구현되는지 이해하게 된다
- 공유 라이브러리의 동작을 이해할 수 있다
2. 정적 연결 (Static Linking)
정적 링커는 재배치 가능 목적파일(.o)들 과 명령줄 인자들 을 입력으로 받아, 로드·실행 가능한 완전히 링크된 실행 파일을 출력한다. 이 과정에서 링커는 두 가지 핵심 작업을 한다.
| 작업 | 설명 | 목적 |
|---|---|---|
| 심볼 해석 (Symbol Resolution) | 목적파일들이 정의·참조하는 심볼(함수, 전역변수, static 변수)을 처리 | 각 심볼 참조를 정확히 하나의 심볼 정의에 연결 |
| 재배치 (Relocation) | 컴파일러·어셈블러가 0번지 기준으로 만든 코드·데이터 섹션을 실제 메모리 위치에 배치 | 심볼로 가는 모든 참조를 실제 메모리 위치를 가리키도록 수정 |
3. 목적파일의 세 가지 형태
| 형태 | 설명 |
|---|---|
| 재배치 가능 목적파일 (Relocatable) | 다른 재배치 가능 목적파일들과 결합해 실행 파일을 만들 수 있는 바이너리 코드·데이터. 예: .o 파일 |
| 실행 가능 목적파일 (Executable) | 메모리에 직접 복사해 바로 실행할 수 있는 형태 |
| 공유 목적파일 (Shared) | 로드 타임이나 런타임에 동적으로 링크·로드되는 특수한 재배치 가능 목적파일 |
정리하면, 컴파일러·어셈블러는 재배치 가능 목적파일을 만들고, 링커는 실행 가능 목적파일을 만든다.
4. ELF 재배치 가능 목적파일 구조
ELF(Executable and Linkable Format) 는 리눅스 같은 유닉스 시스템에서 실행 파일·오브젝트 파일에 쓰는 표준 포맷이다(예: .o, a.out).
ELF 재배치 가능 목적파일은 다음 섹션들로 구성된다.
| 섹션 | 내용 |
|---|---|
| ELF header | 파일 맨 앞의 정보 블록. “ELF 파일임”을 알리는 16바이트 배열로 시작하며, 워드 크기·바이트 순서·목적파일 타입·머신 타입·section header table 위치/크기 등을 담는다 |
| .text | 컴파일된 프로그램의 머신 코드 |
| .rodata | printf 포맷 스트링, switch 점프 테이블 등 읽기 전용 데이터 |
| .data | 초기화된 C 전역·정적 변수. 실제 값이 파일에 저장됨 (디스크 공간 차지) |
| .bss | 초기화되지 않은(또는 0으로 초기화된) 전역·정적 변수. 위치만 표시하고 실제 공간은 차지하지 않음 (디스크 공간 미차지) |
| .symtab | 프로그램에서 정의·참조하는 함수·전역변수 정보를 담는 심볼 테이블 |
| .rel.text | 링크 시 수정해야 하는 .text 내 위치 리스트 |
| .rel.data | 모듈이 정의·참조하는 전역변수에 대한 재배치 정보 |
| .debug | 디버깅 심볼 테이블 (-g 옵션 시 생성) |
| .line | C 소스 라인 번호와 .text 머신 코드의 매핑 (-g 옵션 시 생성) |
| .strtab | 심볼 테이블·섹션 헤더의 이름을 담는 스트링 테이블 (널 종료 스트링 배열) |
| Section header table | 각 섹션의 위치와 크기를 나타내는 테이블 |
.data vs .bss 짚고 가기
이 둘은 시험에 자주 나오니 예시로 구분해두자.
int a = 10; // 초기화된 전역변수 → .data (값 10이 파일에 기록됨)
int b; // 초기화 안 된 전역 → .bss (공간 미차지, 로드 시 0으로 채워짐)
static int c; // 초기화 안 된 정적 → .bss
int y = 0; // 0으로 초기화된 전역 → .bss
void func() {
int local; // 지역변수 → 스택에 들어감 (.data/.bss 아님)
}
.bss 변수에 쓰레기 값이 들어가지 않을까 걱정할 수 있는데, 운영체제가 프로그램을 메모리에 올릴 때 .bss 영역을 자동으로 0으로 채운다.
5. 실행 가능 목적파일의 로딩
실행 파일을 다음처럼 실행하면,
linux> ./prog
내부에서는 이렇게 동작한다.
prog는 내장 Shell 명령어가 아니므로, Shell은 이를 실행 가능 목적파일로 판단하고 Loader(메모리 상주 OS 코드)를 호출한다. 리눅스에서는execve함수가 Loader를 호출한다.- Loader는 디스크의 실행 파일 코드·데이터를 메모리로 복사하고, 프로그램의 첫 인스트럭션(Entry point)으로 점프해 실행을 시작한다.
- 이렇게 프로그램을 메모리로 복사하고 실행하는 과정을 로딩(Loading) 이라 한다.
리눅스 런타임 메모리 이미지

낮은 주소부터 높은 주소 순으로 정리하면 다음과 같다.
| 영역 | 위치 / 동작 |
|---|---|
| Code segment | 주소 0x400000에서 시작 |
| Data segment | code segment 뒤에 위치 (사이에 빈 공간 존재) |
| Run-time heap | data segment 다음. malloc 호출로 위로 성장 |
| 공유 라이브러리 영역 | 공유 모듈을 위해 예약된 영역 |
| 사용자 스택 | 가장 큰 합법 주소(2⁴⁸−1) 아래에서 시작해 아래로 성장 |
| 커널 영역 | 스택 위. OS 상주 부분(커널 코드·데이터)을 위해 예약 |
로더 실행 흐름
로더가 실행될 때의 workflow는 다음 단계를 거친다.
| 순서 | 단계 | 설명 |
|---|---|---|
| 1 | 로더 실행 | 실행 파일 덩어리를 가상 메모리의 code/data segment로 복사 |
| 2 | Entry point로 점프 | crt1.o의 _start 함수 실행 |
| 3 | _start → __libc_start_main 호출 | libc.so의 __libc_start_main 함수 실행 |
| 4 | __libc_start_main 동작 | 실행 환경 초기화 → 사용자 main 함수 호출 → return 값 처리 → 필요 시 제어권을 커널로 반환 |