본문 바로가기
프로그래밍/C & C++

[C/C++] 동적 라이브러리

by 별준 2022. 11. 16.

References

  • Advanced C and C++ Compiling

Contents

  • Creating the Dynamic Library (about -fPIC flag)
  • Designing Dynamic Libraries
  • Dynamic Linking Modes

지난번 포스팅 정적 라이브러리에 이어서 동적 라이브러리에 대해서 살펴보도록 하겠습니다.

 

Creating the Dynamic Library

컴파일러와 링커는 일반적으로 동적 라이브러리를 빌드하는데 다양한 플래그를 제공합니다. 간단하게 플래그부터 살펴보겠습니다.

 

리눅스에서 동적 라이브러리르 생성할 때, 일반적으로 최소 아래의 두 플래그를 사용합니다.

  • -fPIC 컴파일러 플래그
  • -shared 링커 플래그
gcc -fPIC -c first.c second.c
gcc -shared first.o second.o -o libdynamiclib.so

기본적으로 리눅스에서 동적 라이브러리는 lib로 시작하며, 파일 확장자는 so 입니다.

 

위의 플래그들이 컴파일러와 링커에 전달되면, 궁극적으로 정확하고 사용 가능한 동적 라이브러리를 빌드할 수 있습니다. -shared 플래그를 링커에 전달하는 것에는 별다른 주의할 사항은 없지만, -fPIC 컴파일러 플래그는 어떤 의미인지 알고 있으면 좋을 것 같습니다.

 

About the -fPIC Compiler Flag

먼저 -fPIC가 무엇을 의미할까요?

 

-fPIC에서 'PIC'는 position-independent code의 약자입니다. PIC 개념이 나오기 전에는 로더가 프로세스 메모리 공간에 로드할 수 있는 동적 라이브러리를 만드는 것이 가능했습니다. 다만, 이러한 방법으로는 동적 라이브러리를 처음 로드한 프로세스만 동적 라이브러리를 사용할 수 있었습니다. 따라서, 다른 프로세스가 동일한 동적 라이브러리를 로드하려면, 동일한 동적 라이브러리의 복사본을 메모리에 로드할 수 밖에 없었습니다.

이러한 제한의 근본적인 원인은 suboptimal loading procedure design 이었습니다. 동적 라이브러리를 프로세스에 로드할 때, 로더는 동적 라이브러리의 코드(.text) 세그먼트를 변경하여 라이브러리를 로드한 프로세스의 영역 내에서만 동적 라이브러리의 심볼이 의미가 있도록 했습니다. 이러한 접근 방식은 런타임 요구사항에는 적합했지만, 궁극적으로 로드된 동적 라이브러리가 되돌릴 수 없도록 변경되어 다른 프로세스에서는 이미 로드된 동적 라이브러리를 재사용하기가 상당히 어려워지게 되었습니다. 이러한 방식을 load-time relocation 이라고 부릅니다.

 

PIC는 이러한 방식을 바꾸었습니다. 로드된 라이브러리의 코드(.text) 세그먼트를 이를 로드한 첫 번째 프로세스의 메모리 맵에 묶이지 않도록 로드 메커니즘을 다시 설계했고, 여러 프로세스의 메모리 맵에 매핑할 수 있는 방법을 제공했습니다.

 

 

그렇다면, 동적 라이브러리를 빌드할 때는 항상 -fPIC 플래그를 사용해야 할까요?

 

딱 정해진 정답은 없습니다. 일반적으로 32비트 아키텍처(x86)에서는 필요하지 않다고 합니다. 다만, 이 플래그를 지정하지 않으면 동적 라이브러리는 동적 라이브러리를 먼저 로드하는 프로세스만 메모리 맵에 매핑할 수 있는 load-time relocation 메커니즘을 따르게 됩니다.

 

반면, 64비트 아키텍처(x86_64)에서 -fPIC 플래그를 생략하면 링커 에러가 발생합니다. 에러가 발생하는 이유와 이를 우회하는 방법은 여기서 다루지는 않습니다. 우선 이러한 에러가 발생했을 때에 해결책은 -fPIC 플래그 또는 -mcmodel=large 플래그를 사용하는 것입니다.

 

 

-fPIC 플래그를 사용하는 정적 라이브러리는 빌드할 수 없을까요?

 

-fPIC 플래그는 동적 라이브러리에서만 사용된다고 널리 알려져 있지만, 실제로 그렇지는 않습니다.

32비트 아키텍처(x86)에서 -fPIC 플래그를 사용하여 정적 라이브러리를 컴파일하는지 여부는 실제로 중요하지 않습니다. 컴파일된 코드의 구조에 영향을 미치긴 하지만, 라이브러리의 링크 및 전체 런타임 동작에서는 무시할 수 있는 영향입니다.

 

64비트 아키텍처(x86_64)에서는 조금 다릅니다.

실행 파일에 링크된 정적 라이브러리는 -fPIC 플래그를 사용하거나 사용하지 않고 컴파일할 수 있습니다. 즉, 해당 플래그를 지정했는지 여부는 중요하지 않습니다.

하지만, 동적 라이브러리에 링크된 정적 라이브러리는 -fPIC 플래그로 컴파일해야 합니다 (또는 -fPIC 대신 -mcmodel=large 플래그를 사용할 수도 있습니다). 정적 라이브러리가 두 플래그 중 하나로 컴파일되지 않고 동적 라이브러리에 링크하려고 하면, 다음과 같이 링커 에러가 발생합니다.

 

 

Designing Dynamic Libraries

동적 라이브러리를 설계하는 프로세스는 다른 소프트웨어를 설계하는 것과 크게 다르진 않지만, 동적 라이브러리의 특징을 감안하여 몇 가지 중요한 사항들이 있습니다.

 

Designing the Binary Interface

본질적으로 동적 라이브러리는 일반적으로 외부에 특정 기능을 제공하고, 이러한 방식은 내부 기능의 세부 사항에 대한 클라이언트의 개입을 최소화해야 합니다. 이를 달성하기 위한 방법은 인터페이스를 통하는 것입니다.

 

객체 지향 프로그래밍 영역에서의 인터페이스 개념은 바이너리 코드 재사용에 추가적인 특징을 추가합니다. 아래 포스팅에서 언급한 것처럼 dynamic link의 빌드 시간과 런타임 사이에서 application binary interface(ABI)의 불변성이 가장 기본적인 요구 사항입니다.

([C/C++] Static vs Dynamic 라이브러리)

 

언뜻 보기에 ABI의 디자인은 API의 디자인과 크게 다르지 않습니다. 인터페이스 개념의 기본적인 의미는 동일하며, 특정 서비스를 제공하기 위해 클라이언트가 사용할 수 있는 기능의 집합입니다. 실제로 프로그램이 C++로 작성되지 않는 한 동적 라이브러리 ABI의 설계는 재사용 가능한 소프트웨어 모듈의 API를 설계하는 것보다 더 많은 노력을 필요로 하지 않으며, ABI가 런타임에 로드해야 하는 링커 심볼 집합이라는 사실은 동일합니다.

그러나 C++ 언어의 영향(the lack of strict standardization)으로 인해 동적 라이브러리 ABI를 설계할 때는 몇 가지 고려해야할 부분이 있습니다.

 

C++ Issues

#1: C++ Imposes More Complex Synbol Name Requirements

C 언어와 달리 C++ 함수를 링커 심볼에 매핑하면 링커 설계에 많은 문제가 발생합니다. C++의 객체 지향 특성은 아래와 같이 고려해야되는 사항들을 추가합니다.

 

  • 일반적으로 C++ 함수는 standalone이 아니며, 다양한 code entities와 연결되는 경향이 있습니다.
    C++에서 함수는 일반적으로 클래스에 속합니다. 또한 클래스는 네임스페이스에 속할 수 있으며, 템플릿이 등장하면 상황은 더욱 복잡해집니다. 함수를 유니크하게 식별하려면 링커는 함수 진입점에 대해 생성되는 심볼에 대한 추가 정보들을 추가해야 합니다.
  • C++ 오버로딩 메커니즘을 사용하면 동일한 클래스의 다른 메소드가 동일한 이름, 동일한 리턴을 가질 수 있지만, 함수 파라미터 리스트는 다릅니다. 동일한 이름을 공유하는 함수(메소드)를 고유하게 식별하려면 링커는 함수 진입점에 대해 생성되는 심볼에 인자 리스트에 대한 정보를 추가해야 합니다.

이러한 복잡한 요구 사항을 위해 name mangling 이라는 기법이 탄생했습니다. 간단히 말하자면, 함수 이름, 네임스페이스, 함수의 파라미터 리스트를 결합하여 최종 심볼 이름을 만드는 과정이라고 볼 수 있습니다. 일반적으로 함수의 소속은 접두사로 붙고, 함수 시그니처 정보는 함수 이름 뒤에 접미사로 붙게 됩니다.

 

문제의 주요한 원인은 name mangling 규칙이 고유하게 표준화되지 않았고, 서로 다른 링커에서 name mangling 구현을 차이를 보입니다.

 

C++ 컴파일러를 사용할 때, C 스타일의 함수를 사용하면 흥미로운 일이 발생합니다. C 함수에는 mangling이 필요하지 않지만, 링커는 기본적으로 해당 함수에 대해 mangling된 이름을 만듭니다. 이를 피하고 싶으면, 특수한 키워드 extern "C" 를 사용해야 합니다. 일반적으로 다음과 같이 함수 헤더에서 함수를 선언합니다.

#ifdef __cplusplus
extern "C"
{
#endif // __cplusplus
int myFunction(int x, int y);
#ifdef __cplusplus
}
#endif // __cplusplus

위와 같은 코드 결과, 링커는 mangling이 없는 심볼을 생성합니다.

 

#2: Static Initialization Order Fiasco

C 언어에서 유용한 것 중 하나는 간단하게 초기화된 변수를 처리할 수 있다는 것입니다. 링커는 단지 .data 섹션에 저장 공간을 예약하고  해당 위치에 초기값을 쓰는 것만 해주면 됩니다. C 언어 영역에서는 변수가 초기화되는 순서는 특별히 중요하지 않습니다. 중요한 것은 프로그램이 시작되기 전에 변수 초기화가 완료된다는 것입니다.

 

C++로 넘어오면 상황이 다릅니다. C++에서 데이터 타입은 일반적으로 객체이며, 초기화는 클래스의 생성자 메소드에서 수행됩니다. 따라서, 객체 생성 과정을 통해 런타임에 완료됩니다. 이 때문에 링커는 C++ 객체를 초기화하기 위해 훨씬 더 많은 작업을 수행해야 합니다. 링커에서 이러한 작업을 용이하게 처리하기 위해 컴파일러는 특정 파일에 대해 실해해야 하는 모든 생성자 리스트를 object file에 포함시키고, 이 정보를 특정 object file segment에 저장합니다. 링크 타입에 링커는 모든 object file을 체크하고, 이러한 목록을 런타임에 실행될 최종 목록으로 결합합니다.

 

이 시점에서 링커는 상속 체인을 기반으로 생성자를 실행하는 순서를 살펴봅니다. 즉, 베이스 클래스 생성자가 먼저 실행되고, 파생 클래스의 생성자가 뒤따르는 것을 보장합니다. 하지만, 링커가 전지전능하지 않기 때문에 예상치 못한 충돌이 발생할 수 있습니다.

이러한 충돌의 일반적인 시나리오는 객체 초기화가 미리 초기화된 다른 객체에 의존할 때 발생합니다. C++에서는 이러한 종류의 문제를 일반적으로 static initialization order fiasco 라고 부릅니다.

 

먼저 이 문제가 어떻게 발생하는지 살펴보겠습니다.

Non-local static object는 가시성의 범위가 클래스의 경계를 넘어서는 C++ 클래스 인스턴스인데, 다음 중 하나일 수 있습니다.

  • Defined at global or namespace scope
  • Declared static in a class
  • Defined static at file scope

이러한 객체들은 프로그램 실행이 시작되기 전에 링커에 의해 초기화됩니다. 각 객체에 대해 링커는 해당 객체를 만드는 데 필요한 생성자 리스트를 유지 및 관리하고 상속 체인에 지정된 순서대로 실행합니다.

 

다만, 이러한 객체 중 하나가 미리 초기화되는 다른 객체에 의존한다고 가정해봅시다. 예를 들면, 다음과 같습니다.

  • 네트워크 인프라를 초기화하고 사용 가능한 네트워크 리스트를 쿼리하고, 초기 연결을 설정하는 Object A (instance of class a)
  • 클래스 b의 인스턴스에서 인터페이스 메소드를 호출하여 네트워크를 통해 원격 인증 서버로 메세지를 보내는 Object B (instance of class b)

분명히 위의 시나리오에서 올바른 순서는 객체 B가 객체 A 다음에 초기화되는 것입니다. 이 순서가 바뀌면 문제가 발생할 가능성이 매우 높습니다. 설계한 사람이 이러한 문제를 고려했다고 하더라도, 최선의 동작은 클래스 B의 작업이 예상한 대로 완료되지 않는다는 것입니다.

 

사실, static objects의 초기화 순서를 지정하는 규칙은 없으며, 이러한 시나리오를 인식하고 링커에게 올바른 순서를 제안하는 것은 해결하기 어려운 문제라는 것으로 알려져 있습니다. 궁극적으로, 링커는 non-local static object를 임의의 순서로 초기화합니다. 또한, 어떤 순서를 따를 것인지에 대한 링커의 결정은 런타임 상황에 따라 달라질 수 있습니다.

 

실제로 이러한 문제는 여러 가지 이유로 곤란할 수 있는데, 먼저, 디버거를 통해 살펴보기 전에 충돌이 발생하므로 추적하기가 어렵고, 이러한 충돌 발생이 지속적으로 나타나지 않을 수도 있습니다.

 

 

이러한 혼란을 피할 수 있는 방법은 있습니다.

링커는 변수를 초기화하는 순서를 지정하지 않지만, 함수 본문 내에 선언된 static 변수에 대해서는 순서가 정확하게 지정됩니다. 즉, 함수 본문 또는 클래스 메소드 내에서 정적으로 선언된 객체는 해당 함수를 호출하는 동안 처음 발견될 때 초기화됩니다. 따라서, 함수 내에서 정적 변수로 선언하고, 객체에 대한 참조를 리턴하는 방식으로 액세스하도록 할 수 있습니다.

 

#3: Templates

템플릿은 데이터 타입에만 다르고 동일한 알고리즘의 중복을 제거하기 위해 도입되었는데, 여기서 링크 프로세스에 추가적인 문제가 발생합니다. 문제의 본질은 template specialization이 완전히 다른 기계 코드 표현을 갖는다는 것입니다. 

template <class T>
T max(T x, T y)
{
  if (x>y) { return x;}
  else     { return y;}
}

위의 템플릿 코드는 비교 연산자를 지원하는 많은 데이터 타입에 대해 특화될 수 있습니다.

 

컴파일러가 템플릿은 만나면 이를 어떤 형태의 기계 코드로 구체화해야 합니다. 그러나, 다른 모든 소스 파일을 검사하여 코드에서 어떤 특정 specialization이 발생했는지 알아낼 때까지는 수행될 수 없습니다. 이는 standalone 프로그램의 경우 쉬울 수 있으나, 동적 라이브러리에서 템플릿을 export할 때는 몇 가지 고려해야되는 사항들이 있습니다.

 

이러한 종류의 문제를 해결하기 위해 두 가지 일반적인 접근 방식이 있습니다.

  • 컴파일러는 가능한 모든 템플릿 specialization을 생성하고, 각각에 대해 weak symbol을 생성할 수 있습니다. 링커는 weak symbol이 최종 빌드에서 실제로 필요하지 않다고 판단되면 이를 버릴 수 있습니다.
  • 대안적인 방법은 링커가 끝까지 템플릿 specialization의 기계 코드 구현을 포함하지 않는다는 것입니다. 다른 모든 작업이 완료되면 링커는 코드를 검사하고 실제로 필요한 specialization을 정확히 결정하고 C++ 컴파일러를 호출하여 필요한 템플릿 specialization을 생성하고 마지막으로 기계 코드를 실행 파일에 삽입할 수 있습니다. 이러한 방식은 Solaris C++ 컴파일러 제품군에서 선호됩니다.

 

Designing the Application Binary Interface

잠재적인 문제를 최소화하고, 다른 플랫폼으로 이식성을 개선하면서 다른 컴파일러에서 생성된 모듈 간의 상호 운용성(interoperability)을 향상시키기 위해 다음의 가이드라인을 권장합니다.

 

Guideline #1: Implement the Dynamic Library ABI as a Set of C-style Functions

이와 같은 방식으로 다음의 이점을 얻을 수 있습니다.

  • C++ vs. linker interaction로부터 발생하는 다양한 이슈 방지
  • 플랫폼간 이식성 향상
  • 다른 컴파일러에서 생성된 바이너리 간 상호 운용성 개선

ABI 심볼을 C 스타일 함수로 export하려면 extern "C" 키워드를 사용하여 링커가 이러한 심볼에 name mangling을 적용하지 않도록 지시합니다.

 

Guideline #2: Provide the Header File Carrying the Complete ABI Declaration

'Complete ABI declaration'은 함수 프로토타입뿐만 아니라 preprocessor definitions, structures layouts 등을 의미합니다.

 

Guideline #3: Use Widely-Supported Standard C Keywords

플랫폼별 데이터 타입, 다른 컴파일러 및 다른 플랫폼에서 보편적으로 지원되는 것들을 사용

 

Guideline #4: Use a Class Factory Mechanism (C++) or Module (C)

동적 라이브러리의 내부 기능이 C++ 클래스로 구현되는 것이 Guideline #1을 위반해야 한다는 것은 아닙니다. 대신 class factory approach를 따라야 합니다.

class factory concept

클래스 팩토리에 대해서 자세히 다루지는 않겠습니다.

 

Guideline #5: Export Only the Really Important Symbols

동적 라이브러리는 라이브러리를 로드하는 사람이 절대적으로 필요로 하는 함수 및 데이터 심볼만 export하고, 다른 심볼들은 보이지 않도록 합니다.

 

Guideline #6: Use Namespace to Avoid Symbol Naming Collision

동적 라이브러리의 코드를 고유한 네임스페이스에 포함시켜, 다른 동적 라이브러리가 동일한 이름의 심볼을 가질 가능성을 제거합니다.

 

 

Controlling Dynamic Library Symbols' Visibility

High-level 관점에서 링커 심볼 exporting/hiding 메커니즘은 윈도우나 리눅스에서 모두 동일하게 해결됩니다. 유일한 차이점은 기본적으로 모든 Windows DLL 링커 심볼은 숨겨지는 반면, 리눅스의 모든 동적 라이브러리 링커 심볼은 기본적으로 export된다는 점입니다.

 

실질적으로, 플랫폼 간 균일성을 위해 gcc에서 제공하는 일련의 기능들로 인해 symbol exporting 메커니즘은 매우 유사하고, 동일한 작업을 수행합니다. 따라서, 궁극적으로 어플리케이션을 구성하는 링커 심볼만 binary interface로 export되고, 나머지 모든 심볼은 숨겨집니다.

 

Exporting the Linux Dynamic Library Symbols

윈도우와 달리 리눅스에서는 모든 동적 라이브러리의 링커 심볼을 export하기 때문에 라이브러리를 dynamic link하려는 사람이 볼 수 있습니다. 이를 기본으로 사용하면 동적 라이브러리를 쉽게 처리할 수 있지만 모든 심볼을 export하거나 보이도록 하는 것은 여러 가지 이유로 권장되지 않습니다. 클라이언트에게 너무 많이 노출하는 것은 좋지 않을 뿐더러, 최소한의 심볼만 로드하여 라이브러리를 로드하는 데 필요한 시간을 줄일 수 있습니다.

 

Export할 심볼에 대한 제어가 필요하다는 것은 분명한데, 이를 제어하는 방법에 대한 몇 가지 메커니즘이 있습니다.

 

The Symbol Export Control at Build Time

GCC 컴파일러는 링커 심볼의 visibility를 설정하는 몇 가지 메커니즘을 제공합니다.

  • METHOD 1: affecting the whole body of code
-fvisibility compiler flag

'-fvisibility=hidden' 플래그를 전달하면, 동적 라이브러리를 dynamic link하려는 사람에게 모든 동적 라이브러리 심볼을 export하지 않거나 보이지 않도록 할 수 있습니다.

 

  • METHOD 2: affecting individual symbols only
__attribute__ ((visibility("<default | hidden>")))

attribute 속성으로 함수 시그니처를 데코레이트하면 링커가 symbol export를 허용(default)하거나 숨길 수(hidden) 있습니다.

 

  • METHOD 3: affecting individual symbols or a group of symbols
#pragma GCC visibility [push | pop]

이 옵션은 일반적으로 헤더 파일에서 사용되며, 다음과 같이 작성됩니다.

#pragma visibility push(hidden)
void someprivatefunction_1(void);
void someprivatefunction_2(void);
...
void someprivatefunction_N(void);
#pragma visibility pop

기본적으로 #pragma 지시문 사이에 선언된 모든 함수를 보이지 않게 하거나 export하지 않도록 합니다.

 

  • 기타 방법

GNU 링커는 동적 라이브러리 버저닝(versioning)을 처리하는 정교한 방법을 지원합니다 (-Wl,--version-script,<script filename> 을 통해). 메커니즘의 원래 목적은 버전 정보를 지정하는 것인데, 심볼의 visibility에 영향을 미치는 역할도 합니다. 여기서 자세히 다루지는 않겠습니다.

 

Linking Completion Requirements

동적 라이브러리 생성 프로세스는 컴파일 및 링크 프로세스를 모두 포함하므로 완전한 빌드 프로세스입니다. 일반적으로 모든 링커 심볼이 해결회면 링크 프로세스가 완료됩니다.

 

윈도우에서는 이 규칙이 엄격하게 적용됩니다. 모든 링커 심볼이 해결될 때까지 링크 프로세스는 끝난 것으로 간주되지 않으며, 바이너리가 생성되지 않습니다.

 

그러나 리눅스에서 이 규칙은 동적 라이브러리를 빌드할 때 기본적으로 조금 다릅니다. 모든 심볼이 해결되지 않더라도 동적 라이브러리의 링크가 완료되고 바이너리 파일이 생성됩니다.

 

이렇게 다른 이유는 링크 단계에서 누락된 심볼이 결국 다른 동적 라이브러리가 로드되면서 프로세스 메모리 맵에 나타날 것이라고 암묵적으로 가정하기 때문입니다. 런타임에 동적 라이브러리에서 제공되지 않는 심볼은 undefined ("U")로 표시됩니다.

리눅스의 이러한 유연성은 여러 경우에서 긍정적인 요소로 작용될 수 있으며, 매우 복잡한 linking limitation을 효과적으로 극복할 수 있습니다.

 

--no-undefined 플래그

기본적으로 리눅스에서 느슨한 규칙을 적용하는데, gcc 링커는 윈도우와 동일한 기준을 적용할 수 있는 플래그를 지원합니다. --no-undefined 플래그가 gcc 링커에 전달되면 빌드 시 모든 심볼이 확인되지 않으면 빌드가 실패합니다. 이 플래그 앞에는 -Wl prefix가 있어야 합니다.

 

 

Dynamic Linking Modes

Statically Aware (Load-Time) Dynamic Linking

지금까지 언급한 dynamic link는 이 시나리오라고 가정한 것입니다. 실제로 프로그램이 시작되는 순간부터 프로그램이 종료되는 순간까지 특정 동적 라이브러리의 기능이 필요한 경우가 빈번하며, 이러한 상황에서 빌드 할 때 다음의 항목들이 필요합니다.

 

At compile time

  • 동적 라이브러리의 export header files

At link time

  • 프로젝트에서 필요한 동적 라이브러리 리스트
  • 클라이언트 바이너리가 필요한 동적 라이브러리 바이너리 경로
  • 링킹 프로세스 세부 사항을 지정하기 위한 링커 플래그

 

Runtime Dynamic Linking

런타임 시 특정 동적 라이브러리가 실제로 존재하는지 여부 및 로드하는 특정 라이브러리를 결정할 수 있습니다.

동일한 ABI를 지원하는 여러 동적 라이브러리가 있고, 선택에 따라 그 중 하나만 로드하는 경우, 이 모드가 필요할 수 있습니다. 마찬가지로 이 모드에서는 다음의 항목들이 필요합니다.

 

At compile time

  • 동적 라이브러리의 export header files

At link time

  • 로드할 동적 라이브러리의 파일 이름 (동적 라이브러리의 파일 이름의 정확한 경로)

리눅스와 윈도우에서는 다음의 API 함수를 통해 이를 구현할 수 있습니다.

 

댓글