모두의 코드
씹어먹는 C++ - <19 - 1. Make 사용 가이드 (Makefile 만들기)>
이번 강좌에서는
make 의 작동 이해
Makefile 문법 이해
만능 Makefile 만들기
에 대해서 다루어 보겠습니다.
안녕하세요. 이번 강좌 부터는 씹어먹는 C++ 시리즈의 부록 과 같은 개념으로 C++ 언어 자체와는 직접 관련은 없지만 실제로 C++ 을 프로그래밍 하기 위해서 필요한 지식들과 툴들에 대해서 이야기 하고자 합니다. 그 첫 번째 타자로 Makefile 만들기가 되겠습니다.
아무래도 리눅스 환경에서 프로그래밍을 하신 분들은 아시겠지만, 보통 프로그램을 컴파일 할 때 make 라는 프로그램을 사용합니다. 윈도우에서 비주얼 스튜디오를 사용하신다면 컴파일 버튼을 누르면 알아서 컴파일 되는 것과는 달리, 쉘 상에서 컴파일을 하려면 어떤 파일들을 컴파일 하고, 어떠한 방식으로 컴파일 할 지 직접 컴파일러에게 알려줘야 합니다.
물론 매번 명령어를 치면 문제가 없겠지만 프로젝트의 크기가 커지고 파일들이 많아진다면 매번 그렇게 친다는 것이 불가능에 가까워집니다. 이 문제를 해결하기 위해서 리눅스에서는 make 라는 프로그램을 제공하는데, 이 프로그램은 Makefile 라는 파일을 읽어서 주어진 방식대로 명령어를 처리하게 합니다. 덕분에 많은 수의 파일들을 명령어 한 번으로 컴파일 할 수 있습니다.
이번 글에서는 make 프로그램이 어떠한 방식으로 작동되고, 프로그램들을 우리가 원하는 방식으로 컴파일 하기 위해서는 어떠한 방식으로 Makefile 을 작성해야 하는지 살펴보도록 하겠습니다.
소스 코드에서 완성된 프로그램까지
먼저 make 가 어떠한 방식으로 작동하는지 알기 전에 컴파일러를 통해 프로그램을 빌드하게 되면 어떠한 방식으로 소스 코드에서 하나의 프로그램이 완성되는지 살펴보도록 합시다.
컴파일 (Compile)
가장 먼저 여러분의 머리 속으로 떠오르는 과정은 바로 컴파일(Compile) 일 것입니다. 이 컴파일이라는 과정은 여러분의 소스 코드를 컴퓨터가 이해할 수 있는 어셈블리어로 변환하는 과정입니다.
예를 들어서 아래와 같이 foo.h
에 정의된 foo
함수와 bar.h
에 정의된 bar
함수를 main
함수에서 호출하는 간단한 프로그램을 살펴봅시다.
foo.h
int foo();
foo.cc
#include <iostream> #include "foo.h" int foo() { std::cout << "Foo!" << std::endl; return 0; }
bar.h
int bar();
bar.cc
#include <iostream> #include "bar.h" int bar() { std::cout << "Bar!" << std::endl; return 0; }
main.cc
#include "bar.h" #include "foo.h" int main() { foo(); bar(); }
예를 들어서 main.cc
를 컴파일 하기 위해서는 아래와 같이 하면 됩니다.
$ g++ -c main.cc
g++
에 전달하는 -c
명령어는 인자 다음에 주어지는 파일을 컴파일 해서 목적 파일(object file) 을 생성하라는 의미 입니다.
실제로, 성공적으로 컴파일 하였다면 아래와 같이 main.o
라는 파일이 생성된 것을 알 수 있습니다.
$ ls main.o main.o
이 main.o
는 main.cc
를 컴파일 한 어셈블리 코드가 담겨있는 파일 입니다. 실제로 main.o
를 뜯어보면
$ objdump -S main.o main.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: e8 00 00 00 00 callq d <main+0xd> d: e8 00 00 00 00 callq 12 <main+0x12> 12: b8 00 00 00 00 mov $0x0,%eax 17: 5d pop %rbp 18: c3 retq
와 같이 main.cc
의 어셈블리 코드가 잘 들어있음을 알 수 있습니다.
한 가지 재미있는 점은 컴파일러가 main.cc
를 컴파일 할 때 다른 파일들을 참조하지 않았다는 점입니다. 실제로 main.o
의 어셈블리를 슬쩍 보면 foo
나 bar
에 관한 내용이 하나도 없음을 알 수 있습니다.
따라서 main.o
자체로는 우리의 프로그램을 만들 수 없습니다. 왜냐하면 main.o
에는 foo
라는 함수를 호출해라! 라는 내용만 있지, foo
는 어디에 있고 이러이러한 방식으로 동작한다 에 관한 이야기는 없기 때문이죠. 실제로 작동하는 foo
에 관한 정보를 얻기 위해서는 foo.cc
를 컴파일 해서 만들어진 foo.o
가 필요하고, 마찬가지로 bar
에 관한 정보를 얻기 위해서는 bar.cc
를 컴파일 해서 만든 bar.o
가 필요합니다.
따라서 실제 프로그램을 제작하기 위해서는 이 각각의 main.o, foo.o, bar.o
를 하나로 합치는 과정이 필요하겠죠. 이와 같은 과정을 아래 그림과 같이 링킹(Linking) 이라고 합니다.
링킹 (Linking)
링킹이 이름 그대로 링크 하는 작업 인 이유는 실제로 서로 다른 파일에 흩어져 있던 함수나 클래스들을 한 데 묶어서 링크해주는 작업이기 때문이죠. 이 과정에서 main
함수 안에 foo
함수가 어디에 정의되어 있는지 위치를 찾고 제대로 된 함수를 호출할 수 있게 됩니다. 사실 이 링킹에 관해서 이야기만 해도 한참 할 수 있는데, 일단 이 글의 목표는 빌드 파일 만들기 이므로 여기서 줄이겠습니다.
아무튼 이 링킹 과정은 아래와 같이 컴파일러에 목적파일을 전달함으로써 수행됩니다.
$ g++ main.o foo.o bar.o -o main
여기서 -o
옵션은 그 뒤에 링킹 후에 생성할 실행 파일 이름을 이야기 합니다. 위 경우 main
이라는 실행 파일을 만들었고 만약에 이를 따로 지정하지 않는다면 그냥 ./a.out
이라는 파일을 디폴트로 생성하게 됩니다.
실제로 main
파일을 실행하게 된다면
실행 결과
Foo! Bar!
와 같이 잘 나옵니다.
그렇다면 왜 make 를 쓰는지?
일단 위와 같이 main
실행 파일을 생성하기 위해서 입력한 쉘 명령어를 쭉 나열해보자면;
$ g++ -c main.cc $ g++ -c foo.cc $ g++ -c bar.cc $ g++ main.o foo.o bar.o -o main
와 같습니다. 물론 파일 개수가 작다면 매 번 입력하는 것이 크게 문제가 되지는 않습니다만, 프로젝트 크기가 커지면 쳐야할 명령어가 더 많아지게 되서 복잡하겠지요 (특히 파일들이 다른 디렉토리에 있는다면 말이죠.)
물론 쉘 스크립트를 조금 아시는 분이라면
그냥 위 명령어들을 순차적으로 실행하는 쉘 스크립트를 짜면 되는거 아닌가?
라고 물으실 수 있습니다. 그런데 이 방식으로 한다면 한 가지 문제점이 있는데, 예를 들어서 여러분이 main.cc
를 수정하였다고 해봅시다.
그렇다면 실제로는
$ g++ -c main.cc $ g++ main.o foo.o bar.o -o main
딱 이 두 명령어만 실행하면 됩니다. 왜냐하면 main
파일을 바꾼다고 해서 foo
나 bar
의 컴파일 된 내용이 바뀌지 않기 때문이죠. 하지만 위 처럼 단순하게 쉘 스크립트를 짜게 된다면 파일 하나만 바꿔도 전체 모든 파일들을 컴파일 하게 되므로 컴파일 시간이 매우 길어지게 됩니다. 하지만 make 를 사용하면 일부 파일을 수정할 경우 필요한 명령만 빠르게 컴파일을 할 수 있도록 도와줍니다.
자 그렇다면 거두절미하고 make 의 사용법과 make 를 위한 Makefile 을 어떻게 작성하는지 알아보겠습니다.
make
make 는 간단히 말하자면 주어진 쉘 명령어들을 조건에 맞게 실행하는 프로그램 이라고 볼 수 있습니다. 이 때 어떠한 조건으로 명령어를 실행할지 담은 파일을 Makefile 이라고 부르며, make 를 터미널 상에서 실행하게 된다면 해당 위치에 있는 Makefile 을 찾아서 읽어들이게 됩니다.
그렇다면 Makefile 에는 어떠한 방식으로 조건을 기술할까요?
target … : prerequisites … (탭)recipe … …
Makefile 은 기본적으로 위와 같이 3 가지 요소로 구성되어 있습니다.
target
make 를 실행할 때 make abc
과 같이 어떠한 것을 make 할 지 전달하게 되는데 이를 타겟(target) 이라고 부릅니다. 만일 make abc
를 하였을 경우 타겟 중에 abc
를 찾아서 이에 대응되는 명령을 실행해줍니다.
실행할 명령어(recipes)
주어진 타겟을 make 할 때 실행할 명령어들의 나열입니다. 한 가지 중요한 점은 recipe 자리에 명령어를 쓸 때 반드시 탭 한 번으로 들여쓰기를 해줘야만 합니다. 보통 요즘의 편집기의 경우 (예를 들어 VSCode), 자동으로 탭을 스페이스로 바꿔주는 옵션이 활성화 되어 있을 텐데 make 가 Makefile 을 제대로 읽어들이기 위해서는 반드시 탭을 사용해야만 합니다!
필요 조건들(prerequisites)
주어진 타겟을 make 할 때 사용될 파일들의 목록 입니다. 다른 말로 의존 파일(dependency) 이라고도 합니다. 왜냐하면 해당 타겟을 처리하기 위해 건드려야 할 파일들을 써놓은 것이기 때문입니다.
만일 주어진 파일들의 수정 시간 보다 타겟이 더 나중에 수정되었다면 해당 타겟의 명령어를 실행하지 않습니다. 왜냐하면 이미 이전에 타겟이 만들어져있다고 간주하기 때문이죠.
예를 들어서 타겟이 main.o
이고, 명령어가 g++ -c main.cc
라면, 필요 조건들은 main.cc
, foo.h
, bar.h
가 되겠죠. 왜냐하면 이들 파일들 중 하나라도 바뀐다면 main
을 새로 컴파일 해야 하기 때문이죠. 반면에 main.o
의 생성 시간이 main.cc
, foo.h
, bar.h
들의 마지막 수정 시간 보다 나중이라면, 굳이 main.o
를 다시 컴파일 할 필요가 없습니다.
그렇다면 위 내용을 바탕으로 Makefile 을 간단하게 구성해보겠습니다.
foo.o : foo.h foo.cc g++ -c foo.cc bar.o : bar.h bar.cc g++ -c bar.cc main.o : main.cc foo.h bar.h g++ -c main.cc main : foo.o bar.o main.o g++ foo.o bar.o main.o -o main
주의 사항
위 Makefile 을 그대로 복사해서 실행한다면 아마 실행이 안될 것입니다. 왜냐하면 탭이 스페이스로 바뀌어서 그런데요, 제대로 Makefile 을 만드시기 위해서는 g++ 앞에 오는 스페이스 두 번을 탭으로 바꾸어서 저장해보세요.
우리의 목표는 실행 파일인 main
을 만드는 것이기 때문에 make main
을 실행해보면
$ make main g++ -c foo.cc g++ -c bar.cc g++ -c main.cc g++ foo.o bar.o main.o -o main
그리고 실제로 main
이 잘 만들어짐을 알 수 있습니다.
여러분이 한 번도 컴파일 하지 않은 상태에서 make main
을 실행하면 make 파일은 다음과 같은 순서로 명령어를 처리합니다.
make main
이니까 Makefile 에서 타겟이main
인 녀석을 찾아보자.오.
main
의 필요한 파일들이foo.o bar.o main.o
이네? 이들 파일을 어떻게 만드는지 각각의 파일 이름으로된 타겟들을 찾아보자.foo.o
의 경우 필요한 파일이foo.cc
네? 아직foo.o
가 없으니까 주어진 명령어를 실행해야 하겠군!마찬가지로
bar.o, main.o
도 컴파일자 이제 마지막으로
g++ foo.o bar.o main.o -o main
를 실행해야지
만약에 여러분이 make main
을 한 번 실행한 상태에서 foo.cc
만 수정하였다고 해봅시다. 그렇다면 아래와 같이 필요한 부분만 재컴파일 됩니다.
make main
이니까 Makefile 에서 타겟이main
인 녀석을 찾아보자.오.
main
의 필요한 파일들이foo.o bar.o main.o
이네? 이들 파일을 어떻게 만드는지 각각의 파일 이름으로된 타겟들을 찾아보자.foo.o
의 경우 필요한 파일이foo.cc
네? 그런데foo.o
의 생성 시간 보다foo.cc
의 수정 시간이 더 나중이군! 주어진 명령어를 다시 실행해야겠어bar.o
의 경우 필요한 파일이bar.cc
인데,bar.o
의 생성 시간이bar.cc
의 수정 시간 보다 나중이네. 굳이 명령어들을 실행하지 않아도 되겠어main.o
도 마찬가지로 다시 안바꿔도 되겠군!main
의 필요한 파일들 중foo.o
가 바뀌었으니 내 명령어들도 다시 실행해야겠어.
따라서 딱 필요한 g++ -c foo.cc
와 g++ foo.o bar.o main.o -o main
만이 실행됩니다.
변수
재미있게도 Makefile 내에서 변수를 정의할 수 있습니다.
CC = g++
위 경우 CC
라는 변수를 정의하였는데, 이제 Makefile 내에서 CC
를 사용하게 된다면 해당 변수의 문자열인 g++
로 치환됩니다. 이 때 변수를 사용하기 위해서는 $(CC)
와 같이 $()
안에 사용하고자 하는 변수의 이름을 지정하면 됩니다. 예를 들어서
CC = g++ foo.o : foo.h foo.cc $(CC) -c foo.cc
는 사실
CC = g++ foo.o : foo.h foo.cc g++ -c foo.cc
과 정확히 같은 명령이 됩니다.
참고로 정의하지 않는 변수를 참조하게 된다면 그냥 빈 문자열로 치환됩니다.
변수를 정의하는 두 가지 방법
주의 사항
이 부분은 TMI 이므로 바쁘신 분들은 그냥 넘어가셔도 됩니다.
Makefile 상에서 변수를 정의하는 방법으로 = 를 사용해서 정의하는 방법과 :=
를 사용해서 정의하는 방법이 있습니다. 이 둘은 살짝 다릅니다.
= 를 사용해서 변수를 정의하였을 때, 정의에 다른 변수가 포함되어 있다면 해당 변수가 정의되기 될 때 까지 변수의 값이 정해지지 않습니다. 예를 들어서
B = $(A) C = $(B) A = a
C
의 경우 B
의 값을 참조하고 있고, B
의 경우 A
의 값을 참조하고 있습니다. 하지만 B =
를 실행한 시점에서 A
가 정의되지 않았으므로 B
는 그냥 빈 문자열이 되어야 하지만 =
로 정의하였기 때문에 A
가 실제로 정의될 때 까지 B
와 C
가 결정되지 않습니다. 결국 마지막에 A = a
를 통해 A
가 a
로 대응되어야, C
가 a
로 결정됩니다.
B := $(A) A = a
반면에 :=
로 변수를 정의할 경우, 해당 시점에의 변수의 값만 확인 합니다. 따라서 위 경우 B
는 그냥 빈 문자열이 되겠지요.
대부분의 상황에서는 =
나 :=
중 아무거나 사용해도 상관 없습니다. 하지만
만일 변수들의 정의 순서에 크게 구애받고 싶지 않다면
=
를 사용하는 것이 편합니다.A =
와 같이 자기 자신을 수정하고 싶다면:=
를 사용해야지 무한 루프를 피할 수 있습니다.
만 명심하면 됩니다.
그렇다면 이들 변수를 사용해서 Makefile 을 조금 더 깔끔하게 바꾸어보겠습니다.
CC = g++ CXXFLAGS = -Wall -O2 OBJS = foo.o bar.o main.o foo.o : foo.h foo.cc $(CC) $(CXXFLAGS) -c foo.cc bar.o : bar.h bar.cc $(CC) $(CXXFLAGS) -c bar.cc main.o : main.cc foo.h bar.h $(CC) $(CXXFLAGS) -c main.cc main : $(OBJS) $(CC) $(CXXFLAGS) $(OBJS) -o main
make 를 실행해보면
$ make main g++ -Wall -O2 -c foo.cc g++ -Wall -O2 -c bar.cc g++ -Wall -O2 -c main.cc g++ -Wall -O2 foo.o bar.o main.o -o main
와 같이 잘 나옵니다.
CC
와 CXXFLAGS
는 Makefile 에서 자주 사용되는 변수로 보통 CC
에는 사용하는 컴파일러 이름을, CXXFLAGS
에는 컴파일러 옵션을 주는 것이 일반적 입니다. 참고로 이는 C++ 의 경우 이고, C 의 경우 CFLAGS 에 옵션을 줍니다.
우리의 경우 g++
컴파일러를 사용하며, 옵션으로는 Wall
(모든 컴파일 경고를 표시) 과 O2
(최적화 레벨 2) 를 주었습니다.
PHONY
Makefile 에 흔히 추가하는 기능으로 빌드 관련된 파일들 (.o 파일들)을 모두 제거하는 명령을 넣습니다.
clean: rm -f $(OBJS) main
실제로 make clean
을 실행해보면 생성된 모든 목적 파일과 main
을 지워버림을 알 수 있습니다.
그런데, 만약에 실제로 clean
이라는 파일이 디렉토리에 생성된다면 어떨까요? 우리가 make clean
을 하게 되면, make 는 clean 의 필요 파일들이 없는데, clean 파일이 있으니까 clean 파일은 항상 최신이네? recipe 를 실행 안해도 되겠네! 하면서 그냥 make clean
명령을 무시해버리게 됩니다.
$ ls clean clean $ make clean make: 'clean' is up to date.
실제로 디렉토리에 clean
이라는 파일을 만들어놓고 실행해보면 위와 같이 이미 clean
은 최신이라며 recipe
실행을 거부합니다.
이와 같은 상황을 막기 위해서는 clean
을 PHONY
라고 등록하면 됩니다. 아래와 같이 말이지요.
.PHONY: clean clean: rm -f $(OBJS) main
이제 make clean
을 하게 되면 clean
파일의 유무와 상관 없이 언제나 해당 타겟의 명령을 실행하게 됩니다.
패턴 사용하기
우리의 경우 파일이 3 개 밖에 없어서 다행이였지만 실제 프로젝트에는 수십~ 수백 개의 파일들을 다루게 될 것입니다. 그런데, 각각의 파일들에 대해서 모두 빌드 방식을 명시해준다면 Makefile 의 크기가 엄청 커지겠지요.
다행이도 Makefile 에서는 패턴 매칭을 통해서 특정 조건에 부합하는 파일들에 대해서 간단하게 recipe
를 작성할 수 있게 해줍니다.
foo.o : foo.h foo.cc $(CC) $(CXXFLAGS) -c foo.cc bar.o : bar.h bar.cc $(CC) $(CXXFLAGS) -c bar.cc
일단 먼저 비슷하게 생긴 위 두 명령들을 어떻게 하면 하나로 간단하게 나타낼 수 있는지 보겠습니다.
%.o: %.cc %.h $(CC) $(CXXFLAGS) -c $<
먼저 %.o
는 와일드카드로 따지면 마치 *.o
와 같다고 볼 수 있습니다. 즉, .o
로 끝나는 파일 이름들이 타겟이 될 수 있겠지요. 예를 들어서 foo.o
가 타겟이라면 % 에는 foo
가 들어갈 것이고 bar.o
의 경우 % 에는 bar
가 들어갈 것입니다.
따라서 예를 들어 foo.o
가 타겟일 경우
foo.o: foo.cc foo.h $(CC) $(CXXFLAGS) -c $<
가 됩니다. 참고로 패턴은 타겟과 prerequisite
부분에만 사용할 수 있습니다. recipe
부분에서는 패턴을 사용할 수 없습니다. 따라서 컴파일러에 foo.cc
를 전달하기 위해서는 Makefile 의 자동 변수를 사용해야 합니다.
$<
의 경우 prerequisite
에서 첫 번째 파일의 이름에 대응되어 있는 변수 입니다. 위 경우 foo.cc
가 되겠지요. 따라서 위 명령어는 결과적으로
foo.o: foo.cc foo.h $(CC) $(CXXFLAGS) -c foo.cc
가 되어서 이전의 명령어와 동일하게 만들어냅니다.
Makefile 에서 제공하는 자동 변수로는 그 외에도 아래 그림과 같이 $@
, $<
, $^
등등이 있습니다.
$@
: 타겟 이름에 대응됩니다.$<
: 의존 파일 목록에 첫 번째 파일에 대응됩니다.$^
: 의존 파일 목록 전체에 대응됩니다.$?
: 타겟 보다 최신인 의존 파일들에 대응됩니다.$+
:$^
와 비슷하지만, 중복된 파일 이름들 까지 모두 포함합니다.
하지만 애석하게도 위 패턴으로는
main.o : main.cc foo.h bar.h $(CC) $(CXXFLAGS) -c main.cc
를 표현하기에는 부족합니다. 왜냐하면 의존 파일 목록에 main.h
가 없고 foo.h
와 bar.h
가 있기 때문이죠. 사실 곰곰히 생각해보면 이 의존파일 목록에는 는 해당 소스 파일이 어떠한 헤더파일을 포함하냐에 결정되어 있습니다. main.cc
가 foo.h
와 bar.h
를 include
하고 있기 때문에 main.o
의 prerequisite
로 main.cc
외에도 foo.h
와 bar.h
가 들어가 있는 것입니다.
물론 매번 이렇게 일일히 추가할 수 있겠지만, 소스 파일에 헤더 파일을 추가할 때 마다 Makefile 을 바꿀 수는 없는 노릇이니까요. 하지만 다행이도 컴파일러의 도움을 받아서 의존파일 목록 부분을 작성할 수 있습니다.
자동으로 prerequisite 만들기
컴파일 시에 -MD
옵션을 추가해서 컴파일 해봅시다.
$ g++ -c -MD main.cc
그렇다면 main.d
라는 파일을 생성합니다. 파일 내용을 살펴보면;
$ cat main.d main.o: main.cc /usr/include/stdc-predef.h foo.h bar.h
놀랍게도 마치 Makefile 의 target: prerequisite
인것 같은 부분을 생성하였습니다. 그렇습니다. 컴파일 시에 -MD
옵션을 추가해주면, 목적 파일 말고도 컴파일 한 소스파일을 타겟으로 하는 의존파일 목록을 담은 파일을 생성해줍니다.
참고로 main.cc, foo.h, bar.h
까지는 이해가 가는데 왜 생뚱맞은 /usr/include/stdc-predef.h
이 들어가 있냐고 물을 수 있는데, 이 파일은 컴파일러가 컴파일 할 때 암묵적으로 참조하는 헤더 파일이라고 보시면 됩니다. 아무튼 이 때문에 컴파일러가 생성한 의존 파일 목록에는 포함되었습니다.
문제는 이렇게 생성된 main.d
를 어떻게 우리의 Makefile 에 포함할 수 있냐 입니다. 이는 생각보다 간단합니다.
CC = g++ CXXFLAGS = -Wall -O2 OBJS = foo.o bar.o main.o %.o: %.cc %.h $(CC) $(CXXFLAGS) -c $< main : $(OBJS) $(CC) $(CXXFLAGS) $(OBJS) -o main .PHONY: clean clean: rm -f $(OBJS) main include main.d
위 include main.d
는 main.d
라는 파일의 내용을 Makefile 에 포함하라는 의미 입니다.
그렇다면 아싸리
%.o: %.cc %.h $(CC) $(CXXFLAGS) -c $<
부분을 아예 컴파일러가 생성한 .d
파일로 대체할 수는 없을까요? 물론 있습니다!
CC = g++ CXXFLAGS = -Wall -O2 OBJS = foo.o bar.o main.o %.o: %.cc $(CC) $(CXXFLAGS) -c $< main : $(OBJS) $(CC) $(CXXFLAGS) $(OBJS) -o main .PHONY: clean clean: rm -f $(OBJS) main -include $(OBJS:.o=.d)
$(OBJS:.o=.d)
부분은 OBJS
에서 .o
로 끝나는 부분을 .d
로 모두 대체하라는 의미 입니다. 즉, 해당 부분은 -include foo.d bar.d main.d
가 되겠죠. 참고로 foo.d
나 bar.d
가 include
될 때 이미 있는 %.o: %.cc
는 어떻게 되냐고 물을 수 있는데 같은 타겟에 대해서 여러 의존 파일 목록들이 정해져 있다면 이는 make 에 의해 모두 하나로 합쳐집니다. 따라서 크게 걱정하실 필요는 없습니다.
덧붙여 include
에서 -include
로 바꾸었는데, -include
의 경우 포함하고자 하는 파일이 존재하지 않아도 make 메세지를 출력하지 않습니다.
맨 처음에 make 를 할 때에는 .d
파일들이 제대로 생성되지 않은 상태이기 때문에 include
가 아무런 .d
파일들을 포함하지 않습니다. 물론 크게 문제 없는 것이 어차피 .o
파일들도 make 가 %.o: %.cc
부분의 명령어들을 실행하면서 컴파일을 하기 때문에 다음에 make 를 하게 될 때에는 제대로 .d
파일들을 로드할 수 있겠죠.
최종 정리
아래와 같이 간단한 프로젝트 구조를 생각해봅시다.
$ tree . ├── Makefile ├── obj └── src ├── bar.cc ├── bar.h ├── foo.cc ├── foo.h └── main.cc
모든 소스 파일은 src
에 들어가고 빌드 파일들은 obj
에 들어갑니다. 종종 헤더 파일들을 따로 include
에 빼는 경우가 있는데 저는 굳이 라이브러리를 만드는 경우가 아니라면 별로 선호하지 않습니다. (굳이 다른 디렉토리에 넣을 이유가 뭔지 모르겠습니다.)
아무튼 이와 같은 구조에서 항상 사용할 수 있는 만능 Makefile 은 아래와 같습니다.
주의 사항
복사한 후에 $(CC)
와 rm
앞에 스페이스 두 개를 꼭 TAB 으로 치환해주세요! 안 그러면 make 가 읽지 못합니다.
CC = g++ # C++ 컴파일러 옵션 CXXFLAGS = -Wall -O2 # 링커 옵션 LDFLAGS = # 소스 파일 디렉토리 SRC_DIR = ./src # 오브젝트 파일 디렉토리 OBJ_DIR = ./obj # 생성하고자 하는 실행 파일 이름 TARGET = main # Make 할 소스 파일들 # wildcard 로 SRC_DIR 에서 *.cc 로 된 파일들 목록을 뽑아낸 뒤에 # notdir 로 파일 이름만 뽑아낸다. # (e.g SRCS 는 foo.cc bar.cc main.cc 가 된다.) SRCS = $(notdir $(wildcard $(SRC_DIR)/*.cc)) OBJS = $(SRCS:.cc=.o) # OBJS 안의 object 파일들 이름 앞에 $(OBJ_DIR)/ 을 붙인다. OBJECTS = $(patsubst %.o,$(OBJ_DIR)/%.o,$(OBJS)) DEPS = $(OBJECTS:.o=.d) all: main $(OBJ_DIR)/%.o : $(SRC_DIR)/%.cc $(CC) $(CXXFLAGS) -c $< -o $@ -MD $(LDFLAGS) $(TARGET) : $(OBJECTS) $(CC) $(CXXFLAGS) $(OBJECTS) -o $(TARGET) $(LDFLAGS) .PHONY: clean all clean: rm -f $(OBJECTS) $(DEPS) $(TARGET) -include $(DEPS)
추가된 부분만 간단히 부연 설명을 하자면
# Make 할 소스 파일들 # wildcard 로 SRC_DIR 에서 *.cc 로 된 파일들 목록을 뽑아낸 뒤에 # notdir 로 파일 이름만 뽑아낸다. # (e.g SRCS 는 foo.cc bar.cc main.cc 가 된다.) SRCS = $(notdir $(wildcard $(SRC_DIR)/*.cc))
먼저 SRC_DIR
안에 있는 모든 파일들을 SRCS
로 읽어들이려 하고 있습니다. wildcard
는 함수로 해당 조건에 맞는 파일들을 뽑아내게 되는데, 예를 들어서 foo.cc, bar.cc, main.cc
가 있을 경우 $(wildcard $(SRC_DIR)/*.cc)
의 실행 결과는 ./src/foo.cc ./src/bar.cc ./src/main.cc
가 될 것입니다.
여기서 우리는 foo.cc bar.cc main.cc
로 깔끔하게 경로를 제외한 파일 이름만 뽑아내기 위해 notdir
함수를 사용합니다. notdir
은 앞에 오는 경로를 날려버리고 파일 이름만 깔끔하게 추출해줍니다.
OBJS = $(SRCS:.cc=.o)
따라서 이 부분에서 OBJS
는 foo.o bar.o main.o
가 될 것입니다.
이제 이 OBJS
를 바탕으로 실제 .o
파일들의 경로를 만들어내고 싶습니다. 이를 위해서는 이들 파일 이름 앞에 $(OBJ_DIR)/
을 붙여줘야 겠지요. 이를 위해선 patsubst
함수를 사용하면 됩니다.
# OBJS 안의 object 파일들 이름 앞에 $(OBJ_DIR)/ 을 붙인다. OBJECTS = $(patsubst %.o,$(OBJ_DIR)/%.o,$(OBJS))
patsubst
함수는 $(patsubst 패턴,치환 후 형태,변수)
의 같은 꼴로 사용합니다.
따라서 위 경우 $(OBJS)
안에 있는 모든 %.o
패턴을 $(OBJ_DIR)/%.o
로 치환해라 라는 의미가 될 것입니다. 아무튼 덕분에 OBJECTS
에는 이제 ./obj/foo.o ./obj/bar.o ./obj/main.o
가 들어가게 됩니다.
그 뒤에 내용은 앞의 글을 잘 따라 오신 분들이라면 잘 이해 하실 수 있으리라 믿습니다.
헤더 파일들을 따로 뽑는 경우
만약에 헤더 파일들만 따로 뽑는다면 아래와 같은 파일 구조를 가지겠죠.
$ tree . ├── include │ ├── bar.h │ └── foo.h ├── Makefile ├── obj └── src ├── bar.cc ├── foo.cc └── main.cc
이 경우 Makefile 을 아래와 같이 간단히 수정하면 됩니다.
CC = g++ # C++ 컴파일러 옵션 CXXFLAGS = -Wall -O2 # 링커 옵션 LDFLAGS = # 헤더파일 경로 INCLUDE = -Iinclude/ # 소스 파일 디렉토리 SRC_DIR = ./src # 오브젝트 파일 디렉토리 OBJ_DIR = ./obj # 생성하고자 하는 실행 파일 이름 TARGET = main # Make 할 소스 파일들 # wildcard 로 SRC_DIR 에서 *.cc 로 된 파일들 목록을 뽑아낸 뒤에 # notdir 로 파일 이름만 뽑아낸다. # (e.g SRCS 는 foo.cc bar.cc main.cc 가 된다.) SRCS = $(notdir $(wildcard $(SRC_DIR)/*.cc)) OBJS = $(SRCS:.cc=.o) DEPS = $(SRCS:.cc=.d) # OBJS 안의 object 파일들 이름 앞에 $(OBJ_DIR)/ 을 붙인다. OBJECTS = $(patsubst %.o,$(OBJ_DIR)/%.o,$(OBJS)) DEPS = $(OBJECTS:.o=.d) all: main $(OBJ_DIR)/%.o : $(SRC_DIR)/%.cc $(CC) $(CXXFLAGS) $(INCLUDE) -c $< -o $@ -MD $(LDFLAGS) $(TARGET) : $(OBJECTS) $(CC) $(CXXFLAGS) $(OBJECTS) -o $(TARGET) $(LDFLAGS) .PHONY: clean all clean: rm -f $(OBJECTS) $(DEPS) $(TARGET) -include $(DEPS)
사실 기존과 별 차이 없고 그냥 컴파일러 옵션에 -Iinclude/
를 추가해주면 됩니다. 여기서 include
는 헤더파일 경로 입니다.
멀티 코어를 활용해서 Make 속도를 올리자
한 가지 팁으로 그냥 make 를 실행하게 되면 1 개의 쓰레드만 실행되어서 속도가 꽤나 느립니다. 특히 GCC 나 커널을 컴파일 할 경우 한 두 시간은 그냥 걸리지요. 만일 여러분의 컴퓨터가 멀티 코어 CPU 를 사용한다면 (아마 대부분 그럴 것이라 생각합니다) make 를 여러 개의 쓰레드에서 돌릴 수 있습니다. 이를 위해서는 인자로 -j
뒤에 몇 개의 쓰레드를 사용할 지 숫자를 적어서 전달하면 됩니다.
예를 들어서
$ make -j8
을 하면 make 가 8 개의 쓰레드에 나뉘어서 실행됩니다. 아마 make 속도가 월등하게 향상되는 것을 보실 수 있을 것입니다. 통상적으로 코어 개수 + 1 만큼의 쓰레드를 생성해서 돌리는 것이 가장 속도가 빠릅니다.
만약에 내 컴퓨터의 코어 개수를 모른다면 리눅스 터미널의 경우
$ make -j$(nproc)
으로 하면 $(nproc)
이 알아서 내 컴퓨터의 현재 코어 개수로 치환됩니다.
댓글을 불러오는 중입니다..