동적할당
컴퓨터는 CPU와 메모리로 구성되어 있고 CPU는 계산을 담당, 메모리는 저장을 담당한다 흔히 우리가 알고 있는 대표적인 메모리 RAM은 휘발성 메모리로서 휘발이라는 단어에서 알 수 있듯 전원공급이 중단되면 저장했던 내용이 증발하는 특징을 갖고 있다. 이는 코딩을 하다 보면 흔히 겪을 수 있는 현상이기도 한데 대표적으로 함수 선언 등으로 인해서 할당받은 스택메모리가 해당 함수가 종료되어 블록스코프를 벗어나면 재접근이 불가능한 것이 그 예이다. 사실 RAM은 비교적 느린 메모리라고 한다. CPU에서 RAM에 접근할 때 Bus를 통해서 접근을 하는데 이는 메인보드를 유심히 관찰하면 cpu와 ram은 물리적으로 떨어져 있기 때문에 느낌적으로나마 알 수 있다. 그렇다고 효율적인 처리를 하고자 한 번에 왕복하는 데이터의 양을 늘리자니 메모리의 낭비가 심해지고 그렇다고 양을 줄이면 느려져버린다.
레지스터
그렇기에 고안된것이 Bus를 통하지 않고 바로 CPU 바로 옆에 메모리를 두고자 한 레지스터이다. 레지스터는 엄밀히 따지자면 메모리는 아니고 유사메모리 정도라고 생각해 주면 된다. x86 아키텍처에서 사용하는 레지스터는 크게
1. 8개의 범용 레지스터(General-Purpose Register) : esp, ebp, eax, ebx, ecx, edx,...
2. 6개의 세그먼트 레지스터(Segment Register)
3. 1개의 플래그 레지스터(Flags Register)
4. 1개의 명령어 포인터(Instruction Pointer) 등
이렇게 존재한다. 그럼 메모리를 쓰지 말고 모두 레지스터에서 처리하게 하면 되지 않나?라고 생각할 수 있다. 하지만 CPU에 탑재된 레지스터의 수가 비교적 얼마 안 되기 때문에 모든 것을 레지스터에 저장하는 것은 불가능하다. 하지만 C언어에서는 소스코드로 레지스터를 사용해 달라고 요청이 가능하다
#include <stdio.h>
enum{ COUNT = 5};
int main(void)
{
int num;
register size_t i;
num = 0;
for(i=0; i< COUNT; ++i)
{
num += i;
}
printf("num, %d\n", num);
return 0;
}
이렇게 작성하면 컴파일러에게 일단 i라는 변수를 자주 사용할 듯하니 레지스터에 할당해 줄 수 있니? 하며 물어볼 수는 있다. 결국 결정하는 건 컴파일러의 몫이다.
메모리 레이아웃
windows 기준의 메모리 레이아웃은 크게 스택메모리, 힙메모리, 코드섹션, 데이터섹션 스택메모리, 힙메모리, 코드섹션, 데이터섹션으로 나뉜다 물리적으로 보면 모두 같은 메모리 상에 위치한다.
1. 코드섹션과 데이터섹션
우리가 작성한 소스코드들은 이곳에 저장된다고 보면 된다. 데이터섹션은 전역 변수와 정적 변수등이 저장되는 곳이다.
2. 스택메모리
스택메모리는 함수가 호출되는 등의 경우 메모리에 일정 크기가 할당되어 작업이 완료되면 할당받은 메모리를 다시 반환한다. 이는 필연적으로 단점이 발생하는데 컴파일 시 크기가 미리 정해져 할당받으므로 런타임 도중 추가적인 확장이 필요하더라도 추가하지 못하며, 애초에 프로그램이 실행될 환경이 가장 열악하다는 가정에 스택메모리를 잡는 게 안전하기에 처음부터 크게 잡고 들어가는 건 고려대상에서 제외이다. 또한 앞서 설명했듯 함수가 종료되면 할당받은 곧이 접근이 불가능하기에 함수의 지역변수 등은 함수와 같은 수명을 갖는다. 그렇다고 이걸 피하자고 전역변수나 정적변수로 선언하여 데이터섹션에 넣자니 이 변수들은 프로그램이 종료될 때까지 섹션에서 한자리를 차지하게 된다.
3. 힙메모리
우리는 런타임 시에 프로그래머가 원하는 만큼, 원하는 때에 생성 및 삭제 가능한 메모리가 필요하다. 그것이 바로 힙메모리(Heap Memory) 컴퓨터에 존재하는 범용적인 메모리로서 스택메모리처럼 특정 용도로 떼어 놓은 것이 아니며 컴파일러 및 CPU가 자동으로 관리해주지 않는 메모리이다. 그렇기에 프로그래머가 원하는 만큼, 원하는 타이밍에 할당 및 반납이 가능하다. 그럼 힙메모리는 마냥 완벽한가 하면 그건 아니다 일단 스택메모리에 비해 할당/해제 속도가 느리다. 스택메모리는 자료구조 스택의 특성상 할당받아오는데 O(1) 시간이 걸린다. 해제할 때는 별다른 연산을 거치지 않고 값만 바꿔준다. 힙메모리는 할당받아오려면 사용 중이지 않은 메모리이면서 크기가 맞는지 체크 후 제공된다. 또한 메모리 공간에 구멍이 발생할 수 있어 효율적인 메모리 관리가 어렵기도 하다. 그리고 가장 중요한 특징으로는 프로그래머가 직접 메모리를 할당하고 해제해야 한다.
동적할당과 메모리 소유권 문제
동적할당이란 앞서 설명한 힙메모리를 프로그래머가 할당 후 반납하는 과정을 일컫는다. 동적할당은 세 가지의 단계를 거치는데 관련 함수들과 같이 확인해 보면
1. 메모리할당( malloc(), calloc() )
힙메모리 관리자에게 메모리를 원하는 바이트만큼 달라고 요청하는 과정. 힙메모리 관리자는 해당 크기의 연속된 메모리를 찾아서 반환한다. 반환된 값은 연속된 메모리의 시작 주소
2. 메모리사용( memcpy(), memcmp(),... )
할당된 힙메모리 시작 주소를 가지고 원하는 작업을 수행
3. 메모리 해제( free() )
힙메모리 관리자에게 해당 메모리 주소를 반환하면서 다 썼다고 알리면 힙메모리 관리자는 해당 메모리를 점유하지 않은 메모리 상태로 바꾼다. 만약 메모리 주소를 돌려주지 않으면 메모리 누수(Memory leak)가 발생하여 해당 메모리를 계속 점유하고 있게 된다.
이외에도 재할당을 하는 realloc()도 있고 memset()과 같은 함수도 있다. 하나하나 살펴보도록 하자
malloc()
int* nums = void* malloc(size_t size) // 선언방법
Memory allocation의 약자로서 stdlib.h 헤더파일에 선언되어 있으며 size바이트만큼의 메모리를 반환해 준다. 위에서 볼 수 있듯 malloc() 함수의 반환 자료형은 void*로 적혀있는 것을 볼 수 있는데 이는 범용성을 위한 자료형으로서 필요에 따라 프로그래머가 캐스팅해서 쓰라는 의미이다. 할당된 메모리에 들어있는 값은 쓰레기값, 즉 초기화해서 할당해 주는 것이 아니기 때문에 메모리가 더 이상 없다거나 모종의 이유로 실패한다면 NULL 포인터가 반환되며 이러한 상황에서 프로그래머가 할 수 있는 것은 없다. 앞서 메모리를 할당하고 나서 사용이 끝나면 반드시 해제해 주는 작업을 해야 한다. 만약 동적할당받은 메모리 주소를 지역변수에 저장했다가 해제를 안한 상태에서 함수가 종료되면 해당 지역변수에 접근할 방법이 사라져 버린다. 지우려해도 지울수가 없다. 꼭 malloc()을 작성했다면 free()를 작성해주는것을 습관화 하도록 하자.(함수를 선언하고 return을 바로 적어두는 습관처럼)
free()
void free(void* ptr) // 위의 경우에는 대신 free(nums)만 적으면 충분
동적 할당 받은 메모리를 해제하는 함수로서 메모리 할당 함수를 통해서 얻은 메모리 주소만 해제 가능하다. 그 외 메모리 주소를 인자로 전달할 경우에는 Undefined Behavior가 발생한다.
동적할당 시 주의할 점
동적 할당받은 메모리 시작 주소를 연산에 사용한다면 최초에 받아온 시작 주소를 잃어버릴 가능성이 있다. 그렇게 된다면 다른 메모리 주소를 free() 함수의 인자로 보낼지도 모르고 이는 Undefined Behavior를 유발한다. 사본을 만들어서 포인터 연산에 사용해야 한다. 예컨대 다음의 코드를 보도록 하자
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
enum { LENGTH = 10 };
int main(void)
{
size_t i;
int* nums;
nums = (int*)malloc(LENGTH * sizeof(int));
for (i = 0; i < LENGTH; ++i)
{
*nums++ = 10 * (i + 1);
}
free(nums);
nums = NULL;
return 0;
}
nums에 LENGTH와 int자료형의 크기인 4의 곱 즉 여기서는 40만큼 할당받은 상태에서 for문을 통한 반복문으로 nums의 주소에 들어있는 값에 10의 배수를 넣고 nums 주소에 1을 더하는 것을 반복한다. 반복문이 끝나면 할당받은 메모리를 free()를 통해 해제해 주는데 작동할까? nums++는 해당 줄이 끝난 다음다음 1을 더하고 대입하여 다음에 호출될 때 1만큼 커진 값을 가져오게 한다. 즉 for문이 끝난 nums는 기존 nums가 가리키던 주소가 아닌 다른 주소를 가키고 있다 이러한 상황에서 free()에 nums를 대입하여 해제하면 제대로 해제될 리가 없다.
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
enum { LENGTH = 10 };
int main(void)
{
int* ap_nums; /* 메모리 할당된 포인터(allocated pointer)라는 뜻에서 접두어 ap_을 붙히는 것도 아주 좋음. */
/* 이런 코딩 표준은, 일하거나 배우는 곳의 코딩 표준을 따라서 유동적으로 따라주자. 여기선 맛만 보자. */
int* temp_ptr;
size_t i;
ap_nums = (int*)malloc(LENGTH * sizeof(int));
temp_ptr = ap_nums;
for (i = 0; i < LENGTH; ++i)
{
*temp_ptr++ = 10 * (i + 1);
}
free(ap_nums);
ap_nums = NULL;
return 0;
}
그렇기에 위의 코드처럼 temp_ptr을 통해서 malloc에서 반환받은 주소값을 복사한 뒤 작업은 temp_ptr을 통해서 진행한 후 작업이 끝나면 ap_nums에 free()을 걸어주는 형식으로 많이 활용한다.
이미 해제된 동적 할당 메모리 시작주소를 또 해제하려 한다면 어떻게 될까? 또는 이미 해제된 동적 할당 메모리 주소를 사용하려 한다면? 둘 다 Undefined Behavior로서 크래시가 날 수 있다. 그렇기에 항상 코드 마지막에 NULL 포인터를 대입했던 것이다. 추후에 free()를 사용하기 전에 NULL 포인터인지 검사하거나 free를 호출한 다음 확인하는 과정을 코딩 컨벤션으로 정하면 된다.
calloc()
void* calloc(size_t count, size_t size)
무엇의 약자인지는 모르겠다. 할당받을 메모리의 개수 count와 자료형 크기 size를 인자로 받으며 모든 바이트를 0으로 초기화해 준다. 보통은 calloc() 함수를 잘 사용하지 않는다. malloc()와 memset()을 사용하여 대체 가능하다.
ap_nums = (int*)calloc(COUNT, sizeof(int));
/* 위 아래 코드는 동일한 동작을 실시한다. */
ap_nums = (int*)malloc(COUNT * sizeof(int));
memset(ap_nums, 0, COUNT * sizeof(int));
realloc()
void* realloc(void* ptr, size_t new_size)
이미 동적 할당받은 메모리 시작주소 ptr을 new_size 바이트로 재할당 해주는 함수이다. 새로운 크기가 허용하는 한 기존 데이터를 그대로 유지해 준다. realloc() 사용함에 있어서 주의해야 할 점이 있는데 함수 호출에 성공 시 새롭게 동적할당된 메모리의 시작 주소가 반환되고 기존의 메모리 시작주소는 해제된다. 실패 시에는 NULL 포인터가 반환되고 기존 동적 할당된 메모리 시작주소는 해제되지 않는다. 즉 실패시에 다음과 같은 코드에서 메모리 누수가 발생할 수 있다.
int* ap_nums;
ap_nums = (int*)malloc(COUNT);
.../* 무수한 코드 */...
ap_nums = (int*)realloc(ap_nums, 2 * COUNT);
이미 기존의 ap_nums에 realloc을 바로 대입해 버렸기에 이것이 실패한다면 기존 메모리 시작주소에 접근할 방법이 사라지게 된다. 그렇기에 안전하게 사용하기 위해서는 바로 대입하지 말고 사본을 만들어 거기에 realloc()을 적용 후 다시 대입하는 것이 안전하다. realloc()의 동작 구조를 보면 realloc() == (malloc() + memcpy() + free())라는 것을 알 수 있다. 재할당이란 말은 결국 메모리를 할당(malloc)하고 기존 동적 할당 메모리 내용을 복사하고(memcpy) 기존 동적 할당 메모리를 해제(free)하는 것과 같다. 또한 realloc()의 특징으로 다음과 같은 것이 있다.
int* ap_nums = malloc(SIZE);
/* 위 아래 코드는 동일한 동작을 수행 */
int* ap_nums = realloc(NULL, SIZE);
재할당에는 몇 가지 경우의 수가 존재한다. 첫 번째로는 기존에 동적할당받은 메모리 뒤에 충분한 공간이 있는 경우에는 재할당 후에 기존 동적할당 받은 메모리의 시작주소가 반환된다. 근데 이것은 Unspecified Behavior이다. 컴파일러마다 기존 시작 주소가 반환될수도 있고 아닐수도 있다. 두번째로는 기존에 동적할당 받은 메모리 뒤에 충분한 공간이 없는 경우이다. 이 경우에는 새 동적할당을 진행하여 기존 내용을 복사하고 새 동적할당 메모리의 시작주소를 반환한다. 마지막으로는 기존에 동적할당 받은 메모리 보다 작은 공간을 할당받고 싶은 경우인데 이 또한 마찬가지로 Unspecified Behavior이다. 기존 메모리 시작주소가 반환될 수도 있고 안될 수도 있다.
동적 할당 메모리의 소유권
해당 메모리의 소유권 즉 메모리 공간의 주인이 누구인지 따지는 것은 중요한 문제이다. 반드시 책임을 지고 해제해야 하기 때문이기도 하며 주인도 아닌데 빌려가 놓고 맘대로 해제해 버리는 것도 문제이다. 다음 코드를 보도록 하자
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
const char* combine_string(const char* lhs, const char* rhs);
int main(void)
{
const char* str1 = "Hello, ";
const char* str2 = "world!";
const char* res;
/*
호출자 입장에선 combine_string() 함수가 내부에서
동적 메모리를 할당해서 반환한다는 사실을 어떻게 알 수 있을까?
*/
res = combine_string(str1, str2);
return 0;
}
const char* combine_string(const char* lhs, const char* rhs)
{
const char* ap_str;
size_t size = strlen(lhs) + strlen(rhs);
ap_str = (char*)malloc(size);
/* 복사 생략 */
return ap_str;
}
호출자 입장에서는 해당 함수의 내부에서 동적 메모리를 할당했는지 알 방도가 없다. 만약 파악하지 못한 채로 free()를 실행하지 않고 넘어가버린다면? 바로 메모리 누수가 발생한다.
RAII
RAII (Resource Acquisition Is Initialization) 직역하자면 "자원 획득은 초기화", 의역하자면 "획득 한 사람이 정리까지도 도맡아야 한다." 즉 획득했으면 정리는 자동으로 이루어져야 한다는 주의로서 C언어에서는 malloc() 함수 호출 시에 반드시 free()를 호출해 준 뒤 작업을 하자는 거다.
그 외의 메모리 관련 기타 함수
memset : void* memset(void* dst, int ch, size_t count)로 호출 string.h 헤더 파일에 선언되어 있다. 1byte 단위로 초기화된다. 그 외 단위로 초기화하고 싶다면 직접 반복문을 이용해서 초기화해 주면 된다. count가 dst의 메모리 공간을 초기화하는 경우(소유하지 않은 메모리 접근), dst가 NULL 포인터일 경우(NULL 포인터 역참조) Undefined Behavior이다.
memcpy : void* memcpy(void* dst, const void* src, size_t count)로 호출 string.h 헤더파일에 선언되어 있으며 src의 데이터를 count 바이트만큼 dst에 복사해 주는 함수이다. dst의 메모리 공간을 넘어서서 데이터를 복사할 경우, src나 dst가 NULL포인터일 경우 Undefined Behavior이다.
memcmp : int memcmp(const void* lhs, const void* rhs, size_t count)로 호출 string.h 헤더파일에 선언되어 있으며 count 바이트만큼의 메모리를 서로 비교해 주는 함수이다. strcmp() 함수와 매우 비슷하지만 NULL 캐릭터를 만나도 계속 진행한다는 차이점이 있다. lhs와 rhs의 크기를 넘어서서 비교할 경우, lhs나 rhs가 NULL 포인터일 경우 Undefined Behavior이다.
'unreal 5기' 카테고리의 다른 글
| 250908 언리얼엔진 본캠프 25일차 파일3 (0) | 2025.09.08 |
|---|---|
| 250903 언리얼엔진 본캠프 22일차 파일1 (0) | 2025.09.03 |
| 250829 언리얼엔진 본캠프 19일차 문자열 (1) | 2025.08.29 |
| 250828 언리얼엔진 본캠프 18일차 디자인패턴 (1) | 2025.08.28 |
| 250826 언리얼엔진 본캠프 16일차 템플릿 (1) | 2025.08.26 |