这是由 Alexander Sandler 发布的一篇文章,介绍了在Linux系统中debugger的工作原理,还自己编写了一个小的 debugger 程序。
原文地址:http://www.alexonlinux.com/how-debugger-works
原文标题:How debugger works
Posted on March 24, 2008, 11:39 am, by Alexander Sandler, under Programming Articles.
简介
在这篇文章中,我想告诉你真正的调试器是如何工作的。在内部会发生什么,为什么会发生。我们甚至会编写自己的小型调试器,看看它是如何工作的。
我们只讨论Linux系统,尽管同样的原则适用于其他操作系统。另外,我们只谈x86架构,因为它是当今最常见的架构。另一方面,即使你正在使用其他架构,你也会发现这篇文章很有用,因为同样的原则在任何地方都适用。
内核的支持
实际的调试是需要操作系统内核支持的,之所需要内核支持的原因如下。设想我们生活在这样一个世界中,让一个进程去读取属于另一个进程的内存会是一个严重的安全漏洞。然而,在调试程序时,我们想从调试进程中访问属于被调试进程(debuggee)的内存空间。这是个问题,不是吗?当然,我们可以尝试让调试器和被调试进程(debuggee)使用相同的内存空间,但是如果被调试进程(debuggee)本身又创建了新的进程怎么办。这真的使事情变得复杂了。
对调试的支持必须是操作系统内核的一部分。内核能够读写属于系统中每一个进程的内存。此外,只要进程不是处于正在运行的状态,内核就可以看到这个进程的寄存器值,而调试器必须能够知道被调试进程(debuggee)的寄存器值。否则,调试器就不能告诉你被调试进程(debuggee)停在哪里了(例如,当我们在gdb中按下CTRL-C时)。
当我们讨论对调试器支持的基础(是需要操作系统内核的参与)时,我们已经提到了为了支持调试操作系统需要具备的几个特性。我们不希望任何进程都能调试其他进程。必须有人来监控调试器和被调试进程。因此,调试器必须告诉内核它要调试某个进程,内核必须批准或拒绝这个请求。因此,我们需要一种方法来告诉内核,某个进程是一个调试器,它要调试其他进程。我们还需要一种能力来查询和设置被调试进程的内存空间的值。我们还需要能够在被调试进程暂停时,查询和设置它的寄存器的值。
而操作系统可以让我们完成这一切。当然,每个操作系统都会以自己的方式来实现这些功能。Linux提供了一个名为trace()的系统调用(定义在sys/ptrace.h中),它可以完成所有这些以及更多的操作。
ptrace()
ptrace()接受四个参数。第1个参数用于指定所需要的操作,无论是读取被调试进程的寄存器还是改变其内存中的值,给出sys/ptrace.h中定义的enum __ptrac_request枚举的某个值就可以。第2个参数用于指定被调试进程的进程号(pid)。这不是很明显,但一个进程可以调试好几个其它进程。因此,我们必须准确地指出要调试的是哪个进程。最后两个参数是这个系统调用的可选参数。
开始调试
调试器开始调试指定被调试进程的第一件事是附加到被调试进程或运行(创建)一个被调试进程。这两种情况都会有一个ptrace()操作。
第1种操作叫做PTRACE_ATTACH。它告诉内核,用PTRACE_ATTACH调用ptrace()系统调用的进程应该成为调试父进程,而在ptrace()系统调用中指定的进程则应该成为被调试进程。调试父进程意味着它是一个调试器而且它也是被调试进程的父进程。
第2种操作被称为PTRACE_TRACEME,告诉内核调用进程希望它的父进程对自己进行调试。例如,我调用了ptrace( PTRACE_TRACEME )意味着我想让我的父进程来调试我。当你想让调试器进程创建一个新的被调试进程时,这就很方便了,用fork()创建一个新的进程,然后去调用ptrace( PTRACE_TRACEME ),再是调用exec()或execve(),就可以了。
调试器与被调试者的同步
好,现在我们告诉操作系统,我们要对某个进程进行调试。操作系统让它成为我们的子进程。很好。这是一个很好的时机,我们可以让被调试进程暂停下来,在我们真正开始调试之前做一些准备工作。例如,我们可能想分析我们运行的可执行文件,并在我们真正开始调试之前设置一个断点。那么,我们如何暂停被调试进程,让调试器做它的事情呢?
操作系统使用信号为我们做到了这一点。实际上,操作系统会将被调试进程中发生的各种事件通知给调试器,信号是操作系统完成所有这些工作的手段。这包括 "被调试进程已准备好拍照了 "的信号。特别是,如果我们附加到已经在运行的进程的话,这个进程就会收到SIGSTOP信号,一旦它真的暂停住了,我们就会收到SIGCHLD信号。如果我们新建一个进程,并且这个进程做了ptrace( PTRACE_TRACEME )系统调用,一旦它试图exec()或execve(),它将收到SIGTRAP信号。当然,我们(调试器)会得到SIGCHLD的通知。
一个新的调试器诞生了
现在我们来看实际演示代码。完整的代码如下:
listing1.c
#include <stdio.h>
#include <signal.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
void signal_handler( int sig )
{
printf( "Process %ld received signal %d\n", (long)getpid(), sig );
}
void do_debugger( void )
{
int status = 0;
pid_t child;
printf( "In debugger process %ld\n", (long)getpid() );
if (signal( SIGCHLD, signal_handler ) == SIG_ERR)
{
perror( "signal" );
exit( -1 );
}
do {
child = wait( &status );
printf( "Debugger exited wait()\n" );
if (WIFSTOPPED( status ))
{
printf( "Child has stopped due to signal %d\n", WSTOPSIG( status ) );
}
if (WIFSIGNALED( status ))
{
printf( "Child %ld received signal %d\n", (long)child, WTERMSIG(status) );
}
} while (!WIFEXITED( status ));
}
void do_debuggie( void )
{
char* argv[] = { "/tmp", NULL };
char* envp[] = { NULL };
printf( "In debuggie process %ld\n", (long)getpid() );
if (ptrace( PTRACE_TRACEME, 0, NULL, NULL ))
{
perror( "ptrace" );
return;
}
execve( "/bin/ls", argv, envp );
}
int main()
{
pid_t child;
// Creating child process. It will execute do_debuggie().
// Parent process will continue to do_debugger().
child = fork();
if (child == 0)
do_debuggie();
else if (child > 0)
do_debugger();
else
{
perror( "fork" );
return -1;
}
return 0;
}
被调试进程执行了以下的步骤:
.
.
.
if (ptrace( PTRACE_TRACEME, 0, NULL, NULL ))
{
perror( "ptrace" );
return;
}
execve( "/bin/ls", argv, envp );
.
.
.
注意后面跟着execve()的ptrace( PTRACE_TRACEME )系统调用。这就是真正的调试器用来生成将要被调试的进程所做的。正如你所知,execve()将当前进程的可执行映像和内存替换为属于被execve()调用的程序的可执行映像和内存空间。一旦内核完成了这个操作,它就向调用ptrace的进程(新运行的被调试程序)发送SIGTRAP,向调试器发送SIGCHLD。调试器通过信号和wait()返回接收相应的通知。下面是调试器的代码。
.
.
.
do {
child = wait( &status );
printf( "Debugger exited wait()\n" );
if (WIFSTOPPED( status ))
{
printf( "Child has stopped due to signal %d\n", WSTOPSIG( status ) );
}
if (WIFSIGNALED( status ))
{
printf( "Child %ld received signal %d\n", (long)child, WTERMSIG(status) );
}
} while (!WIFEXITED( status ));
.
.
.
编译运行listing1.c,输出如下(signal 17为SIGCHLD,signal 5 为SIGTRAP):
In debuggee process 14095
In debugger process 14094
Process 14094 received signal 17
Debugger exited wait()
Child has stopped due to signal 5
我们可以清楚地看到,调试器确实收到了一个(由内核转来的被调试进程收到的SIGTRAP)信号,并通过wait()得到通知(运行输出的第4行和第5行)。如果我们想在开始调试过程之前放置一个断点,这里就是我们的机会。让我们来讨论一下我们如何做这样的事情。
INT 3汇编指令背后的魔法
现在是时候探讨一下大多数程序员不太喜欢的主题了,那就是汇编语言。恐怕我们没有什么选择,因为断点是在汇编语言层面上工作的。
我们必须明白,我们的每一个编译后的程序实际上都是一组指令,告诉CPU要做什么。我们的一些C语言表达式被翻译成单条指令,而另一些则可能被翻译成数百甚至数千条指令。指令可大可小。对于现代CPU(Intel x86_64)来说,从1字节到15字节长。
调试器大多在CPU汇编指令层面上操作。事实上,gdb能理解C/C++代码并允许你在某些C/C++行放置断点,这只是gdb在指定汇编指令上放置断点的基本能力的一个增强。
有几种方法可以放置断点。最广泛使用的是INT 3汇编指令。这是一条单字节的操作代码指令,一旦被CPU触及,就会告诉它调用操作系统在初始化时提供的特殊断点中断处理程序。由于INT 3指令很小,我们可以安全地用它替代任何指令。一旦操作系统的中断处理程序被调用,操作系统就会知道哪个进程到达了断点,并通过信号通知这个进程以及它的调试器进程。
断点的实践
让我们回到我们的被调试进程/调试器。正如我们所提到的,调试器有机会在让被调试程序运行之前放置一个断点。让我们看看如何做到这一点。
用INT 3指令设置断点。在写入实际的0xcc(INT 3操作代码)之前,我们应该弄清楚应该把这个指令放到哪个位置。基于这篇文章的简单演示目的,我们将手动完成这一工作。相反,真正的调试器包括复杂的逻辑,可以计算出在什么地方和什么时候放置断点。在你不知道的情况下,gdb会自动放置几个断点。很明显,它也能根据你的要求放置断点。
在我们前面的例子中,我们让被调试进程执行了ls程序,这并不适合我们接下来的演示。我们需要一个能让我们轻松演示断点操作的示例程序。这就是它:
sleeper.c
#include <stdio.h>
int main()
{
printf( "~~~~~~~~~~~~> Before breakpoint\n" );
// The breakpoint
printf( "~~~~~~~~~~~~> After breakpoint\n" );
return 0;
}
main()函数的反汇编结果如下:
0000000000400508 <main>:
400508: 55 push %rbp
400509: 48 89 e5 mov %rsp,%rbp
40050c: bf 18 06 40 00 mov $0x400618,%edi
400511: e8 12 ff ff ff callq 400428 <puts@plt>
400516: bf 2a 06 40 00 mov $0x40062a,%edi
40051b: e8 08 ff ff ff callq 400428 <puts@plt>
400520: b8 00 00 00 00 mov $0x0,%eax
400525: c9 leaveq
400526: c3 retq
我们可以看到,如果我们在地址0x400516处放置一个断点,我们会在到达断点之前看到一个打印输出。为了便于演示,我们将在这个地址放置一个断点。一旦我们到达断点,我们将让被调试进程睡眠然后再继续运行。我们应该可以看到被调试进程产生第一个printf的打印输出,然后睡眠几秒钟,然后产生第二个printf的打印输出。
我们将分几个步骤实现我们的目标。
首先,我们应该用fork()新建一个被调试进程。之前的例子已经做过这样的事情。
下一步是在这个新建的被调试进程中调用execve()。这个事情之前也已经做过了。
现在这步是新的东西。我们应该把地址为0x400516的字节从原来的0xbf修改为0xcc,并把原来的值(0xbf)保存起来。这样就已经在这里放置了一个断点。
接下来,我们要在调试器进程中wait()。一旦被调试进程到达断点,我们就会在调试器进程中得到通知。
一旦被调试进程运行sleeper.c的可执行程序sleeper并且到达断点,我们要把被之前用0xcc破坏的代码恢复到它的原来的状态(0xbf)。
此外,我们还要修复RIP寄存器的值。这个寄存器告诉CPU它需要执行的下一条有意义的指令在内存中的位置。RIP寄存器现在的值是0x400517,在我们放置的0xcc指令之后一个字节。我们应该将RIP寄存器设置回到0x400516,因为我们不希望CPU跳过我们用0xcc破坏的MOV指令。
最后,为了演示需要,我们要让被调试进程等待5秒钟之后继续运行。
重要的事优先,让我们先看一下如何做第3步。
.
.
.
addr = 0x400516;
data = ptrace( PTRACE_PEEKTEXT, child, (void *)addr, NULL );
orig_data = data;
data = (data & ~0xff) | 0xcc;
ptrace( PTRACE_POKETEXT, child, (void *)addr, data );
.
.
.
我们再一次看到ptrace()是如何为我们完成工作的。首先,我们从地址0x400516偷看8个字节(sizeof( long ))。在某些架构上,由于不对齐的内存访问可能会引起很多麻烦。幸运的是,我们是在x86_64上,不对齐的内存访问是允许的。接下来我们将最低的字节设置为0xcc——INT 3指令。最后,我们将修改后的8个字节放回被调试进程中原来的内存位置。
由于我们已经知道如何在调试器中等待被调试进程里发生的特定事件,另外,我们现在也知道了如何恢复地址0x400516的原始值(和我们修改0xcc的过程一样)。所以我们可以跳过第4-5步,直接跳到第6步,第6步是我们到目前为止还没有做过的事情。
我们需要做的是读取被调试进程的寄存器值,改变它们并把它们写回去。还是一样,ptrace()可以为我们做所有的事。
.
.
.
struct user_regs_struct regs;
.
.
.
ptrace( PTRACE_GETREGS, child, NULL, ®s );
regs.rip = addr;
ptrace( PTRACE_SETREGS, child, NULL, ®s );
.
.
.
这里的东西没有太好的文档说明。例如,ptrace()的文档从来没有提到过struct user_regs_struct,但这是ptrace()系统调用希望从内核收到的东西。一旦我们知道我们应该使用什么作为ptrace()的参数,就很容易了。我们使用PTRACE_GETREGS操作来获取被调试进程的寄存器值,修改RIP寄存器值并使用PTRACE_SETREGS操作把它们写回去,清晰而简单。
整个调试器程序现在变成了listing2.c,如下:
#include <stdio.h>
#include <signal.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <linux/user.h>
#include <time.h>
void signal_handler( int sig )
{
printf( "Process %ld received signal %d\n", (long)getpid(), sig );
}
void do_debugger( pid_t child )
{
int status = 0;
long data;
long orig_data;
unsigned long addr;
struct user_regs_struct regs;
printf( "In debugger process %ld\n", (long)getpid() );
if (signal( SIGCHLD, signal_handler ) == SIG_ERR)
{
perror( "signal" );
exit( -1 );
}
// Waiting for child process to stop...
wait( &status );
// Placing breakpoint...
addr = 0x400516;
data = ptrace( PTRACE_PEEKTEXT, child, (void *)addr, NULL );
orig_data = data;
data = (data & ~0xff) | 0xcc;
ptrace( PTRACE_POKETEXT, child, (void *)addr, data );
// Breakpoint is ready. Telling child to continue running...
ptrace( PTRACE_CONT, child, NULL, NULL );
child = wait( &status );
// Restoring original data...
ptrace( PTRACE_POKETEXT, child, (void *)addr, orig_data );
// Changing RIP register so that it will point to the right address...
memset( ®s, 0, sizeof( regs ) );
ptrace( PTRACE_GETREGS, child, NULL, ®s );
printf( "RIP before resuming child is %lx\n", regs.rip );
regs.rip = addr;
ptrace( PTRACE_SETREGS, child, NULL, ®s );
// Debuggie is now ready to get resumed... Waiting ten seconds...
printf( "Time before debugger falling asleep: %ld\n", (long)time( NULL ) );
sleep( 5 );
printf( "Time after debugger falling asleep: %ld. Resuming debuggie...\n", (long)time( NU着LL ) );
ptrace( PTRACE_CONT, child, NULL, NULL );
child = wait( &status );
if (WIFSTOPPED( status ))
printf( "Debuggie stopped %d\n", WSTOPSIG( status ) );
if (WIFEXITED( status ))
printf( "Debuggie exited...\n" );
printf( "Debugger exiting...\n" );
}
void do_debuggie( void )
{
char* argv[] = { NULL };
char* envp[] = { NULL };
printf( "In debuggie process %ld\n", (long)getpid() );
if (ptrace( PTRACE_TRACEME, 0, NULL, NULL ))
{
perror( "ptrace" );
return;
}
execve( "sleeper", argv, envp );
}
int main()
{
pid_t child;
// Creating child process. It will execute do_debuggie().
// Parent process will continue to do_debugger().
child = fork();
if (child == 0)
do_debuggie();
else if (child > 0)
do_debugger( child );
else
{
perror( "fork" );
return -1;
}
return 0;
}
让我们看看事件事情的实际运作情况。编译并运行listing2.c,产生了以下输出。
In debuggee process 29843
In debugger process 29842
Process 29842 received signal 17
~~~~~~~~~~~~> Before breakpoint
Process 29842 received signal 17
RIP before resuming child is 400517
Time before debugger falling asleep: 1206346035
Time after debugger falling asleep: 1206346040. Resuming debuggee...
~~~~~~~~~~~~> After breakpoint
Process 29842 received signal 17
Debuggee exited...
Debugger exiting...
你可以看到 "Before breakpoint "打印输出出现5秒钟之后,输出了 "After breakpoint "。"RIP before resuming child is 400517 "清楚地表明,被调试进程在地址0x400517处停止了,正如我们所期望的那样。
单步调试
在看到放置断点是如此简单之后,你可以猜想,跨过一行C/C++代码单步执行的问题,只是在下一行代码上放置一个断点。这正是gdb在你想让它单步跳过某些表达式时的做法。
结论
调试器和它们的工作方式往往与某种魔法联系在一起。
调试器,以gdb为例,是非常复杂的软件。放置断点和单步调试只是它能做的一小部分。特别是gdb能在多种硬件结构上工作。它还支持远程调试。它也许是最先进、最复杂的可执行文件分析器。它知道一个程序何时加载动态库,并自动分析该库的代码。它支持大量的编程语言——从C/C++到ADA。而这些只是它的部分功能。
相反,我们已经看到对指定程序启动调试、放置断点等是多么容易。允许调试的基本功能就在操作系统和CPU中,等着我们去使用。