Make 기반 빌드 시스템 (9): 공유 라이브러리 지원

공유 라이브러리 지원

재활용하기 쉽고 필요한 라이브러리들과 응용 프로그램들을 한방에 빌드할 수 있는 빌드 시스템을 구축하는 사이, MyClient 포로토타입을 받아 간 우리의 영업맨, 어느 날 ‘짐짓 들뜬’ 표정을 지으며 달려 옵니다. 아~~, 갑자기 드는 불길한 예감! 아니나 다를까, 계약 딴거나 다름 없다며, 이제 정말 ‘사소한 거’ 하나만 해 주면 된다네요.

사소한 거라, 진짜로요? 아 그렇다니까. 고객사가 필요로 하는 기능은, 이미 이건 다 구현되어 있고 소스도 다 제공해 줄 건데 우리 쪽에서 함수 몇 개 호출만 해주면 된다는데? 흠. 진정 그렇게 친절한 갑이 있단 말인가요?

사소하단 고객의 요구사항인 즉슨, 오픈 소스 라이브러리의 일부를 가져와 약간의 수정만 해서 빌드하고, 우리 프로그램에서 초기화 함수랑 종료 함수, 그리고 중간 중간 callback 함수를 호출해 주는 것이네요. 흠, 그 정도라면야… 싶었는데, 그 오픈 소스 라이브러리의 라이센스가 LGPL 여서 공유 라이브러리로 빌드해 달라는 조건이 붙었습니다. 윽.

LGPL 라이센스이면, 해당 라이브러리를 링킹해서 사용하는 나머지 소스는 공개할 필요가 없습니다. 대신 (1) LGPL 라이브러리의 소스를 수정하는 경우, 수정된 소스를 공개해야 하고, (2) 최종 사용자가 LGPL 라이브러리의 새 버전 또는 자신이 수정한 것을 빌드해서 사용할 수 있도록 해 줄 의무를 지켜야 합니다.

(2)를 만족시키는 방법은 해당 라이브러리를 정적으로 빌드하느냐 아니면 공유 라이브러리로 빌드하느냐에 따라 달라집니다. 정적 라이브러리로 빌드하는 경우에는, LGPL 라이브러리를 가지고 최종 실행 파일을 빌드하는데 필요한 것들, 다시말해 최종 실행 파일의 컴파일 된 목적 파일과, 최종 실행 파일에서 사용하는 나머지 라이브러리의 바이너리들을 제공해야 합니다.

즉 MyClient 프로그램을 릴리즈 하면서, 또는 추후 사용자로부터의 요청이 있을 때, LGPL 라이브러리의 소스 파일과 함께  libMyThread.a libMyNetwork.a, libMyProtocol.a와 MyClient.o 를 같이 배포해야 한다는 것이죠. 어려운 건 아니지만, 뭐 이런 것들을 챙겨서 배포한다는 게 신경쓰이고 성가십니다.

이에 비해 LGPL 라이브러리를 공유 라이브러리로 빌드해서 사용하는 경우에는, 최종 실행 바이너리를 릴리즈 할 때 LGPL 라이브러리의 소스 파일들만 공개하면 됩니다. 공유 라이브러리는, 응용 프로그램을 빌드할 때가 아니라 실행되는 시점에서 필요하기 때문에 내가 작성한 목적 파일과 라이브러리를 배포할 필요가 없어지죠.

어쨌거나 우리 make 기반 빌드 시스템은 정적 라이브러리만 빌드합니다. 자 얼른 영업팀에 가서 안 된다고 말하세요. 응? 그럴 수는 없죠… 사실 소프트웨어 개발 쪽 일을 좀 하다 보면, 꼭 이런 경우 아니라도, 공유 라이브러리로 빌드해야 하는 경우가 생기곤 합니다. 그리고 공유 라이브러리를 빌드하는 것 자체는 어려운 것도 아니고요.

그럼 공유 라이브러리 빌드 기능을 집어 넣기 앞서 정적 라이브러리와 공유 라이브러리에 대한 개념부터 살펴 볼까요?

정적 라이브러리와 공유 라이브러리

아시는 분들이야 잘 아실테지만, “도대체 라이브러리면 다 라이브러리지, 정적 라이브러리는 뭐고 공유 라이브러리는 뭐람?” 하시는 분이라면 다음의 글들을 읽어보실 것을 추천합니다.

기본적으로 라이브러리는 비슷한 기능을 하나의 파일로 묶어서, 해당 기능을 필요로 하는 다른 프로그램에서 이를 재사용할 수 있게 해 주는 설계 기법입니다. 네, 이제까지 우리가 계속 해 온 겁니다. 그런데 사실 리눅스에서는 두 가지 라이브러리 유형을 지원합니다.

첫 번째는 이제까지 우리가 계속 만들어 온, 정적(Static) 라이브러리입니다. 정적 라이브러리는 응용 프로그램을 컴파일하고 링킹하는 시점에서 정적으로 결합됩니다. 응용 프로그램에서 사용하는 라이브러리의 구현부분들을 링킹할 때 최종 응용 프로그램에 복사해 넣는 것이죠.

여러 개의 응용 프로그램들이 같은 정적 라이브러리를 링킹하면, 개별 프로그램마다 라이브러리의 사본을 가지게 되는 셈입니다. 따라서 라이브러리가 커질 수록 최종 응용 프로그램들의 크기가 커질 뿐더러, 링킹하는 응용 프로그램들이 많아질 수록, 저장공간과 실행 메모리를 더 많이 차지하게 됩니다.

이에 비해 두 번째 라이브러리 유형인 공유(Shared) 라이브러리는, 응용 프로그램을 빌드해서 링킹할 때는 공유 라이브러리의 어떤 기능을 사용할 것이다 표시만 해놓고, 응용 프로그램과 라이브러리와의 결합은 응용 프로그램이 실행될 때 이뤄집니다.

따라서 이름에 걸맞게, 복수 개의 응용 프로그램들이 하나의 라이브러리 파일을 공유하게 됩니다. 당연히 저장공간과 메모리도 적게 차지하고, 라이브러리가 변경되었을 때 (주요 인터페이스의 변경과 같은 호환성 문제만 아니라면) 실행 파일들을 다시 빌드할 필요가 없습니다.

이렇게만 놓고 보면 당최 정적 라이브러리를 써서 좋은 건 하나도 없어 보이네요. 하지만 빛이 생기면 그림자도 생기는 게 세상 이치죠. 일단은 성능 문제가 있습니다. 실행 시에 결합되는 공유 라이브러리의 특성상 컴파일 시 위치 독립적인 코드를 생성해야 하는 등의 몇 가지 이유로 인해 공유 라이브러리의 실행 속도가 정적 라이브러리에 비해 느리다고 합니다. 궁금하신 분은 다음 사이트를 참고하세요.

제 개인적으로 공유 라이브러리냐 정적 라이브러리냐에 따른 실행 속도의 차이를 체감한 적은 아직까지는 없습니다만, 다음 사이트에서는 MySQL 의 경우 심지어 13% 까지 느려질 수 있다는 내용도 있긴 합니다. (좀 극단적인 경우가 아닐까 싶습니다.)

다음으로, 하나의 공유 라이브러리가 여러 개의 응용 프로그램들에 의해 공유되기 때문에 발생할 수 있는 호환성의 문제가 있습니다. 즉 어떤 공유 라이브러리가 새 버전으로 업데이트되어야 하는데, 새 버전의 인터페이스가 예전 버전과 호환되지 않는 경우, 예전 버전을 사용하는 기존 응용 프로그램들에 문제가 발생할 수 있는 거죠. 이를 피해가기 위해 soname 이란 걸 사용해서 버전 관리를 합니다만, 어쨌든 하나의 프로그램에 몽땅 포함되는 정적 라이브러리에 비해서는 복잡하고 그만큼 신경 쓸 게 늘어난다는 건 분명합니다.

다음 사이트는 공유 라이브러리에 대한 이런 저런 내용이 잘 정리되어 있습니다. 관심 있으신 분들은 참고하세요.

공유 라이브러리 소스 파일 컴파일

공유 라이브러리는 “링킹할 때는 어떤 기능을 사용할 것이다 표시만 해놓고, 실제 결합은 응용 프로그램이 실행될 때 이뤄진다”고 했죠? 이 얘기는 다시 말해 어떤 함수가 호출될 때 실제로 어떤 주소에 그 함수가 있게 될지 미리 알 수 없다는 뜻입니다.

라이브러리를 정적으로 링킹할 때에는 오브젝트의 위치가 바로 결정되기 때문에, 호출되는 함수의 참조(레퍼런스)를 실제 주소값(위치)로 바로 교체할 수 있습니다. 그래서 실행 중에 함수 호출을 만나면, 해당위치로 바로 점프하게 됩니다.

하지만 실행 시점에 로딩되는 공유 라이브러리의 경우에는 이게 불가능합니다. 대신 응용 프로그램이 실행될 때, 호출되는 함수들의 목록을 하나의 테이블로 만들어 두고, 공유 라이브러리를 로드하면서 이 테이블에 각 함수의 주소값을 채운 다음, 함수 호출을 만날 때마다 이 테이블을 참조해서 해당 함수의 주소값으로 점프합니다.

이런 이유로 공유 라이브러리에 포함시킬 소스 파일을 컴파일 할 때는 위치 독립적으로 만들 필요가 있게 되는데요, 설명은 꽤 복잡했지만 실제 컴파일 작업은 gcc 호출할 때 위치 독립적인 코드를 생성하게 하는 -fPIC 옵션을 추가하는 것으로 끝입니다. 다음과 같이 말이죠

$ gcc -fPIC -c MyProtocol.c -o MyProtocol.o

너무 간단해서 허탈할 지경이지만, 우리 빌드 시스템에 적용하기 위해서는, 정적 라이브러리와 공유 라이브러리를 같이 지원해야 하므로, 공유 라이브러리인지 아닌지를 결정할 변수가 하나 필요합니다.

# Makefile of MyProtocol

...
IS_SHARED = 1
...

그럼 이제 공유 라이브러리인 경우에는 -fPIC 옵션을 주도록 컴파일 규칙을 고치는 것은 어렵지 않습니다. 공유 라이브러리일 때에만 전용 옵션 변수를 정의해서 컴파일 규칙에 추가하면 되니까요.

# IncludePost.mk

...
ifeq ($(IS_SHARED),1)
SHARED_FLAGS = -fPIC
endif
...
$(OBJS_DIR)/%.o : %.c
    @echo "==================================================="
    @echo "= Compiling $@"
    @echo "==================================================="
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) -p $(OBJS_DIR)`
    $(CC) $(CFLAGS) $(DBG_FLAGS) $(SHARED_FLAGS) $(INC_DIRS) -c $< -o $@

간단하죠? 컴파일이 끝났으니, 이제 이것들을 공유 라이브러리로 묶어야 하는데, 이게 목적 파일들을 ar로 묶은 다음 심볼 인덱싱하는 것으로 끝인 정적 라이브러리에 비해 고려할 게 좀 있습니다.

공유 라이브러리의 버전과 호환성

앞에서 공유 라이브러리의 호환성 문제를 잠깐 얘기했죠? 공유 라이브러리는 하나 이상의 응용 프로그램들이 공유하고 있기 때문에, 버그 수정이라든지 기능 추가 등으로 인해 라이브러리를 업데이트 해야 할 때 호환성 문제를 일으킬 소지가 있습니다.

다시 말해, 변경할 내용이 어떤 함수 내의 한 두줄을 수정하는 수준이면, 그냥 라이브러리를 새로 빌드한 다음 라이브러리 파일을 업데이트하면 됩니다. 하지만 만약에 다른 응용 프로그램이 사용 중인 함수들의 인터페이스가 바뀐다든지 동작 방식이 완전히 변경되는 경우에는, 라이브러리 파일을 그냥 업데이트 했을 때 다른 응용 프로그램들이 오동작 할 수도 있다는 것입니다.

결국 이런 호환성 문제를 피하려면, 공유 라이브러리의 새 버전이 이전 버전과 호환성을 유지하는 경우와 그렇지 않은 경우를 구분해서 처리할 어떤 메커니즘이 필요한데, 리눅스에서는 이를 라이브러리의 버전을 통해서 다음과 같이 처리합니다.

(1) 먼저 공유 라이브러리 버전을 결정할 때, 메이저 버전과 마이너 버전으로 구성합니다. 예전 버전과 호환성이 유지될 때에는 마이너 버전만 증가시키고, 하위 호환성이 없을 때에는 메이저 버전을 증가시킵니다. 라이브러리 버전 정책을 이렇게 정하고 나면, 업데이트 할 때 라이브러리의 버전 비교만으로 호환성 여부를 쉽게 파악할 수 있습니다.

(2) 공유 라이브러리 파일을 만들 때, 2가지 이름을 부여합니다. 먼저 파일 시스템 상에서의 실제 이름(real name)이 필요한데, 이건 버전마다 다르게 구분할 수 있도록 파일 이름에 전체 버전, 그러니까 메이저 버전과 마이너 버전을 모두 포함시킵니다. 보통 공유 라이브러리는 lib 으로 시작하고 .so 로 끝나는 게 관례니까 libXXX.so.major_version.minor_version 이런 식으로 되겠죠? MyProtocol 의 경우라면, libMyProtocol.so.1.0 가 실제 이름이 됩니다.

(3) 다음으로 실제 이름에서 마이너 버전을 제외한 이름을 soname 이라고 정하고, 이 값은 공유 라이브러리 파일 어딘가에다 저장해 둡니다. (libMyProtocol.so.1.0 의 soname은 libMyProtocol.so.1 이 되겠죠?) 이렇게 해 두면, 라이브러리 파일들의 실제 이름과는 상관없이, 파일 내의 soname이 동일한 라이브러리 파일들은 서로 호환성을 유지하는 버전이라고 판단할 수 있게 됩니다.

(4) 그리고 응용 프로그램을 링킹할 때에는, 해당 응용 프로그램이 나중에 실행할 때 링킹할 공유 라이브러리의 soname 정보를 응용 프로그램에 표시해 둡니다. (참고로 어떤 응용 프로그램이 어떤 공유 라이브러리를 필요로 하는 지는 ldd 명령을 사용하면 확인 할 수 있습니다.)

(5) 리눅스 시스템은 응용 프로그램이 실행될 때, 해당 응용 프로그램이 필요로 하는 공유 라이브러리들을, 지정된 경로들을 뒤져서 찾은 다음 이를 동적으로 로드해 줍니다.

잠깐만요. 그런데 응용 프로그램은 공유 라이브러리들의 soname 정보만 가지고 있다면서요. 그런데 파일 시스템 상에는 존재하는 공유 라이브러리 파일의 실제 이름은 soname 과 다르니까 못 찾을 거 같은데요…

오! 정말 예리한 지적입니다. 사실 그게 핵심입니다. (1) ~ (5)까지, 응용 프로그램은 실제 이름이 아닌 soname 만 가지고 라이브러리들을 찾도록 만들었기 때문에, 이렇게만 하면 응용 프로그램 실행할 때 공유 라이브러리를 찾지 못한다는 에러가 나오면서 실행이 중단됩니다. soname 은 라이브러리 파일과 응용 프로그램에 기록된 정보에 불과하므로, 파일 시스템 상에서의 실제 이름으로 존재하는 라이브러리 파일과 연결해 줄 방법이 필요한데 다행히 리눅스에서는 symbolic link 를 이용해서 간단하게 연결할 수 있습니다.

즉, 실제 이름으로 라이브러리 만들 때, 이에 대한 symbolic link 를 soname으로 하나 만들면 오케이입니다. 그러면 soname으로 공유 라이브러리를 찾을 때, 해당 symbolic link가 가리키는 실제 이름의 공유 라이브러리의 파일을 찾게 되고, 그 파일에는 필요로 하는 soname이 들어 있기 때문에, 정상적으로 공유 라이브러리를 찾아서 로딩할 수 있게 됩니다. 빙고.

이젠 soname 이 symbolic link로 존재하기 때문에, 호환성에 영향을 주지 않는(마이너 버전만 바뀌는) 경우에는, 새 버전의 공유 라이브러리 파일을 추가한 다음 symbolic link 만 새 버전의 파일을 가리키도록 바꿔주면 업데이트가 완료됩니다. 문제 없죠?

호환성이 없는(메이저 버전이 변경된) 완전 새 버전인 경우에는, 새 버전의 라이브러리 파일과 함께, 이에 대응되는 symbolic link를 같이 추가합니다. 비록 예전 버전을 쓰는 응용 프로그램들은 아마도 새 버전이 가지고 있을, 완전히 끝내주는 기능들의 혜택을 받을 수는 없겠지만, 적어도 새 버전 때문에 오동작하는 불상사는 일어나지 않는다는 게 보장됩니다.

흠… 설명이 길어졌지만 요약하자면 이렇습니다.

  • 공유 라이브러리를 빌드할 때에는 메이저 버전과 마이너 버전을 정합니다. 호환성이 유지될 때에는 마이너 버전값을 하나 올리고, 그렇지 않을 때에는 메이저 버전을 올리면서, 마이너 버전은 0으로 리셋합니다.
  • 공유 라이브러리의 빌드 결과 파일은 libXXX.so.major_version.minor_version 으로 작성합니다. 이게 공유 라이브러리의 실제 이름(real name)이 됩니다.
  • 동시에, 공유 라이브러리의 실제 이름에서 .minor_vesion 을 제외하는 부분, 즉 soname을 이름으로 가지는, 실제 이름의 파일을 가리키는 symbolic link를 하나 생성합니다.

공유 라이브러리 빌드

그럼 실제로 소스 파일들을 컴파일한 목적 파일들로부터 공유 라이브러리를 작성해 볼까요? MyProtocol 라이브러리 경우, 최초로 릴리즈하는 거니까 메이저 버전은 1, 마이너 버전은 0으로 정했다 치면, 다음과 같이 라이브러리를 만들 수 있습니다.

$ gcc -shared -o libMyProtocol.so.1.0 -Wl,-soname,libMyProtocol.so.1 MyProtocol.o

일단 -shared 옵션은 처음 보지만 딱 이름만 봐도 공유 라이브러리와 관련되었겠거니 짐작할 수 있지 않나요? 네, 짐작대로 다른 오브젝트들에 링크되어 실행 가능한 형태가 될 수 있도록 하는 옵션입니다. 공유 라이브러리 만들 때 그냥 넣어준다고 보시면 되겠네요.

-o 는 결과 파일 이름을 지정하는 옵션이므로 공유 라이브러리의 실제 이름(libMyProtocol.so.1.0)을 지정하고 있습니다. 그 뒤에 나오는 -Wl, 옵션부분이 공유 라이브러리의 soname을 지정하는 부분입니다. (-Wl 옵션은 예전에 라이브러리들의 의존성 문제를 해결하는 과정에서 한번 사용했었느데, 생각 나시나요? , 이후에 나오는 부분을 링커한테 넘겨주는 옵션입니다. Make 기반 빌드 시스템 (7): 빌드 디렉토리 구성 부분을 참고하세요.) -soname, 하고 soname(그러니까 libMyProtocol.so.1)을 지정합니다.

그럼 실제로 Makefile과 IncludePost.mk 파일에 적용해 볼까요? 먼저 MAJOR_VERSION 과 MINOR_VERSION 을 정합니다.

# Makefile of MyProtocol

...
MAJOR_VERSION = 1
MINOR_VERSION = 0
...

그리고, 공유 라이브러리일 경우에는 soname과 실제 이름을 변수로 정의한 다음, 이를 이용해서 실제 이름의 공유 라이브러리를 만들고 나서, 이에 대한 soname의 symbolic link를 만듭니다., 다음을 보시죠.

# IncludePre.mk

...
LN = ln
MV = mv
..
# IncludePost.mk

...
ifeq ($(IS_SHARED),1)
SHARED_FLAGS = -fPIC
SHARED_SO_NAME = lib$(LIB_NAME).so.$(MAJOR_VERSION)
SHARED_REAL_NAME = $(SHARED_SO_NAME).$(MINOR_VERSION)
LIB_FULL_NAME = $(ROOT_LIB_DIR)/$(OBJS_DIR)/$(SHARED_REAL_NAME)
else
LIB_FULL_NAME = $(ROOT_LIB_DIR)/$(OBJS_DIR)/lib$(LIB_NAME).a
endif
...
$(LIB_FULL_NAME) : $(LIB_OBJS)
    @`[ -d $(ROOT_LIB_DIR)/$(OBJS_DIR) ] || $(MKDIR) -p $(ROOT_LIB_DIR)/$(OBJS_DIR)`
ifeq ($(IS_SHARED),1)
    $(CC) -shared -Wl,-soname,$(SHARED_SO_NAME) -o $@ $(LIB_OBJS)
    $(LN) -fs $(SHARED_REAL_NAME) $(SHARED_SO_NAME)
    $(MV) $(SHARED_SO_NAME) $(ROOT_LIB_DIR)/$(OBJS_DIR)/
else
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@
endif 
...

(6) ~ (7)은 각각 메이저 버전과 마이너 버전 값을 이용해서 soname과 실제 이름에 해당하는 변수를 정의합니다. 공유 라이브러리일 때 LIB_FULL_NAME은 (8) 처럼 정의되고요, 물론 정적라이브러리일 때에는 (10)처럼 정의됩니다.

(16) ~ (18) 은 공유 라이브러리를 만들고, 이에 대한 soname 의 symbolic link를 만들어서 이를 $PROJ_ROOT/Out/Library 디렉토리의 Debug 또는 Release 디렉토리로 옮깁니다.

make lib 해서 실행하면, Out/Library/Debug 디렉토리에 libMyProtocol.so.1 와 libMyProtocol.so.1.0 잘 만들어집니다. 오예~

이제 응용 프로그램들을 빌드해야 할 차례네요.

공유 라이브러리 링킹

응용 프로그램을 컴파일하고 링킹하기 앞서 서브 디렉토리의 라이브러리들부터 빌드 해야 하는데요, 이건 make liball targets 를 실행해서 한번에 빌드할 수 있습니다.

근데 어라? 서브 디렉토리들의 라이브러리들이 잘 만들어지는 건 당연한 건데, 응용 프로그램들을 빌드하는데 링킹 에러가 납니다. 뭐가 문제일까요…

$ make liball targets
make[1]: Entering directory `/home/mbbs/Temp/Source/Thread'
===================================================
= Compiling Debug/MyMutex.o
===================================================
gcc  -g -O0 -DDEBUG  -I/home/philnet/mbbs/Temp/Include -c MyMutex.c -o Debug/MyMutex.o
...
make[1]: Leaving directory `/home/mbbs/Temp/Source/Network'
===================================================
= Compiling Debug/MyClient.o
===================================================
gcc  -g -O0 -DDEBUG -fPIC -I/home/philnet/mbbs/Temp/Include -c MyClient.c -o Debug/MyClient.o
===================================================
= Linking Debug/MyClient
===================================================
gcc -o Debug/MyClient Debug/MyClient.o -L/home/philnet/mbbs/Temp/Out/Library/Debug -lMyProtocol -lMyThread -lMyNetwork -lpthread
/usr/bin/ld: cannot find -lMyProtocol
collect2: ld returned 1 exit status
make: *** [Debug/MyClient] 오류 1

-lMyProtocol 을 찾을 수 없다는 거네요… 아차, 이건 다음의 이유 때문입니다.

보통 링커한테 알려주는 라이브러리의 이름을 linker name이라고 하는데요, 라이브러리 파일 이름에서 lib 을 빼고, 정적 라이브러리일 때에는 .a를, 공유 라이브러리일 경우에는 .so 를 뺀 부분이 됩니다. 우리 빌드 시스템의 Makefile에서는 LIB_NAME 이라는 변수로 정의되어 있죠.

-l 옵션 바로 뒤에 linker name 을 붙여서 링커한테 넘겨주면, 링커는 linker name 에 해당하는 라이브러리가 있는 지를 찾는데요, 공유 라이브러리 파일을 먼저 찾고 없으면 정적 라이브러리를 검색합니다. 즉 libMyProtocol.so 파일이 있으면 이것으로 먼저 링킹하고, 없으면 libMyProtocol.a 파일을 가지고 링킹합니다.

참고로,

  • -l 옵션을 쓰지 않고 라이브러리 파일의 전체 경로가 포함된 파일 이름(…/Out/Library/Debug/libMyProtocol.so.1.0)를 써도 되지만, 그것보다는 -l 옵션을 쓰는 게 보통 권장됩니다. 정적 라이브러리와 공유 라이브러리의 전환하기에도 좋고, 타이핑 할 문자도 적죠.
  • 만약 동일한 linker name을 가지는 정적 라이브러리와, 공유 라이브러리가 같이 있는데, 꼭 정적 라이브러리로 링킹하게 하고 싶으면 링킹할 때 -static 옵션을 추가해 주면 됩니다.

자, 이렇게 놓고 보면 공유라이브러리와 관련된 이름은 모두 3개가 되네요. real name은 파일 시스템 상에서의 라이브러리 파일의 실제 이름입니다. soname은 호환성이 보장되는 논리적 이름으로서 라이브러리 내에 저장됩니다. linker name은 링커한테 알려주기 위한 lib 과 확장자를 제외한 이름이 됩니다.

이제는 링커가 -lMyProtocol 가 없다고 하는 게 이해 됩니다. -L 옵션으로 지정된 디렉토리들에, real name과 soname 에 해당하는 파일들은 있지만, linker name으로부터 매치되는 파일, 즉 libMyProtocol.so 파일이나 libMyProtocol.a 둘 중 어느 것도 없기 때문입니다. 따라서 이 문제를 해결하려면, 링커가 애타게 찾을 이름을 soname에 대한 symbolic link 로 만들어주면 되겠습니다.

# IncludePost.mk

...
$(LIB_FULL_NAME) : $(LIB_OBJS)
    @`[ -d $(ROOT_LIB_DIR)/$(OBJS_DIR) ] || $(MKDIR) -p $(ROOT_LIB_DIR)/$(OBJS_DIR)`
ifeq ($(IS_SHARED),1)
    $(CC) -shared -Wl,-soname,$(SHARED_SO_NAME) -o $@ $(LIB_OBJS)
    $(LN) -fs $(SHARED_REAL_NAME) $(SHARED_SO_NAME)
    $(LN) -fs $(SHARED_SO_NAME) lib$(LIB_NAME).so
    $(MV) $(SHARED_SO_NAME) lib$(LIB_NAME).so $(ROOT_LIB_DIR)/$(OBJS_DIR)/
else
...

이제 다시 make liball targets 하면, 라이브러리 디렉토리에 libMyProtocol.so.1.0, libMyProtocol.so.1, libMyProtocol.so 이렇게 하나의 파일과 2개의 symbolic 링크들이 잘 만들어지면서, 응용 프로그램들의 링킹도 잘 되는 것을 확인할 수 있습니다.

흐뭇한 마음에 링킹 에러 없이 완료된 빌드 메시지를 보는데요, 가만가만, 뭔가가 좀 이상합니다.

$ make liball targets
...
===================================================
= Linking Debug/MyClient
===================================================
gcc -o Debug/MyClient Debug/MyClient.o -L/home/philnet/mbbs/Temp/Out/Library/Debug -lMyProtocol -lMyThread -lMyNetwork -lpthread
===================================================
= Compiling Debug/TestConnection.o
===================================================
gcc  -g -O0 -DDEBUG -fPIC -I/home/philnet/mbbs/Temp/Include -c TestConnection.c -o Debug/TestConnection.o
===================================================
= Linking Debug/TestConnection
===================================================

이런, 아까는 링킹 에러 때문에 미처 확인하지 못했는데, (10) 에서 TestConnection.c 을 컴파일하는데, 공유 라이브러리 소스 파일 컴파일할 때 지정한 -fPIC 옵션이 들어가 버린 게 눈에 들어 옵니다.

아, 이건 또 왜 그럴까요? 그런데 곰곰히 생각해보면, 현재 빌드 시스템에서 소스 코드(.c)로부터 목적 파일(.o)로 컴파일하는 규칙은 딱 하나, 다음의 규칙입니다.

# IncludePost.mk

...
$(OBJS_DIR)/%.o : %.c
    @echo "==================================================="
    @echo "= Compiling $@ "
    @echo "==================================================="
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    $(CC) $(CFLAGS) $(DBG_FLAGS) $(SHARED_FLAGS) $(INC_DIRS) -c $< -o $@
...

소스 코드 컴파일 할 때에는 LIB_SRCS 든 TARGET_SCRS 든 구분않고 동일한 규칙 하나로 처리됩니다. 그러니까 LIB_SRCS 파일들을 컴파일할 때 IS_SHARED = 1 인 경우 -fPIC 옵션을 들어가도록 규칙을 수정하면, 당연히 TARGET_SRCS 의 경우에도 똑같이 -fPIC 옵션이 적용될 수 밖에 없습니다.

응용 프로그램의 소스 파일에 -fPIC 옵션을 포함해서 컴파일하고 링킹했을 때 실행 자체는 됩니다만, 공유 라이브러리에 포함되지 않는 소스 코드를 위치 독립적으로 컴파일하는 게 맘에 걸립니다. (혹시 이와 관련해서 보충 설명해 주실 수 있는 분 계시면, 댓글 달아 주세요. 미리 감사 드립니다.)

어쨌든 응용 프로그램에서는 -fPIC 가 적용되지 않도록 하려면, TARGET_SCRS 에 속한 소스 코드일 때는 $(SHARED_FLAGS) 를 빼는 방법을 찾아야 합니다. 그 정도야 make 문자열 함수를 쓰면 가능하지 않을까요? 이렇게 말입니다.

$(OBJS_DIR)/%.o : %.c
...
ifeq ($(findstring $<, $(TARGET_SRCS),)
    $(CC) $(CFLAGS) $(DBG_FLAGS) $(SHARED_FLAGS) $(INC_DIRS) -c $< -o $@
else
    $(CC) $(CFLAGS) $(DBG_FLAGS) $(INC_DIRS) -c $< -o $@
endif
...

(3)에서 나온 $(findstring find, in) 은 in 문자열에서 find 문자열을 찾아 보고, 있는 경우에는 find 를, 없을 때에는 빈 문자열을 반환하는 make 문자열 함수입니다. 그리고 $< 는 첫번 째 선행규칙을나타내는 자동 변수니까 컴파일하는 소스 코드가 될 테고요. 따라서 $< 가 $(TARGET_SRCS)에 포함되어 있는지 여부를 가지고, 다시 말해 $(findstring…) 결과가 $< 인지 빈 문자열인지를 가지고, 라이브러리 소스인지 응용 프로그램 소스인지를 판단할 수 있다는 거죠.

$(findstring…) 실행 결과가 빈 문자열이면 이건 라이브러리의 소스 파일일 테니까, 공유 라이브러리일 때에 -fPIC로 지정되는 $(SHARED_FLAGS) 를 포함시킴으로써, 공유 라이브러리의 소스 파일들에 대해서만 -fPIC 옵션을 적용시킬 수 있을 것 같습니다.

아쉽게도 위의 코드는 제대로 동작하지 않습니다. 왜냐하면, make는 Makefile을 읽을 때 조건들을 평가합니다. 그런데, 자동 변수들은 명령들이 실행되기 전에는 정의되지 않기 때문에, 결과적으로 조건의 테스트 안에서 자동 변수들을 사용할 수 없게 됩니다. 따라서 위의 조건은 항상 true가 되어 TARGET_SRCS 의 소스 파일들의 컴파일 시 여전히 -fPIC 옵션이 적용됩니다.

하지만 여기서 그만둘 수는 없죠. 음… 조금만 더 생각해 보자면, 지금 ifeq … else … endif 으로 분기해야 하는 부분은 복수의 변수들 선언이나 규칙과 같이 Makefile의 구조적 변경사항과 관련된 것이 아니라, 단순히 레시피의 명령어 부분입니다. 이럴 때에는 함수 문맥 내에서 조건 확장이 이루어지는, 그래서 자동 변수 사용 여부와는 상관없는 $(if condition, then-part[, else-part]) 함수를 사용할 수 있습니다. condition이 참이면 then-part를, 그렇지 않으면 else-part가 실행됩니다. (else-part는 생략 가능하고요)

다음을 보세요.

# IncludePost.mk

...
$(OBJS_DIR)/%.o : %.c
    @echo "==================================================="
    @echo "= Compiling $@"
    @echo "==================================================="
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) -p $(OBJS_DIR)`
    $(if $(findstring $<, $(TARGET_SRCS)), \
        $(CC) $(CFLAGS) $(DBG_FLAGS) $(INC_DIRS) -c $< -o $@, \
        $(CC) $(CFLAGS) $(DBG_FLAGS) $(SHARED_FLAGS) $(INC_DIRS) -c $< -o $@)
...

(9) ~ (11) 의 $(if …) 함수는 $(findstring $< $(TARGET_SRCS))가 빈 문자열이면, 다시 말해 library 소스 파일이면 $(SHARED_FLAGS) 없이, 그렇지 않으면 컴파일 옵션에 $(SHARED_FLAGS)를 포하시켜 컴파일을 수행합니다. 자 이제 다시 실행해 볼까요?

$ make liball targets
===================================================
= Compiling Debug/MyProtocol.o
===================================================
gcc  -g -O0 -DDEBUG -fPIC -I/home/philnet/mbbs/Temp/Include -c MyProtocol.c -o Debug/MyProtocol.o
gcc -shared -Wl,-soname,libMyProtocol.so.1 -o /home/philnet/mbbs/Temp/Out/Library/Debug/libMyProtocol.so.1.0 Debug/MyProtocol.o
ln -fs libMyProtocol.so.1.0 libMyProtocol.so.1
ln -fs libMyProtocol.so.1 libMyProtocol.so
mv libMyProtocol.so.1 libMyProtocol.so /home/philnet/mbbs/Temp/Out/Library/Debug/
make[1]: Entering directory `/home/mbbs/Chapter9/Source/Thread'
===================================================
= Compiling Debug/MyMutex.o
===================================================
gcc  -g -O0 -DDEBUG -fPIC -I/home/philnet/mbbs/Temp/Include -c MyMutex.c -o Debug/MyMutex.o
...
===================================================
= Compiling Debug/MyClient.o
===================================================
gcc  -g -O0 -DDEBUG -I/home/philnet/mbbs/Temp/Include -c MyClient.c -o Debug/MyClient.o
===================================================
= Linking Debug/MyClient
===================================================
gcc -o Debug/MyClient Debug/MyClient.o -L/home/philnet/mbbs/Temp/Out/Library/Debug -lMyProtocol -lMyThread -lMyNetwork -lpthread
...

(3), (5) 를 보면, IS_SHARED = 1 일 때에도 TARGET_SRCS 에 정의된 소스 파일들의 컴파일 결과는 -fPIC 옵션이 적용되지 않습니다. 원하는 바를 모두 이루었습니다. ($(if…) 함수를 사용해서 라이브러리 소스인지 여부를 간단하게 판단할 수 있음을 알려 주신 mug896님께 감사드립니다.)

나머지, clean 과 의존성 규칙들

이제 큰 것들은 다 되었으니 마지막으로 clean 과 의존성 규칙만 손 보면 되겠네요. 먼저 clean은 공유 라이브러리일 때 symbolic link까지 삭제해 주는 걸 추가하는 것으로 오케이입니다.

# IncludePostmk

...
clean :
    $(RM) -fr $(OBJS_DIR) $(LIB_FULL_NAME)
ifeq ($(IS_SHARED),1)
    $(RM) -f $(ROOT_LIB_DIR)/$(OBJS_DIR)/lib$(LIB_NAME).so*
endif
...

depend 규칙은, 먼저 LIB_SRCS와 TARGET_SRCS 를 다르게 취급해야 하니까, 한번에 depend 하던 것을 나눠서 하도록 바꿔줄 필요가 있습니다.

# IncludePostmk

...
depend :
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    @$(RM) -f $(DEPEND_FILE)
    @for FILE in $(LIB_SRCS:%.c=%); do \
        $(CC) -MM -MT $(OBJS_DIR)/$$FILE.o $$FILE.c $(CFLAGS) $(DBG_FLAGS) $(SHARED_FLAGS) $(INC_DIRS) >> $(DEPEND_FILE); \
    done
    @for FILE in $(TARGET_SRCS:%.c=%); do \
        $(CC) -MM -MT $(OBJS_DIR)/$$FILE.o $$FILE.c $(CFLAGS) $(DBG_FLAGS) $(INC_DIRS) >> $(DEPEND_FILE); \
    done
...

다음으로, TARGET_NAMES 의 라이브러리들에 의한 의존성 규칙도 손을 봐 줄 필요가 있습니다.

# IncludePostmk

...
$(TARGET_NAMES) : $(LIB_FULL_NAME) \
    $(DEPEND_LIBS:-l%=$(ROOT_LIB_DIR)/$(OBJS_DIR)/lib%.a)
...

(5) 에서 예전에는 정적 라이브러리만 작성했기 때문에, 그냥 lib%.a 로 치환했었죠. 이제는 .so 도 있을 수 있기 때문에 두 가지에 모두 의존하도록 바꾸어야 합니다. 제일 쉬운 방법은 확장자랑 상관없이 모든 라이브러리 이름에 의존할 수 있도록, .a 대신 와일드카드 문자를 사용해서 .* 로 치환하면 되겠죠? 다음과 같이 말입니다.

# IncludePostmk

...
$(TARGET_NAMES) : $(LIB_FULL_NAME) \
    $(DEPEND_LIBS:-l%=$(ROOT_LIB_DIR)/$(OBJS_DIR)/lib%.*)
...

불행히도 이렇게 바꾸고 실행하면, 다음의 에러가 나타납니다.

$ make liball targets
===================================================
= Compiling Debug/Shared/MyProtocol.o
===================================================
gcc  -g -O0 -DDEBUG -fPIC -I/home/philnet/mbbs/Chapter9/Include -c MyProtocol.c -o Debug/Shared/MyProtocol.o
gcc -shared -Wl,-soname,libMyProtocol.so.1 -o /home/philnet/mbbs/Chapter9/Out/Library/Debug/libMyProtocol.so.1.0 Debug/Shared/MyProtocol.o
ln -fs libMyProtocol.so.1.0 libMyProtocol.so.1
ln -fs libMyProtocol.so.1 libMyProtocol.so
mv libMyProtocol.so.1 libMyProtocol.so /home/philnet/mbbs/Chapter9/Out/Library/Debug/
make[1]: Entering directory `/home/mbbs/Chapter9/Source/Thread'
...
===================================================
= Compiling Debug/Static/MyClient.o
===================================================
gcc  -g -O0 -DDEBUG -I/home/philnet/mbbs/Chapter9/Include -c MyClient.c -o Debug/Static/MyClient.o
make: *** `Debug/Static/MyClient'에서 필요로 하는  타겟 `/home/philnet/mbbs/Chapter9/Out/Library/Debug/libMyNetwork.*'를 만들 규칙이 없습니다.  멈춤.

libMyNetwork.* 파일을 만들 규칙이 없다니, 이게 웬일이람? 싶습니다. 더 이상한 것은 다시 make 를 수행하면 이번에 에러가 없이 targets 까지 모두 잘 빌드됩니다. 응? 하지만 make cleanall 해 준 다음, 다시 make 또는 make liball targets 를 실행하면 동일한 에러가 발생합니다.

이상하죠? 이것은 make가 변수를 정의하거나 함수의 매개변수 안에서는 와일드카드 문자 확장을 하지 않고, * 문자를 실제 문자열의 일부로 간주하기 때문인데요, 일반적으로 선행조건이나 레시피에서 와일드카드 문자를 사용하면, 우리가 보통 아는대로 동작(와일드카드 문자 확장)하게 됩니다. 다음을 보세요.

clean:
	rm -f *.o

print: *.c
	lpr -p $?

clean 규칙을 실행하면 레시피에 있는 *.o 는 와일드카드 문자 확장이 일어나서 모든 .o 파일을 가리키게 되어 모든 .o 파일들을 삭제하게 됩니다. 마찬가지로, print 규칙을 실행하면, 선행규칙에 있는 *.c는 확장되어 모든 .c 파일들을 출력합니다. 하지만 $(DEPEND_LIBS:-l%=$(ROOT_LIB_DIR)/$(OBJS_DIR)/lib%.*)의 경우에는 와일드카드 문자 * 가 확장되지 않고 문자열의 일부로 포함되기 때문에, …/libMyNetwork.* 문자열 자체가 선행규칙에 포함되어 버립니다.

첫 번째로 make를 실행할 때, 이 규칙을 parsing하는 시점에는 .a 든 .so 든 아무런 파일도 없기 때문에 그런 파일 없다고 에러를 뱉어내는 거죠. 그렇지만 targets를 링킹할 때 에러가 났지만, 이미 라이브러리 파일 자체는 빌드되었기 때문에, 두 번째로 make를 실행할 때에는 라이브러리 파일이 존재하므로 문제없이 링킹됩니다.

자, 그럼 어떻게 할까요? 제일 쉬운 방법은 (아마도 이럴 때 쓰라고 만들어 둔?) wildcard 함수를 사용하는 겁니다. $(wildcard pattern…) 함수는 makefile안의 어떤 곳에서도 사용될 수 있고요, 주어진 파일 이름 패턴들 중의 하나와 매치되는 현존하는 파일들 이름들의 공백으로 분리된 리스트에 의해서 대체합니다. 그래서 text 에서 pattern 을 replacement로 바꿔주는 $(patsubst pattern,replacement,text) 함수를 아래와 같이 사용하면 원하는 의존성 규칙을 깔끔하게 작성할 수 있습니다.

# IncludePostmk

...
$(TARGET_NAMES) : $(LIB_FULL_NAME) \
    $(patsubst -l%, $(wildcard %.*), $(DEPEND_LIBS))
...

총정리

휴우, 드디어 총정리의 시간입니다. 다음은 2개의 공용 include 파일들인 IncludePre.mk와 IncludePost.mk 과, 이를 사용하는 Makefile 의 원형인 MakeTemplate.mk 입니다.




어떤 라이브러리를 공유 라이브러리로 만들려면 Makefile에서 다음 항목들을 설정합니다.

  • IS_SHARED = 1
  • MAJOR_VERSION = major_version_of_shard_library
  • MINOR_VERSION = minor_version_of_shard_library

이렇게 설정하고 나면, Out/Library 디렉토리의 Debug 또는 Release 디렉토리에 다음의 파일들이 생성됩니다.

  • real name의 공유 라이브러리 파일: libXXX.so.major_version.minor_version (예: libMyProtocol.so.1.0)
  • soname 에 해당하는  symbolic link 파일: libXXX.so.major_version (예: libMyProtocol.so.1) -> libXXX.so.major_version.minor_version 을 가리킵니다.
  • linker name에 해당하는 symbolic link 파일: libXXX.so (예: libMyProtocol.so) -> libXXX.so.major_version 을 가리킵니다.

그리고 빌드된 공유 라이브러리를 링킹한 응용 프로그램들을 실행하려면, 몇 가지 방법이 있지만 제일 쉬운 방법은 LD_LIBRARY_PATH 환경변수를 공유 라이브러리 파일이 있는 디렉토리로 설정하는 것입니다. 예를 들면 디버그용으로 빌드했다면, 다음과 같이 LD_LIBRARY_PATH 를 export 하고 나서 응용 프로그램을 실행합니다.

$ export LD_LIBRARY_PATH=$(PROJ_ROOT)/Out/Library/Debug
$ ./Debug/MyClient 
[DoSomethingNiceProc:MyClient.c(11)]	Do something nice...
[InitCommand:MyProtocol.c(11)]	init command...

공유 라이브러리를 지원하느라, IncludePost.mk 가 좀 복잡해졌지만, 이제는 빌드할 라이브러리의 타입을 정적이든 동적이든 간편하게 지정할 수 있게 되었으니 그 가치는 충분하겠죠? 자, 이제 우리의 영업맨한테 으쓱거리면서, ‘사소한 거’ 다 되었으니 계약이나 빨랑 체결해 오라고 다그치는 일만 남았네요. 개발자한테도 이런 날들이 좀 있어야 하지 않겠어요?

 

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

Make 기반 빌드 시스템 (8): make 재귀 호출에 의한 배치 빌드

make 재귀 호출에 의한 배치 빌드

지난 글에서, 하나의 라이브러리를 적절한 의미와 규모를 가지는 여러 개의 라이브러리들로 구성하되, 각 라이브러리의 개별 설정과 전체 라이브러리들의 일괄 변경을 쉽게 처리할 수 있도록 Makefile의 공용 include 파일들을 분리해 내고 이들을 쉽게 관리할 수 있는 빌드 디렉토리의 구성해 보았습니다.

이걸 가지고 하나로 빌드하던 라이브러리를 MyNetwork, MyThread, MyProtocol 이렇게 3개의 라이브러리들로 나누었고, 덕분에 나중에 다른 프로그램 작성할 때 MyNetwork와 MyThread의 기능을 쉽게 사용할 수 있게 되었습니다. 물론 이건 좋은 일입니다. 그런데, 좋긴 한데, 좋은 건 나중 일이고, 당장 MyClient 프로그램의 개발 담당자 입장에서 보면 오히려 좀 불편해졌습니다.

뭐냐하면 전에는 하나의 라이브러리로 되어 있으니까, thread든 network든 protocol이든 그냥 수정할 게 있으면 수정한 다음 그 자리에서 빌드하면 끝인데, 이제는 MyNetwork의 소스 파일 또는 여기에서 사용하는 헤더 파일을 수정하면, Network 디렉토리로 가서 빌드해 준 다음, 다시 Protocol 디렉토리로 와서 빌드해야 합니다. Thread도 마찬가지고요.

의존하는 라이브러리들이 많아질수록 더 많은 경로 이동과 make가 필요하게 됩니다. 이건 단순히 번거롭다 차원의 문제가 아니죠. 하나라도 깜빡 잊으면 링킹 에러 또는 런타임 에러가 되고, 그 결과 불필요한 시간 허비와 허탈한 한숨을 야기할테니까요.

서브 디렉토리 빌드

어떻하는 게 좋을까요… 사실 답은 간단합니다. 빌드하려는 라이브러리 또는 실행 바이너리가 다른 디렉토리에 있는 라이브러리에 의존하는 경우에는, 해당 라이브러리의 디렉토리로 이동해서 빌드를 먼저 수행하고 나서, 원래 디렉토리로 돌아와서 빌드를 이어할 수 있도록 해 주는거죠, 아, 물론 자동으로 말입니다.

일단 먼저 빌드되어야 하는 디렉토리를 나타내는 변수가 하나 필요한데, SUB_DIRS 라고 해 볼까요? SUB_DIRS 는 필요한 곳에서만 존재하면 될 테니, 공용 include 파일이 아닌 Makefile에서 정의합니다. MyNetwork, MyThread, MyProtocol 중에는 MyProtocol 만 필요하죠. MyProtocol 은 MyThread와 MyNetwork를 사용하기 때문에 해당 라이브러리들이 위치한 디렉토리들을 SUB_DIRS에 지정합니다.

# Makefile of MyProtocol

...
SUB_DIRS = $(ROOT_SRC_DIR)/Thread $(ROOT_SRC_DIR)/Network
...

다음 단계로 SUB_DIRS 에 지정된 디렉토리들로 이동해서 make를 실행시키는 subdirs 라는 규칙을 만들어 볼까요?

MAKE = make
...
subdirs : 
	@for dir in $(SUB_DIRS); do \
		$(MAKE) -C $$dir all; \
		if [ $$? != 0 ]; then exit 1; fi; \
	done

규칙의 타겟은 subdirs이고요, 선행조건은 없습니다. 좀 복잡해 보이는 레시피 부분은 bash 쉘 스크립트의 구문으로 이루어져 있습니다. (4)와 (7)은 $(SUB_DIRS) 의 각 항목에 대한 for 반복문인데, (4)는 for 반복문의 시작 부분으로, $(SUB_DIRS) 각 항목을 dir 이라는 변수를 지정하고 있고, (7)은 반복의 끝을 가리킵니다.

참고로 bash에서도 어떤 변수를 참조할 때에는 변수 이름 앞에 $를 붙이는데요, 그런데 $는 Makefile 내에서 변수를 가리킬 때에도 쓰이죠. 그래서 make에서 shell 변수를 사용할 때에는 shell 변수 이름 앞에 $를 하나 더 붙입니다. (웬지 Make 기반 빌드 시스템 (4): 라이브러리와 실행 파일의 분리 포스트에서 다뤘던 secondary expansion이 연상되지 않나요?) (4)에서 선언된 dir 변수가 $$를 두 개 붙인 건 (달라를 너무 좋아해서가 아니라) 이런 이유 때문입니다.

자, (5)가 바로 서브 디렉토리인 dir 디렉토리에서 make를 수행하는 핵심 부분인데요, make 뒤에 나오는 -C 옵션이 특정 디렉토리로 이동해서 make를 수행하는, 정확히 우리가 필요로 하는 기능입니다. (사실 이 부분은 cd $$dir; $(MAKE) all 이렇게 해도 되지만, make에서 쓰라고 만든 옵션을 굳이 안 쓸 필요는 없겠죠?)

(6)은 바로 위에서 실행한 make가 오류가 발생했을 때 예외 처리하는 부분인데요, bash에서 $? 변수는 이전에 실행된 명령어나 함수, 스크립트의 종료 상태를 나타내는 bash 쉘의 내장 변수입니다. 관습적으로 0은 성공을 나타내고 1에서 255까지의 숫자는 에러를 나타냅니다. 따라서 (6)은 (5)에서 실행한 (서브 디렉토리에서의) make가 성공하지 못했을 때는, 종료 상태를 1로 설정한 채 현재 make의 실행을 중단하라는 의미입니다.

subdirs 규칙은 이렇게 완성 되었으니, 이제 이 녀석이 라이브러리와 실행 바이너리보다 먼저 실행되게 하는 것만 남았네요. 어렵지 않습니다. all 규칙의 선행조건에, 라이브러리와 실행 바이너리를 나타내는 $(LIB_FULL_NAME)과 $(TARGET_NAMES) 앞에 들어가기만 하면 됩니다.

# IncludePost.mk

...
all : subdirs $(LIB_FULL_NAME) $(TARGET_NAMES)

subdirs : 
	@for dir in $(SUB_DIRS); do \
		$(MAKE) -C $$dir all; \
		if [ $$? != 0 ]; then exit 1; fi; \
	done
...

이렇게 수정하고 Prototype 디렉토리에서 make 를 수행해 볼까요?

$ make
make[1]: Entering directory `/home/mbbs/Chapter8/Source/Thread'
===================================================
= Compiling Debug/MyMutex.o 
===================================================
gcc  -g -O0 -DDEBUG -I/home/mbbs/Chapter8/Include -c MyMutex.c -o Debug/MyMutex.o
...
===================================================
= Linking Debug/TestThread 
===================================================
gcc -o Debug/TestThread Debug/TestThread.o -L/home/mbbs/Chapter8/Out/Library/Debug -lMyThread  -lpthread
make[1]: Leaving directory `/home/mbbs/Chapter8/Source/Thread'
make[1]: Entering directory `/home/mbbs/Chapter8/Source/Network'
...
make[1]: Leaving directory `/home/mbbs/Chapter8/Source/Network'
===================================================
= Compiling Debug/MyProtocol.o 
===================================================
gcc  -g -O0 -DDEBUG -I/home/mbbs/Chapter8/Include -c MyProtocol.c -o Debug/MyProtocol.o
...

(2)에서와 같이 SUB_DIRS 에 지정된 첫번째 디렉토리인 Thread 디렉토리로 이동해서 빌드하는 것을 볼 수 있습니다. 이후 빌드가 완료되고 나면 (12), (13)에서처럼 Thread 디렉토리를 나가고, 다시 Network 디렉토리로 이동합니다. 그리고 Network 디렉토리의 빌드가 완료되고 나면 (15)에서 Network 디렉토리를 나갑니다. 이후 원래의 Protocol 디렉토리의 빌드를 시작합니다.

make all 규칙을 가만히 들여다 보세요. 서브 디렉토리가 지정되어 있으면 all 은 선행조건인 subdirs 규칙을 통해 모든 서브 디렉토리에 대해 make all 을 실행하게 되는데, 만약 서브 디렉토리에 다시 서브 디렉토리가 지정되어 있으면, 그 서브-서브 디렉토리에 대해서 또다시 동일한 make all 을 실행합니다. 즉, 모든 서브 디렉토리들을 재귀적으로 빌드하게 되는 거죠.

여기서 make[1] 은 현재 make의 call stack 수를 의미합니다. Thread 디렉토리로 이동해서 빌드를 하려는데 여기에도 Makefile 의 SUB_DIRS 가 지정되어 있다면, 해당 서브-서브 디렉토리로 이동해서 빌드를 할 테고요, 그때는 make[2] 가 될 겁니다. 만의 하나 SUB_DIRS 구성이 잘못되어 순환 참조가 일어나면, 즉 Thread 의 SUB_DIRS 에 Protocol 디렉토리가 포함되어 있다면 메모리가 허용할 때까지 make를 시도하다가 결국엔 비정상 종료가 일어나게 됩니다.

만약 SUB_DIRS 에 지정된 디렉토리가 존재하지 않으면 어떻게 될까요? 예를 들어, $(PROJ_SRC_DIR)/Oops 디렉토리를 실제 디렉토리에는 추가하지 않은 채, SUB_DIRS에만 추가해 볼까요?

# Makefile of MyProtocol

...
SUB_DIRS = $(ROOT_SRC_DIR)/Thread \
	$(ROOT_SRC_DIR)/Oops \
	$(ROOT_SRC_DIR)/Network 
...

이렇게 하고 빌드를 수행하면, 현재의 make 실행 자체가 실패하면서 바로 중단됩니다.

$ make
make: *** `subdirs'에서 필요로 하는  타겟 `/home/philnet/mbbs/Chapter8/Source/Oops'를 만들 규칙이 없습니다.  멈춤.

그럼 Oops 디렉토리는 있는데 Makefile 이 없을 경우는 어떻게 될까요? 그때는 Oops 디렉토리에서 적절한 Makefile 이 없다며 make가 실패하고, 이후의 make 들이 중단됩니다. (디렉토리가 아예 없을 때와는 달리) 그 이전의 make들은 성공하고요.

 $ make
make[1]: Entering directory `/home/mbbs/Chapter8/Source/Thread'
make[1]: Leaving directory `/home/mbbs/Chapter8/Source/Thread'
make[1]: Entering directory `/home/mbbs/Chapter8/Source/Oops'
make[1]: *** 타겟 `all'를 만들 규칙이 없음.  멈춤.
make[1]: Leaving directory `/home/mbbs/Chapter8/Source/Oops'
make: *** [subdirs] 오류 1

참고로, 지금은 $(ROOT_SRC_DIR)/… 이렇게 절대 경로를 사용했습니다만, subdirs 규칙을 보면 어차피 SUB_DIRS 에 지정된 항목 하나하나에 대해서 make -C 를 수행하기 때문에, 유효하기만 하다면 현재 디렉토리에 대한 상대 경로를 사용해도 상관 없습니다. 예를 들어 SpecialSubDir 이나 ../Thread 와 같은 상대 경로 상의 디렉토리들도 SUB_DIRS 에 지정 가능하다는 것이죠.

dependall 과 cleanall

서브 디렉토리들에 대해 재귀적으로 빌드가 가능해졌으니까, 마찬가지로 clean 과 depend 도 재귀적으로 처리할 수 있도록 해야겠죠? 다음을 보세요.

# IncludePost.mk

...
depend :
...
dependall : depend
	@for dir in $(SUB_DIRS); do \
		$(MAKE) -C $$dir dependall; \
		if [ $$? != 0 ]; then exit 1; fi; \
	done
...
clean :
...
cleanall : clean
	@for dir in $(SUB_DIRS); do \
		$(MAKE) -C $$dir cleanall; \
		if [ $$? != 0 ]; then exit 1; fi; \
	done

dependall 과 cleanall 규칙들은 각각, 선행조건에 depend 와 clean 을 지정해서 현재 디렉토리에서 depend 및 clean 을 먼저 수행하고, 이후 서브 디렉토리들에 대해 재귀적으로 같은 작업을 수행합니다. 차이라면 all 은 subdirs 를 통해 재귀 호출은 한데 비해서, dependall 과 cleanall 은 자체적으로 재귀 호출을 수행한다는 것 뿐이네요.

그리고나서는 dependall 나 cleanall 를 실행할 때에는 depend_file 을 include 하지 않도록 해주면 끝입니다.

# IncludePost.mk

...
ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), cleanall)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(MAKECMDGOALS), dependall)
ifneq (S(strip $(LIB_SRCS) $(TARGET_SRCS)),)
-include $(DEPEND_FILE)
endif
endif
endif
endif
endif

응용 규칙 추가

이제까지는 make all 을 실행해서 라이브러리와 실행 바이너리들을 한번에 빌드했습니다. 이건 Makefile에서 all 의 선행조건에 라이브러리(LIB_FULL_NAME)과 실행 파일들(TARGET_NAMES)를 지정했기 때문이죠. 그리고 선행조건 맨 앞에 subdirs 규칙을 넣어서 의존하는 서브 디렉토리의 라이브러리들이 먼저 빌드하도록 했는데요, 그 결과 항상 서브 디렉토리가 먼저 빌드되고 나서, 현재 디렉토리가 빌드됩니다. 네, 이게 우리가 원한 겁니다.

이렇게만 해도 충분합니다. 충분한 것 같은데, 빌드할 소스 파일들이 많아지고 의존하는 라이브러리들이 많아지면, 이런 저런 추가 요구 사항들이 생겨날 지도 모릅니다. 세상은 넓고 별난 개발자도 많은 법.

  • “서브 디렉토리의 소스 파일들보다는 현재 디렉토리에 있는 소스 파일들을 편집하는 빈도가 아무래도 더 높지 않겠어? 현재 디렉토리의 소스 파일에 컴파일 에러를 일으킬 문제가 있다면, 서브 디렉토리들 다 한번씩 갔다 와서 발견하는 것 보다는 빌드 시작하자 마자 바로 발견하는게 좋을 것 같은데…”
  • “난 내 라이브러리하고 실행 파일들만 빌드하고 싶은데, 서브 디렉토리들의 라이브러리들만 빌드하고 거기 있는 실행 파일들은 빌드 안 할 수는 없을까?”
  • 기타 등등…

먼저, 서브 디렉토리의 라이브러리들보다 현재 디렉토리의 라이브러리를 먼저 빌드하는 건 간단합니다. all 규칙의 선행조건에서 subdirs 와 $(LIB_FULL_NAME)의 위치만 바꿔 주면 됩니다. 쉽죠?

# MakefilePost.mk

...
all : $(LIB_FULL_NAME) subdirs $(TARGET_NAMES)

음… 그런데 $(LIB_FULL_NAME) 이나 $(TARGET_NAMES)는 Makefile 마다 제각각인게 좀 거슬립니다. 모든 곳에서 동일한 규칙 이름으로 빌드할 수 있다면, 사용하기가 좀더 편하지 않을까요? 추가하죠, 뭐. 어려운 것도 아닌데 말입니다.

# MakefilePost.mk

...
all : lib subdirs targets
...
lib : $(LIB_FULL_NAME)
...
targets :  $(TARGET_NAMES)

lib 과 targets 규칙도 있는 마당에, 이제 서브 디렉토리의 실행 프로그램들만 빼고 빌드하는 liball 규칙의 작성은 이젠 누워서 떡먹기죠. 현재 디렉토리의 라이브러리가 먼저 빌드되도록, 선행조건에 lib 을 추가하고, $(SUB_DIRS) 의 각 디렉토리 항목마다 make 를 재귀적으로 실행하면 끝입니다.

# MakefilePost.mk

...
liball : lib
	@for dir in $(SUB_DIRS); do \
		$(MAKE) -C $$dir liball; \
		if [ $$? != 0 ]; then exit 1; fi; \
	done

liball 도 만들었는데 targetsall 도 같이 만들어 볼까요? 마찬가지로 현재 디렉토리의 소스 파일들이 먼저 빌드되도록 선행조건에 targets 를 둡니다.

# MakefilePost.mk

...
targetsall : targets
	@for dir in $(SUB_DIRS); do \
		$(MAKE) -C $$dir targetsall; \
		if [ $$? != 0 ]; then exit 1; fi; \
	done

흠, 규칙들을 꽤 많이 만들었네요. 한번 정리해 보죠.

  • all: 현재 디렉토리 및 모든 서브 디렉토리들의 라이브러리와 실행 프로그램들 빌드
  • subdirs: 모든 서브 디렉토리들의 라이브러리와 실행 프로그램들 빌드
  • clean / cleanall: 현재 디렉토리의 결과물만 제거 / 현재 디렉토리 및 모든 서브 디렉토리들의 결과물 제거
  • depend / dependall: 현재 디렉토리의 의존성 파일 생성 / 현재 디렉토리 및 모든 서브 디렉토리드들의 의존성 파일 생성
  • lib / liball: 현재 디렉토리의 라이브러리 빌드 / 현재 디렉토리 및 모든 서브 디렉토리드들의 라이브러리 빌드
  • targets / targetsall: 현재 디렉토리의 실행 프로그램들 빌드 / 현재 디렉토리 및 모든 서브 디렉토리드들의 실행 프로그램들 빌드

적어 놓고 보니, subdirs 만 빼고는 현재 디렉토리에서만 수행하는 규칙 이름에 all 이 붙으면 재귀적으로 수행하는 거네요.  굳이 subdirs 를 써야만 되는 경우는 별로 없을 것 같으니, 실제로는 lib, targets, clean, depend 이렇게 4개의 규칙과, 재귀적으로 빌드할 때는 all 을 붙인다 정도만 알면 크게 어렵지 않게 사용할 수 있을 겁니다.

라이브러리 의존성 규칙

Protocol 디렉토리의 MyClient 프로그램의 경우, lbMyProtocol.a, libMyThread.a, libMyNetwork.a 세 개의 라이브러리에 의존하고 있습니다. MyClinet.c 가 의존하고 있는 MyProtocol.h 파일이 수정되었다면, 다음에 make 를 실행했을 때 libMyProtocol.a 가 빌드되고, MyClient.c 도 컴파일되서 MyClient 가 링킹됩니다.

예를 들어 모든 헤더 파일에 include 되는 MyCommon.h 가 수정되면, 모든 .c 파일들이 컴파일될테고, 라이브러리들과 실행 프로그램들이 일제히 빌드되겠죠? 아, 이제까지 고생한 보람이 느껴집니다.

헌데 MyProtocol.c 파일을 수정하고 나서 make 를 실행해 보면 좀 이상한 결과가 나옵니다.

$ touch MyProtocol.c 
$ make
===================================================
= Compiling Debug/MyProtocol.o 
===================================================
gcc  -g -O0 -DDEBUG -I/home/philnet/mbbs/Chapter8/Include -c MyProtocol.c -o Debug/MyProtocol.o
ar rcv /home/philnet/mbbs/Chapter8/Out/Library/Debug/libMyProtocol.a Debug/MyProtocol.o
r - Debug/MyProtocol.o
ranlib /home/philnet/mbbs/Chapter8/Out/Library/Debug/libMyProtocol.a
make[1]: Entering directory `/home/mbbs/Chapter8/Source/Thread'
make[1]: Leaving directory `/home/mbbs/Chapter8/Source/Thread'
make[1]: Entering directory `/home/mbbs/Chapter8/Source/Network'
make[1]: Leaving directory `/home/mbbs/Chapter8/Source/Network'

어라? MyProtocol.c 컴파일되고, libMyProtocol.a 는 다시 만들어지는데, MyClient 는 다시 빌드가 안 되네요? 이거 웬지 예전에 똑같은 경험을 한 것 같은 기시감이 느껴지지 않습니까? 네, Make 기반 빌드 시스템 (2): Makefile 기초 의 “의존성 규칙” 부분에서 거의 유사한 경험을 했죠.

그때는 .c 파일이 변경되면 컴파일이 되면서 다시 빌드되는데, .c 파일에서 include 한 헤더 파일들이 변경되었을 때는 컴파일이 되지 않았습니다. 그래서 의존성(depend) 규칙을 추가해서 해결했지요.

지금 실행 파일이 새로 링킹 되지 않는 것도 비슷한 이유입니다. 이제까지 작성한 Makefile에서, 실행 프로그램은 실행 프로그램에 포함된 구현 소스 파일과 거기서 include된 헤더 파일에만 의존할 뿐, 라이브러리와는 아무런 상관이 없습니다. 따라서 라이브러리의 구현 소스 파일이 변경되면 라이브러리만 빌드되지, 실행 프로그램들은 변경된 라이브러리를 다시 링킹하지 않습니다. 아, 치명적이네요.

하지만 문제의 원인이 같다면 해결 방법도 같을 테지요? 다시 말해, 실행 프로그램들이 라이브러리 파일에 의존적이라는 것을 알려주는 의존성 규칙을 추가해 주는 거죠. 다음처럼 말입니다.

# Makefile of Protocol

...
$(TARGET_NAMES) : $(LIB_FULL_NAME) \
	$(ROOT_LIB_DIR)/$(OBJS_DIR)/libNetwork.a \
	$(ROOT_LIB_DIR)/$(OBJS_DIR)/libThread.a \

위와 같은 규칙이 추가되면, 이젠 각 MyClient 같은 실행 파일이 세 라이브러리에 모두 의존적이란 걸 make 가 알게 되기 때문에, 세 라이브러리 중 하나라도 변경되면, 실행 파일들을 다시 링킹하게 됩니다.

좋습니다. 하지만 라이브러리 의존성 규칙을 추가한다는 건, libSomething.a 라는 라이브러리를 하나 사용하면, $(LIBS) 변수에 -lSomething 도 추가하고 라이브러리 의존성 규칙에 $(ROOT_LIB_DIR)/$(OBJS_DIR)/libSomething.a 도 추가해야 한다는 얘기가 됩니다. 이거 좀 번거롭다는 생각과 함께, $(LIBS) 로부터 $(TARGET_NAMES) 규칙을 자동으로 만들 수 있지 않을까… 싶지 않으신가요? (그러시다면 이제까지의 제 설명과 게으른 제 성격을 완전히 파악하신 겁니다.)

역시나 이제는 많이 익숙해진 make의 문자열 치환을 써서 $(LIBS) 의 항목들을 $(TARGET_NAMES) 의존성에 들어갈 것으로 쉽게 바꿀 수 있습니다. 자신 있으신 분들은 직접 한 번 해 보셔도 좋겠네요. 정답은 바로…

5

4

3

2

1

짜잔.

$(LIBS:-l%=$(ROOT_LIB_DIR)/$(OBJS_DIR)/lib%.a)

어떻게, 다들 맞추셨나요? 자 그럼 라이브러리 의존성 규칙은 다음과 같이 작성하면 되겠습니다.

# IncludePost.mk

...
$(TARGET_NAMES) : $(LIB_FULL_NAME) $(LIBS:-l%=$(ROOT_LIB_DIR)/$(OBJS_DIR)/lib%.a)

그런데 여기서 하나 짚고 넘어가야 할 게 있습니다. 빌드하고자 하는 실행 프로그램이, 우리 빌드 시스템에서 빌드되지 않은, 그래서 $(ROOT_LIB_DIR)/$(OBJS_DIR) 밑에 없는 라이브러리(예를 들어 -pthread 나 -Xmu 같은)를 $(LIBS)에 지정하는 경우에는, 해당 타겟을 만들 규칙이 없다는 에러 메시지가 나오면서 링킹이 중단됩니다.

조금만 생각해 보면, 우리가 작성하지 않은, 그래서 이미 시스템에 설치된 (외부의) 라이브러리라면 굳이 의존성을 확인하지 않아도 된다고 볼 수 있지 않을까요? 또 그렇다면 라이브러리 의존성 규칙에 포함시킬 필요도 없는 것이고요.

결국 Makefile 에서 링킹할 라이브러리들을 우리 빌드 시스템에서 빌드된, 그래서 의존성 규칙에 포함시켜야 하는 것들과, 그렇지 않은 것들로 분리하고, 의존성 체크가 필요한 것들만 의존성 규칙에 포함시키는 것으로 처리하면 파일이 없다는 링킹 에러 문제를 피해갈 수 있습니다. 다음을 보세요.

# Makefile of Prototype

...
# 의존성 규칙에 포함시킬 라이브러리입니다.
DEPEND_LIBS = -lMyThread -lMyNetwork

# 외부 시스템 라이브러리입니다.
LIBS += -lpthread
...
# InstOfPost.mk

...
ALL_LIBS = -l$(LIB_NAME) $(DEPEND_LIBS) $(LIBS)
...
$(TARGET_NAMES) : $(LIB_FULL_NAME) \
	$(DEPEND_LIBS:-l%=$(ROOT_LIB_DIR)/$(OBJS_DIR)/lib%.a)

의존성 확인이 필요한 라이브러리들은 Makefile에서 $(DEPEND_LIBS) 변수로 따로 모읍니다. 그리고 IncludePost.mk 파일에서는 $(ALL_LIBS) 에 $(DEPEND_LIBS)를 추가하고, 라이브러리 의존성 규칙에는 라이브러리들의 전체 파일 이름들로 치환한 결과를 추가하였습니다.

참고로 만약, 우리 빌드 시스템의 라이브러리가 아니지만, 이것과 비슷하게 라이브러리 의존성 규칙에 포함시킬 필요가 있다면, 그건 해당 Makefile에서 추가적으로 $(TARGET_NAMES) 규칙을 하나 더 처리하면 문제 없이 처리할 수 있습니다.

# Makefile of Something

...
SpecialSomething : /usr/lib/libspecial.so
...

총정리

휴, 이제 대충 다 정리된 것 같으네요. 정리해 볼까요? 2개의 공용 include 파일들인 IncludePre.mk와 IncludePost.mk 과, 이를 사용하는 Makefile 의 원형인 MakeTemplate.mk 는 각각 다음과 같습니다.




새로운 모듈을 만들 때는 MakefileTemplate.mk 를 복사한 다음, 적절한 값으로 바꿔서 사용합니다. make의 실행 방법은 다음과 같습니다.

  • 사용 가능한 규칙
    • make (또는 make all): 현재 디렉토리 및 모든 서브 디렉토리들의 라이브러리와 실행 프로그램들 빌드
    • clean / cleanall: 현재 디렉토리의 결과물만 제거 / 현재 디렉토리 및 모든 서브 디렉토리들의 결과물 제거
    • depend / dependall: 현재 디렉토리의 의존성 파일 생성 / 현재 디렉토리 및 모든 서브 디렉토리드들의 의존성 파일 생성
    • lib / liball: 현재 디렉토리의 라이브러리 빌드 / 현재 디렉토리 및 모든 서브 디렉토리드들의 라이브러리 빌드
    • targets / targetsall: 현재 디렉토리의 실행 프로그램들 빌드 / 현재 디렉토리 및 모든 서브 디렉토리드들의 실행 프로그램들 빌드
    • 위 명령어에 RELEASE=1 을 넣으면 릴리즈 모드로, 그렇지 않으면 디버그 모드로 빌드
  • 전형적인 사용 예
    • 그냥 아무 생각없이 빌드하는 경우: make
    • 현재 디렉토리의 라이브러리만 빌드하려면: make lib
    • 실행 파일들을 빌드하는데 필요한 라이브러리들을 모두 빌드하려면: make liball
    • 현재 디렉토리의 실행파일들만(서브 디렉토리의 실행 파일들은 빼고) 빌드하려면: make liball targets

이번 글의 핵심은 make를 재귀호출해서 여러 개의 서브 모듈들을 한번에 빌드하는 것이었죠. 이 내용을 충분히 이해하신 분들이라면, 본인의 필요에 맞는 여러 가지 응용 규칙들을 만들어서 사용하실 수 있을 겁니다. 또한 Makefile의 서브 디렉토리들을 잘 엮어서, 최상위 디렉토리에서 필요한 라이브러리 및 실행 파일들을 한번에 빌드하는 것도 가능할 테고요.

이제서야 비로소 빌드 시스템이라고 부를만한 기본 틀을 갖추었네요. 앞으로는 여기에다 부가적인 기능들을 하나씩 추가해 보도록 하죠.

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

Make 기반 빌드 시스템 (7): 빌드 디렉토리 구성

빌드 디렉토리 구성

이제까지 작성한 Makefile을 가지고, 하나의 라이브러리와 이를 사용하는 여러 실행파일들을 한번에 빌드할 수 있습니다. 라이브러리에 새로운 소스 파일을 추가하거나, 새로운 실행파일의 추가하려면, Makefile의 한 줄만 고치면 됩니다. depend만 적절한 시점에 한번씩 실행시켜 주면, 수정된 파일들에 영향받는 파일들만 정확하게 컴파일해서 라이브러리와 이를 사용하는 실행파일들을 다시 빌드해 줍니다.

그런데 라이브러리에 포함되는 소스 파일들이 많아지면 어떻게 될까요? 너무 많은 소스파일들이 하나의 라이브러리에 포함되어 있으면 당연히 라이브러리를 빌드하는데 시간도 오래 걸리고 크기도 커집니다. 아주 바쁠 때, 일부 헤더 파일이 수정되어 depend를 해야 하거나, 뭔가 찜찜해서 clean이라도 해야 할라치면 한숨부터 나올 때가 있습니다. 라이브러리 내의 일부 기능만 쓰고 싶은 경우에, 이건 배보다 배꼽이 더 크다는 느낌이 자꾸 들기도 하고요.

일반적으로 소스 파일들이 많아지면 레이어별 또는 기능별과 같이 몇 가지 기준으로 적절한 규모의 라이브러리들로 분리해서 관리하곤 합니다. 테스트 및 유지 보수 관리 측면에서, 또 여러 명이 협업하는 측면에서 그게 좀더 효율적이겠지요.

이전에 작성한 MyProtocol을 예를 들어 볼까요? 사실 thread, mutex, semaphore 같은 기능들은, 잘 만들어 두면 재활용할 수 있는 여지가 큽니다. 따라서 적당한 문서화 및 단위 테스트만 잘 정리해 놓으면 다른 프로젝트에도 쉽게 재사용할 수 있을 겁니다. 마찬가지로, 파일 I/O, stack, queue, 등의 각종 자료 구조나 이런 저런 알고리듬 등도 성능과 안정성 면에서 검증되어 있다면, MyProtocol의 사양이 확장되어 이런 기능들이 필요할 때 바로 가지고 와서 빠르게 변경 요구에 대응할 수 있을 테고요.

적당한 규모의 라이브러리로의 분리와 재활용… 아 말만 들어도 기분이 좋아지는군요. 하지만 각 라이브러리들의 Makefile들에는 똑같은 부분들이 상당 부분 존재할 겁니다. 계속 진행하다 보면 나중에 새로운 빌드 옵션을 일괄적으로 적용하려 할 때 반복적인 노가다를 뛰어야 합니다. 그러다 자칫 집중력이 떨어져 한 군데에서 빼먹으면 빌드 에러가 납니다. 운이 더 나쁘면, 빌드 에러 없이 통과 되어 런타임에 오묘한 오동작으로 나타날 수도 있겠죠.

모름지기 제대로 된 소프트웨어 개발자라면 사소한 코드 중복도 막기 위해 갖가지 노력을 합니다. 반복되는 값들이 있으면 적절한 상수 변수로 선언하고, 반복되는 코드 블럭은 리팩토링해서 공용 함수로, 공용 객체로 뽑아 내겠죠. (거기, 갑자기 눈길을 피하시는 분들은 뭐죠?)

Makefile도 마찬가지입니다. 공통으로 반복적인 부분들은 공용 파일로 만든 다음 include해서 반복을 피할 수 있습니다. (include 하는 방법은 이전 글 중, Make 기반 빌드 시스템 (3): 소스 목록 관리를 조금 편리하게 에서 다루었습니다.) 역시 개발자에겐 말보단 예제가 빠르겠죠? 다음과 같이 총 3개의 라이브러리로 나눈다고 가정해 보겠습니다.

  • MyThread: thread, mutex, semaphore 등과 같이 여러 개의 thread들을 관리하는데 필요한 기능들을 제공
  • MyNetwork: socket 및 기타 네트워크와 관련된 일련의 기능을 제공
  • MyProtocol: 현재 개발 중인 프로토콜과 관련된 기능을 제공

이때 해당 라이브러리들을 별도의 디렉토리로 각각 분리해서, 각각의 라이브러리 및 실행 프로그램들을 작성한다고 했을 때, Makefile들은 어떻게 되어야 할까요? 제일 처음 할 일은 당연히 라이브러리들의 Makefile들에 공통적으로 들어 있는 부분들을 별도의 파일로 분리해 내는 것 아닐까요.

Makefile들의 공통 부분 분리

다음은 Makefile 6.1 내용에서 thread 관련된 부분만을 라이브러리로 만들고, 각각 관련된 테스트 프로그램들을 만든다고 했을 때, 달라지는 부분을 표시한 것입니다.

CC = gcc
AR = ar
...

DEPEND_FILE = $(OBJS_DIR)/depend_file

LIB_NAME = MyThread
LIB_FULL_NAME = $(OBJ_DIR)/lib$(LIB_NAME).a

LIB_SRCS = MyMutex.c MySemaphore.c MyThread.c
LIB_OBJS = $(LIB_SRCS:%.c=$(OBJS_DIR)/%.o)

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

TARGET_SRCS = TestMutex.c TestSemaphore.c TestThread.c
TARGET_OBJS = $(TARGET_SRCS:%.c=$(OBJS_DIR)/%.o)
TARGET_NAMES = $(TARGET_SRCS:%.c=$(OBJS_DIR)/%)

.SUFFIXES : .c .o

all : $(LIB_FULL_NAME) $(TARGET_NAMES)
...

네, 보시다시피 달려져야 할 부분은 단 3줄에 불과합니다. MyNetwork이나 MyProtocol의 경우도 마찬가지일 테고요. 따라서 이 3줄을 제외한 나머지 부분을 별도의 파일들로 분리하면 될 것 같습니다. 공용 파일들의 이름은 각각 IncludePre.mk 와 IncludePost.mk 이라고 해 볼까요?

# Makefile of MyThread

include IncludePre.mk

LIB_NAME = MyThread
LIB_SRCS = MyMutex.c MySemaphore.c MyThread.c
TARGET_SRCS = TestMutex.c TestSemaphore.c TestThread.c

include IncludePost.mk
# IncludePre.mk

CC = gcc
AR = ar
RANLIB = ranlib
RM = rm
MKDIR = mkdir

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

DEPEND_FILE = $(OBJS_DIR)/depend_file
LIB_DIRS = -L.
# IncludePost.mk

LIB_FULL_NAME = $(OBJ_DIR)/lib$(LIB_NAME).a
LIB_OBJS = $(LIB_SRCS:%.c=$(OBJS_DIR)/%.o)

LIBS += -l$(LIB_NAME)
LIBS += -lpthread 

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

.SUFFIXES : .c .o

all : $(LIB_FULL_NAME) $(TARGET_NAMES)

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

$(OBJS_DIR)/%.o : %.c
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    $(CC) $(CFLAGS) $(DBG_FLAGS) -c $< -o $@

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

depend :
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    @$(RM) -f $(DEPEND_FILE)
    @for FILE in $(LIB_SRCS:%.c=%) $(TARGET_SRCS:%.c=%); do \
        $(CC) -MM -MT $(OBJS_DIR)/$$FILE.o $$FILE.c >> $(DEPEND_FILE); \
    done

clean :
    $(RM) -fr $(OBJS_DIR)

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

일단 이렇게 분리되고 나면, Network이나 MyProtocol의 Makefile들도 다음처럼 간단해 질 수 있습니다.

# Makefile of MyNetwork

include IncludePre.mk

LIB_NAME = MyNetwork
LIB_SRCS = Socket.c
TARGET_SRCS = TestSocket.c

include IncludePost.mk
# Makefile of MyProtocol

include MakefilePreinc

LIB_NAME = MyProtocol
LIB_SRCS = MyProtocol.c
TARGET_SRCS = Client.c sTestConnection.c TestDataChange.c TestDrinking.c

LIBS += -lMyNetwork -lMyThread

include IncludePost.mk

공통적인 부분은 IncludePre.mk 와 IncludePostmk 2개의 공용 파일로 분리되어 각 Makefile들에서 공유되기 때문에, 개별 Makefile은 극도로 간단해지고 해당 라이브러리 및 관련 실행 프로그램들과 직접적으로 관련된 내용들만 있게 됩니다. 따라서 특정 라이브러리에 별도의 조건이나 선언하기가 용이합니다. 게다가 모든 라이브러리들에 공통적으로 해당하는 변경사항이 생기면, 공용 파일들에 추가해 주면 일괄적으로 적용할 수 있게 됩니다. 훌륭하죠?

여러 개의 소프트웨어들을 빌드할 때, 개별 변경과 전체 일괄 변경을 함께 처리할 수 있는 소위 한방 빌드 시스템의 윤곽이 비로소 잡히는 순간입니다. 와우. 하지만 이걸 실제 빌드 시스템으로 사용하기 위해서는 손을 좀 봐 줘야 합니다.

첫째, 먼저 각 라이브러리들의 소스 파일들과 Makefile들, 그리고 Makefile에서 포함할 공용 include 파일들을 어디다 위치시켜야 할 것인가를 결정해야 합니다. 두 번째로는 라이브러리 간의 의존성 문제도 처리해야 합니다. 다른 라이브러리에 의존하는 라이브러리의 경우, 라이브러리 빌드하는 건 그냥 되지만, 이 라이브러리를 사용하는 실행 프로그램을 링킹할 때에는 의존하는 다른 라이브러리도 링킹해야 하거든요. 이를 위한 라이브러리 빌드 결과물의 저장 위치나 빌드 순서도 고려의 대상이 됩니다.

이런 것들을 해결한 다음에는 궁극적으로 모든 소프트웨어들을 한번에 빌드할 수 있는 방법도 필요하겠지만, 일단 두 가지 이슈들을 하나씩 풀어나가 보죠. 제일 먼저 소스 파일 및 Makefile 파일, 그리고 공용 include 파일들을 어떻게 위치시키는 것부터 살펴 보겠습니다.

소스 디렉토리 구성

이제까지는 하나의 디렉토리에서 단일 라이브러리와 복수 개의 실행 프로그램들을 빌드했습니다.

.
├── Makefile
├── MyClient.c
├── MyCommon.h
├── MyDebug.h
├── MyMutex.c
├── MyMutex.h
├── MyProtocol.c
├── MyProtocol.h
├── MySemaphore.c
├── MySemaphore.h
├── MySocket.c
├── MySocket.h
├── MyThread.c
├── MyThread.h
├── MyType.h
├── TestConnection.c
├── TestDataChange.c
└── TestDrinking.c

이랬던 것을, 이제 3개의 라이브러리(및 실행 프로그램)들을 위한 디렉토리들에 나누어 저장해야 합니다. 일단 공통인 것은 현재 디렉토리에 그대로 두고, 각 라이브러리에 속하는 것들만 옮겨 보죠

.
├── IncludePre.mk
├── IncludePost.mk
├── MyCommon.h
├── MyDebug.h
├── MyType.h
├── Network
│   ├── Makefile
│   ├── MySocket.c
│   ├── MySocket.h
│   └── TestSocket.c
├── Protocol
│   ├── Makefile
│   ├── MyClient.c
│   ├── MyProtocol.c
│   ├── MyProtocol.h
│   ├── TestConnection.c
│   ├── TestDataChange.c
│   └── TestDrinking.c
└── Thread
      ├── Makefile
      ├── MyMutex.c
      ├── MyMutex.h
      ├── MySemaphore.c
      ├── MySemaphore.h
      ├── MyThread.c
      └── MyThread.h

이렇게 나눌 경우, 소스 파일과 Makefile들에 include되는 헤더 파일과 공용 include 파일들의 경로를 수정해야 합니다. 제일 간단하게는 개별 파일의 디렉토리 위치에 대한 상대 경로로 지정할 수 있겠네요. 다음과 같이 말입니다.

# Makefile of MyNetwork

include ../IncludePre.mk
...
include ../IncludePost.mk
/* MyClient.c */

#include "../MyCommon.h"
#include "../Network/MySocket.h"
#include "../Thread/MyMutex.h"
#include "../Thread/MySemaphore.h"
#include "../MyThread/MyThread.h"
...

그런데 이런 식의 상대 경로를 사용하게 되면, 나중에 디렉토리 이름이 바뀌거나 프로젝트 규모가 커진다든지 하는 이유로 디렉토리를 재조직해서 경로를 이동해야 할 경우, 관련된 모든 Makefile 및 소스 파일들의 경로를 수정해야 하는 문제가 생깁니다. 단순한 Find & Replace 작업이긴 합니다만 파일 수가 많아지면 그 노가다 작업도 솔직히 제법 신경 쓰이죠.

Makefile들은, 기준이 되는 루트 디렉토리를 정해서 시스템의 환경 변수로 설정한 다음, 이에 대한 상대 경로로 공용 include 파일들을 지정하는 게 가장 간단하고 확실한 방법입니다. 다음을 보세요.

$ export PROJ_ROOT=~/MyProject
# Makefile of MyNetwork

include $(PROJ_ROOT)/IncludePre.mk
...
include $(PROJ_ROOT)/IncludePost.mk

Makefile내에서는 시스템의 환경 변수를 Makefile의 일반 변수처럼 사용할 수 있습니다. 그래서 쉘 스크립트에서 PROJ_ROOT 를 export 해 두면, Makefile에서는 $(PROJ_ROOT)로 해당 경로를 지정할 수 있게 되는 거죠. 이렇게 되면 모든 Makefile들에서 공용 include 파일들의 경로가 동일해 지므로 설혹 라이브러리 디렉토리의 경로가 바뀌어도 Makefile은 수정되지 않아도 됩니다. 빌드 할 때마다 PROJ_ROOT 환경 변수를 지정해 주는 게 성가시긴 하지만, 이건 쉘 profile에서 처리하거나 간단한 쉘 스크립트 하나를 만들어서 쓰면 쉽게 해결됩니다.

그러면 소스 파일들에 include되는 헤더 파일들은 어떻게 해야 할까요? 제일 손쉬운 방법은 헤더 파일들을 하나의 디렉토리로 모으고, 공용 include 파일에서 make가 헤더 파일을 찾는 경로에 해당 디렉토리를 포함시키는 겁니다. 헤더 파일을 모으는 디렉토리 이름을 Include로 하고, 나머지 구현 파일들과 Makefile들은 Source 디렉토리를 만들어서 거기 저장하는 것으로 해 볼까요?

.
├── Include
│  ├── MyCommon.h
│  ├── MyDebug.h
│  ├── ...
├── IncludePost.mk
├── IncludePre.mk
└── Source
    ├── Network
    │   ├── Makefile
    │   ├── MySocket.c
    │   ├── ...
# IncludePre.mk

...
INC_DIRS = -I$(PROJ_ROOT)/Include
...
# IncludePost.mk

...
$(OBJS_DIR)/%.o : %.c
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    $(CC) $(CFLAGS) $(DBG_FLAGS) $(INC_DIRS) -c $< -o $@
...

개발자 또는 개발 조직의 취향에 따라, 헤더 파일들을 별도의 디렉토리에 모으는 방법은 거부감을 주기도 합니다. 사실 소스 파일을 생성할 때 같은 이름의 헤더 파일과 구현 파일을 동일한 디렉토리에 저장하는 게 자연스럽습니다. 그리고 헤더 파일과 구현 파일을 오가면서 봐야 할 때도 그렇고요. 어떤 경우에는 프로젝트에서 사용하는 코드 생성 도구가 같은 디렉토리에 쌍으로 생성하기도 하죠.

이런 저런 이유로 헤더-구현 파일 쌍을 같은 디렉토리에 두고 싶다면, IncludePre.mk 파일에 ROOT_SRC_DIR 변수를 두고, 소스 파일에서는 거기에서부터의 경로를 사용하면 됩니다.

# IncludePre.mk

...
ROOT_SRC_DIR = $(PROJ_ROOT)/Source
...
INC_DIRS += -I$(ROOT_SRC_DIR)
...
# MyClient.c

#include "MyCommon.h"
#include "Network/MySocket.h"
...

개발하노라면 언젠가 라이브러리의 구조를 수정하거나 이에 준하는 일(이런 일은 꼭 생기는 법이죠. 머피의 법칙이었던가요?)이 생기면 많은 소스 코드들의 include 부분에서의 경로를 수정하는 노가다를 뛰어야 합니다. 다만 이 경우는 잘못되어도 컴파일러가 걸러주기 때문에 그냥 노가다일 뿐 심각한 문제는 아닐 수 있습니다.

이에 비해, 헤더 파일과 구현 파일이 다른 디렉토리에 위치하는 건, 소스 파일들의 경로 수정에 제약이 없고, 바이너리의 라이브러리와 헤더 파일을 외부에 제공해야 하는 일이 있을 때도 무척 편리합니다. 하지만 모든 개발자가 개발 일상에서 불편하다고 느낄 수 있기 때문에 이게 더 심각할 수도 있을 테지요.

2가지 방법 모두 각자의 장단점이 확실하므로, 취향에 따라 선택하시면 될 듯 싶습니다. 이젠 라이브러리 사이의 의존성에 대해 생각해 볼까요?

라이브러리 사이의 의존성 처리

앞서 MyNetwork, MyProtocol, MyThread 이렇게 세 개의 라이브러리들로 구분했는데요, thread, mutex, semaphore를 멋지게 wrapping한 MyThread 라이브러리를 테스트하는 실행 프로그램들은 당연히 MyThread 라이브러리를 링킹하고 있겠죠?

헌데 MyProtocol는 MyNetwork의 네트워크 기능과 더불어 멀티 쓰레딩 기반으로 구현하느라 MyThread의 기능도 사용합니다. 그럼 MyProtocol 라이브러리들을 사용하는 실행 프로그램들은 어떻게 빌드되어야 할까요? 네, 당연히 사용하는 라이브러리들 모두를 링킹해야 합니다. 즉, MyClient 프로그램을 링킹할 때에는 MyThread, MyNetwork, MyProtocol 세 개의 라이브러리들이 모두 필요합니다.

이걸 Makefile에서는 어떻게 처리하는 게 좋을까요? 고민할 것 뭐 있나요? 사용할 라이브러리들을 직접 다 기입하면 되죠 뭐. 이렇게 말입니다.

# Makefile of MyProtocol

...
LIB_DIRS += -L$(PROJ_ROOT)/Source/Network/$(OBJS_DIR)
LIB_DIRS += -L$(PROJ_ROOT)/Source/Thread/$(OBJS_DIR)

이제 게으른 저의 작업 스타일에 어느 정도 익숙해 진 분이라면 위와 같이 뭔가를 반복해서 쓰는 것, 별로 안 내켜할 거란 것 짐작하시려나요? 예를 들어 지금은 Thread 디렉토리가 Source 바로 밑에 있는데, 나중에 이런 저런 공용 라이브러리를 추가, 관리하면서 Common 이라는 디렉토리를 만들고 그 밑으로 Thread를 옮기게 되면 Thread를 사용하는 Makefile들에서 해당 경로를 수정해 줘야 합니다.

제가 어떻게 하자고 할 지 감이 오시죠? 네, 헤더 파일들을 한 디렉토리로 모으는 것과 비슷합니다. 라이브러리들도 하나의 디렉토리에 모아 두고, 해당 경로를 공용 include 파일에 추가하는 거죠. 이 때, Include 처럼 Library 디렉토리를 PROJ_ROOT에 바로 추가해도 되지만, 추후에 라이브러리가 아닌 다른 결과물도 생길 것을 염두에 두고 Out 이라는 디렉토리를 하나 만들어서, 그 밑에 다음과 같이 추가하는 것도 나쁘지 않습니다.

.
├── Include
│ ├── ...
├── IncludePost.mk
├── IncludePre.mk
├── Out
│ ├── Library
└── Source
    │   ├── ...

일단 이렇게 하기로 결정되었으면, 공용 include 파일에 Out 과 Library 디렉토리들을 각각 나타내는 변수들을 추가하고, 빌드된 라이브러리 파일이 저장되는 경로에 이를 적용합니다.

# IncludePre.mk

...
ROOT_OUT_DIR = $(PROJ_ROOT)/Out
ROOT_LIB_DIR = $(ROOT_OUT_DIR)/Library
...
# IncludePost.mk

LIB_FULL_NAME = $(ROOT_LIB_DIR)/$(OBJS_DIR)/lib$(LIB_NAME).a
...

이렇게 하면, 모든 라이브러리들이 $(ROOT_LIB_DIR)/$(OBJS_DIR) 이라는 하나의 디렉토리에 저장될 것이기 때문에, 사용하는 각 라이브러리가 위치하는 디렉토리들을 Makefile에서 지정해 줄 필요가 없어집니다. 이건 좋죠? 대신 모든 라이브러리가 저장될 디렉토리가 처음 빌드할 때는 존재하지 않을 것이기 때문에, 라이브러리를 만드는 규칙에서 디렉토리 존재 여부를 체크해서 없으면 만들도록 해 주어야 합니다.

# IncludePost.mk

...
$(LIB_FULL_NAME) : $(LIB_OBJS)
    @`[ -d $(ROOT_LIB_DIR)/$(OBJS_DIR) ] || $(MKDIR) -p $(ROOT_LIB_DIR)/$(OBJS_DIR)`
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@
...

$(MKDIR) 뒤에 붙은 -p 는 주어진 경로의 중간 디렉토리들을 한번에 생성하게 하는 mkdir의 옵션입니다. 이를 사용해서 Out, Library, 그리고 Debug (or Release) 디렉토리들을 한번에 만드는 거죠.

여기까지 하고 나면, Protocol 디렉토리의 Makefile에서 LIB_DIRS 변수값을 설정할 필요가 없고, 단지 사용하는 라이브러리들을 LIBS 디렉토리에 설정하기만 하면 됩니다. 그 결과는 다음과 같은데요, 애초에 의도했던대로 각 라이브러리들의 Makefile들이 중복이 (거의) 없어지면서 아주 단순해 졌습니다.


 



아참, 라이브러리 저장 위치가 바뀌었으니까 make clean 할 때, 새 경로에 있는 라이브러리 파일도 같이 지워지도록 해 줘야겠죠?

# IncludePost.mk

...
clean :
    $(RM) -fr $(OBJS_DIR) $(LIB_FULL_NAME)
...

라이브러리 링킹 순서

글을 마무리하기 전에, 마지막으로 하나만 더 알아 볼까 합니다. TARGET_NAMES 변수에 지정된 실행 파일들을 LIBS 변수에서 지정된 라이브러리들과 링킹할 때, 좀 주의해야 할 점이 있는데요, 딴 게 아니라 LIBS에 지정되는 순서입니다.

즉, libA.a 라이브러리가 libB.a 라이브러리에 있는 심볼을 사용하는 경우, 링킹할 때는 반드시 -lA 이 -lB 앞에 있어야 합니다. 반대로 되어 있으면 링킹하다가 A가 사용한 B 심볼을 찾을 수 없다는 깜짝 에러 메시지가 나타납니다. (사실 이건 처음 당해보면 도대체 뭐가 잘못 된 건지 짐작하기도 힘듭니다. 네, 제 경험담이죠…)

이걸 왜 얘기하냐 하면, 이제까지 작성한 Makefile은 LIB_NAME(예: Protocol)만 정하고, 이 라이브러리에서 사용하는 LIBS(예: -lNetwork -lThread)를 선언하고 나서, 이후 IncludePost.mk를 include합니다. 문제는 마지막에 포함되는 IncludePost.mk에서 LIBS에다 -l$(LIB_NAME)을 추가한다는 거죠. 각 Makefile에 동일하게 쓰는 부분은 최대한 공용 include 파일로 옮기려다 보니 생긴 참사(?)인데요, 의존 순서에 맞지 않기 때문에 그대로 두면 링킹 에러가 발생합니다.

음… 모든 Makefile에서 LIBS 변수는 제일 처음에 -l$(LIB_NAME)로 설정되도록 해도 되지만, 그보다는 IncludePost.mk를 약간만 손 봐주는게 나을 듯 싶습니다.

# IncludePost.mk

...
ALL_LIBS = -l$(LIB_NAME) $(LIBS)
...
$(TARGET_NAMES): $$@.o
    @echo "==================================================="
    @echo "= Linking $@ "
    @echo "==================================================="
    $(CC) -o $@ $< $(LIB_DIRS) $(ALL_LIBS)

보시다시피, ALL_LIBS 변수 하나만 추가하는 걸로 잘 해결 되었습니다. 다른 개발자들한테 “Makefile에 LIBS 에는 항상 $(LIB_NAME) 이 제일 먼저 들어가야해…”라로 백번 말하는 것 보다 훨씬 효과가 좋을 겁니다. 제가 장담하죠.

그런데 여기까지는 좋은데요, 만약 라이브러리 사이의 의존 관계가 단방향이 아니라 순환적일 때는 어떻게 될까요? 다시말해, A 가 B 에 의존적이고, B 가 다시 A 에 의존적인 경우가 되겠죠. 사실 이건 별로 좋지 않은 구조입니다. 라이브러리들의 의존 관계는 단방향인 게 맞습니다.

예를 들어 어떤 프레임워크 라이브러리가 있고, 이를 사용해서 구체적인 응용 프로그램 라이브러리를 만드는 경우를 생각해 볼까요? 프레임워크와 응용 프로그램 사이에 순환 관계가 성립한다는 얘기는, 응용 프로그램 독립적이어야 할 프레임워크가 어떤 구체적인 응용 프로그램에 의존적이라 걸 의미합니다. 이렇게 되면 그 프레임워크를 사용하는 다른 응용 프로그램들도 특정 응용 프로그램에 의존적이 되므로 프레임워크로서의 가치가 없어지죠.

일반적으로 프레임워크에서 추후 응용 프로그램에서 결정되어야만 하는 부분에 대해서는, 함수 포인터나 가상 함수들을 선언해 두고 프레임워크 내에서 이들을 사용해서 나머지 기능들을 구현하는 게 보통입니다. 이후 프레임워크를 사용하는 응용 프로그램들은 프레임워크가 요구하는 함수 포인터를 설정해 주거나 가상 함수를 오버로딩 하는 식의 작업을 처리해 줘야 하고요

그런데 응용 프로그램을 만들다가 프레임워크에서 미처 고려되지 못한 기능을 급하게 구현하거나 혹은 프레임워크의 설계를 충분히 이해하지 못한 상태에서 아무 생각없이 프레임워크 쪽에 응용 프로그램 쪽 기능을 가져다 쓰는 순간, 프레임워크와 응용 프로그램 사이에 순환 의존성이 발생하게 됩니다.

따라서 라이브러리들 사이의 순환 의존성이 발생한다면, 단방향 의존 관계로 되게끔 수정하는 게 가장 좋습니다. 하지만 당장 급하게 빌드 해야만 하는 상황에 부딪히면, 일단은 어떻게든 빌드 되도록 해야 하는데, 몇 가지 방법이 있습니다.

첫 번째 방법은 C/C++의 전방선언과 비슷한 방법입니다. 즉 A와 B가 서로 의존하는 경우, 하나를 두번 선언하는 겁니다. 무슨 얘기냐 하면, -lA -lB -lA 이런 식으로 하라는 거죠. 이렇게 하면 보통은 컴파일러가 알아서 잘 처리해 줍니다. 하지만 때로는 이렇게 해도 안 되는 경우가 발생하기도 합니다. 이 경우는 A 와 B 가 서로 지나치게 친한 경우인데요, 이럴 때에는 아예 2개의 라이브러리들를 하나로 합치는 것도 고려해 봄 직 합니다.

하지만 앞에서 예로 든 프레임워크와 응용 프로그램의 경우처럼 하나로 합치는 건 말이 안 되는 경우도 있겠죠? 나중에 바로 잡더라도 일단 급한 불은 꺼야 하는데 첫 번째 방법으로도 안 된다면 링커(ld)의 -( -) 옵션을 사용해서 처리해야 합니다. 참고로 ld 의 -( -) 옵션은 -( 과 -) 사이에 나오는 아카이브들을 그룹핑해서, 새로운 정의되지 않은 참조들이 생성되지(나타나지) 않을 때까지 검색합니다.

자 그럼 이걸 어떻게 써야 할까요? gcc에는 링커에게 특정한 옵션을 넘겨 줄 수 있는 옵션이 있습니다. -Wl,option 이 바로 그건데요, -Wl, 다음에 입력되는 option 을 그대로 링커에게 넘겨줍니다. 다음을 보세요.

# IncludePost.mk
...
$(TARGET_NAMES): $$@.o
	@echo "==================================================="
	@echo "= Linking $@ "
	@echo "==================================================="
	$(CC) -o $@ $< $(LIB_DIRS) -Wl,-\( $(ALL_LIBS) -Wl,-\)
...

우리가 해 줘야 하는 건 $(LIBS) 를 -(  -) 로 그룹핑하는 걸 링커에게 넘기는 거죠. 그런데 ( )는 Makefile 내에서 의미가 있는 문자기 때문에 백슬래시(\)로 에스케이프 해 줘야 gcc 가 링커에게 -( -) 을 잘 전달할 수 있습니다. 빙고.

아 처음부터 이걸로 해 줬으면 좋았잖아요… 투덜거리실 분도 있으려나요? 사실 거기엔 다 이유가 있습니다. 세상에 공짜 점심은 없다니까요.

일반적으로 링커는 명령행에서 지정된 아카이브들을 입력받은 순서대로 단한번만 검색합니다. 그래서 순환 참조가 생기면 심볼을 못 찾는 겁니다. 이에 비해 -( -) 그룹핑을 하게 되면 새로운 정의되지 않은 참조들이 없어질 때까지 반복적으로 심볼을 찾기 때문에 그만큼 비효율적(당연하겠죠?)입니다. 따라서 이 옵션을 사용하는 것에는 상당한 성능 비용을 치르게 되고요, 두개 또는 그 이상의 아카이브들 간에 피할 수 없는 순환 참조가 있을 때만 사용하는 는 것이 좋습니다.

저도 압니다. 소프트웨어가 복잡하게 구성되고, 여러 플랫폼과 다양한 기능들을 선택적으로 빌드하려다 보면, 어쩔 수 없이 순환 참조가 생기기도 합니다. 그래서 정말 다급할 때에는 선택적으로 그룹핑 옵션을 쓸 수 있도록 하는 것도 의미가 있습니다. 사실 어려운 건 아니거든요.

# IncludePost.mk
...
$(TARGET_NAMES): $$@.o
	@echo "=================================================="
	@echo "= Linking $@ "
	@echo "=================================================="
ifeq($(LIBS_CYCLING_DEPEND),1)
    $(CC) -o $@ $< $(LIB_DIRS) -Wl,-\( $(ALL_LIBS) -Wl,-\)
else
    $(CC) -o $@ $< $(LIB_DIRS) $(ALL_LIBS)
endif
...

LIBS_CYCLING_DEPEND 변수 값이 1이면 그룹핑하고, 그렇지 않으면 한번만 찾도록 하는 거죠. 따라서 도저히 피할 수 없는 순환 참조 문제가 발생하면, Makefile에서 LIBS_CYCLING_DEPEND 변수값을 1로 선언하거나 make 실행할 때 LIBS_CYCLING_DEPEND=1 을 인자로 주면 됩니다.

후유, 다 되었네요.

총정리

자, 이제까지 얘기한 것들을 하나로 모아 볼까요? 먼저, 2개의 공용 include 파일들인 IncludePre.mk와 IncludePost.mk는 각각 다음과 같습니다.



그리고 각 모듈의 Makefile은 이 공용 파일들을 사용해서 다음과 같이 작성합니다.


이왕이면 이를 새로운 모듈 작성 시 참조할 수 있도록 template로 만들어서, 공용 include 파일과 동일한 위치에 MakefileTemplate.mk 라고 저장해 두면 좋겠네요. 자, 이제 디렉토리 구조는 다음과 같습니다.

$(PROJ_ROOT)
├── Include
│   ├── Header1.h
│   ├── ...
├── IncludePost.mk
├── IncludePost.mk
├── MakefileTemplate.mk
└── Source
    ├── Module1
    │   ├── Makefile
    │   ├── Source1-1.c
    │   └── ...
    ├── ...

그리고 새로운 모듈을 추가하려면,

  • Source 디렉토리 밑에 적당한 서브 디렉토리를 생성합니다.
  • $PROJ_ROOT/MakefileTemplate.mk 파일을, 생성한 디렉토리에 Makefile 로 복사한 다음, LIB_NAME 변수 값을 지정해 줍니다.
  • 라이브러리의 소스 파일들 중 구현 파일들은 서브 디렉토리에 저장하고, Makefile의 LIB_SRCS 변수에 추가합니다.
  • 라이브러리를 사용하는 (main 함수를 가지고 있는) 실행 프로그램들의 소스 파일들도 서브 디렉토리에 저장하고, Makefile의 TARGET_SRCS 변수에 추가합니다.
  • 해당 라이브러리가 의존하는 다른 라이브러리들을 Makefile의 LIBS 변수에 추가합니다. 만약 라이브러리 간의 순환 참조가 발생한 경우에는 LIBS_CYCLING_DEPEND이 1이 되도록 Makefile에 변수로 추가하거나 make 실행 시 인자로 넣어줍니다.
  • 라이브러리의 소스 파일들 중 헤더 파일들은 Include 또는 Source 디렉토리 중, 취향에 따라 편한 위치에 저장합니다.

빌드하고자 하는 디렉토리에서 make를 실행하면, 라이브러리 파일은 $(PROJ_ROOT)/Out/Library 디렉토리 아래, Debug 또는 Release 디렉토리에 저장됩니다. 나머지 .o 파일들과 실행 프로그램들은 해당 라이브러리 디렉토리 밑의 Debug 또는 Release 디렉토리에 저장됩니다.

길어지긴 했지만 이번 글의 핵심은, 프로젝트 규모가 커질 때 좀더 쉽게 유지 보수할 수 있고, 재활용이 용이하도록 적절한 수준의 라이브러리들로 나누어 구성하는게 핵심입니다. 그리고 이 과정에서 Makefile들의 공통 부분을 별도의 파일들로 분리한 다음 각 Makefile들이 이를 include 하도록 하면, 개별 Makefile은 최대한 단순하게 하면서도 전체 모듈들에 일괄적으로 적용해야 하는 변경 사항들도 쉽게 처리할 수 있게 되죠.

아직까지도 이번 글에서 다룬 내용만 가지고는 실제 사용하기에는 좀 많이 부족합니다. 하지만 중요한 것은, 빌드 디렉토리가 일단 이렇게 한번 구성된 이후에는 전체 모듈들의 빌드 과정을 손쉽게 개선시킬 수 있다는 건데요, 앞으로 그런 것들을 하나씩 살펴 보도록 하겠습니다.

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

Make 기반 빌드 시스템 (6): 빌드모드 별 결과물 관리

빌드모드 별 결과물 관리

이전 글에서 작성된 Makefile 5.1의 경우, 디버그와 릴리즈 모드 구분을 하는 것까지는 잘 됐지만, 개발 과정에서 불편한 문제가 하나 있습니다.

예를 들어 먼저 디버깅 모드로 빌드해서 테스트, 문제 없이 잘 동작하는 것을 확인한 다음, 이제 버전 관리 툴에 커밋하기 전에 확인 사살 차원에서 릴리즈 모드로도 잘 동작하는 것을 확인하는 상황을 생각해 볼까요.

이미 디버그 모드로 빌드되어 있는 상황에서는, 아무리 make RELEASE=1 이라고 실행해봐야 모든 선행조건들이 만족된 상태이기 때문에 컴파일이 일어나지 않고, 링킹도 안 일어납니다. 따라서 릴리즈 모드로 컴파일 되게 하려면 먼저 make clean 을 수행해야만 합니다.

마찬가지로, 릴리즈 모드로 컴파일되어 있는 상태에서 특정한 소스 코드의 디버그 메시지를 보고 싶은 경우에도, 해당 소스 파일을 touch 해서 이전에 빌드된 목적 파일보다 더 최신 상태가 되게 하거나 목적 파일을 지워서 컴파일이 일어나게 해야 합니다. 아니면 make clean을 수행하고요. 디버그/릴리즈 모드간 전환이 번거롭고 시간도 또 많이 걸립니다. 특별한 상황이 아니라면 바람직한 상황은 아니죠.

사실 이에 대한 해결책은 단순합니다. 각 모드의 목적 파일들이 각각 다른 디렉토리에 저장되도록 하는 것이죠. 그리고 이렇게 되면, 소스 파일들과 컴파일된 목적 파일들이 같은 디렉토리에 섞여 너저분해 지는 걸 막아 주는 부수적인 효과도 있습니다.

목적 파일들을 별도의 디렉토리에 저장하기

이렇게 하려면 먼저 목적 파일들이 저장될 위치부터 정해야 할 텐데요, 디버그 모드와 릴리즈 모드에 대한 목적 파일들을 저장할 디렉토리로는 각각 Debug 와 Release로 하면 무난하겠죠? 그럼 이 녀석을 OBJS_DIR이라는 변수로 선언합니다.

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

다음에 할 일은, 컴파일한 목적 파일이 OBJS_DIR에 저장되도록 해야 하는데, 음… 이건 제법 손이 많이 가는 일입니다.

먼저 LIB_SRCS 에서 LIB_OBJS 로 바꿀 때 목적 파일의 경로에 $(OBJS_DIR)을 추가합니다. 이제는 익숙(?)한 make의 문자열 치환 기능을 쓰면 이건 어렵지 않습니다.

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

LIB_OBJS은 Debug/MySocket.o Debug/MyMutex.o … 이렇게 지정됩니다.

문제는, Makefile에 이를 처리할 규칙이 없다는 데 있습니다. Makefile에 우리가 작성한 확장자 규칙은, 임의의 이름의 .c 파일을 동일 이름인데 확장자만 .o 인 파일로 바꾸는 경우에만 일치됩니다. 따라서 소스 파일은 현재 디렉토리의 .c 파일이고 목적 파일을 $(OBJS_DIR) 의 .o 파일에 다룰 수 있는 규칙이 필요하단 거죠.

make 패턴 규칙

어떻게 하면 규칙의 디렉토리 불일치를 맞출 수 있을까요? 그쵸, 우리의 make는 이런 경우에 사용할 수 있는 패턴 규칙을 제공합니다. 패턴 규칙(pattern rules)이란 변수의 치환에서 이미 사용한 것과 비슷하게 규칙의 타겟과 선행조건에 % 가 들어가 있는 규칙입니다. 변수 치환에서와 마찬가지로 % 는 ‘비어 있지 않은 임의의 문자열’과 일치합니다. 음… 글로 쓰고 보니 복잡하게 느껴지네요. 다음을 보세요.

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

위의 규칙에서 $(OBJS_DIR)/ 와 .o 그리고 .c는 정확하게 일치해야 하고, % 은 빈 문자열이 아닌 임의의 문자열과 일치할 수 있습니다. 따라서 make는 타겟이 Debug/MySocket.o 이고 선행조건이 MySocket.c 인 경우와 일치시킬 수 있기 때문에, gcc에게 현재 디렉토리의 MySocket.c 를 컴파일해서 그 결과를 Debug/MySocket.o 로 저장하라고 명령할 수 있습니다. 네, 바로 우리가 원하는 바죠.

그래도 여전히 문제가 하나 남아 있는데요, 다름이 아니라, 현재 디렉토리에는 Debug 디렉토리가 없다라는 겁니다. 그래서 이 상태로 그냥 실행시키면 gcc가 ‘치명적 오류: 그런 디렉토리 또는 파일이 없습니다.’라는 험악한 에러 메시지를 뿜으며 작업을 중단합니다. 까칠한 make의 비위를 맞춰 주려면 gcc 호출 전에 Debug 디렉토리가 있는 지를 확인해서 없으면 생성하는 작업이 필요합니다.

MKDIR = mkdir
...
$(OBJS_DIR)/%.o : %.c
	@echo "============================================================"
	@echo "= Compiling $@ "
	@echo "============================================================"
	@`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
	$(CC) $(CFLAGS) -c $< -o $@

리눅스 쉘 스크립트에서 디렉토리가 있는 지를 확인하는 테스트 구문인 [ -d dir_path ] 를 실행하고, 없을 때만 mkdir 를 실행하도록 || 연산자로 mkdir 명령어를 연결했습니다. 그리고 매번 컴파일할 때마다 해당 명령어가 출력되지 않도록 하기 위해, make의 @“ 구문으로 감싸 준거죠. 이렇게 하고 나면 Debug 또는 Release 디렉토리가 없을 때는 컴파일 전에 디렉토리를 생성하고, 그리고 나서 컴파일 결과를 해당 디렉토리에 저장하게 됩니다. 이미 있는 경우에는 무시하고 다음 명령으로 넘어 갈 테고요.

이제 빌드 결과물이 하나의 디렉토리에 이쁘게 모인 덕분에, clean의 경우는 다음과 같이 그냥 $(OBJ_DIR) 디렉토리를 그냥 지워주면 끝입니다.

clean: 
	rm -fr $(OBJS_DIR)

depend 처리하기

마지막으로 남은 건 depend군요. 이전의 다른 규칙들과 마찬가지로 $(OBJS_DIR) 확인 후 생성하고 경로만 바꿔주면 될테지요. 이렇게 말입니다.

DEPEN_FILE = $(OBJ_DIR)/depend_file

depend :
	@`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
	$(CC) -MM $(LIB_SRCS) $(TARGET_SRCS) > $(DEPEND_FILE)

불행히도 make depend 했을 때 실제로 실행해 보면, depend_file 생성까지는 잘 되는데, 일부 의존성 관계에 따른 재빌드가 정상적으로 동작하지 않는 경우가 있습니다. 예를 들어 모든 .c 파일들이 공통적으로 의존하는 MyCommon.h 파일이 변경되어도 .c 파일들은 다시 컴파일 되지 않습니다.

왜 그럴까요? 위에서 만들어진 depend_file의 내용을 보면 이유를 알 수 있습니다.

MySocket.o: MySocket.c MySocket.h MyCommon.h MyType.h MyDebug.h
MyMutex.o: MyMutex.c MyMutex.h MyCommon.h MyType.h MyDebug.h
MySemaphore.o: MySemaphore.c MySemaphore.h MyCommon.h MyType.h MyDebug.h
MyThread.o: MyThread.c MyThread.h MyCommon.h MyType.h MyDebug.h
MyProtocol.o: MyProtocol.c MyProtocol.h MyCommon.h MyType.h MyDebug.h
MyClient.o: MyClient.c MyCommon.h MyType.h MyDebug.h MySocket.h MyMutex.h \
  MySemaphore.h MyThread.h MyProtocol.h
TestConnection.o: TestConnection.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
TestDataChange.o: TestDataChange.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
TestDrinking.o: TestDrinking.c MyCommon.h MyType.h MyDebug.h MySocket.h

$(OBJS_DIR), 즉 Debug 나 Release 디렉토리가 전혀 나타나 있지 않기 때문입니다. 당연한 거죠.

돌이켜 보자면, depend_file의 내용은 gcc 의 -MM 옵션을 이용해서 (시스템 헤더 파일을 제외한) 소스 파일의 의존성만 출력한 것입니다. gcc는 이 일을 수행할 때 특별한 언급(?)이 없으면 .o 파일은 .c 파일과 동일한 경로에 있을 거라고 가정하고 의존성을 출력하기 때문에 위와 같은 결과가 나오는 것입니다. 결국 depend_file에 있는 .o 파일들의 경로가 올바르지 않으므로 의존성 처리가 제대로 처리되지 않는다는 게 문제의 핵심입니다.

그렇다면 해결책은 depend_file에 있는 .o 파일들의 경로를 올바르게 지정하는 것이 될 터입니다. 정규표현식에 익숙하신 능력자라면 gcc에 의해 만들어진 depend_file 파일을 한번 가공하는 방법도 생각해 봄직 하지만, 가능하다면 gcc의 옵션을 이용해서 애초에 올바르게 나오도록 해결하는 게 더 낫겠죠?

make -MT 옵션

위에서 “특별한 언급이 없으면” 이란 표현을 썼는데요, gcc의 옵션 중에는 의존성 처리 시의 타겟을 명확하게 지정할 수 있는 -MT 옵션이 있습니다. 이 옵션을 다음과 같이 사용해서 우리가 원하는 결과를 얻을 수 있습니다.

$ gcc -MM -MT Debug/MySocket.o MySocket.c 
Debug/MySocket.o: MySocket.c MySocket.h MyCommon.h MyType.h MyDebug.h

다만, 위의 gcc 구문은 하나의 파일에 대해서 동작합니다. 그래서 shell의 for 문을 사용해서 처리해야 합니다. 다음을 보세요.

depend :
	@`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
	@$(RM) -f $(DEPEND_FILE)
	@for FILE in $(LIB_SRCS:%.c=%) $(TARGET_SRCS:%.c=%); do \
		$(CC) -MM -MT $(OBJS_DIR)/$$FILE.o $$FILE.c >> $(DEPEND_FILE); \
	done

(4) ~ (6)은 $(LIB_SRCS)와 $(TARGET_SRCS) 하나하나 파일에 대해 루프를 돌면서, 위의 명령을 수행한 결과를 depend_file에 추가합니다. (5)에서 >> 는 shell에서의 리다이렉션 명령어 중 하나인데, 왼쪽의 실행 결과를 오른 쪽 파일에 추가합니다. 그런데 요렇게만 하면 make depend 할 때마다 depend 파일에 계속 추가될 것이기 때문에, 이를 막기 위해 (3)에서 기존의 파일을 먼저 삭제하는 겁니다.

이렇게 수정된 depend 규칙을 실행한 결과는 다음과 같습니다.

Debug/MySocket.o: MySocket.c MySocket.h MyCommon.h MyType.h MyDebug.h
Debug/MyMutex.o: MyMutex.c MyMutex.h MyCommon.h MyType.h MyDebug.h
Debug/MySemaphore.o: MySemaphore.c MySemaphore.h MyCommon.h MyType.h \
  MyDebug.h
Debug/MyThread.o: MyThread.c MyThread.h MyCommon.h MyType.h MyDebug.h
Debug/MyProtocol.o: MyProtocol.c MyProtocol.h MyCommon.h MyType.h \
  MyDebug.h
Debug/MyClient.o: MyClient.c MyCommon.h MyType.h MyDebug.h MySocket.h \
  MyMutex.h MySemaphore.h MyThread.h MyProtocol.h
Debug/TestConnection.o: TestConnection.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
Debug/TestDataChange.o: TestDataChange.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
Debug/TestDrinking.o: TestDrinking.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h

각 오브젝트 파일들의 경로가 정확하게 표기되어 헤더 파일이 바뀌었을 때 이를 포함하는 모든 소스 파일들이 컴파일됩니다. 좋군요. 짝짝짝.

자, 이제까지의 모든 내용들을 한번에 적용해 볼까요?


RELEASE=1 을 주고 make를 실행하면 릴리즈 모드로 빌드된 모든 오브젝트 파일들과 라이브러리, 실행파일 그리고 depend_file 까지 모두 Release 디렉토리에 저장됩니다. RELEASE=1이 주어지지 않으면 디버그 모드의 빌드 결과물들이 Debug 디렉토리에 저장되고요. 깔끔하죠?

여기서 한발짝 더 나가면, 단지 Debug/Release 뿐 아니라 여러 가지 플랫폼 또는 프로파일들에 맞는 빌드 결과물들을 각각 독립적으로 관리할 수도 있을 겁니다. 환경 변수 또는 make 실행 인자로 적절한 설정값을 주고 이에 따라 OBJS_DIR만 잘 정의하면 될 터입니다. (이와 관련해서는 나중에 좀 더 다뤄보도록 하겠습니다.)

 

Posted in 소프트웨어 | Tagged , , , , , , , | 1 Response