Coding Memo

[에러] 상속과 가상 함수 테이블(vtable)의 메모리 레이아웃 본문

Language/C++

[에러] 상속과 가상 함수 테이블(vtable)의 메모리 레이아웃

minttea25 2024. 4. 19. 20:18

상황

 

GetQueuedCompletionStatus()가 성공적으로 완료되었음에도 불구하고, Overlapped 구조체의 값이 이상했다. (기대했던 값이 아니었다.)

 

GetQueuedCompletionStatus를 호출한 HANDLE에 `connectEvent`라는 overlapped를 상속하는 구조체가 연결되어 있는 파일 핸들을 등록했다. 따라서 GetQueuedCompletionStatus가 성공적으로 완료되었다면, 인자로 넣어주었던 LPOVERLAPPED*가 이 connectEvent를 가리키는 포인터가 되어야 된다. 그러나 이 포인터는 메모리 오염이 일어난 듯 값이 이상했다.

 

 

먼저, IOCPEvent와 ConnectEvent는 다음과 같다.

// EventType은 unsigned char 타입의 enum 구조체

struct IOCPEvent : public _OVERLAPPED
{
public:
	IOCPEvent(EventType eventType) : _eventType(eventType) {...}
public: // virtual
	virtual void Init() {};
private:
	inline void _init() {...}
private:
	EventType _eventType;
	std::weak_ptr<IOCPObject> _iocpObject;
};

struct ConnectEvent : public IOCPEvent
{
public:
	ConnectEvent() : IOCPEvent(EventType::Connect) {}
};

 

아래의 코드를 실행하는 중에 문제 발생.

DWORD numberOfBytesTransferred = 0;
IOCPEvent* iocpEvent = nullptr;
ULONG_PTR key = 0;

BOOL suc = ::GetQueuedCompletionStatus(
    _iocpHandle,
    OUT & numberOfBytesTransferred, 
    OUT & key,
    OUT reinterpret_cast<LPOVERLAPPED*>(&iocpEvent), 
    dwTimeoutMilliseconds);

 

기대대로라면, iocpEvent는 이전에 핸들에 등록했던 다른 핸들에 대한 overlapped를 가리켜야 한다. (의도대로라면!)

 

(벌써, 위 두 코드에서 문제가 발생할 것이라는 것을 예상할 수도 있다.......... 문제를 찾는다면 이 글을 더 이상 안읽어도 될 것이다. 정말 당연한 것이다.)

 

결과는 iocpEvent에서 _eventType은 enum에 있지도 않은 값을 가지고 있었고, _iocpObject 또한 ??? 였다.


문제

 

결과만 봐도, iocpEvent가 잘못 해석(interpret)되었다고 알 수 있을 것이다. 이는 곧, iocpEvent가 connectEvent를 가리키고 있지 않다는 의미이기도 하다. 왜 그럴까?

 


해결

 

메모리 주소를 직접 확인해보니, iocpEvent는 connectEvent를 가리키고 있는 것이 아니었다. 8 바이트 만큼 이동해서 가리키고 있었기 때문에 잘못 해석된 것이었다.

 

근본적인 문제는 IOCPEvent의 메모리 레이아웃에 있었다. 나는 `_OVERLAPPED를 상속하니까 _OVERLAPPED가 IOCPEvent의 메모리 가장 앞에 오겠지?`라는 단순무식한 생각을 해버렸던 것이었다. 따라서 reinterpret_cast로 _OVERLAPPED 포인터로 캐스팅해도 문제가 없을 것이라고 생각했다.

 

물론 맞는 말이긴하다. 상속하는 부모 클래스의 메모리가 자식 클래스의 메모리 앞에 오는 것은 맞다. 다만 문제는, 이 자식 클래스(IOCPEvent)는 virtual void Init이라는 가상함수를 가지고 있다는 것이다.

 

가상 함수를 가지고 있는 클래스는 상속받는 클래스의 메모리보다도 앞에 vtable(가상 함수 테이블)이라는 메모리 레이아웃을 가지고 있다.

 

따라서 IOCPEvent의 메모리 레이아웃에서 가장 앞에 있는 데이터는 _OVERLAPPED에 대한 데이터가 아니라, IOCPEvent의 가상 함수 테이블에 대한 데이터인 것이다.

 

아래 이미지는 실제로 실행시켜서 메모리가 다르다는 것을 확인한 것이다.

 

 

IOCPEvent에서 가상 함수가 있을 때, connectEvent의 메모리 (64바이트)

 

IOCPEvent에서 가상 함수가 없을 때, connectEvent의 메모리

 

(참고: 가상 함수가 없을 때의 connectEvent의 메모리에서, ff라는 값이 있는데, 이는 내가 _eventType의 메모리 위치를 확인하기 위해서 값을 255로 바꾸었기 때문에 나타나는 값이다. 가상 함수가 있을 때의 값은 기존 그대로 0이다.)

(참고2: _OVERLAPPED의 멤버변수는 전부 0으로 초기화 한 상태 이다.)

 

첫 번째 이미지의 앞에 8바이트가 바로 vtable에 대한 메모리인 것을 확인할 수가 있다.


결론

 

가상 함수를 포함하는 클래스(구조체)의 메모리 레이아웃에서 상속받는 클래스(구조체)의 메모리보다도 앞에 vtable(가상 함수 테이블)이라는 메모리를 가지고 있다.

 

따라서 이런 클래스(구조체)를 가리키는 포인터나 메모리에 대해 잘못된 캐스팅을 하지 않도록 주의하자!

 

실제로 reinterpret_cast를 통해 LPOVERLAPPED*로 캐스팅을 했는데, 이 때, 캐스팅하는 포인터는 connectEvent에 대한 포인터가 아니라, 가상 함수 테이블의 포인터였던 것이다.
GetQueuedCompletionStatus에서 (정상적이라면 가상 함수 테이블을 가리켜야 했던) 포인터(reinterpret_cast의 결과값)에 connectEvent의 _OVERLAPPED의 포인터를 할당했다. 따라서 iocpEvent를 IOCPEvent 클래스로 해석할 때, 가상 테이블부터 해석하기 때문에, 가상 테이블의 크기 값인 8 바이트만 큼 밀려 해석한 것이다.

 


추가 설명

 

struct IOCPEvent : public _OVERLAPPED
{
...
private:
	EventType _eventType; // Size: 1byte, Align: 1byte
	std::weak_ptr<IOCPObejct> _iocpObject; // Size: 16bytes, Align: 8bytes
};

// Note: _OVERLAPPED - Size: 32bytes, Align: 8bytes

// Size of IOCPEvent, 32 + 1 + 7(padding) + 16  =  56 (bytes)

 

 

일단, 가상 함수 테이블이 없는 connectEvent에 대한 메모리 레이아웃을 확인하자.

reinterpret_cast<LPOVERLAPPED*>(&iocpEvent)의 값을 r_ptr이라고 가정하자.

결국에 r_ptr부터 메모리를 읽으면 IOCPEvent 값으로 제대로 해석될 수 있다는 것을 의미한다.

GetQueuedCompletionStatus()가 성공해서 r_ptr는 connectEvent의 OVERLAPPED 메모리를 가리키게 된다. 따라서  r_ptr를 IOCPEvent 객체로 해석해도 문제없다.


그렇다면 가상 테이블이 포함되었을 때를 확인해보자.

OVERLAPPED 메모리 앞에 vtable이 온다.

GetQueuedCompletionStatus 이후에, 여전히 r_ptr은 connectEvent의 OVERLAPPED 메모리를 가리키므로 이 r_ptr을 iocpEvent로 해석하게된다면, OVERLAPPED가 아니라 메모리 가장 앞에 오는 vtable부터 해석하려하기 때문에 vtable의 사이즈인 8 바이트 만큼 밀려서 해석하게 된다.


실제 실행했을 때의 메모리를 확인한다.

(실제 실행했을 때의 메모리이다. '00 * 32'는 00이라는 값이 32번 반복된다는 의미이다.)

 

connectEvent 메모리 주소값: 0x00000232BE066510

--

[ a0 b9 f9 a0 f7 7f 00 00 (00 * 32) fe cd cd cd cd cd cd cd d0 64 06 be 32 02 00 00 b0 5b 08 be 32 02 00 00 ]

--

여기서 앞 8바이트는 vtable 값이고 괄호 안의 32개의 0값은 OVERLAPPED의 메모리 이다. 즉, 9번째로 등장하는 00이 OVERLAPPED의 주소값이 된다. 따라서 OVERLAPPED의 메모리 위치는 connectEvent의 시작 메모리 주소에 vtable의 주소 공간(8 바이트)를 뛰어넘은 `0x00000232BE066518`이 된다.

 

GetQueuedCompletionStatus가 실행되면서 r_ptr에 connectEvent의 OVERLAPPED의 주소값이 할당된다.

즉, iocpEvent는 OVERLAPPED의 주소값을 가리키게 된다.

--

[ (00 * 32) fe cd cd cd cd cd cd cd d0 64 06 be 32 02 00 00 b0 5b 08 be 32 02 00 00 00 cd cd cd cd cd cd cd ]

--

앞의 vtable에 대한 정보를 iocpEvent는 인지하지 못하고 있다고 생각하면 된다.

디버깅을 해보면, iocpEvent는 실제로 ` 0x00000232BE066518 `라는 값을 가지고 있고 이 값은 connectEvent의 OVERLAPPED의 메모리 주소 값이다.

 

 

 

 

문제는 여기서 부터다. iocpEvent는 IOCPEvent* 타입이다. 즉, 가리키는 주소부터 IOCPEvent라는 객체로 읽게 된다.

메모리를 IOCPEvent라는 객체로 해석하고 위해서는 다음의 순서를 따를 것이다.

 

1. vtable을 포함하고 있으므로 앞에 8바이트는 vtable로 읽는다.

2. 그 다음 32바이트를 OVERLAPPED 값으로 읽는다.

3. 그 다음 _eventType 값을 읽는다.

4. 8 바이트 정렬이므로, _eventType 뒤의 7바이트는 패딩으로 건너뛴다.

5. 이후 마지막 16바이트를 _iocpObject로 읽는다.

 

그렇다면 앞에 32바이트는 0의 값이므로, vtable의 메모리인 8바이트가 전부 0으로 읽히고

vtable: [ 00 00 00 00 00 00 00 ]

 

이후에 32바이트를 읽는데, 앞의 24바이트는 0의 값이고 그 다음 나머지 8바이트를 읽어서 OVERLAPPED로 인식한다.

OVERLAPPED: [ (00 * 24) fe cd cd cd cd cd cd cd ]

 

그렇다면 _eventType은...

_eventType: [ d0 ]

즉, d0는 208이다. 이렇게 해서 EventType에는 있지도 않은 값이 완성되었다 ㅋㅋ...

 

따라서 이후에 _iocpObject의 타입인 weak_ptr 타입으로 제대로 읽지 못했기 때문에, 위의 이미지와 같이 ???가 나타나고 있다!

 

Note: C++은 enum을 기본적으로 정수형으로 취급한다는 것을 기억하자. 따라서 그냥 정수값을 읽는 것처럼 읽기 때문에 90으로 _eventType이 읽힌 것이다.

 

여기까지 왜 이런 문제가 발생했는지 꽤 자세히 봤다고 생각한다. 결론적으로는 vtable에 대한 메모리가 해석되지 않아 8바이트가 밀려서, 메모리를 읽는데 문제가 생긴것이다.


connectEvent의 overlapped의 주소를 가리키고 있는 iocpEvent 포인터를 IOCPEvent 타입으로 해석하는데 있어, IOCPEvent가 메모리 앞에 가상 테이블을 포함있기에 문제가 발생한 것이 지금까지 정리한 오류의 원인이었다.

 

메모리까지 확인해가면서 자세히 살펴본 것 같다. 시간이 매우 많이 걸렸다!!!

 

문제 발생 후에 멤버 변수 값을 보고, 잘못 해석되었구나 정도는 알았지만, 정확히 무엇이 문제인지, 어떻게 해결할 수 있는지 바로 알지 못했다. 

 

C++의 기본 지식을 좀 더 탄탄히 할 필요가 있을 것 같다.

 


수정 히스토리

 

24.04.24(Wed): 일부 문장 수정 및 설명 추가 (iocpEvent의 주소값은 IOCPEvent의 vtable의 주소값을 가리켜야 하지만, GetQueuedCompletionStatus이후 iocpEvent는 connectEvent의 OVERLAPPED 주소값을 가리키고 있어, 포인터 변수 iocpEvent를 IOCPEvent로 해석할 때 문제가 발생했다는 내용)