본문 바로가기

3.구현/C or C++

Thunk와 용법2 - 콜백함수를 멤버함수로 사용하기

이번에는 thunk의 두번째 내용이다. thunk의 자세한 정의는 "Thunk와 용법1 - 다중 상속에서 가상함수"에서 살펴보길 바란다.

여기서는 콜백함수를 멤버함수를 사용해서 콜백하는 것을 살펴보기로 하겠다. 제목만 봐서는 깜짝 놀랄 것이다.

구라치는 것이라고 생각할 수 도 있다. 물론 나도 그렇게 생각했었다. 어떻게 C함수로 콜백하는데 C++의 멤버함수를 호출할 수 있을까라고... 물론 C함수에 콜백 등록할 때에 객체 인스탄스를 넘겨주고, 콜백함 수 호출할때 해당 인스탄스를 넘겨준 다음에 해당 인스탄스를 적당한 클래스로 캐스팅해서 멤버함수를 호출하면 된다라고 하시는 분도 있을 것이다.

그러나, 여기서는 그런 복잡한 방법을 사용하지 않는다. 물론 더 복잡할 수 있지만... ㅡ.ㅡ;

운영체제 환경은 Windows로 하였다. 그래서 사용하는 어셈블러는 Windows 기반이다. 이는 해당 운영체제로 적당히 변경하면 된다. 그리고, 멤버함수에 대한 호출 지식이 필요하다. 해당 내용은 "Thunk와 용법1 - 다중 상속에서 가상함수"를 참고하기 바란다.

작성: http://ospace.tistory.com/,2010.12.27 (ospace114@empal.com)

콜백함수

먼저 간단한 콜백 함수 호출 방식을 정의하겠다. 이는 윈도우즈나 다른 시스템에 콜백기능을 사용할 수 있지만, 간단한 테스트하기에는 배보다 배꼽이 더 커지기 때문에 자체적인 콜백으로 구현하였다.

아래는 C 함수를 사용해서 콜백함수 호출을 테스트해보았다.

#include <stdlib.h>
// 콜백함수 호출하는 부분
void testCallback ( void ( TestAPI *proc ) ( int ) )
{
    ( *proc ) ( 10 );
}
// 사용자 정의 콜백 함수
void sampleProc ( int p1 )
{
    fprintf ( stdout, "called a sampleProc: %d\n", p1 );
}

int main ( int argc, char* argv[] )
{
    testCallback ( sampleProc ); // 사용자 정의한 sampleProc를 콜백
}

소스 코드는 간단하기 때문에 별다른 설명을 하지 않겠다. 전형적인 콜백이다. 실행결과는

called a sampleProc: 10
c:\_

Thunk사용해서 멤버함수로 콜백하기

C++에서 멤버함수 호출하는 경우 this 객체가 ecx 레지스트리로 넘겨지는 것을 알고 있을 것이다. 이점을 착안해서 thunk에서 이미 설정된 클래스 인스탄스를 ecx 레지스트리에 저장하고 멤버함수를 호출하면 원하는 멤버함수 호출을 할 수 있다. 그림으로 표현하면 다음과 같다.

THUNK 구조체

먼저 사용할 THUNK 구조체를 보자. 이 구조체에는 Thunk code가 저장되고, 이 code을 콜백 호출할 때에 실행하게 된다.[1]

struct THUNK
{
#pragma pack(push, 1)
    unsigned short lea_ecx;    // (1)lea ecx,
    unsigned long  pThis;      // (2)this
    unsigned char  mov_eax;    // (3)move eax,
    unsigned long  jumpProc;   // (4)proc function
    unsigned short jum_eax;    // (5)jmp eax
#pragma pack(pop)
};

이해하기 쉽게 하기 위해서 구조체의 구성요소를 어셈블리 코드를 차용했다. (2)와 (4) 부분이 우리가 원하는 값으로 저장하면 된다. (1)과 (2)가 하나의 명령으로 ecx에 this포인터 값을 저장하며, (3)과 (4)도 하나의 명령으로서 eax에 멤버함수 포인터가 저장된다. 그리고 (5)에 의해서 해당 멤버함수 코드가 있는 부분으로 점프해서 실행하게 된다. 그럼 이 값을 초기화하는 부분을 정의해보자.

typedef unsigned long JULONG;
struct THUNK
{
#pragma pack(push, 1)
    unsigned short lea_ecx;    // (1)lea ecx,
    unsigned long  pThis;      // (2)this
    unsigned char  mov_eax;    // (3)move eax,
    unsigned long  jumpProc;   // (4)proc function
    unsigned short jmp_eax;    // (5)jmp eax
#pragma pack(pop)

    void init(JULONG pThis, JULONG proc)
    {
        this->lea_ecx  = 0x0D8D;
        this->pThis    = pThis;
        this->mov_eax  = 0xB8;
        this->jumpProc = proc;
        this->jmp_eax  = 0xE0FF;
    }
};

각각 어셈블러 코드를 저장하고, 우리가 호출하고자 하는 클래스의 멤버함수도 저장하면 된다. 그리고 함수 호출하는 부분은 "call"아닌 "jmp"이다. 이는 멤버함수 호출이 완료된 후에 바로 콜백 위치로 돌아가기 위한 것이다. 여기까지 하면 거의 이해했으리라 본다. 사실 여기까지가 전부이다. 나머지는 이를 위한 부가 작업일 뿐이다.

사실 이 부가작업이 중요하다. 위의 코드만으로 바로 초기화하고 실행할 수 없기 때문이다.

SomeClass Class

멤버함수를 호출할 클래스를 정의해보다. 간단한 클래스를 사용했다. 그리고 this가 제대로 되는지 확인하기 위해서 멤버변수도 하나 추가했다.

#include <iostream>
//...
class SampleClass
{
private:
    char *name;
public:
    SampleClass() : name("SampleClass") {}
    void sampleProc(int p1) {
        std::cout << "called a " << this->name << "::sampleProc: " << p1 << "\n";
    }
};

위의 Thunk와 같이 사용해서 멤버함수 콜백하는 예를 살펴보면 다음과 같다.

int main(int argc, char* argv[])
{
    testCallback(sampleProc);

    SomeClass someClass;
    THUNK *sampleThunk = new THUNK();

    sampleThunk->init(&someClass, &SomeClass::sampleProc);
    testCallback(sampleThunk);
    return 0;
}

위의 코드를 실행해보면 init()호출에 인자 타입이 맞지 않아서 에러가 발생한다. 이부분을 수정해줘야 한다.

클래스 객체 캐스팅

위에 Init()의 인자를 보면 unsigned long 형이다. 클래스 인스탄스의 포인터는 reinterprete_cast로 캐스팅 가능하지만, 멤버함수는 불가능하다. 이때 강제로 변경이 필요하다. 이를 위한 brute_cast를 정의해보자.[1]

template<typename Target, typename Source>
inline Target brute_cast(const Source s)
{
    typedef int ERROR_Cannot_burst_cast[sizeof(Target)==sizeof(Source)?1:-1]; // 이는 캐스팅 불가능할 때 에러를 발생하기 위한 부분
    union { Target t; Source s; } u;
    u.s = s;
    return u.t;
}

사용법은 간단하다.

SomeClass *someClass = new SomeClass();
JULONG func = brute_cast&lt;JULONG&gt;(&amp;SomeClass::sampleProc);

이를 멤버함수 콜백에 적용해보자.

typedef void ( *CBTestProc ) ( int );
int main ( int argc, char* argv[] )
{
    //testCallback(sampleProc);

    SomeClass someClass;
    THUNK *sampleThunk = new THUNK();

    sampleThunk->init ( reinterpret_cast<JULONG> ( &someClass ), brute_cast<JULONG> ( &SomeClass::sampleProc ) );

    testCallback ( reinterpret_cast<CBTestProc> ( sampleThunk ) );
    delete sampleThunk;

    return 0;
}

이제 다되었다. 그럼 실행해보자.

엉? 이상한 에러가 발생한다.

Unhandled exception at 0x00074cf8 in test_callbackThunk.exe: 0xC0000005: Access violation.

이건 뭐다냥? 디버깅 해보니 내가 작성한 Thunk code 실행할에 발생하였다. 그때 문듯 스치는 생각이 들었다. 예전에 저장공간에 있는 데이터는 실행할 수 없다는 것이다. 즉, 단순 객체 생성을 해서 멤버변수는 데이터 공간에 생성되기 때문에 실행이 안되는 것이라는 생각이 들었다. 다시 참고 문헌을 찾아보고, 일일히 확인해 본 결과 해결책을 찾았다.

THUNK 객체 메모리 할당

메모리 할당 할때에 단순 new에 의해서 할당하면 멤버변수에 있는 코드가 생성될 수 없다. 당연히 THUNK 구조체에 있는 데이터는 실행이 안되는 것이다.

그럼 할당 할 때에 실행가능하게 할당하면 된다. 할당하는 함수는 윈도우 API를 사용하였다. HeapCreate()를 사용한 것과 VirtualAlloc()를 사용하는 방법이 있다. 사용 목적에 따라 적당히 선택하면 될 것이다. 해당 함수에 대한 자세한 설명은 MSDN를 참고하기 바라고, 여기서는 실제 사용한 것을 보겠다.

HeapCreate() 사용

void * volatile heap;
heap = HeapCreate(HEAP_CREATE_ENABLE_EXECUTE, 0, 0);
// heap의 NULL 체크
THUNK *sampleThunk = reinterpret_cast<THUNK*>(HeapAlloc(heap, 0, sizeof(THUNK)));
// sampleThunk의 NULL 체크
//...
//해제
HeapFree(heap, 0, sampleThunk);
HeapDestroy(heap);

VirtualAlloc() 사용

THUNK *sampleThunk = reinterpret_cast<THUNK*>(VirtualAlloc(NULL, sizeof(THUNK), MEM_COMMIT, PAGE_EXECUTE_READWRITE));
//...
//해제
VirtualFree(sampleThunk, 0, MEM_RELEASE);

이를 앞의 코드에 적용하면 다음과 같다.

int main(int argc, char* argv[])
{
    SomeClass someClass;
    void * volatile heap;
    heap = HeapCreate(HEAP_CREATE_ENABLE_EXECUTE, 0, 0);
    THUNK *sampleThunk = new THUNK();

    sampleThunk->init(reinterpret_cast<JULONG>(&someClass), brute_cast<JULONG>(&SomeClass::sampleProc));

    testCallback(reinterpret_cast<CBTestProc>(sampleThunk));

    HeapFree(heap, 0, sampleThunk);
    HeapDestroy(heap);

    return 0;
}

이제 드디어 실행할 수 있게 되었다. 그럼 실행해보자.

헉! OTL.... 이건 무슨 에러?

Run-Time Check Failure #0 - The value of ESP was not properly saved across a function call. This is usually a result of calling a function declared with one calling convention with a function pointer declared with a different calling convention.

정말 미치고 환장할 메시지이다. 차근차근 에러 메시지를 보면 calling convention이 달라서 발생한다고 한다. ㅡ.ㅡ;

이건 함수 호출 규약이 달라서 발생한 것이다. 왜 다를까? 내가 별루 한것이 없는데.. OTL

이거 원인 파악하고 정리하느라 또 시간이 걸렸다.

다음으로 넘어가기 전에 매번 thunk를 사용하기 위해서 메모리할당하는 코드를 넣는 것은 불편하다. 이를 조금 편하게하기 위해 new와 delete연산자를 재정의하는 방법이 있다.

struct THUNK
{
#pragma pack(push, 1)
    unsigned short lea_ecx;    // (1)lea ecx,
    unsigned long  pThis;      // (2)this
    unsigned char  mov_eax;    // (3)move eax,
    unsigned long  jumpProc;   // (4)proc function
    unsigned short jmp_eax;    // (5)jmp eax
#pragma pack(pop)

    THUNK()
            : lea_ecx ( 0 )   // move eax,
            , pThis ( 0 )
            , mov_eax ( 0 )   // move eax,
            , jumpProc ( 0 )
            , jmp_eax ( 0 ) // jmp eax
    { }

    void init ( JULONG pThis, JULONG proc )
    {
        this->lea_ecx  = 0x0D8D;
        this->pThis    = pThis;
        this->mov_eax  = 0xB8;
        this->jumpProc = proc;
        this->jmp_eax  = 0xE0FF;
        //FlushInstructionCache(GetCurrentProcess(), this, sizeof(THUNK));
    }

    void* operator new ( size_t ) {
        return ::VirtualAlloc ( NULL, sizeof ( THUNK ), MEM_COMMIT, PAGE_EXECUTE_READWRITE );
    }

    void operator delete ( void* p ) {
        ::VirtualFree ( p, 0, MEM_RELEASE );
    }
};

위 같을 경우에 main함수에서 사용이 간편해진다.

int main(int argc, char* argv[])
{
    SomeClass someClass;
    THUNK *sampleThunk = new THUNK();
    sampleThunk->init(reinterpret_cast<JULONG>(&someClass), brute_cast<JULONG>(&SomeClass::sampleProc));
    testCallback(reinterpret_cast<CBTestProc>(sampleThunk));
    delete sampleThunk;

    return 0;
}

사용이 좀더 편해졌다.

함수호출 규약 수정

마침 좋은 설명 자료가 있어서 이해하는데 도움이 되었다.[5] 함수 호출 규약에 대해서 자세한 설명은 건너 뒤도록 하겠다. 이는 참조[6]을 참고하기 바란다.

일단 간단히 정리하면 다음과 같다.

  • C 함수는 __stdcall 함수 호출 규약을 사용
  • C++ 멤버함수는 __thiscall 함수 호출 규약을 사용

VC에서는 별다른 호출 규약 선언이 없다면 C함수는 __cdecl로 되고, C++ 멤버함수는 __thiscall로 된다. 여기서 문제가 된다. __cdecl과 __thiscall 호출 규약이 서로 상이하다. 즉, this 객체 넘기는 방식이 틀리다. 앞에서 사용한 ecx를 통해서 넘기는 함수 호출 규약이 __fastcall과 __thiscall이다. __fastcall은 비호환적인 부분이 많아 여기서 사용하지 않는다. 그래서 __thiscall을 사용한다. 이와 호환을 위한 C함수 호출 규약이 필요하다. __stdcall과 __thiscall은 거의 같지만 this객체 처리만 틀리다. 이로 인해 C함수 정의할 때에는 __stdcall를 사용하는 것이 좋다.

가급적이면 모든 콜백함수는 __stdcall로 하는 것이 나중에 C++과 연계될때에 더 유리하다. 물론 자신 만의 규칙을 정해서 함수 호출 규약을 서로 잘 맞춘다면 굳이 이렇게는 할 필요가 없다. 자세한 내용은 "함수 호출 규약"이라는 문서를 참고하기 바란다.[6]

코드를 수정하면 다음과 같게 된다. 수정된 부분만 추려내었다.

void testCallback(void (__stdcall *proc)(int))
//...
void __stdcall sampleProc(int p1)
//...
typedef void (__stdcall *CBTestProc)(int);
//...

SomeClass에 있는 멤버함수는 굳이 수정할 필요는 없다. 이제 실행하면 된다.

에러 없이 잘 실행될 것이다.

결론

정말 쉽지 않은 내용이다. 처음에는 간단하구나. 쉽게 하겠는걸 했는데 예상 외의 복병이 있어서 생각보다 하루가 더 걸렸다. C와 C++간에 호환이 호락호락하지 않았다.

특이 이렇게 꼼수(?)를 사용했을 경우 정확한 이해없이 사용했다가는 나중에 에러 발생하면 엄청난 고생이 뒤따를 것이다.

관련 참고 자료가 많은 도움이 되었으니, 저의 글을 이해하기 힘들다면 참고[1],[2]를 보면 도움이 될 것이다. 제가 더 자세히 쉽게 설명했지만, 참고의 코드가 좀더 사용하기는 편리 할 것이다.

위의 코드는 이식 가능한 코드는 아니지만, 유용하게 사용할 수 있다고 본다. 윈도우 기본 환경이 C API로 되어 있기 때문에 간간히 콜백함수를 사용하게 된다. Thunk를 사용하여 C 콜백함수와 C++과의 부드러운 통합으로 더 낳은 C++ 환경을 제공하리 본다. 그리고, C++의 템플릿을 이용한다면, 캐스팅하는 회수를 줄임으로서 캐스팅 실수를 예방할 수 있을 것이다.

이식성이 떨어지기 때문에 이부분에 대해 명확히 집고 넘어가야하는 것이 있다.

모두 즐프하길 바란다.ospace.

참고

[1] John TWC, 2008, "Thunks and its uses", http://www.codeproject.com/KB/cpp/Thunk_uses.aspx

[2] einaros, 2006, "Thunking in Win32: Simplifying Callbacks to Non-static Member Functions", http://www.codeproject.com/KB/cpp/thunk32.aspx

[3] 양희재, "EH204 시스템 프로그래밍", http://conet.ks.ac.kr/~hjyang/crs/sp/

[4] 박재성, "Thunk와 용법1 - 다중 상속에서 가상함수"

[5] binoopang with Jiny, 2009, "WINAPI 키워드는 머임??", http://binoopang.tistory.com/tag/WINAPI

[6] 박재성, "함수호출 규약"

반응형

'3.구현 > C or C++' 카테고리의 다른 글

함수호출 규약  (2) 2011.01.12
Thunk와 용법3 - 인터페이스 프록시  (0) 2011.01.12
Thunk와 용법1 - 다중 상속에서 가상함수  (2) 2011.01.12
가상함수 구조  (0) 2010.12.07
C에서 C++ 호출하기  (0) 2010.11.30