调试器工作原理:第一部分 基础
本文是英文博客 How debuggers work: Part 1 - Basics 的中文翻译,介绍了gdb的核心调用 ptrace
。
简介
我们首先需要想象调试器需要哪些功能,来完成其工作。调试器可以启动某个进程并对其进行调试,或将自身附加到现有进程。它可以单步执行代码,设置断点并运行它们,检查变量值和堆栈跟踪。许多调试器具有高级功能,例如在被调试进程的地址空间中执行表达式和调用函数,甚至可以即时更改进程代码并观察效果。
虽然现代调试器是复杂的野兽,但令人惊讶的是它们构建的基础却很简单。调试器只从操作系统、编译器和链接器提供的一些基本服务开始,其余只是一个简单的编程问题。
Linux 调试器——ptrace
Linux调试器的核心是ptrace
系统调用。它是一个多功能的复杂工具,允许一个进程控制另一个进程的运行,并检测它的内部情况。完全解释ptrace
可能需要一本书,本文主要专注于实例中的一些应用。
现在要开发一个以“跟踪”模式运行进程的示例,其中我们将单步执行其汇编指令。如果想要更进一步,我们可以把程序分为两块:子进程负责执行用户提供的指令;父进程负责跟踪和监控子进程。
以下是主函数代码:
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;
}
以下是运行用户指令的函数:
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);
}
这其中最有趣的就是ptrace
函数,该函数在头文件 sys/ptrace.h
中声明。
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
第一个参数是一个请求,它可能是许多预定义的PTRACE_ *
常量之一。第二个参数指定请求的进程ID。第三个和第四个参数是内存操作的地址和数据指针。上面代码片段中的ptrace
调用产生PTRACE_TRACEME
请求,这意味着子进程要求操作系统内核让其父进程跟踪它。 man ptrace
中的描述非常清楚:
Indicates that this process is to be traced by its parent. Any signal (except SIGKILL) delivered to this process will cause it to stop and its parent to be notified via wait(). Also, all subsequent calls to exec() by this process will cause a SIGTRAP to be sent to it, giving the parent a chance to gain control before the new program begins execution. A process probably shouldn’t make this request if its parent isn’t expecting to trace it. (pid, addr, and data are ignored.)
请注意,run_target
在调用ptrace
之后,会调用excel
程序,将用户的命令作为参数传入,这导致子进程在执行execl
之前就收到SIGTRAP
信号,停止进程并向父进程发送信号。
接着,我们看看父进程运行的函数:
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
等待子进程,如果子进程停止,父进程会调用WIFSTOPPED
,检查子进程是否是由于接收到信号而中止。
接着,父进程调用ptrace
,传入PTRACE_SINGLESTEP
和子进程id。这样做是告诉操作系统 —— 请重新启动子进程,但在执行下一条指令后停止它。同样,父进程等待子进程停止并且循环执行相同操作。当指令执行结束,子进程退出(WIFEXITED
将在其上返回true
),循环将终止。
请注意,icounter
会计算子进程执行的指令数量。因此,我们的简单示例实际上做了一些有用的事情 —— 给定命令行上的程序名称,它执行程序并返回从开始到结束运行所花费的CPU指令量。让我们看看它的实际效果。
代码测试
我们写一个简答的程序用于测试,代码如下:
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
return 0;
}
出乎意料的是,跟踪器运行了很长时间,并报告说执行了超过100,000条指令。具体原因是,在默认情况下,Linux
上的gcc
动态地将程序链接到C运行时库。这意味着在执行任何程序时运行的第一件事是动态库加载器,它查找所需的共享库。这是相当多的代码,我们的基本跟踪器会查看每个指令,而不仅仅是主要功能。
因此,当我将测试程序编译链接时,加入-static
(可执行文件的重量增加了大约500KB),跟踪仅报告了7,000条指令。数量仍然很多,原因在于必须在main
之前进行libc
初始化,并且在main
之后进行清理。此外,printf
本身十分复杂。
我们看一下程序的汇编代码:
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
指令流分析
通过分析汇编代码,我们可以使用ptrace
的另一个功能——检测进程的状态。我们进一步修改调试器的代码:
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. */
一旦我们在regs
中保存所有寄存器,我们就可以通过用调用ptrace
,使用PTRACE_PEEKTEXT
请求来查看进程的当前指令,并将其作为地址传递给regs.eip
(x86
上的扩展指令指针),得到具体指令。让我们看看这个新的跟踪器在我们的汇编编码片段上运行的情况:
$ 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
可以很容易地观察到它与我们的跟踪输出之间的对应关系。
本文提供的简单跟踪器的完整 源代码(更高级的指令打印版本)可在此处获得。它在gcc
的4.4版本上用-Wall -pedantic --std = c99
完美编译。
结论
单步执行代码很有用,但仅限于某种程度。拿 “Hello,world” 程序为例,要获得main
,可能需要几万条初始化指令才能完成。我们理想的情况是能够在主要入口处设置断点并从那里开始。在本系列的下一部分中,我打算展示如何实现断点。