노무현 대통령 배너


2008. 10. 28. 17:49

디버깅의 도(2)

출처: http://www.buggymind.com/95

손수 만드는 디버깅 툴

디버깅을 할 때 가장 많이 쓰게 되는 툴은 무엇일까요? 아이러니하게도, 디버거가 아닙니다. 사실 툴이라고 하기도 좀 뭐하죠. 사람들이 디버깅할 때 가장 많이 사용하는 도구는, printf입니다. (Java라면 System.out.println이나 System.err.println쯤 되겠군요. C++이라면 cout이나 cerr가 되겠습니다.) 프로그램을 디버깅할 때 '프로그램의 상태를 화면에 출력하는' 일을 가장 많이 하게 된다는 뜻이죠.

하지만 printf를 무작정 프로그램 코드 안에 삽입하다보면, 나중에 삽질을 하게 됩니다. 어떤 삽질일까요? 네, 맞습니다. 나중에 프로그램 개발을 완료하고 시스템을 패키지화 해서 릴리즈 할 때가 되면, 그 모든 'printf' 문들을 전부 코드에서 지워줘야 합니다. 그러니, 가급적이면 printf를 써서 디버깅을 하더라도 좀 지능적으로 하는 것이 좋겠죠.

사용자 삽입 이미지

마이크로소프트의 Visual C++이라는 툴을 쓰다 보면 (물론 다른 툴들에도 그런 기능이 있습니다만) 컴파일을 디버그 모드로 할 것이냐 릴리즈 모드로 할 것이냐 하는 옵션을 보게 됩니다. 이런 두 가지 옵션이 있다는 것은, 릴리즈 모드로 컴파일할 때에는 '디버그 할 때에만 실행되던 코드들은 더 이상 실행되지 않아야 한다'는 원칙이 지켜져야 함을 보여줍니다.

그렇다면 디버그 모드와 릴리즈 모드는 어떻게 구별하나요? 컴파일 옵션을 통해 구별합니다.

디버그 모드로 컴파일할 때에는, 현재 컴파일이 디버그 모드에서 진행됨을 표시하는 특별한 매크로 상수를 정의하는 것이 보통입니다. gcc라면, 다음과 같이 합니다.

gcc -D_DEBUG <이하 생략>
위의 -D 옵션은 컴파일러에게 _DEBUG라는 심볼을 정의한 다음 컴파일 할 것을 주문합니다. 따라서 소스 코드를 작성할 때 #ifdef 와 #ifndef 을 적절히 활용하면, 디버깅 모드에서 실행되어야 할 코드와 그렇지 않아야 하는 코드를 적절히 나누어 작성할 수 있습니다. 다음과 같이 하면 되겠죠.

#ifdef _DEBUG
...
#else
...
#endif
그런데 printf문 하나 넣자고 그 앞뒤로 #ifdef를 둘러치자니, 그것도 못할 짓인것 같군요. 그러니, 매크로 함수를 정의해서 그 매크로 함수가 컴파일 옵션에 따라 서로 다른 방식으로 동작하도록 구현하는게 더 좋겠어요. 우선, 사용자가 원하는 문자열을 화면에 찍을 수 있도록 해 주는 매크로 함수인 DUMP의 구현부터 살펴보죠.

#ifdef _DEBUG

#define DUMP(PRNSTR,...)  printf( \
 "dumping   [%010u,%s,%04d] " #PRNSTR "\n", \
 (unsigned int)pthread_self(), __FILE__,__LINE__,__VA_ARGS__)

#else

#define DUMP(PRNSTR,...)

#endif
위의 DUMP 함수는 두 개의 인자를 받습니다. 첫 번째 인자인 PRNSTR은 printf의 서식 문자열의 일부로 사용될 문자열이고, 두 번째 인자(...)는 그 서식 문자열(PRNSTR)에 의해 출력될 인자들이에요. (printf와 똑같은 방식으로 실행되는 매크로 함수라는 거죠.)

첫 번째 인자 PRNSTR은 #PRNSTR을 통해 큰 따옴표로 둘러쳐진 문자열로 변환됩니다. (# 기호가 어떤 역할을 하는 지에 대해서는 C 전처리기 관련 문서를 찾아보시는 것이 좋겠습니다.) 따라서 "dumping   [%010u,%s,%04d] " #PRNSTR "\n"는 하나의 문자열입니다. C/C++에서는 "a" "b" "c"가 "abc"와 같으니까요. 따라서 최종적으로 만들어질 서식 문자열(printf의 첫 번째 인자로 넘겨지는)에는 쓰레드 아이디와 파일명, 그리고 라인수를 출력할 자리가 기본적으로 포함됩니다.

두 번째 인자(...)는 첫 번째 인자로 준 서식 문자열에 의해 출력될 인자들인데요, __VA_ARGS__를 사용해서 printf의 인자로 그대로 넘겨버렸습니다.

이렇게 매크로 함수를 정의하고 나면, 프로그램 코드 안에서 다음과 같은 짓을 할 수 있습니다.

int variable = 0;
...
DUMP("%d", variable);
...
이 코드를 -D_DEBUG를 사용해서 디버그 모드로 컴파일하고 실행시키면, DUMP가 실행될 때 마다 화면에 쓰레드 아이디와 파일명, 그리고 DUMP가 놓인 라인수를 포함하는 정보가 출력됩니다. 물론 변수 variable의 값도 함께 말이죠.

-D_DEBUG를 컴파일 옵션에서 빼 버리고 다시 컴파일하면 매크로 함수 DUMP는 코드 안에서 사라집니다. (#else와 #endif 사이 부분을 참고하세요) 그러므로 화면에는 어떤 메시지도 출력되지 않습니다.

자. 이런 구현법을 활용하면 다양한 일들을 할 수 있습니다. 가령, 어떤 실행문을 돌릴 때, 디버그 모드에서 돌리면 '해당 실행문이 실행되려 한다는 사실을 알리는' 텍스트 메시지를 함께 출력하고, 릴리즈 모드에서 돌리면 그런 메시지 없이 그냥 그 실행문이 실행되도록 만들려면, 다음과 같은 매크로 함수를 정의해서 사용하면 됩니다.

#ifdef _DEBUG

#define EXECUTE(X)  \
 ( printf("executing [%010u,%s,%04d] " #X "\n", \
   (unsigned int)pthread_self(), __FILE__, __LINE__),(X) )

#else

#define EXECUTE(X) x

#endif
자. 먼저 EXECUTE의 인자 X가 #X를 통해서 큰 따옴표 문자열로 변환된 다음 서식 문자열과 결합됩니다. 그런 다음 쓰레드 아이디와 파일명, 행번호 등의 정보와 함께 화면에 출력됩니다. 그런 다음 실행문 X가 실행됩니다. 세미콜론을 사용하지 않고도 이 두 실행문을 연달아 실행시킬 수 있었던 것은, ',' 연산자 때문입니다. 이 연산자는 좌 우의 피연산자들을 순서대로 실행시키는 역할을 합니다.

int variable = 0;
...
EXECUTE(variable = 1);
따라서 위의 코드는 디버그 모드에서 다음과 같은 출력을 내놓게 됩니다.

executing [0000000012, test.cpp, 36] variable = 1
그리고 그 결과로 variable 에는 1이 대입되죠. 디버그 모드가 아닌 릴리즈 모드(즉, -D_DEBUG를 사용하지 않은 상태)로 컴파일하고 실행해 보면 화면에 아무런 메시지도 출력되지 않지만 variable에 1을 대입하는 동작은 여전히 수행됩니다.

자. 그러면 이런 매크로 함수들을 많이 만들어 두면, 굉장히 쓸만한 디버깅 도구를 스스로 만들어 사용할 수 있겠군요. (물론 그러려면 C/C++의 매크로 전처리기에 대한 지식은 가지고 있어야만 합니다.)

좀 더 나아가면, 프로그램 내 특정 코드 세그먼트의 성능을 측정하는 매크로 함수도 만들어 사용할 수 있습니다. 아래의 예제를 보시죠. #ifdef ... #endif는 생략하고, 매크로 함수의 코드만 보였습니다.

#define PROFILE_BEGIN(pfid)        \
   unsigned int __prf_l1_##pfid = __LINE__; \
   struct timeval __prf_1_##pfid;    \
   struct timeval __prf_2_##pfid;    \
   do {          \
    gettimeofday(&__prf_1_##pfid, 0);  \
   } while ( false )

#define PROFILE_END(pfid)        \
   unsigned int __prf_l2_##pfid = __LINE__; \
   do {          \
    gettimeofday(&__prf_2_##pfid, 0);  \
    long __ds = __prf_2_##pfid.tv_sec - __prf_1_##pfid.tv_sec; \
    long __dm = __prf_2_##pfid.tv_usec - __prf_1_##pfid.tv_usec; \
    if ( __dm < 0 ) { __ds--; __dm = 1000000 + __dm; } \
    printf("profiling [%010u,%s] " #pfid  \
      " (%u ~ %u) total %u.%06u seconds\n", \
      (unsigned int)pthread_self(),   \
      __FILE__,      \
      __prf_l1_##pfid,    \
      __prf_l2_##pfid,    \
      (unsigned int)(__ds),    \
      (unsigned int)(__dm));    \
   } while ( false )
이 코드는 다음과 같이 사용합니다.
PROFILE_BEGIN(test1)
    /* do some programming jobs */
PROFILE_END(test1)

그런 다음 디버그 모드에서 컴파일하고 실행해 보면, 화면에 PROFILE_BEGIN(test1)과 PROFILE_END(test1) 사이의 코드가 실행된 시간이 다음과 같이 찍히게 되죠. (test1이라는 이름은 해당 코드 안에서 유일해야 합니다.)

[test_api.cpp] test1 (90 ~ 98) total 1.34122342 seconds

test_api.cpp 파일의 코드 90번째 줄 부터 98번째 줄까지의 코드 실행 시간이 저만큼 걸렸다는 뜻입니다.

프로파일링까지 통합 환경 안에서 한방에 제공하는 IDE를 쓰신다면 문제가 다르겠습니다만 (뭐 가령 Eclipse나 NetBeans같은 것 말이죠) 그런 툴을 사용할 수 없는 환경에서 vi만 가지고 개발을 해야 한다면 이런 디버그 매크로들을 여러 개 만들어 두고 사용하는 것이 여러가지 버그를 잡는 데 도움을 줍니다.

[참고할만한 링크]


[3부에 계속...]

'프로그래밍' 카테고리의 다른 글

[1] 64-bit/32-bit 빌드 실습  (1) 2009.01.15
디버깅의 도(5)-메모리 관련 문제들  (1) 2008.10.28
디버깅의 도(4)-GDB  (0) 2008.10.28
디버깅의 도(3)-Assert  (0) 2008.10.28
디버깅의 도(1)  (0) 2008.10.28