__purecall과 가상함수, 가상 소멸자, vfptr

J·2024년 8월 25일

테스트

목록 보기
1/12
post-thumbnail
## _purecall

msdn에 나와있는 정의

👉 기본 순수 가상 함수 호출 오류 처리기입니다. 
순수 가상 구성원 함수를 호출하면 컴파일러가 이 함수를 호출하는 코드를 생성합니다

__purecall과 가상 함수 테이블 및 잡다한 개념에 대해서 가끔씩 생각나서 정리, 테스트한 내용을 기록해본다...

※ 개인적인 생각이 추가되어 정확하지 않은 정보일 수 있다.

1. Derived : Base 상속받은 클래스의 객체 생성 시 가상 함수 테이블의 변화

#include <iostream>

class Base
{
	public:
		virtual void Print()
        {
        	std::cout << "Base의 Print -> Base " <<std::endl;
        }
        int GetX() { return _x; }
    private:
        int _x = 0x11223344;
        int _y = 0xaabbccdd;
};

class Derived : public Base
{
	public:
		virtual void Print() override
        {
        	std::cout << "Base의 Print -> Derived 재정의" << std::endl;
        }
        virtual void Print2()
        {
        	std::cout << "Derived의 Print2 -> Derived" << std::endl;
        }
private:
		int _DerivedX = 0x99887766;
};

int main()
{
	Derived d; 
    
	d.GetX();
    
	return 0;
}

에서 Derived 클래스의 객체를 하나 생성하고 그 과정을 확인한다.

1. ecx (this 포인터) 세팅 후 Derived 생성자를 호출

2. Derived 생성자 안에서 Base의 생성자 호출

3. Base 생성자 안에서 자신의 가상 함수 테이블 포인터를 세팅하고, 멤버 변수를 초기화한다.

3-2. Base 생성자 호출 후 메모리.

4. Base 생성자의 호출이 종료되고, Derived의 생성자의 그 다음 작업에서 가상 함수 테이블 포인터를 다시 세팅해주고, Derived의 멤버 변수를 세팅한다.

4-2 Derived 생성자 호출 후 메모리

현재 설정된 가상 함수 테이블의 정보 확인을 위해 Base 가상 함수 테이블 위치로 이동



각 가상함수 테이블엔 호출되어야할 가상 함수 코드가 존재함을 확인.

base 가상 함수 테이블

derived 가상 함수 테이블


2.Base가 순수 가상 함수를 포함할 때.

1.의 코드를 조금 변경해서. Base의 Print를 순수 가상 함수로 만들었다.

class Base
{
public:
    virtual void Print() abstract = 0;

    int GetX() { return _x; }
private:
    int _x = 0x11223344;
    int _y = 0xaabbccdd;
};

class Derived : public Base
{
public:
    virtual void Print() override
    {
        std::cout << "Base의 Print -> Derived 재정의" << std::endl;
    }
private:
    int _DerivedX = 0x99887766;
};


int main()
{
    Derived d;

    d.GetX();

    return 0;
}

가상 함수 테이블과 변수를 세팅하는 과정은 1과 동일하다. (따라서 간략하게 적겠음)

1. 가상 함수 테이블 주소 세팅

0x00e11050 : Base의 생성자 호출로 세팅된 가상 함수 테이블 주소




2. 가상 함수 테이블 확인





3. 각각의 함수 확인

0x00bb2388 : 점프 테이블 내의 purecall함수 호출

Base의 Print()는 순수 가상 함수로 선언되었기 때문에 Base::Print() 대신
pure_call이 들어간 것을 확인할 수 있다.









0x00bb1020 : Derived의 Print


따라서 👉 순수 가상 함수가 호출되면 _purecall handler가 실행되고, run-time error (purecall Error)가 발생할 수 있다.

3. 소멸자에서도 함수 테이블이 변경되는 것을 확인할 수 있다.

class Base
{
public:
    ~Base()
    {
		std::cout << "Base 소멸자" << std::endl;
    }
    virtual void Print() abstract = 0;

    int GetX() { return _x; }
private:
    int _x = 0x11223344;
    int _y = 0xaabbccdd;
};

class Derived : public Base
{
public:
    ~Derived()
    {
		std::cout << "Derived 소멸자" << std::endl;
    }

    virtual void Print() override
    {
        std::cout << "Base의 Print -> Derived 재정의" << std::endl;
    }
private:
    int _DerivedX = 0x99887766;
};

int main()
{
    Derived d;

    d.GetX();

    return 0;
}



가상 함수 테이블 정보




1. 종료 전 d의 가상 함수 테이블 주소

2. Derived::~Derived

클래스 Derived의 소멸자




3. Base::~Base




4. Base::~Base 호출 후

값이 바뀜을 확인할 수 있었다.

결국

  • 생성자를 호출하는 과정에서 순수 가상 함수로 존재하는 부분을 호출

  • 소멸자를 호출하는 과정에서 순수 가상 함수로 존재하는 부분을 호출
    의 경우에 __purecall 이 호출될 수 있다로 인지하면 될까??

    또는 어떤 경우가 있을까??

4. 가상 함수를 비가상 함수처럼 호출하기

방법은 바로 범위 연산자를 호출하는 것이다. 범위 연산자를 호출하면 가상함수도 비가상함수처럼 바로 call이 된다.

 class CParent
{
public:
	void Func()
	{
		VFunc();
		CParent::VFunc();
	}

	virtual void VFunc()
	{
		std::cout << "CParent::VFunc()\n";
	}
};

class CChild : public CParent
{
public:
	virtual void VFunc() override
	{
		std::cout << "CChild::VFunc()\n";
	}
};

void main()
{
	CChild c;

	CParent* pP = &c;

	pP->Func();

	pP->VFunc();
	pP->CParent::VFunc();
}	
  • Parent::Func 내부
    ①의 경우
    • this를 얻어옴 (eax)
    • vfptr을 얻어옴 (edx)
    • vfptr을 통해 가상 함수에 접근하고, 호출하는 모습을 확인했다.

②의 경우

  • this를 넣고 바로 CParent::VFunc을 호출하는 모습을 확인할 수 있다.

뒤의 코드도 동일하게 호출된다.

5. purecall error 내보기

#include <iostream>

using namespace std;

class Base
{
public:
    Base()
    {
        //Wrapper();
    }
    ~Base()
    {
        Wrapper();
    }
    void Wrapper()
    {
        Print();
    }
    virtual void Print() = 0;

    int x = 0;
};

class Derived : public Base
{
public:
    virtual void Print() {
        cout << "Drived Print\n";
    }
};
int main()
{
    Derived d;

}

6. 가상 소멸자 (Virtual 소멸자).

  • RTTI 사용 옵션을 끄고 Release 최적화없음 진행

    우리가 가상 소멸자를 사용하는 이유는

    다음과 같은 상황에서 문제가 되기 때문이다

  class CParent
{
public:
	~CParent() { printf("~CParent\n"); }
  // virtual ~CParent() { printf("Virtual ~CParent\n"); }
};

class CChild : public CParent
{
public:

	~CChild() { printf("~CChild\n"); }
  // virtual ~CChild() { printf("~CChild\n"); }
};

void main()
{
	CParent* pP = new CChild;

  	// .... 사용
	delete pP; // !! new CChild지만 CParent의 소멸자만 호출됨!!
}

이 상황에서는 자식 클래스의 소멸자가 호출되지 않기 때문에 자식 클래스의 데이터에 대한 정리가 이루어지지 않는다. 그렇기 때문에 소멸자를 virtual로 사용해서 완전하게 정리가 되도록 해야한다.

그렇다면 소멸자를 virtual로 만들어줬을 때 동작은 어떻게 되는 것일까?

virtual로 소멸자를 호출하도록 했을 때를 살펴보았다.

new/delete 에서 확인했듯 new / delete로 동적할당/해제를 진행했을 때 자식 클래스의 생성자가 먼저 호출되지만 부모부터 실행된다.

main에서 CParent* pP = new Child 실행 시



Child의 생성자 내부




Parent의 생성자 내부.



Parent의 생성자 호출 후 대입된 가상 함수 테이블 주소



Child의 생성자 호출 후 대입된 가상 함수 테이블 주소



정리해보면 다음과 같다.

여기서 각각 0x0037347C와 0x00373480 에 등록된 함수를 찾아보면 다음과 같다.

  • Parent와 관련
  • Child와 관련





delete pP;가 실행됐을 때이다.
scalar_deleting_desructor 함수 호출 시 push되는 값은 내 생각엔 flag같은 값이지 않을까..싶다.

delete를 하게되면 가상함수 테이블을 이용해 등록되었던 CChild::scalar_deleting_destructor 함수가 실행된다. 이 함수는 위 링크(new/delete)에서 확인했듯이 (소멸자 호출 + 동적 할당 메모리 해제)를 수행한다.

현재(Release, 최적화 없음)에서는 scalar_deleting_destructor 또는 vector_deleting_destructor (소멸자 호출 + 동적 할당 메모리 해제를 수행하는 함수)는 가상 소멸자 또는 new / delete를 수행하는 클래스에 대해 컴파일러가 자동으로 멤버 함수로 추가해버리는 것인 것 같다.

또한 결국 virtual 소멸자는 소멸자가 재정의되는 것이 아니다.

요약

  • virtual 소멸자를 사용하면 가상 함수 테이블에 (소멸자 호출 + 동적 할당된 메모리 해제를 수행하는 함수, 멤버 함수) 가 등록이 된다.
  • 동적할당에 대해서 delete 시 이 가상 함수 테이블에 등록된 함수를 사용한다.
  • delete 연산자 실행 시 내부에서 가상함수 테이블 참조를 통해 객체에 대한 정리(소멸자 호출 + 동적 할당 메모리 해제를 수행하는 함수의 실행)이 이루어진다. 그렇게 원래 new했던 클래스에 맞는 소멸자가 호출이 된다.

MSVC 기준 vfptr이 만들어질 때 적용되는 원칙.

  1. 클래스가 최상위 클래스 (부모 클래스가 없는 경우) 일 때
    1) 멤버 함수 중 가상 함수가 없으면 vfptr을 가지지 않는다.
    2) 멤버 함수 중 가상 함수가 있으면 vfptr을 하나 가진다.

  2. 부모 클래스들이 모두 vfptr을 가지고 있지 않은 경우
    1) 자식 클래스의 멤버 함수 중 가상 함수가 없으면 vfptr을 가지지 않는다.
    2) 자식 클래스의 멤버 함수 중 가상 함수가 있으면 vfptr을 하나 가진다.

  3. 부모 클래스들 중 하나라도 vfptr을 가지고 있는 경우, 자식 클래스는 부모 클래스들의 전체 vfptr을 그대로 물려 받는다.
    (즉, 부모 클래스들의 전체 vfptr 개수가 n개라면 자식 클래스는 n개의 vfptr을 가지게 된다. - (다중 상속에서.. 가상 상속에서는 예외이다.)

  4. 클래스에 vfptr이 있을 경우 클래스의 메모리 시작 위치에 vfptr을 놓기 위하여 부모 클래스의 메모리 위치를 변경하기도 한다.
    a) 부모 클래스에 vfptr이 없을 경우 자식 클래스의 vfptr을 메모리 시작 위치로 이동시킨다.
    b) 자식 클래스는 vfptr을 가진 부모 클래스부터 메모리에 위치시킨다. 즉, 클래스 선언 시 지정된 상속 순서를 기준으로 vfptr을 가진 클래스에 우선 순위를 둔 채 메모리 위치를 지정한다. (가상 상속에서는 예외이다., GCC는 조금 다름)

1,2번같은 경우는 일반적으로 알고 있는 것과 동일한 형태이다..

3번을 통해서는 다중 상속에서 vfptr이 여러개가 존재할 수 있다는 것을 알 수 있다.

3.다중 상속에서 vfptr이 여러개 존재하는 경우
class CParent1
{
public:
	virtual void VFunc1() {}
	int m_iParent1 = 0x11111111;
};

class CParent2
{
public:
	virtual void VFunc2() {}
	int m_iParent2 = 0x22222222;
};

class CChild : public CParent1, public CParent2
{
	int m_iChild = 0x33333333;
};

int main()
{
	CChild c;
}

이 코드에서는 다음과 같이 c객체가 2개의 vfptr을 가진다.

여기서 CParent1과 CParent2로부터 받고 설정된 vfptr이 같은 것은 결국 저 테이블을 통해 호출되는 함수의 내용이 동일하기 때문에 (VFunc1()과 VFunc2()의 함수 코드 자체가 동일하기 때문에) 동일한 테이블을의 함수를 참조하는 것으로 보인다.
또한

어떤 클래스에서 처음으로 가상 함수에 대해 가상 함수는 해당 클래스 메모리 시작 위치에 있는 vfptr이 가리키는 가상함수 테이블에 포함된다.
class CParent1
{
public:
	virtual void VFunc1() { printf("P1\n"); }
	int m_iParent1 = 0x11111111;
};

class CParent2
{
public:
	virtual void VFunc2() { printf("P2\n"); }
	int m_iParent2 = 0x22222222;
};

class CChild : public CParent1, public CParent2
{
public:
	virtual void VFunc1() { printf("C1\n"); }
	virtual void VFunc2() { printf("C2\n"); }
	virtual void VFuncC1() { printf("CC1\n"); }
	virtual void VFuncC2() { printf("CC2\n"); }
	int m_iChild = 0x33333333;
};

int main()
{
	CChild c; 
}

4-a의 경우엔 가상함수와 관련된... 에서 다뤘던 내용을 설명하고 있다.

4-b ( 자식 클래스는 vfptr을 가진 부모 클래스부터 메모리에 위치시킨다 )

class CParent1
{
public:
	int m_iParent1 = 0x11111111;
};

class CParent2
{
public:
	virtual void VFunc2() = 0;
	int m_iParent2 = 0x22222222;
};

class CParent3
{
public:
	int m_iParent3 = 0x33333333;
};

class CParent4
{
public:
	virtual void VFunc4() = 0;
	int m_iParent4 = 0x44444444;
};

class CChild : public CParent1, public CParent2, public CParent3, public CParent4
{
public:
	virtual void VFunc2() { printf("VF2\n"); }
	virtual void VFunc4() { printf("VF4\n"); }
	virtual void VFuncC() { printf("Child\n"); }
	int m_iChild = 0x55555555;
};

int main()
{
	CChild c;

	CParent1* pP1 = &c;
	CParent2* pP2 = &c;
	CParent3* pP3 = &c;
	CParent4* pP4 = &c;

	printf("&c : %p\n", &c);
	printf("p1 : %p\n", pP1);
	printf("p2 : %p\n", pP2);
	printf("p3 : %p\n", pP3);
	printf("p4 : %p\n", pP4);
}

MSVC 기준 그려지는 메모리는 다음과 같다.

vfptr배치를 위한 메모리 위치가 변경되었을 뿐, 상속 순서의 실제 의미는 객체 생성 시 생성자가 불리는 순서를 의미하기때문에 생성자의 호출 순서가 바뀌진 않았다.

✨ 결국 컴파일러는 객체의 시작 위치에 vfptr이 존재하게 만들기 위해 메모리 내부의 클래스 위치를 조정한다.

7. 가상 상속과 vfptr

  • 가상 함수가 새롭게 선언될 때 메모리 시작 위치에 있는 vfptr이 가리키는 가상함수 테이블에 함수가 추가되는 것이 원칙이다.
  class CParent
{
public: 
	virtual void VFuncP() = 0;
	int m_parent = 0x11111111;
};

class CChild : public virtual CParent
{
public:
	virtual void VFuncP() { void* p = this; }
    // virtual void VFuncC() {} // ① 주석 버전 / ② 주석 해제 버전
	int m_child = 0x22222222;
};


int main()
{
  	CChild c;
  
}

①번의 경우 다음과 같은 메모리 구조를 가진다.

하지만 ②와 같이 가상함수가 새롭게 선언되는 경우, 가상 상속에서 vfptr이 메모리 시작 위치에 존재하지 않으면 메모리 시작 위치에 vfptr을 새로 만들게 된다.

CChild 객체 c는 2개의 vfptr을 가지며

  	CChild* c = new CChild;

	c->VFuncC();
	c->VFuncP();
	delete c;

다음과 같이 실행했을 때 VFuncC과 VFuncP을 각각 다른 vfptr을 통해 호출하는 것을 확인.

profile
낙서장

0개의 댓글