포스트

소스 코드는 어떻게 실행 파일이 되는가 - 컴파일러와 링커

소스 코드는 어떻게 실행 파일이 되는가 - 컴파일러와 링커

앞선 글에서는 프로그램이 실행되는 전체 흐름을 살펴봤다.

이번에는 그중 앞부분인 소스 코드가 실행 파일이 되기까지를 조금 더 자세히 살펴본다.

1
2
3
4
5
6
7
8
9
Source Code
    ↓
Compiler
    ↓
Object File
    ↓
Linker
    ↓
Executable

단순한 프로그램에서는 컴파일 명령 한 번으로 실행 파일이 만들어지기 때문에 compiler와 linker가 하나의 작업처럼 보이기 쉽다.

하지만 둘은 서로 다른 일을 한다.

1. 하나의 C 프로그램

다음과 같은 프로그램이 있다고 하자.

1
2
3
4
5
6
#include <stdio.h>

int main(void) {
    printf("hello\n");
    return 0;
}

보통은 다음처럼 빌드한다.

1
gcc main.c -o main

겉으로 보면

1
2
3
4
5
main.c
  ↓
gcc
  ↓
main

으로 끝나는 것처럼 보인다.

하지만 내부에서는 여러 단계가 진행된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
Source
  ↓
Preprocessor
  ↓
Compiler
  ↓
Assembler
  ↓
Object File
  ↓
Linker
  ↓
Executable

2. 전처리

C/C++에는 컴파일 전에 처리되는 전처리 단계가 있다.

대표적인 것이 #include, #define, 조건부 컴파일이다.

1
2
#include <stdio.h>
#define SIZE 10

개념적으로는 소스 코드를 컴파일러가 처리하기 좋은 하나의 입력으로 만드는 과정이다.

GCC에서는 다음처럼 결과를 확인할 수 있다.

1
gcc -E main.c

따라서 #include는 런타임에 라이브러리를 불러오는 동작이 아니다.

컴파일 전에 헤더의 선언을 소스에 포함시키는 전처리 지시자다.

3. 컴파일

전처리가 끝난 코드는 compiler에 의해 더 낮은 수준의 표현으로 변환된다.

GCC를 사용하면 assembly 결과를 직접 볼 수도 있다.

1
gcc -S main.c

결과는 CPU 아키텍처에 따라 달라진다.

1
2
3
4
5
C Source
   ↓
Compiler
   ↓
Assembly

여기서 중요한 점은 컴파일러가 프로그램 전체를 반드시 알고 있을 필요는 없다는 것이다.

예를 들어 다음처럼 파일을 나누어 보자.

1
2
3
4
// add.c
int add(int a, int b) {
    return a + b;
}
1
2
3
4
5
6
7
8
9
// main.c
#include <stdio.h>

int add(int a, int b);

int main(void) {
    printf("%d\n", add(10, 20));
    return 0;
}

main.c를 컴파일할 때 compiler는 add()의 구현을 보지 못해도 된다.

함수의 형태만 알고 있으면 해당 함수를 호출하는 코드를 생성할 수 있다.

4. Object File

각 파일을 개별적으로 컴파일할 수 있다.

1
2
gcc -c main.c -o main.o
gcc -c add.c -o add.o

그러면 다음과 같이 된다.

1
2
main.c ──compile──> main.o
add.c  ──compile──> add.o

하지만 main.o만으로는 아직 완성된 프로그램이 아니다.

main.o에는 개념적으로 다음과 같은 정보가 존재한다.

1
2
3
4
5
main.o

main()          → 정의되어 있음
add()           → 외부에서 필요함
printf()        → 외부에서 필요함

반면 add.o에는:

1
2
3
add.o

add()           → 정의되어 있음

이 들어 있다.

즉 object file은 일부 기계어와 함께 아직 해결되지 않은 외부 참조를 가질 수 있는 중간 결과물이다.

5. Symbol

여기서 symbol이라는 개념이 중요해진다.

함수나 전역 변수처럼 다른 object file에서 참조할 수 있는 이름들이 symbol로 관리된다.

개념적으로:

1
2
3
4
5
6
7
8
9
10
main.o

Undefined Symbols
-----------------
add
printf

Defined Symbols
---------------
main

처럼 볼 수 있다.

Linux 환경에서는 nm 같은 도구로 object file의 symbol을 확인할 수 있다.

1
nm main.o

컴파일 단계에서는 add()가 어디에 구현되어 있는지 몰라도 된다.

그 이름을 나중에 linker가 해결할 수 있도록 남겨두는 것이다.

6. Linker

이제 linker가 등장한다.

1
2
3
main.o ─┐
        ├── Linker ──> Executable
add.o  ─┘

linker는 main.o가 요구하는 add symbol을 찾아 add.o의 구현과 연결한다.

1
2
3
4
5
6
7
8
9
main.o
  │
  │ needs add
  ↓
Linker
  ↑
  │ provides add
  │
add.o

하지만 여전히 printf()가 남아 있다.

printf()는 우리가 작성하지 않았다.

C 표준 라이브러리가 제공한다.

1
2
3
main.o ─────┐
add.o ──────┼── Linker ──> Executable
C Library ──┘

즉 우리가 평소 사용하는 라이브러리 역시 이 linking 과정과 관계가 있다.

7. Static Linking

필요한 라이브러리 코드를 실행 파일 안에 포함시키는 방식을 static linking이라고 한다.

개념적으로:

1
2
3
4
5
6
7
8
Application Object
       +
Static Library
       ↓
     Linker
       ↓
Executable
[필요한 Library Code 포함]

장점은 실행할 때 외부 라이브러리에 대한 의존성이 줄어든다는 것이다.

반면 여러 실행 파일이 같은 라이브러리 코드를 각각 포함하면 파일 크기가 커질 수 있다.

Unix 계열에서는 static library가 흔히 .a 형식을 사용한다.

8. Dynamic Linking

dynamic linking에서는 라이브러리 전체를 실행 파일에 복사하지 않는다.

1
2
3
4
5
Executable
    │
    │ requires shared library
    ↓
Shared Library

Linux의 .so, Windows의 .dll, macOS의 .dylib 등이 대표적이다.

이 경우 일부 연결 작업은 프로그램 실행 시점까지 이어진다.

따라서 실행 파일을 만든다고 해서 모든 코드가 반드시 그 파일 안에 들어 있는 것은 아니다.

9. Header와 Library는 다르다

C/C++를 처음 다룰 때 자주 헷갈리는 부분이다.

1
#include <stdio.h>

를 했다고 printf() 구현이 프로그램 안으로 복사되는 것이 아니다.

header는 주로 선언을 compiler에게 알려준다.

1
2
3
Header
   ↓
Compiler가 함수의 존재와 형태를 이해

실제 구현은 library에 있다.

1
2
3
Library
   ↓
Linker / Loader가 실제 구현과 연결

즉:

1
2
Header  → "이런 함수가 있다"
Library → "그 함수의 실제 구현은 이것이다"

라는 차이가 있다.

10. 왜 소스 파일을 나누어 컴파일하는가

큰 프로젝트를 생각하면 이유가 명확하다.

1
2
3
4
5
6
Project
├── main.cpp
├── network.cpp
├── parser.cpp
├── database.cpp
└── util.cpp

모든 파일을 매번 처음부터 다시 컴파일할 필요는 없다.

1
2
3
4
5
6
7
8
9
10
main.cpp     → main.o
network.cpp  → network.o
parser.cpp   → parser.o
database.cpp → database.o
util.cpp     → util.o

                 ↓
               Link
                 ↓
             Executable

변경된 소스만 다시 컴파일하고 마지막에 object file들을 다시 link할 수 있다.

CMake, Make, Ninja 같은 build system이 이런 dependency를 관리한다.

11. 플랫폼 종속성은 어디에서 들어오는가

컴파일러는 target을 기준으로 코드를 생성한다.

1
2
3
4
5
6
7
                 Same Source
                     │
        ┌────────────┼────────────┐
        ↓            ↓            ↓
   Linux x86-64  Windows x86-64  ARM64
        ↓            ↓            ↓
   Machine Code  Machine Code  Machine Code

CPU instruction set뿐 아니라 object format, ABI, library 등도 target 환경에 영향을 받는다.

따라서 같은 C/C++ 소스가 여러 운영체제에서 컴파일될 수 있다고 해서 컴파일된 object file이나 executable까지 동일한 것은 아니다.

12. WebAssembly와 연결해 보기

WebAssembly 역시 compiler target이 될 수 있다.

1
2
3
4
5
C / C++ / Rust
       │
       ├── x86-64 target ──> Native Object / Executable
       │
       └── wasm32 target ──> Wasm Object / Module

즉 기존 컴파일 과정에서 완전히 새로운 원리가 등장한 것이 아니다.

컴파일러와 linker가 최종적으로 만들어내는 target이 WebAssembly가 된 것으로 볼 수 있다.

이 때문에 C/C++나 Rust의 기존 build model을 이해하면 WebAssembly가 훨씬 자연스럽게 보인다.

정리

전체 흐름을 다시 보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
Source Code
    ↓
Preprocessor
    ↓
Compiler
    ↓
Assembler
    ↓
Object File
    ↓
Linker
    ↓
Executable

그리고 핵심 역할을 구분하면:

1
2
3
4
5
6
7
8
9
10
11
Compiler
  = 하나의 소스 코드를 낮은 수준의 코드로 변환

Object File
  = 아직 연결이 끝나지 않은 중간 바이너리

Symbol
  = 코드와 데이터를 서로 연결하기 위한 이름

Linker
  = 여러 object와 library의 symbol을 해결하여 하나의 프로그램으로 연결

여기까지가 프로그램을 만드는 과정이다.

하지만 실행 파일이 만들어졌다고 프로그램이 실행된 것은 아니다.

다음 단계에서는 운영체제가 이 실행 파일을 어떻게 읽고 메모리에 올려 process로 만드는지, 즉 executable과 loader의 관계를 살펴본다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.