블로그 이미지
대갈장군

calendar

1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30

Notice

2013. 9. 24. 05:18 프로그래밍/MSDN

.NET Framework는 Platform Invoke (줄여서 PInvoke)라고 불리는 Managed Application에서 Unmanaged 함수를 호출할 수 있도록 해주는 기능을 Dllimport 속성을 이용해서 지원한다. 이 PInvoke는 두 가지 형태가 있는데 첫번째는 Explicit (명시적) 호출 방법이고 두 번째는 Implicit (암묵적) 호출 방법이다. 오늘 알아볼 녀석은 Explicit (명시적) 호출 방법이다.


DllImportAttribute를 사용해서 PInvoke를 수행하는데 이 녀석은 각각의 DLL 진입점에서 함수 선언 직전에 위치함으로 여기서 부터는 이 Unmanaged DLL을 사용하겠다고 명시하는 것이다. 


Unmanaged DLL 함수는 Managed 코드로 융합되기 위해서 몇가지 추가 코드와 간단한 데이터 변환을 추가로 가지게 되면 이를 통해서 Managed 코드는 Unmanaged DLL의 함수로 접근이 가능하다. PInvoke는 /clr, /clr:pure, /clr:safe 세 종류의 스위치에 대해서 모두 사용 가능하다. 소 파워풀!


Platform invoke


위의 그림을 보면 PInvoke에 대한 설명이 잘 나와 있는 것 같다. Managed코드가 컴파일러를 통과해서 CLR 서포트를 받는 어셈블리로 생성이 되면 메타 데이터와 IL (MSIL) 코드를 가지게 된다. 그리고 이런 Managed 코드 내부에서 만약 PInvoke가 호출되게 되면 Standard Marshalling Service를 통해 중간 과정에 필요한 변환을 거쳐서 Unmanaged DLL 함수를 호출하게 된다. 


이 PInvoke는 사실 CLR (Common Language Runtime)이 제공하는 서비스이다. 이 서비스를 통해서 Managed Code가 Unmanaged DLL 함수를 호출 할 수 있도록 해준다. 이러한 형태의 Marshalling, 마셜링 (컨테이너를 수송하기 위해 부두 내에서 이동, 정렬 하여 잠시 대기한다는 의미의 전문 용어) 서비스는 이미 Runtime과 COM의 호환 작용 및 "It Just Works" (IJW)를 위해서도 이미 사용된 적 있는 메커니즘이다. 


마셜링에 대해서 좀 더 자세히 살펴 보고 싶지만 일단 넘어가고, PInvoke를 실제적으로 어떻게 사용하는지 살펴보자.


다음의 코드는 PInvoke를 사용하는 예를 보여주는데 puts라고 불리는 native function은 msvcrt.dll에 정의되어 있다. 즉, puts가 바로 Unmanaged function이고 우리는 그것을 managed code에서 불러다가 사용하려고 하는 것이다. 


// platform_invocation_services.cpp
// compile with: /clr
using namespace System;
using namespace System::Runtime::InteropServices;

[DllImport("msvcrt", CharSet=CharSet::Ansi)]
extern "C" int puts(String ^);

int main() {
   String ^ pStr = "Hello World!";
   puts(pStr);
}

위의 코드를 보아하니 메인 함수 진입점 직전에 DllImport 속성을 이용해서 msvcrt DLL 파일을 연결하고있다. 그리고 바로 다음 줄에 보니 puts 함수를 외부에 존재하는 함수라고 명시적으로 알려주고 있다. 그리고 내부 코드에서 puts를 사용해서 출력을 하고 있다.


위와 같은 역활을 하는 코드를 IJW로 작성해 보면 다음과 같다.


// platform_invocation_services_2.cpp
// compile with: /clr
using namespace System;
using namespace System::Runtime::InteropServices;

#include <stdio.h>

int main() {
   String ^ pStr = "Hello World!";
   char* pChars = (char*)Marshal::StringToHGlobalAnsi(pStr).ToPointer(); 
   puts(pChars);
   
   Marshal::FreeHGlobal((IntPtr)pChars);
}

IJW를 보아하니 명시적으로 Native DLL을 임포트 하라는 DllImport 속성을 사용하지 않았지만 간단하게 puts() 함수를 선언하고 있는 stdio.h 헤더를 선언하고 메인 함수 내부에서 마샬링 서비스를 직접적으로 호출해서 라이브로 변환한 다음 사용된 힙 메모리를 직접 해제 하는 것 같다. 불행히도 MSDN에서는 자세하게 위 코드에 대해서 설명하지 않는다. 아마도 IJW에 대한 페이지를 살펴봐야 정확한 의미를 알 수 있을 것 같다.


IJW를 사용하는 장점은 몇가지가 있는데 다음과 같다. 일단, DLL을 명시적으로 DllImport 속성을 이용해 연결할 필요 없고 그냥 헤더 파일이랑 라이브러리를 임포트 하면 장땡이다. 뭐, 그닥 장점처럼 안보이긴 하는데... 어쨌든.. 


IJW 방법을 사용하면 아주 약간 더 빠르다. 왜냐면 프로그래머가 명시적으로 어떤 타입의 어떤 포인터로 접근하라고 명시적으로 알려주기 때문이다. 


마찬가지로 같은 맥락에서 퍼포먼스에 대한 그림이 쉽게 그려진다는 것이 IJW의 또 다른 장점이다. 위의 코드를 예로 보자면 Unicode에서 ANSI로 변환이 필요하다는 것과 그 과정에서 추가적인 메모리 할당과 해제가 필요함이 확연히 드러나게 된다. 사실 위 경우에는 _putws와 PtrToStringChars를 사용하는 것이 퍼포먼스 면에서는 더 유리하다. 


마지막으로 같은 데이터에 대해서 매번 마샬링을 호출하기보다는 한번 호출해서 메모리를 복사해두고 복사된 메모리를 사용하면 성능향상에 매우 도움이 된다. DllImport를 사용하면 같은 데이터라도 매번 오버헤드가 발생하는 변환을 해야 한다.


자, 이제 안 좋은 점에 대해서 알아보자. 장점에서 언급한 것들의 정 반대가 단점이다. ㅋㅋ


우선 마샬링이 아주 아주 명시적이므로 코드를 아주 정확하게 적성해야만 작동한다. DllImport를 사용하면 이러한 복잡한 과정을 생략할 수 있다. 위 두 코드를 딱 봐도 DllImport가 훠얼씬 더 간단하다. 


IJW의 마샬링은 죄다 Inline 함수다. 고로 어플리케이션의 흐름에 지장을 줄수도 있다. 


마지막으로 명시적인 마샬링은 IntPtr 타입을 리턴하므로 반드시 ToPointer 함수를 사용해야 한다. 이게 왜 단점일까 라고 잠시 생각했다. 생각해보니 이것은 데이터 타입을 void로 바꾸는 것과 같다. 고로 프로그래머가 정확한 타입을 명시해 주어야 한다. 위 코드에서처럼 (char*)타입으로 명시적으로 캐스팅 하는 과정이 추가로 필요하다는 말. 


고로 위의 이야기를 요약하자면 IJW를 사용한 것이 더 효율적이 효과적인 프로그래밍이 가능하지만 만약 내가 작성하는 코드가 가끔씩 Unmanaged API를 호출하는 정도라면 둘 중 어떤 것을 사용해도 무방하다.


그 다음으로 MSDN에서 설명하고 있는 것이 PInvoke를 Windows API와 사용하는 것인데 일단 코드를 보자.



// platform_invocation_services_4.cpp
// compile with: /clr /c
using namespace System;
using namespace System::Runtime::InteropServices;
typedef void* HWND;
[DllImport("user32", CharSet=CharSet::Ansi)]
extern "C" int MessageBox(HWND hWnd, String ^ pText, String ^ pCaption, unsigned int uType);

int main() {
   String ^ pText = "Hello World! ";
   String ^ pCaption = "PInvoke Test";
   MessageBox(0, pText, pCaption, 0);
}

사실 Managed 코드라면 얼마든지 MessageBox를 잘 불러다 사용할 수 있지만 이건 예를 들기 위해 이렇게 한거다. user32 DLL을 DllImport를 사용해 명시적으로 연결해 주고 메인 함수 내부에서 실제적으로 사용하고 있다. 아하, 두번째 인자에 대한 비밀이 풀렸다. 위의 예제들에서 보면 DllImport 속성자 다음에 오는 첫번째 인자는 DLL 파일의 이름이라는 것은 알았을테고 두번째 인자에 대해서 저건 왜 필요하지에 대해서 생각했었는데 바로 해답은 아래와 같다.


실제로 user32.dll 파일 안에는 MessageBox라는 함수는 없다. 왜냐면 user32.dll에는 MessageBoxA (ANSI 타입)과 MessageBoxW (Unicode 타입) 둘 만 존재한다. 그래서 저 CharSet = CharSet::Ansi 인자를 통해서 MessageBoxA를 사용하라고 알려주는 것이다. 재미 있는 점은 되도록이면 Unicode 버전을 쓰라는 건데 왜 위의 모든 예제 코드에서는 죄다 Ansi를 쓰는지 모르겠네. 된다는 걸 보여주기 위해서 인가? Unicode를 사용하면 오버헤드가 더 줄어든단다. 


중요한 게 한가지 나오는데 언제 PInvoke를 사용하면 안되느냐 하는 것이다.


예를 들어 다음과 같은 함수를 Unmanaged code로 작성해서 Dll로 만들었다고 치자. 

char * MakeSpecial(char * pszString);


그렇다면 이 함수를 불러다 쓰려면 다음과 같은 형태를 취해야 한다.

[DllImport("mylib")]

extern "C" String * MakeSpecial([MarshalAs(UnmanagedType::LPStr)] String ^);

이 함수 호출의 문제점은 MakeSpecial로 호출되어 리턴되어지는 메모리를 해제 할 수가 없다는 점이다. PInvoke를 통해서 리턴된 포인터는 사용자가 해제할수 없는 내부 메모리로 저장이 되기 때문이란다. 고로 이런 경우에는 반드시 IJW를 써야 한다. 


PInvoke의 한계점은 입력으로 들어오는 변수를 출력으로 리턴할 수 없다는 데 있다. 입력으로 들어가는 변수가 PInvoke에 의해서 마샬링을 거치게 되면 이 변수는 리턴될 때 메모리 커럽션 에러가 발생한다. 방금 위에서 말한 것과 거의 같은 이유인것 같다.


__declspec(dllexport)
char* fstringA(char* param) 
{
   return param;
}

또 다른 간단한 예를 보자면 아래 코드와 같은 user32.dll에 있는 CharLower이라는 함수를 호출해서 사용하는 경우인데 이 함수도 입력으로 들어오는 입력 변수의 메모리 공간을 사용해서 다시 출력으로 돌려 보내는 함수이다. 고로 이렇게 하게 되면 입력으로는 제대로 된 변수와 메모리가 들어가지만 출력으로 돌아오는 변수와 메모리는 해제되어 버린 값이 된다. 간단하게 생각하면 PInvoke를 통해서 호출된 함수는 리턴하는 즉시 해제되어 버린다고 보면 되는건가? 변수와 메모리를 유지하는 일반적인 범위에서는 일어날 수 없는 일이지만 PInvoke는 마샬링 서비스를 통과하기 때문에 잠깐 동안 함수의 범위에서 벗어났다가 들어오기 때문인가..


// platform_invocation_services_5.cpp // compile with: /clr /c using namespace System; using namespace System::Runtime::InteropServices; #include <limits.h> ref struct MyPInvokeWrap { public: [ DllImport("user32.dll", EntryPoint = "CharLower", CharSet = CharSet::Ansi) ] static String^ CharLower([In, Out] String ^); }; int main() { String ^ strout = "AabCc"; Console::WriteLine(strout); strout = MyPInvokeWrap::CharLower(strout); Console::WriteLine(strout); }


PInvoke를 쓰더라도 같은 형태의 데이터 타입에 대해서는 마샬링이 필요가 없는데 예를 들자면 int 타입과 Int32 타입이다. 하지만 같은 형태의 데이터 타입이 아닌 경우에는 당연히 마샬링이 필요하다. 아래 테이블을 보면 다양항 형태의 타입에 대해서 어떤 마샬링이 맵핑 되는지 보여준다. 젠장 테이블이 안맞아서 걍 링크를 건다. http://msdn.microsoft.com/en-us/library/ms235282.aspx


마샬링을 수행하는 '마샬러' (젠장, 한국어로 바꾸려니 말 참 이상해지네..)는 Unmanaged 함수로 넘어가는 변수의 주소를 자동적으로 런타임 힙 메모리 공간에 Pin (꼳아 놓는다) 시켜 놓는다. 즉, 홀딩 시킨다. 이렇게 해야 가비지 컬렉터가 이 메모리 공간을 비워버리지 않는다. 


위에서 본 예제에서 CharSet이라는 두번째 변수를 이용해서 DllImport 속성자에 명시했던 것을 기억하는가? 이것이 바로 Managed String이 어떻게 마샬링 되어야 하는 가를 알려주는 단서다. 위의 예제에서는 Native 함수가 ANSI로 되어 있다고 알려주었다. 


이러한 마샬링에 필요한 정보를 각각의 Native 함수의 입력 변수마다 정의 할 수 있다. String * 입력 변수에 대해서 마샬링 할 수 있는 것들로는: BStr, ANSIBStr, TBStr, LPStr, LPTStr. 이 있고 기본 타입은 LPStr 이다. 


아래의 예제 코드를 보면 string이 2 바이트 유니코드 char string (LPWStr)으로 마샬링 하는 것을 보여주고 있다. 출력으로 나오는 놈은 문장 전체가 아니라 Hello World!의 첫 글자만 나온다. 왜냐면 마샬링된 문자열의 두번째 바이트가 null이므로 문장 종료로 인식되기 때문이다. 고로, 어떤 마샬링을 해야하는지 알려주는 과정이 굉장히 중요하고 또한 프로그래머의 책임이 뒤따른다.


// platform_invocation_services_3.cpp
// compile with: /clr
using namespace System;
using namespace System::Runtime::InteropServices;

[DllImport("msvcrt", EntryPoint="puts")]
extern "C" int puts([MarshalAs(UnmanagedType::LPWStr)] String ^);

int main() {
   String ^ pStr = "Hello World!";
   puts(pStr);
}

PInvoke는 x86 에서 실행시 각 호출마다 약 10 에서 30 의 오버헤드가 발생한다. 그리고 추가로 마샬링을 수행함으로써 또 다른 오버헤드가 발생한다. 다만 같은 형태의 타입에 대해서는 마샬링 오버헤드가 없다. 


고로 보다 나은 퍼포먼스를 위해서는 보다 적은 PInvoke를 사용하고 동시에 많은 데이터를 마샬링 하도록 하는 것이 많은 수의 PInvoke를 사용하며 동시에 적은 데이터를 마샬링 하는 것보다 더 빠르다.






'프로그래밍 > MSDN' 카테고리의 다른 글

ClickOnce를 이용한 윈도우 폼의 배포  (3) 2013.09.27
Implicit Platform Invoke  (0) 2013.09.24
Mixed, Pure, Verifiable  (0) 2013.09.20
[MSDN] .NET Framework 구조  (4) 2013.05.03
Pure C++: Hello, C++/CLI  (0) 2013.04.25
posted by 대갈장군