malloc과 new, free와 delete
개요
malloc과 new, free와 delete는 힙 영역에 메모리를 동적 할당하는 함수이다.
힙 메모리는 스택과 다르게, 프로그래머가 직접 할당하며, 해제하지 않으면 계속해서 사용할 수 있다.
c에서는 malloc, free만 존재했지만, c++에서는 객체 개념이 생기며, 생성자 소멸자를 다루기 위해 new, delete가 도입되었다.
하나 하나 간단하게 살펴보고, 주요 차이점에 대해서 알아보자.
malloc

malloc은 반환형이 void*로 실제 사용하는 타입(포인터가 가리킬 타입)을 캐스팅해서 사용한다.
인자는 size_t형으로 사용할 크기를(byte단위) 넣으면 된다.
보통 할당받을 자료형 크기(sizeof)를 사용해 공간을 할당받는다.
int* a = (int*)malloc(sizeof(int)*10);
int형 10개를 사용하는 경우 위와 같이 할당받을 수 있다.

malloc을 호출했을 때 call stack을 확인하면, malloc 함수 내부에서는 커널 수준 힙API를 통해 실제 메모리를 할당 받는 것을 알 수 있다.
free

free는 인자로 할당받았던 메모리의 시작 주소를 받는다.
힙 블록의 메타데이터를 활용해 메모리를 해제하기 때문에 인자의 타입은 중요하지 않다.

free의 내부에서는 위와 같은 call stack으로 커널 수준 API에 요청을 보내 메모리를 해제한다.


메모리를 할당할 때는 시작 주소를 기반으로 메타데이터 등을 확인하고 있기 때문에, 힙에 할당한적이 없는 주소를 free하면 오류가 발생한다.
new
new는 생성자 유무에 따라서 동작이 달라진다.


int 같은 기본 타입의 경우 내부를 보면, malloc과 크게 다르지 않음을 알 수 있다.
지금 부터는 간단한 객체 "E"를 활용해서 예시를 들어보겠다.
class E
{
public:
E()
{
cout << "Hello ";
}
int val1;
int val2;
};

객체의 경우 위와 같이 생성자를 호출하고 할당한 메모리의 첫 주소를 e에 반환함을 볼 수 있다.

다만 생성자를 없애고(default 생성자는 논리적으로 존재), 멤버 변수에 기본 값이 없다면, 객체가 생성되면서 할 동작이 없기 때문에 위와 같이 간소화된 코드가 동작한다.
new[]

new[]를 하면 iterator를 통해서 생성자를 10번 호출함을 알 수 있다.
만약 코드를 아래와 같이 소멸자를 추가한다면
class E
{
public:
E()
{
cout << "Hello ";
}
~E()
{
cout << "Bye";
}
int val1;
int val2;
};

이전에는 0x50만큼 할당되던 메모리가 0x54로 증가한 것을 확인할 수 있다. 이는 4바이트가 추가로 할당되었음을 의미한다.
전체 흐름을 천천히 따라가 보면, operator new[]가 할당한 메모리의 첫 위치에 0Ah(10, 배열 길이)를 저장하고, 이후 add 4 연산을 통해 배열의 실제 시작 주소를 반환한다는 것을 알 수 있다.
결국, 4바이트가 추가로 할당된 이유는, delete[] 호출 시 몇 개의 요소에 대해 소멸자를 호출해야 하는지를 판단하기 위해, 배열 길이를 메타데이터로 저장해두기 위한 것임을 알 수 있다.
delete

delete는 new와 짝꿍으로 사용되는 operator이다.
delete e;
004D1096 8B 55 F0 mov edx,dword ptr [e]
004D1099 89 55 E4 mov dword ptr [ebp-1Ch],edx
004D109C 6A 08 push 8
004D109E 8B 45 E4 mov eax,dword ptr [ebp-1Ch]
004D10A1 50 push eax
004D10A2 E8 51 06 00 00 call operator delete (04D16F8h)
004D10A7 83 C4 08 add esp,8
004D10AA 83 7D E4 00 cmp dword ptr [ebp-1Ch],0
004D10AE 75 09 jne wmain+89h (04D10B9h)
004D10B0 C7 45 DC 00 00 00 00 mov dword ptr [ebp-24h],0
004D10B7 EB 0D jmp wmain+96h (04D10C6h)
004D10B9 C7 45 F0 23 81 00 00 mov dword ptr [e],8123h
004D10C0 8B 4D F0 mov ecx,dword ptr [e]
004D10C3 89 4D DC mov dword ptr [ebp-24h],ecx
delete의 기본 동작을 보면, 연산자 delete를 통해 메모리를 free하고, 해당 위치에 8123h를 넣어 dangling pointer를 방지함을 알 수 있다.
windows에서 하위 64KB (0x00000000~0x0000FFFF)는 GuardZone으로 접근금지 메모리 영역이기 때문에
delete 이후에 다시 해당 포인터를 접근하면

0x8123위치에 접근하는 것이 되고 따라서 위의 사진처럼 액세스 위반이 된다.
이제 객체에 소멸자를 추가해보고 다시 어셈블리를 살펴보자.
delete e;
004610E6 8B 55 E4 mov edx,dword ptr [e]
004610E9 89 55 E8 mov dword ptr [ebp-18h],edx
004610EC 83 7D E8 00 cmp dword ptr [ebp-18h],0
004610F0 74 0F je wmain+81h (0461101h)
004610F2 6A 01 push 1
004610F4 8B 4D E8 mov ecx,dword ptr [ebp-18h]
004610F7 E8 34 00 00 00 call E::`scalar deleting destructor' (0461130h)
004610FC 89 45 DC mov dword ptr [ebp-24h],eax
004610FF EB 07 jmp wmain+88h (0461108h)
00461101 C7 45 DC 00 00 00 00 mov dword ptr [ebp-24h],0
이전의 소멸자가 없던 delete에 비해 어셈블리가 짧아지고 scalar deleting destructor가 생겼다
scalar deleting destructor 내부로 이동하면
CPP_Study.exe!E::`scalar deleting destructor'(unsigned int):
00FE1130 55 push ebp
00FE1131 8B EC mov ebp,esp
00FE1133 51 push ecx
00FE1134 89 4D FC mov dword ptr [this],ecx
00FE1137 8B 4D FC mov ecx,dword ptr [this]
00FE113A E8 F1 FE FF FF call E::~E (0FE1030h)
00FE113F 8B 45 08 mov eax,dword ptr [ebp+8]
00FE1142 83 E0 01 and eax,1
00FE1145 74 0E je E::`scalar deleting destructor'+25h (0FE1155h)
00FE1147 6A 0C push 0Ch
00FE1149 8B 4D FC mov ecx,dword ptr [this]
00FE114C 51 push ecx
00FE114D E8 26 06 00 00 call operator delete (0FE1778h)
00FE1152 83 C4 08 add esp,8
00FE1155 8B 45 FC mov eax,dword ptr [this]
00FE1158 8B E5 mov esp,ebp
00FE115A 5D pop ebp
00FE115B C2 04 00 ret 4
00FE115E CC int 3
00FE115F CC int 3
위와 같이 이전의 동작도 비슷하게 수행하고 있음을 알 수 있다.
delete[]
delete[]는 소멸자를 할당한 객체의 개수만큼 호출하기 위해 존재한다.
이전의 new[]에서 언급했듯이, delete[]시에는 몇번 소멸자를 호출할지 알기 위해서 4바이트의 메모리를 더 할당하고, 가장 처음 위치에 (실제 반환하는 포인터 1칸앞에) 개수를 넣어 이를 얻을 수 있다.
CPP_Study.exe!E::`vector deleting destructor'(unsigned int):
00281170 55 push ebp
00281171 8B EC mov ebp,esp
00281173 6A FF push 0FFFFFFFFh
00281175 68 40 29 28 00 push 282940h
0028117A 64 A1 00 00 00 00 mov eax,dword ptr fs:[00000000h]
00281180 50 push eax
00281181 51 push ecx
00281182 A1 00 50 28 00 mov eax,dword ptr [__security_cookie (0285000h)]
00281187 33 C5 xor eax,ebp
00281189 50 push eax
0028118A 8D 45 F4 lea eax,[ebp-0Ch]
0028118D 64 A3 00 00 00 00 mov dword ptr fs:[00000000h],eax
00281193 89 4D F0 mov dword ptr [this],ecx
00281196 8B 45 08 mov eax,dword ptr [ebp+8]
00281199 83 E0 02 and eax,2
0028119C 74 41 je E::`vector deleting destructor'+6Fh (02811DFh)
0028119E 68 50 10 28 00 push offset E::~E (0281050h)
002811A3 8B 4D F0 mov ecx,dword ptr [this]
002811A6 8B 51 FC mov edx,dword ptr [ecx-4]
002811A9 52 push edx
002811AA 6A 14 push 14h
002811AC 8B 45 F0 mov eax,dword ptr [this]
002811AF 50 push eax
002811B0 E8 61 06 00 00 call `eh vector destructor iterator' (0281816h)
002811B5 8B 4D 08 mov ecx,dword ptr [ebp+8]
002811B8 83 E1 01 and ecx,1
002811BB 74 1A je E::`vector deleting destructor'+67h (02811D7h)
002811BD 8B 55 F0 mov edx,dword ptr [this]
002811C0 6B 42 FC 14 imul eax,dword ptr [edx-4],14h
002811C4 83 C0 04 add eax,4
002811C7 50 push eax
002811C8 8B 4D F0 mov ecx,dword ptr [this]
002811CB 83 E9 04 sub ecx,4
002811CE 51 push ecx
002811CF E8 34 06 00 00 call operator delete[] (0281808h)
002811D4 83 C4 08 add esp,8
002811D7 8B 45 F0 mov eax,dword ptr [this]
002811DA 83 E8 04 sub eax,4
002811DD EB 21 jmp E::`vector deleting destructor'+90h (0281200h)
002811DF 8B 4D F0 mov ecx,dword ptr [this]
002811E2 E8 69 FE FF FF call E::~E (0281050h)
002811E7 8B 55 08 mov edx,dword ptr [ebp+8]
002811EA 83 E2 01 and edx,1
002811ED 74 0E je E::`vector deleting destructor'+8Dh (02811FDh)
002811EF 6A 14 push 14h
002811F1 8B 45 F0 mov eax,dword ptr [this]
002811F4 50 push eax
002811F5 E8 34 07 00 00 call operator delete (028192Eh)
002811FA 83 C4 08 add esp,8
002811FD 8B 45 F0 mov eax,dword ptr [this]
00281200 8B 4D F4 mov ecx,dword ptr [ebp-0Ch]
00281203 64 89 0D 00 00 00 00 mov dword ptr fs:[0],ecx
0028120A 59 pop ecx
0028120B 8B E5 mov esp,ebp
0028120D 5D pop ebp
0028120E C2 04 00 ret 4
002811A9에서 edx는 this의 한칸 앞의 소멸자 호출회수를 뜻하는데, 이를 소멸자 호출 시에 인자로 push함을 볼 수 있다.
002811BD 부터 보면 할당된 크기를 imul로 계산하고 add 4를 해 전체 크기를 계산한 값,
this값을 저장한 ecx에 -4를해 실제로 할당한 메모리의 첫 주소 값을 실제 메모리를 해제하는 delete의 인자로 사용함을 볼 수 있다.

현재 코드에서 e의 주소가 0x1624EEC인데 한칸 앞에 0a(10) 인것도 확인할 수 있다.
new, new[], delete, delete[] 만약 혼용한다면?
new, delete의 기본버전, 배열버전을 혼용하면 안된다는 것은 알고 있지만 왜그럴까? 또는 만약 혼용되었다면 어떤 문제가 생길까?
지금까지 위에서 정리한 내용을 바탕으로 어떤 문제가 생길지 유추해보자.
new와 delete[]
기본적으로는 생성자와 소멸자의 호출 횟수일 것이다. new[10]과 같이 객체를 만들었는데 delete로 지운다면 10번의 소멸자가 이루어지지 않기 때문에 문제가 생길 것이다.
정확히는 delete[]의 동작을 생각해보면 된다. 위에서 살펴봤듯이 delete[]는 한칸 앞의, 4바이트 메모리를 보고 해당 회수만큼의 소멸자를 호출할 것이다.

현재 객체 e의 시작주소 한칸 앞의 값은 0x8c000972 = 10진수로 2348814450이기 때문에 끊임 없이 소멸자가 호출될 것이다.

그렇다면 앞의 숫자가 작다면 조금 호출하고 말일일까? 또는 소멸자가 존재하지 않는다면?
E* e = new E;
int* raw = reinterpret_cast<int*>(e);
*(raw - 1) = 1;
delete[] e;
e의 한칸 앞 4바이트를 임의로 1로 바꾸고 실행해보았다.

소멸자는 1번만 호출되었지만, 이후 메모리를 free하는 과정에서 한칸 앞의 메모리를 해제하려고 하고, 이는 힙 블록에 등록된적이 없기 때문에 힙이 손상되었다는 에러가 생긴다.
new[]와 delete
이 경우는 명백하다.
new[]를 통해 돌려받은 e의 주소는 실제 힙이 할당된 주소의 한칸 앞이고, delete는 e의 주소를 그대로 해제하려고 하기 때문에 힙이 손상된다.
결론
malloc과 free, new와 delete는 단순히 생성자, 소멸자를 호출하는 것 외에 추가적인 동작을 한다.
이해하기 위해서는 실제로 메모리를 할당하는 방식, 추가적으로 페이지 할당 지식이 필요하다.
어렵다!
추후에 dangling pointer 파트를 추가해야겠다.
placement new
new의 기능은 기본적으로
1. 메모리 할당
2. 생성자 호출
두가지로 나뉜다.
new 표현식을 그대로 사용하면 1, 2의 기능을 같이 사용하는 것이며, operator new를 오버로딩 하는 것은 1번 기능을 오버로딩하는 것이라고 볼 수 있다.
그리고 '객체가 생성되었다' 라고 말할 수 있는 시점은 메모리 할당 시점이 아닌, 생성자의 호출이 마무리 된 시점일 것이다.
그렇다면 만약 메모리 풀 처럼 미리 사용 공간을 할당한 뒤 필요할 때 객체를 사용하는 방식이라면 어떻게 처리해야할까?
이를 위해 C++에는 placement new라는 기능을 제공한다.
이미 할당되어있는 공간을 통해 생성자만 호출하는 방법이다.
아래는 이를 사용하는 예시이다.
class A
{
public:
A()
{
cout << this << '\n';
}
int a;
};
int main() {
void* memoryPool = malloc(sizeof(A) * 10);
A* aArr[10];
for (int i = 0; i < 10; ++i)
{
aArr[i] = new((char*)memoryPool + sizeof(A) * i)A;
}
return 0;
}
memoryPool에 사용할만큼의 크기를 미리 할당받고, placement new를 활용해 각각 객체의 생성자를 호출하는 예시이다.
new (address) Type (constructor_args...)
위와 같은 문법으로 호출할 수 있다.
여기서 사용되는 address는 생성자를 위해 this포인터를 넘겨주는 것과 같다.
주의할 점은 메모리 할당 시점과, 생성자 호출 시점이 분리되었기 때문에,
소멸자를 직접 모두 호출하고, 메모리 할당 해제를 해주어야 한다는 것이다.
특히 new를 통한 할당이 아닌 malloc을 통해 Heap에 임의 할당했으므로, 주소 한칸 앞에 소멸자 호출 회수가 저장되어있지 않기 때문에 delete를 통한 메모리 해제는 확실한 동작을 기대할 수 없고, 개별적인 메모리 접근을 통해 소멸자를 호출하고, free를 통한 메모리 해제가 필요하다.