Debugging with GDB: User Defined Commands in Python

From Wiki

Jump to: navigation, search

Related Articles

Debugging with GDB: How to create GDB Commands in Python

Currently, I wrote English version of Debugging with GDB: gdbx.py here.

If you want an English version of this article, ask the author via <cinsky at gmail dot com>.


GDB version 7.0 이후로, GDB는 Python interpreter를 내장하고 있습니다. 간단한 제어만 제공하는 기존 방식과는 달리, 새 GDB 명령을 만드는데 Python을 쓸 수 있기 때문에, 더욱 강력한 명령을 작성할 수 있습니다. 기존 방식으로 새 GDB 명령을 만드는 것은 Debugging with GDB: User Defined Commands를 참고하기 바랍니다.

이 글은, GDB Python 기능 중, 새 GDB 명령(command)을 만드는 것에 대해 설명합니다. 새 GDB 함수(function)나, 기타 기능은 다루지 않는다는 것을 미리 알려둡니다. 자세한 내용은 GDB info 매뉴얼을 참고하기 바랍니다.

이 글을 읽는 분들은, 이미 GDB와 Python에 친숙하다는 것을 전제로 합니다.

Contents

Introduction

GDB Python 기능은 GDB 명령 "python"으로 실행할 수 있습니다. 즉, "(gdb)" 프롬프트 에서 python을 입력하고, 그 뒤에 python 코드를 입력하면 그 실행 결과를 보여줍니다. Python 인터프리터와 달리, 명백하게(explicitly) print 명령을 쓰지 않으면, 그 결과를 출력하지 않는다는 것도 알아두기 바랍니다.

(gdb) python print 1 + 2
3
(gdb) python msg = "Greeting, %s!" % "cinsk"
(gdb) python print msg
Greeting, cinsk!

GDB는 standard output(stdout)과 standard error(stderr)를 GDB와 동기화시켰기 때문에, Python의 sys.stdout과 sys.stderr를 직접 쓸 수도 있습니다:

(gdb) python import sys
(gdb) python sys.stdout.write("hello, world\n")
hello, world
(gdb) python sys.stderr.write("error output\n")
error output

여러 줄로 된 Python 코드를 작성하려면 'python'만 입력하고 RETURN 키를 누른 다음, Python 코드를 입력하고, 다 입력했다면 'end'를 입력하면 됩니다:

(gdb) python
>if True:
>  print "it is true."
>else:
>  print "it is false."
>end
it is true.
(gdb) _

모든 Python 코드마다 GDB python 명령을 써서 입력하는 것이 번거롭기 때문에, GDB는 "source" 명령을 제공합니다. source 명령에 특정 Python 소스 파일을 입력하면, GDB가 자동으로 읽어서 실행하게 됩니다:

(gdb) source my-gdb-python-source.py

일반적으로, 개발자가 작성한 Python 코드는 위와 같이 따로 파일로 만들어 두고, .gdbinit 파일에 다음과 같이 써주면, GDB가 시작할 때, 자동으로 해당 python 소스 코드를 읽습니다:

source my-gdb-script.py

GDB가 내장한 Python interpreter는 자동으로 Python module인 gdb를 import한 상태로 동작합니다. gdb module이 제공하는 interface는 크게 다음과 같습니다:

  • gdb.Command -- 새 GDB 명령을 만드는데에 쓰이는 class입니다. 새 명령을 만들려면, 새로 gdb.Command class를 상속받아서 만듭니다.
  • pretty printer -- GDB 명령 "set print pretty"를 썼을 때, 출력되는 내용을 제어하기 위해 사용합니다. 이 방식을 쓰려면 새 클래스를 만들고 필요한 멤버 함수들(to_string, children, display_hint)을 등록하면 됩니다. 이는 특히 C++ class를 출력하는데 매우 좋습니다.
  • gdb.Function -- 새 GDB 함수를 만드는데에 쓰입니다.
  • gdb.Frame -- 현재 콜 스택의 프레임을 나타내는데에 쓰입니다. GDB backtrace 명령을 조금 변경한 명령이 필요하다면 이 클래스를 쓰면 좋습니다.

아래 내용은 gdb.Command를 써서 새 GDB 명령을 만드는 방법에 대해 설명합니다.

Creating New Commands

가장 먼저 "hello, world" GDB 명령을 만들어 봅시다. 그냥 만들면 너무 단순하기 때문에, 다음과 같이 동작하는 명령을 만들어보려 합니다:

(gdb) hello cinsk
Hello, cinsk!

새 GDB 명령을 만들려면 먼저, 해당 명령을 나타내는 class를 만들어야 합니다. 이 클래스는 gdb.Command를 상속받아야하며, 필요에 따라 몇몇 method를 새로 정의해 주어야 합니다. 위 hello 명령을 만들기 위한 python 소스는 다음과 같습니다:

class Hello(gdb.Command):
    """Typical hello world: hello NAME
 
Print "hello NAME" where NAME is the argument.  This command is for
demonstrating of creating new command in Python."""
 
    def __init__(self):
        gdb.Command.__init__(self, "hello", gdb.COMMAND_OBSCURE)
 
    def invoke(self, arg, from_tty):
        print "hello, %s!" % arg
 
Hello()

위에서 새 GDB 명령을 위한 클래스 이름은 Hello입니다. 사실 실제 GDB 명령 이름(위에서는 hello)과 class 이름은 아무 상관이 없습니다. 실제 GDB 명령 이름은 gdb.Command.__init__()의 두번째 인자로 결정됩니다. 세번째 인자는 이 GDB 'hello' 명령이 어떤 타입의 명령인가를 나타냅니다. GDB 명령의 타입은 원래 GDB `help' 명령을 써서 해당 타입의 도움말을 출력하기 위해 필요한 것입니다. 예를 들어, 타입을 gdb.COMMAND_DATA로 주었을 경우, GDB `help data' 명령을 쓸 경우에 새 명령의 도움말이 출력됩니다. 참! GDB help 명령은 새 명령에 대한 도움말을 해당 class의 documentation string에서 얻습니다. 따라서 GDB에서 위 코드를 읽었다면, 다음과 같이 GDB `help' 명령을 쓸 수 있습니다:

(gdb) help obscure
... 기타 다른 명령에 대한 설명 ...
hello -- Typical hello world
...

(gdb) help hello
Typical hello world: hello NAME

Print "hello NAME" where NAME is the argument.  This command is for
demonstrating of creating new command in Python.
(gdb) _

위를 보면 알겠지만, documentation string의 첫 줄은 해당 타입에 대한 help 명령에 쓰일 한 줄 도움말이며, 해당 명령에 대해 `help'가 실행될 경우, documentation string 전체가 출력되는 것을 알 수 있습니다.

각 타입은 gdb module안에 COMMAND_XXX 꼴의 상수로 준비되어 있으며, 이에 대한 설명은 뒤로 미룹니다. gdb.Command.__init__()는 이외에 두 개의 인자를 더 받는데, 둘 다 optional이기 때문에 이에 대한 것도 뒤로 미룹니다.

GDB에서 'hello' 명령을 실행하면, 해당 클래스의 invoke() method가 호출됩니다. 이 때, 명령에 전달된 모든 인자는 하나의 문자열로 두번째 인자인 arg로 전달되며, 터미널 상에서 사용자가 직접 입력한 명령인 경우에 세번째 인자인 from_tty이 True로 전달됩니다. 다시 한번 말하지만, arg는 GDB 'hello' 명령에 전달된 내용 전부를 포함하고 있는 문자열입니다. 따라서 필요한 경우, 직접 인자를 parsing해야합니다.

위 소스의 마지막에, Hello class의 instance를 만든 것을 볼 수 있는데, 이 과정은 의미없어보이지만, 사실 gdb.Command가 새 명령을 등록하기 위해 필요한 과정입니다.

gdb.execute()

gdb.execute()를 쓰면, Python 코드에서 GDB 명령을 실행할 수 있습니다. gdb.execute()는 GDB 명령을 문자열 형태로 받습니다. (두번째 optional 인자 from_tty가 있긴 한데, 신경쓸 필요 없습니다.) 예를 들면 다음과 같습니다:

(gdb) print 1 + 2
$1 = 3
(gdb) p 1 + 2
$2 = 3

(gdb) python gdb.execute("p 1 + 2")
$3 = 3

(gdb) p asdf
No symbol "asdf" in current context.

(gdb) python gdb.execute("p asdf")
Traceback (most recent call last):
  File "<string>", line 1, in <module>
RuntimeError: No symbol "asdf" in current context.
Error while executing Python code.

(gdb) _

처음 두 GDB 명령은 그냥 단순하게 1 + 2를 실행해 본 것이라 패스. 세번째 gdb.execute("p 1 + 2")도 같은 결과를 얻을 수 있는 것을 볼 수 있습니다. 네번째 'p asdf' 명령은, symbol asdf를 출력하라는 것인데, 현재 asdf란 심볼이 없기 때문에 에러가 난 것입니다. 마찬가지로 이 명령을 gdb.execute()를 써서 실행하면, 같은 에러가 Python exception 형태로 발생한 것을 볼 수 있습니다. 즉, stable한 코드를 작성하기 위해 다음과 같이 할 수 있다는 뜻입니다:

try:
  gdb.execute("p asdf")
except RuntimeError as e:
  print "error: exception occurred: %s" % e


gdb.Command.__init__() 그리고 GDB sub command

Command.__init__()의 정확한 타입은 다음과 같습니다:

class Command(...):
  def __init__(self, name, command_class, completer_class = None, prefix = False):
    ...

'command_class'는 앞에서 이미 다루었으며, 이 명령의 type을 나타납니다. 예를 들면 다음과 같습니다:

  • gdb.COMMAND_DATA -- data 타입을 나타내며, 주로 변수의 값을 조사하는 명령들이 여기에 속합니다. 예를 들어 GDB `print'가 이 타입에 속합니다. 이 글에서 만들 명령들은 거의 이 타입에 해당합니다.
  • gdb.COMMAND_NONE -- 어떤 타입에도 속하지 않을때 사용합니다. 쓸 일은 별로 없으며, 이 타입에 속한 명령은 GDB `help'로 도움말을 볼 수 없습니다.
  • gdb.COMMAND_OBSCURE -- 거의 쓰이지 않으며, 정말 드문 상황에만 사용하는 GDB 명령들이 여기에 속합니다. 예를 들면 GDB `stop', `fork' 등이 여기에 속합니다. 위에서 만든 `hello' 명령도 거의 쓰이지 않는 명령일 것이므로, 이 타입으로 만들었습니다.

나머지 type에 대한 것은 GDB info 문서의 Commands In Python을 참고하기 바랍니다.

'completer_class'는 GDB가 이 명령을 (사용자에게) 입력받을 때, 어떤 자동 완성 기능을 쓸 것인가를 지정합니다. 생략할 경우, 해당 class의 complete() method가 호출됩니다. 생략하지 않고 미리 정의된 상수를 쓸 수도 있습니다:

  • gdb.COMPLETE_NONE -- 자동완성 기능을 쓰지 않습니다.
  • gdb.COMPLETE_LOCATION -- 소스 위치에 대한 자동 완성 기능을 사용합니다. (예: 파일 이름, 함수 이름, 줄 번호 등)
  • gdb.COMPLETE_SYMBOL -- 심볼(변수/상수 이름, 함수 이름 등)에 대한 자동 완성 기능을 씁니다.

나머지 COMPLETE_ 상수는 GDB info 문서 참고.

마지막 인자인 'prefix'는 현재 이 클래스가 정의하고 있는 명령이 prefix 명령일 경우 True로 설정합니다. Prefix 명령이란, 그 자체만으로 동작하는 것이 아니라, 세부 인자값에 의해 의미가 갈라지는 명령을 뜻합니다. 예를 들어, GDB dump 명령의 문법을 보면 다음과 같습니다:

dump value FILENAME EXPR
dump memory FILENAME START-ADDR END-ADDR

이 때, 위 명령에서 "dump"는 prefix 명령, 실제 명령은 "dump value"와 "dump memory"로 구분할 수 있습니다. 즉, 위와 같은 명령을 Python으로 구현할려면, 세 개의 class를 만들고, 각각 "dump", "dump value", "dump memory"를 구현하면 됩니다. 이 때, "dump" 명령을 초기화할 때, 'prefix' 인자를 True로 설정하면 됩니다. 간략하게 코드를 살펴보면 다음과 같습니다:

class MyDump(gdb.Command):
  def __init__(self):
    gdb.Command.__init__(self, "dump", gdb.COMMAND_DATA, gdb.COMPLETE_NONE, True)
class MyDumpValue(gdb.Command):
  def __init__(self):
    gdb.Command.__init__(self, "dump value", gdb.COMMAND_DATA, gdb.COMPLETE_SYMBOL)
  def invoke(self, args, from_tty):
    # args에 전달된 "FILENAME EXPR"를 분석해서 실행.
    ...
class MyDumpMemory(gdb.Command):
  def __init__(self):
    gdb.Command.__init__(self, "dump value", gdb.COMMAND_DATA, gdb.COMPLETE_SYMBOL)
  def invoke(self, args, from_tty):
    # args에 전달된 "FILENAME START-ADDR END-ADDR"를 분석해서 실행.
    ...

위와 같이 작성해 두면, 사용자가 GDB에서 'dump'를 입력하고 <TAB>을 눌렀을 때, "value", "memory"의 자동 완성 기능이 제공된다는 장점도 있습니다.

물론, 'prefix' 기능은 편의상 제공하는 것입니다. 예를 들어 "dump" 명령을 한 class로 구현하고, invoke()에 전달된 인자를 분석해서, 해당하는 세부 작업을 수행해도 됩니다:

class MyDumpValue(gdb.Command):
  def __init__(self):
    gdb.Command.__init__(self, "dump", gdb.COMMAND_DATA, gdb.COMPLETE_SYMBOL)
  def invoke(self, args, from_tty):
    (subcmd, dummy, rest) = args.partition(" ")
    if subcmd == "value":
      # rest에 있는 "FILENAME EXPR"을 실행.
      ...
    else:
      # rest에 있는 "FILENAME START-ADDR END-ADDR"를 실행
      ...

그러나, 위와 같이 한 class로 구현하는 것은 별로 추천하고 싶지 않습니다. 몇 가지 이유는 다음과 같습니다:

  • class 정의가 복잡해져서 읽기 힘들고 지저분해집니다.
  • documentation string을 하나만 쓸 수 있기 때문에, 'help' 명령으로 세부 도움말을 보기 어렵습니다.
  • 따로 complete() method를 만들어주지 않는 한, 'value'와 'memory' 사이의 자동 완성 기능을 쓸 수 없습니다.

(이 글의 내용과 별 상관은 없지만...) GDB 명령을 잘 아시는 분이라면, GDB 'dump'의 문법은 사실 위에서 설명한 내용과 다르다는 것을 아실 것입니다:

dump [FORMAT] memory FILENAME START_ADDR END_ADDR
dump [FORMAT] value FILENAME EXPR

따라서 실제 GDB dump 명령은, 위와 같이 단순하게 세 개의 class로 구현할 수 있는 게 아니지만, 이 글의 목적상 '[FORMAT]'에 해당하는 부분은 없다고 가정했습니다.

Utility class

지금까지 소개한 내용만으로도, (Python을 쓸 줄 안다면) 꽤 유용한 GDB 명령들을 만들 수 있습니다. 이 단원에서는, GDB 명령들을 만들때, 유용하게 쓸 수 있는 class들을 만들어보겠습니다.

앞으로 만들 몇가지 GDB 명령들은 대부분 다음과 같은 순서로 동작합니다:

  1. 임시로 파일을 하나 지정해서 만들어(create) 둡니다.
  2. GDB 'dump' 명령을 써서 (사용자가 전달한) 데이터를 앞에서 만든 파일에 기록합니다.
  3. 해당 데이터를 적절하게 가공하여 출력합니다.
  4. 임시 파일을 제거합니다.

임시 파일 작업을 간편히 하기 위해, tempfile.NamedTemporaryFile을 쓰겠습니다. 위 세번째 단계를 제외한 나머지 과정은 거의 비슷하기 때문에 다음과 같이 GdbDumpParent란 class를 만들고 이 나머지 과정을 맡기려 합니다.

GdbDumpParent는 gdb.Command를 상속받으며, 공통적인 작업을 다 미리 수행해야 합니다. 즉, 임시로 파일을 만들어서, 내부적으로 GDB 'dump' 명령을 통해, 이 파일에 데이터를 기록하고, 이 파일에 대해 개발자가 지정한 shell 명령을 수행한 다음, 그 결과를 출력해 주는 일을 합니다. 이 경우, GDB 'dump' 명령에 전달할 인자(argument)와 개발자가 지정한 shell 명령에 쓰일 인자(argument)는, GDB 명령을 입력하는 사용자에게 입력받게 됩니다. 편의상 전자는 dump argument, 후자를 exec argument라 부르겠습니다. 전체 구조는 아래와 같은 꼴이 됩니다:

class GdbDumpParent(gdb.Command):
    def __init__(self):
        # gdb.Command.__init__()을 불러 초기화
        ...
 
    def invoke(self, args, from_tty):
        # args - GDB에서 이 명령을 실행할때 전달된 인자들
        # 이 인자들을 바탕으로 dump argument와 exec argument를 만들어야 함.
        # 따라서 자체 parse_arguments() method를 통해 결정하게 하고,
        # parse_arguments()는 이 class를 상속받은 class에서 override할 수 있게 함
        (dump_args, exec_args) = self.parse_arguments(args)
 
        # TODO-1: dump_args를 써서 GDB 'dump' 명령을 실행, 임시 파일에 데이터를 저장
        ...
 
        # 앞에서 만든 임시 파일과 exec_args를 써서 shell 명령 실행        
        # 실제 작업은 execute() method에서 처리하도록 함.
        self.execute(temporary_file_name, exec_args)
 
    def parse_arguments(self, args):
        # sub-class에서 override해야 함.
        return (args, "")
 
    def execute(self, filename, args):
        # TODO-2: 주어진 임시 파일과, 전달된 args를 써서 shell command-line을
        #         만들고, 실행, 그 결과를 출력한다.
        ...

위에서 TODO-1을 먼저 끝내도록 합시다. GdbDumpParent.invoke()는 아래와 같이 만들 수 있습니다:

    def invoke(self, args, from_tty):
        with tempfile.NamedTemporaryFile(prefix="gdb-") as tmp:
            try:
                 (dump_args, exec_args) = self.parse_arguments(args)
 
                 # 실제 GDB 'dump' 명령 수행은 dump() method에 의해 처리
                 self.dump(tmp.name, dump_args)
 
                 # shell 명령 실행은 execute() method에 의해 처리
                 self.execute(tmp.name, exec_args)
            except RuntimeError as e:
                 print e
            except:
                 sys.stderr.write("error: an exception occurred.")

TODO-2를 위해 잠시 더 생각하면, execute()에 전달된 두번째 인자 exec_args만 써서 shell command-line을 자동으로 만들기 어렵다는 것을 알 수 있을 것입니다. 따라서 commandline()이란 method를 통해, 실제 shell command-line을 완성하도록 하고, 이 commandline() method는 sub-class에서 override하도록 합니다:

    def execute(self, filename, args):
        cmdline = self.commandline(filename, args)
        p = subbrocess.Popen(cmdline, shell=True,
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        (out, err) = p.communicate()
        status = p.wait()
        sys.stdout.write(out)
        if err != "":
            sys.stdout.write(err)
        return True
 
    def commandline(self, filename, args):
        # sub-class에서 override할 method
        return []

앞에서 TODO-1을 해결하기 위해 내부적으로 dump() method를 쓴 것을 알 수 있습니다. dump() method를 만들기 전에 먼저, GDB 'dump' 명령을 실행해 주는 utility function cmd_dump()를 다음과 같이 만들어 봅시다:

def cmd_dump(filename, args, format="binary", type="value"):
    cmd = "dump %s %s %s %s" % (format, type, filename, args)
    try:
        gdb.execute(cmd)
    except RuntimeError as e:
        # error message 출력

이제, 실제 dump() method를 구현해야 하는데, 약간 어려움이 있습니다. 왜냐하면 GDB 'dump' 명령은 아시다시피, 아래와 같이 크게 'dump value' 명령과 'dump memory' 명령으로 구분할 수 있습니다:

dump binary value FILENAME EXPR
dump binary memory FILENAME START_ADDR END_ADDR

각 GDB 'dump' 며령에 따라, 처리하는 인자 갯수가 다르고, "value", "memory"를 구분해야 하므로, dump() method만으로는 처리하기 힘듭니다. 그래서 GdbDumpParent.dump()는 아무런 일도 하지 않도록 만들고, GdbDumpParent class를 상속받은 두 class를 더 만들겠습니다. (뭐 꼭 좋은 디자인이라고 할 순 없겠지만...) GDB 'dump value' 명령을 사용하는 GdbDumpValueParent class와, GDB 'dump memory' 명령을 사용하는 GdbDumpMemoryParent class입니다.

class GdbDumpParent(gdb.Command):
    ...
    def dump(self, filename, args):
        pass
 
class GdbDumpValueParent(GdbDumpParent):
    ...
    def dump(self, filename, args):
        cmd_dump(filename, args, format="binary", type="value")
 
class GdbDumpMemoryParent(GdbDumpParent):
    ...
    def dump(self, filename, args);
        cmd_dump(filename, args, format="binary", type="memory")

위와 같이 만들어 두면 모든 작업은 끝납니다.  :)

Demo: Hexdump

GDB 안에서 hexdump(1)를 써서 주어진 데이터를 출력하는 명령을 만들어 보겠습니다. 이 명령은 다음과 같은 형식을 가집니다:

hexdump
hexdump value EXPR
hexdump memory START_ADDR END_ADDR

첫번째 'hexdump'의 경우, 아래 두 명령을 지원하기 위한 prefix 명령입니다. 따라서 다음과 같이 간단하게 만들 수 있습니다:

class HexdumpCommand(gdb.Command):
    """Dump the given data using hexdump(1)"""
    def __init__(self):
        gdb.Command.__init__(self, "hexdump", gdb.COMMAND_DATA, -1, True)
 
HexdumpCommand()

GDB 'hexdump value'의 경우, 앞에서 만든 GdbDumpValueParent class를 써서 만들면 다음과 같습니다:

class HexdumpValueCommand(GdbDumpValueParent):
    """Dump the given value EXPR using hexdump(1)"""
    def __init__(self):
        GdbDumpValueParent.__init__(self, "hexdump value", 
                                    gdb.COMMAND_DATA, gdb.COMPLETE_SYMBOL)
    def commandline(self, filename, args):
        return ["/usr/bin/hexdump", "-C", filename ]
 
HexdumpValueCommand()

GDB 'hexdump memory'의 경우, GdbDumpMemoryParent class를 쓴다는 것을 제외하면 똑같습니다:

class HexdumpMemoryCommand(GdbDumpMemoryParent):
    """Dump the given value EXPR using hexdump(1)"""
    def __init__(self):
        GdbDumpMemoryParent.__init__(self, "hexdump memory", 
                                    gdb.COMMAND_DATA, gdb.COMPLETE_SYMBOL)
    def commandline(self, filename, args):
        return ["/usr/bin/hexdump", "-C", filename ]
 
HexdumpMemoryCommand()

지금까지 작업한 파일을 gdbx.py란 파일에 저장해 두고, .gdbinit에서 아래와 같이 불러옵니다:

source /somewhere/gdbx.py

그리고나서 GDB를 실행해서 작업한 결과는 다음과 같습니다:

# 자동 완성이 지원됩니다.
(gdb) hex<TAB>
(gdb) hexdump <TAB>
memory value
# buffer에 있는 내용 출력
(gdb) hexdump value buffer
00000000  3c 6d 65 73 73 61 67 65  20 66 72 6f 6d 3d 22 63  |<message from="c|
00000010  69 6e 73 6b 79 40 73 61  6d 73 75 6e 67 2e 63 6f  |insky@xxxxxxx.co|
00000020  6d 2f 74 76 22 20 74 6f  3d 22 63 69 6e 73 6b 79  |m/tv" to="cinsky|
00000030  40 73 61 6d 73 75 6e 67  2e 63 6f 6d 2f 77 65 62  |@xxxxxxx.com/web|
00000040  74 6f 70 22 3e 3c 65 76  65 6e 74 20 78 6d 6c 6e  |top"><event xmln|
...
# buffer로부터 32바이트만 출력
(gdb) hexdump memory buffer ((char*)buffer+32)
00000000  3c 6d 65 73 73 61 67 65  20 66 72 6f 6d 3d 22 63  |<message from="c|
00000010  69 6e 73 6b 79 40 73 61  6d 73 75 6e 67 2e 63 6f  |insky@xxxxxxx.co|
00000020
(gdb) _

Download / Full source

지금까지 쓴 글은 모두 글쓴이가 만들어 둔 gdbx.py를 알려드리기 위해 썼습니다. gdbx.py는 여기에서 다운로드 받을 수 있으며, 그냥 읽어보기 위해서는 여기를 보기 바랍니다.

Personal tools