C++에서 작성한 소스코드를 실행 가능한 실행 파일로 변환하기 위해서는, 일련의 4단계를 따른다.
- 먼저 #include / #define 같은 전처리기 매크로들을 처리하는 전처리( Preprocessing ) 단계
- 각각의 소스 파일을 어셈블리 명령어로 변환하는 컴파일( Compile ) 단계
- 어셈블리 코드들을 실제 기계어로 이루어진 목적 코드( Object File ) 로 변환하는 어셈블( Assemble ) 단계
- 마지막으로 각각의 목적 코드들을 한데 모아서 하나의 실행 파일로 만들어주는 링킹( Linking )단계로 나누어 진다.
대부분 전처리 단계 - 컴파일 단계 - 어셈블 단계를 모두 합쳐 컴파일 단계 하나로 생각해도 무방하다. 즉, 많은 경우 어셈블 명령어 같은 파일을 생성하지 않고 바로 목적 코드로 넘어간다.
전처리 단계
전처리 단계와 컴파일 단계는 모두 컴파일러 안에서 수행된다. C++ 표준에 따르면, 이 두 단계는 총 8단계의 세부 단계로 나뉜다. 1~6단계 까지를 전처리, 나머지 과정을 컴파일 과정으로 볼 수 있다.
1단계 : 문자들 해석
첫 번째 단계는 소스 파일에 있는 문자들의 해석이다. 기본적으로 C++ 코드에서는 총 96개의 문자들로 이루어진 Basic Source Character Set이 있는데 이 Set에 해당되지 않는 다른 모든 문자들은 \u를 통해 유니코드 값으로 치환되거나, 컴파일러에 의해서 따로 해석된다.
* 5 종류의 공백 문자들 ( 스페이스, 탭, 개행 문자 등 )
* 10 종류의 숫자들 ( 0부터 9까지 )
* 52 종류의 알파벳 대소문자
* 29 종류의 특수 문자들 ( $, %, # 등 )
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++' 카테고리의 다른 글
[C++] string 타입 문자열을 Split (분할)하기 (0) | 2023.12.06 |
---|---|
[C++] 배열을 함수의 매개변수로 사용 시 주의점 (0) | 2023.11.15 |
[C++] 두 배열을 비교할 수 있는 함수 equal (0) | 2023.11.06 |
[C++] 재귀함수 응용 (0) | 2023.10.31 |
[C++] tuple (튜플) 사용법 & 예제 (0) | 2023.10.23 |