cs

[크래프톤 정글] 컴파일러 와 링커(CSAP 7.1장)*

하루이2222 2024. 10. 11. 19:47

컴파일러 드라이버는 여러 단계를 통해 소스 코드를 실행 파일로 변환하는데,
각 단계는 서로 밀접하게 연결되어 있으며, 이 과정에서 각각의 파일이 중간 산출물로 생성된다.

1. 전처리 단계 (Preprocessing)

전처리 단계에서는 전처리기(cpp)가 동작하여 소스 파일의 전처리 지시문을 처리한다. 이는 컴파일 프로세스의 첫 단계로, #include, #define, #if, #ifdef와 같은 전처리 지시문을 해결한다.

주요 작업:

  • 헤더 파일 확장: #include 지시문에 의해 포함된 헤더 파일들은 소스 파일에 삽입된다. 예를 들어, #include <stdio.h>는 해당 헤더 파일의 전체 내용을 소스 코드에 삽입하는 과정이다.
  • 매크로 확장: #define으로 정의된 매크로들은 실제 코드에서 사용될 때 해당 값으로 대체된다. 예를 들어, #define MAX 100이 있으면, 코드에서 MAX를 사용할 때마다 100으로 변환된다.
  • 조건부 컴파일: #ifdef, #ifndef, #if 등의 지시문에 따라 특정 조건에 맞는 코드만 컴파일되도록 한다. 전처리기에 의해 조건이 평가되고, 조건이 참인 경우에만 해당 코드가 남는다.
  • 주석 제거: 전처리 단계에서는 주석도 제거된다. 이 주석 제거는 소스 코드의 가독성을 높이기 위한 작업이므로 컴파일러 입장에서는 불필요하다.

전처리 예제

1.1 소스 코드

#include <stdio.h>    // 표준 입력/출력 라이브러리 포함
#define PI 3.14       // 매크로 정의
#define DEBUG 1       // 디버그 모드 활성화

int main() {
    #ifdef DEBUG
        printf("Debug mode is ON\n");
    #endif
        float radius = 5.0;
        float area = PI * radius * radius;
        printf("Area of circle: %f\n", area);

    return 0;
}

1.2 전처리 단계의 주요 작업

  1. 헤더 파일 포함 (#include)
    • #include 지시문은 전처리 과정에서 표준 라이브러리나 다른 헤더 파일을 소스 코드에 삽입한다. 예를 들어, 위 코드에서 #include <stdio.h>는 C 표준 라이브러리의 stdio.h 파일을 코드 상단에 삽입하는 역할을 한다. 이 파일에는 printf와 같은 입출력 함수를 사용하는 데 필요한 선언들이 들어 있다.
    • * stdio.h의 일부 * int printf(const char *format, ...); <- 전처리 결과는 다음과 같은 코드로 변환된다. 실제 stdio.h 파일의 내용은 수백 줄이 넘을 수 있으므로 여기서는 일부만 보여준다.
  2. 매크로 확장 (#define)
    • #define 지시문은 매크로를 정의하고, 이를 코드에서 사용하는 부분을 해당 값으로 치환한다. 예를 들어, #define PI 3.14는 코드에서 PI가 사용될 때마다 3.14로 치환한다.또한 #define DEBUG 1도 마찬가지로 코드에서 DEBUG가 등장할 때마다 1로 치환된다.
      • float area = 3.14 * radius * radius;
  3. 조건부 컴파일 (#ifdef)
    • 조건부 컴파일 지시문인 #ifdef는 전처리기에서 특정 조건을 만족하는 경우에만 해당 코드를 컴파일하도록 한다. 예제 코드에서는 #ifdef DEBUG라는 조건부 컴파일이 있다.
    • 이 지시문은 DEBUG가 정의되어 있는 경우에만 printf("Debug mode is ON\n");가 컴파일된다.
    • 만약 #define DEBUG 1이 없었더라면, 이 코드는 전처리 과정에서 무시되었을 것이다.만약 #define DEBUG이 없었다면, 전처리기는 이 부분을 다음과 같이 처리했을 것이다:
    • printf("Debug mode is ON\n");
    #include <stdio.h> 
    #define PI 3.14 
    #define DEBUG 1
    
    int main() { 
        #ifdef DEBUG 
    
          printf("Debug mode is ON\n"); 
    
        #endif float radius = 5.0; 
    
        float area = PI * radius * radius; 
        printf("Area of circle: %f\n", area); 
        return 0; 
    }
  1. 주석 제거
    전처리기는 주석도 제거한다. 코드의 가독성을 높이기 위한 주석은 컴파일 단계에서는 필요하지 않기 때문에 전처리 과정에서 모두 제거된다. 예제에서 표준 입력/출력 라이브러리 포함`과 같은 주석은 전처리 후에 코드에서 사라진다.

1.3 전처리 완료 후의 결과

전처리가 끝나면 최종 결과물은 모든 전처리 지시문이 해결된 상태로 C 소스 코드로 변환된다. 실제로 전처리된 코드는 다음과 같은 형태로 나온다:

int printf(const char *format, ...);   // stdio.h에서 포함된 선언

int main() {
    printf("Debug mode is ON\n");      // DEBUG가 정의되어 있어 출력
    float radius = 5.0;
    float area = 3.14 * radius * radius; // PI가 3.14로 치환
    printf("Area of circle: %f\n", area);

    return 0;
}

2 컴파일 단계 (Compilation)

컴파일 단계에서는 전처리된 소스 파일을 컴파일러(cc1)가 어셈블리 코드로 변환한다.
이 단계는 소스 코드의 고수준 구조(변수 선언, 함수 정의 등)를 저수준 명령어(어셈블리어)로 변환하는 핵심 단계이다.

주요 작업:

  • 구문 분석 (Parsing): 컴파일러는 소스 코드를 토큰(token) 단위로 분석한다. 토큰은 변수명, 연산자, 함수명 등 소스 코드의 각 구성 요소를 의미하며, 이를 기반으로 소스 코드를 구문 트리(AST)로 변환한다.
  • 구문 트리 생성: 구문 분석이 완료되면, 소스 코드의 논리 구조를 나타내는 구문 트리(AST)가 생성된다. 이 트리는 프로그램의 흐름과 구조를 표현한다.
  • 중간 표현(IR): 구문 트리를 바탕으로 중간 표현(Intermediate Representation, IR)이라는 일종의 추상 명령어로 변환된다. 이 단계에서 프로그램의 흐름과 명령어들이 좀 더 저수준 형태로 변환되며, 최적화를 적용할 수 있다.
  • 최적화: 중간 표현 단계에서는 다양한 최적화가 적용된다. 예를 들어, 불필요한 연산을 제거하거나, 상수 전파(Constant Propagation)를 통해 상수를 미리 계산하는 등 코드의 효율성을 높인다.
  • 어셈블리 코드 생성: 최적화된 중간 표현을 바탕으로 실제 CPU 명령어에 가까운 어셈블리 코드로 변환된다. 어셈블리 코드는 CPU의 명령어 세트에 맞춰 생성되며, 프로그램이 실제로 어떻게 동작할지 기술한다.

컴파일 단계는 전처리된 C 코드가 어셈블리 코드로 변환되는 과정이다. 이 단계에서 고수준의 C 코드가 저수준 명령어로 변환되며, 프로그램의 논리적 구조가 CPU가 직접 이해할 수 있는 형식에 가까워진다. 이제 컴파일 단계의 주요 작업을 예제를 통해 구체적으로 알아보자.

컴파일 단계 예제

2.1 전처리된 코드

먼저 전처리 과정을 거친 C 코드가 아래와 같이 존재한다고 가정하자.
이 코드는 gcc -S 명령어로 컴파일할 때 어셈블리 코드로 변환된다.

int printf(const char *format, ...);   // stdio.h에서 포함된 선언

int main() {
    float radius = 5.0;
    float area = 3.14 * radius * radius;
    printf("Area of circle: %f\n", area);

    return 0;
}

2.2 컴파일 단계의 주요 작업

컴파일 단계는 크게 두 가지 주요 작업으로 나눌 수 있다

  1. 구문 분석 및 중간 표현 생성
  2. 어셈블리 코드 생성이다

2.3 구문 분석 및 중간 표현 생성

컴파일러는 먼저 전처리된 C 코드를 분석하여 소스 코드를 여러 토큰으로 나눈 후,
구문을 분석해 추상 구문 트리(AST, Abstract Syntax Tree)를 생성한다.
이 과정은 프로그램의 구조를 이해하고, 논리적 흐름을 파악하는 과정 이라 볼 수 있다.

2.4 구문 분석 (Parsing)

구문 분석은 프로그램의 각 요소(변수, 함수, 제어문 등)를 토큰으로 분리하고 이를 바탕으로 코드의 논리적 구조를 파악한다. 예를 들어:

  • int main()은 함수 선언으로 분석된다.
  • float radius = 5.0;는 변수 선언으로 분석된다.
  • float area = 3.14 * radius * radius;는 수식으로 분석된다.

컴파일러는 이를 기반으로 추상 구문 트리(AST) 를 만든다.
이 트리는 프로그램의 구조를 트리 형태로 표현한 것이다. 이를 통해 각 변수, 함수, 연산자의 관계가 정의된다.

2.5 중간 표현(IR, Intermediate Representation)

구문 트리가 생성된 후, 컴파일러는 이를 중간 표현(Intermediate Representation)이라는 저수준 명령어 형태로 변환한다. 중간 표현은 CPU 명령어보다는 추상적이지만, 코드 최적화나 변환을 수행하기 쉬운 형태이다. 중간 표현 단계에서는 프로그램의 흐름을 최적화할 수 있는 여러 작업이 적용된다.

예를 들어, float area = 3.14 * radius * radius;는 중간 표현으로 다음과 같이 변환될 수 있다:

tmp1 = radius * radius
tmp2 = tmp1 * 3.14
area = tmp2

이와 같은 변환을 통해 각 연산의 중간 결과를 효율적으로 관리하고, 이후 최적화 작업을 수행하기 쉽게 할 수 있다.

2.6 어셈블리 코드 생성

최적화된 중간 표현을 바탕으로, 컴파일러는 CPU가 이해할 수 있는 어셈블리 코드를 생성한다. 이 어셈블리 코드는 CPU 명령어에 더 가까운 저수준의 명령어로 구성되며, 프로그램의 각 연산이 어떻게 수행되는지를 설명한다.

어셈블리 코드 예시

컴파일된 결과물은 CPU의 명령어에 맞춰 변환된다. 예를 들어, 위의 C 코드가 x86-64 아키텍처에서 어셈블리 코드로 변환된 예시는 다음과 같다:

main:
    subq    $16, %rsp        # 스택 포인터를 16바이트 만큼 감소
    movss   $5.0, -4(%rsp)   # radius에 5.0을 저장
    movss   -4(%rsp), %xmm0  # xmm0에 radius를 로드
    mulss   %xmm0, %xmm0     # radius * radius (제곱)
    movss   %xmm0, %xmm1     # 중간 결과를 xmm1에 저장
    mulss   $3.14, %xmm1     # xmm1 * 3.14 (원 넓이 계산)
    movss   %xmm1, -8(%rsp)  # area에 결과 저장
    movss   -8(%rsp), %xmm0  # printf를 위한 준비
    movq    $.LC0, %rdi      # 형식 문자열 ("Area of circle: %f") 준비
    movq    $1, %rax         # printf 호출을 위한 준비
    call    printf           # printf 함수 호출
    addq    $16, %rsp        # 스택 복구
    ret                      # 함수 종료

2.7 어셈블리 코드 설명

  • 스택 관리: 함수의 시작에서 스택 포인터(%rsp)를 16바이트 감소시키는 것은 지역 변수를 위한 메모리를 할당하는 작업이다.
  • 변수 저장: radiusarea는 스택에 저장되며, 어셈블리 명령어 movss로 해당 값이 메모리에서 레지스터로, 또는 레지스터에서 메모리로 이동된다.
  • 부동 소수점 연산: 부동 소수점 연산은 xmm0, xmm1 같은 SIMD 레지스터에서 수행된다. 예를 들어 mulss 명령어는 두 레지스터의 값을 곱한다.
  • printf 호출: printf 함수는 레지스터에 필요한 값들을 적절히 설정한 후, call printf 명령어로 호출된다.

3 어셈블리 단계 (Assembly)

어셈블리 단계에서는 어셈블러(as)가 어셈블리 코드를 이진 명령어로 변환하여 재배치 가능한 오브젝트 파일을 생성한다. 오브젝트 파일은 CPU가 실제로 이해할 수 있는 기계어 명령어로 구성되며, 프로그램 실행 시 동적으로 재배치될 수 있다.

주요 작업:

  • 어셈블리 명령어 변환: 어셈블러는 각 어셈블리 명령어를 대응하는 기계어 명령어로 변환한다. 예를 들어, mov 명령어는 CPU에서 사용할 수 있는 적절한 이진 명령어로 변환된다.
  • 기호 테이블 생성: 각 함수와 변수에 대한 주소를 추적하는 기호(Symbol) 테이블이 생성된다. 이 테이블은 링커가 다른 오브젝트 파일과 결합할 때 참조할 수 있는 중요한 정보이다.
  • 섹션 분할: 오브젝트 파일은 여러 섹션으로 나뉜다. 일반적으로 .text 섹션에는 실행할 코드가, .data 섹션에는 초기화된 전역 변수가, .bss 섹션에는 초기화되지 않은 변수가 저장된다.

결과물은 .o 확장자를 가진 오브젝트 파일이다.

as main.s -o main.o

4. 링킹 단계 (Linking)

링킹 단계는 여러 오브젝트 파일을 결합하여 하나의 실행 가능한 프로그램을 만드는 중요한 과정이다. 이 단계에서 링커(ld)는 프로그램이 참조하는 모든 함수와 변수를 올바르게 연결하고, 필요에 따라 외부 라이브러리를 포함시킨다. 링킹 과정은 프로그램이 실제로 실행될 수 있도록 프로그램의 구조를 완성한다.

주요 작업:

  1. 심볼 결합:
    • 각 오브젝트 파일은 심볼 테이블(symbol table)을 포함하고 있다. 이 심볼 테이블은 함수와 변수들의 정의와 참조 정보를 가지고 있다. 링커는 오브젝트 파일들 간에 서로 참조하는 심볼들을 결합하여 함수와 변수가 적절히 연결되도록 한다.
    • 예를 들어, main.o에서 sum() 함수를 호출하고 sum.o에서 이 함수가 정의된 경우, 링커는 main.o에서 sum()을 참조하는 부분을 sum.o에 정의된 실제 함수 주소로 대체한다.
  2. 라이브러리 연결:
    • 프로그램이 사용하는 외부 라이브러리(예: C 표준 라이브러리 libc)도 이 단계에서 결합된다. 링커는 해당 라이브러리에서 참조되는 함수(예: printf, malloc 등)를 찾아서 오브젝트 파일과 연결한다.
    • 만약 프로그램이 동적 라이브러리(예: .so 파일)를 사용하는 경우, 링커는 실행 시 라이브러리를 로드할 수 있도록 참조를 남긴다. 반면 정적 라이브러리(예: .a 파일)를 사용하는 경우, 필요한 함수들이 실행 파일에 직접 포함된다.
  3. 주소 재배치:
    • 링커는 각 함수와 변수가 실제 메모리에서 실행될 주소를 결정한다. 오브젝트 파일은 재배치 가능 상태(relocatable object)로 저장되어 있기 때문에, 실제 실행 파일을 생성할 때 각 심볼의 주소가 확정된다.
    • 예를 들어, main.o에서 참조된 sum.o의 함수가 실행 파일에서 어느 위치에 존재할지를 링커가 결정하고, 그에 따라 주소 값을 갱신한다.
  4. 섹션 결합:
    • 각각의 오브젝트 파일에는 여러 섹션이 존재한다. 예를 들어, .text 섹션은 실행할 코드, .data 섹션은 초기화된 전역 변수, .bss 섹션은 초기화되지 않은 변수들이 포함된다. 링커는 이 섹션들을 결합하여 최종 실행 파일의 메모리 레이아웃을 구성한다.
  5. 최종 실행 파일 생성:
    • 모든 오브젝트 파일과 라이브러리가 결합되고, 심볼과 메모리 주소가 재배치되면, 링커는 최종 실행 파일을 생성한다. 이 파일은 운영 체제에 의해 실행될 수 있는 상태로, 실행 시 프로그램의 첫 번째 명령어가 실행되도록 준비된다.

링커의 명령어 사용 예시:

ld main.o sum.o -o prog

이 명령어는 main.osum.o라는 두 오브젝트 파일을 링크하여, prog라는 실행 파일을 생성한다. 링커는 각 오브젝트 파일의 심볼을 분석하고 결합하여 프로그램의 전체 구조를 완성한 후, 최종 실행 가능한 파일을 생성한다.

실제 링킹 과정:

  • 링커는 먼저 main.osum.o에서 심볼 테이블을 확인한다. main.o에서 sum() 함수를 참조하고 있음을 확인한 후, sum.o에서 해당 함수의 정의를 찾아 이를 결합한다.
  • 이후, main()sum() 함수들이 실제로 실행될 메모리 주소를 계산하여 프로그램의 메모리 레이아웃을 구성한다.
  • 프로그램에서 사용하는 C 표준 라이브러리(libc)의 함수들, 예를 들어 printf() 함수도 함께 링크된다.

결과물:

링킹 단계가 완료되면, 결과물은 실행 가능한 바이너리 파일이 된다. 보통 확장자는 .out 또는 사용자가 명시적으로 지정한 이름을 따른다. 이 파일은 운영 체제에 의해 로드되고, 프로그램이 실행될 준비가 완료된다.

추가 정보:

만약 프로그램이 동적 라이브러리(.so)를 참조하는 경우, 링커는 해당 참조를 남기고, 프로그램 실행 시점에 동적 라이브러리가 메모리에 로드되도록 한다. 반대로 정적 라이브러리(.a)는 링킹 시점에 이미 바이너리 안에 포함되어, 실행 파일만으로 프로그램이 동작할 수 있다.

플로우 요약

컴파일러 드라이버는 소스 코드를 실행 파일로 변환하는 데 여러 단계를 거치며, 각 단계에서 중간 파일들이 생성 되게 되는게 이 전체 과정을 요약 하자면 다음과 같다.


1. 전처리 (Preprocessing)

주요 작업:

  • 헤더 파일 포함 (#include)
  • 매크로 확장 (#define)
  • 조건부 컴파일 (#ifdef, #ifndef)
  • 주석 제거

결과물:

  • 확장된 소스 파일 (.i 또는 .cpp 파일): 모든 전처리 지시문이 처리되고, 헤더 파일이 포함된 상태의 소스 코드 파일이 생성된다.

파일 예시:

  • file1.i, file2.i, file3.i

2. 컴파일 (Compilation)

주요 작업:

  • 구문 분석 (Parsing)
  • 추상 구문 트리(AST) 생성
  • 중간 표현(IR) 생성
  • 최적화
  • 어셈블리 코드 생성

결과물:

  • 어셈블리 파일 (.s 파일): 최적화된 소스 코드가 어셈블리 코드로 변환된다.

파일 예시:

  • file1.s, file2.s, file3.s

3. 어셈블리 (Assembly)

주요 작업:

  • 어셈블리 코드를 이진 명령어로 변환
  • 기호(Symbol) 테이블 생성
  • 섹션 분할 (.text, .data, .bss)

결과물:

  • 재배치 가능한 목적 파일 (.o 파일): 기계어로 변환된 상태로, 다른 오브젝트 파일과 결합 가능하다.

파일 예시:

  • file1.o, file2.o, file3.o

4. 링킹 (Linking)

주요 작업:

  • 심볼 결합 (기호 테이블 연결)
  • 라이브러리 연결
  • 주소 재배치
  • 섹션 결합

결과물:

  • 실행 파일 (예: .out 또는 .exe 파일): 모든 오브젝트 파일이 결합되고, 심볼이 해결된 최종 실행 파일.

파일 예시:

  • prog.out

심볼 테이블 (Symbol Table)과 재배치 가능 목적 파일

.o 파일은 심볼 테이블을 포함하고 있으며, 이 심볼 테이블은 함수와 변수가 어디에서 정의되고 어디에서 참조되는지를 추적한다.

예를 들어, main.c, sum.c, util.c 파일이 있는 경우:

  • main.o에서는 main() 함수가 정의되고, sum() 함수가 참조될 수 있다.
  • sum.o에서는 sum() 함수가 정의되고, util() 함수가 참조될 수 있다.
  • util.o에서는 util() 함수가 정의된다.

심볼 테이블에서 링커는 각 오브젝트 파일이 참조하는 심볼과 정의된 심볼을 결합하여, 프로그램이 정상적으로 실행되도록 심볼들의 주소를 확정한다.


이렇게 각 단계에서 파일이 생성되고, 링킹 과정에서 모든 오브젝트 파일이 결합되어 최종 실행 가능한 파일이 만들어진다.