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

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

by 별준 2022. 11. 8.

References

  • Advanced C and C++ Compiling

Contents

  • Static Libraries
  • Dynamic Libraries

[C/C++] 간단한 프로그램 컴파일/링크 과정

 

[C/C++] 간단한 프로그램 컴파일/링크 과정

References Advaned C and C++ Compiling Contents Program's lifetime Compile Process Link Process 프로그램의 lifetime은 바이너리의 내부 구조에 의해 결정되는데, 바이너리는 OS loader가 load, unpack, 그리고, 그 내용을 실행

junstar92.tistory.com

위 포스트에서 컴파일러와 링커의 작업을 분리하는 이유를 이야기하면서 code reuse principle에 대해 언급했었습니다. 

Executable 파일을 빌드하는 영역에서 적용된 code reuse concept는 처음에 object file들의 모음인 static libraries (정적 라이브러리)로 실현되었습니다. 현재는 정적 라이브러리와 동적 라이브러리 두 종류의 개념이 사용중이고, 각각 장단점이 있으며 두 라이브러리의 세부 사항에 대해 이해하는 것은 중요합니다.

 

Static Libraries

정적 라이브러리의 아이디어는 매우 간단합니다. 컴파일러가 translation units(TCs) 모음을 binary object files로 변환하면, 나중에 다른 프로젝트에서 사용하도록 object file들을 보관할 수 있습니다. 그리고 이러한 object file들은 링크 타임에 다른 프로젝트 고유의 object file과 쉽게 결합될 수 있습니다.

 

Binary object files을 다른 프로젝트에 통합하려면 최소한 하나의 조건이 충족되어야 합니다. Binary file에는 다양한 definition과 function declaration을 제공하는 export header include file이 동반되어야 합니다.

다양한 프로젝트에서 object file들을 사용하는 방법에는 여러 가지가 있습니다.

  • 자명한 솔루션은 아래 그림과 같이 컴파일러에 의해 생성된 object file을 저장하고, 가능한 모든 방법으로 이를 필요로 하는 프로젝트에 복사하거나 전송하는 것입니다.

  • 조금 더 좋은 방법은 object file을 정적 라이브러리인 single binary file로 묶는 것입니다. 하나의 바이너리 파일을 전달하는 것이 분리된 여러 object file들을 전달하는 것보다 훨씬 간단하고 깔끔합니다.

  • 위의 경우, 반드시 필요한 요구 사항은 링커가 정적 라이브러리 파일의 포맷을 이해하고 링크하기 위해 해당 내용(정적 라이브러리 내에 포함된 object file들)을 추출할 수 있다는 것입니다. 물론 이는 마이크로프로세서 프로그래밍 초창기부터 모든 링커에서 충족합니다.
  • 정적 라이브러리는 단순히 여러 가지 방법으로 사용될 수 있는 object file들의 archive일 뿐입니다. 적절한 도구를 사용하여 정적 라이브러리를 original object files들로 분해할 수 있습니다. 그래서 하나 이상의 object file들이 라이브러리에서 제외될 수 있고, 새로운 object file이 추가될 수 있습니다.

 

Dynamic Libraries

어셈블러 프로그래밍 초창기부터 있었던 정적 라이브러리의 개념과는 달리 동적 라이브러리의 개념은 훨씬 나중에 나왔습니다. 이는 멀티태스킹 OS의 출현과 밀접하게 연관되어 있습니다.

 

멀티태스킹 OS에서 중요한 개념 중 하나는 동시에 수행되는 작업의 다양성에 관계없이 특정 시스템 리소스는 모두에게 공유되어야 한다는 것입니다. 데스크탑에서 공유 리소스의 예로는 키보드, 마우스, 사운드 카드, 네트워크 카드 등이 있습니다. 이러한 공통 리소스에 액세스하려는 모든 응용 프로그램이 리소스에 대한 제어를 제공하는 코드(소스 or 정적 라이브러리)를 통합해야 한다면 재앙이 될 수 있습니다. 이는 매우 비효율적이고 동일한 코드의 복사본을 저장하여 저장 공간을 낭비합니다.

 

이러한 상황에서 더 효율적인 OS를 위해 중복되는 소스 파일을 컴파일하거나 중복 object file에 링크하지 않도록 하는 공유 메커니즘 아이디어가 나왔습니다. 대신 일종의 runtime sharing으로 구현됩니다. 즉, 실행 중인 어플리케이션은 프로그램 메모리 맵에서 다른 실행 파일의 부분을 통합할 수 있고, 이러한 통합은 런타임에서 필요할 때 발생합니다. 이러한 개념을 dynamic linking 또는 dynamic loading이라고 부릅니다.

 

초기 디자인 단계에서 한 가지 명확한 부분이 있었는데, 이는 동적 라이브러리의 모든 부분에서 코드(.text) 섹션만 공유하는 것이 합리적이고 다른 프로세스와 데이터는 공유하지 않는다는 것입니다. 요리에 비유하면, 여러 명의 요리사가 같은 요리책(코드)를 공유할 수 있지만, 다른 요리사와 동일한 주방 기구(데이터)를 공유하지 않는 다는 것입니다. 확실한건, 여러 프로세스가 동적 라이브러리 data 섹션에 액세스할 수 있는 경우, 임의의 순간에 변수를 덮어쓸 수도 있어 실행을 예측할 수 없게 됩니다. 코드 섹션만 매핑하면 여러 응용 프로그램들이 서로 별도의 자체 섹션에서 공유되는 코드를 자유롭게 실행할 수 있습니다.

 

Dynamic vs. Shared Libraries

OS 개발자의 초기 목표는 모든 응용 프로그램의 바이너리에 동일한 OS 코드 조각이 불필요하게 여러 개 존재하지 않도록 하는 것이었습니다. 예를 들면, 문서 인쇄 기능이 필요한 응용 프로그램들은 인쇄 기능을 제공하기 위해 프린트 드라이버를 포함하는 complete printing stack을 통합해야 합니다. 정적 라이브러리 개념이 사용되면 프린터 드라이버가 변경될 때 인쇄 기능을 사용하는 모든 응용 프로그램들을 다시 컴파일해야 합니다.

 

따라서, 다음과 같은 방식으로 OS를 구현해야 합니다.

  • 일반적으로 필요한 기능들은 동적 라이브러리 형태로 제공
  • 공통 기능에 액세스가 필요한 응용 프로그램은 런타임에 동적 라이브러리로 로드

기본적인 개념은 아래 그림과 같습니다.

이러한 문제를 해결하는 첫 번째 방법은 load time relocation(LTR)로 알려져 있는 dynamic linking 구현의 첫 번째 버전입니다. 응용 프로그램이 바이너리에 OS 코드를 싣지 않아도 된다는 목표를 달성했습니다. 하지만, 여러 응용 프로그램이 런타임에 특정 시스템의 기능을 필요로 하는 경우, 각 응용 프로그램이 동적 라이브러리의 복사본을 로드해야 한다는 것입니다. 이러한 제한의 근본적인 원인은 load time relocation 기능이 지정된 응용 프로그램의 특정 주소 매핑에 맞도록 동적 라이브러리의 .text 섹션의 심볼을 수정했기 때문입니다. 이렇게 한 응용 프로그램에 로드되면서 수정된 라이브러리 코드는 다른 응용 프로그램의 메모리 레이아웃에 맞지 않습니다.

결과적으로 동적 라이브러리의 여러 복사본들이 런타임에 프로세스의 메모리 맵에 존재하게 되었습니다.

 

동적 라이브러리를 한 번만 로드할 수 있다는 문제(다른 응용 프로그램에서 로드하려면 복사본을 사용해야 함)를 해결하기 위해 보다 효율적인 메커니즘이 등장했는데, 이는 position independent code(PIC) 입니다. 이 개념은 동적 라이브러리 코드가 심볼에 액세스하는 방법을 변경하여, 응용 프로그램의 메모리 맵에서 메모리 매핑을 통해 공유할 수 있도록 했습니다.

the PIC technique of dynamic linking

PIC 개념이 나온 이후, 이를 지원하도록 설계된 동적 라이브러리를 공유 라이브러리(shared libraries)라고 합니다. 요즘은 이 개념이 널리 사용되고, 64비트 시스템에서는 컴파일러가 PIC를 강력하게 선호하므로 동적 라이브러리와 공유 라이브러리의 구분이 사라지고 거의 동일한 의미로 사용됩니다.

 

Dynamic Linking

Dynamic linking은 동적 라이브러리 개념의 핵심입니다. 이번에는 dynamic linking 과정에서 실제로 어떤 일들이 발생하는지 간단히 살펴보겠습니다.

 

Part 1: Building the Dynamic Library

바로 위의 그림에서 알 수 있듯이 동적 라이브러리를 빌드하는 프로세스는 compilation과 resolving the references 과정을 모두 포함하는 완전한 빌드입니다. 동적 라이브러리 빌드 프로세스의 결과는 executable의 속성과 동일한 바이너리 파일입니다. 유일한 차이점은 동적 라이브러리에서는 독립적인 프로그램으로 시작할 수 있는 startup routines가 없다는 것입니다.

Building the dynamic library

몇 가지 참고해야할 내용은 다음과 같습니다.

  • 윈도우에서 동적 라이브러리를 빌드하려면 모든 참조들을 확인해야 합니다. 동적 라이브러리 코드가 다른 동적 라이브러리의 함수를 호출하는 경우, 다른 동적 라이브러리와 그 라이브러리에 포함된 참조 심볼을 빌드 시에 알고 있어야 합니다.
  • 리눅스에서 기본 옵션은 더 많은 유연성을 허용합니다. 다른 동적 라이브러리가 링크된 후, 최종 라이브러리에 나타날 것이라고 기대하면서 일부 심볼이 unresolved 되는 것을 허용합니다. 링커 옵션을 통해 윈도우의 링커의 엄격한 조건을 만족시키도록 설정할 수 있습니다.
  • 리눅스에서는 동적 라이브러리를 수정하여 그 자체로 실행할 수 있도록 할 수 있습니다. 실제로 libc (C runtime library)는 그 자체로 실행 가능합니다. 쉘 창에 파일 이름을 입력하여 호출하면 화면에 메세지를 출력하고 종료합니다.

libc 실행 결과

Part 2: Playing by Trust While Building the Client Executable

다음 단계는 런타임에서 동적 라이브러리를 사용하려는 실행 파일을 빌드하는 것입니다. 링커가 실행 파일을 생성하는 정적 라이브러리와는 달리, 동적 라이브러리를 링킹하는 것은 동적 라이브러리 바이너리를 생성할 때 완료된 기존의 링크 결과와 현재 작업을 결합하려고 한다는 점에서 조금 특이합니다.

중요한 것은 링커가 동적 라이브러리의 심볼에 거의 모든 관심을 쏟는다는 것입니다. 이 단계에서 링커는 코드(.text)와 데이터(.data/.bss)에 전혀 관심을 가지지 않습니다.

 

구체적으로 말하자면 신뢰를 바탕으로 진행됩니다. 링커는 동적 라이브러리의 바이너리를 철처히 조사하지 않습니다. 섹션들의 크기를 찾지 않고 생성하려는 바이너리에 통합하려고 시도하지도 않습니다. 대신, 최종 바이너리에 필요한 심볼이 동적 라이브러리에 포함되어 있는지 확인하기만 합니다. 이를 찾으면 작업을 완료하고 실행 가능한 바이너리를 생성하게 됩니다.

Build time linking with a dynamic library

직관적으로 동적 라이브러리는 그저 '나는 이렇게 생겼다'고 약속하는 것이라고 볼 수 있습니다.

Part 3: Runtime Loading and Symbol Resolution

Load time에 발생하는 이벤트는 동적 라이브러리의 약속이 링커에게 확인되어야 하는 시간이며, 매우 중요합니다. 빌드 프로세스에서 실행 파일에 필요한 심볼을 검색할 때, 동적 라이브러리 바이너리의 복사본을 조사했습니다. Load time, 즉, 런타임에서 발생하는 작업은 다음과 같습니다.

  1. 먼저 동적 라이브러리 바이너리 파일을 찾아야 합니다. 각 OS에는 loader가 동적 라이브러리 바이너리를 찾는 디렉토리에 대한 규칙이 있습니다.
  2. 동적 라이브러리가 프로세스에 성공적으로 로드되어야 합니다. 이것이 바로 빌드할 때의 약속이 이행되는 순간입니다. 실제로 런타임에 로드된 동적 라이브러리는 빌드 시 사용할 수 있다고 약속된 동일한 심볼들을 전달해야 합니다. 구체적으로 동일하다는 것은 런타임에 동적 라이브러리에서 발견되는 함수 심볼이 빌드할 때 약속되었던 함수 시그니쳐(이름, 인자 리스트, linkage/calling convention)가 정확히 일치해야 함을 의미해야 합니다.
    조금 흥미롭지만, 런타임에 발견된 동적 라이브러리의 실제 어셈블리 코드(섹션 내용)가 빌드할 때 사용된 동적 라이브러리 바이너리에서 발견된 코드와 일치할 필요는 없습니다.
  3. Executable symbol은 동적 라이브러리가 매핑되는 메모리 맵 프로세스 부분에서 올바른 주소를 가리키도록 해석되어야 합니다.

위의 모든 단계가 성공적으로 완료되면 아래 그림과 같이 동적 라이브러리에 포함된 코드를 응용 프로그램이 실행할 수 있습니다.

Load time linking of a dynamic library

 

Special Binary File Types Related to Dynamic Linking in Windows

윈도우에서는 위의 각 단계에서 약간 다른 바이너리 파일 포맷을 사용합니다. 구체적으로 말하자면, DLL 프로젝트가 생성되고, 빌드될 때 컴파일러는 여러 개의 다른 파일들을 같이 생성합니다.

 

Dynamically Linked Library (.dll)

이 파일 포맷은 실제로 dynamic linking 메커니즘을 통해 프로세스에서 런타임에 사용하는 shared object인 동적 라이브러리입니다.

Import Library File (.lib)

이 바이너리 파일은 윈도우에서 특히 'Part 2' 단계에서 사용됩니다. DLL 심볼 리스트만 포함하고 링커 섹션은 포함하지 않으며, 이 파일의 목적은 오직 동적 라이브러리의 exported 심볼을 클라이언트 바이너리에게 전달하는 것입니다.

Windows import library

이 파일의 확장자 때문에 혼란스러울 수 있는데, 정적 라이브러리에도 동일한 확장자가 사용됩니다.

 

약간 논의가 필요한 부분이 하나 더 있는데, 이 파일을 import library라고 부르지만 사실 DLL 심볼을 export하는 과정에서 사용된다는 것입니다. Dynamic linking 과정을 보는 관점에 따라 네이밍이 달라지는 것이 사실이므로 이 파일이 DLL 프로젝트에 속해있고, DLL 프로젝트를 빌드하여 생성되고 배포될 수 있다는 것도 맞고, 수 많은 응용 프로그램에 배포될 수 있습니다.

이러한 관점을 microsoft 내에서도 공유했기 때문에 __declspec 키워드를 사용할 때, __declspec(dllexport)는 DLL에서 클라이언트 앱으로 export하는 것을 가리킵니다.

 

이러한 네이밍을 계속 사용하는 이유는 DLL 프로젝트가 circular dependecies가 발생할 때 이 파일 대신 사용할 수 있는 다른 타입의 라이브러리 파일을 생성하기 때문입니다. 이를 export file (.exp)라고 하며, 둘을 구분하기 위해서 기존 이름을 그대로 유지합니다.

 

Export File (.exp)

Export file은 import library file과 동일한 성질을 가지고 있습니다. 그러나, 일반적으로 두 실행 파일에 circular dependencies가 있어서 둘 중 하나의 빌드를 완료할 수 없는 상황에서 사용됩니다.

 

 

Unique Nature of Dynamic Library

동적 라이브러리가 독특한 특성을 가지고 있다는 것을 알고 있으면 디자인 문제를 다룰 때 꽤 도움이 됩니다. 실행 파일과 정적 라이브러리는 서로 반대의 특성을 가지고 있다는 것이 명백합니다. 정적 라이브러리를 생성할 때 링크 단계가 포함되지 않지만, 실행 파일의 경우에는 필수적입니다. 그런 점에서 '라이브러리'라는 단어를 가지고 있지만 동적 라이브러리는 정적 라이브러리보다 실행 파일의 특성에 훨씬 더 가깝다고 볼 수 있습니다.

 

Property 1: Dynamic Library Creation Requires the Complete Build Procedure

동적 라이브러리를 생성하는 과정에는 컴파일 단계뿐만 아니라 링크 단계로 포함됩니다. 정적 라이브러리와 이름은 비슷하지만, 링크 단계를 포함하기 때문에 실행 파일과 더 유사합니다. 유일한 차이점은 실행 파일에는 커널이 프로세스를 시작할 수 있도록 하는 startup 코드가 있다는 것입니다.

(리눅스에서는 커맨드 라인에 라이브러리를 실행할 수 있도록 하는 몇 줄의 코드를 동적 라이브러리에 추가할 수 있습니다)

 

Property 2: The Dynamic Library Can Link In Other Libraries

동적 라이브러리를 로드하고 링크할 수 있는 것은 실행 파일뿐만 아니라 다른 동적 라이브러리도 가능합니다. 따라서, 동적 라이브러리에서 링크되는 바이너리를 표현하려면 'executable'이 아닌 다른 용어가 적절하긴 합니다. 참조 문헌에서는 이를 'client binary'라고 칭합니다.

 

Application Binary Interface (ABI)

인터페이스 개념이 프로그래밍 언어 영역에서 적용될 때, 일반적으로 함수 포인터의 구조를 나타내는데 사용됩니다.

소프트웨어 모듈에서 클라이언트로 내보내는 인터페이스는 일반적으로 API (Application Programming Interface)라고 부릅니다. 바이너리 영역에서 적용될 때, 인터페이스 개념은 Application Binary Interface라는 domain-specific한 특징을 얻게 됩니다.

 

  • Dynamic linking의 첫 번째 단계(build-time) 동안, 클라이언트 바이너리는 실제로 라이브러리의 exported ABI에 대해 링크됩니다. 빌드 시, 클라이언트 바이너리는 실제로 동적 라이브러리가 심볼(ABI와 같은 함수 포인터)를 export하는지 여부만 확인하고 섹션(function body)에 대해서는 전혀 신경쓰지 않습니다.
  • Dynamic linking의 두 번째 단계(runtime)를 성공적으로 완료하려면 런타임에 사용 가능한 동적 라이브러리의 바이너리가 빌드 시에 확인했던 것과 동일한 ABI를 export해야 합니다.

 

Static vs. Dynamic Libraries Comparison Points

정적 라이브러리와 정적 라이브러리의 이면에 있는 개념을 깊게 다루지는 않겠지만, 두 라이브러리의 일부 차이점에 대해 살펴보겠습니다.

 

정적 라이브러리와 동적 라이브러리의 가장 흥미로운 차이점은 링크를 시도하는 클라이언트 바이너리가 적용하는 selectiveness criteria의 차이입니다.

 

Import Selectiveness Criteria for Static Libraries

클라이언트 바이너리가 정적 라이브러리를 링크할 때, 정적 라이브러리 전체 내용을 링크하지 않습니다. 대신 아래 그림과 같이 실제로 필요한 심볼이 포함된 object file에만 연결됩니다.

따라서, 정적 라이브러리에서 관련된 코드의 양만큼만 클라이언트 바이너리 크기가 증가하게 됩니다.

 

Import Selectiveness Criteria for Dynamic Libraries

동적 라이브러리를 링크할 때는 심볼 테이블 수준에서만 selectivity를 제공합니다. 여기서 실제로 필요한 동적 라이브러리 심볼만 심볼 테이블에서 선택됩니다. 즉, 아래 그림과 같이 일부 기능이 필요한 것과 관계없이 전체 동적 라이브러리가 동적으로 연결됩니다.

증가되는 코드의 양은 런타임에만 발생하며, 클라이언트 바이너리의 크기는 크게 증가하지 않습니다.

 

Whole Archive Import Scenario

조금 흥미로운 것 중 하나는 정적 라이브러리가 동적 라이브러리를 통해 바이너리 클라이언트에 제공되는 경우입니다.

중간에 위치하는 동적 라이브러리 자체에는 정적 라이브러리의 기능들이 필요하지 않습니다. 따라서 import selectiveness rule에 따라 정적 라이브러리의 어떤 것과도 링크되지 않습니다. 여기서 동적 라이브러리가 디자인된 유일한 이유는 정적 라이브러리의 기능들을 모으고, 다른 곳에서 사용할 수 있도록 해당 심볼들을 export하는 것입니다.

 

클라이언트 바이너리에 심볼이 필요한지 여부에 관계없이 라이브러리를 무조건적으로 링크할 수 있는 방법이 있는데, 이는 링커 플래스 '--whole-archive'를 사용하는 것입니다. 이를 사용하면 나열된 하나 이상의 라이브러리가 무조건 완전히 링크됩니다.

gcc -fPIC <source files> -Wl,--whole-archive-l<static libraries> -o <shlib filename>

 

Deployment Dilemma Scenarios

소프트웨어 배포 패키지를 디자인할 때, 일반적으로 배포되는 패키지의 크기를 최소화하는 요구 사항이 있을 수 있습니다. 가장 간단한 시나리오 중 하나는 소프트웨어 제품의 기능 중 특정 부분이 라이브러리에서 제공되는 것입니다. 이때, 라이브러리는 정적 라이브러리 또는 동적 라이브러리로 제공될 수 있으므로, 패키지의 크기를 최소화하기 위해서 어떤 링크 시나리오를 활용할 지 질문할 수 있습니다.

 

  • Linking with a Static Library

한 가지 방법은 정적 라이브러리와 링크하는 것입니다. 정적 라이브러리를 사용하면 실행 파일이 필요한 모든 코드를 포함하기 때문에 완전히 독립적입니다. 다만, 정적 라이브러리에서 수집한 코드의 양만큼 실행 파일의 크기가 증가합니다.

  • Linking with a Dynamic Library

반면, 동적 라이브러리를 링크하면 심볼을 예약하는 비용을 제외하고는 실행 파일의 크기를 증가시키지 않습니다.

다만, 여러 이유로 필요한 동적 라이브러리가 target에서 물리적으로 사용하지 못할 가능성이 항상 존재합니다. 이런 문제 때문에 필요한 동적 라이브러리가 실행 파일과 함께 배포될 수 있지만, 결국 전체 패키지 크기는 증가하게 되는 등 여러 문제가 발생할 수 있습니다.

 

위의 내용을 요약해서 정리하자면, 일단 정적 라이브러리는 응용 프로그램이 비교적 적은 수의 정적 라이브러리의 일부분에 링크될 때 좋은 선택이 될 수 있습니다.

동적 라이브러리의 링크는 응용 프로그램이 target system의 런타임에 존재할 것으로 예상되는 경우 좋은 선택이 될 수 있습니다. 예를 들어, C runtime 라이브러리와 같은 OS-specific 동적 라이브러리입니다.

 

'프로그래밍 > C & C++' 카테고리의 다른 글

[C/C++] 동적 라이브러리  (0) 2022.11.16
[C/C++] 정적 라이브러리  (0) 2022.11.11
[C/C++] Program Execution  (0) 2022.11.04
[C/C++] 간단한 프로그램 컴파일/링크 과정  (1) 2022.11.03
[C++] std::string 최적화  (1) 2022.04.24

댓글