모두의 코드
씹어먹는 C++ - <20 - 1. 코드 부터 실행 파일 까지 - 전체적인 개요>

작성일 : 2020-10-20 이 글은 18023 번 읽혔습니다.

이번 강좌에서는

  • C++ 컴파일 과정의 전체적인 개요

  • 전처리기 작동 방식

에 대해서 다루어 보겠습니다.

여태까지 C++ 을 공부하면서 수 도 없이 많은 파일들을 컴파일 하고, 실행시켜 보았겠지만, 아직까지 정확히 컴파일이 어떠한 방식으로 진행되고, 또 나의 수 많은 소스 코드들이 어떻게 합쳐지는지에 대해서는 자세히 다룬 적이 없습니다.

앞으로 연재될 3 개의 강좌들을 통해서 C++ 에서 컴파일을 한다 라는 것이 정확히 어떠한 일련의 과정으로 이루어지는 것인지, 또 어떻게 실행 파일이 생성되는 것인지에 대해서 알아보도록 하겠습니다.

C++ 의 컴파일 과정

C++ 에서 우리가 작성한 소스코드를 실제로 실행할 수 있는 실행 파일로 변환하기 위해서는 총 3 가지 단계를 거쳐야만 합니다.

  1. 먼저 #include 와 #define 와 같은 전처리기 매크로들을 처리하는 전처리(Preprocessing) 단계 (그래서 이름도 처리 단계죠).

  2. 그 뒤에 각각의 소스 파일들을 어셈블리 명령어로 변환하는 컴파일 (Compile) 단계

  3. 그 후에 어셈블리 코드들을 실제 기계어로 이루어진 목적 코드(Object file)로 변환하는 어셈블 (Assemble)단계

  4. 마지막으로 각각의 목적 코드들을 한데 모아서 하나의 실행 파일로 만들어주는 링킹 (Linking) 단계로 나누어볼 수 있습니다.

간단히 위와 같은 과정이라 보시면 됩니다.

보통 전처리 단계, 컴파일 단계, 어셈블 단계를 모두 뭉뜽그려서 컴파일 단계 하나로 생각해도 무방합니다. 즉 많은 경우 위 그림에서 a.S 나 b.S 와 같은 파일들을 생성하지 않고 바로 그냥 목적 코드로 넘어간다고 봐도 됩니다.

그렇다면 각 단계에서 어떠한 일이 일어나는지 살펴보도록 하겠습니다.

전처리 단계

전처리 단계와 컴파일 단계는 모두 컴파일러 안에서 수행되니다. C++ 표준 에 따르면, 이 두 단계는 총 8 개의 세부 단계들로 쪼개질 수 있는데, 1 부터 6 세부 단계까지가 전처리 과정으로 볼 수 있고, 나머지 세 개의 단계를 컴파일 과정으로 볼 수 있습니다. 아무튼 그렇다면 전처리 단계에서 어떠한 일이 벌어지는지 살펴봅시다.

Phase 1: 문자들 해석하기

가장 첫 번째 단계로 소스 파일에 있는 문자들을 해석하는 것입니다. 기본적으로 C++ 코드에서는 총 96 개의 문자들로 이루어진 Basic source character set 이 있는데, 이들은

  • 5 종류의 공백 문자들 (스페이스, 탭, 개행 문자 등등)

  • 10 종류의 숫자들 (0 부터 9 까지)

  • 52 종류의 알파벳 대소문자

  • 29 종류의 특수 문자들 (_, {, + 등등)

으로 구성되어 있습니다. 이 기본 문자 셋에 포함되어 있지 않은 다른 모든 문자들은 \u 를 통해 유니코드 값으로 치환되거나, 컴파일러에 의해서 따로 해석됩니다. (적어도 GCC 의 경우 유니코드를 지원하므로 따로 치환되는 것은 아닌 것 같습니다.)

Phase 2: \ 문자 해석하기

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

abc def

로 된 코드는

abcdef

로 변경된다고 보시면 됩니다.

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

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

  • 헤더 이름 (<iostream> 과 같이)

  • 식별자

  • 문자/문자열 리터럴

  • 연산자들 (+, ##)

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

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

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

int a = bar+++++baz

라는 문장이 있을 때 우리는 bar ++ + ++baz 를 의도한 것이겠지만, maximal munch 규칙에 따라 컴파일러가는 가장 긴 전처리 토큰을 구성하려고 하기 때문에 bar++ ++ +baz 로 해석되어서 컴파일 오류가 발생합니다.

마찬가지로

int bar = 0xE+foo

역시 우리는 0xE + foo 를 의도한 것이겠지만 컴파일러의 경우 0xE+ foo 로 해석해서 오류가 됩니다. 그 이유는 부동 소수점 리터럴의 경우 E 를 통해서 지수를 지정할 수 있기 때문입니다. (0xE+10 과 같은 식으로)

Phase 4: 전처리기 실행 단계

이제 전처리 토큰들로 분리하였으므로 전처리기를 실행합니다. 전처리기가 하는 일들로는

  • #include 에 지정된 파일의 내용을 복사합니다.

  • #define 에 정의된 매크로를 사용해서 코드를 치환합니다.

  • #if, #ifndef 와 같은 구문들을 실행해서 코드를 치환합니다.

  • #pragma 와 같은 컴파일러 명령문들을 해석합니다.

예를 들어서 #include<iostream> 을 하였다면 해당 부분은 iostream 헤더파일의 내용으로 치환됩니다. 실제로 아래와 같이 간단한 프로그램을 살펴봅시다.

#include <iostream>
int main() {}

실제로 전처리 단계가 끝난 이후의 컴파일러가 보는 소스 파일의 모습은 아래와 같습니다.

namespace std
{
  typedef long unsigned int size_t;
  typedef long int ptrdiff_t;
  typedef decltype(nullptr) nullptr_t;
}
namespace std
{
  inline namespace __cxx11 __attribute__((__abi_tag__ ("cxx11"))) { }
}
namespace __gnu_cxx
{
  inline namespace __cxx11 __attribute__((__abi_tag__ ("cxx11"))) { }
}

// ... (생략) ...
namespace std __attribute__ ((__visibility__ ("default")))
{
  extern istream cin;
  extern ostream cout;
  extern ostream cerr;
  extern ostream clog;
  extern wistream wcin;
  extern wostream wcout;
  extern wostream wcerr;
  extern wostream wclog;
  static ios_base::Init __ioinit;
}

int main() {
}

와 같이 생겼습니다. 그 길이는 무려 27312 줄입니다.

또다른 예시로 우리는 보통 헤더파일이 여러분 중복되어서 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 에서는 A_H 가 정의되어 있으므로 #ifndef 와 #endif 사이의 모든 내용들이 개행 문자로 치환됩니다. 따라서 그냥

class A {};

int main() {}

이렇게 됩니다.

참고로 #include 로 복사된 헤더 파일은 다시 Phase 1 부터 4 단계까지의 과정을 거칩니다. 이 과정은 소스 파일에 더이상의 전처리기문이 없을 때 까지 지속됩니다.

참고 사항

간단히 생각해봐도 이와 같은 방식은 굉장히 비효율적이라는 것을 알 수 있습니다. 앞서 본 #include <iostream> 을 포함하는 간단한 main 함수만 보아도 우리가 작성한 코드는 단 두 줄에 불과하지만, 실제로 컴파일러가 보는 코드의 길이는 대략 2만 7천줄이기 때문이죠.

이와 같은 문제를 해결 하기 위해서 미리 컴파일된 헤더 (Precompiled header) 라는 개념이 도입됬지만, 사용시에 몇 가지 제약이 있습니다.

C++ 20 에서는 모듈(module) 이라는 개념을 도입해서 이와 같은 문제를 해결할 수 있습니다. 모듈은 다른 파일의 클래스의 함수들을 참조할 수 있지만 #include 를 할 때 처럼 해당 파일의 모든 내용을 불러오지는 않습니다. 물론 아직 (2020년 10월 현재) 모듈이 정식으로 컴파일러에서 구현된 것은 아니라서 이를 사용하려면 조금 시간이 걸릴 것으로 보입니다.

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

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

Phase 6: 인접한 문자열 합치기

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

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

의 경우

std::cout << "abcdef";

로 변경됩니다.

자 여기까지가 바로 전처리기 과정이라 보시면 됩니다.

컴파일

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

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

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

참고로 이 해석 유닛은 각 소스파일 별로 하나 씩 존재하게 됩니다.

Phase 8: 인스턴스 유닛 생성 (Instantitaion Unit)

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

이 단계를 마치게 되면 컴파일러는 비로소 목적 코드를 생성할 수 있게 됩니다. 이 목적 코드는 마지막 단계인 링킹 단계를 위해서 링커로 전달됩니다.

참고 사항

여기서는 간단하게 짚고 넘어가겠지만 이 두 단계에서 정확히 무슨 일들이 벌어지는지는 다음 강의를 참조하시기 바랍니다.

링킹 (Linking)

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

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

이 두 실행 파일의 포맷이 다르기 때문에 같은 코드를 컴파일 하더라도 윈도우즈의 PE 실행 파일을 리눅스에서 (일반적으로) 실행할 수 없고, 반대로 윈도우즈에서 리눅스의 ELF 파일을 실행할 수 없습니다.

참고 사항

링커에서 정확히 어떤 일이 벌어지는지는 다음 강의 를 살펴봐주기 바랍니다.

자 그럼 이것으로 간단히 우리가 흔히 이야기 하는 컴파일 이라는 과정에서 전체적으로 일들이 일어나는지 살펴보았습니다. 다음 강의에서는 위 과정중 7,8 단계, 흔히 우리가 의미하는 컴파일 단계에서 어떠한 일들이 일어나는지 다루어보겠습니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C++ - <20 - 1. 코드 부터 실행 파일 까지 - 전체적인 개요>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 3 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..