Dev/C, C++

애플리케이션 개발시의 메모리 디버깅 : 메모리 누수 발견 기법

newtype 2007. 3. 19. 12:41

원문 출처

http://www-128.ibm.com/developerworks/kr/library/opendw/20061219/


필자는 DVD 레코더와 셋톱박스의 복합 모델을 개발하는 팀에 소속되어 있다. 현재 유럽에서는 아날로그 방송을 디지털로 서서히 대체하고 있기 때문에, 관련 제품의 개발 요청이 쇄도하고 있다.
얼마 전 유럽을 타깃으로 3개의 유사 모델(D197, D198, D199)을 개발하고 있을 때의 일이다. 우여곡절 끝에 기본 모델인 D197 개발을 마치고 양산 시켰으며, D198도 완료하여 QA 그룹에 테스트를 의뢰한 후 결과를 기다리고 있었다. 팀원들 모두, D197 모델이 별 이상 없었으니 부가기능을 조금 추가한 D198 역시 무난히 양상 단계로 넘어갈 것이라고 판단, 모처럼의 한가한 시간을 보내고 있었다.
그런데 그 순간 옆자리에서 통화하는 소리가 들렸다.
"네, 세트가 멎었다고요?"
테스트 그룹으로부터 D198 세트가 오동작 한다는 연락을 받은 것이다. 우리는 즉시 테스트 그룹으로 달려갔다. 노트북을 D198에 연결하고 에러 메시지를 확인했다. 디버그 화면에는 메모리 부족을 나타내는 경고 메시지가 반복해서 출력되고 있었다. 사용할 수 있는 메모리가 바닥나서 더 이상 동작하지 못하고 멈춰버린 것이었다.
D198의 DRAM은 시스템을 동작시키고 남을 넉넉한 크기여서, 정상적인 경우라면 메모리 부족 문제가 발생할 이유가 없었다. D197 모델에서는 없었던 메모리 문제가 왜 발생했을까? 팀원들은 오늘도 일찍 퇴근하기는 틀렸다는 생각에 투덜거리며 D198에서 새로 추가된 코드를 중심으로 살펴보기 시작했다.

메모리 누수

자바나 C# 같은 언어에는 가비지 컬렉터(Garbage Collector)가 있어서, 알아서 메모리 관리를 해주지만 프로그래밍 언어의 대표주자 C/C++ 에서의 메모리 할당과 해제는 프로그래머의 몫이다. 따라서 애플리케이션이 메모리를 할당 받아서 사용했다면, 사용이 끝난 뒤에는 반드시 반납해서 해당 메모리를 재사용할 수 있도록 해야 한다. 그러나 프로그래머도 사람인지라 할당 받은 메모리를 실수로 해제하지 않는 경우가 발생할 수 있다. 이러한 경우 해제되지 않은 메모리는 사용이 끝난 뒤에도 남아있어 공간만 차지하는 상태로 있게 된다. 이것을 메모리 누수라고 한다.
언뜻 생각해보면 메모리를 할당 받고 해제하는 것이 무슨 어렵고 복잡한 일인가? 의문을 가질 수도 있겠지만, 실제로 코딩을 하다 보면 그렇게 간단한 문제가 아니다.
상용코드는 여러 프로그래머들이 개발하고 유지보수하기 때문에 시간이 지나면 일관성을 잃고 난해해지기 쉬울 뿐 아니라 그 양도 방대해진다. 때문에 복잡해진 애플리케이션의 상태나 조건을 후임 개발자가 꿰뚫고 있기는 쉽지 않다.
여기에 새로운 기능을 추가하다 보면 예상치 못한 상태에 빠지기도 하고 복잡해진 조건에 누락되는 부분이 생겨 메모리 누수가 발생하기 쉽다. 특히 문제의 루틴이 단발적으로 사용되는 것이 아니라면 루틴이 실행될 때마다 메모리 누수가 누적되어 언젠가는 메모리 부족 문제가 터질 수밖에 없게 된다.

메모리 감시자

짧은 분량의 간단한 프로그램이라면 차분히 코드를 살펴보면서 메모리 누수를 찾아 나설 수도 있다. 실제로 느긋하게 코드를 읽어 나가는 것은 메모리 누수뿐 아니라 야근하며 작성했던 코드의 결함을 찾아내는 가장 좋은 방법일 수도 있다.
그 러나 방대한 분량의 상용 코드에서 생긴 문제라면 어떻겠는가? 거기에 상용 프로그램 개발에 느긋한 일정을 주는 회사를 본 적이 있는가? 긴박한 일정에 메모리 누수까지 생겼다면 과중한 스트레스로 이력서를 고쳐 쓰고 취업 사이트를 뒤지게 될지도 모르겠다.
방대한 프로그램에 숨겨진 감쪽같은 메모리 누수는 사막에서 잃어 버린 바늘과도 같다. 맨 손으로 사막에서 바늘을 찾으려는 것은 너무도 무모한 짓이다. 사막에서 바늘을 찾으려면 도구가 필요하다. 이를테면 금속탐지기 같은 것이 있으면 큰 도움이 될 것이다. 우리도 메모리 누수를 찾기 위한 금속탐지기부터 구해 보도록 하자.
필자는 도입부의 문제 상황에서 다른 팀원들과 함께 코드를 살펴보는 대신에 메모리 감시 모듈을 작성하여 코드에 추가하는 작업을 했다. 물론 다른 팀원들이 열심히 코드를 분석하는 동안에 다른 코드를 작성하고 있었기 때문에, 자칫 딴짓을 하는 것처럼 보였을 수도 있었겠지만 결국은 필자의 방법으로 문제를 해결할 수 있었다. 두 세시간 정도의 시간이 지나자 코드 분석을 하던 팀원들은 하나 둘씩 지쳐서 포기하기에 이르렀는데, 그 즈음에 필자의 프로그램이 완성되었다.
필자가 개발한 메모리 감시자는 메모리의 할당과 해제 상태를 저장하고 리포트 하는 기능을 하는데, 감시 모듈을 포함시킨 D198을 부팅시켜 기본동작을 수행시켜 본 후 메모리 상태를 확인해 보았다. 예상대로 D198에서 새롭게 추가된 유료방송 처리 부분이 문제였다.
우리는 리포트 받은 부분의 코드를 살펴보고 곧 문제를 찾아내어 메모리 누수를 해결할 수 있었다. 그리고 덤으로 세트에 주는 영향은 미미하지만 감추어져 있던 또 다른 메모리 누수(몇 년 정도 동작 시키면 세트를 멈추게 할 수도 있는 문제)도 찾아낼 수 있었다.

메모리 감시자의 구현

메모리 감시자가 하는 일은 거창해 보이지만 구현은 생각보다 간단하다. C언어에서는 malloc이나 calloc을 이용하여 메모리를 할당하고, free를 이용하여 메모리를 해제한다. 마찬가지로 C++에서는 new와 delete가 같은 동작을 한다. 메모리 감시자는 링크드 리스트로 구현되며, malloc이 호출 되면 할당한 메모리의 주소를 리스트에 저장해 두었다가 free로 해당주소를 해제하면 저장된 리스트에서 해제된 주소 값을 찾아 삭제한다.
이러한 동작으로 메모리 감시자는 현재 할당되어 있는 메모리의 주소만을 보관하고 있게 된다. 따라서 메모리 감시자를 포함한 어플리케이션을 실행시킨 후 리포트를 확인하면, 비정상적으로 여러 번의 메모리 할당을 받은 부분을 어렵지 않게 발견할 수 있다. 메모리 감시자는 헤더 파일 하나(TraceMem.h)와 소스 파일 하나(TraceMem.c)로 구성된다. 각각의 구현 방법에 대해 설명하도록 하겠다.

 

TraceMem.h

메모리 감시자가 애플리케이션이 할당 받은 동적 메모리의 주소 값을 저장하는 역할을 하지만, 메모리 감시자 자체도 링크드 리스트로 구현되기 때문에 스스로가 동작하는 동안에도 동적 메모리의 할당을 하게 된다. 즉, 애플리케이션에서 할당한 메모리 주소를 메모리 감시자의 링크드 리스트에 저장하기 위해 다른 메모리를 할당해야 하는 것이다.
그러면 메모리 감시자는 주소 값을 저장하기 위해 할당 받은 메모리의 주소를 또 다시 저장하려 들것이다. 이 때문에 순환참조가 발생되며, 메모리 누수 잡기를 시작도 하기 전에 시스템이 다운되는 상황이 벌어지게 된다. 이러한 문제를 피하기 위해 메모리 감시자가 스스로의 목적에 의해 사용하는 동적 메모리의 할당과 해제는 메모리 감시자의 감시를 받지 않도록 할 필요가 있다.
아래 코드를 살펴보면 매크로를 이용해서 _MALLOC와 _FREE를 정의하였다. _MALLOC와 _FREE는 realloc을 이용하였는데, realloc은 malloc과 free의 기능도 모두 할 수 있는 범용적인 메모리 함수다. 본래 realloc은 malloc이나 calloc으로 할당한 메모리의 크기를 변경시킬 때 사용하는 함수이다. 기존에 할당 받은 주소를 NULL로 주면 메모리를 새로 할당하는 malloc의 역할을 하고, 새로 할당할 메모리의 크기를 0으로 하면 기존의 메모리만 해제시켜 버리기 때문에 free와 같은 역할을 한다.
메모리 감시자는 malloc과 free가 호출될 때 할당되거나 해제된 주소를 관리하므로, 메모리 감시자 코드를 구현하는 데는 malloc이나 calloc을 사용하지 않고 realloc을 사용한 _MALLOC과 _FREE를 사용할 것이다.
TraceMem은 메모리 감시자의 몸체가 되고 MemItem은 메모리 주소 값을 저장하는 리스트의 노드이다. STL을 사용할 경우는 더욱 편리하게 리스트를 구현할 수 있지만, C에서도 사용할 수 있도록 링크드 리스트를 직접 구현해 보겠다.

#define _MALLOC(p)     realloc(NULL, p)

#define _FREE(p)       realloc(p, 0)

 

void* dbgMalloc(int size, char* file, int line);

#define malloc(n) dbgMalloc(n, __FILE__, __LINE__)

 

void dbgFree(void* ptr);

#define free(p) dbgFree(p)

 

typedef struct _MemItem

{

        void* ptr;

        char* file;

        int line;

        unsigned long size;

        int num;

        struct _MemItem *next;

} MemItem;

 

typedef struct _TraceMem

{

        MemItem* head;

        MemItem* tail;

        int num;

} TraceMem;

 

TraceMem* TraceMemCreate();

void TraceMemDelete(TraceMem*);

void TraceMemPrint(TraceMem* self);

TraceMem* TraceMemGetSummary(TraceMem* self);

 

extern TraceMem* traceMem;

TraceMem.c

dbgMalloc과 dbgFree는 malloc과 free를 대신하여 수행되며 메모리 감시자에 메모리 주소를 추가하고 삭제하는 역할을 하며 아래 코드는 메모리 감시자의 몸체 부분으로 임베디드 환경에서도 사용할 수 있도록 C를 객체지향 스타일로 작성한 것이다.


void* dbgMalloc(int size, char* file, int line)

{

        void* ptr = _MALLOC(size);

 

        TraceMemAdd(traceMem, ptr,  file, line, size);

 

        return ptr;

}

 

void dbgFree(void* ptr)

{

        TraceMemRemove(traceMem, ptr);

 

        _FREE(ptr);

}

 

TraceMem* traceMem = NULL;

 

/* 링크드 리스트 노드 생성자 */

MemItem* MemItemCreate(void* ptr, char* file, int line, unsigned long size)

{

        MemItem* self = (MemItem*) _MALLOC(sizeof(MemItem));

 

        self->file = (char*)_MALLOC(strlen(file) + 1);

        strcpy(self->file, file);

        self->line = line;

        self->size = size;

        self->num = 1;

        self->ptr = ptr;

 

        return self;  

}

 

/* 소멸자 */

void MemItemDelete(MemItem* self)

{      

        if (self == NULL)

        {

               return;

        }

              

        _FREE(self->file);

        _FREE(self);  

}

 

void MemItemPrint(MemItem* self)

{      

        printf(" ++ [%s:%d:%p] : %d/%d\n", self->file, self->line, self->ptr, self->num, self->size ); 

}

 

/* 메모리 감시자 생성자 */

TraceMem* TraceMemCreate()

{

        TraceMem* self = (TraceMem*) _MALLOC(sizeof(TraceMem));

       

        self->head = MemItemCreate(0, "Head", 0, 0);

        self->tail = MemItemCreate(0, "Tail", 0, 0);

       

        self->head->next = self->tail;

        self->tail->next = NULL;

       

        self->num = 0;

 

        return self;

}

 

/* 소멸자 */

void TraceMemDelete(TraceMem* self)

{

        while ( self->head->next != self->tail )

        {

               MemItemDelete( TraceMemPop(self, self->head->next) );       

        }

 

        if (self->num != 0)

        {

               printf(" ++++ ERROR : TraceMem has Items %d", self->num);

        }

 

        _FREE(self->head);

        _FREE(self->tail);

        _FREE(self);

}

 

/* 주소값을 이용한 노드 검색 */

MemItem* TraceMemFindPtr(TraceMem* self, void* ptr)

{

        MemItem* iter;

 

        for ( iter = self->head->next; iter != self->tail; iter = iter->next )

        {

               if (ptr == iter->ptr) 

                       return iter;

        }

 

        return NULL;  

}

 

int TraceMemPush(TraceMem* self, MemItem* item)

{

        MemItem* next = self->head->next;

 

        self->head->next = item;

        item->next = next;

 

        return (self->num++);

}

 

MemItem* TraceMemPop(TraceMem* self, MemItem* item)

{

        MemItem *iter;

 

        for ( iter = self->head; iter != self->tail; iter = iter->next )

        {

               if (iter->next == item)

               {

                       iter->next = iter->next->next;               

                       item->next = NULL;

                       self->num--;

                      

                       return item;

               }

        }

 

        return NULL;

}

 

/* 메모리 정보를 리스트에 추가 */

int TraceMemAdd(TraceMem* self, void* ptr, char* file, int line, unsigned long size)

{

        MemItem *tar;

       

        if ( (tar = TraceMemFindPtr(self, ptr)) == NULL)

        {

               MemItem* item = MemItemCreate(ptr, file, line, size);       

               TraceMemPush(self, item);

        }

        else

        {

               TraceMemPrint(self);

        }

 

        return 0;

}

 

/* 메모리 정보를 리스트에서 제거 */

int TraceMemRemove(TraceMem* self, void* ptr)

{

        MemItem *tar;

       

        if ( (tar = TraceMemFindPtr(self, ptr)) != NULL)

        {

               MemItemDelete( TraceMemPop(self, tar) );

        }

        else

        {

               TraceMemPrint(self);

        }

 

        return 0;

}

 

/* 메모리 감시자 내용 출력 */

void TraceMemPrint(TraceMem* self)

{

        MemItem *iter;

 

        printf("\n ++ TraceMemPrint\n");

 

        for(iter = self->head->next; iter != self->tail; iter = iter->next)

        {

               MemItemPrint(iter);

        }

}

 

/* 파일명과 라인수로 노드를 찾는 함수 */

MemItem* TraceMemFindFileLine(TraceMem* self, char* file, int line)

{

        MemItem* iter;

 

        for ( iter = self->head->next; iter != self->tail; iter = iter->next )

        {

               if (line == iter->line && strcmp(file, iter->file) == 0)

                       return iter;

        }

 

        return NULL;  

}

 

/* 정리된 메모리 상태를 주는 함수 */

TraceMem* TraceMemGetSummary(TraceMem* self)

{

        MemItem *iter, *tar;

        TraceMem* sum = TraceMemCreate();

 

        for (iter = self->head->next; iter != self->tail; iter = iter->next)

        {             

               if ( (tar = TraceMemFindFileLine(sum, iter->file, iter->line)) == NULL)

               {

                       MemItem *item = MemItemCreate(0, iter->file, iter->line, iter->size);

                       TraceMemPush(sum, item);

               }

               else

               {

                       tar->num ++;

                       tar->size += iter->size;

               }

        }

 

        return sum; 
 

Test 수행하기

#include <iostream>

#include "TraceMem.h"

 

using namespace std;

 

int main()

{

        traceMem = TraceMemCreate();

 

        int *ptr = (int*) malloc(100);  // Line 11

        int *ptr2 = (int*) malloc(200);  // Line 12

 

        for (int i=1; i<=100; i++)

        {

               malloc(i); // Line 16

               if (i%4 == 0)

               {

                       malloc(i);  // Line 19

               }

        }

 

        free(ptr);

        free(ptr2);

 

        TraceMem* summary = TraceMemGetSummary(traceMem);

        TraceMemPrint(summary);

       

        TraceMemDelete(summary);

        TraceMemDelete(traceMem);

 

        return 0;

}

 

 > Report

 ++ TraceMemPrint

 ++ [D:\Works\VCxx\MemTest\Main.cpp:16:00000000] : 100/ 5050

 ++ [D:\Works\VCxx\MemTest\Main.cpp:19:00000000] : 25/ 1300

 

위의 예는 Main.cpp의 11, 12 번째 줄에서 할당한 메모리는 정상적으로 해제한 반면 16, 19번째 줄에서 할당한 메모리는 해제하지 않았다. Report는 해제되지 않은 메모리를 보여주는데, Main.cpp의 16번째 줄의 두 숫자 100은 해제되지 않은 메모리의 개수이고 5050은 메모리 사이즈의 합이다. 테스트 코드에서는 1부터 100까지의 크기로 메모리를 할당했었기 때문에, 할당된 횟수는 100회이고 사이즈는 1부터 100의 합인 5050이 된다.

결론

이상으로 메모리 감시자를 이용하여 메모리 누수를 손쉽게 찾을 수 있는 방법에 대해 알아봤다. 소개한 코드에서는 malloc과 free를 사용한 경우를 예로 들었지만 calloc에 대해서도 적용할 수 있으며, 약간의 매크로 트릭과 연산자 오버로딩을 이용하면 C++의 new와 delete에도 적용할 수 있다. 물론 소개한 알고리즘을 응용하여 가비지 컬렉터가 없는 여타의 언어에 대해서도 모두 적용할 수 있다.
메모리 감시자는 간단하지만 부담스러운 가격의 상용 디버깅 툴을 사용하지 않더라도 메모리 누수를 찾아내는 강력한 기능을 제공한다. 더구나 대부분 C를 이용하여 작성되는 임베디드 환경에서라면, 그나마도 변변한 디버깅 툴도 없는 실정이어서 특히 유용한 툴이 될 수 있을 것이다.

 

반응형