모두의 코드
씹어먹는 C++ - <19 - 1. Make 사용 가이드 (Makefile 만들기)>

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

이번 강좌에서는

  • 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.omain.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 의 어셈블리를 슬쩍 보면 foobar 에 관한 내용이 하나도 없음을 알 수 있습니다.

따라서 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 파일을 바꾼다고 해서 foobar 의 컴파일 된 내용이 바뀌지 않기 때문이죠. 하지만 위 처럼 단순하게 쉘 스크립트를 짜게 된다면 파일 하나만 바꿔도 전체 모든 파일들을 컴파일 하게 되므로 컴파일 시간이 매우 길어지게 됩니다. 하지만 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), 자동으로 탭을 스페이스로 바꿔주는 옵션이 활성화 되어 있을 텐데 makeMakefile 을 제대로 읽어들이기 위해서는 반드시 탭을 사용해야만 합니다!

필요 조건들(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 파일은 다음과 같은 순서로 명령어를 처리합니다.

  1. make main 이니까 Makefile 에서 타겟이 main 인 녀석을 찾아보자.

  2. 오. main 의 필요한 파일들이 foo.o bar.o main.o 이네? 이들 파일을 어떻게 만드는지 각각의 파일 이름으로된 타겟들을 찾아보자.

  3. foo.o 의 경우 필요한 파일이 foo.cc 네? 아직 foo.o 가 없으니까 주어진 명령어를 실행해야 하겠군!

  4. 마찬가지로 bar.o, main.o 도 컴파일

  5. 자 이제 마지막으로 g++ foo.o bar.o main.o -o main 를 실행해야지

만약에 여러분이 make main 을 한 번 실행한 상태에서 foo.cc 만 수정하였다고 해봅시다. 그렇다면 아래와 같이 필요한 부분만 재컴파일 됩니다.

  1. make main 이니까 Makefile 에서 타겟이 main 인 녀석을 찾아보자.

  2. 오. main 의 필요한 파일들이 foo.o bar.o main.o 이네? 이들 파일을 어떻게 만드는지 각각의 파일 이름으로된 타겟들을 찾아보자.

  3. foo.o 의 경우 필요한 파일이 foo.cc 네? 그런데 foo.o 의 생성 시간 보다 foo.cc 의 수정 시간이 더 나중이군! 주어진 명령어를 다시 실행해야겠어

  4. bar.o 의 경우 필요한 파일이 bar.cc 인데, bar.o 의 생성 시간이 bar.cc 의 수정 시간 보다 나중이네. 굳이 명령어들을 실행하지 않아도 되겠어

  5. main.o 도 마찬가지로 다시 안바꿔도 되겠군!

  6. main 의 필요한 파일들 중 foo.o 가 바뀌었으니 내 명령어들도 다시 실행해야겠어.

따라서 딱 필요한 g++ -c foo.ccg++ 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 가 실제로 정의될 때 까지 BC 가 결정되지 않습니다. 결국 마지막에 A = a 를 통해 Aa 로 대응되어야, Ca 로 결정됩니다.

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

와 같이 잘 나옵니다.

CCCXXFLAGSMakefile 에서 자주 사용되는 변수로 보통 CC 에는 사용하는 컴파일러 이름을, CXXFLAGS 에는 컴파일러 옵션을 주는 것이 일반적 입니다. 참고로 이는 C++ 의 경우 이고, C 의 경우 CFLAGS 에 옵션을 줍니다.

우리의 경우 g++ 컴파일러를 사용하며, 옵션으로는 Wall (모든 컴파일 경고를 표시) 과 O2 (최적화 레벨 2) 를 주었습니다.

PHONY

Makefile 에 흔히 추가하는 기능으로 빌드 관련된 파일들 (.o 파일들)을 모두 제거하는 명령을 넣습니다.

clean:
  rm -f $(OBJS) main

실제로 make clean 을 실행해보면 생성된 모든 목적 파일과 main 을 지워버림을 알 수 있습니다.

그런데, 만약에 실제로 clean 이라는 파일이 디렉토리에 생성된다면 어떨까요? 우리가 make clean 을 하게 되면, makeclean 의 필요 파일들이 없는데, clean 파일이 있으니까 clean 파일은 항상 최신이네? recipe 를 실행 안해도 되겠네! 하면서 그냥 make clean 명령을 무시해버리게 됩니다.

$ ls clean
clean
$ make clean
make: 'clean' is up to date.

실제로 디렉토리에 clean 이라는 파일을 만들어놓고 실행해보면 위와 같이 이미 clean 은 최신이라며 recipe 실행을 거부합니다.

이와 같은 상황을 막기 위해서는 cleanPHONY 라고 등록하면 됩니다.

아래와 같이 말이지요.

.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.hbar.h 가 있기 때문이죠. 사실 곰곰히 생각해보면 이 의존파일 목록에는 는 해당 소스 파일이 어떠한 헤더파일을 포함하냐에 결정되어 있습니다. main.ccfoo.hbar.hinclude 하고 있기 때문에 main.oprerequisitemain.cc 외에도 foo.hbar.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

놀랍게도 마치 Makefiletarget: 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.dmain.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.dbar.dinclude 될 때 이미 있는 %.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 로 깔끔하게 경로를 제외한 파일 이름만 뽑아내기 위해 nodir 함수를 사용합니다. nodir 은 앞에 오는 경로를 날려버리고 파일 이름만 깔끔하게 추출해줍니다.

OBJS = $(SRCS:.cc=.o)

따라서 이 부분에서 OBJSfoo.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)

으로 하면 $(proc) 이 알아서 내 컴퓨터의 현재 코어 개수로 치환됩니다.

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

현재 여러분이 보신 강좌는 <Make 사용 가이드 (Makefile 만들기)> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 13 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요