Extremely Agile/TDD2008.01.14 01:17

지난 시간까지 손수 디버깅 툴을 만드는 사례를 살펴보았습니다. 구구절절 말이 많았습니다만, 사실 그 모든 매크로들은 결국 printf 함수를 사용해 뭔가를 화면에 찍어보도록 하는 것에 불과했어요.

C 프로그래머들 사이에 유명한 농담 가운데 하나로 (정말 농담?) "세상에 printf보다 강력한 디버깅 툴은 없다"는 말이 있습니다. (Java 프로그래머라면 "세상에 System.out.println보다 강력한 디버깅 툴은 없다"는 말로 바꾸어야 하겠군요.) 사실 디버깅이라는 작업의 대부분이 "어디어디에 값이 제대로 들어가 있는지 확인하는" 것이기 때문에, 이 말은 비록 농담이긴 하지만 유효합니다. 뭔가를 화면에 찍어 보는 데 printf보다 더 나은 방법은 사실 드무니까요.

그런데 printf를 사용해 만든 디버깅 매크로는 여러가지로 유용하기는 합니다만, 사실 사용하기에 좀 귀찮은 면이 있어요. 왜 그렇습니까? 프로그램이 뭔가 이상하게 동작하는 것처럼 보인다면, (1) 그 즉시 프로그램을 종료시키고 (2) 버그가 있으리라 의심되는 곳 여기저기에 디버깅 매크로를 좍 뿌린 다음 (3) 프로그램을 다시 실행시켜 보는 작업을 반복해야 하거든요. 나중에 릴리즈 모드로 컴파일할 때 그 디버깅 매크로들을 죄 없앨 수 있다고는 하지만, 결국 프로그램을 고쳐야 하는 건 마찬가지인데다, 디버깅을 해야 할 때마다 돌아가고 있는 프로그램을 세우는 삽질을 해야 하니, 지금 당장 중지시키기는 곤란한 프로그램이라면 (실제로 그런 경우가 있을 수 있습니다) 디버깅 매크로를 활용하기는 곤란해요. 디버깅 매크로를 넣을 때 마다 CVS가 해당 소스 코드를 새로운 버전으로 인식한다는 점도 사소하긴 하지만 귀찮은 점이죠.

이런 귀찮음을 극복할 수 있는 궁극의 방법은 무엇일까요? 제목에 나와 있으니 다들 아시겠습니다만, 가장 좋은 것은 바로 디버거를 사용하는 것입니다.

Unix에서 C/C++ 관련 프로그램을 디버깅 할 때 가장 많이 사용하게 되는 디버거는 바로 gdb입니다. gcc나 g++로 컴파일 할 때 -g 옵션을 주면 생성되는 디버깅 정보를 사용해서 동작하는, 가장 기본적인 디버거입니다. 이 디버거의 장점은 '거의 모든 플랫폼이 다 지원하는 디버거'라는 점이 되겠구요. 단점은 '너무 단순한 인터페이스' 정도가 되겠습니다. 하지만 Emacs의 gdb 인터페이스나 cgdb 같은 gdb 인터페이스 프로그램을 사용하면, Eclipse와 같은 IDE 프로그램을 통해 디버거를 사용하는 것과 유사한 방식으로 디버깅을 해 나갈 수 있으므로, 앞에 단점으로 든 문제점은 이제 상당 수준 해소가 되어 있다고 봐도 되겠습니다.

자. 그러면 이제 gdb를 통해 "디버깅 매크로로는 할 수 없는 일들"을 찾아서 해 봅시다. 그 첫 걸음은, "이미 돌고 있는 프로그램을 죽이지 않고도 가능한 디버깅"입니다. 디버깅 매크로로는 이런 일을 할 수 없습니다.

gdb <program> <process-ID of the program>

위와 같이 gdb를 실행시키면, 이미 실행중인 프로그램에 gdb를 붙일 수 있습니다. 이 때 "program"은 -g 옵션을 주고 컴파일된 프로그램이어야 하고, 그 process ID를 알고 있어야 합니다. (Unix 명령어 ps를 사용하면 알 수 있습니다.)

이 기법이 유용한 가장 간단한 사례를 살펴보죠. 가령 어떤 프로그램이 돌다가 멎어서 아무 반응도 보이지 않게 되어버렸다고 해 봅시다. 그런 경우 어디를 수행하다가 그렇게 멎어버렸는지 알고 싶다면? 그 프로그램의 이름이 stopped_program_name이고 Process ID가 34567 번이라고 한다면, 다음과 같이 하면 됩니다.

$ gdb stopped_program_name 34567

(gdb) bt

그러면 다음과 같은 메시지들이 주욱 뜨는 것을 볼 수 있죠. AccessRepository 클래스의 소멸자 안에서 데이터베이스에 링크 정보를 save하려다 멎어서 아무 짓도 하지 않게 되어버렸군요. 프로그램 수행 중에 데이터 베이스 연결이 사라진 것이 원인입니다. (어떻게 고칠 것인지는 논외로 하죠 -_-;)

#0  0x00742410 in __kernel_vsyscall ()
#1  0x00a1cb8b in __read_nocancel () from /lib/libpthread.so.0
#2  0x00155e38 in vio_read () from /usr/lib/mysql/libmysqlclient.so.15
#3  0x00155eae in vio_read_buff () from /usr/lib/mysql/libmysqlclient.so.15
#4  0x0015722c in net_realloc () from /usr/lib/mysql/libmysqlclient.so.15
#5  0x0015761b in my_net_read () from /usr/lib/mysql/libmysqlclient.so.15
#6  0x00150a48 in cli_safe_read () from /usr/lib/mysql/libmysqlclient.so.15
#7  0x00153bc5 in cli_advanced_command ()
   from /usr/lib/mysql/libmysqlclient.so.15
#8  0x00124ade in mysql_ping () from /usr/lib/mysql/libmysqlclient.so.15
#9  0x0804e12b in BJLEE::CMySQL::query (this=0xbfb17d38, query=@0xbfb177ec,
    suppress_exception=false)
    at /home/bjlee/work/libbjlee/include/bjlee/cmysql.h:214
#10 0x0804bab1 in LinkTable::save (this=0xbfb18144, mysql_server=@0xbfb17d38,
    aid=@0x8089c28) at linktable.cpp:227
#11 0x0805bd31 in ~AccessRepository (this=0xbfb17d34) at accessrepository.h:34
#12 0x0806a79d in start_access_bacf () at bacf.cpp:117
#13 0x0806b42f in main (argc=1, argv=0xbfb18b34) at bacf.cpp:264

간단하죠? 이것이 가장 간단한 활용 방법입니다. 이제 조금 복잡한 사례를 살펴보겠습니다. 가령 다음과 같은 프로그램을 컴파일하여 a.out을 만들었다고 해 봅시다.

#include <iostream>
#include <stdlib.h>

using namespace std;

int foo() {
    return 3;
}

int main() {
    int i = 0;
    while ( (i = foo()) != 0 ) {
        cout << "test" << endl;
        sleep(1);
    }

    return EXIT_SUCCESS;
}

그리고 이 프로그램을 실행시켰다고 해 봅시다. process ID는 8606입니다. 화면에는 1초마다 한번씩 test라는 문자열이 한 줄에 하나씩 찍히게 될 겁니다. 이 세션에 gdb를 붙이려면 gdb를 실행할 때 다음과 같이 해 주면 됩니다.

$ gdb a.out 8606

그렇게 하면 gdb가 a.out에 붙습니다. 그 순간, 프로그램의 수행은 일시적으로 정지합니다. 이제 gdb가 프로그램의 실행을 통제하기 시작합니다. 그 상태에서 br 명령을 실행합니다.

(gdb) br 15

15번째 줄(cout 이 있는 부분)에 breakpoint를 걸라는 소리입니다. 그런 다음 c를 입력하고 리턴키를 눌러 프로그램의 실행을 재개합니다.

(gdb) c

continue의 약자죠. ^^; 어차피 프로그램이 무한 루프를 돌게 만들어 뒀기 때문에 프로그램은 다시 14번째 줄까지 실행한 다음, 화면에 test를 찍는 15번째 줄을 실행하기 직전에 멈출 것입니다. i의 값을 확인하고 싶다면, 다음과 같이 print 명령을 실행하면 됩니다.

(gdb) print i

자. 이렇게 하면 실행 중인 프로그램에 gdb를 붙여서 그 실행 과정을 확인할 수 있습니다. 그럼 지금부터는 이 과정을 좀 더 '자동화 하는 방법'을 알아보도록 하죠.

그러려면 command 명령의 사용법을 알아야 합니다. command 명령은 특정한 breakpoint에 도달한 경우, 특정 집합의 gdb 명령어들이 실행될 수 있도록 해 줍니다. breakpoint를 특정 코드를 실행하기 위한 조건(condition)으로 사용하는 것이죠. 그런 점에서 보면 gdb 환경을 DTrace의 D 프로그래밍 언어처럼 사용하는 것과도 유사하다고 할 수 있겠어요. (DTrace를 써보신 경험이 없으시다면 걍 그런가보다 하고 넘어갑시다 ^^;)

command 명령은 다음과 같이 사용합니다. 아까 지정했던 breakpoint 1번에 대해서, command 명령을 실행하는 사례입니다.

(gdb) command 1

저기까지 입력하고 리턴 키를 누르면, 화면에 > 프롬프트가 떠서 breakpoint 1번에 도달했을 때 실행될 gdb 명령을 입력하도록 요구합니다. 맨 마지막 명령 다음에는 end를 입력해 주어야 합니다. 다음과 같이 입력해 봅시다.

(gdb) command 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>silent
>print i
>continue
>end


silent 명령어에 대해서는 gdb 프롬프트 상에서 help command라고 입력해 보면 친절한 설명이 나오니까 설명하지 않겠습니다. 우선 이렇게 입력하고 엔터 키를 누릅시다. 그러면 화면에 다시 gdb 프롬프트 (gdb)가 뜹니다. 그 상태에서 c를 눌러 프로그램 수행을 재개합니다.

그러면 화면에 다음과 같은 출력이 계속적으로 발생하는 것을 볼 수 있습니다.

$16 = 3
$17 = 3
$18 = 3
$19 = 3
...


프로그래머가 별다른 입력을 주지 않아도, 1번 breakpoint가 hit 되면 실행될 명령의 마지막에 continue를 넣어놨기 때문에 출력은 계속적으로 발생합니다. 이 출력을 좀 더 보기좋게 만들고 싶다면 printf 대신에 printf를 다음과 같이 사용하면 되겠습니다.

>printf "i = %d\n", i

그렇게 하면 화면에는 다음과 같이 출력됩니다.

i = 3
i = 3
...


출력이 화면에 가득 차면, 계속 진행할 것인지 말 것인지 묻는 프롬프트가 뜨는데, 거기서 q를 눌러서 일단은 빠져나와 보도록 합시다. 그러면 gdb 프롬프트가 떨어지는데, 거기서 다시 q를 누르면 gdb는 종료되고, 프로그램 a.out은 다시 수행을 계속합니다. (q를 누르기 전에 detach 명령을 실행해도 gdb와 원래 프로그램 프로세스가 분리되기 때문에, a.out이 계속 수행되도록 만들 수 있습니다.)

그러면 이제 위의 명령들을 별도의 파일에 저장하고, gdb를 실행할 때 스크립트 형태로 실행될 수 있도록 하는 방법을 알아보겠습니다. (여기까지 살펴보면 이제 자동화에 관련된 부분은 거의 다 살펴본 것이 되려나요?) 대부분의 UNIX 툴들이 그렇듯, gdb도 스크립트 파일을 활용해 배치 모드로 실행하면 더 멋지게 써먹을 수 있습니다. gdb 정도의 툴이 그런 방법을 제공하지 않았다면 아마 더 이상했겠죠.

다음의 코드를 입력한 다음, a.out과 같은 디렉터리에 verify_i.gdb라는 이름으로 저장해보겠습니다.

br 15
command
  silent
  printf "i = %d\n", i
  continue
end
continue

command 명령을 실행할 때 그 뒤에 breakpoint 번호를 주지 않으면, 가장 마지막에 생성된 breakpoint 번호가 내정치로 사용된다는 점을 이용했습니다. 자. 그러면 이제 이 파일을 저장한 다음에, 다음과 같이 실행해 보죠. gdb를 batch mode로 실행하기 위해 -x 옵션을 사용했습니다.

$ gdb a.out 8606 -x verify_i.gdb

그렇게 하면 화면에는... 실행하자 마자 아까 봤던 i = 3이 계속해서 찍히기 시작합니다. 중단시키려면 Ctrl-C를 누른 다음에 detach를 입력해서 프로세스로부터 디버거를 분리시키면 됩니다. 그러면 원래 프로그램의 수행은 지속될 수 있습니다.

이런 스크립트 파일을 잘 만들어 두면 디버깅 매크로 같은 것을 만들지 않고서도 프로그램 안에 등장하는 각종 변수의 값을 편하게 모니터링 할 수 있습니다. gdb를 띄울 때 마다 매번 명령어를 입력하는 수고를 들이지 않고서도 말이죠.

- 참고할만한 자료 :
영문 gdb 사용자 가이드 http://sourceware.org/gdb/current/onlinedocs/gdb_toc.html
한글 gdb 사용자 가이드 http://korea.gnu.org/manual/release/gdb/gdb.html (오래전 버전이므로 주의할것)
gdb 사용하기 http://theeye.pe.kr/40


[5부에 이어집니다]

신고
Posted by 이병준
TAG ,

소중한 의견, 감사합니다. ^^