저자: 백창우
출처: 유닉스 리눅스 프로그래밍 필수 유틸리티: vi, make, gcc, gdb, cvs, rpm(한빛미디어, 2004)
컴파일을 잘하기 위해서는 단순히 컴파일러의 옵션 몇 개를 더 안다고 잘하게 되는 것은 아니다. 컴파일을 잘하기 위해서는 컴파일 과정이 어떻게 이루어지는가에 대해서 반드시 이해해야 한다. 필자가 이번 장에서 컴파일 과정을 알려주고자 하는 이유는 컴파일 과정을 이해하면 C 소스가 컴파일되지 않을 때 문제 해결 능력을 키울 수 있고 원하는 바이너리를 쉽게 만들 수가 있기 때문이다.
필자는 이 컴파일 과정이 이 장 전체에 있어서 가장 중요한 장이라고 생각한다. 다소 지루 할지도 모르겠지만 컴파일 과정에 대해서 이해하고 나면 여러분의 프로그래밍 실력이 한 단계 성숙된 것을 느낄 수 있을 거라고 확신한다. 먼저 앞으로 사용할 gcc 컴파일러에 대해서 설명하겠다.
gcc는 GNU에서 만든 C 컴파일러다. 수많은 옵션만큼이나 기능이 풍부하여 원하는 바이너리를 쉽게 만들 수 있기 때문에 응용 프로그램뿐만 아니라 운영체제, 부트 로더 등도 다른 컴파일러에 비해 쉽게 만들 수 있다.
또한 gcc는 현존하는 어떤 컴파일러보다 많은 CPU 아키텍처를 지원한다. ARM, DEC, AVR, i386, PPC, SPARC, M68XX 등 수 없이 많은 아키텍처를 지원하기 때문에 원하는 어떤 CPU의 크로스 컴파일러도 쉽게 찾아서 사용할 수 있다.
gcc가 C 컴파일러라고 해서 C 소스만을 컴파일할 수 있는 것은 아니다. C++ 소스도 컴파일할 수 있고 심지어 Fortran, ada, Objective-C도 컴파일할 수 있다. 그것은 gcc의 구조적 특성 때문에 가능한 일이다. 그럼 gcc는 어떠한 구조적 특성 때문에 여러 언어를 컴파일 가능한 것일까?
흔히들 gcc 컴파일러가 C 컴파일러라고 알고 있겠지만 정답을 이야기하자면 엄밀한 의미에서 /usr/bin/gcc는 C 컴파일러가 아니다. 방금 위에서 GNU에서 만든 C 컴파일러라고 말해 놓고 다시 C 컴파일러가 아니라고 말하니 필자가 가증스럽겠지만 엄연한 사실이다.
/usr/bin/gcc는 내부적으로 전처리기인 cpp0을 호출하여 전처리 과정을 수행하고, 진짜 C 컴파일러인 cc1을 호출해서 컴파일한 후, 어셈블러인 as를 호출해서 오브젝트 코드로 만들고, 마지막으로 링커인 ld 또는 collect2를 호출해서 오브젝트 코드를 링크하여 실행 파일로 만들어낸다.
즉 gcc는 실제 컴파일 과정을 담담하는 것이 아니라 전처리기와 C 컴파일러, 어셈블러, 링커를 각각 호출하는 역할만을 담당하는 것이다. 그런 의미에서 볼 때 /usr/bin/gcc는 C 컴파일러라고 볼 수 없고 진짜 C 컴파일러를 말하자면 /usr/lib/gcc-lib/i386-redhat-linux/3.2.2/cc1을 들 수 있다.
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2 디렉토리에 가보면 여러 실행 파일들이 있는데 각 실행 파일들의 역할은 다음과 같다.
• cpp0: 전처리기
• cc1: C 컴파일러
• cc1obj: Objective-C 컴파일러
• cc1plus: C++ 컴파일러
• f771: 포트란 컴파일러
• jc1: java 컴파일러
• collect2: 링크
아래 그림은 like.c 소스 파일이 있을 때 gcc가 like.c 파일을 어떤 과정을 통해 컴파일하는지에 대해서 그림으로 나타낸 모습이다.
[그림 3-5] gcc 컴파일 과정
[그림 3-5]에서 gcc는 cpp0(C PreProcesser)를 호출하여 전처리 과정을 거쳐 like.c 파일을 like.i 파일로 만든다. like.i 파일은 C 컴파일러인 cc1에 의하여 어셈블리 코드인 like.s로 컴파일되고 이후 like.s는 as 어셈블러에 의해 어셈블 과정을 거쳐 like.o 오브젝트 파일로 만들어진다.
like.o 파일은 다시 링크인 collect2가 libc.a와 같은 표준 C 라이브러리와 링크하여 최종적으로 실행 파일인 like 파일을 만들게 된다. gcc에 의한 C 소스 컴파일 과정은 크게 이와 같은 방식으로 이루어진다. 만약 like.c가 C 소스가 아니라 like.cc와 같이 C++ 소스였다면 cpp0에 의해 생성되는 전처리 과정 파일이 like.i가 아니라 like.ii이고 cc1 C 컴파일러 대신 cc1plus C++ 컴파일러가 사용되게 된다.
독자들의 이해를 돕기 위해 실제로 이러한 컴파일 과정이 일어나는지 확인해보겠다. 먼저 기존에 사용하던 like.c 파일이 아래와 같이 있다.
[예제 3-3] like.c 소스 파일 내용
#include
int main()
{
printf("I like you!\n");
return 0;
}
그리고 아래 명령어로 컴파일해본다.
gcc -v --save-temps -o like like.c
위 명령에서 -v 옵션은 컴파일되는 과정을 화면으로 출력하라는 옵션이고 --save-temps 옵션은 컴파일 과정에서 발생되는 중간 파일을 지우지 않고 저장하라는 명령이다.
gcc는 컴파일 과정 시 생성되는 전처리 파일(like.i)과 어셈블리 파일(like.s)을 /tmp 디렉토리에 생성하고 지워 버리는데 --save-temps 옵션을 주게 되면 중간에 생성한 파일을 지우지 않고 현재 디렉토리에 저장하게 된다. 위 명령을 내려보면 [그림 3-6]과 같은 메시지가 출력됨을 확인할 수 있다.
[그림 3-6] like.c 컴파일 메세지
[그림 3-6]에서 확인할 수 있듯이 gcc는 내부적으로 cpp0와 cc1, as, collect2를 각각 호출함을 알 수 있다. 그리고 --save-temps 옵션에 의해서 ls 명령을 내려보면 컴파일 중간 과정에서 생성된 like.i 파일과 like.s 파일이 보존되었음을 확인할 수 있다.
[그림 3-7] 컴파일 과정에서 생성된 like.i 파일과 like.s
like.i 파일과 like.s 파일을 각각 열어보면 like.i 파일에는 전처리 과정이 끝난 후 C 소스 파일이 있는 것을 알 수 있고 like.s 파일은 컴파일 과정을 거친 후의 어셈블리 파일이 있는 것을 확인할 수 있다.
[그림 3-8] 전처리 과정이 끝난 like.i 파일의 내용
[그림 3-9] 컴파일 과정이 끝난 like.s 파일의 내용
이러한 like.i 파일과 like.s 파일은 컴파일 중간 과정의 산물로써 컴파일러 오류로 인한 문제 또는 전처리 과정에서의 오류로 인한 문제가 발생했을 때 유용하게 사용할 수 있다. 예를 들면 다음과 같은 상황이다.
[예제 3-4] like.h 소스 파일 내용
struct my_struct {
int a;
} my;
#define my MY
[예제 3-5] like.c 소스 파일 내용
#include "like.h"
int main()
{
my.a = 0;
printf("I like you! my.a = %d\n", my.a);
return 0;
}
독자들은 위와 같은 코드가 왜 문제가 있는지 단번에 알 수 있을 것이다. like.h에서 my_struct 타입의 구조체 my를 정의했는데 바로 밑에서 define으로 인해 앞으로 my로 쓰이는 모든 문자는 MY로 대체하게 되어있다. 그래서 like.c에서 my를 사용하게 되면 전처리 과정 내에서 MY로 치환되어 버리기 때문에 MY가 정의되지 않아 컴파일 오류가 발생하게 된다.
그럼 위 예제들을 컴파일하게 되면 어떠한 오류 메시지가 출력될까, 똑똑한 gcc가 알아서 위 예제의 어떤 부분에 문제가 있다고 알려줄 수 있을까? 다음은 컴파일 메시지다.
[그림 3-10] gcc 오류 메시지
당연하게도 gcc는 MY가 정의되지 않았다는 오류 메시지를 보여주었다. C 소스만 놓고 볼 때 위 소스에서 대문자 MY가 나올 일은 없다. 물론 우리는 위 소스의 어떤 부분이 문제가 있는지 알고 있는 상태고 헤더 파일로 like.h 밖에 include하지 않았기 때문에 문제를 모르더라도 한번에 찾을 수 있을 것이다. 그렇지만 이런 상황을 한번 가정해보자. 자신이 직접 짜지 않은 소스를 수정하여 컴파일하고자 하는데 그 소스 파일은 여러 헤더 파일을 include하고 있고 또한 include한 헤더 파일 내에서 다른 헤더 파일을 include하고 있다.
컴파일하는데 소스에서는 분명히 사용하지 않은 심볼이 정의되지 않았다는 오류 메시지가 출력된다. 가령 위와 같이 MY가 정의되지 않았다고 출력되는 것처럼 말이다.
어떻게 보면 정말 쉽게 찾기 어려운 오류가 될 수 있다. --save-temps 옵션을 모를 경우에 말이다. --save-temps 옵션을 사용하여 중간에 생성되는 파일을 저장하고 전처리 과정을 거치고 난 파일에서 확인해보면 우리는 어떤 부분에서 문제가 발생했는지 한번에 알 수 있다.
[그림 3-11]은 위 like.c 파일의 전처리 과정 산물인 like.i 파일의 내용이다.
[그림 3-11] like.i 파일의 내용
위와 같은 현상은 드물지만 가끔씩 발생하는 현상으로 한번 발생하면 정말 문제를 해결하기가 힘들다. 그러나 이제 독자들은 컴파일 과정이 어떤 철차로 진행되는지 알았을 것이고 그 부산물은 무엇인지 알았기 때문에 이러한 문제가 발생해도 문제를 찾는데 어려움이 없을 거라고 본다. like.s 파일은 cc1 C 컴파일러로 컴파일한 결과 파일이다. 이러한 like.s 어셈블리 파일은 as 어셈블러에 의해 어셈블되게 되면 like.s에 있는 어셈블리 명령대로 인스트럭션으로 변환되어 like.o 바이너리로 된다.
like.s와 같은 어셈블리 파일은 독자들이 어떤 프로그램을 작성하느냐에 따라서 매우 유용할 수 있고 한번도 쳐다보지 않을 수도 있다. 만약 여러분이 응용 프로그램을 제작한다면 중간 과정에서 생성된 어셈블리 파일은 C 소스 중간에 인라인 어셈블리를 사용하지 않은 한 거의 볼 필요가 없을 것이다. 그러나 독자들이 운영체제나 부트 로더와 같이 시스템 아키텍처의 특성을 많이 타는 프로그램을 제작하고자 한다면 거의 매번 컴파일 시마다 어셈블리 파일을 확인해야 할 것이다.
운영체제나 부트 로더의 제작에 있어서 컴파일러는 더 이상 우리들의 든든한 친구가 아니라 변덕쟁이 여자 친구가 되고 만다. 컴파일러가 만들어 내는 코드에 항상 관심을 가지고 주의를 기울여야 컴파일러가 만들어내는 예기치 못한 오류를 잡아낼 수 있다.