함수 포인터

J·2025년 12월 30일

테스트

목록 보기
12/12
post-thumbnail
  • MSVC 기준이다.

  • 함수 포인터는 반환 타입을 비롯한 인자의 개수, 인자의 타입 정보, 함수 호출 규약, 클래스 타입 등의 정보 (함수의 시그니처 정보)를 가지며, 정확히 일치하는 시그니처만 대입 가능

  • ✨제일 중요하다고 느낀 것은 함수 포인터의 크기가 고정된 크기가 아니라는 것 ( 클래스 구성에 따라 달라짐 )

전역 함수 포인터

  • 전역 함수 포인터는 아는 것과 같은 기본적인 형태로 사용됨.
int Add(int a, int b)
{
	return a+b;
}

typedef int (*PADD)(int,int);

int main()
{
	PADD pAdd = &Add;
    int Sum = pAdd(1,2);
}

비가상 멤버 함수 포인터

멤버 함수 포인터의 사용과 상속 클래스간의 멤버 함수 대입

class CParent
{
public:
    int Add(int a, int b)
    {
        return a + b;
    }
};

class CChild : public CParent
{
public:
    int Sub(int a, int b)
    {
        return a - b;
    }
};

typedef int (CParent::* PFunc)(int, int);

typedef int (CParent::* PPFUNC)(int, int);
typedef int (CChild::* PCFUNC)(int, int);
int main()
{
    // 멤버 함수 포인터 사용
    {
        PFunc pFunc = &CParent::Add; // 규칙 강화로 &를 사용해야한다.

        CParent p;
        CParent* pP = &p;

        int sum1 = (p.*pFunc)(1, 2);
        int sum2 = (pP->*pFunc)(1, 2);
    }

    // 상속 클래스 간 멤버 함수 포인터 
    {
        CChild* pC = new CChild;
        CParent* pP = pC;

        PPFUNC pPFunc1 = &CParent::Add;
        PPFUNC pPFunc2 = &CChild::Add; // CParent::Add와 동일, 만약 CChild에서 Add를 정의하게 되면 (CChild::Add가 CParent::Add가 아니게 되면) 컴파일 에러

        PCFUNC pCFunc1 = &CParent::Add;
        PCFUNC pCFunc2 = &CChild::Add; // CParent::Add와 동일, 만약 CChild에서 Add를 정의하게 되면 CParent::Add가 아닌 CChild::Add가 들어감.
        PCFUNC pCFunc3 = &CChild::Sub;

        (pP->*pPFunc1)(1, 2);
        (pC->*pPFunc1)(1, 2);
        
        (pP->*pPFunc2)(1, 2);
        (pC->*pPFunc2)(1, 2);

        // (pP->*pCFunc1)(1, 2); // 컴파일 에러!!
        // (pP->*pCFunc2)(1, 2); // 컴파일 에러!!
        // (pP->*pCFunc3)(1, 2); // 컴파일 에러!!

        (pC->*pCFunc1)(1, 2);
        (pC->*pCFunc2)(1, 2);
        (pC->*pCFunc3)(1, 2);

        delete pC;
    }
}

상속관계에서의 포인터를 사용하여 함수 호출하는 것과 동일.

멤버 함수 포인터와 this

  • 비가상 멤버 함수의 경우 해당 멤버 함수가 정의된 클래스 메모리 시작 주소를 this로 사용하기 때문에 offset을 통해 this를 결정해야할 경우는 멤버 함수 포인터의 크기가 기본 크기보다 커질 수 있음 (MSVC, offset 정보 추가, 필요없는 경우 추가되지 않음)
class CTest
{
public:
	int Add(int a, int b)
	{
		printf("Add this : %p\n", this);
		return a + b;
	}

	int m_val1 = 0x11111111;
};

class CTestB
{
public:
	int Subtract(int a, int b)
	{
		printf("Subtract this : %p\n", this);

		return a + b;
	}
	int m_val2 = 0x22222222;
};

class CChild : public CTest, public CTestB
{
public:
	int m_val3 = 0x33333333;
};
typedef int (CChild::* PFunc)(int, int);

void main()
{

	CChild* pC = new CChild;
	printf("main pC : %p\n", pC);

	PFunc pFunc1 = &CChild::Add;
	int Sum = (pC->*pFunc1)(1, 2);

	PFunc pFunc2 = &CChild::Subtract;
	int Gap = (pC->*pFunc2)(1, 2);


	int s1 = sizeof(pFunc1);
	int s2 = sizeof(pFunc2);

	printf("S1 : %d\n", s1);
	printf("S2 : %d\n", s2);
	
	delete pC;
}

CChild 객체의 메모리 구조는 다음과 같다.

CTest 클래스와 CChild 클래스는 시작 지점이 같기 때문에 offset에 0이 들어가있고, 저 offset을 통해 CTest::Add 호출 전 this를 재설정하는 것을 확인.

CTestB 클래스는 CChild 클래스로부터 +4바이트 이후에 시작되므로 offset에 4가 들어가있고, offset을 통해 CTestB::Sub 호출 전 CChild 객체 주소 + offset (4)를 활용함.

또한 다음과 같은 경우

// CTest, CTestB, CChild는 기존과 동일
class COther
{
	int m_other;
};

class CGrandChild : public COther, public CChild
{
public:
};

typedef int (CChild::*PFUNC)(int, int);

int main()
{
	CGrandChild* pGc = new CGrandChild;
    
    PFUNC pFunc = &CChild::Subtract;
    
    // 위 코드는 다음 형태와 같다.
    int gap = (pGc->*pFunc)(1,2); 
    
    // CChild* pC = pGc;
    // int gap = (pc->*pFunc)(1,2);
    
    delete pGc;
}

CGrandChild 객체의 메모리 구조는 다음과 같지만

pFunc은 CChild의 함수 포인터이기 때문에 호출 시 ecx는 CChild의 포인터로 사용하는 것과 같이 조정된 후 전달된다.

멤버함수 포인터 크기

멤버 함수 포인터 타입은 경우에 따라 offset이 필요할 수도, 필요없을 수도 있다. (MSVC 기준) 아예 필요없는 경우는 만들지 않지만, 확실하지 않다면 컴파일러는 offset 공간을 무조건 마련한다.

// 이 말 뜻이 멤버 함수 포인터가 사용되는 클래스에 대해 그 클래스와 상속 관계를 이루는 클래스의 
// 시작 메모리 주소가 동일하지 않다면 offset 공간을 마련하는 것으로 느껴짐 
// Ret (MyClass::*NAME)(param1, param2, ...)
// class MyClass : public ParentClass 에서 MyClass 객체의 
// MyClass 시작 주소와 ParentClass의 시작 주소가 다른 경우와 같이.

// 멤버 함수 포인터 크기 측정


class CP
{
public:
	void Parent_CP() { void* p = this; }
};
class CParentA
{
public:
	void Parent_A() { void* p = this; }
	int m_a = 0x11111111;
};

class CParentB
{
public:
	void Parent_B() { void* p = this; }
	int m_b = 0x22222222;
};

// 단일 상속
class CChildA : public CParentA
{
public:
	void Child_A() { void* p = this; }
	int m_ca = 0x33333333;
};

// 단일 상속 + 자식 클래스 가상 함수
class CChildB : public CParentA
{
public:
	virtual ~CChildB() { };
	void Child_B() { void* p = this; }

	int m_cb = 0x44444444;
};

// 다중 상속
class CChildC : public CParentA, public CParentB
{
public:
	void Child_C() { void* p = this; }
	int m_cc = 0x55555555;
};

// 다중 상속이지만, CChild 객체의 CP, CParentB, CChildD 클래스 시작 주소는 모두 동일하기 때문에 offset 생기지 않는다!!
class CChildD : public CP, public CParentB
{
public:
	void Child_D() { void* p = this; }
	int m_cd = 0x66666666;
};

typedef void (CParentA::* PAFUNC)(); // CParentA 멤버 함수 포인터
typedef void (CParentB::* PBFUNC)(); // CParentㅠ 멤버 함수 포인터
typedef void (CChildA::* CAFUNC)(); // CChildA 멤버 함수 포인터
typedef void (CChildB::* CBFUNC)(); // CChildB 멤버 함수 포인터
typedef void (CChildC::* CCFUNC)(); // CChildC 멤버 함수 포인터
typedef void (CChildD::* CDFUNC)(); // CChildD 멤버 함수 포인터

void main()
{
	int sizeOfPA = sizeof(PAFUNC);
	int sizeOfPB = sizeof(PBFUNC);
	int sizeOfCA = sizeof(CAFUNC);
	int sizeOfCB = sizeof(CBFUNC);
	int sizeOfCC = sizeof(CCFUNC);
	int sizeOfCD = sizeof(CDFUNC);

	printf("PA Size : %d\n", sizeOfPA);
	printf("PB Size : %d\n", sizeOfPB);
	printf("CA Size : %d\n", sizeOfCA);
	printf("CB Size : %d\n", sizeOfCB);
	printf("CC Size : %d\n", sizeOfCC);
	printf("CD Size : %d\n", sizeOfCD);
}

①CParentA / ②CParentB : this가 변할 일 없음. => 4byte

③CChildA : 객체의 CParentA와 CChildA의 시작 메모리 주소가 동일하다 => this가 변할 일이 없다. => 4byte

④CChildB : vfptr을 가지기 때문에 CChildB와 CParentA의 시작 메모리 주소가 다르다. (CBFUNC엔 CChildB / CParentA의 멤버 함수가 들어갈 수 있음 -> 함수가 저장될 때 offset 값을 저장한다. => 8byte

⑤CChildC : 다중 상속. CChildC와 CParentA, CParentB의 시작 메모리 주소가 다르다. (CCFUNC에는 CChildC / CParentA / CParentB 의 멤버 함수가 들어갈 수 있음 => 함수가 저장될 때 offset 값을 저장한다. => 8byte

⑥CChildD : 다중 상속이지만 CP 클래스의 크기가 0이기 때문에 객체의 CChildD / CP / CParentB 메모리 시작 주소는 모두 동일하다 => this가 변할 일이 없다. => 4byte

가상 상속 클래스 멤버 함수 포인터

가상 상속의 경우 vbptr의 offset 정보를 추가한다. 이때는
[ 함수 주소 | this offset | vbtable로부터 해당 가상 기반 클래스의 오프셋주소까지의 상대적 메모리 차이 (x86 / x64 상관없이 vbtable은 int배열처럼 => 개당 4바이트 )]의 형태로 저장된다.
코드를 보면서 떠올릴 수 있도록 흐름을 적겠다...

class CParent
{
public:
	void FuncParent() { void* p = this; }
	int m_parent = 0x11111111;
};

class CChild : virtual public CParent
{
public:
	void FuncChild() {}
	int m_child = 0x22222222;
	int m_child2 = 0x22222222;
};

class MD 
{
public:
	void FF() { void* p = this; }
	int m_md1 = 0x33333333;
	int m_md2 = 0x33333333;
};

class CGrandChild : public CChild, public MD
{
public:
	int m_arrayGrandChild = 0x44444444;
};

typedef void (MD::*M)();
typedef void (CChild::*F)();

int main()
{
	F f = &CParent::FuncParent; // ① - ①
	F f2 = &CChild::FuncChild;  // ② - ①
	M m = &MD::FF;			    // ③ - ①

	int sizeOfF = sizeof(F);
	int sizeOfM = sizeof(M);

	printf("size of F : %d\n", sizeOfF);
	printf("size of M : %d\n", sizeOfM);
	
	CGrandChild* cg = new CGrandChild;

	(cg->*f)();  // ① - ②
	(cg->*f2)(); // ② - ②
	(cg->*m)();  // ③ - ②
}

CChild 객체의 구조는 다음과 같다.

내가 생각한 흐름.

사이즈의 출력은 다음과 같이 된다.

①-①과 ②-①을 살펴보자.
F f의 경우는 CChild의 멤버 함수 포인터이고, CParent의 함수를 저장한다.
CChild의 주소를 받았을 때 this를 CParent로 설정해야하는데, CParent 클래스는 가상 기반 클래스이기 때문에 this offset만으로는 결정할 수 없는 문제가 존재한다.
(만약 위 코드에서 CChild c를 통해 (c->f)()와 (cg->*f)()를 호출하게 된다면 CChild 클래스와 CGrandChild의 시작 메모리 주소로부터 가상 기반 클래스인 CParent까지의 offset자체가 다르기 때문에) 그래서 vbtable에서 가상 기반 클래스 CParent까지의 오프셋이 저장된 위치까지의 차이를 상대적으로 계산해 저장.

F f2의 경우 CChild의 주소를 받아서 CChild의 this로 사용하기 때문에 변화 없음.. 하지만 컴파일러는 F에 대해 f처럼 offset에 대한 처리가 필요할 수도, f2처럼 필요없을 수도 있기 때문에 다 만드는것으로 보인다.. f와 f2는 다음과 같이 구성된다.

③-①
M m의 경우 MD는 상속없는 단일 클래스이기 때문에 M은 멤버 함수에 대한 정보만을 가진다. 구성이 FuncParent와 같기에 FuncParent의 주소가 들어간다.

①-② cg를 사용하기 위해서 cg는 일단 CGrandChild에서 CChild 형태로 사용될 것이고, 현재의 경우는 두 클래스의 메모리 시작 주소가 동일하다.
f는 CParent의 함수를 담고 있기 때문에 현재 this인 CChild를 CParent주소로 변경한 후 call을 해야한다.

②-②는 CChild의 함수를 호출하고 있기 때문에 this를 CChild로 계산하는 것을 빼고는 동일하다.

③-② .. MD클래스는 상속없는 단일 클래스이기 때문에 멤버 함수 포인터 호출을 하기 위해 다른 작업이 필요 없이 CGrandChild를 바로 MD 형태로 변환하는 것과 같이 바로 add를 통해 MD 메모리 시작 주소로 설정한 후 call을 한다.

내 생각..
결국 상속관계에서 함수포인터는 부모 클래스의 함수를 담을 수 있음으로 인해 함수 포인터가 부모클래스의 함수를 담게 되면 그 과정까지의 offset을 담아야 하는 필요가 생김 + 상속 형태에 따라 약간의 정보 추가 (해당 함수를 호출하는 클래스까지의 offset / vbptr내부에서의 offset)가 이루어진다.

아키텍처상속 형태전체 크기내부 구성
 x86 일반 상속8 byte4 byte (함수 포인터) + 4 byte (this 보정값)
 x86 가상 상속12 byte4 byte (함수 포인터) + 4 byte (this 보정값) + 4 byte (vbtable offset)
 x64 일반 상속16 byte8 byte (함수 포인터) + 4 byte (this 보정값) + padding
 x64 가상 상속16 byte8 byte (함수 포인터) + 4 byte (this 보정값) + 4 byte (vbtable offset)

가상 함수 포인터

  • 멤버 함수 포인터이기 때문에 비가상 멤버 함수를 담을 때와 유사하지만 다음과 같은 차이가 있다.

    • 가상 함수 호출 시 this는 가상 함수가 선언된 클래스로 세팅이 된다는 점.
    • vcall (일종의 thunk처럼 특정 목적을 수행하는 간단한 코드) 을 거친다.
class CParentA
{
public:
	void FuncParentA() { printf("CParentA::FuncParentA\n"); }
	virtual void VFuncParentA() { void* p = this; printf("CParentA::VFuncParentA\n");}
	int m_parentA = 0x11111111;
};
class CParentB
{
public:
	void FuncParentB() { printf("CParentB::FuncParentB\n"); }
	virtual void VFuncParentB() { void* p = this;  printf("CParentB::VFuncParentB\n");}
	virtual void VFuncParentB2() { void* p = this;  printf("CParentB::VFuncParentB2\n");}
	int m_parentB = 0x22222222;
};

class CChild : public CParentA, public CParentB
{
public:
	void FuncParentA() { void* p = this; printf("CChild::FuncParentA\n"); }
	void FuncParentB() { void* p = this; printf("CChild::FuncParentB\n"); }
	
	virtual void VFuncParentA() { void* p = this; printf("CChild::VFuncParentA\n");}

	virtual void VFuncParentB() { void* p = this; printf("CChild::VFuncParentB\n");}

};
typedef void (CParentA::* PPFUNC)();
typedef void (CChild::* PCFUNC)();

int main()
{
	printf("Size of PPFUNC : %d\n", sizeof(PPFUNC));
	printf("Size of PCFUNC : %d\n", sizeof(PCFUNC));
	
	CParentA p1;
	CChild c;

	// 1. 함수 호출에 있어서 this를 어떻게 전달할 것인가.?
	// 2. 함수 포인터는 가상 함수를 어떻게 호출하는 것인가? 에 대한 부분이 달라진다.
	
	// PPFUNC은 상속없는 단일 클래스의 함수 포인터 -> 4바이트의 공간 (가상함수 관계 없음)
	PPFUNC pParentVFunc = &CParentA::VFuncParentA;
	
	// 가상 함수 호출
	(p1.*pParentVFunc)();
	
	// PCFUNC은 상속 관계에서 OFFSET을 활용하기 때문에 8바이트의 공간을 차지
	PCFUNC pChildFunc = &CChild::FuncParentB;  
	
	// ☆가상 함수를 호출할땐 그 가상함수가 선언된 클래스를 this로 전달해야한다.!!
	PCFUNC pChildVFunc= &CChild::VFuncParentB2; 

	// 비가상 함수 호출
	(c.*pChildFunc)();  
	
	// 가상 함수 호출
	(c.*pChildVFunc)();  
}
  1. PPFUNC은 CParentA의 멤버 함수 포인터이다. CParentA는 상속 관계가 아니기 때문에 PPFUNC은 CParentA의 멤버 함수만을 담으며, offset을 가질 필요가 없다.

PPFUNC pParentVFunc = &CParentA::VFuncParentA; 코드를 보면
vcall{0}을을 담고 있으며

pParentVFunc을 호출 시 vcall{0}이라는 코드를 호출하게 되는데 이 vcall{0}은 vfptr을 이용해 해당 가상함수 CParentA::VFuncParentA를 실행한다.

  1. PCFUNC은 CChild의 멤버 함수 포인터이며, CParentA와 CParentB의 멤버 함수를 담을 수 있기 때문에 offset을 가져야한다.

PCFUNC pChildFunc = &CChild::FuncParentB; 의 경우
pChildFunc은 CChild::FuncParentB를 담고 있으며 호출 시 this값으로 사용되는 것은 CChild 클래스의 메모리 시작 지점이어야 한다. 따라서 offset은 0이다.

PCFUNC pChildVFunc= &CChild::VFuncParentB2; 의 경우

pChildVFunc은 가상함수인 VFuncParentB2를 호출하기 위해서 this 값을 VFuncParentB2가 실제로 선언되어있는 CParentB로 전달해야 한다.
그렇기 때문에 현재의 메모리 구조에서 offset은 8이 기록된다.

함수 호출 시 다음과 같은 형태로 ecx가 세팅된다.

가상 함수 호출 시 call 되는 부분을 살펴보면 다음과 같이 실행된다.

현재 CChild와 관련된 가상함수 테이블은 이렇다.

또한 vcall{4}인 이유는 가상함수 테이블의 1번째 항목 (x86이니 주소상 +4)을 가리키기 때문이었다. vcall{0}은 가상함수 테이블의 0번째 항목 (x64에서는 0, 8, 16... 로 8의 배수)

그림에서 CParentA::vfptr과 CParentB::vfptr은 CParentA에서 물려받은 vfptr과 CParentB에서 물려받은 vfptr을 의미한다.

가상 상속과 가상 함수 포인터

위에 나왔던 가상 상속 / 가상 함수의 경우를 모두 적용하면 된다.

class CGrandParentA
{
public:
	virtual void VFuncA() = 0;
	int m_grandParentA[8]{0x11111111,0x11111111, 0x11111111, 0x11111111, 0x11111111, 0x11111111, 0x11111111, 0x11111111};
};

class CGrandParentB
{
public:
	virtual void VFuncB() = 0;
	int m_grandParentB[4]{0x22222222,0x22222222,0x22222222,0x22222222};
};

class CParent : public CGrandParentA, public CGrandParentB
{
public:
	int m_parent[2]{ 0x33333333 ,0x33333333 };
};

class CChild : virtual public CParent
{
public:
	virtual void VFuncA() override
	{
		void* p = this;
	}

	virtual void VFuncB() override
	{
		void* p = this;
	}
	int m_child = 0x44444444;
};

typedef void (CChild::* PCFUNC)();


int main()
{
	CChild c; 
        
	CChild* pC = &c;

	// PCFUNC의 크기를 예상해보자 
	// 각각의 요소에 어떤 값이 들어가게 될지 예상해보자.
	PCFUNC vFuncA = &CChild::VFuncA;
	PCFUNC vFuncB = &CChild::VFuncB;
	
	printf("size of PCFUNC : %d\n", sizeof(PCFUNC));

	// 1. 함수 호출 시 ecx값이 어떻게 설정될지 예상해보자.
	(pC->*vFuncA)();

	// 2. 함수 호출 시 ecx값이 어떻게 설정될지 예상해보자.
	(pC->*vFuncB)();
}

생각했을 때 CChild클래스의 크기는 72byte를 가지는데, sizeof(CChild)는 76byte로 나온다. 이건 CChild 클래스에서 가상 함수를 선언했을 때 vfptr이 생기는 것까지 생각해서 이런 결과가 나오는 것일까?? 현재는 생성자 호출 시 메모리의 변화를 확인했을 때 72byte만 변경된다. 이것은 잠시 접어두고 함수 포인터를 통해 여러 함수 호출 시 전달되는 ecx (this포인터의 세팅)을 생각해보자.

CChild는 CParent 클래스를 가상 상속받고 있으며, CParent는 CChild의 가상 기반 클래스이다. 해당 클래스의 메모리 구조는 다음과 같이 이루어진다.

vFuncA는 GrandParentA 클래스로부터 선언된 가상함수 VFuncA를 호출하도록 했고, vFuncB는 GrandParentB 클래스로부터 선언된 가상함수 VFuncB를 호출하도록 했기 때문에 호출 함수 주소를 가리키는 항목엔 둘 다 vcall{0}이 들어가게 된다.(VFuncA와 VFuncB 모두 선언된 클래스에서의 0번째 가상함수이니)

vFuncA과 vFuncB에서 내부 vbTable에서의 offset을 넣는 항목은 모두 4가 들어가게 된다.(각 함수를 호출하게 될 때 ecx 값은 그 가상함수가 최초 선언된 클래스의 시작 주소를 전달하고, vFuncA와 vFuncB가 호출될때 전달해야 하는 각 주소는 CGrandParentA와 CGrandParentB의 주소이다. 이 주소를 찾기 위해서 CChild의 [vbTable + 4] 주소엔 CChild의 vbptr이 위치한 주소에서 CParent까지 가기 위한 오프셋이 기록되어 있기 때문에 4가 기록되어 있고, 나중에 ecx 세팅을 위해 이 4라는 항목을 통해 [vbTable + 4]에 있는 offset을 활용할 것이다.)

vFuncA의 경우 vbTable offset을 통해서 얻은 주소(CParent)가 CGrandParentA와 같기 때문에 this offset을 설정하는 항목은 0이 된다.

vFuncB의 경우 vbTable offset을 활용해 얻은 주소 (CParent)에서 CGrandParentB로 가기 위해 this offset을 활용해야하고, 그 차이는 36이기 때문에 36이 기록된다.

이 상황에서 함수 포인터를 사용하여 함수를 호출할 때 순서를 정리하면 ( 가상 상속 / 가상 함수에서 봤던 흐름과 동일하기에 그림은 제외)

  1. 멤버 함수 포인터로 지정된 클래스(CChild)로 사용하기 위한 ecx 설정 (현재는 동일하기 때문에 바로 &c 사용)
// 만약 다음과 같다면
class CGrandChild : public CChild
{
	// 이 상황에서는 CGrandChild 객체에서 CGrandChild 클래스 시작 위치와 CChild 시작 위치가 다르다. 
	virtual void VFuncGC(){}
}
typedef void (CChild::* PCFUNC)();

int main()
{
	// ...
    PCFUNC vFuncA = ...
    CGrandChild cg;
    (cg.*vFuncA)(); // 이때 ecx는 CGrandChild 객체 내에서 ecx를 CChild 위치로 설정한다는 얘기.
}
  1. 위에서 말했듯 각 함수를 호출하게 될 때 ecx 값은 그 가상함수가 최초 선언된 클래스의 시작 주소를 전달하고, vFuncA와 vFuncB가 호출될때 전달해야 하는 각 주소는 CGrandParentA와 CGrandParentB의 주소이다. 이 주소를 찾기 위해서 CChild의 [vbTable + 4] 주소엔 CChild의 vbptr이 위치한 주소에서 CParent까지 가기 위한 오프셋이 기록되어 있고, 4라는 값은 vFuncA과 vFuncB에서 내부 vbTable에서의 offset을 넣는 항목에 저장되어 있기 때문에 이를 활용해 CParent의 주소를 찾는다.

  2. vFuncA의 경우 CParent의 주소에서 CGrandParentA까지 가야하는 offset을, vFuncB의 경우 CParent의 주소에서 CGrandParentB까지 가야하는 offset을 구해야하는데 이것은 각각의 this offset기록을 위한 항목에 저장되어 있고, 이 모든 작업을 통해 ecx를 셋팅한다. (vFuncA는 0, vFuncB는 0x24 (36))

  3. CGrandParentA와 CGrandParentB에서 선언된 가상함수는 각각 1개씩밖에 존재하지 않고, 그것은 VFuncA와 VFuncB이다. vcall{0}을 통해 vFuncA,vFuncB는 가상함수 테이블에 존재하는 0번째 함수 (VFuncA, VFuncB)의 주소로 가게 되고, 그때 ecx로 CGrandParentA의 주소, CGrandParentB의 주소를 전달하게 되며, 이 가상 함수에서는 전달받은 ecx에서 CChild만큼 만들기 위해 ecx를 sub해 활용한다.

전방 선언 함수 포인터

전방 선언 : 해당 클래스가 존재한다는 사실을 알려줌
이를 통해 클래스 구조를 모르더라도 해당 클래스 포인터(어차피 포인터는 크기가 고정형태) 또는 전방 선언 함수 포인터를 선언할 수 있음

class CParent
{
public:
	BYTE m_arrayP[4]{ 0x01,0x02,0x03,0x04 };

	virtual void VFuncP() = 0;
};

class CChild : virtual public CParent
{
public:
	BYTE m_arrayC[8]{ 0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18 };

	virtual void VFuncP() { printf("CChild::VFuncP\n"); }
	virtual void VFuncC() { printf("CChild::VFuncC\n"); }
};

enum
{
	CHILD_FUNC = 0,
	PARENT_FUNC = 0,

	THIS_OFFSET,  // 비가상 상속관계에서 실제 호출되는 클래스로 변형을 위해
	VBPTR_OFFSET, // ☆ 전방 선언 함수 포인터에서 vbPtr 위치를 찾기 위한 오프셋
	VBMEMBER_OFFSET // 가상 상속이 이루어지는 클래스일때, 전방 선언 함수 포인터에서 VBPTR_OFFSET 추가로 한 칸 밀려난다.
};

void main()
{
	CTest t;
	CChild c;

	int sizeOfPCFUNC = sizeof(PCFUNC);

	printf("sizeOfPCFUNC : %d\n", sizeOfPCFUNC);

	t.m_pFuncChild = &CChild::VFuncC;
	t.m_pFuncParent = &CChild::VFuncP;

	t.FuncChild(&c); //(c.*t.m_pFuncChild)();
	t.FuncParent(&c);// (c.*t.m_pFuncParent)();

	int* funcOffsetPArray[]{ (int*)(&t.m_pFuncChild),(int*)(&t.m_pFuncParent)};

	for (int i = 0; i < 2; i++)
	{
		std::cout << i << std::endl;

		printf("%-20s : [%d]\n", "THIS_OFFSET", funcOffsetPArray[i][THIS_OFFSET]);
		printf("%-20s : [%d]\n", "VBPTR_OFFSET", funcOffsetPArray[i][VBPTR_OFFSET]);
		printf("%-20s : [%d]\n\n", "VBMEMBER_OFFSET", funcOffsetPArray[i][VBMEMBER_OFFSET]);
	}
}
// 사용 형태

전방 선언 함수 포인터의 크기는 예상할 수 있는 구조를 모두 포함해야한다. (클래스의 크기를 모두 모르기 때문에 최대치를 예상)

가상 상속의 경우, 위 코드에서는 CChild 객체의 메모리는 vfptr - vbptr의 순서로 존재하지만, 만약 CChild에서 VFuncC가 선언되지 않았다면 CChild 객체의 메모리 시작 지점의 vfptr은 존재하지 않는다. 전방 선언을 하면 클래스가 있다는 사실은 알지만, 클래스의 구조는 모르기때문에 전방 선언 함수 포인터는 해당 기준 클래스로부터 vbptr까지의 정보까지 저장해야하며 구조는 아래와 같다.

또한 ⭐컴파일러는 4번 항목이 0일 경우 3번 항목도 0으로 설정하게 되며, 실제 함수 포인터 호출 과정을 대폭 간소화시키게 된다.(중요) (4번 항목이 0일 경우 결국 자기 자신을 가리키게 되기 때문에 3번 항목이 의미가 없음)

  • 전방 선언 함수 포인터

    역시 x64에선 항목3의 offset은 8단위로 늘어남


  • CChild 객체 c

t.FuncChild(&c); 호출 코드를 보자.

이 코드는 분기를 타며, 분석한 실행 로직은 이렇다.

t.m_pFuncChild는 전방 선언된 클래스 CChild의 함수 포인터이다.
그렇기 때문에 위에 나왔던 전방 선언 함수 포인터의 구조를 가지며, 값이 채워지게 된다. t.m_pFuncChild = &CChild::VFuncC; 이 코드에서 함수 포인터에 정보를 채울 때, 호출할 때 분석한 결과는 다음과 같다.

① 항목. VFunC가 가상 함수기 때문에 vcall을 하는 thunk 코드를 호출하도록 한다.
②~④ 항목. CChild::VFunc의 최초 선언한 클래스는 CChild 클래스이다. 결국 최종적으로 CChild 클래스가 ecx 셋팅되어야 한다. CChild 객체의 주소를 받아서 CChild 클래스로 셋팅해야함 (변화 없음) -> ④항목은 0 (가상 기반 클래스가 아닌 자기 자신을 가리켜야함) -> ④항목이 결국 자기 자신을 가리키기 때문에 vbptr을 사용할 필요 없음 -> ③항목도 0이 된다 -> CChild에서 this offset을 움직일 필요 없음 (CChild) -> ②항목도 0이 된다

t.FuncChild(&c);호출 시 해당 함수 포인터 (m_pFuncChild)의 4번째 항목이 0이냐 아니냐 (자신에서 시작하냐, 가상 기반 클래스에서 시작하냐)에 따라 분기를 타는 모습이다.


t.m_pFuncParent도 호출 코드는 비슷하다. 하지만 코드의 동작이 다르다.
t.m_pFuncParent = &CChild::VFuncP;

① 항목. VFuncP가 가상 함수기 때문에 vcall을 하는 thunk 코드를 호출하도록 한다.

②~④ 항목. t.m_pFuncParent = &CChild::VFuncP;이지만 VFuncP가 처음 선언된 클래스는 CParent 클래스이다. 결국 최종적으로 CParent 클래스가 ecx 셋팅되어야 한다. CChild 객체의 주소를 받아서 CParent 클래스로 셋팅해야함 -> CParent 클래스는 CChild 클래스의 가상 기반 클래스가 되고, offset은 4가 된다. -> CChild의 메모리는 vfptr - vbptr 순으로 이루어져있으며 CChild의 메모리 시작으로부터 vbptr이 위치한 메모리까지의 오프셋은 4이다. -> 가상 기반 클래스 CParent 에서 this offset을 움직일 필요가 없음 (CParent) -> ② 항목은 0이 된다.

t.FuncParent(&c) 호출 시 이전과 다르게 ④항목이 0이 아니기 때문에 다른 분기를 탄다.

함수 호출 시 로직의 흐름은 여태까지 했던 것과 다르지 않게
포인터 변환 -> 실제 함수 호출 클래스로 변환 (가상기반클래스 체크 -> 상속 관계에서의 thisoffset 체크의 순서) 동일하지만, 가상 기반 클래스가 존재하지 않는다면 굳이 vbptr까지의 이동 처리 없이 분기를 타서 쓸데없는 작업을 하지 않는게 인상적이었다.

공부하며 여태까지 함수 포인터에 대해 잘 몰랐구나.. 라는 사실을 깨닫는다. 그래도 기본적인 내용을 공부하고 어셈블리도 잘 모르지만 계속 보니 눈에 익숙해지고 기억에 많이 남는 것 같다. 나중에 이 내용이 잊히더라도 이 글을 정독하고 다시 떠올릴 수 있으면 좋겠다.

profile
낙서장

0개의 댓글