1. 문제 제기 (Introduction & Problem Statement)
관찰 현상 또는 질문:
Java와 Spring으로 개발할 때는javac로 컴파일하고java명령어로 실행하면 그만이었다. 하지만 C/C++ 프로그램을 빌드하면서 "왜 gcc는 컴파일과 링킹을 분리하는가?", "같은 이름의 전역 변수가 여러 파일에 있으면 어떻게 되는가?", "정적 라이브러리(.a)와 동적 라이브러리(.so)는 어떻게 다르게 처리되는가?" 같은 질문들이 생겼다.탐구 목표:
본 글에서는 컴파일 이후 단계인 링킹(Linking)과 로딩(Loading) 과정을 단계별로 분석한다. 특히 (1) 링커가 여러 목적 파일(.o)의 심볼을 어떻게 해석하고 충돌을 해결하는지, (2) 재배치(Relocation)를 통해 실제 메모리 주소가 어떻게 확정되는지, (3) 최종적으로 실행 파일이 메모리에 로드되어 런타임 이미지를 구성하는 과정을 기술적으로 탐구한다.
2. 기술 분석 및 핵심 원리 (Technical Deep Dive)
2-1. 심볼 해석(Symbol Resolution): 같은 이름의 전역 변수, 어떻게 연결할 것인가?
링커는 여러 모듈(.o 파일)을 하나로 합칠 때, 동일한 이름의 전역 변수가 여러 곳에 존재할 수 있다는 문제에 직면한다. 리눅스 링커는 이를 규칙 기반으로 해결한다.
심볼의 강도 분류:
- Strong 심볼: 함수 정의, 초기화된 전역 변수 (예:
int x = 5;) - Weak 심볼: 초기화되지 않은 전역 변수 (예:
int x;)
링커의 심볼 해석 규칙:
- Rule 1: Strong 심볼이 2개 이상이면 → 링크 에러 발생 (Multiple definition error)
- Rule 2: Strong 심볼 1개, Weak 심볼 여러 개 → Strong 심볼 선택
- Rule 3: Weak 심볼만 여러 개 → 그 중 아무거나 선택 (경고 발생 가능)
이 규칙은 Java의 클래스 로딩과는 다른 방식이다. Java는 동일한 Fully Qualified Class Name이 클래스패스에 중복되면 먼저 발견된 클래스를 로드하고, 이후 발견된 클래스는 무시한다. 반면 C 링커는 위와 같은 명시적 규칙으로 충돌을 해결한다.
2-2. 정적 라이브러리(.a)와 심볼 참조
printf, atoi 같은 표준 함수들은 정적 라이브러리(Static Library)로 제공된다. 리눅스에서는 .a 확장자를 사용하며, 이는 여러 .o 파일들을 하나로 묶은 아카이브 파일이다.
정적 라이브러리의 링킹 특징:
- 링커는 실행 파일에 참조되는 심볼이 포함된 .o 모듈만 라이브러리에서 추출하여 실행 파일에 복사한다.
- 사용하지 않는 함수는 실행 파일에 포함되지 않아 파일 크기를 최적화할 수 있다.
- 하지만 여러 프로그램이 같은 라이브러리를 사용하면, 각 실행 파일에 중복으로 코드가 포함되어 디스크와 메모리 낭비가 발생한다.
이 문제는 동적 라이브러리(.so, .dll)를 통해 해결되며, 이는 Java의 .jar 파일이 런타임에 클래스패스를 통해 로드되는 방식과 유사하다.
2-3. 재배치(Relocation): 링커가 실제 주소를 확정하는 단계
컴파일 단계에서 각 .o 파일의 코드와 데이터는 상대 주소(0부터 시작)로만 존재한다. 링커는 재배치 단계에서 다음 작업을 수행한다.
재배치 과정:
- 섹션 병합: 모든
.o파일의.text섹션을 하나로 합치고,.data,.bss섹션도 각각 하나로 통합한다. - 주소 확정: 각 심볼(전역 변수, 함수)에 고유한 런타임 주소를 할당한다.
- 참조 업데이트: 코드 내의 심볼 참조 위치를 찾아 실제 주소로 채워 넣는다.
재배치가 완료되면 실행 파일(Executable Object File)이 생성된다.
2-4. 실행 파일(.exe, ELF)과 목적 파일(.o)의 차이점
| 구분 | 목적 파일(.o) | 실행 파일 |
|---|---|---|
| 엔트리 포인트 | 없음 | 프로그램 시작 지점(_start) 명시 |
| 재배치 정보(.rel) | 포함됨 (링커가 사용) | 삭제됨 (이미 재배치 완료) |
| 초기화 섹션(.init) | 없음 | 런타임 초기화 코드 포함 |
| 프로그램 헤더 | 없음 | 메모리 로딩 정보 포함 |
실행 파일의 프로그램 헤더 예시:
- Code Segment (Read-Only): ELF 헤더 +
.init+.text+.rodata→ 메모리0x400000번지에 읽기 전용으로 로드 - Data Segment (Read/Write):
.data+.bss→ 그 뒤쪽(0x600df8)에 읽기/쓰기 가능으로 로드
2-5. 로딩(Loading) 과정: 실행 파일이 메모리에서 프로세스가 되기까지
운영체제의 로더(Loader)는 실행 파일을 메모리에 적재하여 프로세스로 만든다.
로딩 단계:
execve시스템 콜: 로더는execve함수를 통해 호출된다.- 메모리 초기화: 현재 프로세스의 가상 메모리 공간(코드, 데이터, 스택)을 비운다.
- 세그먼트 매핑:
- 실행 파일의 프로그램 헤더 테이블을 읽어서 파일 내의 코드/데이터 조각을 메모리의 약속된 주소에 매핑한다.
- 실제 물리적 복사는 CPU가 해당 주소를 접근할 때 페이지 폴트(Page Fault)가 발생하면서 일어난다. (Demand Paging)
- 진입점 점프: ELF 헤더에 명시된 엔트리 포인트(
_start)로 점프한다._start→__libc_start_main→main순서로 호출되어 우리의 코드가 실행된다.
Java/Spring과의 비교:
- Java에서는 JVM이
.class파일을 로드할 때,ClassLoader가 바이트코드를 메모리에 적재하고 메서드 영역(Method Area)에 배치한다. - C의 로더는 OS 커널이 직접 수행하지만, Java의 클래스 로딩은 JVM 내부에서 이루어진다는 점이 다르다.
2-6. 런타임 메모리 이미지 (Runtime Memory Image)
로딩이 완료되면 프로세스의 가상 메모리는 다음과 같은 표준 구조를 가진다.
프로세스 가상 메모리 레이아웃 (위에서 아래로):
┌─────────────────────────────┐ 높은 주소 (0xFFFFFFFF)
│ Kernel Memory │ ← OS 커널 전용, 유저 접근 불가
├─────────────────────────────┤
│ User Stack (↓ 자람) │ ← 함수 호출 스택
├─────────────────────────────┤
│ Shared Library Region │ ← printf 등 공유 라이브러리
├─────────────────────────────┤
│ Heap (↑ 자람) │ ← malloc 동적 메모리
├─────────────────────────────┤
│ Read/Write Segment │ ← .data, .bss (전역 변수)
├─────────────────────────────┤
│ Read-Only Segment │ ← .text, .rodata (코드, 상수)
└─────────────────────────────┘ 낮은 주소 (0x400000)각 영역의 역할:
- Kernel Memory: OS 커널 코드와 데이터 (사용자 프로세스는 접근 불가)
- User Stack: 함수 호출 시 지역 변수, 리턴 주소, 레지스터 저장 (아래로 성장)
- Shared Library Region: 동적 라이브러리(
.so) 매핑 영역 - Heap:
malloc/free로 관리되는 동적 메모리 (위로 성장) - Data Segment: 초기화된 전역 변수(
.data), 초기화되지 않은 전역 변수(.bss) - Code Segment: 실행 코드(
.text), 읽기 전용 데이터(.rodata)
Spring/JVM 메모리 구조와의 비교:
| C 프로세스 메모리 | JVM Heap 영역 |
|---|---|
| Heap (malloc) | Young Generation + Old Generation |
| Stack (함수 호출) | JVM Stack (메서드 호출 프레임) |
| Code Segment (.text) | Method Area (클래스 메타데이터, 바이트코드) |
| Data Segment (.data) | Static 변수는 Method Area에 저장 |
3. 결론 및 고찰 (Conclusion & Takeaways)
핵심 요약:
- 링커는 심볼 해석(Symbol Resolution) 단계에서 Strong/Weak 규칙을 통해 중복 심볼 충돌을 해결한다.
- 재배치(Relocation)를 통해 링커는 모든 코드와 데이터에 실제 런타임 주소를 할당하고, 실행 파일에는 재배치 정보가 삭제된다.
- 로더는
execve시스템 콜을 통해 실행 파일의 세그먼트를 프로세스 가상 메모리에 매핑하며, Demand Paging으로 필요한 시점에만 물리 메모리에 로드한다. - 최종적으로 프로세스는 Code Segment, Data Segment, Heap, Stack, Shared Library Region, Kernel Memory로 구성된 런타임 메모리 이미지를 가진다.
기술적 통찰 및 나의 생각:
Java 개발자로서 JVM의 클래스 로딩과 가비지 컬렉션에만 익숙했는데, C의 링킹/로딩 과정을 학습하면서 "프로그램이 실행된다"는 것의 의미를 OS 레벨에서 이해하게 되었다. 특히 Java의ClassLoader가 수행하는 역할이 C에서는 링커와 로더로 분리되어 있고, JVM이 관리하는 Heap과 Stack이 실제로는 OS가 제공하는 가상 메모리 공간의 일부라는 사실이 흥미로웠다.또한 Spring의 빈(Bean) 컨테이너가 객체 참조를 주입(DI)하는 과정이, 링커가 심볼 참조를 실제 주소로 바인딩하는 과정과 개념적으로 유사하다는 점을 깨달았다. "참조를 해석하고 연결한다"는 본질은 같지만, C는 컴파일/링크 타임에, Spring은 런타임에 이를 수행한다는 차이가 있다.
향후 과제 / 추가 질문:
- 동적 링킹(
.so,.dll)은 런타임에 어떻게 심볼을 해석하는가? Position Independent Code(PIC)와 GOT/PLT의 동작 원리는? - Java의
ClassLoader계층 구조(Bootstrap, Extension, Application)와 C의 정적/동적 링킹 전략을 비교하면 어떤 트레이드오프가 있을까? - 프로세스의 가상 메모리와 물리 메모리 간의 매핑은 페이지 테이블을 통해 어떻게 관리되는가? (8장 예외적인 제어 흐름에서 계속)
- 동적 링킹(
'Study > CSAPP' 카테고리의 다른 글
| [CSAPP] 정적/동적 라이브러리와 Java 동적 로딩: DLL, JNI, AOP까지 (0) | 2025.12.10 |
|---|---|
| [CSAPP] 빌드 시스템의 핵심 '링킹(Linking)': 코드가 실행 파일이 되기까지의 여정 (0) | 2025.11.26 |
| [CSAPP] 메모리 계층과 지역성: CPU–메모리 갭을 줄이는 하드웨어·소프트웨어 전략 (0) | 2025.11.18 |
| [CSAPP] 컴파일러는 왜 내 코드를 최적화하지 못할까?: 데이터 의존성과 분기 예측의 비밀 (0) | 2025.11.12 |
| [CSAPP] Y86-64 ISA의 순차적 구현: SEQ 프로세서 동작 원리 분석 (0) | 2025.11.11 |