Microsoft MVP성태의 닷넷 이야기
VC++: 111. C++ 클래스의 상속에 따른 메모리 구조 [링크 복사], [링크+제목 복사]
조회: 22115
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

C++ 클래스의 상속에 따른 메모리 구조


1. 비어 있는 단일 클래스

우선, 빈 클래스를 하나 생성해 볼까요?

class Empty
{
};

void main()
{
    Empty inst;
    printf("sizeof(inst) == %d\n", sizeof(inst));
}

/*
출력 결과

sizeof(inst) == 1
*/

놀랍게도 출력 결과는 1입니다. 검색을 해보니, 표준에 의해 객체의 크기를 0으로 반환하지 못하고 최소 1을 반환한다고 합니다. "http://stackoverflow.com/questions/2362097/why-is-the-size-of-an-empty-class-in-c-not-zero" 글을 보면 이에 대한 2가지 이유가 나옵니다. 첫 번째로는,

Empty *pa = new Empty();
Empty *pb = new Empty();

위와 같은 경우, 만약 빈 클래스가 0 바이트 메모리를 할당한다면 heap의 사용 증가가 없기 때문에 pa, pb 모두 같은 주소를 반환하는 부작용이 있습니다.

두 번째 이유는 다음과 같은 식으로 배열의 요소 수를 알아내는 코드가 있는데,

Empty arr[10];
int elemCount = sizeof(arr) / sizeof(Empty); // elemCount == 10

sizeof(Empty)가 0을 반환하면 DivideByZero 예외 상황이 발생하기 때문입니다.





2. 필드를 가진 단일 클래스

클래스에 필드 하나를 추가해 보면,

class FieldSample
{
    char ch1;
};

FieldSample inst;
printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 1

자료형에 따라(위에서는 char 1바이트) 크기가 출력됩니다. char 필드 2개를 추가하면,

class FieldSample
{
    char ch1;
    char ch2;
};

FieldSample inst;
printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 2

2바이트가 됩니다. 그런데, ch1과 ch2 사이에 int를 넣으면 상황이 달라집니다.

class FieldSample
{
    char ch1;
    int n1;
    char ch2;
};

그럼, 4바이트 정렬이 되어 다음과 같은 layout을 갖게 됩니다.

[오프셋 8: ch2] - 4바이트
[오프셋 4: n1]  - 4바이트
[오프셋 0: ch1] - 4바이트

따라서, sizeof(FieldSample)을 하게 되면 12가 나옵니다.

낭비되는 메모리가 좀 심하다고 생각되면 (정렬에 따른 성능 향상을 희생하는) pragma pack 옵션을 줄 수 있습니다.

#pragma pack(1) // 1바이트 정렬
class FieldSample
{
public:
    char ch1;
    int n1;
    char ch2;
};

그럼 메모리 layout이 다음과 같이 바뀝니다.

[오프셋 5: ch2] - 1바이트
[오프셋 1: n1]  - 4바이트
[오프셋 0: ch1] - 1바이트

따라서 sizeof(FieldSample) == 6이 나옵니다.





3. 멤버 함수를 가진 단일 클래스

int 필드 하나에 함수 하나를 추가해볼까요?

class FieldFuncSample
{
public:
    int n1 = 1;

    void func1() { }
};

FieldFuncSample inst;

printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 4

보는 바와 같이 함수의 추가는 메모리 변화와 상관없습니다. (당연한 결과입니다.)

그런데, 가상 함수(virtual function)를 추가하면 상황이 달라집니다.

class FieldFuncSample
{
public:
    int n1 = 1;

    void func1() { }

    virtual void vfunc1() { }
};

FieldFuncSample inst;

printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 8

왜냐하면 virtual 함수의 추가는, 이에 대한 동적 바인딩을 관리하기 위한 virtual function table(vtable)이 생성되면서 객체는 vtable에 대한 포인터(vptr) 하나를 소유하기 때문입니다. 즉, 다음과 같은 layout을 갖게 됩니다.

[오프셋 4: 필드 n1]       - 4바이트
[오프셋 0: vtable 포인터] - 4바이트

물론, 가상 함수의 수와는 상관이 없습니다. 추가되는 가상 함수는 vtable에만 영향을 줄 뿐 객체는 여전히 vtable 포인터 하나만을 유지하기 때문입니다.





4. 단일 상속 클래스

그런데, 상속을 받으면 어떻게 될까요?

class A
{
public:
    int n1 = 1;

    void func1() { n1 ++; }

    virtual void vfunc1() { }
};

class BonA : A
{
public:
    int n2 = 2;

    void func2() { n2 ++; }

    virtual void vfunc2() { }
};

class ConBonA : BonA
{
public:
    int n3 = 2;

    void func3() { n3 ++; }

    virtual void vfunc3() { }
};

BonA inst;
printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 12

ConBonA inst2;
printf("sizeof(inst2) == %d\n", sizeof(inst2)); // sizeof(inst2) == 16

할당된 메모리를 보면,

BonA inst 객체 (12바이트)

    [오프셋 8: 필드 BonA::n2]  - 4바이트
    [오프셋 4: 필드 A::n1]     - 4바이트
    [오프셋 0: vtable 포인터]  - 4바이트

ConBonA inst2 객체 (16바이트)

    [오프셋12: 필드 ConBonA::n3] - 4바이트
    [오프셋 8: 필드 BonA::n2]  - 4바이트
    [오프셋 4: 필드 A::n1]     - 4바이트
    [오프셋 0: vtable 포인터]  - 4바이트

정직하게 누적된 객체들이 가진 필드의 수에 따라 메모리가 늘어나는 것을 볼 수 있습니다. (참고로, vfunc1, vfunc2, vfunc3 가상 함수들은 어차피 별도로 마련된 vtable에만 기록되기 때문에 할당받은 객체와는 무관합니다.)

가만 보면, 규칙이 있습니다. vtable이 가장 하단에 있고, 그 위에 가장 처음의 기반 클래스에 있는 필드들이 먼저 누적되어 쌓입니다. 물론, 이것에는 나름의 이유가 있습니다. 가령 A::func1 함수를,

void func1() { n1 ++; }

컴파일된 기계어 코드로 살펴보면 다음과 같이 나옵니다.

00041000  inc         dword ptr [ecx+4]  // ecx == this 포인터
00041003  ret  

즉, n1 필드를 접근하는데 this 포인터에 +4 위치로 지정합니다. BonA::func2의 컴파일 결과는 다음과 같습니다.

void func2() { n2 ++; }

00041000  inc         dword ptr [ecx+8]
00041003  ret  

위의 코드를 기반으로 다음과 같은 함수 호출을 할 때,

BonA inst;
inst.func1();
inst.func2();

C++ 컴파일러는 다음과 같이 기계어 코드를 간편하게 생성할 수 있습니다.

mov ecx, [inst]
call [func1]
call [func2]

즉, ecx에 담기는 this 포인터 값을 아무런 변경 없이 func1과 func2에 전달해서 사용할 수 있는 것입니다.





5. 단일 상속 클래스들의 형 변환

상속받은 객체들의 메모리 구조가 저렇게 지정되었기 때문에 this 포인터를 형 변환할 때도 쉬워졌습니다.

ConBonA *pInst = new ConBonA();

BonA *pbInst = (BonA *)pInst;
A *paInst = (A *)pInst;

간략하게 기계어로 다음과 같이 표현됩니다.

00B71B8E  mov         eax, [new로 할당받은 힙 주소]
00B71B9B  mov         dword ptr [ebp-34h],eax  // pInst 변수에 ConBonA* 타입의 this 포인터 보관

        BonA *pbInst = (BonA *)pInst;
00B71BB1  mov         dword ptr [ebp-40h],eax  // pbInst 변수에 BonA* 타입의 this 포인터 보관

        A *paInst = (A *)pInst;
00B71BB7  mov         dword ptr [ebp-4Ch],eax  // paInst 변수에 A* 타입의 this 포인터 보관

여기서도 별다르게 this 포인터의 값을 아무런 변경 없이 기반 클래스들의 타입으로 지정해도 무방합니다.





6. 다중 상속 클래스

그런데, 이번에는 상속을 다음과 같이 다중으로 받도록 변경해 보겠습니다.

class A
{
public:
    int n1 = 1;

    void func1() { n1++; }

    virtual void vfunc1() { }
};

class B
{
public:
    int n2 = 2;

    void func2() { n2++; }

    virtual void vfunc2() { }
};

class C
{
public:
    int n3 = 3;

    void func3() { n3++; }

    virtual void vfunc3() { }
};

class ConBandA : public B, public A
{
public:
    int c = 10;

    void func_c() { c++; }

    virtual void vfunc_c() { }
};

class DonCandBandA : public C, public B, public A
{
public:
    int d = 11;

    void func_d() { d++; }

    virtual void vfunc_d() { }
};

ConBandA inst;
printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 20

DonCandBandA inst2;
printf("sizeof(inst2) == %d\n", sizeof(inst2)); // sizeof(inst) == 28

객체 크기부터 달라졌는데, "C -> B -> A" 순으로 상속받았을 때는 16바이트가 필요했던 반면 "C -> B, A"와 같이 다중으로 상속받으니 20바이트가 필요해졌습니다. 실제로 이때의 메모리 할당은 다음과 같이 배열됩니다.

ConBandA inst 객체 (20바이트)

    [오프셋16: 필드 ConBandA::c]             - 4바이트
    [오프셋12: 필드 A::n1]                   - 4바이트
    [오프셋 8: 필드 ConBandA::vtable 포인터] - 4바이트
    [오프셋 4: 필드 B::n2]                   - 4바이트
    [오프셋 0: B::vtable 포인터]             - 4바이트

3개의 상속을 받은 DonCandBandA는 더 복잡해집니다.

DonCandBandA inst 객체 (28바이트)

    [오프셋24: 필드 DonCandBandA::d]        - 4바이트
    [오프셋20: 필드 A::n1]                  - 4바이트
    [오프셋16: DonCandBandA::vtable 포인터] - 4바이트
    [오프셋12: 필드 B::n2]                  - 4바이트
    [오프셋 8: B::vtable 포인터]            - 4바이트
    [오프셋 4: 필드 C::n3]                  - 4바이트
    [오프셋 0: C::vtable 포인터]            - 4바이트

그래도 나름의 규칙이 있습니다. 직접 상속받는 것으로 처리할 클래스 한 개를 제외하고는 전부 하위에 vtable 포인터와 필드를 배치시키고 있습니다.

문제는 지금부터입니다. 기존의 직렬로 상속한 구조와는 달리 이제부터는 C/C++ 소스 코드에서 동일한 객체를 사용함에도 불구하고 어떤 메서드를 호출하느냐에 따라 컴파일러가 적극적으로 개입해서 this 포인터의 주소를 조정해 주어야 합니다.

우선, 처음 DonCandBandA 타입의 객체를 생성했을 때의 this 포인터는 "오프셋 0" 지점이 있는 메모리 주소를 가리키게 됩니다.

DonCandBandA *pInst = new DonCandBandA(); // pInst 주솟값의 위치는 "오프셋 0"을 가리킴 (이하, 그 주소를 0x1000이라고 가정하고 설명)

이 상태에서 DonCandBandA 클래스의 func_d 함수와 C 클래스의 func3 함수를 호출하는 것은 아무런 변경 없이 그대로 사용할 수 있습니다.

    pInst->func3();
00C64F6C  lea         ecx,[0x1000] // this 포인터가 오프셋 0을 가리킴. (할당된 주소가 0x1000으로 가정)
00C64F6F  call        C::func3 (0C613ACh)  

    pInst->func_d();
00C64F74  lea         ecx,[0x1000]  
00C64F77  call        DonCandBandA::func_d (0C613B1h)  

C 클래스의 경우 어차피 단독 생성했을 때의 메모리 구조와 다를 바가 없으므로 func3의 코드가,

    void func3() { n3 ++; }
00041000  inc         dword ptr [ecx+4]  // this 포인터에 4를 더했으므로 "[오프셋 4: 필드 C::n3]" 위치를 지정
00041003  ret  

정상적으로 동작합니다. 또한, DonCandBandA 클래스의 func_d는,

    void func_d() { d++; }
00C61886  inc         dword ptr [ecx+18h]
00041003  ret  

ecx + 0x18(24)의 오프셋 연산을 하는데, 어차피 DonCandBandA 클래스는 C++ 컴파일러가 모든 메모리 레이아웃을 계산할 수 있기 때문에 this 포인터로부터 해당 클래스의 멤버 필드들이 어떤 위치에 알 수 있으므로 저렇게 ecx 변경 없이 곧바로 필드 접근을 할 수 있게 됩니다.

그런데, A::func1과 B::func2를 호출할 때는 어떻게 될까요? 이미 A::func1은 n1 필드를 접근하는데 0x04 오프셋을 사용했고, B::func2는 0x04 오프셋을 사용하고 있는 중입니다.

    void func1() { n1 ++; }
00041000  inc         dword ptr [ecx+4]
00041003  ret  

    void func2() { n2 ++; }
00041000  inc         dword ptr [ecx+4]
00041003  ret  

따라서, func_3과 func_d를 호출했던 것과 동일한 this 포인터를 넘겨주면 정상적인 필드 접근을 할 수 없게 됩니다. 이 때문에 C/C++ 컴파일러는 func1, func2 함수를 호출 시에 다음과 같이 this 포인터 주소를 조정해서 넘겨주게 됩니다.

    pInst->func1();
00C64F7C  lea         ecx,[0x1000 + 0x10]  
00C64F7F  call        A::func1 (0C613B6h)  
    pInst->func2();
00C64F84  lea         ecx,[0x1000 + 0x8]  
00C64F87  call        B::func2 (0C613BBh)  

보는 바와 같이, this 포인터를 넘겨주는 ecx의 값을 A와 B가 마치 상속된 적이 없었다는 듯이 단일 클래스로 new 할당했을 때와 동일한 환경으로 변경해 줍니다. (개발자 입장에서 아무렇지도 않게 호출하던 멤버 함수의 이면에는 C/C++ 컴파일러의 이런 눈물겨운 숨은 노력이 있었던 것입니다. ^^;)





7. this 포인터 처리 관점에서의 static_cast, reinterpret_cast, dynamic_cast 형 변환 차이점

결국, 컴파일러 입장에서는 this 포인터 값에 대해 짝을 이뤄 호출되는 함수가 어떤 클래스에 정의되었느냐에 따라 오프셋 값을 자동화해서 맞춰주는 코드를 산출해 주어야 합니다.

이로 인해, 우리가 익히 알고 있던 형 변환 연산자가 static_cast, reinterpret_cast, dynamic_cast로 다양하게 나눠진 것입니다.

이전의, A, B, C를 병렬로 다중 상속받았던 DonCandBandA 객체의 메모리 layout을 다시 한번 보면,

DonCandBandA inst 객체 (28바이트)

    [오프셋24: 필드 DonCandBandA::d]        - 4바이트
    [오프셋20: 필드 A::n1]                  - 4바이트
    [오프셋16: DonCandBandA::vtable 포인터] - 4바이트
    [오프셋12: 필드 B::n2]                  - 4바이트
    [오프셋 8: B::vtable 포인터]            - 4바이트
    [오프셋 4: 필드 C::n3]                  - 4바이트
    [오프셋 0: C::vtable 포인터]            - 4바이트

형 변환이 얼마나 복잡해지는지 유추해 볼 수 있습니다. 가령, 다음과 같은 코드에서,

DonCandBandA *pInst = new DonCandBandA();
A *paInst = (A *)pInst;

C/C++ 컴파일러는 pInst가 오프셋 0 지점을 가리키고 있으므로 클래스 A 타입으로 형 변환했을 때, 16 바이트(0x10)를 더한 값을 반환해야 합니다.

        DonCandBandA *pInst2 = ...;
00D31D95  mov         eax, [inst2]

        A *paInst = (A *)pInst2;
00D31D9E  mov         [paInst], [eax + 10h]

그럼, paInst 변수는 오프셋 16 위치를 자신의 this 포인터 위치로 알고 동작하게 됩니다. 그런데 이 상황에서 해당 포인터를 B 클래스로 바꾸는 경우 어떻게 될까요?

B *pbFromPa1 = (B *)paInst;
pbFromPa1->func2();

아시는 것처럼, 캐스팅 연산자는 '강제 형 변환'을 하게 되므로, paInst와 pbFromPa의 주솟값이 같습니다. 즉, func2가 실행될 때의 ecx에 들어 있는 this 포인터 주소는 오프셋 8이 아닌 오프셋 16의 위치를 가리키고 있으므로 func2 실행은 B::n2 필드의 값을 접근하지 못하고 A::n1 필드의 값을 접근하는 결과를 낳게 됩니다. (현실적인 경우, 보통 이렇게 되면 비정상 종료를 하는 등의 예측할 수 없는 결과를 보게 됩니다.)

이 문제는 강제 형 변환을 하는 reinterpret_cast에서도 동일하게 발생합니다.

B *pbFromPa2 = reinterpret_cast<B *>(paInst); // 주솟값이 같음: pbFromPa2 == paInst

반면, static_cast를 쓰게 되면 컴파일 시에 아예 에러를 내며 쓸 수 없게 해줍니다.

A *paFromPbError = static_cast<B *>(paInst); // Error C2440 'static_cast': cannot convert from 'A *' to 'B *'

마지막으로 dynamic_cast를 쓰면 RTTI 정보를 참조해 결국 DonCandBandA 객체의 포인터라는 것을 알 수 있어 정상적으로 B 포인터 값을 구할 수 있습니다.

B *pbFromPa3 = dynamic_cast<B *>(paInst);
    // pbFromPa3 = paInst + offset(8)

dynamic_cast는 RTTI 정보를 참조한다는 것에 유의해야 합니다. 만약 (Visual C++의 경우) C/C++의 Langauge 범주에서 "Enable Run-Time Type Information" 옵션을 "No(/GR-)"로 설정하면 RTTI 정보가 없어지는데 이 상황에서 dynamic_cast를 실행하면 (컴파일 타임이 아닌) 런타임 시에 다음과 같은 예외가 발생합니다.

A *paFromPb3 = dynamic_cast<A *>(pbInst);

// RTTI 정보가 없으면 Runtime에 다음과 같은 예외 발생
// Exception thrown at 0x0F1A813F (vcruntime140.dll) in ConsoleApplication1.exe: 0xC0000005: Access violation reading location 0x00000004. occurred





8. 마름모 꼴의 상속 클래스

그런데, 문제는 더욱 복잡해질 수 있습니다. 이른바 마름모 꼴 상속을 받는 구조가 바로 그것입니다.

class A
{
public:
    int n1 = 1;

    void func1() { n1++; }

    virtual void vfunc1() { }
};

class BonA : public A
{
public:
    int n2 = 2;

    void func2() { n2++; }

    virtual void vfunc2() { }
};

class ConA : public A
{
public:
    int n3 = 3;

    void func3() { n3++; }

    virtual void vfunc3() { }
};

class Don_BonA_and_ConA : public BonA, public ConA
{
public:
    int d = 11;

    void func_d() { d++; }

    virtual void vfunc_d() { }
};

Don_BonA_and_ConA inst;
printf("sizeof(inst) == %d\n", sizeof(inst)); // sizeof(inst) == 28

객체가 할당된 메모리 layout을 보면 2번 상속받게 된 A 클래스의 필드 멤버들이 2중으로 존재한다는 것을 알 수 있습니다.

Don_BonA_and_ConA inst 객체 (28바이트)

    [오프셋24: 필드 Don_BonA_and_ConA::d]        - 4바이트
    [오프셋20: 필드 ConA::n3]                    - 4바이트
    [오프셋16: 필드 A::n1]                       - 4바이트
    [오프셋12: Don_BonA_and_ConA::vtable 포인터] - 4바이트
    [오프셋 8: 필드 BonA::n2]                    - 4바이트
    [오프셋 4: 필드 A::n1]                       - 4바이트
    [오프셋 0: BonA::vtable 포인터]              - 4바이트

이 때문에 다음과 같이 BonA, ConA에 대해 형 변환을 한 다음 호출하면 각각의 n1 필드 값을 변경하게 됩니다.

Don_BonA_and_ConA *pInst = new Don_BonA_and_ConA();

BonA *pbaInst = (BonA *)pInst;
ConA *pcaInst = (ConA *)pInst;

pbaInst->func1(); // (BonA *)로 호출한 func1은 오프셋  4의 A::n1 필드를 변경하고,
pcaInst->func1(); // (ConA *)로 호출한 func1은 오프셋 16의 A::n1 필드를 변경

문제는, pInst 변수로 func1을 호출할 때 발생합니다.

pInst->func1(); // Error C2385 ambiguous access of 'func1'

DonCandBandA 클래스 입장에서는 func1의 호출로 인해 (결국) 2중으로 존재하는 객체를 접근하게 되므로 이에 대한 선택을 자동화할 수 없기 때문에 개발자에게 명시할 것을 요구합니다. 따라서, 다음과 같이 원하는 측의 상속을 지정해야 합니다.

pInst->BonA::func1();





9. 마름모 꼴의 가상 상속 클래스

마름모 꼴로 상속되는 경우의 중복 데이터 문제를 해결하기 위해, 중복되는 클래스를 virtual로 상속할 수 있습니다.

class A
{
public:
    int n1 = 1;

    void func1() { n1++; }

    virtual void vfunc1() { }
};

class BonVirtualA : virtual public A
{
public:
    int n2 = 2;

    void func2() { n2++; }

    virtual void vfunc2() { }
};

class ConVirtualA : virtual public A
{
public:
    int n3 = 3;

    void func3() { n3++; }

    virtual void vfunc3() { }
};

class D_virtual_on_BonA_and_ConA : public BonVirtualA, public ConVirtualA
{
public:
    int d = 11;

    void func_d() { d++; }

    virtual void vfunc_d() { }
};

D_virtual_on_BonA_and_ConA inst;
printf("sizeof(inst) == %d\n", sizeof(inst));  // sizeof(inst) == 36

중복된 데이터가 없어졌는데도 불구하고 객체의 크기는 오히려 기존 28에서 virtual로 바꾼 경우 36으로 늘어납니다.

D_virtual_on_BonA_and_ConA inst 객체 (36바이트)

    [오프셋32: 필드 A::n1]                                   - 4바이트
    [오프셋28: A::vtable 포인터]                             - 4바이트
    [오프셋24: 필드 D_virtual_on_BonA_and_ConA::d]           - 4바이트
    [오프셋20: 필드 ConVirtualA::n3]                         - 4바이트
    [오프셋16: ConVirtualA의 virtual 기반 클래스 정보 포인터]  - 4바이트
    [오프셋12: D_virtual_on_BonA_and_ConA::vtable 포인터]      - 4바이트
    [오프셋 8: 필드 B::n2]                                     - 4바이트
    [오프셋 4: BonVirtualA의 virtual 기반 클래스 정보 포인터]   - 4바이트
    [오프셋 0: BonVirtualA::vtable 포인터]                     - 4바이트

가만 보면, BonVirtualA의 vtable 포인터와 D_virtual_on_BonA_and_ConA::vtable 포인터의 다음 오프셋 위치에 virtual base 클래스인 A::vtable 포인터에 대한 위치 변위를 갖고 있는 정보의 주소가 기록됩니다. (이 값을 "Effective C++ Digital Collection: 140 Ways to Improve Your Programming" 책에서는 "Pointer to virtual base class"라고 지칭)

이 값이 어떻게 활용되는지 역어셈블을 해보면 됩니다.

        D_virtual_on_BonA_and_ConA *pInst2 = new D_virtual_on_BonA_and_ConA();
00B220AE  mov dword ptr [pInst2],[this pointer address]  
                    // pInst2에는 D_virtual_on_BonA_and_ConA 객체의 오프셋 0 위치를 가리킴.

        int nback = pInst2->n1;
00B220B4  mov eax,dword ptr [pInst2]  
00B220BA  mov ecx,dword ptr [eax+4]  // 오프셋 0 위치의 바로 위에 있는 "Pointer to virtual base class" 주소를 구함
                                     // 가령, 구해진 ecx 값이 0x00b27bf0라고 할때,
                                     // 0x00b27bf0 == fx ff ff ff
                                     // 0x00b27bf4 == 18 00 00 00
                                     // 0x00b27bf8 == 00 00 00 00

00B220BD  mov edx,dword ptr [ecx+4]  // 변위값 0x18 값이 edx로 들어감
00B220C6  mov ecx,dword ptr [eax+edx+8]  // [this pointer + 0x18 + 8]이 되어
                                         // 결국 pInst2 주소로부터 0x20(32) 위치의 값을 가리키고 그것이 필드 A::n1 임.

그럼, 이번에는 ConVirtualA 타입으로 형 변환 후 구하면 어떻게 될까요?

   249:         ConVirtualA *vba = (ConVirtualA *)pInst2;
   250:         nback = vba->n1;
00B2211B  mov  eax,dword ptr [pInst2 주소로부터 오프셋 12 위치의 vtable]  
00B22121  mov  ecx,dword ptr [eax+4]  // 오프셋 16 위치의 또 다른 "Pointer to virtual base class"
                                      // 가령, 구해진 ecx 값이 0x00b27bfc라고 할때,
                                      // 0x00b27bfc == fx ff ff ff
                                      // 0x00b27c00 == 0c 00 00 00
                                      // 0x00b27c04 == 00 00 00 00
          
00B22124  mov  edx,dword ptr [ecx+4]  // 변위값 0x0c 값이 edx로 들어감
00B2212D  mov  ecx,dword ptr [eax+edx+8]  // [this pointer + 0x0c + 8]이 되어 
                                          // 결국 vba 주소로부터 0x14(20) 위치의 값을 가리키고 그것이 필드 A::n1 임.

보는 바와 같이 코드가 참 복잡해지는데, 이러한 모든 복잡성을 C/C++ 컴파일러가 책임지기 때문에 그나마 개발자가 '상속'이라는 기능을 사용할 수 있게 된 것입니다.




휴~~~ 이것으로 C++ 클래스의 메모리 layout이 대충 정리된 것 같습니다.

보는 바와 같이, C/C++은 다중 상속을 허용함으로 인해 복잡한 컴파일러가 되었습니다. 어찌 보면, C#이나 Java에서 단일 상속만을 허용한 것은 이런 면에서 봤을 때 당연한 것이 아니었을까 ... 라고 당연하게 생각할 정도입니다. ^^;

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 8/3/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2019-11-05 12시59분
[신현준] 와 제가 찾고 있던 자료입니다.. 이렇게 깔끔하게 정리해주신분이 있을줄이야
정말 감사합니다.
[guest]
2020-07-23 10시59분
[띠띵] 정말 여기는 정의에 대한 근거가 있어서 너무 좋습니다..
이제라도 알게되어서 다행이네요 감사합니다
[guest]

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13602정성태4/20/2024223닷넷: 2244. C# - PCM 오디오 데이터를 연속(Streaming) 재생 (Windows Multimedia)파일 다운로드1
13601정성태4/19/2024261닷넷: 2243. C# - PCM 사운드 재생(NAudio)파일 다운로드1
13600정성태4/18/2024300닷넷: 2242. C# - 관리 스레드와 비관리 스레드
13599정성태4/17/2024469닷넷: 2241. C# - WAV 파일의 PCM 사운드 재생(Windows Multimedia)파일 다운로드1
13598정성태4/16/2024444닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더파일 다운로드2
13597정성태4/15/2024519닷넷: 2239. C# - WAV 파일의 PCM 데이터 생성 및 출력파일 다운로드1
13596정성태4/14/2024875닷넷: 2238. C# - WAV 기본 파일 포맷파일 다운로드1
13595정성태4/13/2024996닷넷: 2237. C# - Audio 장치 열기 (Windows Multimedia, NAudio)파일 다운로드1
13594정성태4/12/20241034닷넷: 2236. C# - Audio 장치 열람 (Windows Multimedia, NAudio)파일 다운로드1
13593정성태4/8/20241053닷넷: 2235. MSBuild - AccelerateBuildsInVisualStudio 옵션
13592정성태4/2/20241211C/C++: 165. CLion으로 만든 Rust Win32 DLL을 C#과 연동
13591정성태4/2/20241169닷넷: 2234. C# - WPF 응용 프로그램에 Blazor App 통합파일 다운로드1
13590정성태3/31/20241074Linux: 70. Python - uwsgi 응용 프로그램이 k8s 환경에서 OOM 발생하는 문제
13589정성태3/29/20241143닷넷: 2233. C# - 프로세스 CPU 사용량을 나타내는 성능 카운터와 Win32 API파일 다운로드1
13588정성태3/28/20241198닷넷: 2232. C# - Unity + 닷넷 App(WinForms/WPF) 간의 Named Pipe 통신파일 다운로드1
13587정성태3/27/20241157오류 유형: 900. Windows Update 오류 - 8024402C, 80070643
13586정성태3/27/20241305Windows: 263. Windows - 복구 파티션(Recovery Partition) 용량을 늘리는 방법
13585정성태3/26/20241096Windows: 262. PerformanceCounter의 InstanceName에 pid를 추가한 "Process V2"
13584정성태3/26/20241051개발 환경 구성: 708. Unity3D - C# Windows Forms / WPF Application에 통합하는 방법파일 다운로드1
13583정성태3/25/20241158Windows: 261. CPU Utilization이 100% 넘는 경우를 성능 카운터로 확인하는 방법
13582정성태3/19/20241423Windows: 260. CPU 사용률을 나타내는 2가지 수치 - 사용량(Usage)과 활용률(Utilization)파일 다운로드1
13581정성태3/18/20241591개발 환경 구성: 707. 빌드한 Unity3D 프로그램을 C++ Windows Application에 통합하는 방법
13580정성태3/15/20241138닷넷: 2231. C# - ReceiveTimeout, SendTimeout이 적용되지 않는 Socket await 비동기 호출파일 다운로드1
13579정성태3/13/20241494오류 유형: 899. HTTP Error 500.32 - ANCM Failed to Load dll
13578정성태3/11/20241631닷넷: 2230. C# - 덮어쓰기 가능한 환형 큐 (Circular queue)파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...