본문 바로가기

3.구현/C or C++

Thunk와 용법1 - 다중 상속에서 가상함수

thunk를 들은 것은 최근이다. 물론 전에 들었을지는 몰라도 그때는 그냥 넘어갔을 것이다. 최근 C++용 Delegates[1]를 보면서 다중 상속에서 가상 함수 호출이란 부분을 보면 이해하기 어려운 부분 있었다. 바로 그곳에 thunk라는 키워드가 있었다. 해당 내용은 각 컴파일러에서 가상함수 호출하는 방법에 대해 설명한 것인데 너무 간단히 설명만 해 놓아서 이애하는데는 너무 어려웠다.

그럼 thunk를 살펴보자.

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

Thunk?

thunk라는 용어부터 정의해보자.

thunk라는 것은 C++에서만 사용하는 것은 아니었다. thunk의 용법을 모두 3가지로 구분하고 있다.[2]

  • 지연된 계산을 수행하기 위한 코드 조각
  • 가상 함수 구현에 사용되는 생성 코드
  • 하나의 특정 시스템에서 다른 곳으로 머신 데이터를 매핑, 호환성 문제로 인함.

다른 곳에서는 thunk을 다음과 같이 구분하고 있다.[3]

  • 클래스 멤버함수로 콜백하기
  • C++의 다중상속에서 가상함수 지원
  • 인터페이스 프록시 제공

재미있는 구분이다. 각자 판단해서 정리하면 될 것 같다.

다중 상속에서 가상함수 구현

C++에서 다중 상속은 간단한 부분이 아니다. 물론 사용자는 간단하게 쓰지만, 컴파일러를 구현하는 개발자는 간단한 문제가 아니기 때문이다. 여기서는 그 개발자의 관점에서 접근하는 것이다.

일단 가상함수에 대한 기본 구조는 알고 있다는 전제 하에 진행하겠다.

단일 상속인 경우는 문제가 간단하다. 그러나 다중 상속인 경우는 조금 복잡해진다. 그냥 함수 포인터를 쓰면 안되는가라고 생각하지만, C++에서는 함수 포인터 외에 내부 this 포인터도 고려해야하기 때문이다. 그리고 함수 포인터도 가상함수인 경우는 쉽게 사용하기는 힘들다.

class Base1 {
private:
    int valBase1;
    char* name1;
public:
    Base1() : valBase1(1), name1("Base1") {}
    virtual int getValue() {
        return this->valBase1;
    }
    virtual char* getName() {
        return this->name1;
    }
};

class Base2 {
private:
    int valBase2;
    char* name2;
public:
    Base2() : valBase2(2), name2("Base2") {}
    virtual int getValue() {
        return this->valBase2;
    }
    virtual char* getName() {
        return this->name;
    }
};
class Derived : public Base1, public Base2 {
private:
    int valDerived;
public:
    Derived() : valDerived(3) {}
    virtual int getValue() {
        return this->valDerived;
    }
};

int main()
{
    Derived *d = new Derived();
    Base1 *b1 = (Base1*)d;
    std::cout << b1->getName() << ": " << b1->getValue() << "\n";
    Base2 *b2 = (Base2*)d;
    std::cout << b2->getName() << ": " << b2->getValue() << "\n";
}

다음 그림은 다중 상속를 가지는 클래스 구조이다.

실행결과는 다음과 같다.

Base1: 3
Base2: 3

Derived에서 getName 함수에 대한 구현이 없기에 부모 클래스의 getName함수를 호출하게 된다. getValue함수는 Derived에 있기 때문에 당연히 3값이 나오게 된다. 이는 C++을 배우면 너무 쉬운 결과이다. 당연히 이렇게 나온다고 생각한다. 그러나, 내부적 구현을 보면 쉽지는 않다.

재미 있는 것은 Base1으로 캐스팅한 b1의 포인터 주소가 0x002b4fe0이라면 b2의 포인터 주소는 0x002b4fec가 되었다. 정확히 Base1 크기 만큼 이동하였다. 이 주소를 사용해서 가상함수 테이블 위치를 확인하고 처리하기 위한 것이다. 즉, 이전에 가상함수 처리 로직을 그대로 사용하기 위한 트릭이라고 할 수 있다.

특히 Derived에서 Base2로 캐팅한 인스탄스에 대한 getName 함수 호출하는 부분이 복잡하다. 실제 그림으로 이해해보자.

위의 클래스 구조에서 가상함수 호출 형태를 보면 다음과 같다.

위의 그림은 실제 구현된 객체의 메모리 구조를 그린 것이다. 그림에서 보듯이 각 가상함수 테이블의 멤버함수는 함수 구현 위치로 연결되어 있다. 그러나 Derived에서 Base2 인스탄스에서의 getName 함수 호출 방식은 틀리다.

예제에서도 보았지만, 이런 차이를 구분하는 것은 Base2로 캐스팅하면서 this 포인터 위치도 변경하기에 가능한 부분이다. this 포인터 위치 이동으로 다른 vtable을 사용하게 된다.

Base1으로 캐스팅한 인스탄스 인경우 Derived의 this 포인터 위치와 Base1의 this 포인터 위치가 동일하기에 그냥 사용할 수 있다. 즉, Base1의 가상함수를 호출할 때에도 Derived의 this 포인터를 사용해도 같은 위치이기 때문에 Base1의 가상함수를 호출해도 this 포인터 위치 차이이 없이 사용할 수 있다.

그러나 Base2로 캐스팅한 인스탄스인 경우 this 포인터 주소가 변경되었다. 그래서 Derived의 this 포인터를 그대로 사용할 수 없게 된다. 바로 이 this 포인터 값을 Base2에 맞게 변경이 불가피하다. 가상함수 테이블은 단순 함수 포인터 저장만 하기 때문에 this 포인터 수정은 불가능하다. 별도 함수 구현을 만들어서 this 포인터 수정을 해야한다.

"바로 이런 함수를 thunk라고 한다." 이런 코드는 프로그래머가 만드는 것이 아니라 컴파일러에서 자동으로 생성되기 때문에 구현에는 신경쓸 필요는 없다.

위의 thunk에 대한 자세한 구현을 알려면, 어셈블러 코드를 살펴볼 수 밖에 없다.

멤버함수 호출하는 자세한 구조

이 부분은 C++에서 멤버함수 호출하는 구조를 어셈블리 통해서 살펴보기 위한 부분이다. 앞으로 살펴볼 어셈블러 코드는 Visual Studio에서 확인한 내용이라 gcc에서는 다를 수 있다.

일단 C++에서 멤버함수 호출에는 this 포인터라는 특수한 값이 인자로 넘어가게 된다. 일반적인 함수 호출을 하면 범위가 함수 내의 지역 변수, 넘겨 받은 파라미터, 그리고 전역 변수 정도이다. C++인 경우는 추가로 멤버함수가 속한 멤버변수까지 범위가 넓어지게 된다. 이를 위해 특수한 this 포인터가 사용되게 된다.

Foo f;                                      // 생성자 호출.  ecx에 f에 포인터를 저장하네요
00BD13FE  lea         ecx,[f]                   // lea은 mov와 같지만, 다른 점은 추가 연산이 가능하다는 점(ex. lea ecx, [f+1000])
00BD1401  call        Foo::Foo (0BD10C8h)
    int val = f.getValue();                     // f 인스탄스의 가상함수 호출
00BD1406  lea         ecx,[f]                   // ecx에 f 인스탄스 포인터 저장
00BD1409  call        Foo::getValue (0BD11D1h)  // 함수 호출
00BD140E  mov         dword ptr [val],eax       // 결과를 val에 저장

다음은 실제 Foo의 getValue 멤버함수가 실행되는 부분입니다.

Foo::getValue:
00BA11D1  jmp         Foo::getValue (009114C0)

다시 실제 원하는 함수 구현으로 이동하게 된다. 이 코드는 실행할 때마다 매번 변경되는 것으로 봐서 실제 실행 코드 위치를 예측하지 못하게 하는 것 같다. 아마 보안 이슈로 인한 부분인 듯 하다. 아무튼 계속 추적을 하면,

virtual int getValue() {
009114C0  push        ebp
009114C1  mov         ebp,esp
009114C3  sub         esp,0CCh
009114C9  push        ebx
009114CA  push        esi
009114CB  push        edi
009114CC  push        ecx
009114CD  lea         edi,[ebp-0CCh]
009114D3  mov         ecx,33h
009114D8  mov         eax,0CCCCCCCCh
009114DD  rep stos    dword ptr es:[edi]
009114DF  pop         ecx
009114E0  mov         dword ptr [ebp-8],ecx
        return this->value;
009114E3  mov         eax,dword ptr [this]
009114E6  mov         eax,dword ptr [eax+4]
}

ecx 값을 ptr [ebp-8] 저장하여 this 포인터 사용 중비를 완료했습니다.

중요한 것은 this 포인터 값이 ecx 저장되어 함수 호출할 때에 넘겨지게 된다는 점이다.

추가로 함수 호출할 때에 인자 값은 스택에 저장이 되겠죠. 물론 이 부분도 함수 호출 규약에 따라서 순서가 달라집니다.

다중 인스탄스 호출에서 Thunk의 자세한 구현

앞의 내용을 잘 이해했다면, 이 부분은 어렵지 않다. 다중 상속에서 호출할때에 thunk라는 생성된 함수만 고려하면 된다. 앞의 예제에서 고려할 부분이 Derived 인스탄스를 Base2로 캐스팅해서 호출하는 경우이다.

Derived *d = new Derived();
//...
Base2 *b2 = (Base2*)d;
std::cout << b2->getName() << ": " << b2->getValue() << "\n";
-        __vfptr    0x012d783c const Derived::`vftable'{for `Base2'}    *
        [0]    0x012d10e1 [thunk]:Derived::getValue`adjustor{12}' (void)    *
        [1]    0x012d10d7 Base2::getName(void)    *
[thunk]:Derived::getValue`adjustor{12}':
012D2530  sub         ecx,0Ch
012D2533  jmp         Derived::getValue (12D115Eh)

Derived 인스탄스 값은 0x00494ee0이고 Base2 인스탄스 값은 0x00494eec이다. 즉, Base1 크기(12byte)만큼 이동한 값이 b2에 저장된다. 이렇게 이동한 이유는 Base2에 대한 가상함수 데이블 위치와 인스탄스 위치를 일치하기 위해서이다. 이때 Base2 인스탄스에 대한 가상함수 테이블을 살펴보면 다음과 같다. 물론 VisualStudio로 확인한 내용이다.

위의 가상함수 테이블에서 첫번째 항목이 thunk에 해당하는 부분이다. 해당 포인터 위치에 우리가 원하는 thunk가 들어 있다. 두번째 항목은 Base2에 있는 멤버함수를 바로 사용하기 때문에 thunk를 사용하고 있지 않다.

Derived *derived = (Derived*) base2;
return derived->getValue();

다시 설명하면, 일반적인 멤버 함수 호출 순서는

멤버함수 호출 --> 가상함수 검색하여 멤버함수 포인터 획득 --> 실제 멤버함수 구현 실행

다중 구현에서 앞의 Base2의 getValue 멤버함수 호출 순서는

멤버함수 호출 --> 가상함수 검색하여 thunk 포인터 획득 --> thunk 실행 --> 실제 멤버함수 구현 실행

위에서 멤버함수 포인터 획득과 thunk 포인터 획득을 구분했지만, 같은 포인터를 획득하고 호출하는 과정이다. 중간에 "thunk 실행"이 추가되었을 뿐이다. 그럼 어떤 코드가 thunk에 있는지 살펴보자.

thunk 코드를 보면 단순하다. 단지 ecx 값만 변경할 뿐이다. ecx는 this 값이 들어 있는 레지스트라고 앞에서 말했었다. 그리고 "0Ch"값을 보면 이를 10진수로 바꾸면 "12"가 된다. 이는 앞에서 보았던 Base1의 크기이다.
그리고, ecx 값은 0x00484eec이다. 이 값은 Base2 인스탄스 값이다. 여기에서 0Ch를 빼는 것은 다시 Derived 인스탄스 위치로 이동한다는 의미이다. 그래서 Derived의 getValue 멤버함수가 호출되면서 제대로된 Derived 인스탄스에 대한 this 값이 넘겨지게 된다.
어떻게 보면 위의 코드를 C++코드로 변경하면 다음과 같을 것이다.

위의 어셈블러 코드만으로는 return인지 여부는 판단할 수 없지만, 앞에 예제 코드에서는 return이 있기 때문에 return을 붙인 것이다. 어셉블러에서 return 부분은 물론 호출한 함수에서 특정 영역에 리턴 값을 저장하는지 여부를 판단해야 한다.

나머지는 일반 멤버함수 호출과 동일하다. 이렇게 thunk를 추가함으로써 기존 가상함수 처리 방식에 큰 변화없이 다중 인스탄스에 대한 처리를 할 수 있게 되었다. 여기서는 thunk가 패턴으로 보면 proxy 패턴처럼 보인다.

결론

여기까지 C++에서 다중 상속에서 가상함수 처리에서 사용된 thunk를 살펴보았다. 실제 코드를 살펴보니 C++에서 외 가상함수 또는 다중 인스탄스를 사용하지 말라고 했는지 이해가 확실히 되는 것이다. 그리고 내부 멤버함수 호출 방식을 알게되니 어떤 식으로 포인터를 관리할지를 좀더 잘 알 수 있는 계기가 되었다.

그리고 , 어셈블러를 좀더 공부해야될 필요성도 느꼈다.

아직은 C++코드에서 어셈블리 코드로 변화하는 과정은 정확히 이해하지 못했다. 그리고, 최적화 과정을 거치면서 어떻게 어셈블리 코드가 변할지도 확인하지 못했다. 이런 부분은 앞으로 확인할 필요성이 있다.

그리고 GCC등 다른 컴파일러에서는 어떻게 되었는지도 확인하지 못했다. 정말 해야할 일이 많은 것 같다.

일단 이 것이 시작되어서 더 많은 지식을 쌓아가는 한 발자국이 되었기를 바란다. 그리고 다른 thunk에 대한 적용도 계속 다룰려고 한다. 모두 즐프하세요~(ospace)

참조

[1] Don Clugston, 2005, Member Function Pointers and the Fastest Possible C++ Delegate
[2] Thunk, wikipedia, http://en.wikipedia.org/wiki/Thunk
[3] John TWC, 2008, thunk and its uses

반응형