Using `make` to compile C programs (for non-C-programmers)

Julia Evans

`make`로 C 프로그램 컴파일하기 (C 프로그래머가 아닌 사람을 위해)

원문은 Julia Evans님이 에 게재했습니다. 이 블로그 구독하기

나는 C 프로그래머로 살아본 적이 없지만, 가끔 소스에서 C/C++ 프로그램을 컴파일해야 할 때가 있다. 이게 꽤 애를 먹었다. 오랫동안 나의 접근법은 기본적으로 “의존성을 설치하고 make를 실행한다, 안 되면 누군가 컴파일해 둔 바이너리를 찾아보거나 그냥 포기한다”는 것이었다.

‘누군가 이미 컴파일해 놨겠지’ 전략은 리눅스를 쓸 때는 꽤 잘 통했지만, 지난 몇 년간 Mac을 쓰면서부터는 직접 프로그램을 컴파일해야 하는 상황을 더 자주 마주하게 됐다.

그래서 이번에는 C 프로그램을 컴파일하려면 무엇을 해야 하는지 이야기해보자! 내가 직접 컴파일해 본 몇 가지 구체적인 C 프로그램 예시를 들어보고, 어떤 것들이 잘못될 수 있는지도 살펴보겠다. 이번에 다룰 세 가지 프로그램은 다음과 같다:

  • paperjam
  • sqlite
  • qf (검색 결과에서 파일을 빠르게 열 수 있는 페이저로 rg -n THING | qf로 실행할 수 있다)

1단계: C 컴파일러 설치하기

이건 꽤 간단하다. 우분투 시스템에서 C 컴파일러가 아직 없다면 이렇게 설치한다:

sudo apt-get install build-essential

이 명령은 gcc, g++, make를 설치한다. Mac에서는 상황이 좀 더 헷갈리는데, 대략 “Xcode 커맨드 라인 도구를 설치하라”는 식이다.

2단계: 프로그램 의존성 설치하기

몇몇 최신 프로그래밍 언어와 달리, C에는 의존성 관리자가 없다. 그래서 프로그램에 의존성이 있다면 직접 찾아서 설치해야 한다. 다행히 그래서인지 C 프로그래머들은 보통 의존성을 아주 최소한으로 유지하고, 의존성은 대개 쓰고 있는 패키지 매니저에서 바로 찾을 수 있다.

README에는 거의 항상 의존성을 어떻게 구하는지 설명하는 섹션이 있다. 예를 들어 paperjam의 README에는 이렇게 쓰여 있다:

PaperJam을 컴파일하려면 libqpdf와 libpaper 라이브러리의 헤더가 필요합니다 (보통 libqpdf-dev와 libpaper-dev 패키지로 제공됩니다).

매뉴얼 페이지를 빌드하려면 a2x(AsciiDoc에 포함)가 필요할 수 있습니다.

그래서 데비안 기반 시스템에서는 의존성을 이렇게 설치할 수 있다.

sudo apt install -y libqpdf-dev libpaper-dev

README에서 패키지 이름(예: libqpdf-dev)을 알려주면, 보통은 “데비안 기반 리눅스 배포판 기준”으로 이해하면 된다. Mac에서 brew install libqpdf-dev는 동작하지 않는다. 나는 아직 Mac에서의 개발에 완전히 익숙해지지 않아서 팁이 많지 않다. 이 경우에는 Homebrew를 쓴다면 brew install qpdf가 맞을 것이다.

3단계: ./configure 실행하기 (필요한 경우)

일부 C 프로그램은 Makefile을 포함하고 있고, 어떤 프로그램은 대신 ./configure라는 스크립트를 포함하고 있다. 예를 들어 sqlite 소스 코드를 다운로드하면, Makefile 대신 ./configure 스크립트가 들어 있다.

내가 이해한 이 ./configure 스크립트의 동작은 이렇다:

  1. 실행하면 다소 알 수 없는 출력을 잔뜩 쏟아낸 뒤, Makefile을 생성하거나 의존성이 없어서 실패한다
  2. ./configure 스크립트는 autotools라는 시스템의 일부인데, 나는 “Makefile을 생성하기 위해 실행한다”는 것 말고는 굳이 배울 필요가 없었다.

아마 ./configure 스크립트에 옵션을 넘겨서 다른 Makefile을 만들게 할 수도 있을 것 같지만, 나는 해본 적이 없다.

4단계: make 실행하기

다음 단계는 make를 실행해서 프로그램을 빌드하는 것이다. make에 대해 몇 가지 참고할 점이 있다:

  • 때로는 make -j8로 빌드를 병렬화해서 더 빠르게 만들 수 있다
  • 프로그램을 컴파일할 때 보통 컴파일러 경고가 엄청나게 많이 출력된다. 나는 항상 그냥 무시한다. 내가 만든 소프트웨어도 아닌데! 컴파일러 경고는 내 문제가 아니다.

컴파일러 오류는 대개 의존성 문제다

Mac에서 paperjam을 컴파일하다가 만난 오류다:

/opt/homebrew/Cellar/qpdf/12.0.0/include/qpdf/InputSource.hh:85:19: error: function definition does not declare parameters
   85 |     qpdf_offset_t last_offset{0};
      |                   ^

세월이 지나면서 이런 문제는 너무 깊게 고민하지 않는 게 보통 최선이라는 걸 배웠다. qpdf에 대해 언급하고 있다면, 십중팔구 qpdf 의존성을 포함하는 방법을 잘못했기 때문일 가능성이 높다.

이제 qpdf 의존성을 제대로 포함시키는 몇 가지 방법을 이야기해보자.

세상에서 가장 짧은 컴파일러와 링커 소개

의존성 문제를 어떻게 고치는지 이야기하기 전에, C 프로그램 빌드는 두 단계로 나뉜다는 점을 짚고 넘어가자:

  1. 컴파일 — 코드를 오브젝트 파일로 컴파일하기 (gccclang으로)
  2. 링킹 — 그 오브젝트 파일들을 최종 바이너리로 링크하기 (ld로)

C 프로그램을 빌드할 때 이걸 아는 것이 중요한데, 컴파일 중인 프로그램의 의존성을 어디서 찾아야 하는지 컴파일러와 링커에게 알려주기 위해 올바른 플래그를 전달해야 하는 경우가 있기 때문이다.

make는 환경 변수를 이용해 컴파일러와 링커를 설정한다

Mac에서 paperjam을 설치하려고 make를 실행하면 이런 오류가 난다:

c++ -o paperjam paperjam.o pdf-tools.o parse.o cmds.o pdf.o -lqpdf -lpaper
ld: library 'qpdf' not found

이건 내 시스템에 qpdf가 설치되어 있지 않아서 그런 게 아니다(실제로는 설치되어 있다!). 하지만 컴파일러와 링커가 qpdf 라이브러리를 찾는 방법을 모르는 것이다. 이를 해결하려면 다음이 필요하다:

  • 컴파일러에 "-I/opt/homebrew/include"를 전달하기 (헤더 파일을 어디서 찾을지 알려주기 위해)
  • 링커에 "-L/opt/homebrew/lib -liconv"를 전달하기 (라이브러리 파일을 어디서 찾을지 알려주고 iconv를 링크하기 위해)

그리고 make가 환경 변수를 이용해 이런 추가 파라미터를 컴파일러와 링커에 전달하게 할 수 있다! 어떻게 동작하는지 보자. paperjam의 Makefile 안에는 LDLIBS 같은 환경 변수들이 보인다:

paperjam: $(OBJS)
	$(LD) -o $@ $^ $(LDLIBS)

LDLIBS 환경 변수에 넣는 모든 것은 커맨드 라인 인자로 링커(ld)에 전달된다.

숨겨진 환경 변수: CPPFLAGS

Makefiles는 때로 컴파일러/링커에 전달하는 자체 환경 변수를 정의하기도 하지만, make에는 C 컴파일러와 링커에 자동으로 전달되는 “암시적” 환경 변수도 많이 있다. 암시적 환경 변수의 전체 목록은 여기에서 볼 수 있지만, 그중 하나가 CPPFLAGS로, C 컴파일러에 자동으로 전달된다.

(엄밀히 말하면 이럴 때는 CXXFLAGS를 쓰는 게 더 일반적이지만, 이 특정 MakefileCXXFLAGS를 하드코딩하고 있어서 Makefile을 수정하지 않고 컴파일러 플래그를 설정할 수 있는 유일한 방법이 CPPFLAGS를 설정하는 것이었다)

여담이지만, `make`가 C/C++와 얼마나 밀접하게 연결되어 있는지 깨닫는 데 오랜 시간이 걸렸다. 나는 예전에 `make`가 그냥 일반적인 빌드 시스템이라고 생각했는데(물론 무엇이든 빌드하는 데 쓸 수 있다!) 다른 종류의 프로그램을 빌드할 때는 없는 C/C++ 프로그램을 빌드하기 위한 많은 편의 기능이 있다.

make에 환경 변수를 전달하는 두 가지 방법

@zwol 덕분에 make에 환경 변수를 전달하는 방법이 실제로 두 가지가 있다는 걸 알게 됐다:

  1. CXXFLAGS=xyz make (일반적인 방법)
  2. make CXXFLAGS=xyz

둘의 차이는 make CXXFLAGS=xyzMakefile에 설정된 CXXFLAGS 값을 덮어쓰지만, CXXFLAGS=xyz make는 그렇지 않다는 것이다.

어느 쪽이 표준인지는 잘 모르겠지만, 이 글에서는 첫 번째 방법을 쓰겠다.

CPPFLAGSLDLIBS로 이 컴파일러 오류를 고치는 방법

이제 CPPFLAGSLDLIBS가 컴파일러와 링커에 어떻게 전달되는지 이야기했으니, 프로그램을 성공적으로 빌드하기 위해 내가 사용한 최종 주문을 소개하겠다!

CPPFLAGS="-I/opt/homebrew/include" LDLIBS="-L/opt/homebrew/lib -liconv" make paperjam

이 명령은 -I/opt/homebrew/include를 컴파일러에, -L/opt/homebrew/lib -liconv를 링커에 전달한다.

그리고 저 올바른 인자들이 무엇인지 내가 “마법처럼” 알았던 척하고 싶지는 않다. 그걸 알아내는 과정에는 이 글에서는 생략한 혼란스러운 구글링이 잔뜩 포함되어 있었다. 다만 이렇게 말할 수 있다:

  • -I 컴파일러 플래그는 /opt/homebrew/include/qpdf/QPDF.hh 같은 헤더 파일을 어느 디렉터리에서 찾을지 컴파일러에게 알려준다
  • -L 링커 플래그는 /opt/homebrew/lib/libqpdf.a 같은 라이브러리를 어느 디렉터리에서 찾을지 링커에게 알려준다
  • -l 링커 플래그는 어떤 라이브러리를 링크할지 링커에게 알려준다. 예를 들어 -liconv는 “iconv 라이브러리를 링크하라”는 뜻이고, -lm은 “math를 링크하라”는 뜻이다

팁: 특정 파일 하나만 빌드하는 방법: make $FILENAME

어제 qf라는 멋진 도구를 발견했는데, ripgrep의 출력에서 파일을 빠르게 여는 데 쓸 수 있다.

qf는 여러 도구가 모여 있는 큰 디렉터리에 있지만, 나는 qf만 컴파일하고 싶었다. 그래서 이렇게 qf만 컴파일했다:

make qf

기본적으로 빌드하려는 파일의 출력 파일 이름을 알고 있거나(혹은 추측할 수 있다면), make $FILENAME을 실행해서 make에게 그 파일만 빌드하라고 시킬 수 있다

팁: Makefile이 없어도 된다

나는 가끔 의존성이 없는 5줄짜리 C 프로그램을 작성하는데, blah.c라는 파일이 있으면 Makefile을 만들지 않고도 이렇게 컴파일할 수 있다는 걸 최근에 알게 됐다:

make blah

이 명령은 자동으로 cc -o blah blah.c로 확장되어 타이핑을 조금 줄여준다. 이걸 기억할지는 모르겠지만(아마 그냥 계속 gcc -o blah blah.c라고 칠지도 모른다), 재미있는 요령인 것 같다.

팁: 다른 패키징 시스템이 같은 C 프로그램을 어떻게 빌드했는지 살펴보기

C 프로그램을 빌드하는 데 어려움을 겪고 있다면, 다른 사람들도 빌드하면서 같은 문제를 겪었을지 모른다! 모든 리눅스 배포판은 빌드하는 모든 패키지에 대한 빌드 파일을 가지고 있어서, 그 배포판에서 패키지를 직접 설치할 수 없더라도 패키지를 어떻게 빌드하는지에 대한 힌트를 얻을 수 있다. 이걸 깨달은 것(친구 Dave 덕분이다)은 나에게 큰 아하 모먼트였다.

예를 들어, 이 줄은 paperjam용 nix 패키지에서 가져온 것이다:

  env.NIX_LDFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-liconv";

이건 기본적으로 “Mac에서 빌드하려면 링커 플래그 -liconv를 전달하라”는 뜻이므로, 빌드할 때 활용할 수 있는 단서가 된다.

같은 파일에는 env.NIX_CFLAGS_COMPILE = "-DPOINTERHOLDER_TRANSITION=1";라고도 쓰여 있다. 이게 무슨 뜻인지는 잘 모르겠지만, paperjam 패키지를 빌드하려고 하면 실제로 PointerHolder라는 것에 대한 오류가 발생하니, 아마 “PointerHolder 전환”과 어떻게든 관련이 있는 것 같다.

5단계: 바이너리 설치하기

프로그램 컴파일에 성공했다면, 아마 어딘가에 설치하고 싶을 것이다! 일부 Makefile에는 install 타깃이 있어서 make install로 시스템에 도구를 설치할 수 있다. 나는 이게 항상 좀 무섭다(파일을 어디에 넣는 거지? 나중에 삭제하고 싶으면 어떡하지?), 그래서 꽤 간단한 프로그램을 컴파일할 때는 대신 이렇게 바이너리를 직접 복사해서 설치하는 경우가 많다:

cp qf ~/bin

6단계: 직접 패키지를 만들어보기!

이 모든 걸 어떻게 하는지 알게 된 뒤에는, 새로 알게 된 make 지식으로 Homebrew에 paperjam 패키지를 기여할 수 있겠다는 생각이 들었다! 그러면 앞으로는 새 시스템에서 그냥 brew install paperjam만 하면 된다.

좋은 점은 다양한 패키징 시스템들의 세부 사항이 어떻게 다르더라도, 근본적으로는 모두 C 컴파일러와 링커를 사용한다는 것이다.

C 프로그래머가 아니더라도 C를 조금 이해하면 유용할 수 있다

이 모든 것이, 살면서 복잡한 C 프로그램을 직접 작성할 계획이 없더라도 C 프로그램이 어떻게 동작하는지에 대한 기초(예를 들어 “헤더 파일이 있다”는 것)를 이해하는 것이 얼마나 유용할 수 있는지 보여주는 흥미로운 사례라고 생각한다.

컴파일러와 링커 플래그에 대해 아직 완전히 자신 있는 건 아니고, autotools에 대해서도 “Makefile을 생성하기 위해 ./configure를 실행한다”는 것 말고는 앞으로도 배울 생각이 없지만, 그래도 C/C++ 프로그램을 직접 컴파일할 수 있는 능력이 생기니 기분이 좋다.

이 글에서 빠뜨린 두 가지는 다음과 같다:

  • LD_LIBRARY_PATH / DYLD_LIBRARY_PATH (동적 링커에게 런타임에 동적 링크된 파일을 어디서 찾을지 알려줄 때 쓰는 것) — 마지막으로 LD_LIBRARY_PATH 문제를 겪은 게 언제인지 기억나지 않고 예시도 찾지 못해서 다루지 않았다.
  • pkg-config — 중요하다고 생각하지만 아직 이해하지 못해서 다루지 않았다

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글