본문 바로가기
C&C++

Derived Class에만 소멸자에 virtual이 붙으면 invalid free가 발생하는 건에 대하여

by Ken out of ken 2024. 11. 22.
#include <string>
#include <iostream>

class WrongAnimal
{
protected:  
	std::string type_;

public:
	~WrongAnimal() { std::cout << "WrongAnimal" << std::endl; };
};

class WrongCat : public WrongAnimal
{
public:   
	virtual ~WrongCat() { std::cout << "WrongCat" << std::endl; };
};

int main(void)
{
	WrongAnimal* wa = new WrongCat();
    
	delete wa;

	return 0;
}

 

위의 코드를 실행하면 다음과 같은 출력문이 뜬다

 

WrongAnimal
malloc: *** error for object 주소값: pointer being freed was not allocated
malloc: *** set a breakpoint in malloc_error_break to debug

 

도대체 왜?

WrongCat의 소멸자가 호출이 된 것도 아닌데, WrongAnimal의 소멸자가 호출이 되고 잘못된 주소값에 대한 free()가 발생하는지 의문이었다   

 

디버깅을 해보았지만 정확한 답은 나오지 않았기에 어쩔 수 없이 어셈블리를 열어보기로 했다   

 

 

https://godbolt.org/

 

Compiler Explorer

 

godbolt.org

에서 해당 코드를 입력하고 어셈블리어를 얻었다

 

어셈블리어

mov     edi, 40
call    operator new(unsigned long)     ; 40바이트 크기의 메모리 할당
mov     rdi, rax                        ; rbx에 메모리 시작 주소 저장
mov     qword ptr [rbp - 32], rdi		; [rbp - 32]에 메모리 시작 주소 저장

.
.
.

mov     rcx, qword ptr [rbp - 32]		; rcx로 할당된 메모리 시작 주소 저장
xor     eax, eax
cmp     rcx, 0
mov     qword ptr [rbp - 24], rax
je      .LBB1_2
mov     rax, qword ptr [rbp - 32]
add     rax, 8
mov     qword ptr [rbp - 24], rax


.
.
.

mov     rdx, QWORD PTR [rbp-40]         ; rdx에 객체 포인터 (rbx+8) 저장
mov     rsi, rdx
mov     rdi, rax                        ; 반환값 저장할 공간의 주소
call    WrongAnimal::getType[abi:cxx11]() const

.
.
.


mov     rbx, QWORD PTR [rbp-40]         ; rbx에 rbx+8 저장
test    rbx, rbx
je      .L26
mov     rdi, rbx                        ; rbx+8 주소로 소멸자 호출
call    WrongAnimal::~WrongAnimal()
mov     esi, 32
mov     rdi, rbx                        ; rbx+8 주소로 operator delete 호출
call    operator delete(void*, unsigned long)

 

위의 어셈블리를 통해 delete 연산자에 WrongAnimal의 시작 주소가 아닌 WrongCat의 시작주소로 추정되는 WrongAnimal의 부분 객체의 주소를 넘기는 것을 알 수 있다   

 

그렇기에 원래 delete 연산자에 할당받은 메모리의 시작 주소 rbx가 아닌 그보다 8바이트 뒤의 주소 rbx + 8을 전달하기에 메모리의 중간값을 받아 free invalid pointer 라는 에러가 나는 것이다   

 

그렇다면 왜 자원을 해제할 때 메모리의 시작주소를 넘겨야 할까?   

 

그 이유는 메모리의 헤더 정보 때문인데, 기본적으로 각 할당된 메모리의 앞부분에 있는 헤더 정보는 OS의 메모리 관리자가 할당 크기나 블록의 상태를 추적하는 데 사용하며 해제할 때에도 이 정보를 이용하기에 시작 부분을 받아야 제대로 된 해제가 이루어질 수 있기 때문이다!

 

 

 

 

 

 

그렇기에 여러 테스트를 진행해 보았다!

#include <string>
#include <iostream>

class WrongAnimal
{
protected:
	// 멤버 변수는 각 객체마다 고유하게 메모리에 할당되기에 객체의 크기에 영향을 끼친다
	// base 객체에 멤버 변수가 할당이 된다면 dervied 객체와의 시작 주소가 달라진다   
	std::string type_;

public:
	~WrongAnimal() { std::cout << "WrongAnimal" << std::endl; };

	// 멤버 함수는 객체 내부가 아닌 text segment에 저장되기 때문에 
	// 객체의 크기에 영향을 끼치지 않는다
	void makeSound() const {};
};

class WrongCat : public WrongAnimal
{
public:
	// 영향을 받아 부모 객체의 크기가 커진 지금 자식 객체의 소멸자가 virtual로 호출이 되면   
	// base 객체에서 멤버 변수의 크기를 더한 derived 객체의 시작 주소값을 전달하게되어 
	// 잘못된 위치의 메모리 해제가 이루어지게 된다    
	virtual ~WrongCat() { std::cout << "WrongCat" << std::endl; };

	void makeSound() const {};
};

int main(void)
{
    WrongAnimal* wa = new WrongCat();
	WrongCat *wc = new WrongCat();

	// 두 객체의 size를 재본 결과 크기는 1임을 알 수 있다 (virtual table크기는 계산 안함)
	// 이를 통해 base 객체가 비어있으면 dervied 객체와 시작 주소가 동일하게 설정됨을 알 수 있다   
	std::cout << "WrongCat size: " << sizeof(*wa) << std::endl; // WrongAnimal size: 1
	std::cout << "WrongCat size: " << sizeof(*wc) << std::endl; // WrongCat size: 8 (vtable size)
    
	delete wa;
	delete wc;

    return 0;
}

 

위와 같이 base 객체의 크기에 따라 동작방식이 달라짐을 알 수 있다

하지만 항상 이렇게 free invalid pointer에러가 발생하는 것은 아니다

어떤 컴파일러는 컴파일 단계에서 해당 에러를 잡아 내기도 하였기 때문이다

 

정확히는 Undefined Behavior라고 볼 수 있겠다

이는 컴파일러와 환경마다 다르게 작동할 수 있으니 말이다