调试器是怎样工作的(二):断点
本文是 How debuggers work: Part 2 - Breakpoints 的中文翻译,如有错误或表述不当之处,敬请指正。
在上文中,我们介绍了调试器中的重要模块——系统调用ptrace. 在本文中,我将介绍断点是如何实现的。
软中断
在x86架构上实现断点,需要用到软中断(software interrupt, 软件中断,又叫“陷阱”)。在深入细节前,我想先解释下中断和陷阱的概念。
在程序员看来,CPU具有单执行流,它按照顺序一条接着一条执行指令。为了处理异步事件,例如I/O、硬件定时器等,CPU使用中断。硬中断(hardware interrupt,硬件中断)通常是具有特殊“响应电路”的专用电信号。中断信号出现时,此电路会响应中断,使得CPU停止当前进程,保存CPU的状态,并跳转到预定义的中断处理程序。当中断处理程序执行完毕后,CPU将从停止处再次恢复执行。
软中断在原理上与硬中断类似,但在实现上略有差异。 CPU支持特殊指令模拟中断。当特殊指令运行时,CPU把它当作中断——停止当前正在运行的进程,保存CPU的状态并跳转到中断处理程序处。这样的“陷阱”是现代操作系统中许多核心功能,例如任务调度、虚拟内存、内存保护、调试等得以实现的基础。
某些编程错误(例如除以0)也会被CPU当作陷阱,它们通常被称为“异常”。在这里,硬件与软件之间的界限变得模糊,很难说这种异常是硬中断还是软中断。
int 3的原理
简单来说,断点的功能基于一个名为int 3
的特殊陷阱。int
是x86的术语,表示“陷阱指令”(trap instruction)——它会调用预定义的中断处理程序。 x86支持int
指令,可以通过一个8位的操作数指定中断类型,因此理论上支持256种陷阱。前32个编号被CPU保留,其中,第3个正是我们感兴趣的——它被称为“调试器陷阱”。
Intel's Architecture software developer's manual, volume 2A
INT 3
指令生成一个特殊的单字节操作码 (0xCC
),它可以调用调试异常处理程序。 (这个单字节操作码很有用,它可以替换任何指令的第一个字节,包括单字节指令,而不会覆盖其它指令)。
括号中的内容很重要,但现在解释还为时尚早,之后我会解释它。
int 3的实现
当进程执行到int 3
指令时,操作系统将暂停当前进程。在Linux中,它将向进程发送信号SIGTRAP
.
回顾上文提到的内容,跟踪进程(调试器)会接收到子进程(或附加在上面的被调试进程)接收到的所有信号,来看个例子。
手动设置断点
我将展示如何在程序中设置断点,以下是被调试的目标程序:
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, len1
mov ecx, msg1
mov ebx, 1
mov eax, 4
; Execute the sys_write system call
int 0x80
; Now print the other message
mov edx, len2
mov ecx, msg2
mov ebx, 1
mov eax, 4
int 0x80
; Execute sys_exit
mov eax, 1
int 0x80
section .data
msg1 db 'Hello,', 0xa
len1 equ $ - msg1
msg2 db 'world!', 0xa
len2 equ $ - msg2
之所以使用汇编,是为了避免使用C语言时可能出现的编译问题和符号问题。上述程序的功能很简单:首先输出“Hello”,然后在下一行输出“world!”。
我想在程序输出“Hello”后设置一个断点,因此,这个断点应该设置在第一条int 0x80
指令后,也就是指令mov edx, len2
处。首先,我们需要知道这条指令的地址,运行objdump -d
:
traced_printer2: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000033 08048080 08048080 00000080 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 0000000e 080490b4 080490b4 000000b4 2**2
CONTENTS, ALLOC, LOAD, DATA
Disassembly of section .text:
08048080 <.text>:
8048080: ba 07 00 00 00 mov $0x7,%edx
8048085: b9 b4 90 04 08 mov $0x80490b4,%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: ba 07 00 00 00 mov $0x7,%edx
804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx
80480a0: bb 01 00 00 00 mov $0x1,%ebx
80480a5: b8 04 00 00 00 mov $0x4,%eax
80480aa: cd 80 int $0x80
80480ac: b8 01 00 00 00 mov $0x1,%eax
80480b1: cd 80 int $0x80
因此,断点应设置在地址0x8048096处。等等,调试器好像不是这么用的?在实际中,我们会在某行代码或某个函数上设置断点,而不是通过内存地址设置断点。确实如此,但目前我们还做不到这一点——如果想像真正的调试器那样设置断点,还需要了解符号和调试信息,这些都是后话了。目前,我们只使用内存地址。
为什么是0x8040096?
0x8048096本身并无多大含义,它只是可执行文件中text段开头的几个字节。如果仔细查看上面的输出,可以看到text段从0x08048080开始。这告诉操作系统将从该地址开始的text段映射到虚拟内存中。在 Linux 上,这些地址可以是绝对的(可执行文件在加载到内存中时不会被重定位),因为使用虚拟内存系统,每个进程都有自己的虚拟内存,并将整个32位地址空间视为自己的地址空间(也叫线性地址)。
使用
readelf
检查ELF文件头,可以看到$ readelf -h traced_printer2 ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x8048080 Start of program headers: 52 (bytes into file) Start of section headers: 220 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 2 Size of section headers: 40 (bytes) Number of section headers: 4 Section header string table index: 3
注意“entry point address”,它也指向0x8048080. 因此,如果我们向操作系统解释ELF文件中编码的指令,它会说:
- 将text段映射到地址0x8048080;
- 从入口处(entry point)开始执行——即地址0x8048080.
但为什么是0x8048080?由于历史原因,每个进程地址空间的前128MB为堆栈保留。128MB恰好是0x8000000,这恰好是可执行文件中其它段开始的地方。特别是0x8048080,它是Linux的ld链接器的默认入口点,可通过指定-Ttext参数修改入口点。
总之,这个地址没有任何特别之处,我们可以随意修改它。只要ELF可执行文件的格式正确,且程序的入口地址与实际地址一致即可。
使用int 3设置断点
为了在被调试进程中的某个地址处设置断点,调试器将执行以下操作:
- 保存目标地址处的数据;
- 将目标地址处数据的首字节替换为
int 3
指令。
之后,当调试器要求操作系统运行被调试的进程(使用上文提到的PTRACE_CONT
)时,被调试的进程将会运行,直到运行到int 3
指令,进程将会停止,操作系统向它发送一个信号。这是调试器再次进入的地方,它将接收到“被调试进程已经暂停”的信号。之后,调试器将:
- 将目标地址处的
int 3
指令替换为原来的指令; - 将被调试进程的指令指针减1。这是必要的,因为指令指针现在指向
int 3
的后面,并且已经执行了它; - 允许用户以某种方式与被调试进程交互,因为进程仍在目标地址处暂停。此时,用户可以查看变量值和调用堆栈等;
- 当用户想要继续运行时,调试器将负责把断点再次放回目标地址(因为步骤1已经将其删除),除非用户将这个断点删除。
让我们看看这些步骤如何转换为实际代码。我们将使用前文提到的调试器“模板”(fork一个子进程并跟踪它)。完整的代码位于文末。
/* Obtain and show child's instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child started. EIP = 0x%08x\n", regs.eip);
/* Look at the word at the address we're interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);
注释
常量
PTRACE_PEEKTEXT
中,peek一词表示从内存中读取数据,而poke表示向内存中写入数据(即下文中的PTRACE_POKETEXT
).
此处,调试器从被调试的进程中获取指令指针,并读取位于0x8048096处的内存,运行上述代码,输出:
[13028] Child started. EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba
目前为止一切顺利。接下来:
/* Write the trap instruction 'int 3' into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);
/* See what's there again... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);
注意int 3
是怎么被插入到目标地址的。以上代码输出:
[13028] After trap, data at 0x08048096: 0x000007cc
正如我们所期望的那样,0xBA
被替换为0xCC
. 调试器将运行被调试的子进程,一直等到它运行到断点处暂停:
/* Let the child run to the breakpoint and wait for it to
** reach it
*/
ptrace(PTRACE_CONT, child_pid, 0, 0);
wait(&wait_status);
if (WIFSTOPPED(wait_status)) {
procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));
}
else {
perror("wait");
return;
}
/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);
以上代码会输出:
Hello,
[13028] Child got a signal: Trace/breakpoint trap
[13028] Child stopped at EIP = 0x08048097
注意,在断点前输出“Hello”,这正是我们所期望的。另外,请注意被调试的子进程暂停的位置——在单字节的陷阱指令之后。
最后,正如前面提到的,为了让被调试的子进程继续运行,需要用原始指令替换陷阱指令,并让被调试的子进程继续运行。
/* Remove the breakpoint by restoring the previous data
** at the target address, and unwind the EIP back by 1 to
** let the CPU execute the original instruction that was
** there.
*/
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);
regs.eip -= 1;
ptrace(PTRACE_SETREGS, child_pid, 0, ®s);
/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);
子进程将输出“World!”并退出程序。
请注意,我们没有在这里恢复断点。可以在单步模式下执行原始指令,然后将陷阱指令重新写回,最后再执行PTRACE_CONT
.
关于int 3的更多内容
是时候解释Intel手册中那条奇怪的注释了,如下:
Intel's Architecture software developer's manual, volume 2A
INT 3
指令生成一个特殊的单字节操作码 (0xcc
),它可以调用调试异常处理程序。 (这个单字节操作码很有用,它可以替换任何指令的第一个字节,包括单字节指令,而不会覆盖其它指令)。
x86的int
指令占用两个字节——0xcd
和一个中断号。int 3
可以编码为cd 03
,但有一个特殊的单字节指令为它保留——0xcc
.
为什么会这样呢?因为这允许我们插入断点,而不会覆盖其它指令。请看以下示例:
.. some code ..
jz foo
dec eax
foo:
call bar
.. some code ..
假设我们想在dec eax
处设置断点,它是个单字节指令(操作码为0x48
)。如果断点指令超过1个字节,断点指令将覆盖下一条指令的一部分(也就是call
),从而引发错误。如果jz foo
的跳转条件被满足,那么这条指令将直接跳转到函数foo
,而不会执行指令dec eax
,但由于foo
所在地址的指令被部分覆盖,因此这将是个无效的指令。
为int 3
提供一个特殊的1字节编码就可以解决这个问题。由于1个字节是x86上一条指令被编码的最短字节,从而保证了只有我们希望被中断的指令才会被更改。
上述的许多底层细节可以封装成一个API, 我将这些细节封装到debuglib库中,代码可在文末下载。在这里,我只想展示它的基本用法,我们将跟踪一个C程序,而不是汇编代码。
跟踪C程序
事实上,调试C语言实现的程序与汇编语言并无太大的不同,只是更难找到断点的位置。请看以下程序:
#include <stdio.h>
void do_stuff()
{
printf("Hello, ");
}
int main()
{
for (int i = 0; i < 4; ++i)
do_stuff();
printf("world!\n");
return 0;
}
如果我想在do_stuff
处设置一个断点。我将使用objdump
反汇编可执行文件,反汇编的结果中包含很多内容。特别是其中的text段,包含了许多C runtime的初始化代码。我们只关注输出中的do_stuff
:
080483e4 <do_stuff>:
80483e4: 55 push %ebp
80483e5: 89 e5 mov %esp,%ebp
80483e7: 83 ec 18 sub $0x18,%esp
80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp)
80483f1: e8 22 ff ff ff call 8048318 <puts@plt>
80483f6: c9 leave
80483f7: c3 ret
我们将断点设置在地址0x080483e4处,这是do_stuff
的首地址。此外,由于此函数在循环中被调用的,因此我们希望保存断点的位置,一直到循环结束。我们将使用debuglib库,以下是完整代码:
void run_debugger(pid_t child_pid)
{
procmsg("debugger started\n");
/* Wait for child to stop on its first instruction */
wait(0);
procmsg("child now at EIP = 0x%08x\n", get_child_eip(child_pid));
/* Create breakpoint and run to it*/
debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
procmsg("breakpoint created\n");
ptrace(PTRACE_CONT, child_pid, 0, 0);
wait(0);
/* Loop as long as the child didn't exit */
while (1) {
/* The child is stopped at a breakpoint here. Resume its
** execution until it either exits or hits the
** breakpoint again.
*/
procmsg("child stopped at breakpoint. EIP = 0x%08X\n", get_child_eip(child_pid));
procmsg("resuming\n");
int rc = resume_from_breakpoint(child_pid, bp);
if (rc == 0) {
procmsg("child exited\n");
break;
}
else if (rc == 1) {
continue;
}
else {
procmsg("unexpected: %d\n", rc);
break;
}
}
cleanup_breakpoint(bp);
}
我们只使用了函数create_breakpoint
、resume_from_breakpoint
和cleanup_breakpoint
. 以上代码的输出如下:
$ bp_use_lib traced_c_loop
[13363] debugger started
[13364] target started. will run 'traced_c_loop'
[13363] child now at EIP = 0x00a37850
[13363] breakpoint created
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
world!
[13363] child exited
正如我们所料!
代码
bp_manual.c
/* Code sample: manual setting of a breakpoint, using ptrace
**
** Eli Bendersky (http://eli.thegreenplace.net)
** This code is in the public domain.
*/
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.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>
#include "debuglib.h"
/* For some reason this declaration isn't available from string.h */
extern char* strsignal(int);
void run_debugger(pid_t child_pid)
{
int wait_status;
struct user_regs_struct regs;
procmsg("debugger started\n");
/* Wait for child to stop on its first instruction */
wait(&wait_status);
/* Obtain and show child's instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child started. EIP = 0x%08x\n", regs.eip);
/* Look at the word at the address we're interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);
/* Write the trap instruction 'int 3' into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);
/* See what's there again... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);
/* Let the child run to the breakpoint and wait for it to
** reach it
*/
ptrace(PTRACE_CONT, child_pid, 0, 0);
wait(&wait_status);
if (WIFSTOPPED(wait_status)) {
procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));
}
else {
perror("wait");
return;
}
/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);
/* Remove the breakpoint by restoring the previous data
** at the target address, and unwind the EIP back by 1 to
** let the CPU execute the original instruction that was
** there.
*/
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);
regs.eip -= 1;
ptrace(PTRACE_SETREGS, child_pid, 0, ®s);
/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);
wait(&wait_status);
if (WIFEXITED(wait_status)) {
procmsg("Child exited\n");
}
else {
procmsg("Unexpected signal\n");
}
}
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;
}
在这里可以找到完整的源代码,其中包含:
- debuglib.h, debuglib.c:封装了调试器的基本功能
- bp_manual.c:文中的示例代码1
- bp_use_lib.c:文中的示例代码2
小结
到目前为止,我们已经介绍了如何实现断点。虽然不同操作系统在实现细节上有所差异,但在x86上,基本上都是用int 3
替换我们希望进程停止的指令。
相信很多像我一样的读者,并不满足于指定内存地址设置断点这件事。我们更希望“在函数do_stuff
处设置断点”或者“在函数do_stuff
中的某一行设置断点”。在下一篇文章中,我将展示如何实现这一点。