동적 메모리 관리
동적 메모리 관리
Understanding and Using C Pointers: Core Techniques for Memory Management 1st Edition을 읽고 정리한 내용을 기술합니다.
Understanding and Using C Pointers 정리 시리즈
동적 메모리 관리
C 프로그램은 런타임 시스템 안에서 실행된다. 런타임 시스템은 일반적으로 운영체제에서 제공되는 환경이며, 많은 프로그램 기능들과 함께 스택(stack)과 힙(heap)을 지원한다.
C99 표준에서 가변 길이 배열(Variable Length Array)이 도입되었다. 이 배열의 크기는 컴파일 될 때가 아니라 실행될 때 결정된다. 하지만 일단 가변 길이 배열이 생성되고 나면, 여전히 크기를 변경할 수 없다.
동적 메모리 할당은 할당, 해제 함수를 사용하여 수동으로 처리된다. 이 과정을 동적 메모리 관리(dynamic memory management)라고 한다.
앞으로의 포스팅에서는 어떻게 메모리가 할당되고 해제되는지 간략히 살펴보는 것으로 시작하며, malloc
, realloc
같은 기본 할당 함수들을 알아본다. 그리고 중복 해제(double free) 문제와 더불어 NULL의 사용을 포함한 free 함수에 대해서도 다룬다.
그리고 댕글링(dangling) 포인터와 같은 일반적인 문제가 발생했을 때 처리 기술을 설명하기 위한 예제를 소개한다.
동적 메모리 할당
C에서 동적 메모리 할당의 기본 단계
- malloc류의 함수로 메모리를 할당한다.
- 애플리케이션에서 할당된 메모리를 사용한다.
- free 함수를 이용해 할당된 메모리를 해제한다.
int *pi = (int*) malloc(sizeof(int));
*pi = 5;
printf("*pi: %d\n", *pi);
free(pi);
위 코드는 기본적인 동적할당의 예시를 보여주고 있다. 첫 번째 줄은 다음으로 바꿀 수도 있다.
int *pi = (int*) malloc(4)
이는 int자료형의 크기가 4바이트일때에 한정적이며, 시스템에 따라 자료형의 크기는 다를 수 있기 때문에
이식성을 고려하여 sizeof
함수를 사용하는 것이 좋다.
malloc 앞의 (int*)를 생략하면 (void*)이고, 이는 컴파일러가 자동적으로 맞는 형으로 바꿔준다. 그러나 코드의 가독성과 명확성을 위해 캐스팅 해주는 것이 권장된다.
마지막으로 free
함수를 통해 할당된 메모리를 해제한다.
메모리를 해제하지 않으면 다양한 문제가 발생할 수 있다.
메모리 누수
메모리 누수는 할당된 메모리가 더는 사용되지 않지만, 해제되지 않았을 때 발생한다.
- 메모리의 주소를 잃어버리는 경우
- free 함수가 호출되어야 하는 상황에 호출되지 않은 경우
메모리 누수로 인해 사용 가능한 메모리 공간이 더는 없을 경우, OOM(Out Of Memory) 오류가 발생한다. 극단적인 경우에는 운영체제 오류가 발생하기도 한다.
메모리 주소를 잃어버리는 경우
int *pi = (int*) malloc(sizeof(int));
*pi = 5;
...
pi = (int*) malloc(sizeof(int);
- 첫 번째 할당된 메모리 주소는 여전히 할당되지 않은 상태이며 프로그램 어디에서도 해당 주소를 찾을 수 없다.
struct 키워드를 사용하여 생성한 구조체를 해제하려고 할 때, 구조체가 동적으로 생성괸 메모리 포인터를 포함하고 있다면, 해당 메모리 포인터를 먼저 해제해줘야 한다.
동적 메모리 할당 함수
아래에 나열된 동적 메모리 할당 관련 함수는 stdlib.h
파일에서 찾을 수 있다.
malloc
realloc
calloc
free
함수 | 설명 |
---|---|
malloc | 힙에서 메모리 할당 |
realloc | 기존 할당된 메모리의 크기 변경 |
calloc | 힙에서 메모리 할당. 그리고 0으로 설정 |
free | 할당된 ㅁ모리를 힙으로 반환 |
malloc 함수 사용하기
malloc
함수는 메모리 블록을 힙으로부터 할당하며, 할당될 바이트 수를 호출 인자로 전달한다. 그리고 할당된 메모리에 대한 void
포인터를 반환한다.
메모리를 할당할 수 없을 때 malloc
함수는 NULL
을 반환한다. malloc
함수는 반환된 메모리의 내용을 지우거나 수정하지 않기 때문에 반환된 메모리에 포함된 불필요한 가비지 값을 염두에 두고 다뤄야 한다. malloc
함수의 프로토타입은 다음과 같다.
void* malloc(size_t)
malloc 함수는 메모리를 할당할 수 없을 때 NULL을 반환하기 때문에, 아래 코드와 같이 반환 주소를 사용하기 전에 값을 검사하는 습관을 들이는 것이 좋다.
int *pi = (int*) malloc(sizeof(int)); if(pi != NULL) { // 할당 성공 } else { // 할당 실해 }
calloc 함수 사용하기
calloc
함수는 메모리 할당과 동시에 초기화한다. 이 함수의 프로토타입은 다음과 같다.
void *calloc(size_t numElements, size_t elementSize);
calloc
함수는 numElements
와 elementSize
인자 값에 의해 결정되는 크기만큼의 메모리를 할당하며, 할당된 메모리의 첫 바이트를 가리키는 포인터를 반환한다.
calloc 함수는 메모리를 할당할 수 없는 경우 NULL
을 반환한다.
두 인자 numElements
와 elementSize
중 하나라도 값이 0이면, 널 포인터가 반환된다.
pi
변수에 총 20(5*4바이트)바이트를 할당하고 0으로 초기화하는 다음과 같은 코드에서:
int *pi = calloc(5, sizeof(int));
calloc
함수 대신에 malloc
함수와 memset
함수를 같이 사용하면 calloc
함수와 동일한 결과를 얻을 수 있다.
int *pi = malloc(5 * sizeof(int));
memset(pi, 0, 5 * sizeof(int);
실행시간 : malloc
< calloc
realloc 함수 사용하기
포인터에 할당된 메모리의 크기를 늘리거나 줄이는 일이 필요할 때가 있다. 특히 추후 설명할 가변 배열 구현에 유용하다.
realloc
함수는 메모리를 재할당하며, 함수의 프로토타입은 다음과 같다.
void *realloc(void *ptr, size_t size);
realloc
함수는 호출 시 두개의 인자를 취하며, 재할당된 메로리 영역의 포인터를 반환한다.
첫 번째 인자는 기존 할당된 메모리에 대한 포인터이며, 두 번째 인자는 요청할 메모리의 크기다.
- 새로 요청할 메모리 크기가 더 작은 경우
-> 여분의 메모리는 힙으로 반환된다. - 새로 요청할 메모리 크기가 더 큰 경우
-> 기존 메모리 바로 인접 영역에 메모리를 할당한다.
-> 할당할 수 없는 경우, 다른 영역에 메모리를 할당하고 기존 내용을 복사한다.
💡C++ STL라이브러리의 vector는 내부적으로 realloc으로 구현되었을까?
-> realloc의 디자인은 C++의 객체지향 스타일과 어울리지 않는다. 복사하는 데이터의 유형을 알 수 없으므로 생성자나 소멸자가 호출되지 않음. 결론적으로 vector와 realloc은 별개이다.
메모리 반환 함수 free
프로그래머는 할당된 메모리가 더는 필요치 않을 경우, 다른 용도에 사용될 수 있도록 free 함수를 사용하여 메모리를 해제할 수 있다. free 함수의 프로토 타입은 아래와 같다.
void free(void *ptr);
아래의 간단한 예제에서는 pi 포인터에 메모리가 할당되고 해제된다.
int *pi = (int*) malloc(sizeof(int));
...
free(pi);
위 예제에서 free
가 호출된 이후 할당된 메모리는 해제되었으나 포인터 변수 pi는 여전히 해당 주소를 가리키고 있으며 해당 메모리도 여전히 어떤 값을 포함하고 있다.
이러한 상태를 댕글링 포인터라고 한다.
해제된 포인터에 NULL 할당하기
포인터는 메모리가 해제된 이후에도 문제를 유발할 수 있다. 메모리가 해제된 포인터를 역참조하면, 그 포인터의 행동은 정의되어 있지 않다(undefined).
몇몇 프로그래머들은 해제된 포인터가 더는 유효하지 않음을 표시하기 위해 명시적으로 NULL을 할당한다. 이미 해제된 포인터의 사용은 런타임 예외를 발생시킨다.
이중 해제
불행하게도, 힙 관리자는 메모리가 이미 해제되었는지 판단하지 못한다. 같은 메모리가 두 번 해제되려고 하는지도 발견하지 못한다. 메모리의 이중 해제 시도는 메모리를 손상시키거나 프로그램을 종료시킨다.
힙 메모리와 시스템 메모리
힙은 프로그램이 실행될 때 고정된 크기로 정해지거나, 실행 도중에 크기를 늘리도록 할 수 있다. 하지만 free 함수가 호출되었다고 해서 힙 관리자가 반드시 해제된 메모리를 운영체제로 반환하는 것은 아니고, 애플리케이션에서 해당 메모리를 다시 사용할 수 있게 할 뿐이다. 그래서 프로그램이 메모리를 할당한 후 다시 해제한다고 해도, 일반적으로 운영체제 측면에서는 해제된 메모리가 애플리케이션의 메모리 사용량에 반영되지 않는다.
댕글링 포인터
포인터가 여전히 해제된 메모리 영역을 가리키고 있다면, 이러한 포인터를 댕글링 포인터(Dangling Pointers)라고 한다. 이러한 메모리는 더는 유효하지 않다. 댕글링 포인터는 종종 너무 빠른 해제(premature free)라고 불리기도 한다.
댕글링 포인터의 사용은 아래 목록에 나열된 문제를 포함한 다양한 문제를 발생시킨다.
- 메모리 접근 시 예측 불가능한 동작
- 메모리 접근 불가 시 세그멘테이션 오류(Segmentation fault)
- 잠재적인 보안 위협
이러한 유형의 문제는 다음과 같은 동작의 결과로 발생한다.
- 메모리 해제 후, 해제된 메모리에 접근
- 함수 호출에서 자동 변수를 가리키는 포인터의 반환
댕글링 포인터 예제
int *pi = (void*)malloc(sizeof(int));
*pi = 5;
free(pi);
*pi = 10;
printf("*pi: %d\n", *pi);
free
함수로 메모리를 해제한 후에도 변수 pi
는 여전히 메모리의 주소를 가리키고 있다. 그러나 이 메모리는 힙 관리자에 의해 재사용되거나 기존의 정수가 아닌 다른 타입으로도 사용될 수 있다.
이미 해제된 pi
에 10을 할당하자 printf의 결과로 *pi: 10
이 출력된다.
포인터 에일리어싱
int *pi = (int*) malloc(sizeof(int));
*p1 = 5;
...
int *p2;
p2 = p1;
...
free(p1);
...
*p2 = 10; // 댕글링 포인터
두 개의 포인터가 같은 메모리를 가르키다가 하나가 해제된 경우, 나머지 하나는 댕글링 포인터가 되며 이러한 상황을 포인터 에일리어싱(Aliasing) 이라 한다.
스택 프레임 해제에 따른 댕글링 포인터
int *pi;
...
{
int tmp = 5;
pi = &tmp;
}
// 이 위치에서 pi는 댕글링 포인터가 된다.
foo();
tmp
의 주소가 스택프레임이 제거되며 비유효한 값이 되고, pi
는 댕글링 포인터가 된다.
블록의 스택 프레임으로 사용된 메모리 영역은 foo
함수에 의해 덮어 씌워져 재사용되며, 변수 pi
는 여전히 그 위치를 가리키고 있다.
이는 심각한 보안 위협을 초래할 수 있다.
댕글링 포인터 다루기
포인터가 원인인 문제들의 디버깅은 때로 해결하기 어려울 때가 있다. 댕글링 포인터 문제를 처리하기 위한 몇 가지 접근 방법을 아래에 나열하였다.
- 메모리 해제 후 포인터를 NULL로 설정하기
- free 함수를 대체할 새로운 함수를 작성하기
- 몇몇 런타임 시스템이나 디버깅 시스템은 해제된 메모리를 특별한 값으로 덮어쓴다(예를 들어, 0xDEADBEEF).
- 댕글링 포인터 발견을 위한 서드파티 도구 사용
동적 메모리를 관리하는 비표준 기술들
- 가비지 컬렉션
- 리소스 획득 즉시 초기화(RAII, Resource Acquisition Is Initialization)
C++에서 생성자 - 소멸자 를 통해 객체에 RAII 패턴을 도입하는 예제를 배웠다.
- 예외 처리기