1.1 C++에서 빌드는 어떻게 이루어지는가

SeungHee Yun·2023년 7월 20일
0

전문가를 위한 C++

목록 보기
1/15

개요

C++에서 작성한 소스코드를 실행 가능한
실행 파일로 변환하기 위해서는, 일련의 4단계를 따른다.

  1. 먼저 #include / #define 같은 전처리기 매크로들을 처리하는
    전처리( Preprocessing ) 단계
  2. 각각의 소스 파일을 어셈블리 명령어로 변환하는 컴파일( Compile ) 단계
  3. 어셈블리 코드들을 실제 기계어로 이루어진 목적 코드( Object File ) 로 변환하는
    어셈블( Assemble ) 단계
  4. 마지막으로 각각의 목적 코드들을 한데 모아서 하나의 실행 파일로 만들어주는
    링킹( Linking )단계로 나누어 진다.

대부분 전처리 단계 - 컴파일 단계 - 어셈블 단계를 모두 합쳐
컴파일 단계 하나로 생각해도 무방하다.
즉, 많은 경우 어셈블 명령어 같은 파일을 생성하지 않고
바로 목적 코드로 넘어간다고 생각해도 무방하다.


전처리 단계

전처리 단계와 컴파일 단계는 모두 컴파일러 안에서 수행됩니다.
C++ 표준에 따르면, 이 두 단계는 총 8단계의 세부 단계로 나뉩니다.
1~6단계 까지를 전처리, 나머지 과정을 컴파일 과정으로 볼 수 있습니다.

1단계 : 문자들 해석

첫 번째 단계는 소스 파일에 있는 문자들의 해석입니다.
기본적으로 C++ 코드에서는 총 96개의 문자들로 이루어진
Basic Source Character Set이 있는데,

* 5 종류의 공백 문자들 ( 스페이스, 탭, 개행 문자 등 )
* 10 종류의 숫자들 ( 0부터 9까지 )
* 52 종류의 알파벳 대소문자
* 29 종류의 특수 문자들 ( $, %, # 등 )

으로 구성되어 있습니다. 이 Set에 해당되지 않는 다른 모든 문자들은 \u를 통해
유니코드 값으로 치환되거나, 컴파일러에 의해서 따로 해석됩니다.

2단계 : \ 문자 해석하기

만약에 백슬래시 ( \ ) 문자가 문장 맨 끝 부분에 위치해있다면,
해당 문장과 바로 다음에 오는 문장이 하나로 합쳐지고 개행 문자는 삭제 됩니다.

abc def
->
abcdef

3단계 : 전처리 토큰들로 분리하기

소스 파일을 주석 ( Comment ), 공백 문자,
전처리 토큰 ( Preprocessing token ) 들로 분리하는 단계입니다.
전처리 토큰은 C++에서 가장 기본적인 문법 요소로,
후에 컴파일러가 사용하는 컴파일러 토큰의 근간이 됩니다.
아래 해당 하는 것들이 전처리 토큰에 포함됩니다.

* 헤더이름 ( <iostream>과 같이 )
* 식별자
* 문자/문자열 리터럴
* 연산자를 ( +, ## )

이 단계에서 raw string literal을 확인하여,
만일 1~2단계를 거치며 해당 문자열 안의 내용이 바뀌었다면 그 변경은 취소됩니다.

또한 주석은 모두 공백 문자 하나로 변경됩니다.

참고로 컴파일러가 전처리기 토큰을 인식할 때에는 가능한 긴 전처리 토큰을 만드려고 합니다.
이러한 규칙을 Maxiamal Munch라고 부릅니다. 예를 들어

int a = bar+++++baz;

라는 문장이 있을 때, 우리는

bar++ + ++baz

를 의도한 것이겠지만, Maximal Munch 규칙에 따라 컴파일러는

bar++ ++ +baz

로 해석되어 컴파일 오류가 발생합니다.

마찬가지로

int bar = 0xE+foo;

역시 우리는

0xE + foo

를 의도한 것이겠지만, 컴파일러의 경우

0xE+ foo

로 해석하여 오류가 발생합니다. 그 이유는, 부동 소수점 리터럴의 경우
E를 통해서 지수를 지정할 수 있기 때문입니다. (0xE+10 등..)

4단계 : 전처리기 실행 단계

전처리 토큰들로 분리하였으므로, 전처리기를 실행합니다.

* #include에 지정된 파일의 내용을 복사
* #define에 정의된 매크로를 사용해서 코드를 치환
* #if, #ifndef와 같은 구문을 실행해서 코드를 치환
* #pragma와 같은 컴파일러 명령문들을 해석

또한, 보통 헤더파일이 여러번 중복되어 include 되더라도
한 번만 포함이 되게 아래와 같은 헤더 가드(Header guard)를 작성합니다.

#ifndef A_H
#define A_H

class A{};
#endif

위와 같은 헤더 가드가 작동하는 이유는 예를 들어서

#include "a.h"
#include "a.h"

int main{}

을 하더라도, 전처리기에 의해서

#ifndef A_H
#define A_H

class A {};
#endif
#ifndef A_H
#define A_H

class A {};
#endif
int main() {}

와 같이 변경되지만, 두 번째 ifndef에서는 이미 A_H가 정의되어 있기 때문에,
#ifndef와 #endif 사이의 모든 내용들이 개행 문자로 치환됩니다. 따라서,

class A {};
int main() {}

로 바뀌게 됩니다.

참고

간단히 생각해봐도 매우 비효율적입니다. #include을 포함하는 간단한 main 함수라도
실제 컴파일러가 보는 코드의 길이는 2만 7천줄이기 때문입니다.
이와 같은 문제 해결을 위해, 미리 컴파일된 헤더(Precompiled header)라는 개념이 있지만, 사용시에 제약이 있습니다.

C++20에서는 모듈(module)이라는 개념을 도입해서 이와 같은 문제의 해결이 가능합니다.
하지만 2020년을 기준으로 모듈이 정식적으로 컴파일러에 구현된 것은 아니기 때문에,
이를 사용하려면 시간이 필요할 것입니다.

5단계 : 실행문자 셋으로 변경하기

모든 문자들은 이전의 소스 코드 문자 셋에서
실행 문자 셋( Execution character set )의 문자들로 변경됩니다.
마찬가지로 이전의 Escaped된 문자들도 실행 문자 셋의 문자들로 변경 됩니다.

6단계 : 인접한 문자열 합치기

이 단계에선 인접한 문자열들이 하나로 합쳐집니다.

std::cout << "abc"
             "def";

의 경우

std::cout<< "abcdef";

로 변경됩니다.

여기까지가 전처리기 과정 이라 생각하면 됩니다.


컴파일

전처리기 과정이 끝나고 나면 실제 컴파일 과정이 수행됩니다.
컴파일 과정에서는 앞서 생성되었던 전처리기 토큰들을 바탕으로
실제 컴파일 토큰을 생성하여 분석합니다.

7단계 : 해석 유닛 생성( Translation Unit )

이 단계에서 우리가 소위 말하는 컴파일이 이루어집니다.
전처리기 토큰들이 컴파일 토큰으로 변환이 되고, 컴파일 토큰들은 컴파일러에 의해 해석되어
해석 유닛( Translation Unit - 줄여서 보통 TU )을 생성하게 됩니다.
참고로 이 해석 유닛은 각 소스파일 별로 하나씩 존재하게 됩니다.

8단계 : 인스턴스 유닛 생성( instantiation Unit )

컴파일러는 생성된 TU를 분석하여 필요로 하는 템플릿 인스턴스들을 확인합니다.
템플릿들의 정의 위치가 확인이 되면 해당 템플릿들의 인스턴스화가 진행되고
이를 통해 인스턴스 유닛이 생성됩니다.

이 단계를 마치게 되면 컴파일러는 비로소 목적 코드를 생성할 수 있게됩니다.


링킹( Linking )

마지막으로 링킹 단계에서는 컴파일러가 생성한 목적 파일들과
외부 라이브러리 파일들을 모아서 실행 파일을 생성합니다.

이 링킹 과정이 끝나면, 사용하는 시스템에 따라서
각기 다른 형태의 파일들을 생성하게 됩니다.
윈도우즈 계열에서 주로 사행하는 실행 파일 형태는 Portable Executable 이라 불리는
PE 파일 형식의 파일을 생성하고 ( .exe ), 리눅스 계열의 시스템의 경우 Executable and Linkable Format, 흔히 ELF라 불리는 형태의 실행 파일을 생성합니다.


참조 : 모두의 코드 : 씹어먹는 C++


profile
Enthusiastic Game Developer

1개의 댓글

comment-user-thumbnail
2023년 7월 20일

정말 유익한 글이었습니다.

답글 달기