API

win32/DynamicLinkLibrary(dll) - Good

디버그정 2008. 9. 5. 23:27
Written by 이동우(leedw at ssrnet.snu.ac.kr)

내용

  1. Introduction
  2. 왜 DLL을 사용 하는가
  3. DLL과 프로세스 주소 공간
  4. DLL의 작성과 사용
    1. Implicit Loading 방식
      1. 개요
      2. DLL의 작성
      3. 실행 파일의 작성
      4. 실행 파일의 실행
    2. Explicit Loading 방식
      1. DLL Loading
      2. Unloading
  5. 내부 구조
    1. Export
    2. Import


 

1 Introduction

dynamic-link library는 어느 운영체제에서나 매우 중요한 이슈 사항이다. 프로그램을 실행하기 위해 소스 코드를 작성하고 이 소스 코드를 컴파일링해서 실행 파일을 생성하는 과정은 고전적인 프로그래밍 개발 모델로 오랫동안 자리를 잡아 왔다. 그러나 하드웨어 인프라가 발전하고 사용자 환경이 GUI로 바뀜에 따라 하나의 프로그램에 내장시켜야 할 기능도 점차 늘어나고 추가되는 코드의 양이 증가함에 따라 생성되는 실행 파일의 크기도 기하 급수적으로 커지게 되었다. 실행 파일이 커지면 당연히 메모리 요구량이 늘어나고 메모리에 로딩되는 속도는 물론 수행 속도 마저 떨어질 수 밖에 없다. 문제는, 그렇게 사이즈가 증가하는 프로그램들 간에는 서로 공통되는 코드와 리소스를 중복해서 가지고 있기 때문에 디스크와 메모리등 한정적인 하드웨어 리소스를 상당히 비효율적으로 사용하게 되는 문제점을 드러내게 되었다는 것이다. 이런 문제점에 대한 해결 방안으로 프로그램을 여러 모듈로 분활시키고 중복되는 코드나 리소스는 서로 공유하는 형태로 프로그램을 구성하는 방법을 고안하게 된다. 이렇게 해서 dynamic-link library라는 개념이 등장하게 된다.
이 페이지에서는 dll의 기초 개념과 구조, dll이 메모리에 로딩되는 메커니즘등을 살펴보면서 윈도우즈 운영체제의 작동 방식을 이해하는 기회를 갖도록 하겠다.

2 왜 DLL을 사용 하는가

윈도우즈는 초창기 버전부터 dll을 중요하게 사용하도록 디자인된 운영체제였다. 그것은 대부분의 윈도우즈 API 함수들이 모두 dll로 제공되고 있는 것으로도 반증이 되고 있다. 윈도우즈에서는 어플리케이션이 구동될 수 있도록 3가지의 핵심적인 dll을 제공하고 있는데 kernel32.dll, user32.dll, gdi32.dll이 그것이다. kernel32.dll은 프로세스, 쓰레드, 메모리 관리를 위한 함수들을 포함하고 있으며 user32.dll은 윈도우 생성이나 메시지 핸들링과 같은 유저 인터페이스 테스크를 위한 함수들을 제공하고 있고 마지막으로 gdi32.dll은 화면에 글자를 출력하거나 그래픽 이미지를 그리고 출력하는 각종 함수들이 모아져 있다. 물론 윈도우즈에는 그 외에도 레지스트리나 이벤트 로깅과 관련된 함수들을 포함하는 advapi32.dll, 파일 열기나 파일 저장과 같은 공통 대화상자에 관련된 함수들을 제공하는 comdlg32.dll, 기본 윈도우 제어를 위한 comctrl32.dll과 같은 여러 dll이 존재하고 있다.
그렇다면 왜 이렇게 윈도우즈에서는 dll을 적극적으로 이용하고 있을까. 그 이유는 다음과 같은 범주로 나누어 설명할 수 있을 것이다.

어플리케이션의 기능을 확장시킬 수 있다.
dll은 프로세스 주소 공간에 동적으로 로딩될 수 있기 때문에 어플리케이션은 run-time시에 필요한 기능을 적절한 dll을 선택적으로 로딩하여 사용할 수 있다. 예를 들어 한 소프트웨어 회사에서 제품을 내놓고 다른 회사에서 dll을 통해 그 제품의 기능을 확장시키는 것도 가능하다. 가까운 예로 리버서들이 많이 사용하는 SoftIce나 Olly 디버거의 add-in들이 dll 형태로 제공되고 있는 것을 보았을 것이다.

다른 종류의 프로그래밍 언어로도 dll을 만들 수 있다.
예를 들어 UI는 Visual Basic으로 만들고 내부의 비지니스 로직은 C++로 만들어 dll 형태로 로딩할 수 있을 것이다. C++뿐만 아니라 코볼, 포트란등 다른 프로그래밍 언어로 얼마든 dll을 제작할 수 있다.

프로젝트 관리가 손쉬워진다.
개발 공정 단계에서 팀 단위로 모듈을 나누어 작성하여 dll로 만들면 프로젝트 관리가 좀더 수월해 진다. 물론 이때 모듈을 효율적으로 나누는 지혜가 필요하며 하나의 어플리케이션에서 지나치게 많은 dll을 로딩하게 만들면 어플리케이션의 초기화 단계에서 성능이 저하되는 문제점이 있다는 것을 염두에 둬야 한다.

메모리를 절약할 수 있다.
이것은 공유 라이브러리의 핵심 기능이다. 모든 어플리케이션이 공통으로 사용하는 C/C++의 run-time library를 메모리상의 한번만 로딩해 두고 다른 코드들은 이 run-time 라이브러리가 로딩된 메모리의 페이지를 공유하도록 하면 굳이 같은 라이브러리 코드들을 중복해서 여러번 메모리에 로딩하지 않아도 된다.

리소스 공유가 가능하다.
대화상자 템플릿, 스트링, 아이콘, 비트맵등의 리소스를 dll로 만들어 두면 여러 어플리케이션에서 리소스를 공유하여 사용할 수 있게 된다.

플랫폼 버전 차이에 따른 문제를 해결한다.
윈도우즈 운영체제도 여러 버전이 존재하며 버전마다 제공하는 함수들이 각기 다르다. 만약 개발자가 상위 버전이나 특정 버전에서만 제공하는 함수를 사용하여 어플리케이션을 만든다면, 그 함수를 지원하지 못하는 버전에서는 어플리케이션이 실행되지 못할 것이다. 그러나 버전에 의존적인 함수들을 사용하는 루틴을 dll로 만들어 둔다면 하위 버전에서도 어플리케이션 자체는 로딩되어 이상없이 실행될 수 있게 된다. 물론, 해당 함수는 정상적으로 호출되지는 않을 것이다.

특수 목적으로 dll을 사용할 수 있다.
윈도우즈에서는 dll로만 제공할 수 있는 기능들이 몇몇 있다. 예를 들어 SetWindowsHookExSetWinEventHook과 같은 후커를 설치하기 위해서 hook notification 함수는 반드시 dll로 만들어야 한다. ActiveX나 COM을 사용하여 IE 쉘의 기능을 확장시키는 것도 dll로 작성해야 한다.

3 DLL과 프로세스 주소 공간

윈도우즈에서는 윈도우를 생성하여 화면에 띄우고 내부적으로 메시지 루프가 돌면서 사용자로부터 키, 마우스등의 입력을 기다리는 것이 전형적인 윈도우즈 어플리케이션의 모델이다. 사용자로부터 입력이 들어오면 어플리케이션의 메시지 루프로 이벤트 메시지가 전달되며 어플리케이션은 이 메시지를 처리하고 다시 다른 메시지를 기다리는 상태로 돌아간다.
이와는 달리 dll은 윈도우를 생성하거나 윈도우 메시지를 처리하는 기능을 지원하지 않는다. dll은 단순히 변수와 함수들을 모아 놓고 어플리케이션이나 다른 dll에서 사용할 수 있도록 제공하는 역활만 하게 된다. 이렇게 dll이 변수나 함수를 외부로 제공하는 것을 export라 하고 dll이 export하는 변수나 함수를 가져다 오는 것을 import라고 한다. 그런데 여기서 중요한 것은 어플리케이션에서 dll이 제공하는 함수나 변수를 사용하기 위해선 반드시 dll 이미지가 프로세스의 주소 공간에 매핑, 즉 로딩되어야 한다는 것이다. 이렇게 dll을 프로세스의 주소 공간에 로딩하는 방법에는 dll을 언제 로딩하느냐에 따라 암시적 로딩(implicit loading)과 명시적 로딩(explicit loading)으로 나뉜다. 프로세스 로딩시에 dll을 주소 공간에 매핑시키면 implicit load-time loading이 되는 것이고 run-time때에 매핑시키면 explicit run-time loading이 되는 것이다.

어떠 방법으로든 이렇게 dll이 프로세스 주소 공간에 매핑되고 나면 dll은 자신의 정체성을 잃어버리고 완전히 프로세스에 포함된 코드의 일부분으로서 동작하게 된다. dll에서 사용하는 로칼 변수들은 dll을 호출하는 쓰레드의 스택에 생성이 되며 만약 dll에서 VirtualAlloc() 함수로 가상 메모리를 잡는다면 이는 dll을 로딩한 프로세스의 가상 메모리를 할당받게 되며 dll이 주소 공간에 unmap되어도 이 메모리는 계속 남아 있게 되는 것이다.1

주의
윈도우즈든 리눅스든 어플리케이션, 혹은 dll은 각기 다른 방식으로 라이브러리들을 링크할 수 있다. 다른 방식이라 함은 정적 라이브러리 방식으로 라이브러리를 링크할 수도 있고 dll 방식으로 라이브러리를 링크할 수 있다라는 말이다. 이것이 왜 중요한 이슈인가 하면 예를 들어 다음의 코드를 살펴보자.

void EXEFunc() 
{ 
    int *p = DLLFunc(); 
 
    delete[] p; 
} 
 
int *DLLFunc() 
{ 
    return (new int[100]); 
} 

EXEfunc()가 우리가 만드는 어플리케이션의 코드이고 어플리케이션에서 로딩하는 dll에서 DLLfunc()라는 함수를 제공하고 있다고 생각해 보자. 만약 어플리케이션과 dll에서 모두 C/C++ run-time library를 dll 방식으로 링크하고 있다면 new와 delete는 같은 코드를 실행하고 있기 때문에 위 코드는 이상없이 잘 작동할 것이다. 그러나 우리가 만드는 어플리케이션에서는 C/C++ run-time library를 dll 방식으로, 혹은 static 방식으로 링크하고 있고, dll은 static하게 링크하고 있는 상황이라면 위의 delete와 new는 서로 다른 모듈의 코드를 수행하기 때문에 delete에서는 access violation이 발생하게 될 것이다. 프로세스의 주소 공간에는 하나의 실행 모듈과 여러 방식으로 링크하는 dll들로 잡다하게 구성되어 있기 때문에 C/C++ run-time 함수라 할지라도 자신의 프로세스와 dll이 동일한 함수를 호출할 것이라 가정하면 안된다는 것이다. 그렇다면 위의 문제점은 어떻게 해결할 수 있을까.

해결은 간단하다. 메모리를 할당하는 모듈이 있다면 그 모듈에서 메모리를 해제하도록 하면 된다.

void EXEfunc() 
{ 
    int *p = DLLFunc(); 
 
    DLLFreeFunc(); 
} 
 
int *DLLFunc() 
{ 
     char *p = new int[100]; 
 
    return p; 
} 
 
void DLLFreeFunc(int *p) 
{ 
    delete[] p; 
} 

이것은 malloc()과 같은 C run-time library 함수를 사용할 때도 마찬가지이다. 특히 dll에서 메모리 할당 함수를 사용할 때는 주의해야 한다.

4 DLL의 작성과 사용

4.1 Implicit Loading 방식

4.1.1 개요

윈도우즈에서 DLL이 어떻게 만들어지고 이용되는가. 이는 크게 dll을 작성하고, 생성된 dll을 링크해서 실행 파일을 작성하며 이렇게 해서 생성된 실행 파일을 실행시켜 프로세스를 생성하는 일련의 과정을 거치게 된다.
다음은 dll을 사용하는 전형적인 방식인 implicit load-time loading 방식을 기준으로 각 단계를 설명한 것이다.

  1. DLL의 작성
    1. dll을 만들기 위해서는 먼저 dll에서 export하고자 하는 함수 프로토타입, 구조체, 변수등을 정의하는 헤더 파일을 생성한다. 이 헤더 파일은 dll의 모든 소스에서 include할 뿐 아니라 dll을 사용하는 어플리케이션에서도 include할 수 있도록 제공되어야 한다.
    2. 헤더 파일이 정의되었으면 여기에서 정의한 내용을 구현하는 C/C++ 소스 파일들을 작성한다.
    3. dll 소스 파일들을 컴파일한다. 그럼 각 파일 하나마다 .obj 파일이 생성이 된다.
    4. .obj 파일의 생성이 끝나면 linker가 모든 .obj 파일을 묶어서 하나의 dll 이미지 파일을 생성한다. 그리고 더불어 dll에서 export하는 함수나 변수들의 symbol name을 담은 .lib 파일도 함께 생성하게 된다.2 이렇게 생성이 된 dll과 lib 파일은 어플리케이션을 빌드할 때도 필요한 파일들이다.
  2. 실행 파일의 작성
    1. dll에서 export하고 있는 함수, 구조체, 변수, 클래스등을 참조하려 한다면 dll 개발자가 작성한 헤더 파일을 include해야 한다.
    2. dll에서 제공하고 있는 함수, 구조체, 변수등을 사용해서 어플리케이션 소스 파일을 작성한다.
    3. 소스를 컴파일한다. 그럼 컴파일러가 각 소스 파일마다 .obj 파일을 생성하게 된다.
    4. linker가 .obj 파일들을 묶어서 하나의 실행 파일(.exe)를 생성한다. 이때 실행 파일의 import section에는 실행 파일이 이용하는 dll 모듈의 목록이 기록된다. 더불어 import section에는 dll 중에서 어플리케이션이 참조하는 함수나 변수명도 함께 들어가 있다.
  3. 실행 파일의 실행
    1. 로더loader가 어플리케이션의 프로세스를 실행하기 위해 가상 메모리를 생성한다. 실행 모듈이 새로운 프로세스 주소 공간에 매핑되고 로더는 실행 모듈의 import section을 parsing해서 어플리케이션이 이용하는 dll 모듈의 이름을 알아낸다. 이렇게 필요한 dll 모듈을 디스크에서 읽어서 프로세스 주소 공간에 로딩한다. 이때 로딩한 dll에서도 import section을 가지고 있어서 다른 dll 모듈을 import할 수도 있기 때문에 이 역시 로더에 의해 메모리에 매핑된다. 프로세스의 초기화가 완전히 끝나려면 로더가 실행 모듈과 모든 dll 모듈의 import section에 대한 parsing을 거쳐서 dll 모듈들이 모두 프로세스 주소 공간에 매핑이 되어야 한다. 때문에 프로세스 초기화는 시간 소모가 상당한 과정이라는 것을 알 수 있다.

explicit run-time loading 방식은 2번의 ⅵ항목부터 달라지는데 explicit loading 방식에서는 export symbol에 대한 직접 참조(direct reference)를 하지 않기 때문에 import section에 기록하지 않는다. 따라서 3번의 ⅰ항목에서 import section을 parsing해서 dll 모듈을 매핑하는 절차도 필요가 없다. explicit loading은 아래에서 더 자세히 논하도록 하자.

이런 절차가 모두 끝나면 비로소 프로세스의 primary thread가 실행이 된다. 이제 각 항목을 좀더 자세히 학습해 보도록 하겠다.

4.1.2 DLL의 작성

dll을 작성하는 코드의 형태는 다음과 같다.

MyLib.h

#ifdef MYLIB_EXPORT 
#define MYLIBAPI extern "C" __declspec(dllexport)    
#else 
#define MYLIBAPI extern "C" __declspec(dllimport) 
#endif 
 
MYLIBAPI int g_nResult;   // exported variable 
 
MYLIBAPI int DllFunc();  // exported function 

MyLib.cpp

#define MYLIB_EXPORT 
 
#include "MyLib.h" 
 
int g_nResult; 
 
int DllFunc() 
{ 
   ... 
} 

dll 소스 코드를 작성할 때 export하고자 하는 함수나 변수는 반드시 그 앞에 __declspec(dllexport) 라는 변경자modifier를 붙여서 선언해줘야 한다. 반대로 실행 모듈이나 다른 dll에서 함수를 import할 때는 __declspec(dllimport)를 붙여서 선언한다. 위의 코드는 dll을 만들기 위한 헤더 파일과 그 dll을 이용하는 실행 모듈에서 include하는 헤더 파일을 따로 만들어야 하는 번거로움이 있기 때문에 MYLIBAPI라는 매크로를 사용해서 같은 헤더 파일을 쓸 수 있도록 한 것이다. 위의 코드에서는 소스에서 MYLIB_EXPORT를 선언했지만 dll 소스가 많을 때는 프로젝트 설정에서 선언하면 각 소스에서마다 MYLIB_EXPORT 매크로를 선언하지 않아도 될 것이다.

또하나 주목해야할 것은 만약 dll을 C코드가 아닌 C++로 작성한다면 위의 코드에서처럼 extern "C" 변경자modifier를 선언해주는 것이 좋다. 일반적으로 C++ 컴파일러는 함수나 변수명에 대해 name mangling을 하기 때문에 만약 C코드나 다른 종류의 프로그래밍 랭귀지에서 C++로 만든 dll을 이용하려 한다면 import하는 함수에 대하여 링커가 undefined symbol 에러를 뿌려댈 것이다.

dll에서는 일반적으로 실행 모듈이 이용할 수 있는 함수들을 제공하는 것이 보통인데 그외 변수, 구조체, 클래스등을 제공하는 것도 가능은 하다. 그러나 변수를 dll에서 export하는 것은 C++에서 강조하는 추상화abstraction를 깨뜨리기 때문에 별로 권장하지는 않는다. C++ 클래스를 export하는 것도 가급적이면 피하는 것이 좋은데 이는 클래스를 import하는 실행 모듈이 반드시 dll을 생성하는데 사용한 툴과 동일한 툴로 빌드해야한다는 제한점이 있기 때문이다. 가령, 클래스를 export하는 dll이 볼랜드 C++이나 파워빌더로 만들어졌는데 MS사의 툴로 빌드하는 실행 모듈에서는 그 dll이 제공하는 클래스는 import할 수 없다는 것이다.

예제)
실제로 작동하는 dll을 예제로 만들어 보자.

MyDll.h

#pragma once 
#ifdef MYLIB_EXPORT  
#define MYLIBAPI __declspec(dllexport) // dll 내부구조 설명을 목적으로 extern "C" 변경자는 고의로 넣지 않았다. 
 
#else 
#define MYLIBAPI __declspec(dllimport) 
#endif 
 
MYLIBAPI int GetData(); 
MYLIBAPI void SetData(int); 

MyDll.cpp

#include "stdafx.h" 
 
#define MYLIB_EXPORT 
 
#include "MyDll.h" 
 
static int g_nData; 
 
BOOL APIENTRY DllMain(HANDLE module, DWORD ul_reason_for_call, LPVOID lpReserved) 
{ 
   return TRUE: 
} 
 
MYLIBAPI int GetData() 
{ 
   return g_nData; 
} 
 
MYLIBAPI void SetData(int data) 
{ 
   g_nData = data; 
} 

4.1.3 실행 파일의 작성

application에서 dll이 export하고 있는 함수를 사용하기 위해선 application 소스 코드에서 dll의 헤더 파일을 include해야 한다. 이 헤더를 제대로 include하지 못하면 dll의 함수를 콜하는 부분에서 undefined symbol 에러가 발생하게 된다.
위에서 예제로 든 MyLib.cppDllFunc()함수를 사용하고자 한다면 먼저 응용 프로그램 소스에서 MyLib.h를 include하고 MYLIB_EXPORT 매크로를 정의하지 않도록 한다. 그러면 MYLIBAPI 매크로는 extern "C" _ _declspec(dllimport)로 확장되며 이후 compiler는 MYLIBAPI 매크로로 정의된 함수와 변수들은 import symbol이라는 것을 인식하게 된다.
compiling 단계에서는 직접적으로 dll 파일이 필요한 단계는 아니며 단지 import symbol을 올바른 형태 참조하는지만 체크하게 된다. linking 단계에서는 모든 .obj 파일을 묶어서 하나의 executable module을 생성하게 되는데 이때 어느 dll 파일이 application에서 콜하는 함수를 export하고 있는지 linker가 알아야 모든 external symbol에 대해 resolving을 할 수가 있게 된다. 때문에 옵션으로 dll의 .lib 파일명을 linker에게 전달해줘야 하는 것이다.3 모든 external symbol reference에 대해 resolve가 끝나면 실행 파일의 생성이 완료된다.

예제)
MyApp.cpp

#include "stdafx.h" 
#include <iostream> 
#include "MyDll.h" 
 
using namespace std; 
 
int main(int argc, char* argv[]) 
{ 
   int data = 0; 
 
   cout << "DLL data: " << GetData() << "\n"; 
   cout << "Input data: "; 
   cin >> data; 
   
   SetData(data); 
   cout << "Changed dll data: ";  
   cout << GetData() << "\n"; 
 
   return 0; 
} 

4.1.4 실행 파일의 실행

application 실행 파일이 실행되면 운영체제의 loader는 새로운 프로세스를 위한 주소 공간(virutal address space)을 생성하고 이 주소 공간에 실행 파일의 모듈이 매핑된다. loader는 실행 파일 이미지에 존재하는 import section을 검사해서 dll들을 찾아 해당 dll들도 프로세스 주소 공간에 매핑시킨다. 물론 dll이 다른 dll을 import하고 있다면 그들 역시 프로세스 주소 공간에 매핑이 이루어진다.
import section에는 dll 파일명만 명시되어 있고 그 dll이 어느 경로에 있는지는 명시되어 있지 않으므로 loader는 다음과 같은 순서로 dll 모듈을 검색한다.4

  1. 실행 파일 이미지가 있는 디렉토리
  2. 프로세스의 현재 디렉토리
  3. 윈도우즈 시스템 디렉토리 (ex. \winnt\system32 )
  4. 윈도우즈 디렉토리 (ex. \winnt)
  5. path 환경 변수에 명시되어 있는 디렉토리(PATH)

이렇게 dll의 로딩이 모두 끝나면 loader는 external symbol 참조에 대한 resolving을 수행하게 된다. 이 작업을 위해 각 모듈의 import section을 다시 검색해서 각각의 symbol에 대해 dll의 export section과 비교해서 존재하는 symbol인지 체크한다. 만약 어느 dll에서도 존재하지 않는 symbol이라면 loader는 프로세스 초기화 실패라는 에러 메시지 박스를 출력하고 application 실행을 중지한다.

dll의 export section에서 symbol을 발견하면 symbol의 RVA 값을 가져와서 DLL 모듈이 로딩된 base address 주소와 더한 후 결과값을 실행 모듈의 import section에 기록한다. 이제 실행 모듈의 코드에서 해당 symbol에 대한 참조가 일어나면 import section에 기록된 주소값으로 참조가 가능해 진다. 이렇게 해서 실행 모듈의 실행 준비가 모두 끝나고 비로소 프로세스가 실행run 된다.

여기서 loader는 메모리에 로딩되는 dll의 내역 정보를 가지고 있어서 여러 모듈이 동일한 dll을 로딩할 때 그때마다 dll을 메모리에 매핑시키지 않고 한번만 매핑한 후 dll의 reference counter를 증가시키는 방법으로 메모리 관리를 하게 된다. 그럼 이렇게 dll을 한번만 로딩한다고 했는데 그럼 dll에서 관리하는 data를 여러 프로세스가 참조하게 되는 문제점은 어떻게 해결할까.
간단한 예로 위에서 작성한 예제를 빌드하고 커맨드 라인에서 MyApp를 실행해 보자. 초기에 Dll data 값은 0이 되어 있으며 새로운 값을 입력하면 g_nData 값이 변경되는 것을 볼 수 있다. 자, 그럼 이때 커맨드 창을 열고 MyApp를 다시 실행시켜 보면 어떻게 될까. 이전 창에서 수정한 g_nData 값에 상관없이 Dll data 값은 0으로 나올 것이다. 서로 다른 프로세스에서 같은 dll을 로딩하였지만 dll 내부 데이타는 전혀 별개로 다루어지고 있음을 알 수 있다. 이것은 Win32메모리관리에서 설명한 copy-on-write 기법이 적용되고 있기 때문이다.

지금까지 설명한 방식은 dll을 사용하는 전형적인 방식인 explicit loading 기준으로 설명한 것이다.
explicit loading 방식은 dll을 로딩하고 external symbol에 대한 resolving이 모두 프로세스 초기화 단계에서 이루어지기 때문에 run-time 성능에는 영향을 미치지 않는다는 장점이 있다. 반면 프로세스 초기화 단계에서 부하가 많기 때문에 application의 load time이 느려질 수가 있다. 이를 위해 rebasebind라는 기법이 소개되어 있는데 이는 밑에서 다시 설명하도록 하겠다.

4.2 Explicit Loading 방식

4.2.1 DLL Loading

지금까지는 어떤 dll을 사용할지 미리 정해 놓고 프로그램 코드를 작성한 후 암묵적으로 운영체제가 필요한 dll을 자동으로 주소 공간에 매핑시키는 implicit loading에 대해서 공부해 보았다. 그러나 dll을 프로세스 주소 공간에 매핑시키는 방법은 실행모듈이 메모리에서 실행중일 때에도 원하는 때에 dll을 불러다 주소 공간에 로딩하여 특정 symbol을 명시적으로 link하는 방법이 있는데 이를 explicit loading 방식이라고 부른다.
이 방식의 장점은 실행중에 언제든 필요한 dll을 메모리에 로딩하였다가 다시 해제시킬 수 있다는 점이다. dll을 로딩할 때는 win32 API인 LoadLibrary()LoadLibraryEx()를 사용한다.

HINSTANCE LoadLibrary( 
  LPCTSTR lpLibFileName   // address of filename of executable module 
); 
HINSTANCE LoadLibraryEx( 
  LPCTSTR lpLibFileName,  // points to name of executable module 
  HANDLE hFile,           // reserved, must be NULL 
  DWORD dwFlags           // entry-point execution flag 
); 

LoadLibraryEx()함수에서 dwFlags에는 DONT_RESOLVE_DLL_REFERENCE, LOAD_LIBRARY_AS_DATAFILE, LOAD_WITH_ALTERED_SEARCH_PATH등 3개의 플래그가 단독, 조합해서 들어갈 수 있는데 간략히 말하면 DONT_RESOLVE_DLL_REFERENCE은 dll이 주소 공간에 매핑될 때 초기화하는 함수인 dll main이 호출되지 않게 하고 dll에서 import하는 다른 dll을 주소 공간에 매핑시키지 않도록 지시하는 플래그이며 LOAD_LIBRARY_AS_DATAFILE은 DONT_RESOLVE_DLL_REFERENCE 플래그의 기능에 dll의 내용을 data 파일인 것처럼 로딩하는 플래그이다. 함수 코드가 없이 주로 resource를 담아 넣은 dll을 로딩할 때 사용한다. 그리고 LOAD_WITH_ALTERED_SEARCH_PATH은 dll 파일을 검색하는 경로명을 조정하는 플래그로 자세한 내용은 MSDN 도움말을 참조하도록 한다.

위 함수들을 통해 dll이 메모리에 매핑되면 매핑된 base address 값이 HINSTANCE 타입으로 리턴된다. 이 HINSTANCE 값이 메모리에 매핑된 dll을 구별하는 값이다. dll이 메모리에 로딩되면 dll에서 export하는 함수를 자신의 모듈 내에 있는 함수처럼 호출할 수 있는데 이때 원하는 함수나 symbol의 주소 값을 얻어와서 사용해야 하며 주소 값을 가져오기 위해선 GetProcAddress() API를 사용한다.

FARPROC GetProcAddress( 
  HMODULE hModule,    // handle to DLL module 
  LPCSTR lpProcName   // name of function 
); 

4.2.2 Unloading

dll의 사용이 끝나면 프로세스 주소 공간에서 dll을 명시적으로 unloadin할 수가 있다.

BOOL FreeLibrary( 
  HMODULE hLibModule   // handle to loaded library module 
); 

엄밀히 말해 FreeLibrary() API는 dll을 무조건 프로세스 주소 공간에서 unmap시키는 것이 아니라 usage count를 1 감소시키는 것으로 만약 usage counter가 0이 되면 그때서야 비로소 dll 이미지를 프로세스 주소 공간으로부터 unmap한다. 프로세스내의 여러 쓰레드가 LoadLibrary()를 통해 같은 dll 이미지를 메모리에 매핑시킬 수 있다. 이때 프로세스 주소 공간에는 dll 이미지가 초기에 한번만 매핑이 이루어지고 이후에 호출되는 LoadLibrary()에 대해서는 usage counter를 증가시키기만 하는 것이다. dll의 usage counter는 per-process 기반 값으로 다른 프로세스에서 같은 dll을 매핑한다고 해서 자신의 usage counter가 증가하는 것은 아니다.
한 프로세스내에 여러 쓰레드가 같은 dll을 매핑하는 것은 그리 효율적인 코딩은 아니므로 dll을 매핑하기 전에 자신의 프로세스 공간에 이미 매핑되어 있는지 확인해 보고 dll을 로딩할 수 있다. 이때 사용하는 API가 GetModuleHandle()이다.

HINSTANCE hinstDll = GetModuleHandle("MyDll"); 
if (hinstDll == NULL) { 
   hinstDll = LoadLibrary("MyDll"); 
} 

5 내부 구조

5.1 Export

dll을 작성할 때 "__declspec(dllexport)" 변경자를 사용하여 symbol을 export하게 되면 compiler는 .obj 파일에 export하는 symbol에 대해 추가적인 정보를 삽입한다. 그리고 linker가 .obj 파일들을 통합할 때 compiler가 삽입한 정보를 읽어서 .lib 파일을 생성하게 된다. 이 .lib 파일은 dll이 export하는 symbol들의 목록이 나열된 텍스트 파일이다. 또한 linker는 이와 함께 export symbol들의 목록을 테이블의 형태로 dll의 이미지 파일에도 삽입하는데 이것이 바로 export section에 있는 export table이다. export table에는 export symbol들이 알파벳순으로 기록되고 각 export symbol의 RVA(dll 파일 이미지의 base로부터 상대 주소)값도 함께 기록된다.
Visual Studio에서 제공하는 유틸리티중 dumpbin 이라는 툴로 export section의 내용을 확인할 수 있다. 이때 -exports라는 옵션이 사용된다. 아래는 dumpbin을 이용해 위에서 작성했던 예제 MyDll.dll의 export 내용을 본 것이다.

D:\Progra~1\Micros~3\VC98\BIN\dumpbin -exports MyDll.dll 
File Type: DLL 
 
  Section contains the following exports for MyDll.dll 
 
           0 characteristics 
    41908CBD time date stamp Tue Nov 09 18:24:13 2004 
        0.00 version 
           1 ordinal base 
           2 number of functions 
           2 number of names 
 
    ordinal hint   RVA        name 
 
          1    0   00001010 ?GetData@@YAHXZ 
          2    1   00001020 ?SetData@@YAXH@Z 
 
  Summary 
 
        4000 .data 
        1000 .rdata 
        1000 .reloc 
        4000 .text 

내용을 살펴보면, MyDll.dll은 2개의 함수를 export하고 있다. ordinal은 윈도우즈의 초기 16비트 운영체제에서 외부 함수를 link할 때 사용하던 값으로 현재는 거의 사용하지 않으며 단지 하위 호환성(backward compatibility)을 위해 존재하는 값이다. 현재는 외부 함수는 모두 symbol name으로 link하는데 간혹 성능을 위해 ordinal 번호로 symbol을 link하는 개발자들도 있다. 왜냐하면 symbol name으로 link를 하고자 하면 스트링 비교 작업이 필요하기 때문인데 그럼에도 불구하고 ordinal 번호로 symbol을 link하는건 버전이 다른 플랫폼에서는 정상적인 실행을 보장할 수 없기 때문에 가능하면 피하는 것이 좋다.
hint 번호는 성능상의 목적을 위해 시스템 내부에서 사용하는 번호로 application 수준에서는 다룰 일이 없는 필드이다.
중요한 것은 name 필드 부분인데 함수 앞뒤에 이상한 기호들이 붙어 있는 것을 볼 수 있다. 이것은 Visual C++이 사용하는 이름 장식(name decoration), 혹은 name mangling이라고 부르는 것이다. symbol을 export하는 쪽이나 import하는 쪽이나 이 이름들이 일치해야 하는데 현재는 C++ 언어에서 name mangling에 대한 표준이 정해지지 않았기 때문에 compiler vendor별로 제각각의 규칙을 가지고 있다. 때문에 위에서 나열된 이름 장식은 MS Visual C++에서만 통용되는 값이므로 다른 회사의 compiler에서 작성한 application에서는 이 dll을 link할 수 없다. 물론 다른 회사에서 생성한 dll을 Visual C++의 응용에서 link할 수 없는 것도 마찬가지이다.

이렇게 다른 벤더의 compiler끼리 dll을 link할 수 있게 하려면 두 가지가 고려되어야 하는데 첫번째로 link하는 symbol들이 이름 장식을 쓰지 않는 C언어로 만들어져야 하며 두 번째는 link하는 함수의 calling convention이 일치해야 한다는 것이다.
첫번째의 경우에는 C++에서 dll을 작성할 때는 link하는 각 symbol들을 extern "C" 변경자로 선언해서 해당 symbol들의 이름 장식을 금지시켜야 한다. 그렇게 해서 dll을 빌드하고 dumpbin으로 export 내용을 확인해 보면 이름 장식이 없이 GetData, SetData라는 이름으로 export하고 있음을 확인할 수 있을 것이다. C++이 아닌 C compiler로 dll을 만들 때도 MS C compiler는 calling convention에 따라 이름 장식을 적용하는 경우가 있는데 이는 __stdcall 방식을 사용할 때가 바로 그렇다. __stdcall 방식은 win32 API의 기본 calling convention으로 __pascal 방식과 유사하게 인자 전달 방식이 오른쪽에서 왼쪽으로 스택에 저장되지만 리턴 시에 스택 정리는 callee 함수에서 처리하는 방식이다. 윈도우즈에서는 WINAPI로 선언된 함수들이 __stdcall 방식을 사용하는 함수들이다.

#define WINAPI __stdcall 

__stdcall 방식은 전달되는 인자가 가변 인자가 아닌 고정된 개수의 인자를 처리할 때 적합하며 __cdecl과는 달리 스택을 청소하는 코드가 callee에 한번만 들어가기 때문에 __cdecl 방식보다는 실행 파일 크기가 줄어드는 장점이 있다. 대신 __pascal 방식과는 달리 가변 인자의 함수의 경우 __stdcall로 선언되었다 하더라도 컴파일할 때 컴파일러에 의해 __cdecl로 자동으로 변경된다.
__stdcall에서 name mangling 규칙은 함수 이름앞에 underscore(_)를 붙이고 함수 이름 뒤에 @ 기호를 붙인 후 함수가 넘겨 받는 인자의 byte 크기를 추가한다.
위의 예제에서 두 함수를 다음과 같이 선언하고,

__declspec(dllexport) extern "C" void __stdcall PutData(void); 
__declspec(dllexport) extern "C" void __stdcall SetData(int data); 

이를 빌드해서 dumpbin으로 확인하면 다음과 같이 name mangling되어 있음을 확인할 수 있다.

    ordinal hint   RVA        name 
       1     0    00001010 _GetData@0 
       2     1    00001020 _SetData@4 

따라서 이런 name mangling을 피하려면, 작성하는 export 함수에 대해 __stdcall로 선언하는 것을 피해야 한다.

5.2 Import

어떤 dll에서 symbol을 import할 때 반드시 __declspec(dllimport) 변경자를 써야하는 것은 아니다. 그냥 간단히 extern "C" 키워드로 symbol을 선언해도 되지만 코드에서 참조하는 symbol이 다른 dll에서 import하는 것을 선언할 때 컴파일러에게 알려주면 좀더 효율적인 코드가 생산된다고 한다. 그런데 우리가 win32 API를 사용할 때 __declspec(dllimport) 변경자를 쓰지 않는 이유는 VC++ 컴파일러에서 자동으로 그 일을 해주기 때문이다.
linker가 실행 모듈을 빌드 할 때 import symbol에 대한 parsing 작업을 하게 되며 그 mport 정보를 실행 파일 이미지에 기록하는데 이를 import section이라는 것을 설명한 바 있다. import section에는 실행 모듈이 실행되는데 필요한 dll 목록과 import symbol이 저장되는데 그렇기 때문에 dll이 이름이 바뀌거나 export하는 symbol이 바뀌면 실행 모듈도 다시 빌드를 해줘야 한다.5
import section의 내용도 dumpbin 유틸을 사용해서 살펴볼 수 있다.

D:\MyApp\release\dumpbin -imports MyApp.exe 
Dump of file myapp.exe 
 
File Type: EXECUTABLE IMAGE 
 
  Section contains the following imports: 
 
    MyDll.dll 
                4160E4 Import Address Table 
                4188F0 Import Name Table 
                     0 time date stamp 
                     0 Index of first forwarder reference 
 
                   1  SetData 
                   0  GetData 
 
    KERNEL32.dll 
                416000 Import Address Table 
                41880C Import Name Table 
                     0 time date stamp 
                     0 Index of first forwarder reference 
 
                 152  GetStdHandle 
                 115  GetFileType 
                 11D  GetLocaleInfoW 
                 22F  RtlUnwind 
                       ,,,,,,,,,,, 
   Summary 
 
        6000 .data 
        3000 .rdata 
       15000 .text 

MyApp.exeMyDll.dllKernel32.dll 2개의 dll을 사용한다는 것을 알 수 있다. MyDll.dll에서는 2개의 함수를 import하고 있고 import 함수 옆에 있는 번호는 시스템이 내부적으로 사용하는 hint 번호이다.


주석
____
   1 이는 다시 말해 특정 프로세스에 dll을 삽입하게 되면 dll 내에서 프로세스의 유저 메모리 영역 모두를 접근할 수 있게 된다는 의미이다. 따라서 프로세스가 특정 API로 넘기는 인자들이나 리턴값을 가로챌 수 있는 것도 바로 이런 원리때문이다.
   2 dll 소스에서 함수나 변수를 하나라도 export하고 있다면 dll 소스 파일을 빌드할 때.lib 파일은 반드시 생성이 된다. 때문에 만일 dll 소스를 빌드했는데 lib 파일이 생성이 안되었다면 무엇인가 잘못된 것이다.
   3 VC++ 6.0에서는 Project -> Settings -> Object/library modules 항목에 .lib 파일명을 입력한다.
   4 윈도우즈를 사용하다 보면 dll의 위치를 찾지 못해 application의 실행이 안되거나 다른 버전의 dll들이 엉켜서 엉뚱한 dll이 로딩되는 문제들이 발생하곤 한다. 이런 것을 소위 "DLL Hell" 이라고 부른다.
   5 이렇게 dll이 바뀌면 실행 파일을 다시 빌드해야하는 것은 재사용성과 캡슐화에 큰 장애가 되었고 이후 COM이 출현한 계기가 된다.


windows프로그래밍지도