IBM®
메인 컨텐츠로 가기
    Korea [국가변경]    이용약관
 
 
   
        제품    서비스 & 솔루션    고객지원 & 다운로드    회원 서비스    
메인 컨텐츠로 가기

한국 developerWorks  >  Rational  >

"누수되는" 보트에서 "C" 탐험이라? Purify를 써보자

IBM Rational Purify를 사용해 메모리 오류와 누수 문제를 수정하기

developerWorks
문서 옵션

JavaScript가 필요한 문서 옵션은 디스플레이되지 않습니다.

토론

샘플 코드

영어원문

영어원문


제안 및 의견
피드백

난이도 : 초급

Satish Chandra Gupta, 프로그래머, RAD/PurifyPlus, Rational Software, IBM Japan
Giridhar Sreenivasamurthy, Programmer, PurifyPlus, Rational Software, IBM 

옮긴이: 박재호 이해영 dwkorea@kr.ibm.com

2008 년 7 월 08 일

부적절한 메모리 사용은 프로그램 오류 중에서 가장 고치기 어렵습니다. 이런 오류를 분석해 수정하는 작업은 두 가지 이유 때문에 엄청나게 어렵습니다. 메모리 손상 지점과 오류 발생 지점이 일반적으로 아주 멀리 떨어져 있기에 인과관계를 연관짓기가 어려우며, 증상은 예외적인 조건에서 발생하므로 오류를 일관성있게 재현하기가 어렵습니다. IBM® Rational® Purify® 도구는 이런 오류를 정확하게 자동으로 찾아줍니다. Purify 사용법을 익힘으로써 C/C++ 프로그램에서 이런 메모리 오류를 몰아낼 수 있습니다.

도입

대다수 프로그래머가 부적절한 메모리 사용이나 관리와 연관된 결함은 격리해 분석해서 수정하기가 어렵다는 사실에 동의한다. 따라서 메모리 오류는 프로그램에서 가장 값비싼 결함이다. 이런 결함은 일반적으로 초기화되지 않은 메모리, 소유하지 않은 메모리 사용, 버퍼 넘침, 힙 관리 문제 때문에 일어난다.

IBM® Rational® Purify®는 소프트웨어 개발자들이 C/C++ 프로그램에서 메모리 오류를 감지하도록 도와주는 첨단 메모리 오류 감지 도구다. 프로그램이 동작하는 동안, Purify는 일어날지 모르는 메모리 오류를 정확하게 파악하기 위해 자료를 모아 분석한다. Purify는 오류 위치(함수 호출 스택), 영향을 받는 메모리 크기와 같은 세부 정보를 제공해 문제 영역을 빠르게 찾아내도록 도와준다. 또한 디버깅 시간과 복잡도를 상당히 줄여주므로, 오류를 초래하는 응용 프로그램 논리에 숨은 오류를 수정하는 데 집중할 수 있다.

Purify는 PowerPC® 기반 IBM® AIX®, PA-RISC 기반 HP-UX®, x86과 x86/64 기반 리눅스(Linux™), 스팍(SPARC®) 기반 썬 솔라리스(Sun™ Solaris™), x86 기반 마이크로소프트 윈도우(Microsoft® Windows®, 지원 플랫폼 목록은 문서를 참조하자)를 포함한 모든 주요 플랫폼을 지원한다. 이 기사에서는 우선 예를 통해 다양한 메모리 접근 오류 유형을 정리한 다음에 Purify를 사용해 이런 오류를 찾아 수정하는 방법을 설명한다. 다운로드 절에서 이 기사에서 사용한 코드 예제가 담긴 C 파일(memerrors.c)을 찾아 Purify로 실험해보기 바란다.

메모리 오류

메모리 오류는 크게 다음과 같은 네 가지 범주로 분류가 가능하다.

  1. 초기화하지 않은 메모리 사용
  2. 소유하지 않은 메모리 사용
  3. 할당받은 공간보다 메모리를 더 사용(버퍼 넘침)
  4. 힙 메모리 관리 문제

Purify는 이 모든 범주에 해당하는 오류를 찾아내고, 범주 내에서 오류 유형을 인식한다. 오류 유형을 이해하고 있으면, 이상하고 예측이 어렵게 동작하는 프로그램에서 미묘한 실수를 찾아내어 격리할 수 있다. 지금부터 코드 예제를 활용해 다양한 오류 유형을 설명하겠다.

초기화하지 않은 메모리 사용

초기화를 잊어먹은 메모리에서 값을 읽으면 쓰레기 값만 얻는다. 이런 오류는 무해하게 보이지만 이해하기 어려운 프로그램 동작을 초래할 가능성이 있다.

우발적으로 얻은 쓰레기 값이라도 프로그램이 다룰 수 있는 의미있는 값일 가능성이 있다. 예를 들어, 몇몇 운영체제는 처음 할당할 때 메모리 블록을 초기화한다. 프로그램에서 0이 의미있는 값이면 초기에는 부드럽게 잘 돌아간다. 하지만 프로그램이 어느 정도 동작한 다음에 메모리가 할당 해제되었다가 다시 할당될지도 모른다. 메모리 블록을 재활용하면, 마지막으로 사용된 내용이 값으로 남아있게 된다. 이 값은 예측 불허다. 값에 따라, 프로그램은 바로 비정상 종료할지도 모르고, 어느 정도 동작하다가 나중에 비정상 종료할지도 모르고, 아니면 잘 돌아가긴 하지만 희한한 결과를 낼지도 모른다. 돌릴 때마다 값이 달라지기 때문에, 프로그램 동작 방식은 걷잡을 수 없으며, 결국 문제를 똑같이 재연하기가 아주 어렵다.

Purify는 이런 오류를 찾아 초기화하지 않은 메모리를 사용할 때마다 UMR(Uninitialized Memory Read) 오류로 보고한다. 또 Purify는 초기화하지 않은 메모리 사용과 초기화하지 않은 메모리 영역에서 다른 메모리 영역으로 값을 복사하는 경우를 구분한다. 초기화하지 않은 메모리 복사가 일어나면 Purify는 UMC(Uninitialized Memory Copy) 오류를 보고한다. 복사가 끝난 다음에, 목적지 역시 초기화하지 않은 메모리 상태가 되므로, 이 영역에 위치한 메모리를 사용할 경우, Purify는 UMR을 보고한다.

Listing 1에 간단한 예를 들었다. ij라는 정수값 두 개가 있다. 정수 i는 10으로 초기화된다. 그리고 나서 j 값을 i로 복사한다. j는 초기화되지 않았으므로, i 역시 j 값 복사 이후에 쓰레기 값이 들어있다. Purify는 각 메모리 위치 상태를 관리한다. Purify는 i가 10으로 초기화되었지만 초기화되지 않은 값을 복사함으로써 i 또한 초기화되지 않은 사실을 밝혀내는 분석 능력을 보유하고 있다. 따라서 Purify는 i를 사용할 경우(예를 들어 다음 행에서 printf 매개변수로 i를 넘길 때) UMR 오류를 보고한다.


Listing 1. UMR과 UMC 오류 예
                
void uninit_memory_errors() {
    int i=10, j;
    i = j;     /* UMC: j는 초기화되지 않았으며 i로 복사됨 */
    printf("i = %d\n", i); /* UMR: 쓰레기 값이 들어있는 i를 사용하는 상황 */
}

여기서 소개한 예는 코드를 살펴봄으로써 바로 문제를 파악하기 쉽게 인위적으로 간단하게 만들었다. 하지만 실세계 응용 프로그램 코드 수는 수천 행이 넘어가며, 제어 흐름도 복잡하다. 유효한 값에 쓰레기 값을 복사하는 방법으로 손상이 일어나는 위치는 다른 함수이거나 하위 시스템, 라이브러리일지도 모른다. Listing 2에 제시한 bar 메서드를 검사할 때, 이 메서드에 대해 잘 모른다면 foo 메서드 호출 다음에 i가 손상당할 가능성이 있다는 사실을 의심하기란 쉽지 않다. 원시 코드 크기와 복잡성에 따라, 이런 결함 유형을 분석해 수정하려면 상당히 많은 시간과 노력을 들여야 한다. Purify는 이런 노력을 줄이며, 초기화하지 않은 메모리 값을 사용했음을 알려주는 UMR 오류를 보고한다.


Listing 2. 또다른 UMR과 UMC 오류 예
                
void foo(int *pi) {
    int j;
    *pi = j; /* UMC: j는 초기화되지 않았으며, *pi로 복사됨 */
}

void bar() {
    int i=10;
    foo(&i);
    printf("i = %d\n", i); /* UMR: 쓰레기 값이 들어있는 i를 사용하는 상황 */
}

눈치챘겠지만, UMC 오류가 생긴 메모리 위치를 최종적으로 사용할 때, Purify는 동일한 메모리 위치에서 UMR 오류를 보고한다. UMC 오류가 항상 심각하지는 않으므로, 기본적으로 Purify는 이런 오류를 감춘다. 이 기사 후반부에 Purify가 감춰버린 UMC와 다른 오류를 살펴보는 방법을 설명하겠다.

소유하지 않은 메모리 사용

명시적인 메모리 관리와 포인터 연산은 작고 효율적인 프로그램 설계를 가능하게 만든다. 하지만 이런 기능을 잘못 사용하면 소유하지 않은 메모리를 참조하는 포인터와 같이 복잡한 결함을 초래한다. 이 경우 문제가 되는 포인터를 통해 메모리를 읽으면 쓰레기 값을 얻거나 세그멘테이션 폴트를 일으키며, 쓰레기 값을 사용하면 예측이 어려운 프로그램 동작이나 비정상 종료를 초래한다.

Purify는 이런 오류를 감지한다. 오류 유형을 보고하는 이외에, Purify는 포인터가 참조하는 메모리 영역과 메모리 할당 장소를 알려준다. 전형적으로 이는 오류 원인을 찾아내는 멋진 단서가 된다. 이런 범주는 다음과 같은 오류 유형을 포함한다.

  • 널 포인터 읽기나 쓰기(NPR(Null Pointer Read), NPW(Null Pointer Write))
  • 0 페이지 읽기나 쓰기(ZPR(Zero Page Read), ZPW(Zero Page Write))
  • 유효하지 않은 포인터 읽기나 쓰기(IPR(Invalid Pointer Read), IPW(Invalid Pointer Write))
  • 해제된 메모리 읽기나 쓰기(FMR(Free Memory Read), FMW(Free Memory Write))
  • 스택을 벗어난 읽기나 쓰기(BSR(Beyond Stack Read), BSW(Beyond Stack Write))

널 포인터 읽기나 쓰기(NPR, NPW), 0 페이지 읽기나 쓰기(ZPR, ZPW):

잠재적으로 포인터 값이 널(NULL)이 될 수 있다면, 널 값인지 점검하지 않은 상태에서 이 포인터를 역참조하면 안 된다. 예를 들어, malloc 호출은 메모리가 부족할 경우 널 값을 반환한다. malloc이 반환한 포인터를 사용하기 앞서, 널이 아닌지 확인할 필요가 있다. 예를 들어, 트리 탐색 알고리즘이나 연결 리스트는 다음 노드나 자식 노드가 널인지를 점검해야 한다.

이런 점검을 잊어먹는 경우는 흔하다. Purify는 널 포인터 역참조로 일어나는 메모리 접근을 감지해 NPR이나 NPW 오류를 보고한다. 이런 오류가 생기면, 널 포인터 점검 루틴이 필요한지, 프로그램 논리가 널이 아닌 포인터를 보장한다고 잘못 가정하는지를 살펴봐야 한다. AIX, HP, 솔라리스에서 몇몇 링커 옵션을 사용하면 널 포인터 역참조가 세그멘테이션 폴트 시그널을 날리는 대신 0 값을 생성한다.

메모리는 페이지로 나뉘며, 0 번째 페이지에 속한 메모리 위치를 읽거나 쓰는 작업은 "불법"이다. 이런 오류는 일반적으로 널 포인터나 잘못된 포인터 연산 계산 때문에 생긴다. 예를 들어, 구조체를 가리키는 널 포인터가 있는데 이 포인터로 구조체에 속한 필드에 접근할 경우, ZPR 오류가 발생한다.

Listing 3은 NPR과 ZPR 오류를 동시에 보여주는 간단한 예다. findLastNodeValue 메서드에 결함이 있는데, head 매개변수가 널인지 점검하지 않는다. nextval 필드에 접근할 때 각각 NPR과 ZPR 오류가 발생한다.


Listing 3. NPR과 ZPR 오류 예
                
typedef struct node {
    struct node* next;
    int          val;
} Node;

int findLastNodeValue(Node* head) {
    while (head->next != NULL) { /* NPR 발생 */
        head = head->next;
    }
    return head->val; /* ZPR 발생 */
}

void genNPRandZPR() {
    int i = findLastNodeValue(NULL);
}

유효하지 않은 포인터 읽기나 쓰기(IPR, IPW):

Purify는 모든 메모리 연산을 추적한다. Purify가 프로그램에 할당되지 않은 메모리 위치를 가리키는 포인터를 감지할 때, 읽기나 쓰기 연산에 따라 IPR이나 IPW 오류를 보고한다. 이 오류는 여러 가지 이유 때문에 일어난다. 예를 들어, 초기화하지 않은 포인터 변수가 있는데, 쓰레기 값이 무효한 상태가 될 경우 이런 오류 유형이 뜬다. 또 다른 예를 들자면, pi가 정수를 가리키는 포인터이며, i가 정수인 경우에 *pi = i;를 원했는데, 실수로 *를 빼먹는 바람에 그냥 pi = i;로 작성할지도 모른다. 명시적인 형 변환에 따라 정수 값이 포인터 값으로 복사된다. pi를 다시 역참조하면 IPR과 IPW 오류가 발생한다. 이런 오류는 또한 포인터 연산 결과가 비록 0 페이지는 아니지만 유효하지 않은 주소일 때도 발생한다(Listing 4 참조).


Listing 4. IPR과 IPW 오류 예
                
void genIPR() {
    int *ipr = (int *) malloc(4 * sizeof(int));
    int i, j;
    i = *(ipr - 1000); j = *(ipr + 1000); /* IPR 발생 */
    free(ipr);
}

void genIPW() {
    int *ipw = (int *) malloc(5 * sizeof(int));
    *(ipw - 1000) = 0; *(ipw + 1000) = 0; /* IPW 발생 */
    free(ipw);
}

IPR과 IPW 오류는 일반적으로 64비트 환경에서 포인터를 반환하는(예: malloc) 함수를 사용할 때 일어난다. 포인터는 8바이트이고, 정수는 4바이트이기 때문이다. 메서드 선언이 포함되지 않았다면, 컴파일러는 메서드가 정수를 반환한다고 가정하며, 반환 값을 암시적으로 형 변환해 포인터 값 중 단지 하위 4바이트만 보존한다. Purify는 이런 유효하지 않은 포인터(Listing 5 참조)를 사용할 때 IPR과 IPW 오류를 보고한다.


Listing 5. 또 다른 IPR과 IPW 오류 예
                
/* 다음에 나오는 64비트 응용을 위한 헤더 파일 인클루드를 잊어먹었다.
#include <malloc.h>
#include <stdlib.h>
 */

void illegalPointer() {
    int *pi = (int*) malloc(4 * sizeof(int));
    pi[0] = 10; /* Expect IPW */
    printf("Array value = %d\n", pi[0]); /* IPR 발생 */
}

해제된 메모리 읽기나 쓰기(FMR, FMW):

malloc이나 new를 사용할 때, 운영체제는 메모리를 힙에서 할당한 다음에 해당 메모리 영역을 가리키는 포인터를 반환한다. 이런 메모리를 더 이상 사용하지 않을 때, freedelete를 호출해 할당을 해제해야 한다. 이상적인 이야기지만, 할당을 해제한 다음에는 이 영역에 위치한 메모리를 다시 읽고 쓰면 안 된다.

하지만 동일한 메모리 영역을 가리키는 프로그램 내부 포인터를 둘 이상 사용할지도 모른다. 예를 들어, 연결 리스트를 탐색하는 동안에, 노드를 가리키는 포인터와 함께 직전 노드에서 next로 저장된 노드를 가리키는 포인터를 유지할지도 모른다. 이런 경우에는 두 포인터가 동일 메모리 블록을 가리킨다. 이 노드를 free하면, 두 포인터는 힙 허상 포인터가 된다. 이런 오류를 일으키는 또 다른 원인으로 realloc 메서드 사용을 들 수 있다(Listing 6).

동일한 프로그램에서 또 다른 malloc 호출에 반응해 힙 관리 시스템이 해제한 메모리를 다른 관련없는 객체에 할당할지도 모른다. 허상 포인터를 사용하면서, 이를 통해 메모리에 접근한다면, 프로그램 동작 방식은 미정의 상태가 된다. 이렇게 되면 이상한 동작을 하거나 비정상 종료를 일으킬 가능성이 있다. 허상 포인터로 읽은 값은 완전히 관련성이 없는 자료이며 쓰레기다. 허상 포인터를 통해 메모리를 수정하고, 나중에 이 값을 미리 의도한 방식으로 관련없는 맥락에서 사용할 경우 어떻게 동작할지 예측이 불가능하다. 물론 초기화되지 않은 포인터나 잘못된 포인터 연산 역시 이미 해제된 힙 메모리를 가리키는 결과를 초래할 가능성이 있다.


Listing 6. FMR과 FMW 오류 예
                
int* init_array(int *ptr, int new_size) {
    ptr = (int*) realloc(ptr, new_size*sizeof(int));
    memset(ptr, 0, new_size*sizeof(int));
    return ptr;
}

int* fill_fibonacci(int *fib, int size) {
    int i;
    /* 이런 뭔가를 잊어버렸네?: fib = */ init_array(fib, size);
    /* fib[0] = 0; */ fib[1] = 1;
    for (i=2; i<size; i++)
        fib[i] = fib[i-1] + fib[i-2];
    return fib;
}

void genFMRandFMW() {
    int *array = (int*)malloc(10);
    fill_fibonacci(array, 3);
}

스택을 벗어난 읽기나 쓰기(BSR, BSW):

전역 변수, 힙 메모리 영역, 호출 연쇄 과정에서 함수 내 지역 변수 주소가 부모 함수의 스택 프레임에 직간접으로 저장되어 있을 경우, 함수 수행을 마치고 돌아올 때 스택 허상 포인터가 된다. 스택 허상 포인터는 메모리 위치에 읽고 쓰기 위해 역참조될 경우, 현재 스택 경계를 벗어난 외부 메모리에 접근하므로, Purify는 BSR이나 BSW 오류를 보고한다. 초기화하지 않은 포인터 변수나 잘못된 포인터 연산 또한 BSR이나 BSW 오류를 일으킨다.

Listing 7에 제시한 예를 보면, append 메서드는 지역 변수 주소를 반환한다. 이 메서드가 수행을 마치고 돌아오면, 스택 프레임이 해제되며, 스택 영역이 줄어든다. 이제 반환된 포인터는 스택 영역 바깥에 위치한다. 이 포인터를 사용하면, Purify는 BSR이나 BSW 오류를 보고한다. 이 예제에서, append("IBM ", append("Rational ", "Purify"))를 수행하면 "IBM Rational Purify"를 반환하리라 기대하겠지만, BSR과 BSW 오류를 일으키는 쓰레기 값을 반환한다.


Listing 7. BSR과 BSW 오류 예
                
char *append(const char* s1, const char *s2) {
    const int MAXSIZE = 128; 
    char result[128];
    int i=0, j=0;

    for (j=0; i<MAXSIZE-1 && j<strlen(s1); i++,j++) {
        result[i] = s1[j];
    }

    for (j=0; i<MAXSIZE-1 && j<strlen(s2); i++,j++) {
        result[i] = s2[j];
    }

    result[++i] = '\0';
    return result;
}

void genBSRandBSW() {
    char *name = append("IBM ", append("Rational ", "Purify"));
    printf("%s\n", name); /* BSR 발생 */
    *name = '\0'; /* BSW 발생 */
}

할당하지 않은 메모리 사용이나 버퍼 넘침

배열에서 범위 점검을 올바르게 하지 않을 때, 루프를 돌다가 배열 범위를 벗어나는데, 이를 버퍼 넘침이라 부른다. 버퍼 넘침은 할당한 메모리보다 더 많이 사용함으로써 발생하는 아주 흔한 프로그래밍 오류다. Purify는 힙 메모리에 위치한 배열에서 일어나는 버퍼 넘침을 감지해 ABR이나 ABW 오류를 보고한다(Listing 8 참조).


Listing 8. ABR과 ABW 오류 예
                
void genABRandABW() {
    const char *name = "IBM Rational Purify";
    char *str = (char*) malloc(10);
    strncpy(str, name, 10);
    str[11] = '\0'; /* ABW 발생 */
    printf("%s\n", str); /* ABR 발생 */
}

힙 메모리 관리 문제

C/C++에서 명시적인 메모리 관리는 프로그래머에게 메모리 관리라는 부담을 지게 한다. 따라서 힙 메모리를 할당하고 해제하는 동안에 방심하지 말아야 한다. 흔히 일어나는 메모리 관리 실수는 다음과 같다.

  • 메모리 누수와 잠재적인 메모리 누수(MLK(Memory LeaKs), PLK(Potential LeaKs), MPK)
  • 유효하지 않은 메모리 해제(FIM, Freeing Invalid Memory)
    • 일치하지 않는 메모리 해제(FMM, Freeing Mismatched Memory)
    • 힙에 없는 메모리 해제(FNH, Freeing Non-Heap memory)
    • 할당하지 않은 메모리 해제(FUM, Freeing Unallocated Memory)

메모리 누수와 잠재적인 메모리 누수:

특정 힙 메모리 블록을 가리키는 포인터를 남김없이 잃어버리는 현상을 흔히 메모리 누수라고 부른다. 이 메모리를 가리키는 유효한 포인터가 없다면, 이 메모리를 사용하거나 해제할 방법은 없다. 다른 주소로 포인터 값을 덮어 써버리거나 포인터 변수가 영역 바깥으로 나가거나 포인터를 저장하고 있는 구조체나 배열을 할당 해제하면, 해당 메모리를 가리키는 포인터를 잃어버린다. Purify는 모든 메모리를 검사해, 이 메모리 주소를 가리키는 포인터가 없는 영역을 MLK로 보고한다. 또한, Purify는 블록 중간을 가리키는 포인터가 있지만 블록 시작을 가리키는 포인터가 없을 경우 잠재적인 누수(PLK, 윈도우 플랫폼에서는 MPK)로 해당 전체 블록에 문제가 있다고 보고한다.

Linsting 9는 메모리 누수와 힙 허상 포인터를 보여주는 간단한 예다. 이 예에서 흥미롭게도 메서드 foomain은 떼놓고 보면 오류가 없어 보이지만, 함께 결합할 경우 둘 다 오류를 일으킨다. 이 예제는 메서드 사이에 상호 작용이 단순히 개별 함수를 검사하는 방법만으로 발견하기 곤란한 복합 오류를 찾아내는 예를 보여준다. 실세계 응용 프로그램은 아주 복잡하므로 제어 흐름과 연쇄를 검사하고 분석하느라 따분하고 시간 소모적인 작업이 들어간다. Purify는 이런 상황에서 오류 발견에 결정적인 도움을 준다.

첫째로, 메서드 foo에서 포인터 pi는 새로운 메모리 할당으로 겹쳐써지므로, 과거 메모리 블록을 가리키는 모든 포인터 값을 잃어버린다. 이런 결과로 인해 main 메서드에서 할당된 메모리 블록에 누수가 일어난다. Purify는 MLK를 보고하며, 누수되고 있는 메모리를 할당한 위치가 몇 행인지 알려준다. Purify는 누수가 일어나는 메모리 블록을 추적하느라 낭비하는 시간을 줄여주므로 디버깅 시간을 단축한다. 누수가 보고된 메모리 위치부터 디버깅을 시작하면, 포인터로 어떤 작업을 하고 있으며 어디서 겹쳐쓰는지 추적이 가능하다.

다음으로 메서드 foo는 할당된 메모리를 해제하지만, 포인터 pi는 여전히 해당 주소를 포함한다(null로 설정하지 않은 상태다). 메서드 foo에서 main으로 돌아온 다음에, 포인터 pi를 사용할 때, 이미 해제된 메모리를 참조하므로 pi는 허상 포인터가 된다. Purify는 이 위치에서 FMW 오류를 보고한다.


Listing 9. 메모리 누수와 허상 포인터를 다루는 예
                
int *pi;
void foo() {	
    pi = (int*) malloc(8*sizeof(int)); /* pi를 위한 메모리 할당 */
    /* 이런, 4 int를 담은 pi가 가리키는 예전 메모리에 누수가 생긴다. */
    /* use pi */
    free(pi); /* pi를 다 썼기에 foo()에서 할당 해제한다. */
}
void main() {
    pi = (int*) malloc(4*sizeof(int)); /* MLK 발생: foo에서 누수가 일어난다. */
    foo();
    pi[0] = 10; /* FMW 발생: 이런, pi는 이제 허상 포인터다. */
}

Listing 10은 잠재적인 메모리 누수를 다루는 예다. plk 포인터를 증가시키면, plk는 메모리 블록의 중간을 가리키지만, 이 메모리 블록의 시작을 가리키는 포인터는 없다. 따라서 잠재적인 메모리 누수가 블록 메모리 할당 지점에서 보고된다.


Listing 10. 잠재적인 메모리 누수를 다루는 예
                
int *plk = NULL;
void genPLK() {
    plk = (int *) malloc(2 * sizeof(int)); /* PLK 발생 */
    plk++;
}

유효하지 않은 메모리 해제:

이 오류는 해제를 허용하지 않는 메모리를 해제하려고 시도할 때 발생한다. 이런 오류는 여러 가지 이유 때문에 발생한다. 일관성없는 메모리 할당과 해제, 힙이 아닌 메모리 해제(다시 말해 스택 메모리 영역을 가리키는 포인터를 해제), 할당하지 않은 메모리 해제 등이 좋은 예다. 윈도우용 Purify는 이 모든 오류를 FIM으로 보고한다. 유닉스 시스템에서, Purify는 정확한 오류 원인을 알려주기 위해 FMM(Freeing Mismatched Memory), FNH(Freeing Non-Heap memory), FUM(Freeing Unallocated Memory)으로 세분화해 FIM 오류를 보고한다.

FMM은 할당에 사용한 함수와 계열이 다른 함수가 메모리를 할당 해제할 때 보고된다. 예를 들어, new 연산자를 사용해 메모리를 할당했는데, free 메서드로 메모리를 해제하는 경우를 생각해보자. Purify는 다음 계열이 쌍이 맞는지 점검한다.

  • malloc() / free()
  • calloc() / free()
  • realloc() / free()
  • operator new / operator delete
  • operator new[] / operator delete[]

Purify는 메모리 할당과 할당 해제에서 호환이 이뤄지지 않는 쌍을 사용할 경우 FMM 오류를 보고한다. Listing 11에 제시한 예를 보면, 메모리는 malloc 메서드로 할당되었지만 올바른 쌍이 아닌 delete 연산자로 해제되었으므로 호환성이 없다. 자주 일어나는 또 다른 FMM 오류 예는 new[] 연산자를 사용해 배열을 할당했지만, delete[] 연산자 대신 일반 delete 연산자를 사용하는 경우에 일어난다. 이런 오류는 코드 검사를 통해 찾아내기가 아주 어렵다. 메모리 할당과 할당 해제 위치가 인접해 있지 않으며, 정수 포인터와 정수 배열 포인터를 가리키는 구문에 차이가 없기 때문이다.


Listing 11. 쌍이 맞지 않은 메모리 할당 오류를 다루는 예
                
void genFMM() {
    int *pi = (int*) malloc(4 * sizeof(int));
    delete pi; /* FMM/FIM 발생: free(pi);를 사용해야 한다. */
    pi = new int[5];
    delete pi; /* FMM/FIM 발생: delete[] pi;를 사용해야 한다. */
}

FNH 오류는 힙이 아닌 주소를 매개변수로 free를 호출할 경우에 보고된다. FUM은 이미 할당 해제가 이뤄진 메모리나 메모리 블록 중간을 가리키는 지점을 해제하는 등 할당하지 않은 메모리를 해제하려고 시도할 때 보고된다. Listing 12는 이런 오류를 다루는 예다.


Listing 12. 힙이 아닌 메모리나 할당하지 않은 메모리 해제 오류를 다루는 예
                
void genFNH() {
    int fnh = 0;
    free(&fnh); /* FNH 발생: 스택 메모리 해제 */
}

void genFUM() {
    int *fum = (int *) malloc(4 * sizeof(int));
    free(fum+1); /* FUM 발생: fum+1은 블록 중간을 가리킨다. */
    free(fum);
    free(fum); /* FUM 발생: 이미 해제된 메모리 해제 */
}

IBM Rational Purify 활용하기

Purify는 프로그램에서 동적 분석을 수행하는 방법으로 프로그램에 숨어 있는 메모리 오류를 찾아낸다. Purify는 프로그램에서 적절한 위치에 보조 코드를 삽입하는 방법으로 가공한다. 가공된 프로그램이 수행되면, 추가로 삽입된 코드는 적절한 정보를 수집하는 방법으로 실행 과정에서 메모리 유효성 검사를 수행한다. 메모리 점검에 실패하면, Purify는 오류를 보고한다. 점검 끝 무렵에는 Purify가 누수되는 메모리 블록(물론 어느 시점에서도 누수되는 메모리가 있는지 요청이 가능하다)을 탐색하는 작업을 수행한다.

Purify는 프로그램과 라이브러리 목적 코드를 가공한다. 이런 과정은 객체 코드 가공(OCI, Object Code Instrumentation)이라고 부른다. Purify는 객체 코드 가공에 의존하며, 소스 코드 가공에는 의존하지 않으므로 심지어 라이브러리 원시 코드가 없는 상황에서도 프로그램과 링크된 외부 라이브러리를 대상으로 메모리 점검을 수행할 수 있다.

디버그 플래그를 붙여 코드를 컴파일하자. Purify는 디버그 정보를 활용해 원시 코드 행 번호와 오류를 관련지으므로 오류 보고 과정에서 관련 원시 코드를 출력할 수 있다. 디버그 정보가 없는 프로그램 영역을 만나면 Purify는 프로그램 카운터나 CPU 명령어와 같은 목적 코드 정보와 오류를 관련짓는다.

Purify는 메모리 사용 오류와 함께 함수 호출 체인을 보고한다. 오류가 외부 라이브러리에서 발생했을 경우에도 이 라이브러리를 호출한 함수는 (원시 코드가 있는) 코드 내부에 반드시 존재한다. 호출 체인을 살피면 디버그 정보와 함께 메서드가 나타나므로 외부 라이브러리 호출이 일어난 정확한 행을 원시 코드와 함께 출력한다. 이런 정보는 오류가 일어나는 주변 환경을 찾아내는 과정에서 중요한 단서가 된다. 이런 정보를 토대로 코드에서 오류가 일어났는지(예를 들어 외부 함수에 초기화하지 않은 인수를 전달하는 경우) 아니면 외부 라이브러리에서 오류가 일어났는지(예를 들어 라이브러리 내부에 초기화하지 않은 변수가 있는지) 분석할 수 있다.

Purify를 사용하려면 다음 단계를 밟는다.

  1. 디버그 옵션을 붙여 코드 컴파일하기
  2. Purify를 사용해 이진 파일을 가공하기
  3. 가공된 프로그램을 실행하기
  4. Purify가 보고한 오류를 검사해서 수정하기

가공 과정은 윈도우와 유닉스 플랫폼이 다르다. 이 절 나머지 부분에서는 플랫폼 별로 Purify 사용법을 설명하겠다.

윈도우에서 Purify 사용하기

윈도우 환경에서 Purify를 사용하는 방법에는 두 가지가 있다.

  • 첫 번째로, Purify는 마이크로소프트 비주얼 스튜디오(Microsoft® Visual Studio®) IDE와 통합되어 있으므로 버튼 클릭으로 Purify를 붙였다 뗄 수 있다. Purify를 붙이면, 프로젝트를 빌드할 때, Purify가 자동으로 빌드하는 실행 파일을 가공한다. 이렇게 만들어진 실행 파일을 수행하면, Purify의 오류 점검 코드가 동작해 IDE 내부에서 메모리 사용 통계와 더불어 오류를 보고한다.
  • 두 번째로, Purify를 수행해 Purify GUI로 실행 파일을 가공하고 다양한 가공 옵션을 설정하는 방법이 있다. 가공이 끝난 프로그램을 수행하면, Purify GUI 내부 창으로 오류가 출력된다. 이 절에서는 화면 예제를 통해 실행 파일을 가공하고 가공된 파일을 돌리고, 오류를 검사하는 방법을 설명한다.

Purify는 정확한 오류 점검을 위해 자료를 재배치해야 하는데, 비주얼 스튜디오 컴파일러는 기본적으로 이런 기능을 수행하지 않는다. 자료 재배치 기능을 사용하려면 링커 옵션에 /fixed:no/incremental:no를 추가해야 한다. 자료 재배치를 하지 않으면 Purify는 최소 오류 점검만 수행한다.

프로그램을 가공해 돌리려면 단독으로 Purify를 실행한 다음, 아래에 소개하는 단계를 밟아보자.

  1. (다운로드 절에서 내려받은) memerrors.c를 디버그 옵션을 붙여 컴파일해 실행 파일을 생성한다.
  2. File > Run을 선택해 (그림 1에 나온) Run Program 대화 상자를 띄운다.
  3. Program name 상자에 실행 파일 위치 경로를 지정한다.
  4. Collect 옵션 아래에 Error and leak data 라디오 버튼을 선택한다.
  5. 프로그램이 동작을 마친 다음에 콘솔을 유지하도록 Pause console after exit 체크 박스를 선택한다.
  6. (옵션) Settings를 클릭해 Purify 설정을 변경한다.
  7. Run을 클릭한다.

그림 1. 실행 파일 가공을 위한 Run Program 대화 상자
실행 파일 가공을 위한 Run Program 대화 상자

Purify는 이진 파일을 가공해 프로그램을 돌린다. 그림 2에서 보이는 윈도우는 실행 프로그램 가공 진행 과정과 프로그램이 요구하는 여러 라이브러리를 보여준다.


그림 2. 실행 파일과 DLL 가공 진행 과정
실행 파일과 DLL 가공 진행 과정

가공 이후, 가공된 프로그램을 수행한다. 가공된 프로그램을 수행하는 과정에서 Purify가 감지한 메모리 접근 오류를 보고한다. 마지막으로 Purify는 프로그램에서 메모리 누수를 보고한다. 그림 3은 전형적인 Purify 오류와 누수 보고 결과를 보여준다.


그림 3. (윈도우에서) Purify가 발견한 메모리 오류
(윈도우에서) Purify가 발견한 메모리 오류

Purify는 오류, 경고, 메모리 누수 요약을 보고한다. 통계, 스택 추적, 행 번호와 같은 세부 사항을 보려면 오류 항목을 클릭한다. 예를 들어, ABR 오류를 클릭하면, 오류 위치와 메모리 할당 위치를 둘 다 확인할 수 있다(그림 4 참조). 오류 지점에서 원시 코드를 확인하는 방법으로 문자열 str이 printf로 넘어갈 때 memerrors.c 파일 110행에서 ABR 오류가 발생함을 알 수 있다. printf 메서드는 NULL 바이트를 만날 때까지 str 문자열을 처리한다. printf 호출에 앞서, strname에서 10바이트를 복사했으며, str[11]은 문자열을 끝내기 위해 NULL로 설정되었다. 따라서 printf는 11바이트를 읽어야만 한다. 메모리 할당 위치를 살펴보면, memerrors.c 파일의 107행에서 str이 문자 10개만 저장하도록 할당된 사실을 찾을 수 있다. 단서 세 개를 합쳐 생각해보면, str[11]은 배열 경계를 넘어선 바이트에 접근했으므로 ABR 오류가 발생한다. 이는 NULL 문자로 끝을 맺는 문자열을 저장하는 데 필요한 배열 크기를 잘못 계산한 전형적인 오류다.


그림 4. (윈도우에서) 원시 코드와 행 번호 정보를 포함하는 ABR 오류 세부 내역
(윈도우에서) 원시 코드와 행 번호 정보를 포함하는 ABR 오류 세부 내역

Purify가 보고한 각 오류를 검토하자. Purify가 제공한 유용한 세부 정보를 통해 오류를 디버깅해서 제거할 수 있다. 결함을 수정한 다음에 Purify를 다시 한번 돌려 더 이상 오류 보고가 없는지 비교하기 바란다.

Purify는 필요한 분석 유형을 선택하는 과정에서 유연성을 제공하기 위해 다양한 옵션으로 제어가 가능하다. (수정이 불가능한 외부 라이브러리에 존재하는 오류처럼) 관심이 없는 오류 보고를 받지 않을 수도 있다.

여기에 스택 변수와 관련된 UMC 오류에 관심이 있다고 윈도우 환경에 알려주는 방법을 정리했다.

  1. Run Program 대화상자에서 Settings를 클릭한다(그림 1).
  2. Errors and Leaks 탭으로 가서 Show UMC messages를 켠다(그림 5).
  3. Files 탭으로 가서 Additional options 상자에서 -stack-load-checking을 입력해(그림 6), 스택 변수에서 UMR 오류를 점검하도록 Purify에 알려준다.
  4. OK를 클릭하고, Run Program 대화상자에서 Run을 클릭한다.
  5. Purify는 프로그램을 수행하고 프로그램에서 UMC 메모리 오류를 보고한다(그림 3).

그림 5. (윈도우 환경에서) Settings 대화상자에서 "Show UMC messages" 체크박스를 켜는 모습
(윈도우 환경에서) Settings 대화상자에서 'Show UMC messages' 체크박스를 켜는 모습

그림 6. (윈도우 환경에서) 스택 변수에 대해 UMR 오류를 검사하기 위한 추가 옵션
(윈도우 환경에서) 스택 변수에 대해 UMR 오류를 검사하기 위한 추가 옵션

살펴보기를 원하지 않는 오류 보고를 제외하도록 필터를 정의할 수도 있다. Purify 윈도우 왼쪽 영역에서 Run을 오른쪽으로 클릭하고 Filter Manager 대화상자를 선택한다(그림 7). Purify는 특정 라이브러리나 특정 호출 스택에서만 오류 보고 집합을 제외하도록 만들거나 특정 오류 보고 유형만 제외하도록 만드는 풍부한 선택 집합을 제공한다.


그림 7. (윈도우에서) 흥미없는 오류를 제외하는 Filter Manager 동작하기
(윈도우에서) 흥미없는 오류를 제외하는 Filter Manager 동작하기

여러 가지 Purify 기능을 살펴보려면 Help 메뉴 아래에 제공되는 세부 문서를 읽어보자.

유닉스와 리눅스에서 Purify 사용하기

유닉스 플랫폼에서, 가공된 실행 파일을 빌드하는 방법은 다양하다. 가장 손쉬운 방법은 실행 파일을 빌드하기 위해 사용하는 명령행 앞에 purify라는 단어를 접두어로 붙인다.

예를 들어, (다운로드 절에서 내려받은) memerrors.c 파일로 a.out을 만들고 싶다면 다음과 같이 명령을 내린다.

ksh% cc -g memerrors.c

접두어 purify를 붙이면, 가공된 a.out 프로그램을 만들어낸다.

ksh% purify cc -g memerrors.c

실행 파일 빌드에 Makefile을 사용한다면, 가공된 실행 파일을 빌드하기 위해 대상을 하나 더 추가한다.

a.out: foo.c bar.c
       $(CC) $(FLAGS) -o $@ $?
a.out.pure: foo.c bar.c
        purify $(CC) $(FLAGS) -o $@ $?

대상 실행 파일(이 경우에는 a.out이다)을 빌드하려면 지시자를 복사해 두 가지만 바꾸면 된다.

  • 목표 이름을 바꾼다(a.out.pure).
  • 목표 빌드를 위한 명령어에 purify 접두어를 추가한다.

모든 플랫폼에서 가공 작업은 링크 시점에 수행한다. AIX에서 가공 작업은 실행 파일을 대상으로 직접 수행할 수도 있다.

ksh% purify a.out

여기에 AIX를 기준으로 memerrors.c를 컴파일하고 가공한 다음에 가공된 실행 프로그램을 수행하는 과정을 정리했다.

ksh % cc -g memerrors.c
ksh % purify a.out
Purify 7.0 AIX (32-bit) (C) Copyright IBM Corporation. 1992, 2006 All Rights Reserved.
Instrumenting: a.out. libc.a,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,.......,,,,, libcrypt.a.,
Instrumented a.out is a.out.pure.
Done.
ksh % ./a.out.pure

가공된 프로그램을 수행할 때, Purify GUI는 메모리 오류를 감지하자마자 바로 보고한다(그림 8 참조).


그림 8. (유닉스에서) Purify가 발견한 메모리 오류
(유닉스에서) Purify가 발견한 메모리 오류

세부 사항을 보려면 오류를 클릭한다. 그림 9는 프로그램에서 발생한 ABR 오류 세부 사항을 보여준다. 널로 끝나는 str에 대한 메모리는 memerrors.c 파일 107행에서 할당된다는 세부 사항을 추론할 수 있다. 하지만 genABRandABW 메서드에서 크기 계산을 잘못했기에, name 문자열을 str에 복사한 직후 NULL 바이트가 str[11]에 저장된다. 이는 str을 읽는 과정에서 printf가 경계를 넘어가도록 만들기에 110행에서 ABR 오류가 일어난다.


그림 9. (유닉스에서) 원시 코드와 행 번호 정보를 포함한 ABR 오류 세부 사항
(유닉스에서) 원시 코드와 행 번호 정보를 포함한 ABR 오류 세부 사항

유닉스와 리눅스에서 동작하는 Purify는 스택 변수와 관련있는 내용을 포함한 모든 UMC 오류를 추적한다. 하지만 기본적으로 Purify는 이런 오류를 제외한다. View > Suppressed messages(그림 10)을 선택해 제외된 모든 오류를 확인할 수 있다.


그림 10. (유닉스에서) 제외된 오류 살펴보기
(유닉스에서) 제외된 오류 살펴보기

관심이 없는 오류 보고서를 제외하는 작업 역시 아주 간단하다. 오류 유형을 선택한 다음에 오른쪽 클릭을 해서 나타나는 메뉴에서 Suppress를 선택한다(그림 11).


그림 11. (유닉스에서) 오류를 제외하기
(유닉스에서) 오류를 제외하기

여러 가지 Purify 기능을 살펴보려면 Help 메뉴 아래에 제공되는 세부 문서를 읽어보자.

요약

이제 다양한 메모리 오류 유형과 이를 초래하는 잠재적인 프로그래밍 실수를 익혔다. 대다수 메모리 관련 오류는 프로그램에서 오류가 없어 보이는 메서드끼리 상호 작용을 통해 일어난다. 따라서 특히 복잡한 제어 흐름을 보이는 현실에서 사용하는 대규모 응용 프로그램에 대해서는, 코드 검토만으로 이런 오류 유형을 감지하기란 거의 불가능하다. 이런 오류를 찾아 수정하는 작업이 엄청나게 어렵고 따분하고 시간 소모가 큰 이유는 결함과 원인을 초래한 장소가 멀리 떨어져 있는데다 근본적으로 이런 오류는 예측이 불가능하고, 증상이 이상하고, 일관성있는 재현이 어렵기 때문이다.

이런 오류가 미치는 영향은 프로그램에 따라 다르며, 종종 이해하지 못하는 비정상 종료로 끝나거나 실행할 때마다 결과가 다를지도 모른다. IBM Rational Purify는 메모리 오류를 찾아 격리해서 수정하는 데 도움을 주므로 디버깅 시간을 상당히 단축할 수 있다. 이 기사에서는 Purify를 사용해 프로그램을 가공하는 방법을 보여줬다. 가공된 프로그램을 수행할 때, Purify는 오류가 발생했을 때 각 오류에 대해 보고하며, 행 번호와 호출 스택 같은 세부 내역을 제공함으로써 근본 원인을 인식하고 문제를 디버깅하는 과정에서 결정적인 증거를 제공한다. Purify를 사용해 메모리 결함에서 벗어난 더 나은 C/C++ 응용 프로그램을 개발할 수 있다.





위로


다운로드 하십시오

설명이름크기다운로드 방식
이 글에서 사용한 예제 코드memerrors.zip3KBHTTP
다운로드 방식에 대한 정보


참고자료

교육

제품 및 기술 얻기

토론


필자소개

Satish Gupta 사진

Satish Chandra Gupta는 인도 방갈로르에 위치한 IBM Rational® PurifyPlus® 그룹의 개발자다. 관심 분야는 컴파일러, 프로그래밍 언어, 런타임 분석, 자바 메모리 누수, 유형 이론, 소프트웨어 엔지니어, 소프트웨어 개발 환경 등이다. 이와 관련한 연구가 ACM/IEEE 컨퍼런스에 출간됐다. 인도 칸푸르의 Indian Institute of Technology에서 학사 학위를, 미국 밀워키의 University of Wisconsin에서 석사 학위를 받았다.


Giridhar Sreenivasamurthy

Giridhar Sreenivasamurthy는 인도 방갈로르에 위치한 IBM® Rational® PurifyPlus® 그룹에서 개발자로 근무한다. Sreenivasamurthy는 실행 중 분석, 컴퓨터 아키텍처, 컴파일러, 객체 지향 설계에 관심이 많다. Sreenivasamurthy는 인도 카나타카에 있는 비스베스와라이아 기술 대학교에서 학사 학위를 받았다




기사에 대한 평가


보다 나은 서비스를 제공하기 위함이오니 잠시 짬을 내어 이 양식을 제출하여 주십시오.



 


 


 


이 문서 북마킹 하기

mar.gar.in mar.gar.in naver naver eolin eolin del.icio.us del.icio.us





위로


developerWorks 콘텐트를 다른 사이트에 전재하기:
developerWorks 콘텐트에 대한 저작권은 IBM에 있습니다. IBM의 서면 허가나 원본 저자의 허락이 없이는 전재를 금합니다. 저희 콘텐트를 전재하시려면 IBM developerWorks 담당자 에게 문의하십시오.
    IBM 소개 개인정보 보호정책 문의