Skip to content

article0

Inho Oh edited this page Aug 8, 2024 · 11 revisions

샘플 C 라이브러리를 다양한 운영체제의 각 패키징 시스템에 맞게 패키징하고 배포하기

들어가며

C/C++ 라이브러리 개발자에게 크로스 플랫폼 지원은 중요한 과제입니다. 이 블로그 시리즈에서는 샘플 C 라이브러리를 만들고, 이 라이브러리를 여러 운영체제에 맞춰 패키징 및 배포하는 과정을 소개하려고 합니다.

시리즈 구성:

  1. 샘플 C 라이브러리 작성 및 라이브러리로서 갖춰야 할 요소들
  2. Ubuntu Linux에 deb 패키지로 배포하기
  3. Fedora Linux에 rpm 패키지로 배포하기
  4. macOS에 Homebrew TAP을 통해 배포하기
  5. Windows에 vcpkg custom registry를 통해 배포하기

샘플 C 라이브러리 작성 및 라이브러리로서 갖춰야 할 요소들

ALN(Amazing Lucky Numbers) 라이브러리 소개

먼저, 패키징 예시를 위한 적절한 라이브러리 선택에 대해 고민이 있었습니다. 너무 단순한 라이브러리는 실제 개발 상황과 거리가 멀고, 반대로 너무 복잡한 라이브러리는 패키징 과정보다 코드 이해에 더 많은 시간을 할애하게 만들 수 있기 때문입니다.

이러한 고민 끝에, 적당한 수준의 복잡성을 가진 예제 라이브러리를 만들기로 결정하고 'ALN(Amazing Lucky Numbers)'이라는 예제 라이브러리를 만들었습니다. 이 라이브러리는 다음과 같은 특징을 가지고 있습니다:

  1. 외부 라이브러리(glib)에 대한 의존성을 가지고 있습니다.
  2. API를 호출하면 행운의 번호를 생성해주는 기능을 수행합니다.
  3. 6개의 API를 제공합니다. - ALN 생성/해제/리셋, 번호 생성 (한번에/하나씩), 번호 얻기

ALN 라이브러리의 전체 소스 코드는 GitHub 저장소(https://github.com/webispy/aln)에서 확인하실 수 있습니다. 이 라이브러리는 실제 로또 번호를 생성하는 것이 아니라, 단순히 예제를 위한 '행운의 번호'를 제공한다는 점을 유의해 주시기 바랍니다.

이제 이 ALN 라이브러리를 기반으로, C 라이브러리가 갖춰야 할 핵심 요소들과 멀티 플랫폼 지원을 위한 고려사항들을 자세히 살펴보겠습니다.

라이브러리 구성 요소

개발자가 ALN 라이브러리를 사용하기 위해 제공해야 할 필수적인 요소들과 사용 편의성을 높이기 위한 추가 요소들에 대해 자세히 알아보겠습니다.

필수 요소

아래 두 요소는 라이브러리 사용을 위해 반드시 필요합니다.

  1. 헤더 파일 (Header File):
    • API의 선언이 포함된 .h 파일입니다.
    • 사용자가 라이브러리의 기능을 이해하고 사용할 수 있게 해줍니다.
  2. 라이브러리 파일 (Library File):
    • 컴파일된 코드가 포함된 Shared object(.so, .dylib, .dll 등) 또는 Static archive(.a, .lib) 파일입니다.
    • 어플리케이션에서 라이브러리를 링크하여 사용할 수 있게 합니다.

추가 요소

빌드 정보 제공 (pkg-config 파일)

라이브러리가 가지고 있는 의존성과 빌드 방법에 대한 정보는 매우 중요한데, 이런 정보들을 제공하는 가장 효과적인 방법은 pkg-config 파일(.pc)을 같이 제공하는 것입니다.

다음은 Linux의 /usr/lib/x86_64-linux-gnu/pkgconfig 디렉토리에 있는 xdmcp.pc 파일의 내용입니다. (사용자의 환경에 따라 libxdmcp-dev 패키지가 설치되어 있지 않으면 해당 파일이 없을 수 있습니다.)

prefix=/usr
exec_prefix=${prefix}
libdir=${prefix}/lib/x86_64-linux-gnu
includedir=${prefix}/include

Name: Xdmcp
Description: X Display Manager Control Protocol library
Version: 1.1.3
Requires: xproto
Cflags: -I${includedir}
Libs: -L${libdir} -lXdmcp

위 파일에서 라이브러리의 이름(Name), 설명(Description), 빌드할 때 필요한 컴파일 옵션(Cflags)과 링크 옵션(Libs), 그리고 의존성 라이브러리(Requires) 정보를 확인할 수 있습니다. 이를 통해 사용자는 xdmcp 라이브러리를 사용하려면 xproto 라이브러리가 필요하다는 것을 알 수 있습니다.

.pc 파일에 기술된 내용은 아래와 같이 pkg-config 명령을 통해 직접 값을 얻을 수 있으며, 이를 이용하면 xdmcp 라이브러리를 사용한 어플리케이션을 쉽게 빌드할 수 있습니다.

$ pkg-config --cflags --libs xdmcp
-lXdmcp

$ gcc -o a.out test.c `pkg-config --cflags --libs xdmcp`
# `gcc -o a.out test.c -lXdmcp` 와 동일

xdmcp 라이브러리 하나만 사용하는 예제라 pkg-config 없이 직접 컴파일 옵션을 넣는 것이 더 간단해 보일 수 있지만, 사용하는 라이브러리 갯수가 늘어나게 되면 큰 힘을 발휘하게 됩니다.

아래는 예제 라이브러리인 ALN에서 제공하는 aln.pc 파일에 대한 pkg-config 결과입니다.

$ pkg-config --cflags --libs aln.pc
-I/usr/include/aln -I/usr/include/glib-2.0 -I/usr/lib/aarch64-linux-gnu/glib-2.0/include -laln -lglib-2.0

aln.pc에 정의된 의존성 정보(Requires:)를 참조해서 의존성 라이브러리인 glib에 대한 빌드 옵션까지 한번에 결과로 출력된 것을 확인할 수 있습니다.

API 문서

위에서 언급한 헤더 파일, 라이브러리 파일, pkg-config 파일 등 라이브러리에서 제공해야 하는 기본 구성 요소들 외에도 API 문서가 있다면 더욱 좋습니다. 많은 개발자들이 문서화를 귀찮아하지만, API를 작성할 때 Doxygen 형태로 주석을 추가해 놓으면 문서 작업도 큰 노력 없이 어느 정도 대응이 가능합니다.

또한 Doxygen은 웹브라우저에서 사용할 수 있는 HTML 기반의 API 문서와 콘솔에서 확인할 수 있는 man 페이지 형태의 API 문서를 모두 만들어낼 수 있습니다. 자세한 사용법은 https://www.doxygen.nl/manual/index.html 에서 참고할 수 있습니다. 예제 라이브러리인 ALN에서도 Doxygen을 이용해서 문서를 만들고 있으니 참고하시면 좋을 것 같습니다.

샘플 프로그램

라이브러리를 잘 만들어서 제공했는데, 사용하는 곳에서 잘못 사용하면 라이브러리 탓을 할 수 있습니다. 반대로, 사용하는 곳의 시스템 특성을 고려하지 않아 오작동할 경우 샘플 프로그램이 있으면 이를 통해 로그 및 덤프를 제공받을 수 있어 디버깅에 도움이 됩니다. 따라서 동작을 검증할 수 있는 샘플 프로그램도 같이 제공하는 것이 좋습니다.

제공하는 API의 기능이 다양하다면, 샘플 프로그램을 기능 단위로 나누서 여러 개의 샘플 프로그램들을 제공하거나 실행 옵션을 통해 API를 테스트할 수 있게 하는 것도 좋은 방법입니다.

ALN의 경우 샘플 프로그램 실행시 추가 옵션을 통해 API 기능을 테스트할 수 있게 구현하였고, --help 옵션으로 전체 옵션들을 확인할 수 있도록 구현하였습니다. 자세한 내용은 https://github.com/webispy/aln/tree/master/tool 에 있는 소스 코드를 참고하시기 바랍니다.

ALN 구성 요소

ALN 라이브러리의 최종 배포 파일 구조는 아래와 같습니다. (HTML 기반의 API 문서는 https://webispy.github.io/aln 에서 확인할 수 있습니다.)

.
└── usr
    ├── bin
    │   └── aln
    ├── include
    │   └── aln
    │       └── aln.h
    ├── lib
    │   └── x86_64-linux-gnu
    │       ├── libaln.so -> libaln.so.0
    │       ├── libaln.so.0 -> libaln.so.0.1.0
    │       ├── libaln.so.0.1.0
    │       └── pkgconfig
    │           └── aln.pc
    └── share
        └── man
            ├── man1
            │   └── aln.1
            └── man3
                ├── aln.h.3
                ├── aln_draw_all.3
                ├── aln_draw_number.3
                ├── aln_free.3
                ├── aln_get_number.3
                ├── aln_new.3
                └── aln_reset.3

이 구성은 헤더 파일, 라이브러리 파일, pkg-config 파일, 샘플 실행 파일, 그리고 man 페이지를 포함하고 있어, 사용자가 라이브러리를 쉽게 사용하고 문서를 참조할 수 있게 해줍니다.

멀티 플랫폼 지원을 위해 고려하기

라이브러리를 여러 플랫폼에서 사용할 수 있도록 만들기 위해서는 다음과 같은 요소들을 고려해야 합니다:

  1. 빌드 시스템
    • 플랫폼마다 지원하는 빌드 시스템이 다를 수 있습니다.
    • 크로스 플랫폼 빌드를 지원하는 빌드 시스템을 사용하면 여러 플랫폼에서의 빌드 프로세스를 단순화할 수 있습니다
  2. 플랫폼 호환성
    • 각 플랫폼별로 표준 C API 및 프레임워크 API가 다를 수 있습니다.
    • 플랫폼 특성에 영향을 받는 프레임워크 API를 사용해야 한다면, 조건부 컴파일을 사용할 수 있습니다. 예: #ifdef _WIN32
  3. Shared Library 특성
    • 플랫폼별로 shared library의 특성이 다르기 때문에, API 구현 시 이를 고려해야 합니다.
    • 예를 들어, 심볼 가시성(symbol visibility) 설정을 통해 의도한 API만 외부에 노출되도록 해야 합니다.

ALN 예제 라이브러리는 위의 고려사항들을 다음과 같이 적용하였습니다:

  1. 빌드 시스템

    • CMake를 선택하였습니다. CMake는 오래되고 대중적이며, 다양한 플랫폼을 지원하기 때문입니다. 다른 옵션으로는 meson, scons, autotools 등이 있으며, 개인 선호에 따라 선택할 수 있습니다.
    • 개인적으로 다음 프로젝트의 빌드 시스템을 선택하라고 하면 meson을 선택할 것 같습니다. 참고로 meson에서 제공하는 빌드 시스템 비교 문서 https://mesonbuild.com/Simple-comparison.html 공유드립니다.
  2. 플랫폼 호환성

    • 이번 기술 블로그의 주 목적이 라이브러리 자체에 있기 때문에, 대상 플랫폼(Linux, macOS, Windows)에서 모두 호환 가능한 API 및 모든 대상 플랫폼을 지원하는 의존성 라이브러리(glib)를 사용하였습니다.
    • 따라서 ALN 코드 안에는 플랫폼에 따라 분기해서 Framework API를 호출하는 로직은 없습니다. 단, API 심볼 처리를 위한 macro 처리는 존재합니다.
  3. Shared Library 특성

    • 심볼 가시성 설정 등을 통해 의도한 API만 외부에 노출되도록 하였습니다. 또한, Windows의 DLL 빌드시 필요한 심볼의 export/import 처리도 포함하고 있습니다.

플랫폼별 Shared library 특성

Linux

Linux에서 사용되는 Shared library는 .so 형태의 파일 이름을 가지고 있습니다.

버전 및 SONAME

.so 파일의 경우 일반적으로 3개의 파일을 같이 제공합니다. 아래는 ALN 라이브러리에서 제공하는 파일들입니다.

libaln.so -> libaln.so.0
libaln.so.0 -> libaln.so.0.1.0
libaln.so.0.1.0

실제 라이브러리 파일은 libaln.so.0.1.0 이고, 나머지 2개 파일은 이 파일에 대한 symbolic link 입니다.

왜 이렇게 하는 걸까요? 라이브러리 배포 유형은 보통 아래 2가지로 분류할 수 있습니다.

  1. 어플리케이션을 설치/배포하는 데 필요한 의존성 라이브러리로서 배포
  2. 어플리케이션을 빌드할 때 필요한 라이브러리로서 배포

1번의 경우 실제 라이브러리 파일인 libaln.so.0.1.0 이 있어야 하고, 2번의 경우 컴파일할 때 -laln 옵션으로 링크하려면 libaln.so 파일이 있어야 합니다(헤더파일도 있어야 합니다). 따라서 이 2개 파일이 존재해야 한다는 것은 어느정도 이해가 됩니다.

경우에 따라 1,2번 모두에 해당할 수도 있으니 2개 파일을 각각 제공하는 것보다 Symbolic link로 제공하는 것이 더 효율적일 것입니다.

libaln.so -> libaln.so.0.1.0
libaln.so.0.1.0

그럼 libaln.so.0 파일은 왜 필요할까요? 일반적으로 라이브러리 버전은 major.minor.patch 형식(Semantic Versioning 참고)을 따릅니다. 그리고 major 버전 변경은 API 호환성이 깨질 수 있음을 의미합니다. 따라서 v0.1.0와 v0.2.0은 API 호환성이 유지되어야 하고, API에 큰 변동이 있다면 major 버전을 올려서 v1.0.0 같은 버전을 사용해야 합니다.

그렇기 때문에, Linux 시스템에 아래와 같이 같은 이름의 라이브러리인데 major 버전이 다른 파일들이 존재할 수 있지만, 같은 major 버전의 라이브러리는 일반적으로 최신 버전 1개만 존재합니다.

libaln.so.0 -> libaln.so.0.1.1
libaln.so.0.1.0 -> 불필요한 파일
libaln.so.0.1.1
libaln.so.1 -> libaln.so.1.0.0
libaln.so.1.0.0

만약 ALN 라이브러리 내부에서 버그 패치 등이 발생해서 0.1.1 버전이 새로 배포되었다고 가정해 봅시다.

libaln.so.0.1.0 (기존)
libaln.so.0.1.1 (신규)

시스템에 설치된 ALN 라이브러리가 0.1.1로 버전업 되면 기존 0.1.0 버전의 라이브러리는 더이상 존재할 이유가 없으니 지워야 합니다. 그럼 0.1.0 버전의 라이브러리를 이용해 빌드된 어플리케이션은 어떻게 될까요? 라이브러리가 없으니 실행할 수 없습니다.

이런 상황을 해결하기 위해 libaln.so.0 파일이 필요합니다.

자, 다시 libaln.so.0.1.0 파일로 돌아가서, 이 라이브러리 파일을 readelf 라는 툴을 이용해 내용을 살펴보면 아래와 같은 결과를 확인할 수 있습니다.

$ readelf -d libaln.so.0.1.0
...
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libglib-2.0.so.0]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000e (SONAME)             Library soname: [libaln.so.0]
 ...

NEEDED type은 의존성 라이브러리에 대한 정보입니다. 여기서 중요하게 볼 항목은 바로 SONAME 입니다. 라이브러리 파일 이름은 분명 libaln.so.0.1.0인데, 실제 SONAMElibaln.so.0으로 설정된 것을 확인할 수 있습니다.

마찬가지로, ALN 라이브러리를 통해 어플리케이션을 빌드한 후 readelf 툴을 이용해서 어플리케이션 정보를 확인해 보면 아래와 같이 NEEDED 항목에 libaln.so.0.1.0 이 아니라 SONAMElibaln.so.0이 사용된 것을 확인할 수 있습니다.

$ readelf -d /usr/bin/aln
...
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libaln.so.0]
 0x0000000000000001 (NEEDED)             Shared library: [libglib-2.0.so.0]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [ld-linux-aarch64.so.1]
...

이렇게 되면, aln v0.1.0에서 aln v0.1.1으로 버전을 업그레이드 해도 기존 어플리케이션은 여전히 libaln.so.0을 바라보고 있기 때문에 별도의 재빌드없이 업그레이드된 ALN 라이브러리를 사용할 수 있습니다.

따라서 라이브러리 배포시 아래처럼 파일을 구성하게 됩니다.

# libaln v0.1.0
libaln.so -> libaln.so.0
libaln.so.0 -> libaln.so.0.1.0
libaln.so.0.1.0

# libaln v0.1.1
libaln.so -> libaln.so.0
libaln.so.0 -> libaln.so.0.1.1
libaln.so.0.1.1

그럼 SONAME은 어떻게 해야 설정할 수 있을까요?

직접 gcc 등을 이용해 컴파일할 경우 라이브러리 빌드시 컴파일 옵션에 -Wl,-soname,libaln.so.0 옵션을 추가하면 됩니다. CMake와 같은 빌드 시스템을 사용한다면 아래처럼 Property를 설정하면 됩니다.

set_target_properties(libaln PROPERTIES VERSION "0.1.0" SOVERSION 0 OUTPUT_NAME aln)
Symbol visibility

다른 고급 언어들과는 달리 C 언어에는 별도의 namespace가 존재하지 않습니다. 이는 개발자가 만든 라이브러리의 API가 다른 라이브러리의 API와 이름이 충돌할 수 있음을 의미합니다. 이런 상황이 발생하면 애플리케이션을 정상적으로 빌드할 수 없습니다. 결국, 어느 라이브러리의 이름을 바꿔야 하는 상황이 발생할 수 있습니다.

그래서 보통 표준 라이브러리나 매우 유명한 라이브러리가 아닌 경우, 공개할 API 이름 앞에 라이브러리 이름을 붙이는 것이 관행입니다.

int draw_all(...);     // X
int aln_draw_all(...); // O

하지만, 라이브러리 규모가 커지면 예기치 않게 이름이 충돌하는 상황이 발생할 수 있습니다. 라이브러리 내의 소스 파일이 수십 개로 늘어나고, 함수가 커져 여러 함수로 분리되며, 다른 소스 파일의 함수를 호출하는 등의 상황이 복잡해지면 모든 함수에 prefix를 붙이기 어려워집니다.

다음은 이런 상황을 설명하는 간단한 소스코드와 요구사항입니다.

소스코드:

// include/aln_foo.h
int aln_foo_draw();

// src/aln_foo.c
int calculate() { ... }
int aln_foo_draw() { calculate(); gen_hash(); ... }

// include/aln_bar.h
int aln_bar_draw();

// src/aln_bar.c
int aln_bar_draw() { gen_hash(); ... }

// src/util.h
int gen_hash()

// src/util.c
int gen_hash() { ... }

요구사항:

  • aln_foo.h, aln_bar.h를 통해 aln_foo_draw(), aln_bar_draw() 기능을 제공하는 라이브러리입니다.
  • aln_foo.c 내의 calculate() 함수는 해당 소스파일 내에서만 사용합니다.
  • 내부 공용 기능으로 util.cgen_hash() 함수를 만들었는데, 이 함수는 외부로 header 파일을 제공하지 않습니다. 그런데 함수 심볼이 여전히 노출되기 때문에 이를 해결하고 싶습니다.

일단 aln_foo.ccalculate() 함수는 static을 통해 쉽게 해결할 수 있습니다.

// src/aln_foo.c
static int calculate() { ... }
int aln_foo_draw() { calculate(); gen_hash(); ... }

그러나 util.cgen_hash 심볼을 외부로 노출되지 않게 하려면 어떻게 해야 할까요?

GCC 에서는 -fvisibility 라는 옵션을 제공하고 있는데, 기본값은 -fvisibility=default 입니다. 이는 모든 심볼이 외부로 노출된다는 의미입니다. 이 값을 -fvisibility=hidden으로 변경하면 기본적으로 모든 심볼이 숨겨지며, 원하는 심볼만 아래와 같이 annotation을 통해 노출할 수 있습니다.

// include/aln_bar.h, include/aln_foo.h
#ifndef ALN_API
#define ALN_API __attribute__ ((visibility ("default")))
#endif

ALN_API aln_foo_draw();
ALN_API aln_bar_draw();

이 방법의 장점은 내가 API로 제공하고 싶은 함수들만 선택적으로 노출할 수 있다는 점 외에도 다음과 같은 추가 장점이 있습니다. (ChatGPT 협찬)

  1. 바이너리 크기 감소 기본 가시성을 hidden으로 설정하면 공유 라이브러리에 포함되는 심볼 수가 줄어듭니다. 이는 결과적으로 바이너리 크기를 감소시키고, 로드 시 메모리 사용량을 줄입니다.
  2. 링크 시간 및 로드 시간 단축 심볼 해석에 필요한 시간이 줄어들기 때문에 링크 시간과 라이브러리 로드 시간이 단축됩니다. 이는 프로그램 시작 시간을 개선할 수 있습니다.
  3. 네임스페이스 충돌 감소 숨겨진 심볼은 외부에서 참조할 수 없으므로, 동일한 심볼 이름을 사용하는 다른 라이브러리와의 충돌을 방지할 수 있습니다. 이는 특히 대규모 프로젝트에서 중요한 이점입니다.
  4. 최적화 기회 증가 컴파일러가 숨겨진 심볼을 더 공격적으로 최적화할 수 있습니다. 예를 들어, 인라인 확장이나 불필요한 코드 제거와 같은 최적화가 더 잘 수행될 수 있습니다.

라이브러리를 제공하는 입장에서는 사용하지 않을 이유가 없는 옵션입니다. 예제로 만든 ALN 라이브러리는 규모가 작아 내부 함수 없이 전부 외부로 노출할 함수들로만 구성되어 있지만, 소스코드에서 위 옵션들을 참고할 수 있습니다.

rpath

라이브러리를 개발할 때, 유닛 테스트나 샘플 애플리케이션을 통해 직접 실행하여 라이브러리의 동작을 확인하는 경우가 많습니다. 이 과정에서 다음과 같은 에러 메시지를 자주 마주친 경험이 있을 것입니다.

$ ./aln
./aln: error while loading shared libraries: libaln.so.0: cannot open shared object file: No such file or directory

심지어 실행 파일인 aln과 라이브러리 파일인 libaln.so.0이 같은 디렉토리 안에 있는데도 실행이 되지 않습니다.

$ ls
aln  libaln.so  libaln.so.0  libaln.so.0.1.0

이는 링커가 기본적으로 설정된 시스템 라이브러리 경로(/lib, /usr/lib 등)에서만 라이브러리를 찾으려고 하기 때문입니다. 아래와 같이 디버깅 옵션을 켜고 실행을 하면 어떤 경로를 찾는지 확인할 수 있습니다.

$ LD_DEBUG=libs ./aln
      4068: find library=libaln.so.0 [0]; searching
      4068:  search cache=/etc/ld.so.cache
      4068:  search path=...:/lib/aarch64-linux-gnu/aarch64:...
:/usr/lib/aarch64-linux-gnu:/...:/usr/lib	(system search path)
      ...
      4068:   trying file=/lib/aarch64-linux-gnu/libaln.so.0
      ...
      4068:   trying file=/usr/lib/aarch64-linux-gnu/libaln.so.0
      ...
./aln: error while loading shared libraries: libaln.so.0: cannot open shared object file: No such file or directory

위와 같은 상황에서 일반적으로 LD_LIBRARY_PATH 환경 변수를 설정하여 문제를 해결할 수 있습니다.

# 환경변수에 현재 디렉토리를 설정하고 aln 실행
$ LD_LIBRARY_PATH=. ./aln

# 환경변수 업데이트 후 실행
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.
$ ./aln

# 디버깅 옵션을 통해 확인
$ LD_DEBUG=libs LD_LIBRARY_PATH=. ./aln
      5131: find library=libaln.so.0 [0]; searching
      5131:  search path=./tls/aarch64/atomics:./tls/aarch64
:./tls/atomics:./tls:./aarch64/atomics:./aarch64:./atomics:.    (LD_LIBRARY_PATH)
      ...
      5131:   trying file=./libaln.so.0

하지만, 매번 LD_LIBRARY_PATH 환경 변수를 설정하는 것은 비효율적일 수 있는데, 다른 방안으로 rpath를 사용할 수 있습니다. rpath는 실행 파일에 내장된 라이브러리 검색 경로를 지정하여, 실행 시점에 동적으로 라이브러리를 찾는 과정에서 경로를 변경합니다.

컴파일 과정에서 아래와 같이 -Wl,-rpath,{path} 옵션을 통해 경로를 설정할 수 있습니다.

gcc -o myapp myapp.c -Wl,-rpath,/path/to/libs -L/path/to/libs -lmylib

아래는 rpath가 적용된 aln 실행파일을 readelf 명령으로 확인해본 결과입니다.

$ readelf -d build/tool/aln
...
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libaln.so.0]
 0x0000000000000001 (NEEDED)             Shared library: [libglib-2.0.so.0]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [ld-linux-aarch64.so.1]
 0x000000000000001d (RUNPATH)            Library runpath: [/home/parallels/git/my/aln/build/src:]
...

소스 코드가 있는 디렉토리는 /home/parallels/git/my/aln 이고, 빌드시 build 디렉토리 아래에 라이브러리와 실행파일들이 생성되도록 구성되어 있는 상태에서 rpath(RUNPATH) 값이 정상적으로 설정된 것을 확인할 수 있습니다.

이제 유닛 테스트나 샘플 애플리케이션 실행시 별다른 설정 없이 바로 실행하면 에러 없이 정상적으로 라이브러리를 찾아서 실행됩니다.

$ LD_DEBUG=libs ./aln
      6659:	find library=libaln.so.0 [0]; searching
      6659:	 search path=.../home/parallels/git/my/aln/build/src:tls/aarch64/atomics:tls/aarch64
:tls/atomics:tls:aarch64/atomics:aarch64:atomics:    (RUNPATH from file ./aln)
      ...
      6659:	  trying file=/home/parallels/git/my/aln/build/src/libaln.so.0

...실행 결과...

단, 주의할점은 개발 단계에서는 rpath를 사용하면 좋지만 이를 외부에 배포할 때에는 꼭 필요한 경우가 아니면 rpath 정보를 제거한 상태로 배포하는 것이 좋습니다.

수동으로 제거하는 방법은 chrpath 도구를 설치해서 chrpath -d aln 명령으로 제거할 수 있습니다.

더 쉬운 방법은 CMake와 같은 빌드 시스템을 사용하는 것입니다. CMake를 통해 라이브러리를 만들고 이 라이브러리에 의존성을 가지는 실행파일을 빌드하면 자동으로 rpath 정보가 실행파일에 삽입됩니다. 그리고 cmake install을 통해 빌드한 파일들을 설치하면 자동으로 rpath 정보가 제거된 상태로 설치됩니다.

아래는 ALN 라이브러리에 대해 CMake를 사용해서 빌드 및 실행, 배포할 파일들을 만들어내는 예제입니다.

  1. 먼저 프로젝트에 정의된 CMakeLists.txt 스크립트를 이용해서 빌드 configuration을 진행합니다. 결과물은 하위의 build 디렉토리에 생성됩니다.
$ cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/usr
-- The C compiler identification is GNU 11.4.0
-- The CXX compiler identification is GNU 11.4.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found PkgConfig: /usr/bin/pkg-config (found version "0.29.2")
-- Checking for module 'glib-2.0'
--   Found glib-2.0, version 2.72.4
-- Configuring done
-- Generating done
-- Build files have been written to: /home/parallels/git/my/aln/build
  1. 다음으로 위에서 생성된 빌드 configuration을 기반으로 실제 빌드를 진행합니다.
$ cmake --build build --parallel
[ 16%] Building C object src/CMakeFiles/libaln.dir/aln.c.o
[ 33%] Linking C shared library libaln.so
[ 33%] Built target libaln
[ 66%] Building C object tool/CMakeFiles/aln.dir/main.c.o
[ 66%] Building C object tests/CMakeFiles/test_default.dir/test_default.c.o
[ 83%] Linking C executable aln
[100%] Linking C executable test_default
[100%] Built target aln
[100%] Built target test_default
  1. 생성된 실행 파일들에는 rpath 정보가 모두 삽입되어 있기 때문에, 직접 유닛 테스트 및 샘플 애플리케이션을 실행할 수 있습니다.
$ cd build && make test && cd -
Running tests...
Test project /home/parallels/git/my/aln/build
    Start 1: test_default
1/1 Test #1: test_default .....................   Passed    0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.01 sec

$ ./build/tool/aln
01 05 36 16 25 07
22 09 07 04 23 11
32 44 21 13 22 01
16 33 03 39 31 26
19 32 22 17 06 31
  1. 배포할 파일들을 생성합니다. 여기서 생성된 파일들은 rpath 정보가 빠져있습니다. 참고로 DESTDIR 환경 변수를 설정하지 않으면 시스템(/usr/ 경로)에 바로 설치됩니다. 이제 생성된 out 디렉토리에 있는 파일들을 묶어서 배포하면 됩니다.
$ DESTDIR=out cmake --install build
-- Install configuration: ""
-- Installing: out/usr/include/aln/
-- Installing: out/usr/include/aln//aln.h
-- Installing: out/usr/lib/aarch64-linux-gnu/pkgconfig/aln.pc
-- Installing: out/usr/lib/aarch64-linux-gnu/libaln.so.0.1.0
-- Installing: out/usr/lib/aarch64-linux-gnu/libaln.so.0
-- Installing: out/usr/lib/aarch64-linux-gnu/libaln.so
-- Installing: out/usr/bin/aln
-- Set runtime path of "out/usr/bin/aln" to ""
-- Installing: out/usr/share/man/man1/aln.1

macOS

macOS에서 사용되는 Shared library는 .dylib 형태의 파일 이름을 가지고 있습니다. Linux와 유사한 점들이 많지만, 몇 가지 차이점이 있습니다.

Linux와 유사한 점
  1. 파일 확장자: .so 대신 .dylib를 사용합니다.
  2. 버전 관리: Linux와 유사하게 symbolic link를 사용하지만, 확장자가 다르기 때문에 형태가 약간 다릅니다.
libaln.0.dylib -> libaln.0.1.0.dylib
libaln.dylib -> libaln.0.dylib
libaln.0.1.0.dylib
다른 점

macOS에도 rpath와 유사한 개념이 존재하지만 이를 설정하고 확인하는 방법이 조금 다릅니다. 아래에서 다음 항목들에 대해 알아보겠습니다.

  • @executable_path
  • @loader_path
  • @rpath

아래의 도구들도 같이 사용되며, 설명에 있는 예제를 참고하면 이해하는 데 도움이 될 것입니다.

  • install_name_tool - change dynamic shared library install names
  • otool - object file displaying tool
@executable_path

Linux와 다르게 macOS에는 @executable_path라는 옵션이 존재합니다. 간단한 예제를 통해 확인해 보겠습니다.

// get_num.h
int get_num();

// get_num.c
int get_num() { return 1; }

// main.c
#include "get_num.h"

int main() { return get_num(); }

컴파일 및 실행:

gcc -dynamiclib -o libget_num.dylib get_num.c
gcc -o main main.c -L . -lget_num
./main

Linux에서는 LD_LIBRARY_PATH 등을 설정해야 동작했는데, macOS에서는 정상적으로 실행됩니다. otool로 확인해보면 아무런 경로 정보 없이 라이브러리 파일 이름(실제로는 LC_ID_DYLIB 항목에 설정된 이름)이 그대로 적용된 것을 확인할 수 있습니다.

$ otool -L libget_num.dylib
libget_num.dylib:
        libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

$ otool -L main
main:
        libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

그러나, 아래와 같이 다른 디렉토리에서 실행하면 실행할 수 없다는 에러가 발생합니다.

$ mkdir sub && cd sub
$ ../main
dyld[27832]: Library not loaded: libget_num.dylib
  Referenced from: <EFBFA0E2-6711-3629-A8A8-C5CB8BB113A8> /Volumes/work/git/my/aln/mac/main
  Reason: tried: 'libget_num.dylib' (no such file), ...
[1]    27832 abort      ../main

이 때 install_name_tool을 이용해 @executable_path를 적용하면 실행할 수 있게 만들 수 있습니다.

$ install_name_tool -change libget_num.dylib @executable_path/libget_num.dylib main

$ otool -L main
main:
        @executable_path/libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

$ cd sub
$ ../main

main 실행파일에서 libget_num.dylib를 찾을 때, @executable_path가 적용되어 있기 때문에, main이 실행된 경로에서 libget_num.dylib를 찾게 됩니다. 따라서, 하위 디렉토리에서 main을 실행했어도 main 애플리케이션이 위치하는 디렉토리에 libget_num.dylib이 같이 존재하기 때문에 정상적으로 라이브러리를 찾아서 실행됩니다.

@loader_path

위의 예제 코드를 조금 변경해서 라이브러리(libget_num.dylib)가 다른 라이브러리(libget_even_num.dylib)에 대해 의존성을 가지도록 수정해보겠습니다.

// get_even_num.h
int get_even_num();

// get_even_num.c
int get_even_num() { return 2; }

// get_num.h
int get_num();

// get_num.c
#include "get_even_num.h"
int get_num() { return get_even_num(); }

// main.c
#include "get_num.h"
int main() { return get_num(); }

컴파일 및 실행: libget_even_num.dylib -> libget_num.dylib -> main

gcc -dynamiclib -o libget_even_num.dylib get_even_num.c
gcc -dynamiclib -o libget_num.dylib get_num.c -L. -lget_even_num
gcc -o main main.c -L . -lget_num
./main

이번에도 정상적으로 실행이 됩니다. 그러나, 하위 디렉토리에서 실행할 경우 @executable_path를 설정했음에도 정상적으로 실행되지 않습니다.

$ install_name_tool -change libget_num.dylib @executable_path/libget_num.dylib main

$ otool -L main
main:
        @executable_path/libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

$ mkdir sub && cd sub
$ ../main
dyld[38258]: Library not loaded: libget_even_num.dylib
  Referenced from: <4CCFE945-934C-38A9-AF72-19E2BB273789> /Volumes/work/git/my/aln/mac/libget_num.dylib
  Reason: tried: 'libget_even_num.dylib' (no such file)
[1]    38258 abort      ../main

그 이유는 main에서 의존하고 있는 libget_num.dylib@executable_path를 통해 정상적으로 찾을 수 있지만, libget_num.dylib가 의존하고 있는 libget_even_num.dylib는 찾을 수 없기 때문입니다.

$ otool -L libget_num.dylib
libget_num.dylib:
        libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        libget_even_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

$ otool -L libget_even_num.dylib
libget_even_num.dylib:
        libget_even_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

이 때 install_name_tool을 이용해 libget_num.dylib 라이브러리에 @loader_path를 적용하면 실행할 수 있게 만들 수 있습니다.

$ install_name_tool -change libget_even_num.dylib @loader_path/libget_even_num.dylib libget_num.dylib

$ otool -L libget_num.dylib
libget_num.dylib:
        libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        @loader_path/libget_even_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)
@rpath

Linux와 유사하게 macOS에서도 rpath 정보를 추가할 수 있습니다.

$ gcc -dynamiclib -o libget_even_num.dylib get_even_num.c -install_name @rpath/libget_even_num.dylib

$ otool -L libget_even_num.dylib
libget_even_num.dylib:
        @rpath/libget_even_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

# libget_num.dylib에서 의존하는 libget_even_num.dylib 라이브러리를 찾을때 사용할 rpath 정보를 추가
$ gcc -dynamiclib -o libget_num.dylib get_num.c -install_name @rpath/libget_num.dylib -L. -Wl,-rpath,$PWD -lget_even_num

$ otool -L libget_num.dylib
libget_num.dylib:
        @rpath/libget_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        @rpath/libget_even_num.dylib (compatibility version 0.0.0, current version 0.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)

# otool로 확인해보면 LC_RPATH 항목이 추가된 것을 확인할 수 있습니다.
$ otool -l libget_num.dylib
...
Load command 13
          cmd LC_RPATH
      cmdsize 48
         path /Volumes/work/git/my/aln/mac (offset 12)
...

# 실행파일에도 rpath를 설정합니다.
$ gcc -o main main.c -L. -Wl,-rpath,$PWD -lget_num

이제 실행해보면 현재 디렉토리 뿐만 아니라 다른 디렉토리에서도 별다른 설정없이 정상적으로 실행되는 것을 확인할 수 있습니다.

./main

mkdir sub && cd sub
../main

Windows

Windows의 동적 라이브러리는 Linux나 macOS와 몇 가지 중요한 차이점이 있습니다:

  1. 파일 확장자: .dll(Dynamic-Link Library)을 사용합니다.
  2. Windows는 일반적으로 symbolic link와 파일명을 통한 버전 관리를 사용하지 않습니다.
  3. Import Library: Windows에서는 .dll 파일과 함께 .lib 파일(import library)을 제공합니다. 이 파일은 컴파일 시에 사용됩니다.
  4. Symbol Export/Import: Windows에서는 __declspec(dllexport)__declspec(dllimport)를 사용하여 심볼의 가시성을 제어합니다.

3번과 4번이 가장 큰 차이점인데, 하나씩 살펴보겠습니다.

dll / lib

Linux와 macOS에서는 크게 2가지 종류로 라이브러리를 구분할 수 있었습니다.

  • Static library (.a)
  • Shared library (.so, .dylib)

Static library의 경우 가져다 사용하는 애플리케이션에 같이 포함되어 빌드 및 배포되고, Shared library의 경우는 빌드할때 링크만 되고 별개로 배포됩니다. 따라서 애플리케이션에서 Shared library로만 사용할 경우 라이브러리 배포 시 .a 파일을 같이 배포할 필요가 없습니다.

하지만, Windows에서는 .lib 파일이 Static library 역할도 하지만 .dll에 대한 Import library 역할도 같이 수행합니다. Import Library는 DLL의 함수를 호출하는 데 필요한 정보를 포함하고 있어, 링커가 이를 사용하여 애플리케이션과 DLL을 연결합니다.

따라서 .dll 파일만 있어도 실행할 때는 문제가 없지만, 빌드할 때는 .lib 파일이 있어야 애플리케이션을 정상적으로 빌드할 수 있습니다.

Linux와 macOS에서는 라이브러리를 찾을 때 rpath와 같은 방법을 사용해서 경로를 지정할 수 있었습니다. 하지만, Windows는 직접적인 rpath 개념이 없습니다. 실행 파일과 라이브러리(.dll)가 같은 경로에 있거나, PATH 환경 변수로 지정된 경로에 라이브러리가 있으면 라이브러리를 찾을 수 있습니다.

dllexport / dllimport

다음으로 살펴볼 특징은 Linux와 macOS에는 없는 개념이라서 조금 특이합니다. Windows에서는 함수를 정의할 때 이 함수가 DLL로 제공하기 위해 내보낼 목적인지, DLL에서 제공한 함수를 사용하기 위해 가져오는 목적인지 구분해서 알려줘야 합니다.

DLL로 만들어서 내보낼 때:

// get_num.h
__declspec(dllexport) int get_num();

// get_num.c
int get_num() { return 0; }

DLL에서 제공하는 함수를 가져와서 사용할 때:

// my_app.c
__declspec(dllimport) int get_num();

int main() { return get_num(); }

dllexport, dllimport용으로 헤더파일을 두벌씩 만들어서 사용하는 것은 너무 비효율적이라서 일반적으로 아래와 같이 매크로를 통해 자동 적용되는 방식을 사용합니다. DLL을 만들 때에는 ALN_LIBRARY_BUILD 매크로를 활성화시키고 그 외의 경우에는 비활성화 시키는 방식으로 활용할 수 있습니다.

#ifdef ALN_LIBRARY_BUILD
#define ALN_API __declspec(dllexport)
#else
#define ALN_API __declspec(dllimport)
#endif

ALN_API int aln_foo_draw();

Linux와 Windows를 모두 고려하면 아래와 같이 매크로를 더 확장할 수 있습니다.

#if defined(_WIN32)
#define ALN_API_EXPORT __declspec(dllexport)
#define ALN_API_IMPORT __declspec(dllimport)
#else
#define ALN_API_EXPORT __attribute__((visibility("default")))
#define ALN_API_IMPORT
#endif

#ifdef ALN_LIBRARY_BUILD
#define ALN_API ALN_API_EXPORT
#else
#define ALN_API ALN_API_IMPORT
#endif

마무리

지금까지 라이브러리의 구성 요소와 각 플랫폼별 특성에 대해 알아보았습니다. 이제 라이브러리를 각 플랫폼에서 쉽게 사용할 수 있도록 배포하는 방법에 대해 알아보겠습니다.