调试器是怎样工作的(一):ptrace系统调用
本文是 How debuggers work: Part 1 - Basics 的中文翻译,如有错误或表述不当之处,敬请指正。
在本文中,我将介绍构建Linux调试器的重要模块——系统调用ptrace. 文章内的代码运行在32位Ubuntu上,代码本身与操作系统高度相关,但将其移植到其它操作系统上并不麻烦。
调试器的功能
正式开始前,不妨先想一想调试器都有哪些功能。调试器可以启动一个进程或附加到一个正在运行的进程上。它可以单步执行代码、设置断点、检查变量值、追踪栈。很多调试器还带有高级功能:例如表达式求值、调用被调试进程中的函数,甚至在调试过程中修改代码。
尽管现代调试器相当复杂(我确信gdb的代码量至少在六位数),但实现一个基本的调试器却很简单。它只用到操作系统、编译器和链接器提供的几个基本功能,剩下的都是些简单的编程问题。
系统调用ptrace
ptrace可以说是Linux调试器中的一把瑞士军刀(man 2 ptrace
),它是一个功能丰富且相当复杂的工具,它可以使得一个进程控制另一个进程的执行、查看或修改其内部状态。详细解释ptrace需要一本厚厚的书,但在这里,我们只关心它的一小部分功能。话不多说,让我们开始吧!
单步执行代码
接下来我将在追踪模式下运行一个进程,单步运行其代码,也就是CPU执行的机器指令,完整代码位于文末。
我的大体思路如下:父进程创建一个子进程,子进程用于运行用户程序,而父进程用于追踪子进程。因此,main
函数如下所示:
int main(int argc, char** argv)
{
pid_t child_pid;
if (argc < 2) {
fprintf(stderr, "Expected a program name as argument\n");
return -1;
}
child_pid = fork();
if (child_pid == 0)
run_target(argv[1]);
else if (child_pid > 0)
run_debugger(child_pid);
else {
perror("fork");
return -1;
}
return 0;
}
具体实现很简单:使用fork
创建一个子进程,之后,if
分支用于运行子进程(这里称为“target”,即目标进程),而else if
分支运行父进程(这里称为“debugger”,即调试器进程)。
以下是目标进程的代码:
void run_target(const char* programname)
{
procmsg("target started. will run '%s'\n", programname);
/* Allow tracing of this process */
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Replace this process's image with the given program */
execl(programname, programname, 0);
}
译注
在上述代码中,
procmsg
是作者定义的一个函数,文末有它的具体实现,如果不想使用此函数,此处可以直接替换为printf
. 另外,调用execl
时,编译器可能会提示warning: missing sentinel in function call. 如果遇到这种情况,可以将代码修改为execl(programname, programname, (char*)0)
.
以上代码中,最重要的是ptrace
调用,它的声明如下(定义在文件 sys/ptrace.h 中):
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
第一个参数表示请求的类型,是众多预定义的PTRACE_*
常量之一;第二个参数指定了进程的ID(某些类型的请求需要进程的PID). 第三、四个参数分别为地址、数据指针,它们用于读写内存。在上述代码中,ptrace调用发出PTRACE_TRACEME
请求,表示子进程请求操作系统内核让它的父进程追踪它。man 手册中对这一请求有着详细的描述:
指示此进程由父进程追踪。发送给此进程的任何信号(除
SIGKILL
外)都会导致此进程停止,并通过wait()
通知父进程。此外,此进程之后对exec()的所有调用都将导致向其发送信号SIGTRAP
,这使得父进程可以在新的程序运行前获得控制权。如果父进程不想跟踪它,那么此进程不应当发出此请求(参数pid、addr和data会被忽略)。
我高亮了重要部分。注意,在run_target
中,调用ptrace后所做的第一件事情是使用execl
调用运行参数指定的程序。正如高亮部分所说的,这会导致操作系统内核在进程运行execl
中的程序前停止该进程,并向父进程发送一个信号。
现在来看看父进程做了些什么:
void run_debugger(pid_t child_pid)
{
int wait_status;
unsigned icounter = 0;
procmsg("debugger started\n");
/* Wait for child to stop on its first instruction */
wait(&wait_status);
while (WIFSTOPPED(wait_status)) {
icounter++;
/* Make the child execute another instruction */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Wait for child to stop on its next instruction */
wait(&wait_status);
}
procmsg("the child executed %u instructions\n", icounter);
}
回顾之前提到的内容:一旦子进程调用exec
,它将停止运行并接收到SIGTRAP
信号。这里父进程通过第一个wait
调用等待这一情况的出现。当一些有趣的事情发生时,wait
将会返回,之后父进程检查是否是因为子进程被暂停(如果子进程被信号暂停,WIFSTOPPED
将返回true
)。
接下来父进程所做的,正是本文中最有意思的部分。它通过PTRACE_SINGLESTEP
请求调用函数ptrace
, 并传入子进程的进程ID. 这样做相当于告诉操作系统——请继续运行子进程,但在它执行下一条指令后停止它。接下来,父进程再次等待子进程暂停,一直这样循环下去。当wait
调用得到的结果并没有表示子进程正处于暂停状态,循环将终止。正常情况下,这个信号用于通知父进程子进程已经退出(对此信号调用WIFEXITED
会返回true
)。
变量icounter
用于记录子进程执行的指令数。因此,上述代码的功能是——使用命令行指定程序的名称,它将运行该程序,并输出此程序从开始到结束运行时总共执行的CPU指令数。让我们试着运行它。
测试运行
编译以下程序并在追踪器中运行它:
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
return 0;
}
令人诧异的是,跟踪器运行的时间相当长,输出执行了超过100,000条指令。仅仅一个简单的printf
调用?到底发生了什么?答案很有意思(如果你像我一样对底层细节着迷 :-))。默认情况下,Linux中的gcc
将程序动态链接到C运行时库。也就是说,任何程序运行时,会首先运行动态库加载器,查找程序所需的共享库。执行的指令数相当大——请注意,这里实现的基本跟踪器会查看进程中的每一条指令,而不仅仅是main
函数包含的指令。
使用-static
静态链接测试程序后(可执行文件的大小增加了约500KB,这对于静态链接C运行时是合理的),跟踪器输出约7000条指令。指令数仍然很大,但如果你还记得在main
函数运行前还必须初始化libc,在main
函数结束运行后还必须做一些清理工作,这个数字是完全合理的。另外,printf
是一个十分复杂的函数。
但我还是不满足,我想看到一些能测试的东西。也就是说,我可以计算出运行过程中每一条执行的指令,可以通过汇编代码实现。以下是汇编语版本的“Hello, world!”:
section .text
; The _start symbol must be declared for the linker (ld)
global _start
_start:
; Prepare arguments for the sys_write system call:
; - eax: system call number (sys_write)
; - ebx: file descriptor (stdout)
; - ecx: pointer to string
; - edx: string length
mov edx, len
mov ecx, msg
mov ebx, 1
mov eax, 4
; Execute the sys_write system call
int 0x80
; Execute sys_exit
mov eax, 1
int 0x80
section .data
msg db 'Hello, world!', 0xa
len equ $ - msg
果然,现在跟踪器输出执行了7条指令,这是我很容易验证的。
深入指令流
通过汇编语言编写的程序,我将介绍ptrace
的另一个强大用途——仔细检查被跟踪进程的状态。以下是函数run_debugger
的全新版本:
void run_debugger(pid_t child_pid)
{
int wait_status;
unsigned icounter = 0;
procmsg("debugger started\n");
/* Wait for child to stop on its first instruction */
wait(&wait_status);
while (WIFSTOPPED(wait_status)) {
icounter++;
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);
procmsg("icounter = %u. EIP = 0x%08x. instr = 0x%08x\n",
icounter, regs.eip, instr);
/* Make the child execute another instruction */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Wait for child to stop on its next instruction */
wait(&wait_status);
}
procmsg("the child executed %u instructions\n", icounter);
}
唯一的区别在于while
循环中的前几行:新增了两个ptrace
调用。第一个调用将进程的寄存器值读入一个结构体中,结构体user_regs_struct
定义于文件 sys/user.h 中。有意思的地方来了——如果你查看这个头文件,顶部的注释写道:
/* The whole purpose of this file is for GDB and GDB only.
Don't read too much into it. Don't use it for
anything other than GDB unless know what you are
doing. */
/* 此文件仅供GDB使用。不要过多浏览此文件。不要在除GDB外的任何场合中使用此文件,除非你知道自己在做什么。 */
不知道你是怎么想的,在我看来,我们的路子走对了 :-) 无论如何,回到这个例子中。一旦通过变量reg
s获取所有的寄存器信息,就可以通过PTRACE_PEEKTEXT
请求调用ptrace,并将regs.eip
(x86中的扩展指令指针)作为地址参数以读取进程当前执行的指令。我们将得到指令本身(注意:正如我之前提到的,文中很多内容都是与平台高度相关的。我做了一些简化的假设——例如,一条x86指令不一定是4个字节大小(在32位Ubuntu上unsigned类型的大小)。事实上,很多指令并不是4个字节大小。查看指令的具体含义需要一个完整的反汇编器,我们这里没有,但真正的调试器会有。)。现在来看看在这个新的跟踪器上运行之前汇编程序的结果:
$ simple_tracer traced_helloworld
[5700] debugger started
[5701] target started. will run 'traced_helloworld'
[5700] icounter = 1. EIP = 0x08048080. instr = 0x00000eba
[5700] icounter = 2. EIP = 0x08048085. instr = 0x0490a0b9
[5700] icounter = 3. EIP = 0x0804808a. instr = 0x000001bb
[5700] icounter = 4. EIP = 0x0804808f. instr = 0x000004b8
[5700] icounter = 5. EIP = 0x08048094. instr = 0x01b880cd
Hello, world!
[5700] icounter = 6. EIP = 0x08048096. instr = 0x000001b8
[5700] icounter = 7. EIP = 0x0804809b. instr = 0x000080cd
[5700] the child executed 7 instructions
除了icounter
外,在每个步骤中还能看到指令指针以及它指向的指令。如何验证输出结果是否正确?通过对可执行文件上使用objdump -d
:
$ objdump -d traced_helloworld
traced_helloworld: file format elf32-i386
Disassembly of section .text:
08048080 <.text>:
8048080: ba 0e 00 00 00 mov $0xe,%edx
8048085: b9 a0 90 04 08 mov $0x80490a0,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094: cd 80 int $0x80
8048096: b8 01 00 00 00 mov $0x1,%eax
804809b: cd 80 int $0x80
很容易观察到输出结果与追踪器输出之间的关联。
附加到正在运行的进程
众所周知,调试器也可以附加到正在运行的进程上。现在,你应该不会惊讶于这件事情也是用ptrace做的,通过发送PTRACE_ATTACH
请求。我就不在这里举例了,考虑到我们之前讨论过的代码,实现起来应该非常容易。出于教学的目的,上面使用了更方便的方法(因为我们可以在子进程刚开始时马上停止它)。
代码
本文介绍的简单跟踪器完整C源代码(具有更高级的指令打印功能)如下。在 gcc 4.4中,使用-Wall -pedantic --std=c99
可以编译通过。
simple_tracer.c
/* Code sample: using ptrace for simple tracing of a child process.
**
** Note: this was originally developed for a 32-bit x86 Linux system; some
** changes may be required to port to x86-64.
**
** Eli Bendersky (https://eli.thegreenplace.net)
** This code is in the public domain.
*/
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <signal.h>
#include <syscall.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <unistd.h>
#include <errno.h>
/* Print a message to stdout, prefixed by the process ID
*/
void procmsg(const char* format, ...)
{
va_list ap;
fprintf(stdout, "[%d] ", getpid());
va_start(ap, format);
vfprintf(stdout, format, ap);
va_end(ap);
}
void run_target(const char* programname)
{
procmsg("target started. will run '%s'\n", programname);
/* Allow tracing of this process */
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Replace this process's image with the given program */
execl(programname, programname, 0);
}
void run_debugger(pid_t child_pid)
{
int wait_status;
unsigned icounter = 0;
procmsg("debugger started\n");
/* Wait for child to stop on its first instruction */
wait(&wait_status);
while (WIFSTOPPED(wait_status)) {
icounter++;
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);
procmsg("icounter = %u. EIP = 0x%08x. instr = 0x%08x\n",
icounter, regs.eip, instr);
/* Make the child execute another instruction */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Wait for child to stop on its next instruction */
wait(&wait_status);
}
procmsg("the child executed %u instructions\n", icounter);
}
int main(int argc, char** argv)
{
pid_t child_pid;
if (argc < 2) {
fprintf(stderr, "Expected a program name as argument\n");
return -1;
}
child_pid = fork();
if (child_pid == 0)
run_target(argv[1]);
else if (child_pid > 0)
run_debugger(child_pid);
else {
perror("fork");
return -1;
}
return 0;
}
小结
本文涵盖的内容并不多,我们离真正的调试器还有很长的路要走。但是,我希望它至少使得整个调试过程变得不那么神秘了。ptrace是一个特别清大的系统调用,目前我们见到的,只是它的冰山一角。
逐步执行代码很有用,但也没那么有用。对于一个C语言实现的“Hello, world!”程序,在运行到main
函数前,需要执行几千条初始化代码,此时逐步执行就没那么方便了。我们希望在main
函数的入口设置断点,并从那里开始逐步执行。在下一篇文章中,我将介绍断点是如何实现的。
参考资料