Make 기반 빌드 시스템 (5): 디버그 버전과 릴리즈 버전 빌드

디버그 버전과 릴리즈 버전 빌드

소프트웨어 개발자라면 누구나 한번쯤 경험해 보셨겠지만, 소프트웨어 개발하는 과정 중에는 종종 정말이지 아무것도 믿을 수 없는 상태에 빠질 때가 있습니다. 완벽해 보이는 코드(?)를 작성해서 컴파일하고 링킹해서 빌드된 결과물을 실제로 실행했건만, 의도한 대로 동작하지 않습니다. 갖가지 험악한 메시지를 뿜으며 비정상 종료되거나 조용히 아무런 출력도 없이 데드락이 걸리기도 하고, 시스템의 모든 리소스를 무한정 집어 삼키는 바람에 결국 시스템의 전원을 껐다 켜기만 하는 상황이 발생하기도 하죠.

나 자신 혹은 동료의 바보 같은 오타 때문이든, 테스터가 실행 환경 혹은 테스트 데이터를 잘못 설정해서였든, 덕이 부족한 나랏님 탓이든… 좌우지간 뭔가 잘못 동작하면 그 원인을 찾거나 최악의 경우 회피책이라도 찾아야만 하는 상황에 처하게 됩니다. 원인을 찾아야만 스스로를 탓하며 머리카락을 쥐어 뜯거나, 원인 제공자를 구박하며 분풀이(?)라도 할 수 있을 테고, 촉박한 일정, 하드웨어나 라이브러리의 한계, 뭐 이런저런 이유로 근본적인 원인 파악 및 해결이 힘들다면, 최소한 문제가 발생하는 시점이나 상황을 찾아서 이를 피해갈 수 있는 방법을 찾아야겠지요.

원인이나 문제 발생 지점을 찾기 위해 코어 덤프를 떠서 첨단 디버거에서 분석할 수도 있을테고, 혹은 printf() 호출 신공으로 작성된 로그를, 눈이 빠져라 들여다 보며 소프트웨어 개발자의 팔자를 탓할 수도 있겠습니다…만, 뭐가 됐던 디버깅을 하려면 일단 작성한 코드를 디버그 버전으로 컴파일 해야 합니다. 디버깅 심벌을 삽입하고 최적화도 하지 않고, 최종 배포 버전에서는 출력되지 않을, 각종 로그나 디버그 메시지도 출력되도록 하고요.

디버그 모드와 릴리즈 모드 구분

보통 디버그 버전일 경우에는 컴파일 옵션을 다음과 같이 줍니다.

gcc -g -O0 -DDEBUG source_file -o target_file ...

-g 는 컴파일 시 디버깅 심볼을 넣으라는 옵션이고요, -O (대문자 O입니다.) 뒤에 숫자를 붙는 숫자는 최적화 정도입니다. 디버깅을 할 것이기 때문에 최적화를 하지 않도록 숫자 0 을 붙인 거죠. 그리고 -D 는 컴파일 매크로 선언에 붙는 prefix입니다. 보통은 .c 코드 소스 상에서 디버깅용 코드는 #ifdef DEBUG … #endif 사이에 넣어 두죠. 뭐, 사람이나 조직에 따라 _DEBUG 또는 _DEBUG_ 이렇게 쓰기도 할 텐데, 그런 경우에는 -D_DEBUG 또는 -D_DEBUG_ 식으로 맞춰 선언해 주면 되겠죠?

한편 릴리즈로 빌드할 경우에는 흔히 다음과 같이 줍니다.

gcc -O2 -DNDEBUG ...

최적화 레벨 2에 NDEBUG 라는 매크로를 선언하는 거죠. 역시 필요에 따라 최적화 레벨을 달리 줄 수도 있고 특별한 매크로를 쓰지 않거나 다른 매크로를 선언해 줄 수도 있을 겁니다.

어쨌거나 핵심은 gcc 로 컴파일 할 때 넘겨주는 옵션을 디버그냐 릴리즈냐에 따라 다르게 선언해 주는 거죠. 이건 이전 글에서 이미 다뤘던, if … else … endif 를 사용하면 어렵지 않게 모드에 따라 다르게 선언할 수 있습니다. 모드 구분을 위해 RELEASE 라는 이름의 변수를 추가하고, 이 값이 1이라는 값을 가지면 릴리즈용 옵션으로, 그렇지 않으면 DEBUG용 옵션으로 넘어가게 하는 겁니다. 이렇게 말이죠.

ifeq ($(RELEASE), 1)
DBG_FLAGS = -O2 -DNDEBUG
else
DBG_FLAGS = -g -O0 -DDEBUG
endif

gcc $(DBG_FLAGS) source_file -o target_file ...

그럼 남는 문제는 (1) ‘옵션을 gcc에게 어떻게 넘겨 주느냐’ 와, (2) ‘RELEASE 변수를 어떻게 선언하느냐’ 가 되겠네요.

확장자 규칙으로 컴파일 옵션 지정하기

Make 기반 빌드 시스템 (2): Makefile 기초에서 다룬 확장자 규칙(Suffix Rule)은 참 편리한 기능입니다. 내가 작성하지 않아도 make가 알아서 처리 해주니 간단한 빌드 작업을 위한 Makefile을 작성할 때는 특히나 유용합니다. 하지만, 만약 뭔가 다른 나만의 옵션을 쓰고 싶으면, 내장된 확장자 규칙 대신 그에 상응하는 ‘나만의’ 확장자 규칙을 Makefile에 작성하면 됩니다. 다음을 보세요.

ifeq ($(RELEASE), 1)
DBG_FLAGS = -O2 -DNDEBUG
else
DBG_FLAGS = -g -O0 -DDEBUG
endif

.c.o:
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    $(CC) $(CFLAGS) $(DBG_FLAGS) -c $< -o $@

(1) ~ (5)는 디버그/릴리즈 모드와 관련된 DBG_FLAGS 라는 변수를 정의한 것입니다. 그리고 (7) ~ (11)는 내장 확장자 규칙을 대체할 나만의 .c.o 확장자 규칙인데요, (8) ~ (10)은 단순히 컴파일 작업 앞에 좀 이쁘게(?) 꾸며주는 역할만 수행하는 것으로 뭐 동작에는 큰 영향은 없습니다. (그래도 그냥 gcc 컴파일 명령만 나오는 것 보다는 이런 장식이 있는 게 낫지 않나요? 각자 고유의 미적 감각을 동원해서 자신만의 장식을 한번 만들어 보셔도 좋겠습니다.)

실제 일은 (11)의 gcc 한테 컴파일을 시키는 부분입니다. $< 는 확장자 규칙의 전제조건인 .c 소스 파일이 되고, $@는 타겟인 .o 파일을 의미하게 됩니다. 따라서 gcc 는 $(CFLAGS) 옵션과 $(DBG_FLAGS)을 가지고 .c 파일을 컴파일 해서, 그 결과인 목적 파일을 동일한 이름의 .o 확장자로 저장합니다.

참고로 CFLAGS 변수는 make가 내장 규칙에 사용하는 사전 정의된 변수 중 하나입니다. 사실 DBG_FLAGS의 내용을 CFLAGS에 추가해서 굳이 .c.o 규칙을 만들지 않고 내장 확장자 규칙을 사용할 수도 있습니다.

하지만, 프로젝트 규모가 커지고, 가독성을 위해 다양한 변수들로 옵션을 지정해서 사용하는 것이 더 좋다고 판단되면 .c.o 확장자 규칙을 쓰는 게 낫겠죠. 특히나 나만의 컴파일 출력 문구의 장식 쓸 수 있다는 것도 나만의 확장자 규칙 작성의 의미로 충분할 테지요… 뭐, 전 그렇게 생각합니다.

이제까지의 내용을 모두 적용하면 다음과 같습니다.

예제 5.1
DEPEND_FILE = depend_file

CC = gcc
AR = ar
RANLIB = ranlib

ifeq ($(RELEASE), 1)
DBG_FLAGS = -O2 -DNDEBUG
else
DBG_FLAGS = -g -O0 -DDEBUG
endif

LIB_NAME = MyProtocol
LIB_FULL_NAME = lib$(LIB_NAME).a

LIB_SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyProtocol.c
LIB_OBJS = $(LIB_SRCS:%.c=%.o)

LIBS = -lpthread
LIBS += -l$(LIB_NAME)
LIB_DIRS = -L.

TARGET_SRCS = MyClient.c TestConnection.c TestDataChange.c TestDrinking.c
TARGET_OBJS = $(TARGET_SRCS:%.c=%.o)
TARGET_NAMES = $(TARGET_SRCS:%.c=%)

.SUFFIXES : .c .o

all : $(LIB_FULL_NAME) $(TARGET_NAMES)

$(LIB_FULL_NAME) : $(LIB_OBJS)
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@

.c.o:
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    $(CC) $(CFLAGS) $(DBG_FLAGS) -c $< -o $@

.SECONDEXPANSION:
$(TARGET_NAMES): $$@.o
    $(CC) -o $@ $< $(LIB_DIRS) $(LIBS)

depend :
    $(CC) -MM $(LIB_SRCS) $(TARGET_SRCS) > $(DEPEND_FILE)

clean :
    rm -f $(LIB_FULL_NAME) $(LIB_OBJS) $(TARGET_NAMES) $(TARGET_OBJS)

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(strip $(LIB_SRCS) $(TARGET_SRCS)),)
-include $(DEPEND_FILE)
endif
endif
endif

RELEASE 변수 지정

그럼 이제 디버그냐 릴리즈냐를 구분하기 위한 RELEASE 변수를 어떻게 정하느냐만 남았군요. 뭐 다른 변수들처럼 Makefile 앞 부분에 선언해 두고 릴리즈냐 디버그냐에 따라 바꿔서 써 주면 되긴 합니다. 하지만 이렇게 하면 역시 버전 관리 툴로 관리할 때 실수할 가능성이 커집니다. 예를 들어 출시 직전이라는 ‘비상 상황’에 몰려 야근에 지친 개발자들이 다음과 같이 작업할 수 있다는 거죠…

  1. 디버깅을 위해 Makefile에서 RELEASE = 1 부분을 주석 처리하고, 디버깅을 합니다.
  2. 나중에 디버깅을 마치고, Makefile의 주석 푸는 걸 깜빡하고 다른 소스들과 함께 커밋합니다. 이런!!!

네, 나중에 최종 제품용 소프트웨어에 디버그 심볼과 메시지가 모두 포함되어 릴리즈 될테죠. 이런 극단적인 이유가 아니더라도, 개발 과정에서 이런저런 테스트 하다 보면, 릴리즈/디버그 모드를 바꿔 빌드해야 할 경우도 빈번히 발생할텐데, 그 때마다 Makefile을 수정하는 것은 어쨌든 번거로운 일입니다. 그래서 RELEASE 변수를 Makefile에 포함시키는 것 보다는, 빌드할 때마다, 다시 말해 make 실행 시킬 때마다 인자로 받는 게 좋습니다. 참고로 make는 실행될 때의 시스템 환경 변수에 선언된 값들도 Makefile 내에서 선언된 것과 동일한 변수로 받아들일 뿐 아니라, 실행 시 인자로도 변수를 받아 들일 수 있거든요. 즉, 쉘에서 다음과 같이 make를 실행하면 마치 Makefile내에서 RELEASE = 1 이라고 선언한 것과 동일한 효과를 가집니다. (단, 이때에는 = 전후에 공백이 들어가면 안 되고, RELEASE=1 이렇게 딱 붙여서 써야 합니다.)

$ make RELEASE=1 …

이렇게 make 실행 시 인자로 받아들이게 하면, 매번 실행할 때마다 개발자가 어떤 모드로 실행시키는 지를 끊임없이 상기시키기 때문에, 가끔 릴리즈 모드로 빌드 걸어둔 채, ‘아니 왜 디버그 메시지가 출력되지 않지?’ 하는 개발자 ‘멍모드’로 빠지는 불상사를 조금이나마 방지할 수 있습니다.

네? 릴리즈 모드로 빌드할 때마다 매번 RELEASE=1 를 입력하는 번거로움이 더 치명적이라고요… 진심인가요? 좋습니다. 모름지기 개발자라면 그정도의 게으름은 가지고 있는 것도 나쁘지 않죠. (엄청난 노가다를 두려워 않는 사람 보다는 훨씬 낫다고 생각합니다). 하지만 그 정도의 게으름의 소유자라면 당연히 자신의 편집기에 외부 쉘 명령을 실행시키는 명령어들을 단축키로 등록해서 쓰고 있을 테죠? make 실행 옵션을 디버그용, 릴리즈용으로 등록해서 쓰시도록 권장합니다.

[Tip] emacs 빌드 단축키 지정

제가 주로 사용(하려고 노력)하는 emacs의 경우, ~/.emacs 파일에 다음을 추가하면 F7/F9 키로 디버그용 빌드 및 전체 리빌드를, Ctrl + F7/F9 키로 릴리즈용 빌드 및 전체 리빌드를 수행할 수 있습니다. make 인자나 키 지정은 각자의 취향대로 설정할 수 있겠지요.

;; Compile debug command
(defun buildDebug ()
  (interactive)
  (compile "make")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(defun rebuildAllDebug ()
  (interactive)
  (compile "make clean depend all")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(defun buildRelease ()
  (interactive)
  (compile "make RELEASE=1")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(defun rebuildAllRelease ()
  (interactive)
  (compile "make RELEASE=1 clean depend all")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(global-set-key [(f7)] 'buildDebug)
(global-set-key [(f9)] 'rebuildAllDebug) 
(global-set-key [(control f7)] 'buildRelease)
(global-set-key [(control f9)] 'rebuildAllRelease)
Posted in 소프트웨어 | Tagged , , , , , , , | 3 Responses

Make 기반 빌드 시스템 (4): 라이브러리와 실행 파일의 분리

라이브러리와 실행 파일의 분리

이전 글에서 새로운 기능 추가를 가뿐하게 완료했습니다. 더불어 추후에 소스 파일들이 추가되거나 할 일이 있어도 쉽게 대응할 수 있는 것도 가능해 졌고요…

그런데 이제 이를 가지고 테스트를 하는데 대체로 잘 동작하는데 가끔씩 뭔가 오동작을 합니다. 여러분의 ‘완벽한’ 코드가 문제는 아닐 테고, 보나마나 저쪽 서버 사이드에서 뭔가 사양대로 구현되지 않았거나 예외 처리가 잘 안 되었을 가능성이 99.999999%일 테죠? 하지만 갑-을 관계의, 을 입장에서 그렇게 쉽게 얘기할 순 없지 않겠습니까?

어쩌나 고민 끝에 사양의 각 항목 별로 단순한 기능을 테스트해서 원인 규명을 하기로 결정합니다. 처음엔 테스트하는 부분 별로 조건부 컴파일 플래그를 만들어서 할까 했지만, 그러자니 매번 다른 테스트를 할 때마다 컴파일을 다시 해야 하는 게 귀찮습니다. 모든 기능을 다 넣은 다음 configuration 파일의 입력이나 환경 변수 등을 참조해서 테스트 기능이 동작하게 하는 방안은, 귀차니스트 입장에선 조건부 컴파일 플래그 안과 별 차이도 없는데다, 만약 릴리즈할 때 깜빡하면 테스트용 코드가 실제 프로그램에 들어갈 수 있다는 게 마음에 걸립니다. 그보다는 테스트 코드들을 별도의 프로그램들로 만들어 두면 두고두고 유용할 것 같습니다. 좋습니다. 별도 프로그램들로 만들기로 하죠.

TestConnection, TestDataChange, TestDrinking (응?), … 여러 개의 실행 파일들을 구현하는 건 그냥 하면 되는데, 현재의 Makefile에서는 MyClient 만 만들어지도록 되어 있다는 게 걸림돌이군요. 그리고 아마도 이 테스트 프로그램들은 MyClient와 소스의 많은 부분을 공유하겠지요. 어떻게 하는게 좋을까요. 그냥 각 테스트 프로그램마다 MyClient의 모든 소스 파일과 Makefile이 있는 디렉토리를 통째로 복사한 다음, MyClient.c 대신 TestConnection.c로 바꾸면 되는 것 아니냐고요? 이거 왜 이러세요, 코드 중복보다 더 무서운게 소스 파일의 중복이란 것쯤은 다 아시잖아요…

그 보다는 MyClient로부터 각 테스트에 공통으로 사용되는 부분을 분리해서 하나의 라이브러리로 만들면 어떨까요? 일단 라이브러리로 분리만 되어 있으면, 테스트 별 필요한 코드들만 별도의 소스 파일로 만들어서 컴파일하고, 만들어진 라이브러리와 링킹해서 각 테스트 프로그램들을 만들 수 있겠네요. 사실 그렇게 공용으로 쓰려고 만드는 게 라이브러리죠.

자, 이런 전차로 이제 TARGET 이라는 하나의 실행파일만 빌드하던 것을 한 개의 라이브러리와 복수 개의 실행 파일들로 분리해서 빌드하도록 하겠습니다.

라이브러리 만들기

일단 MyClient로부터 라이브러리로 분리해낼 부분들을 먼저 정합니다. 소스 파일들 중 MyClient.c 를 제외한 부분이 될텐데요, 이를 LIB_SRCS라고 하겠습니다. 그리고 MyClient.c에 있는 내용들 중에서도 서버쪽과의 프로토콜에 해당하는 부분, 그래서 다른 테스트 프로그램에서 공용으로 사용될 부분은 MyProtocol.c 라는 별도의 파일로 분리해서 저장하는 것으로 하죠. 그리고 빌드할 라이브러리의 이름은, 일단 libMyProtocol.a 라고 하겠습니다. 그럼 라이브러리 빌드를 위한 변수와 규칙은 다음과 같이 되어야 할 겁니다. 다음을 보세요.

AR = ar
RANLIB = ranlib

LIB_FULL_NAME = libMyProtocol.a

LIB_SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyProtocol.c
LIB_OBJS = $(LIB_SRCS:%.c=%.o)

$(LIB_FULL_NAME) : $(LIB_OBJS)
	$(AR) rcv $@ $(LIB_OBJS)
	$(RANLIB) $@

별거 없습니다. ar 을 사용해서 컴파일된 .o 파일들을 하나의 archive 파일을 묶고, ranlib을 실행해서 symbol index를 생성, 정적 라이브러리로 만드는 작업입니다. (ar과 ranlib이 무엇인지 궁금하신 분들은 man 내용을 참고하시거나, 각종 검색 엔진에서 검색해 보시면 되겠습니다. 매뉴얼과 검색의 생활화!!! 다 아시죠?). 그리고 ar과 ranlib을 직접 쓰는 대신, 이를 변수로 선언해서 이를 사용하도록 한 것이고요.

실행파일을 라이브러리와 링킹하기

일단 라이브러리가 이렇게 빌드되고 나면 이제 남는 것은 main() 함수가 있는 .c 파일들을 컴파일해서 이를 $(LIB_NAME)과 링킹하는 것입니다. 먼저 라이브러리에서 한 것과 비슷하게 소스파일, 목적 파일, 실행파일들을 각각 TARGET_SRCS, TARGET_OBJS, TARGET_NAMES를 정의해 보죠.

TARGET_SRCS = MyClient.c TestConnection.c ...
TARGET_OBJS = $(TARGET_SRCS:%.c=%.o)
TARGET_NAMES = $(TARGET_SRCS:%.c=%)

그런데 .c 로부터 .o 를 컴파일하는 것은 make의 내장 규칙에 의해 실행될 테니 문제 없지만, 각각의 .o를 libMyClient.lib을 링킹해서 실행 파일들을 생성하는 규칙의 작성은, 음… 좀 까다롭습니다.

규칙의 작성에 앞서, .o를 libMyClient.lib을 링킹해서 실행 파일들을 생성하기 위한 gcc 명령을 먼저 보죠. 다음과 같을 겁니다.

gcc -o MyClient MyClient.o -L. -lMyProtocol -lpthread
gcc -o TestConnection TestConnection.o -L. -lMyProtocol -lpthread
...

참고로, gcc에서 -L 옵션은 링킹할 라이브러리를 찾을 경로를 지정합니다. 따라서 -L. 은 앞서 만들어진 libMyProtocol.a가 현재 디렉토리에 존재하기 때문에 현재 디렉토리를 라이브러리 검색 경로에 지정하는 것이죠.

이를 make에서 수행하기 위해 필요한 Makefile의 규칙들은 다음과 같습니다.

LIB_NAME = MyProtocol
LIB_FULL_NAME = lib$(LIB_NAME).a

LIBS += -l$(LIB_NAME)
LIB_DIRS = -L.

$(LIB_FULL_NAME) : $(LIB_OBJS)
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@

MyClient: MyClient.o
    $(CC) -o $@ MyClient.o $(LIB_DIRS) $(LIBS)
TestConnection: TestConnection.o
    $(CC) -o $@ TestConnection.o $(LIB_DIRS) $(LIBS)
TestDrinking: TestDrinking.o
    $(CC) -o $@ TestConnection.o $(LIB_DIRS) $(LIBS)

(1)는 링킹할 때 사용할 단순한 라이브러리 이름인데요, 이는 (4)에서 사용됩니다. (2)는 lib 과 .a 까지 다 있는, 파일 시스템 상의 라이브러리 파일 이름이고, (7)에서 규칙의 타겟으로 사용되고요.

(4)는 이전에 정의된 LIBS에다 -lMyClient 를 추가한 것입니다. 참고로 Makefile에서 변수 다음에 = 를 쓰면 변수를 새로 정의하는 것이고요, += 를 사용하면 기존 값에다 추가하는 것입니다. (5)의 LIB_DIR은 라이브러리 검색 경로를 지정하는 변수이고요.

(11) ~ (16)은 각 프로그램을 만들기 위한 규칙들입니다. 빌드는 됩니다만, 개별 프로그램 하나 하나에 대해 거의 똑같은 별도의 규칙들을 작성하는 건 좀 내키지 않습니다. 규칙의 타겟을 가리키는 $@를 사용해서 선행조건을 일반화해서 하나의 규칙으로 만들 수 있지 않을까요? 다음과 같이 말입니다.

$(TARGET_NAMES): $@.o
    $(CC) -o $@ $@.o $(LIB_DIRS) $(LIBS)

각 규칙에서 $@가 MyClient, TestConnection, TestDrinking을 각각 가리킬 테니까, $@.o 는 각각 MyClient.o, TestConnection.o, TestDrinking.o 를 가리킬 테고요, 그럼 저 규칙 하나로 멋지게 빌드가 될 것 같습니다. 와우! 하지만 불행히도, 위의 규칙을 실행하면 다음과 같은 결과가 나옵니다.

$ make
gcc      -o .o
gcc: no input files
make: *** [.o] 오류 1

이는 make의 $@ 같은 자동 변수는, 레시피에서만 값을 가지기 때문입니다. 따라서 선행조건에서는 빈 문자열을 나타내고, 결과적으로 gcc는 입력 파일을 주지 않은 채, .o 라는 이름으로 컴파일하라는 명령만 받은 셈이니 gcc가 불평하는 게 당연한 것이죠.

비록 최종으로 원하는 결과를 얻지는 못했지만, 중요한 거 하나를 생각해 냈군요. 선행조건에 $@ 에 해당하는 것만 표현할 수 있으면 하나의 규칙으로 정리가 가능하다는 것입니다. 다행히, make는 정확하게 그에 해당하는 기능을 가지고 있는데요, Secondary Expansion이 바로 그것입니다.

Secondary Expansion

Second Expansion을 알기 위해서는 먼저 make가 Makefile을 어떻게 읽어 들이는 지에 대한 좀더 구체적인 이해가 필요합니다.

make는 크게 2개의 단계로 Makefile을 읽어 들입니다. 첫 번째 단계는 read-in phase로서, make는 입력으로 지정된 Makefile의 모든 내용을, include된 다른 파일들까지 포함해서 모두 읽어 들인 후, 모든 변수들과 규칙들을 정의하고, 규칙들의 타겟들과 선행조건들의 의존성 그래프를 구조화합니다. 그리고 두 번째 단계, target update phase에서, make는 이러한 내부 구조들을 사용해서 어떤 타겟이 다시 만들어져야 하는지, 그리고 어떤 규칙들이 실행되어야 하는 지를 결정합니다.

이 과정에서 변수의 값이 결정되는 것을 expansion이라고 하는데, 보통의 경우는 대부분 read-in phase에서 expansion이 일어납니다. 하지만 make는 특정 규칙들에 대한 선행조건 목록에 한해서, expansion을 나중으로 미룰 수 있는 기능을 제공하는데요, 이를 secondary expansion이라 합니다.

Makefile 내에서 .SECONDEXPANSION: 이 선언되어 있으면, 이후 규칙의 선행조건 목록에서 $$()로 참조되는 변수의 expansion은 read-in phase에서 일어나지 않고, 단지 $ 하나를 떼면서 target update phase로 보류됩니다

알듯말듯, 좀 어려운가요? 역시 개발자한테는 예제가 빠르죠. 다음을 보시기 바랍니다. (해당 예제는 gnu manual site에서 인용했습니다.)

.SECONDEXPANSION:
AVAR = top
ONE_FILE: $(AVAR)
TWO_FILE: $$(AVAR)
AVAR = bottom

(2)에서 AVAR은 top 으로 지정되어 있습니다. (3)의 $(AVAR)은 read-in phase에서 expansion이 일어나게 되고, 따라서 ONE_FILE의 선행조건은 top이 됩니다. target-update phase에서도 달라지는 게 없구요.

하지만 (4)의 $$(AVAR)은 read-in phase에서는 $ 하나만 떼고 expansion이 target-update phase로 보류됩니다. 즉, read-in phase에서 TWO_FILE의 선행조건은 그냥 문자열 그대로 “$(AVAR)”이 되는 거지요. 이후 (5)에서 AVAR는 bottom으로 지정됩니다. 그리고 난 다음 target-update phase에서 (4)의 $(AVAR)에 대한 second expansion이 일어나고, 그 결과 TWO_FILE의 선행조건은 bottom 이 되는 것입니다.

사실 위 예제는 second expansion의 극적인 효과를 확인하는 것으로는 좋지만, 그 이상의 의미는 없습니다. 중요한 건 second expansion을 통해, 우리가 필요로 하는 규칙을 만들 수 있다는 거죠. 바로 앞의 단락에서 필요로 했던, 여러 개의 실행 binary를 만들어내는 하나의 규칙 말입니다. 다음을 보세요.

.SECONDEXPANSION:
$(TARGET_NAMES): $$@.o
    $(CC) -o $@ $< $(LIB_DIR) $(LIBS)

$@는 선행조건에서 빈 문자열을 나타내지만, .SECONDEXPANSION: 으로 지정된 상태에서의 $$@.o는 second expansion에 의해 make가 규칙을 실행하는 단계에서 각각 MyClient.o, TestConnection.o, TestDrinking.o 로 치환될 수 있습니다. 네, 우리가 앞서 필요로 한 바로 그 규칙입니다.

참고로 위 규칙의 레시피에서 $< 는 $@ 와 마찬가지로 make에서 내장한 자동변수인데요, 이는 선행조건의 첫 번째 항목을 나타냅니다. 따라서 $$@.o 에 의해 치환되는 MyClient.o, TestConnection.o, TestDrinking.o가 되는 것이죠.

결과적으로 바로 이 규칙 하나로 TARGET_NAMES로 지정된 실행 파일들이 모두 compile되고 libMyCleint.a 와 링킹됩니다. (위 예제에서 $@.o 와 $< 는 동일한 결과를 가져옵니다만, 정확하게 가리키는 $< 쓰는 것이 더 낫겠지요.)

남은 건, depend와 clean에 TARGET_NAMES와 LIB_NAME 관련된 부분을 추가하는 건데요, 이건 뭐 누워서 떡 먹기죠. 먼저 depend 규칙은, gcc -MM 할 때 TARGET_SRCS를 추가하기만 하면 됩니다. 마찬가지로, clean도 $(LIB_NAME)와 $(TARGET_NAMES), 그리고 $(TARGET_OBJS)들이 같이 삭제되도록, rm -f 명령에 추가만 하면 끝입니다. 다음과 같이 말이지요.

depend :
    $(CC) -MM $(LIB_SRCS) $(TARGET_SRCS) > $(DEPEND_FILE)

clean :
    rm -f $(LIB_FULL_NAME) $(LIB_OBJS) $(TARGET_NAMES) $(TARGET_OBJS) $(DEPEND_FILE)

자 이제까지 나온 모든 것을 하나로 정리하면 예제 4.1과 같습니다.

예제 4.1
DEPEND_FILE = depend_file

CC = gcc
AR = ar
RANLIB = ranlib

LIB_NAME = MyProtocol
LIB_FULL_NAME = lib$(LIB_NAME).a

LIB_SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyProtocol.c 
LIB_OBJS = $(LIB_SRCS:%.c=%.o)

LIBS = -lpthread
LIBS += -l$(LIB_NAME)
LIB_DIR = -L.

TARGET_SRCS = MyClient.c TestConnection.c TestDataChange.c TestDrinking.c 
TARGET_OBJS = $(TARGET_SRCS:%.c=%.o)
TARGET_NAMES = $(TARGET_SRCS:%.c=%)

.SUFFIXES : .c .o

all : $(LIB_FULL_NAME) $(TARGET_NAMES)

$(LIB_FULL_NAME) : $(LIB_OBJS)
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@

.SECONDEXPANSION:
$(TARGET_NAMES): $$@.o
    $(CC) -o $@ $< $(LIB_DIR) $(LIBS)

depend :
    $(CC) -MM $(LIB_SRCS) $(TARGET_SRCS) > $(DEPEND_FILE)

clean :
    rm -f $(LIB_FULL_NAME) $(LIB_OBJS) $(TARGET_NAMES) $(TARGET_OBJS) $(DEPEND_FILE)

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(strip $(LIB_SRCS) $(TARGET_SRCS)),)
-include $(DEPEND_FILE)
endif
endif
endif

(34)과 (41)에서, 반복적으로 사용되는 $(LIB_SRCS) $(TARGET_SRCS) 도 거슬리는 깔끔한 (성격의) 분이라면 이를 $(SRCS)라고 별도로 정의해서 사용해도 좋겠네요.

참고로, (41)에 쓰인 $(strip string) 은 string의 앞뒤에 있는 공백문자들을 제거하고 내부에 있는 하나 이상의 공백문자들을 단일 스페이스로 교체하는 make 내장 함수입니다. 그냥 ifneq ($(LIB_SRCS) $(TARGET_SRCS),) 로 쓰게 되면, $(LIB_SRCS)와 $(TARGET_SRCS) 사이에 공백 하나로 인해, $(LIB_SRCS) 와 $(TARGET_SRCS) 가 모두 지정되지 않았음에도 해당 조건이 ” 와 다르다고 판별되어 (42)의 include 문이 실행되는데, $(strip)을 써서 이를 막는 것이죠.

이제 라이브러리에 .c 소스 파일 추가할 때에는 LIB_SRCS에 해당 파일을 추가하면 되고, 실행 파일을 위한, main() 함수가 있는 소스 파일을 추가할 때는TARGET_SRCS 에 추가하면 됩니다. 아, 물론 소스가 바뀌면, make depend 한번 돌려주는 것 잊지 말아야겠죠?

Posted in 소프트웨어 | Tagged , , , , , , , | 10 Responses

Make 기반 빌드 시스템 (3): 소스 목록 관리를 조금 편리하게

소스 목록 관리를 조금 편리하게

이전 글에서 간단한 예제를 가지고, make의 기본적인 동작 방식을 알아 보았습니다. 소켓 기능을 사용해서 간단한 서버를 만드는 것이었죠.

대단히 짧은 기간에 make로 빌드한 MyClient의 기본 동작에 문제 없음을 확인했다는 기쁨도 잠시, 이번엔 PM 또는 영업사원, 혹은 마케팅 담당자? 하여간에 별로 이쁘지 않은 사람이 와서는 이렇게 떠들어 댑니다. MyClient가 아주 괜찮은 프로그램이라며, 여러 개의 서버들로과의 동시 접속을 처리해 주는 기능만 지원 해주면, 어디 가서 데모해서 계약을 따 올 수 있다고 말이죠… 비슷한 지난 경험들로 생각하면 그닥 신뢰가 가지는 않지만, 매출을 올릴 수 있다는 데다, 이게 이런 건 당신한테는 일도 아니잖아, 나 한번만 도와주라… 결국 동시 접속 처리 기능을 추가해야 하는 상황이 됩니다. 흠… 별로 달갑진 않지만, 어쩝니까, 구현해야죠. (아아 이게 바로 그 개발자 인생이죠.)

동시 접속 처리 기능이라… 멀티 쓰레딩 구조로 바꾸는 게 구현하기 좀 까다롭기는 하지만, 리눅스에서 지원되는 pthread library와, mutex 나 semaphore로 동기화만 잘 해주면 된다는 걸 아는 개발자한테는, 사실 아주 어려운 일은 아닐 겁니다. MyThread.c, MyThread.h, MyMutex.c, MyMutex.h, MySemaphore.c, MySemaphore.h 이렇게 총 6개의 파일을 후다닥 작성한 다음, 이제 Makefile에 추가하려 하는데, 문제는 이렇게 추가된 소스 파일들을 예제 2.2에 추가하려니, 해야 할 일이 좀 된다는 거죠.

첫번 째로 일단 (2)의 OBJS 선언부에 MyThread.o MyMutex.o MySemaphore.o 를 추가해야 하고, 두번 째로 다음으로 (15) 이후에 새로 추가된 .c 파일들의 의존성을 나타내는 규칙들을 작성해햐 합니다.

첫 번째 단계는 그다지 어려울 게 없지만, 두 번째 단계는 #include 한 거에 맞는 규칙을 작성해햐 한다는 게 좀 부담스럽습니다. 한번이라면 눈 딱 감고 노가다 뛸 수도 있겠지만, #include가 추가될 때마다 의존성 규칙을 수정해야 할텐데, 이건 보통 인내심의 개발자라면 받아들이기 힘든 노가다입니다. 갑자기 멀티 쓰레딩 기능이 추가된 마당에, 앞으로 또 어떤 일이 벌어질 지 모르는 것 아니겠어요? 그런 노가다를 반복적으로 할 수는 없죠.

gccmakedep 사용해서 의존성 규칙 추가하기

그러자면 먼저 의존성을 처리하기 위한, 좀 편리한 방법이 필요합니다. 소스 파일들 사이의 의존 관계를 따지는 일이라… 각종 스크립트 언어들과 정규식 표현들과 같은 여러 가지 재능들을 겸비한 능력자라면, 한번 도전해 볼 수 있는 일이긴 합니다. (벌써 편집기를 여셨다고요? 워워, 잠깐만 참으세요.) 사실 이건 컴파일러의 기본 기능입니다. 따라서 이 일을 가장 잘 할 수 있는 건 당연히 컴파일러입니다. C 소스 파일이니까 각종 조건부 컴파일 옵션도 따져 줘야 하고, 현재 디렉토리 외에 gcc 옵션 중 -I 옵션에 의해 지정되는 외부 include 경로까지 따져야 합니다.

네, 이건 꼭 gcc의 기능에 의존해야만 하는 일입니다.

게다가 gcc 는 주어진 소스 파일들 사이의 의존 관계를 생성해 주는 옵션을 제공하고 있습니다. gcc -M 이 바로 그 옵션인데요, 이를 이용해서 소스 파일들의 의존 관계를 규칙으로 만들어서 Makefile 내에 자동으로 삽입시켜 주는 프로그램이 있습니다. 바로 gccmakedep 입니다.

gccmakedep의 사용법은 간단합니다. 그냥 소스 파일들 목록과 컴파일에 필요한 여러 옵션들(gcc 를 이용하니까 당연한 겁니다.)을 인자로 주고 gccmakedep 를 실행만 하시면 됩니다. 그러면, gccmakedep는 gcc한테 부탁해서 의존성을 알아내고 이를 현재 디렉토리의 Makefile 파일 끝부분에 추가해 주기까지 합니다. (만약 Makefile 이름을 별도로 하고 싶으면 -f makefile_name 옵션을 주면 됩니다.)

백번 듣는 것보다는 한번 보는 것이, 그리고 실행해 보는 것이 나은 법! gccmakedep을 예제 2.2이 있는 디렉토리에서 다음과 같이 실행해 보세요.

$ gccmakedep MySocket.c MyClient.c

그러면 Makefile의 내용이 다음과 같이 바뀌게 됩니다.

예제 3.1
CC = gcc
OBJS = MySocket.o MyClient.o
TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $(TARGET) $(OBJS)

clean:
    rm -f $(OBJS) $(TARGET)

MySocket.o : MySocket.c MySocket.h
MyClient.o : MyClient.c MySocket.h
# DO NOT DELETE
MySocket.o: MySocket.c MySocket.h
MyClient.o: MyClient.c MySocket.h

(17) ~ (19)까지가 추가된 부분입니다. gccmakedep는 친절하게도 Makefile 끝 부분에 # DO NOT DELETE 라는 줄을 추가한 다음 그 밑에 소스 코드들 사이의 의존성을 나타내는 규칙들을 작성해 줍니다. 따라서 (15) ~ (16) 사이에 손으로 작성한 부분은 필요가 없으니, 그냥 삭제해 버리면 되겠네요.  

depend 규칙 추가

gccmakedep를 써서 의존성 규칙이 자동으로 포함되게 하는 것은 좋습니다. 그런데, 매번 손으로 gccmakedep를 실행시키는 것은 그 자체로 번거로울 뿐더러, 소스 코드 파일들이 변경되었을 때 실수할 가능성이 높습니다. (저는 요런 일 할 때의 제 자신을 그다지 신뢰하지 않습니다. 네? 본인은 안 그럴 것 같다고요? 설마, 그럴리가요…) 그럼, 그런 실수 가능성을 없애기 위해서 어떻게 하는 게 좋을까요? 그렇습니다. Makefile 내에 포함시키면 간단해 지겠지요.

예제 3.2
CC = gcc

OBJS = MySocket.o MyClient.o
SRCS = MySocket.c MyClient.c

TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $(TARGET) $(OBJS)

depend:
    gccmakedep $(SRCS)

clean:
    rm -f $(OBJS) $(TARGET)

예제 3.2를 보시면 (15) ~ (16)의 depend라는 규칙을 추가했습니다. 이 녀석은 선행조건이 없기 때문에 make depend 하고 실행할 때 gccmakedep 를 실행합니다. 단, 소스 파일들을 타이핑하는 것 대신, $(OBJS)를 선언한 것처럼 (4)의 SRCS라는 변수를 별도로 선언한 다음, $(SRCS)를 입력했습니다. 이렇게 하면 새로운 소스 코드를 추가할 때 OBJS 와 SRCS를 한번에 추가하면 되니까 아무래도 실수 가능성이 줄겁니다. 줄긴 하는데, 그래서 이번 장 처음의 상황에 비하면 많이 좋아지긴 했지만, 거의 똑같고 단지 확장자만 바꾼 OBJS 와 SRCS를 작성한다는 게 좀 마음에 걸립니다.

뭐라고요? 현재 사용하시는 편집기에서 .o 를 .c로 바꾸는 건 일도 아니라구요? 네, 여러분의 편집기와 그걸 사용하는 여러분의 능력을 무시하는 건 결코 아닙니다만, 사실 그 일은 make가 더 정확하게 잘 할 수 있거든요.

make는 변수들을 조작하는 여러 기능을 가지고 있는데요,변수 치환 기능을 이용한 다음 한줄로 SRCS로부터 OBJS를 만들어 낼 수 있습니다.

OBJS = $(SRCS:%.c=%.o)

척 봐도 짐작 가능하시죠? 내용인즉슨, SRCS 변수 내용 중에서 .c 인 부분을 .o 로 바꾸라는 내용입니다. 따라서 예제 3.2의 (3) ~ (4)은 다음과 같이 바뀔 수 있습니다.

SRCS = MySocket.c MyClient.c
OBJS = $(SRCS:%.c=%.o)

음, 이제 깔끔해 졌습니다. 소스 코드가 추가되면 그냥 SRCS 에 .c 파일들만 쭉 추가하면 됩니다. (저같이 타이핑이 귀찮으신 분은, 터미널에서 ls 한 결과를 붙여 넣어도 됩니다.) 그리고 나서 make depend 한번 실행해 주면 끝입니다. 그리고 소스 코드 내에 새로운 #include 가 추가될 때마다 그냥 make depend 만 한번 실행해 주면 되니까, 의존성 누락으로 인한 어이없는 컴파일 에러 및 디버깅할 일이 없어지고, 그만큼 칼퇴근 가능성이, 아주 약간은 높아지겠군요. 좋습니다.

링킹 라이브러리 추가

그럼 이제 할 일은 Makefile의 SRCS 에다 새로 추가되는 .c 파일들을 넣기만 하면 되는 거죠. 다음과 같이 말입니다.

...
SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyClient.c
OBJS = $(SRCS:%.c=%.o)
...

그런데 이러고 나서 빌드하니, 다음의 메시지가 나옵니다.

gcc -o MyClient MySocket.o MyClient.o MyMutex.o MySemaphore.o MyThread.o
MySemaphore.o: In function `NewSemaphore':
MySemaphore.c:(.text+0x8f): undefined reference to `sem_init'
MySemaphore.o: In function `DeleteSemaphore':
MySemaphore.c:(.text+0x11c): undefined reference to `sem_destroy'
MySemaphore.o: In function `LockSemaphore':
MySemaphore.c:(.text+0x150): undefined reference to `sem_wait'
MySemaphore.o: In function `UnlockSemaphore':
MySemaphore.c:(.text+0x181): undefined reference to `sem_post'
MyThread.o: In function `RunThread':
MyThread.c:(.text+0x1e9): undefined reference to `pthread_attr_setstacksize'
MyThread.c:(.text+0x26e): undefined reference to `pthread_create'
MyThread.o: In function `QuitThread':
MyThread.c:(.text+0x321): undefined reference to `pthread_join'
collect2: ld returned 1 exit status

아차, 링커가 내뿜는 에러군요. semaphore와 pthread 관련 함수를 썼으니, 링킹 시에 pthread 라이브러리를 옵션을 줘야 하는데 그 부분이 빠져 있기 때문입니다. 따라서 링킹 에러를 없애는 건, 예제 4의 $(TARGET) 규칙의 레시피에다 -lpthread를 추가해 주면 끝(참고로 gcc에서의 라이브러리 링크는, -l 뒤에다 .확장자를 제외한 라이브러리 이름을 주면 됩니다.)입니다만, 프로그램 개발 과정에 필요한 라이브러리들이 한둘이 아닐테죠. 이 참에 링킹할 라이브러리들을 처리하기 위해 별도의 변수를 하나 추가하고, 링킹할 때 이를 사용하는 게 좋겠습니다.

예제 3.3
CC = gcc

SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyClient.c
OBJS = $(SRCS:%.c=%.o)

LIBS = -lpthread

TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $(TARGET) $(OBJS) $(LIBS)

depend:
    gccmakedep $(SRCS)

clean:
    rm -f $(OBJS) $(TARGET)

(6)의 LIBS라는 변수를 하나 선언해 두고, 이를 -lphtread로 설정한 다음, (15)의 링킹 명령어에 이를 추가합니다. 이후 make depend 실행한 다음 make를 실행합니다. 네, 이번엔 문제 없이 잘 빌드됩니다. 그리고 앞으로는 추가로 링킹할 라이브러리가 생기면 그냥 LIBS에다 추가만 하면 끝입니다.

의존성 규칙를 Makefile에서 분리하기

이제, 딱 하나만 고려하면 될 것 같습니다. 혼자 개발하는 거면 이제까지 한 얘기로 충분합니다. 그렇지만, 이제는 전산 수업의 과제도 여러 명이 조 짜서 해야 하는 세상입니다. 웬만한 조직에서는 여려 개발자가 함께 협업을 하는 게 보통이겠지요. 그게 빌드 시스템이랑 무슨 상관이람? 아시겠지만, 여러 명이 동시에 소프트웨어 개발을 하다보면 버전관리 툴의 사용은 필수입니다. (사실 혼자서 작업할 때도 버전관리 툴을 사용하는 건 필수죠.)

어이 거기, 고개 숙이시고 계신 분… 네? 버전관리 툴을 안 쓰신다고요? 사는데 지장이 없다고요? 만약에 아직 버전관리 툴이 없는 상태에서 개발중이시라면, make 빌드 시스템 구축 전에 버전관리 툴부터 구축하시기를 권해드립니다. 지금 당장이요. 찾아보면 훌륭한 기능을 갖췄으면서도 공짜로 쓸 수 있는 버전관리 툴이 많이 있고요, 구축 및 사용과 관련된 훌륭한 가이드 문서들도 많이 있습니다. 조금만 시간 들여서 버전관리 툴을 구축해 놓으면 말로 헤아릴 수 없는 혜택을 보실 겁니다.

흠… 예전에 버전관리 툴 없던 곳에 몸 담았던 암울한 기억에 잠깐 흥분했네요. 다시 본론으로 돌아와서, Makefile이랑 협업과 버전관리 툴이 도대체 웬 상관이냐고 하실 수 있습니다만… 상관이 있을 수도 있습니다.

딴게 아니라 이겁니다. gccmakedep를 사용하면 Makefile 내에 의존성 규칙들이 추가됩니다. 보통 Makefile은 소스와 함께 같이 버전관리 툴에 포함됩니다. 당연하지요. 모든 개발자가 버전관리 툴에서 받아온 소스를 빌드하면 동일한 결과가 나와야 할테니까요. 그런데 Makefile 내에 의존성 부분이 포함되어 있으면 이게 약간 문제가 될 소지가 있습니다.

예를 들어 디지털 녹화기나 네트워크 카메라 같은 임베디드 시스템에 탑재될 소프트웨어를 개발한다고 가정해 봅시다. 메인 칩이나 하드웨어 사양에 따라 기능과 포함되는 소스가 조금씩 달라질 수 있을 겁니다. 칩 업체에서 제공되는 특정 헤더 파일들이 포함되었다 빠졌다 할 수도 있고요. 그럴 때마다 의존성 규칙이 달라지기 때문에, make depend 해서 Makefile이 변경되어야 합니다.

또 새 기능을 추가할 때면, 각 플랫폼에 맞춰 빌드하고 테스트를 해야 하는데, 플랫폼마다 Makefile이 달라질, 이 부분이 변경 히스토리에 포함되면 좋을 게 없습니다. Makefile의 변경 이력 추적할 때는, 소스 파일의 추가, 삭제 그리고 컴파일 옵션의 변경 등과 같은 실제적으로 의미 있는 것만 나오는 게 좋은데, 기계적으로 생성가능한 의존성 부분이 들어감으로 인해 변경량이 많아 져서 눈에 자꾸 거슬립니다.

이걸 막으려면 개발자들이 테스트 다 하고 나서, 버전관리 툴에 커밋(또는 체크인)하기 전에 Makefile에서 이 부분을 빼고 커밋해 줘야 하는데, 맨날 야근에 찌든 개발자들은 이 부분을 깜빡하기 쉽습니다. 아주 가끔이라도 이상한 의존성 규칙이 있어서 이상하게 빌드되면, 아아 정말 생각만으로도 갑갑합니다.

앓는 소리는 다 빼고, 경험적으로 봤을 때도, 소스 코드 사이의 의존성 부분은 Makefile에서 빼는 게 좋더라는 겁니다. 아니 그럼, 손으로 수작업하면 안 되고, gcc 를 이용해서 의존성 규칙을 만들어 써야 한다더니, 어쩌라고요 싶으신가요? (흠흠… 두 가지를 만족하는 방법이 있으니까 이렇게 잘난 척 얘기하는 겁니다.)

우리의 친구 make는 정말이지 다재다능한 녀석이어서, 우리가 (마치 c 파일처럼) Makefile 에서 다른 파일을 include 할 수도 있습니다. gcc 를 이용해서 의존성 규칙을 만들어 사용하되 Makefile 에서 제외시키는 방법이 바로 이겁니다. 의존성 파일은 외부에다 만들고 이를 Makefile 내에 포함시키는 거지요. 먼저 의존성 규칙을 외부에다 만들려면 다음과 같이 하면 됩니다. gcc -MM 실행결과를 depend_file 이라는 파일에다 저장하는 것이죠.

$(CC) -MM $(SRCS) > depend_file

그 결과는 다음과 같습니다. 당연한 결과죠.

$ cat depend_fle
MySocket.o: MySocket.c MySocket.h
…
MyClient.o: MyClient.c MySocket.h

이제 이 depend_file을 Makefile 내에 포함만 시키면 되겠네요, 그건 Makefile 내에 다음 내용을 넣는 것으로 할 수 있습니다.

...
include depend_file
...

이렇게 하면 이는 Makefile 내에 depend_file을 직접 입력한 것과 동일한 효과를 지닙니다. 원하는 바를 다 이룬 것 같습니다만, 아직 약간의 문제가 남아 있습니다. 만약에 depend_file이 없는 경우에는 make가 이것 때문에 실행을 멈춘다는 것입니다. 즉, make 실행하기 전에 반드시 make depend 한번 해 주도록 강제하는 것이죠. 이게 일반 개발자한테는 좋은 수도 있겠지만, 개발용이 아니라 단순히 일회성 빌드만 수행해야 하는 경우, 예를 들어 릴리즈용으로 빌드하는 경우라면, depend 하느라 쓸데없이 시간만 잡아 먹을 필요는 없겠지요. gcc -MM 을 실행하는 거란 것 기억나시죠? 소스 파일이 많아지고 include 하는 회수와 include path가 많아지면 의존성 찾는 데도 꽤 시간이 걸립니다.

당연히 make는 이런 것도 다 고려하고 있습니다. include 앞에 – 를 붙이면, 파일이 없는 경우 그냥 무시하고 넘어갑니다. 따라서 이렇게 고치면 되겠지요.

-include depend_file

그리고 이왕 빌드 시간을 조금이라도 줄이자는 차원에서 하나 더 덧붙이자면, 사실 의존성 규칙은 컴파일하고 링킹할 때만 필요한거지, clean 또는 depend 같은 규칙을 실행할 때는 필요가 없습니다. 따라서 불필요하게 depend_file을 읽어들일 필요가 없게끔 해서 나쁠 건 없을 겁니다. 다음과 같이요.

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(SRCS),)
-include depend_file
endif
endif
endif

제가 Makefile에서도 조건문을 검사할 수 있다는 건 아직 얘기하지 않았죠? Makefile에서 특정 조건이 만족될 때만 시행하고 싶은 경우에는 ifeq ( condition ) … endif 를, 그 반대라면 ifneq 을 쓰면 됩니다. 그리고 make는 MAKECMDGOALS 라는 내장 변수를 가지고 있는데요, 이는 make 실행할 때 인자로 지정한 규칙이 되겠습니다. 아무 것도 지정되지 않으면 빈 값이 됩니다.

따라서 위의 내용은 실행 시 지정된 규칙이 clean이 아니고, depend도 아니면서, SRCS 파일이 빈 값이 아닐 때만, 그리고 depend_file이 있을 때에만, include 하게 됩니다. 이로서 불필요한 depend_file를 포함하는 일을 피할 수 있는 거지요.

마지막으로 clean 할 때 depend_file도 같이 삭제되도록 하는 것만 넣으면 끝입니다.

clean :
    rm -f $(OBJS) $(TARGET) $(DEPEND_FILE)

이상의 모든 내용을 적용한 게 예제 3.4 입니다.

예제 3.4
DEPEND_FILE = depend_file

CC = gcc

SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyClient.c
OBJS = $(SRCS:%.c=%.o)

LIBS = -lpthread

TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $@ $(OBJS) $(LIBS)

depend :
    $(CC) -MM $(SRCS) > $(DEPEND_FILE)

clean :
    rm -f $(OBJS) $(TARGET) $(DEPEND_FILE)

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(SRCS),)
-include $(DEPEND_FILE)
endif
endif
endif

이제 버전관리 툴에 들어가는 Makefile에는 의존성 관련 부분이 모두 빠집니다. 대신 각 개발자가 버전관리 툴로부터 소스를 받아온 다음 make depend만 한번 수행해 주면 됩니다. 개발용이 아니라 릴리즈를 위해 빌드할 때처럼 한번만 빌드하는 경우라면, make depend 할 필요 없이 그냥 make 하면 그만입니다.

네, 원하는 대로 다 되었네요. 다음 글에서는 라이브러리와 실행 파일을 분리하는 것에 대해 정리해 보도록 하죠.

Posted in 소프트웨어 | Tagged , , , , , , | 6 Responses

Make 기반 빌드 시스템 (2): Makefile 기초

Makefile 기초

간단한 예제

리눅스 상에서 IDE 없이 C/C++로 어떤 실행 프로그램을 만들 때에는, 보통 gcc 와 make 를 가지고 시작합니다. 적당한 오픈 소스 프로젝트의 소스에 있는 Makefile를 기반으로 시작할 수 있겠지만, make에 대한 기본적인 공부도 할 겸, 일단은 간단한 예제로부터 시작해 보겠습니다.

간단한 네트워크 프로그램을 작성할 일이 생겼다고 해 보죠. 다행히 여러분은 이미 예전에 작성해 둔 소켓 모듈(MySocket.c, MySocket.h)들이 있기 때문에, 그리 어려운 일은 아닙니다. MyClient.c이라는 파일에다 MySocket.h를 include해서 socket 모듈에 있는 socket 관련 함수를 쓰고, 몇 가지 필요한 기능만 추가해서, MyClient로 빌드하면 끝입니다. 다음은 MyClient를 빌드하기 위한 Makefile입니다.

예제 2.1
CC = gcc
OBJS = MySocket.o MyClient.o
TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET): $(OBJS)
    $(CC) -o $@ $(OBJS)

clean :
    rm -f $(OBJS) $(TARGET)

네, 공백 다 포함해도 13줄 밖에 안 됩니다. 처음 보는 분이라면 정말로 되나 싶을 수도 있을 테지만, 저 정도는 너무 우스운 분들이 대부분 일거란 것 쯤은 저도 압니다. 그런 분들은 그냥 재미삼아 따라 오시든가, 혹은 제가 허튼 소리 하는 건 아닌 지 지켜 보셔도 되겠습니다.

변수 (Variable)

먼저 (1) ~ (3) 사이에 있는 CC, OBJS, TARGET 들은 우리 소프트웨어 개발자에게는 그 이름도 익숙한 ‘변수’들입니다. 즉 Makefile에서 특정한 문자열을 표현하기 위해 사용합니다. (다른 문서에서는 매크로라고 부르기도 합니다.) 변수는 보통 = 에 의해 선언되고요, 이를 참조할 때에는 $(변수이름) 으로 하게 됩니다. 참고로 변수의 이름은 ‘:’, ‘#’, ‘=’, 또는 시작이나 끝에 공백문자를 담고 있지 않는, 문자들의 임의의 조합이면 됩니다. 하지만 알파벳, 숫자, 그리고 밑줄이 아닌 것을 담고 있는 변수 이름들은 피하는 것이 좋습니다. 미래에 다른 특수한 의미를 가질 수 있기 때문이죠. 또한 변수 이름은 대소문자를 구분합니다. 즉, ‘foo’, ‘FOO’, ‘Foo’ 는 모두 서로 다른 변수가 됩니다. 그리고 꼭 그래야만 하는 것은 아닌데, 관례적으로 Makefile에서 변수이름은 보통 대문자로 구성하고 길어질 때에는 ‘_’ 를 이용해서 읽기 좋게 만듭니다. 예를 들어 PROJECT_NAME 뭐 이런 식으로요. 변수는 일단 이정도로만 설명하도록 하고 다음으로 넘어가겠습니다.

확장자 규칙 (Suffix Rule)

(5)의 .SUFFIXES 는 make의 확장자 규칙인데요, GNU Make강좌의 3장에 잘 정리되어 있어, 그대로 인용해 봅니다. (http://wiki.kldp.org/KoreanDoc/html/GNU-Make/GNU-Make-3.html)

확장자 규칙이란, 간단히 말해서 파일의 확장자를 보고, 그에 따라 적절한 연산을 수행시키는 규칙이라고 말할 수 있다. 가령 .c 파일은 일반적으로 C 소스 코드를 가리키며, .o 파일은 목적 파일(Object file)을 말하고 있다. 그리고 당연히 .c 파일은 컴파일되어서 .o 파일이 되어야 하는 것이다. 여기서 한가지 변수가 등장하게 된다. .SUFFIXES 라고 하는 변수인데 우리가 make 파일에게 주의 깊게 처리할 파일들의 확장자를 등록해 준다고 이해하면 될 것이다. .SUFFIXES : .c .o 위의 표현은 ‘.c’ 와 ‘.o’ 확장자를 가진 파일들을 확장자 규칙에 의거해서 처리될 수 있도록 해준다.

Makefile 내에서 (1)과 같이 지정되면, 내가 작성한 Makefile에 별도의 규칙이 없을 경우에는, make에 내장된 기본 규칙 중에서, 확장자가 .c 및 .o 와 관련된 규칙을, make가 자동으로 적용해서 처리하게 됩니다. 다시 말해서 .c 로 된 C 소스 파일들이 .o 로 컴파일 되는 것이죠.

규칙(Rule)

(7) ~ (13) 내용은 Makefile에서 선언된 규칙입니다. 일반적으로 make의 규칙은 다음의 형식을 가지고 있습니다. (http://www.gnu.org/software/make/manual/make.html 에서 인용했습니다.)

target ... : prerequisites ...
[TAB]recipe
[TAB]...
[TAB]...

타겟(target)은 프로그램에 의해 생성해 내고자 하는 파일의 이름 또는 수행하고자 하는 액션(action)의 이름입니다. (9)에서의 타겟은 $(TARGET), 다시말해 MyClient 라는 실행 파일이 됩니다. 그리고 (12) 에서의 타겟은 clean 입니다.

선행조건(prerequisites)은 말 그대로 타겟을 만들어내거나 수행하기 위해 미리 갖춰야 하는 조건입니다. 동시에 이는 타겟이 선행조건에 의존적이라는 의미도 동시에 가지고 있습니다. (실제 다른 make 가이드 문서에서는 dependencies라고도 합니다.) 선행조건에 해당하는 파일이 존재하는 경우에는, 타겟 파일이 선행조건 파일보다 더 최신일 경우, 선행조건이 만족한 것으로 판단합니다.

그리고 레시피(recipe)는 선행조건이 만족될 때 make가 수행할 명령이 되는데요, 반드시 TAB 문자로 시작해야 합니다. (다른 make 가이드 문서에서는 command 라고 부르기도 합니다.)

일반적으로 규칙은 하나 이상의 선행조건들을 가질 수도 있고, 또는 선행조건이 아예 없을 수도 있습니다. 선행조건이 여러 개일 때에는, 당연히 모든 선행조건이 만족되어야만 레시피를 실행합니다.

마찬가지로 레시피도 여러 개가 될 수 있고요, 선행조건이 만족되면 몇 개가 되든 모든 레시피가 실행됩니다. 레시피가 하나도 없는 경우에는 뭐, 아무일도 하지 않고 해당 규칙이 처리된 것으로 간주됩니다.

예제 2.1에서는 안 나왔지만 타겟이 여러 개인 경우는 어떻게 될까요? 그때에는 각 타겟에 대해서, 하나의 타겟을 가지는(그리고 나머지 선행조건 및 레시피는 모두 동일한) 다수의 규칙들을 작성하는 것과 동일합니다. 즉 다음의 규칙을 보시죠.

$(OBJS) : MyCommon.h
    echo “$@ depends on MyCommon.h”

$(OBJS)는 MySocket.o MyClient.o 으로 정의되어 있기 때문에, 위의 규칙은 다음 두 개의 규칙으로 풀어 쓴 것과 동일합니다.

MySocket.o : MyCommon.h
    echo “$@ depends on MyCommon.h”

MyClient.o : MyCommon.h
    echo “$@ depends on MyCommon.h”

당연히 풀어 쓴 것 보다야 하나의 규칙이 훨씬 컴팩트해서 보기도 좋고 유지 보수도 용이합니다.. (아, 물론 타이핑도 적습니다.) 이제 실제 규칙의 처리는 어떻게 되는 지 알아 보도록 하겠습니다.

make의 규칙 처리

예제 2.1 에는 총 3개의 규칙이 있습니다.

  • (7)의 all 은 단순히 $(TARGET) 이라는 선행조건만 있고, 레시피는 없습니다.
  • (9)의 $(TARGET)은, $(OBJS)라는 선행조건을 가지고 있고, (10)의 레시피 하나를 가지고 있습니다.
  • (12)의 clean 은 선행조건 없이 하나의 레시피를 가지고 있습니다.

기본적으로 make은 입력으로 주어진 Makefile(특별한 입력이 없을 때에는 현재 디렉토리에 있는 Makefile이 입력 파일이 됩니다.)을 먼저 읽어 들여서, 변수들과 규칙들을 파악한 다음, make 실행 시 주어진 이름의 규칙을 찾아서 처리합니다. 만약 make 실행시 규칙 이름이 인자로 주어지지 않으면, make는 Makefile에서 만나는 첫 번째 규칙을 처리합니다. (예제 2.1에서는 all 입니다.)

규칙의 처리는 다음과 같은 딱 2개의 동작으로 이루어집니다.

  1. 선행조건이 없거나 모두 만족되면, 레시피들을 실행한다.
  2. 만족되지 않은 선행조건이 있으면, 해당 선행조건에 해당하는 규칙을 찾아서 처리한 다음 원래 규칙을 처리한다.

네, 참으로 심플합니다. 아, 인생 살아가는데 지켜야 할 규칙도 이렇게 간단하다면 얼마나 좋을까요… 하지만 세상은 그리 간단하지 않고, 특히나 소프트웨어 개발자로 살아가기는 더더욱 쉽지 않습니다. 그래서 make도 잘 알아야 하는 거지만 말입니다.

신세 한탄은 이쯤 하고, 예제 2.1의 Makefile을 인자로 줘서 make를 실행하면 다음과 같은 일이 일어납니다.

첫 단계로 make는 Makefile 을 읽어 들여서, 변수들과 규칙들을 구성하는데요, 이 과정에서 CC, LIBS, OBSJ, SRCS 그리고 TARGET등의 변수들, all, $(TARGET), clean 규칙들이 정의됩니다.

다음으로 Makefile에서 첫 번째로 만나는 규칙, 즉, all을 처리하려 합니다. all 은 $(TARGET) 이라는 선행조건을 가지는데, $(TARGET) 즉 MyClient라는 파일이 존재하지 않으므로, 이 선행조건은 만족되지 않은 것으로 간주되고, make는 $(TARGET) 규칙을 찾아서 이를 먼저 처리하려 합니다. 마찬가지로 $(TARGET)은 다시 $(OBJS)를 선행조건으로 가지므로 같은 방식으로 $(OBJS) 규칙을 찾아 처리하려 합니다.

$(OBJS)는 MySocket.o MyClient.o 이므로, make는 각각 MySocket.o와 MyClient.o 규칙이 있는 지 찾아 보지만, 불행히도 이것과 관련된 규칙은 Makefile 내에 존재하지 않습니다. 저 같은 보통 사람이라면 여기서 그만둘 테지만, 끈기 있으면서도 뛰어난 능력을 가진 우리의 친구, make는 좀더 많은 일을 해냅니다.

(5)에서 .SUFFIXES : .c .o 라고 선언했기 때문에, make는 확장자가 .c .o 인 파일들에 대해서는 별도의 규칙이 제공되지 않는 범위 내에서, 자신이 가지고 있는 확장자 규칙을 사용할 수 있습니다. make가 가지고 있는 .c 와 .o 확장자 규칙은 .c (C 소스 파일)를 컴파일해서 .o (목적 파일)를 만들어 내는 레시피를 가집니다. 이런 확장자 규칙 덕분에, 특별히 요청하지도 않았음에도 MySocket.o 가 다음과 같이 컴파일되고, 그 결과로 $(TARGET)의 선행 조건 중 MySocket.o 는 처리된 것으로 간주됩니다.

gcc -c -o MySocket.o MySocket.c

MySocket.o 와 마찬가지 과정을 거쳐, MyClient.o 도 MyClient.c 로부터 컴파일됩니다. 이제 $(OBJS)에 해당하는 파일들이 모두 존재하므로, 결과적으로 $(TARGET)의 모든 선행조건이 만족되었습니다. 따라서 드디어 (9)의 $(TARGET) 규칙의 레시피가 실행됩니다. 아마도, 2개의 목적 파일들로부터 최종 타겟인 MyClient를 linking하는 일이 되겠지요. 그런데, (10)의 레시피에는 낯선 부분이 있습니다.

$(CC) -o $@ $(OBJS)

$(CC)는 gcc고, $(OBJS)는 MySocket.o MyClient.o 인데, $@는 뭘까요. gcc를 좀 아시는 분이라면, gcc 의 인자 중 -o 다음에 나온 걸로 봐서 MyClient를 나타낼 거라고 짐작하실 겁니다. 맞습니다. 사실 $@는 make가 미리 내장하고 있는 내장 변수(make는 이외에도 많은 내장 변수를 가지고 있습니다)인데요, 이는 규칙에서 타겟을 가리킵니다. 즉, 여기서는 $(TARGET)과 동일합니다. (실제로 $@ 대신에 $(TARGET) 을 써도 상관 없습니다. 그렇지만 $@을 사용하게 되면 나중에 규칙 이름이 바뀌게 되어도, 예를 들어 TARGET 이 TARGETS로 바뀌는 경우에도, 레시피를 고치지 않는 장점이 있기 때문에, $@을 사용하는 게 좋습니다.)

$(TARGET)은 MyClient이기 때문에, 결과적으로 다음의 명령이 실행되어 최종 목적 파일인 MyClient 실행파일을 만들어집니다.

gcc -o MyClient MySocket.o MyClient.o

이제 $(TARGET) 규칙마저 처리되었기 때문에, 제일 처음 시작한 all 의 선행조건이 비로소 만족되었습니다. 따라서 all의 레시피가 실행되어야 할 테지만, all은 아무런 레시피를 가지고 있지 않고, 여기에 개입할만한 내장 규칙들도 없기 때문에, 드디어 make는 all의 처리를 완료하면서 실행을 종료하게 됩니다. 결과적으로 최종 실행 결과는 다음과 같습니다.

$ make
gcc    -c -o MySocket.o MySocket.c
gcc    -c -o MyClient.o MyClient.c
gcc -o MyClient MySocket.o MyClient.o

의존성 규칙

이제 make all 또는 그냥 make 치는 것만으로 MySocket.o, MyClient.o, MyClient 가 컴파일되고 링킹이 수행됩니다. 오예… 하지만 좀 더 들여다 봐야 할 게 있습니다. 바로 의존성 문제입니다.

여러분은 일단 make를 실행해서 MyClient가 잘 동작하는 것까지 확인했습니다. 뭐, 이정도 쯤이야 어깨를 한번 으쓱한 다음, 이젠 테스트를 위해 MySocket.c 와 MyClient.c에 임시로 추가했던 디버깅 메시지를 삭제합니다. 그러고나서, 다시 make 실행하면, 어떻게 될까요? 네, 맞습니다. MyClient.c 가 MyClient.o 보다 더 최신이므로, all → $(TARGET) → $(OBJS) → .c.o 규칙을 살피는 과정에서 컴파일과 링킹이 다시 잘 됩니다. 임시 디버깅 메시지가 없어진 것도 잘 확인했습니다.

좋습니다.이번엔 앞으로 추가할 기능에 대한 comment를 MySocket.h에 추가하고 저장한 다음, 확인차원에서 다시 make를 실행했습니다. 어? 다음의 메시지가 출력되네요.

$ make
make: Nothing to be done for `all'.

음, 뭔가 수상하죠? MyClient.c와 MySocket.c는 MySocket.h를 include하고 있으니, 분명히 모두 다시 컴파일되고 MyClient도 다시 링킹되어야 하는데, make가 아무일도 안하겠다는 메시지만 뱉어냅니다. 혹시나 해서 rm 명령어를 써서 .o 파일들과 MyClient를 지운 다음 다시 make를 하니, 이번에는 잘 되네요. 왜 그럴까요?

“뭐 이유 알 필요 있나요? 다 지우고 다시하면 된다면서요… 그냥 지우고 빌드하면 되는 거 아녜요?”

글쎄요, MyClient 같이 소스 파일이 몇개 안 되서, 컴파일 시간이 얼마 안 걸린다면, 뭐 하나 수정할 때마다 이전 빌드 결과 다 지우고 다시 빌드해도 상관 없습니다. 하지만, 그런 간단한 거만 짜는 거면 도대체 회사에서 왜 여러분 같은 고급 인력을 투입하겠습니까. 아마, 소스 파일 개수가 최소한 몇 천개 이상이고, 전체 코드 라인이 몇 십만, 몇 백만 라인 정도는 될 테지요. 코드 하나 수정할 때마다, 매번 전체 리빌드해야 한다면, 안 그래도 야근에다 휴일 업무까지… 우리네 개발자 인생이 얼마다 더 고달파 지겠습니까.

따라서 정확히 수정한 부분에 영향받는 부분들만 컴파일하는게 (당연히, 꼭) 필요합니다. MySocket.c 가 수정되면 MySocket.c만 다시 컴파일하고 MyClient를 링킹해야 합니다. 만약 MySocket.h가 수정되었을 때면, MySocket.c 와 MyClient.c 는 모두 다시 컴파일되어야 할 테고 MyClient도 다시 링킹되어야 할 거고요. .c 파일들은 잘 되는데 왜 .h 파일들은 안 될까요?

그건 예제 2.1 에 .h 파일들에 대한 의존성을 고려한 부분이 없기 때문입니다. 한번 빌드한 다음, 헤더 파일을 고치고 나서 다시 make를 수행해도 컴파일이나 링킹이 읽어나지 않습니다. 물론 make가 팽팽 놀고 있는 것은 아니고요,앞서 설명한 과정을 똑같이 수행합니다. 다만 선행조건들(에 해당하는 파일들, 즉 MyClient, MyClient.o, MySocket.o, MyClient.c, MySocket.c) 모두 존재하고 이미 모든 조건들이 만족된 상태이기 때문에, 수행될 레시피가 없을 뿐입니다.

이에 비해 .c 파일들을 수정했을 때는 .c .o 확장자 규칙의 타겟 파일(.o 파일)보다 선행조건 파일(.c 파일)이 더 최신이므로 레시피가 다시 수행되어 .o 파일이 다시 컴파일되고, 다시 .o 파일이 MyClient 파일보다 더 최신이므로, MyClient가 링킹되는 것입니다.

하지만 .h 파일의 경우에는 이와 관련된 규칙이 없습니다. 그래서 .h 파일이 변경되어도 모든 선행조건들이 이미 만족된 상태로 있기 때문에 make는 아무런 레시피도 수행하지 않는 것입니다. 따라서 다음의 규칙들을 추가로 제공해서, MyClient.c, MySocket.c 및 MySocket.h 사이의 의존성을 make에게 알려줄 수 있습니다.

MySocket.o : MySocket.c MySocket.h
MyClient.o : MyClient.c MySocket.h

위의 2개 규칙들은 모두 타겟과 선행조건으로만 이루어져 있고 레시피는 없습니다. 이는 단지 파일들 사이의 의존성만을 알려 주기 위한 규칙들입니다. MySocket.o규칙은 MySocket.c 와 MySocket.h라는 2개 파일들을 선행조건으로 가집니다. 따라서 make가 $(TARGET) 규칙을 처리하는 과정에서 MySocket.o 규칙을 살펴볼 때, MySocket.c 와 MySocket.h 를 선행조건으로 가진다는 것을 알았기 때문에, 선행조건에 해당하는 파일들(즉, MySocket.c 와 MySocket.h)의 time stamp들을 타겟(MySocket.o)의 time stamp과 비교해서 만약 타겟이 선행조건보다 더 오래되었을 경우에는, 레시피를 다시 적용해서 다시 컴파일하게 됩니다. MyClient.o 의 경우도 마찬가지이고요.

이러한 규칙들이 주어진 상태에서 MySocket.h 파일이 수정되면, 이제는 MySocket.o 와 MyClient.o 가 다시 컴파일되고, MyClient 파일이 이들 .o 파일들보다 더 오래되었기 때문에 다시 $(TARGET) 규칙의 레시피가 다시 수행되어 MyClient가 링킹되는 것입니다.

예제 2.2
CC = gcc
OBJS = MySocket.o MyClient.o
TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $@ $(OBJS)

clean:
    rm -f $(OBJS) $(TARGET)

MySocket.o : MySocket.c MySocket.h
MyClient.o : MyClient.c MySocket.h

2개의 의존성 규칙까지 포함한 것이 예제 2.2입니다. 소스 파일 중 어떤 변경 사항이 생긴 후, make를 실행하면 규칙에 정의된 의존성에 따라 정확하게 꼭 필요한 컴파일 및 링킹 작업이 수행됩니다. 모든게 기대한 대로 움직입니다. 바로 이것이 바로 make를 사용하는 이유이고요, 우리가 Makefile의 규칙을 정확하게 작성해야 하는 이유입니다.

명시적인 규칙이 인자로 주어진 경우의 규칙 처리

만약 make clean이라고 실행하는 경우는 어떻게 될까요? make는 실행시 특정 규칙이 인자로 주어지면, 해당 규칙을 찾아서 처리합니다. 따라서 이 경우, 명시적으로 clean 이라는 규칙 이름이 주어졌기 때문에, Makefile을 읽어서 변수와 규칙들을 구성한 다음, clean이라는 규칙만 찾습니다. 찾고 보니 clean은 선행조건이 없으므로, 바로 (13) 레시피를 실행합니다.

rm -f $(OBJS)와 $(TARGET)

다시 말해 빌드 중간 결과물과 최종 결과물들이 삭제됩니다.

보통 일반적으로 Makefile에는 clean 이라는 규칙이 관행적으로 제공됩니다. 그래서 빌드결과 뭔가 찜찜한게 있으면 make clean한 다음 다시 빌드하곤 하죠.

뭐, 개발 머신의 성능과, 소스 규모에 따라서 다르겠지만, 소스 규모가 좀 큰 경우에는 일단 make clean하고 나면 빌드가 완료될 때까지, 꽤 시간이 걸리겠지요. 디버깅하다가 머리가 안 돌아가서 잠깐 바람을 쐬러 가고 싶을 때, 흡연자라면 눈치 안보고 담배 피러 가고 싶을 때 make clean; make 한번 쳐 둔다음, 어쩔 수 없다는 제스춰와 함께 잠시 나갔다 올 수도 있겠습니다.

하지만 이거 너무 자주 쓰면 음… 동료 및 상사의 곱지않은 눈총을 받을 수 있다는 것, 명심하세요.

Posted in 소프트웨어 | Tagged , , , , , , | 18 Responses

Make 기반 빌드 시스템 (1): 빌드 시스템?

빌드 시스템?

기본적으로 하나의 프로그램은 수 많은 소스코드들로 이루어져 있고, 규모가 커지면 여러 가지 모듈 또는 라이브러리들로 나누어지게 됩니다. 따라서 작성된 소스 코드로부터 최종 결과물들을 빌드하는데 시간도 많이 걸릴 뿐 아니라, 경우에 따라서는 여러 가지 환경과 옵션에 따라 다르게 빌드할 필요도 생깁니다. 효율적으로 개발하기 위해서는 의존성 관계를 잘 추적해서 그중 일부가 수정되면, 수정된 소스 파일들과, 그 수정으로 인해 변경되어야 하는 소스 파일들만 새로 컴파일 하는 기능도 꼭 필요할 겁니다.

이런 점들을 고려한 통합 개발 환경(Integrated development environment, IDE)은 예전서부터 있어 왔고, 요즘은 정말 훌륭한 툴들이 많이 있습니다. IDE를 사용하면 소스 코드 편집, 빌드, 실행 및 디버깅을 하나의 프로그램 내에서 편리하게 진행할 수 있습니다. 당연히, 의존성에 따라 꼭 필요한 파일들만 다시 빌드 해주는 식으로 빌드 옵션 관리도 똑똑하게 해 줍니다. 심지어는 코드를 수정해서 저장하자 마자, 백그라운드로 빌드해 주기도 하죠.

하지만, 어떤 경우에는 적절한 IDE가 없을 수도 있고, 경우에 따라서는, 예를 들어 일일 자동 빌드를 하는 경우에는, IDE가 오히려 불편할 수 있습니다. 그래서 좋은 개발 도구들은 GUI 기반의 IDE와 함께, 도스나 터미널 같은데서 명령어 기반으로 빌드할 수 있는 기능을 같이 제공하더군요.

그러나 어떤 이유에서였든, GUI 기반의 IDE 없이 개발해야 한다면, 그럼 도대체 어떻게 그런 똑똑하고 효율적인 빌드를 수행할 수 있을까요… 불행히도 없습니다… (응?) 농담입니다. 다행히도, 그런 운 나쁜(?) 개발자들이 의지할 수 있는 친구들이 몇 있는데요, 그 중 하나가 make입니다.

make는 주어진 규칙에 따라 작성된 입력 파일의 내용에 따라 주어진 일을 자동으로 수행하는 유틸리티입니다. 사실 make를 이용해서 여러 가지 작업들을 할 수 있겠습니다만, 소프트웨어 개발자 입장에서 보면 소스 코드를 컴파일하고 링킹해서 최종 결과물을 만드는데 없어서는 안 되는 도구입니다.

참고로 GNU make는 GNU에서 구현한 make이고요, 일반적인 리눅스에는 기본으로 포함되어 있거나 적어도 쉽게 무료로 설치할 수 있습니다. gmake라고도 하죠. 유닉스나 BSD나 기타 다른 OS에서는 gnu make와 다른 make가 있을 수 있는데요, GNU make는 다른 make들에 비해 대부분의 기능 면에서는 문제없이 호환되지만, 자체 확장 기능도 가지고 있습니다. 제 글에서는, 리눅스에서 GNU make를 사용하는 것을 전제로 하기 때문에, GNU make를 그냥 make라고 통칭하도록 하겠습니다.

make에 관련된 가장 훌륭한 레퍼런스는 당연히(?) GNU 사이트의 make 문서입니다.

  • http://www.gnu.org/software/make/manual/make.html: Makefile을 수정하다가, 뭔가 추가로 이런 기능이 있지 않을까? 또는 다른 사람이 작성한 Makefile을 참고하는데 이 내용은 도대체 뭘까? 하는 의문이 들때 결국 찾게 되는 사이트입니다. (단, 영어 실력이 좀 받춰줘야 합니다.)
  • 위 Make 매뉴얼 한글판은 http://www.viper.pe.kr/docs/make-ko/make-ko_toc.html 에 있습니다. (Taehun Kim 님께서 알려 주셨습니다. 아무래도 영어가 부담스러운 사람에게, 위 make 매뉴얼 페이지의 빠르게 내용을 파악하는데 큰 도움이 될 수 있겠네요.

앞으로 쓰는 글에서도 나름 설명하겠지만, make의 기본을 좀 속성(?!)으로 이해하려면, 다음 사이트들의 내용이 큰 도움이 됩니다. 고맙게도 한글로 잘 정리되어 있습니다.

리눅스 쪽 개발 작업에서는 많은 경우 make를 사용합니다. 그리고 생산성과 품질을 중요시하는 제대로 된 개발팀이라면, 다음과 같은 작업이 가능한 빌드 시스템 및 Makefile 파일을 작성하는 고유의 규칙을 가지고 있을 겁니다.

  • 하나의 소스 트리에서 여러 가지 타겟 플랫폼들을 단일 명령으로 빌드할 수 있고,
  • 특정 타겟 별로, 그리고 각 모듈 별로 필요한 빌드 옵션을 적용할 수도 있고, 모든 플랫폼과 모듈들에 동일한 옵션을 한번에 적용할 수도 있으며,
  • 나아가 최종 실행 라이브러리들과 실행파일들을 한방에, 즉 명령어 하나의 입력이나 단축키 하나 누르는 걸로 만들어 내고, 자동화된 단위 테스트(unit test)들을 실행해 낼 수 있습니다.

하지만 늘 해야할 일에 비해 개발인력은 부족한 개발팀에서, 이런 환경을 갖추기란 쉽지 않습니다. 특히나 (제가 주로 몸 담아온 회사들처럼) 규모가 작은 중소기업이라면, 그리고 개발하는 소프트웨어 그 자체로, 돈을 벌어주는 최종 제품이 되는 것이 아니라, 어떤 HW 제품에 내장되는 경우(이를 임베디드 시스템이라 하지요)라면 더더욱 그렇습니다.

일반적으로 뭔가 새 라이브러리나 실행 프로그램을 만들어야 한다면, 그래서 이를 위한 Makefile을 하나 작성해야 한다면, 기존에 존재하는 모듈들의 Makefile 중에서 내가 하려는 것에 가장 비슷한 걸 하나 복사한 다음, 최소한의 수정만 가해서 빌드하는 게 가장 빠르긴 합니다. 하지만 시간이 흐르면서, 모듈들이 많아지고, 요구 조건은 많이 변경됩니다. 하나 둘 컴파일 옵션 추가하는 일도 빈번해집니다. 그때마다 복사해서 사용하던 모든 Makefile들을 똑같이 고치는 단순 반복 노가다를 해야 합니다. 그중의 일부가 누락되면 컴파일 에러가 발생하거나, 운 나쁘면 런타임 에러가 발생합니다. 네, 소스 코드를 복사해서 사용하는 것과 똑같은, 아니 더 심각한 문제가 생기는 거죠.

좋은 환경이 주어지지 않은 걸 한탄만 한다고 달라지지는 건 없습니다. 뭔가 문제가 있다는 인식을 하고 그것을 꼭 해결하겠다는 의지만 있다면, 적어도 효율적인 소프트웨어 빌드 시스템은 만들어 낼 수 있습니다. 이 글은 그런 과정에서의 시행착오를 줄여서 생산적인 개발 작업에 좀더 집중할 수 있게 하고, 소프트웨어 개발자의 삶이 조금이나마 편해지는데 도움이 되었으면 하는 바람에서 쓰여 졌습니다.

사실 요즘은 좋은 빌드 툴들이 여럿 있습니다. 제 개인적으로는 jam 이나 waf 같은 것들을 고민해 봤던 경험이 있습니다. 특히 waf는 제가 좋아하는 python을 이용한 시스템이어서 참 매력적이었지만, 여러 명이 사용해야 하는 환경에서는, 관련된 사람들이 얼마나 쉽게 사용할 수 있어야 하느냐도 무척 중요합니다. 빌드 시스템을 관리하기 위해 새 언어를 배워야 한다라는 걸, 아주 바쁜(!) 여러 사람들한테 설득한다는 것은, 음… 쉽지 않은 일입니다.

그에 비해 Makefile을 작성해서 make로 빌드하는 건, 많은 개발자들, 특히 중요한 역할을 담당하는 경험 많은 개발자들일수록 익숙해져 있기 때문에, 빌드 시스템 구축 및 도입을 비교적 용이하게 할 수 있는 장점이 있습니다. 물론 make도 많은 기능을 가지고 있어서, 어느 정도 쓸 수 있기까지 시간과 노력이 좀 필요하지만, 일단 익숙해지고 나면 개발 과정에서 생기는 여러 가지 돌발 상황을 효과적으로 대응할 수 있습니다. 또한 개발을 진행하다 보면, 이런 저런 이유로 개발팀 외부의 소스 코드나 라이브러리를 활용해야 하는데, 많은 경우 Makefile이 제공되어 make를 이용해서 빌드하도록 되어 있기 때문에, 소프트웨어 개발자로 살아가는데 make를 잘 아는게 언젠가는 도움이 될 겁니다.

이제 그럼 make를 이용해서, “생산성과 품질을 중요시하는데 도움이 되는” 빌드 시스템을 실제로 만들어 보도록 하겠습니다.

Posted in 소프트웨어 | Tagged , , , , | 9 Responses