巴别塔上的雇工


How Debugger Works
5月 26, 2006, 12:58 下午
Filed under: 技术体会
搞软件跑不了要fix bug,bug往往狡猾得很,只有软件运行时才能看清楚真面目,这样只是靠肉眼看静态的source code很难找到bug,需要使用动态运行时调试程序的工具,这样的工具叫做debugger,bug藏身的程序叫做debugee。不管是什么样的debugger,提供的基本功能都差不多,无外乎以下几点:
  • 观察调用栈信息(View Stack)
  • 设断点(Breakpoint)
  • 控制debugee执行(Step In,Step Out, Step Over)
  • 观察变量运行时值(view Variable)
  • ……
这里不介绍说怎么使用Debugger(How to work with debugger),而是介绍debugger的大概工作原理(How Debugger works)。
 
Debugger也是一个程序,能够让用户控制一个debugee程序的运行。对于C/C++这样的Native Code,要运行就先要编译连接成二机制文件,这样才能被机器理解从而被执行,但是这样得二进制文件又没有办法被常人理解(我说的是常人,真的有猛将能够读懂二进制代码的),Debugger要解决的首要问题就是把二进制代码和源代码联系起来。要做到这一点,需要在编译的时候产生symbol,symbol是一个编译中的概念,就是地址的标识符,函数、变量被编译之后其实都可以看作地址,所以实际上symbol可以表示组成程序得所有这些砖瓦,在编译连接之后,大多数symbol对程序运行来说没有作用了,比如局部变量的symbol,对其访问都是通过栈上的偏移来访问,所以说如果只是为了运行程序,大多数symbol都可以去掉(一些export得symbol还是要保留的),但是,如果需要debug,这些symbol是需要的,因为要靠他们起到二进制代码和源代码的桥梁作用。
 
对gcc,加编译-g参数会产生用于debug的信息;对MSVC,加参数/Zi会产生和二进制文件同名的PDB(Program Database)文件存储symbol信息。两种方式的不同是,gcc的symbol就放在二进制文件中,而PDB文件是独立于二进制文件的。我觉得后者比较好,因为这样可以发布比较小的二进制文件,同时还能用保留的PDB来debug,当然gcc也可以产生独立的symbol file。
 
对于Java,不需要symbol,因为Java source code被编译成byte code后,原来的所有变量、函数、类等信息都存在byte code里面,也就是说byte code是self-explainable的,从byte code里面完全可以恢复source code(注释除外),这也就是商用Java软件要使用obfuscator让别人反编译了也看不懂的原因。
 
好了,我们现在知道Debugger是通过symbol来联系二进制文件和源文件,虽然有众多symbol的格式,但是毫无疑问这种联系是可以实现的,这里就不列举各种格式了。
 
以Windows平台为例,Windows32 SDK中有关于Debug的一组API,Debugger就是利用这些API操纵Debuggee。Debugger可以在创建一个进程的时候指定一个特殊参数,然后这个进程就成为他的傀儡,也就是debuggee;debugger也可以动态的attach一个已经在运行的进程,使其成为debuggee。Debugger的主要任务就是一个循环,循环体调用一个函数获得Debuggee中发生的exception,这个函数是blocking的,debuggee没发生exception的话就阻塞,直到debuggee产生异常,然后这个函数返回,根据异常类型,debugger对debuggee进行一些操纵,然后继续执行,重复循环体,下面是伪代码
  do
  {
     WaitOnDebuggeeException(Exception &e)
     switch (e->type)
     {
       case X_EX:
           ….;
       case Y_EX:
           ….;
       ……
     }
     ActivateDebugee();
  }
  while (isNotFinished)
 
对Debuggee来说,每发生一个异常,他所有的线程都会停下来,直到Debugger在做完相应处理之后激活它。 
 
通过Windows Debug API,Debugger可以操纵Debuggee的内存空间,所以观察栈上的情况就不成问题,加上有symbol辅助,evalute任何个变量的值也不成问题。
 
有点意思的是设置断点(Breakpoint)的实现。虽然Debugger可以控制Debuggee,但是还不能直接发布这样的命令“你,执行到XXX位置停下来”,Debugger要设breakpoint还是要费点劲。Debugger可以通过symbol把源代码中的位置解析成二进制代码中的位置,在二进制代码中设置断点。断点其实就是一个产生异常的指令,在Intel X86族CPU上,就是INT 3,二进制表示就是0xCC,设置断点就是把这样一个字节放到debuggee的进程空间去,这点没问题,虽然可执行代码是Read-Only,但是Debugger可以把Debuggee的这块内存变成Writable的,把INT 3放进去,然后再恢复成Read-Only。虽然一般说的是“插入一个break point”,但是INT 3并不是“插”进去的,想一想,要是真的是“插”进去,那岂不是要后面的指令代码后移一个字节?这样既没有必要而且没有好办法实现,真正做的是直接覆盖掉断点位置的那个字节,当然覆盖之前Debugger需要把这个位置上以前的值保存下来,回头等断点执行完了,再写回去,呵呵,够复杂吧。
 
对于Step In、Step Out、Step Over这样的操作,其实还是利用了Breakpoint,不过是once breakpoint,也就是只用一次。Debugger通过symbol和对源代码的分析,确定Step之后应该停留的位置,在这个位置上设Once Breakpoint。在Once breakpoint产生异常之后,Debugger捕获异常,做完事情,在激活Debuggee之前,把之前保存的breakpoint位置的指令写回去,这样这个breakpoint就消失了。
 
相对而言,普通的断点实现还要复杂一点,为了让断点一直有效,每次在激活Debuggee之前还要做一件事,就是设置一下CPU的Trap Flag(对Intel X86 Family CPU而言),这样这个刚刚恢复的指令刚刚执行完马上又产生一个异常,Debugger重新获得控制权,这时候这个指令已经执行过了,所以Debugger又把0xCC写到断电位置上去,下次执行到这里,这个breakpoint就在这里等着呢。这样看出来了吧,普通的Breakpoint设置做的事情,比Step In/Out/Over要复杂一些。
 
其他平台上的Debugger,虽然各有特点,但是功能差不多,内部机理也大同小异。
 
 

4条评论 so far
留下评论

 偶只喜欢printf

评论 由 Unknown

用printf来debug也是一个办法,但是我想找到bug的位置是一个循环逼近的过程,先用printf确定bug的一个大概范围,然后逼近的话,添加新的printf之后还需要重新编译再来,对于大的程序,这样就不合适了,所以Debug还是需要的。
 
还有,用printf的话,最好不要直接使用,而是通过一个宏来使用,例如
#ifdef _DEBUG
#define LOG_INFO(format, str) do { printf(format, str); } while (0)
#else
#define LOG_INFO(format, str)
#endif
这样,code中可以任意使用LOG_INFO,在Debug版本中会产生log信息,在Release版本中就不会产生冗余的printf调用了。
 
 
 

评论 由 Morgan

对于大的程序一种解决方法是
#ifdef _DEBUG
#define LOG_INFO(modulename, level, format, str)  \
                  do {   \
                  printf("%s\n", modulename); \
                  printf("level is %d\n", level); \
                 printf(format, str); } while (0)
#else
#define LOG_INFO(format, str)
#endif

评论 由 Unknown

不错,是原创?我之前有点奇怪INT 3是怎么“插”进去的。现在明白了,呵呵。另外,真的有人不用查手册,直接看懂2进制代码么?如果是RISC之类的似乎还容易些,如果是X86,真是难以想象。

评论 由 jiang




留下评论