[C/C++] C/C++ 컴파일 과정을 '조금만 더' 자세히 알아보자 (feat. guard macro)

apriljade·2022년 6월 9일
1

C언어

목록 보기
1/3

C언어를 처음 접했을 때


저는 C언어를 대학교 1학년 1학기 때 처음으로 접했습니다. 이때 #include라는 친구를 배우게 되면서 덩달아 "헤더 파일"이라는 개념을 탑재했죠. 이때 귀찮았던 것이 있는데, 헤더파일 마다 고유의 매크로를 설정해줘야 한다는 것이었습니다.

#ifndef SOME_HEADER_H
#define SOME_HEADER_H

/* ...some code here... */

#endif

상당히 귀찮았습니다. 왜 해야하느냐는 단순하게 "헤더 파일을 중복 참조하면 컴파일이 되지 않는다" 정도로 알고 넘어갔었죠. 시간이 흘러 전역을 하고 다시 C언어 공부를 하게 되었는데, 머릿속에서 "?"가 떠나가질 않았습니다. 왜 중복참조 하면 안되는걸까. 다른 언어들은 그냥 상관없던데... 제 검색실력이 부족한 탓인지 검색을 해보아도 찾아볼 수가 없었습니다. 정답에 대한 힌트는 컴파일 과정에 대해 서치를 해보다가 흭득하게됩니다. 컴파일 과정에 대해 언급하기 전에, 저렇게 중복 방지를 하는 그것을 뭐라고 하는지 설명하겠습니다!

제어 매크로, 가드 매크로


제어 매크로(controlling macro), 가드 매크로(guard macro)는 같은 의미의 매크로입니다. 글 처음에 작성되어있는 코드가 바로 그것입니다. 무엇을 제어하고 무엇을 가드하는가는 "중복 참조"입니다. 이 가드 매크로는 다양한 방법이 있는데, 제가 아는 것만 소개해보겠습니다.

Standard

#ifndef SOME_HEADER_H
#define SOME_HEADER_H

/* ...some code here... */

#endif

#pragma once

#pragma once

/* ...some code here... */

이런 방법도 있습니다. 보통 Windows환경에서 C/C++ 개발을 진행하다보면 마주치게 되거나 처음부터 이렇게 배웠을 수도 있습니다. 하지만 이는 "비표준"입니다. 만약 여러 컴파일러로 컴파일이 가능한 소스코드를 작성하고자한다면 피하시는게 좋겠죠! 컴파일러에 포함된 전처리기에 따라 컴파일이 불가능할 수 있으니 주의하라고 합니다.

#import

#import는 존재한다는 것만 알고 어떻게 쓰는지 모르고 알 필요도 없기에 간략한 설명만 하겠습니다! #import는 특이하게 MSVC와 GCC에서 용도가 다릅니다. GCC에서는 위에 소개된 두 가지와 마찬가지로 Guard Macro의 기능을 수행합니다.
반면 MSVC는 전혀다른 기능을 수행하는데, COM interface를 사용하기 위한 TLB(Type Library)를 불러오기 위해 사용합니다. 자세한 사항은 이곳에서! COM은 Component Object Model의 준말인데, COM에 대한 자세한 사항은 이곳에서 참조바랍니다!
마찬가지로 비표준이기 때문에 안쓰는게 낫습니다.

가드 매크로를 사용하는 이유


제가 글 초반에 언급했던 것이 기억나시나요? 저는 이러한 가드 매크로를 사용하는 이유가 궁금했습니다.
'중복 방지를 하려고 사용하는건데 뭐가 더 궁금한거지?' 싶으실 수 있을 것 같지만...왜 중복이 일어나는지도 궁금해서 참을 수가 없었습니다..허허
그 답은 컴파일 과정을 따라가다보면 알 수 있습니다.

컴파일 과정

원래 알고있던 컴파일 과정

제가 다니던 학교에서 배웠던 바로는 gcc 명령을 입력하면 컴파일'만'하는 것이 아니라 링커라는 친구가 링크를 해준다까지 배웠습니다.

1. 컴파일러가 소스 코드를 컴파일해준다.

이때 컴파일의 결과물은 실행 파일(Executable File)이 아니라 obejct code입니다.

2. object code들을 링커가 합체해준다.

링크의 결과 실행 파일이 생성됩니다.

사실 위의 순서는 틀렸다고는 할 수 없지만 정확하지는 않았습니다. 생략된 부분이 있더군요.

'조금 더' 자세한 컴파일 과정

1. Preprocess

전처리기라는 말을 들어보셨을 것입니다. 그것을 영어로하면 Preprocessor인데요, #define으로 정의된 매크로나 #include와 같은 것을 처리해주는 것이 전처리기입니다. 정확한 묘사로는 #으로 시작하는 구문(혹은 행)을 "전처리 지시자(Preprocess Directive)"라고 합니다. 이 전처리 과정을 거친 이후의 결과물을 Expanded Source Code라고합니다.

2. Complie

Expanded source code를 기반으로 우리 컴파일러가 컴파일을 실시합니다. 컴파일을 실시하면 Assembly code가 생깁니다. object file이 아니죠...

3. Assemble

어셈블리 코드를 Assembler가 object file로 변환해줍니다.

아니 Object file이 뭔데?
어셈블리어로 되어있는 코드던 소스코드던 우리의 컴퓨터는 실행시키지 못합니다. 컴퓨터가 실행시킬 수 있는 언어를 "기계어"라고 하는데, Object file은 기계어로된 코드입니다. Machine Code라고도 하죠.

object file을 가지고 실행 파일로 만들어주는 처리를 링크(Link)라고 하며 링크를 해주는 프로그램을 링커(Linker)라고 합니다. 왜 이름이 링크인지는 잘 모르겠습니다. 추측하기로는 복수의 object file을 연결해주기에 링크라고 하는 것 같은데... object file이 하나일 수도 있는데 왜 이름이 link인지 잘 납득이 안가네요. 아무튼! 이렇게 4가지 과정을 거치면 드디어 실행파일이 생성됩니다.

전처리 과정을 직접해보자!

실습의 경우 Unix기반 OS (eg. Mac OS, Ubuntu, ...)에서 실시합니다. Windows의 경우 Mingw를 통해 GCC를 설치해주시거나 WSL을 이용해주세요!
GCC컴파일러를 이용해서 전처리만 해보려고 합니다! 우선 실험을 진행할 폴더 아무거나 만들어주고 main.c파일을 만들어 이렇게 작성해주었습니다.

#include "./test.h"

void main(void) {
    printf("hello, world!\n");
    return 0;
}

main.c가 있는 디렉토리에 test.h라는 파일도 만들어주고 이렇게 작성했습니다.

int g_number=3000

딱 저 한줄만 작성하고 가드 매크로는 생략하고 실험을 진행하겠습니다.
터미널을 열고 디렉토리로 이동하여(visual studio code의 경우 crtl + `을 입력해주시면 터미널이 뜹니다) 다음 명령어를 실행합니다.

gcc -E main.c

-E 옵션이 바로 전처리만 하겠다는 옵션입니다. 이렇게만 실행할 경우 표준 출력으로 출력될 뿐이니 shell의 redirect기능으로 파일로 만들어봅시다.

gcc -E main.c > main.i

이렇게하면 main.i파일이 새로 생기셨을 겁니다. 바로 열어서 확인해보죠!

# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "./test.h" 1
int g_num = 3000;
# 2 "main.c" 2

void main(void) {
    printf("hello, world!\n");
    return 0;
}

보시면 뭐 어쩌고 저쩌고 써있는데 사이에 test.h에 선언해두었던 전역변수가 보이실 겁니다. 그럼 main.c파일을 이렇게 바꾸고 실행해보겠습니다.

#include "./test.h"
#include "./test.h"

void main(void) {
    printf("hello, world!\n");
    return 0;
}

네, 일부러 헤더파일을 중복참조했습니다. 중복참조이후 다시 전처리 과정을 거쳐보면

# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "./test.h" 1
int g_num = 3000;
# 2 "main.c" 2
# 1 "./test.h" 1
int g_num = 3000;
# 3 "main.c" 2

void main(void) {
    printf("hello, world!\n");
    return 0;
}

같은 변수가 두 개 생깁니다. 이번엔 test.h에 가드 매크로를 추가해서 해보겠습니다.

#ifndef TEST_H
#define TEST_H

int g_num = 3000;

#endif

test.h만 이렇게 수정하고 main.c에서 중복참조는 수정하지 않았습니다. 전처리 결과는 다음과 같습니다.

# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "./test.h" 1



int g_num = 3000;
# 2 "main.c" 2


void main(void) {
    printf("hello, world!\n");
    return 0;
}

아까와는 다르게 중복 선언이 되어있지 않습니다.

유레카! 바로 이겁니다.

C 컴파일러는 복수의 파일을 컴파일할 때 그냥 복사 붙여넣기를 때리는 것과 유사합니다. 그러니 가드 매크로 없이는 여러 헤더파일을 여러번 복사 붙여넣기를 해버리겠지요. 그 결과 컴파일 과정에서 같은 이름의 두 변수를 유일한 변수로 특정할 방법이 없게되어 컴파일을 할 수 없습니다.

이래서 중복이 발생하는 것이고 이래서 가드 매크로를 작성해주어야 했던 것입니다.

include의 문제점


include가 단순한 복사/붙여넣기였다는 것을 알아봤습니다. 그렇기 때문에 파생되는 문제점이 있지요.

일단 가드 매크로 쓰기 귀찮다.

엄청 귀찮습니다. 그나마 Windows 전용 어플리케이션을 개발할 때에는 어차피 MSVC로만 무조건 컴파일할 거고 다른 OS 생태계는 지원할 생각이 없기때문에 #pragma once를 때려박아서 편하긴한데, 그것도 귀찮기는 매한가지입니다. (저는 귀찮았어요...)

컴파일의 비효율화 그에 따른 더럽게 오래걸리는 컴파일

복사/붙여넣기를 하게되면 굉장히 무식한 일이 벌어집니다. 만들어야할 머신 코드가 하나라면 문제가 없을지도 모르지만 여러개가 되면 어떨까요?
바로 실험해봅시다. 위의 main.c, test.h와 더불어 util.c, util.h를 만들어줬습니다.

#include "./util.h"

util.c에는 이렇게 include만 해줍니다.

#ifndef UTIL_H
#define UTIL_H

int g_util = 2000;

#endif

util.h에도 전역변수 하나를 만들어줍니다.

#include "./test.h"
#include "./util.h"

void main(void) {
    printf("hello, world!\n");
    return 0;
}

main.c는 위처럼 수정해줍니다. 이렇게 되면 util.c와 main.c 두 곳에서 util.h 파일을 include합니다. 그 이후 다음 명령어를 실행해봅니다.

gcc -E main.c util.c > main.i

실행하고 main.i파일을 열어보면...

# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "./test.h" 1



int g_num = 3000;
# 2 "main.c" 2
# 1 "./util.h" 1



int g_util = 2000;
# 3 "main.c" 2

void main(void) {
    printf("hello, world!\n");
    return 0;
}
# 0 "util.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "util.c"
# 1 "./util.h" 1



int g_util = 2000;
# 2 "util.c" 2

우리 전처리기 님은 util.h를 두 번 복사합니다. 지금은 헤더파일이 작아서 그렇지 흔히 사용하는 #include <stdio.h>, #include <string.h> 등 표준 라이브러리를 사용할 때도 이렇게 복사 붙여넣기가 중복되어 실시됩니다. 라이브러리를 열어보면 헤더파일 안에서 또 다른 헤더파일을 include하고 그 헤더파일안에서 또 다른 헤더파일을 include하는 경우가 많습니다. 그럼 이 친구들이 다 전부다 일자무식하게 복사 붙여넣기를 당한다는 것입니다.

대규모 프로젝트의 빌드가 (매우)오래 걸리는 이유

그럴 일은 없겠지만 매우 극단적으로 하나의 프로그램에 c파일이 1000개 있고 그 c파일에서 전부 stdio.h를 인클루드 한다고 치면....이건 굉장히 구리다는 것을 금방 깨칠 수 있습니다. c파일 각각 stdio.h를 복사/붙여넣기를 할 것이고 그럼 전처리 후 산출물은 stdio.h가 무려 1000개가 생깁니다. 어차피 똑같은 코드인데 1000개나 중복되죠. 자연히 산출물의 크기는 쓸데없이 커지기만 하고, 복사/붙여넣기 하는 시간이 무의미하게 길어집니다. 또 이것을 파싱하고 어셈블리어로 만들고 기계어로 만들려니 매우 비효율적이죠.

이러한 이유때문에 프로젝트의 크기가 커지면 빌드가 오래걸리는 것입니다.

그럼 C파일을 하나로 만들어서 빌드하면 되지 않을까? 라고 생각하신 분이 계실 수도 있습니다. 그래서 개발된 것이 Unity build입니다. 문제는 표준이 아니고 MSVC++에만 있는 것 같습니다... Unity Build에 대해 궁금하신 분은 이곳을 참조해주세요! 그리고 incredibuild라는 것도 있는데 이는 유료인데다가 라이선스 비용이 만만치 않습니다. 이렇게 일자무식한 C/C++을 버리기엔 또 대체할 언어를 찾기란 쉽지 않습니다.

그래도 아직 절망하긴 이릅니다! 2022년의 우리는 방법이 있습니다.

C++20 Modules

저만 include가 무식하고 그다지 좋지 않다고 생각한 것은 아닌지 C++20 표준에서 모듈이라는 기능이 추가되었습니다. import, export라는 인터페이스를 가집니다. 가장 큰 특징은,

각 모듈은 "독립적"으로 컴파일 되고 "딱 한번" 컴파일 된다는 것입니다.

정확하게 include의 단점을 해소한 기술입니다! module에 대해서는 다음 기회에 자세히 살펴보도록 하겠습니다! 궁금해서 빨리 알아야겠다 싶은 분은 MSDN에 좋은 문서가 있습니다. 이 글도 너무 좋습니다.

사족..


일단 처음든 생각은 이걸 글로 남겨야겠다 싶었습니다. 글을 다 작성한 시점에는 조금은 무례할 수 있는 생각을 했는데,, "뭐야 C 컴파일러 생각보다 멍청한데...?" 싶었습니다.
저는 처음에는 컴파일을 무슨 마법이라고 생각하고 있었습니다. C언어 문법에 따라 작성한 소스코드가 컴파일 한번에 실행파일이 되어서 나오는 것이 마냥 신기했죠. 알고보니 마법은 아니었습니다. 거기다 그 마법이라고 생각했던 것은 어떻게 보면 무식한 ctrl+c/v였지요.

역시 마법이 아니었고 컴퓨터의 세상에는 마법이 없다는 것을 다시 한번 느꼈습니다. (양자컴퓨터는 마법같아요.) 더하여 이제라도 제대로 전공을 해야하나 싶기도합니다. 이렇게 지식을 쌓아가고는 있지만 전문적으로 전공을 하시는 분에게 비할 수 있을까요...

참고한 링크


아래의 글을 작성해주시어 저의 호기심 해결에 도움을 주신 모든 분들에게 감사의 말씀 전합니다.

0개의 댓글