调试器工作原理:第二部分 断点
本文是英文博客 How debuggers work: Part 2 - Breakpoints 的中文翻译,这是关于调试器如何工作的系列文章的第二部分。请务必在此之前阅读第一部分。
本文将演示如何在调试器中实现断点。断点是调试的两个重要功能之一一 另一个功能是能够检查进程内部的变量。我们已经看过该系列第一部分中对数值检查的介绍。本文将介绍断点的工作原理。
软件中断
系统使用软件中断(trap)在x86
体系结构上实现断点。在我们深入了解细节之前,我想解释一般中断(interrupts)和陷阱(trap)的概念。
CPU线性处理机器指令。当处理IO和硬件定时器等异步事件时,CPU会触发中断。CPU使用特殊的“响应电路”,发送专用的电信号,实现硬件中断。该电路捕捉到中断的激活并使CPU停止其当前执行的指令,保存其状态,并跳转到预先定义的中断处理程序所在地址。当处理程序完成其工作时,CPU将恢复到中断触发前的状态,继续执行指令。
软件中断在原理上类似,但在实现上略有不同。 CPU为软件提供模拟硬件中断的特殊指令。当执行这样的指令时,CPU将其视为中断 - 停止其正常的执行流程,保存其状态并跳转到处理程序例程。这种“陷阱”的设计使现代操作系统很多神奇的功能(任务调度,虚拟存储器,存储器保护,调试)得以实现。
一些编程错误(例如除以0)也被CPU视为陷阱,并且通常被称为“异常”(exception)。在这里硬件和软件没有清晰的界限,因为很难说这些异常是硬件中断还是软件中断。
INT 3 原理
简单来说,CPU通过一个名为int 3
的特殊陷阱来实现断点。int
特指 x86
中的“陷阱指令” —— 调用预定义的中断处理函数。 x86
支持int
指令带有8个操作数,用于指定中断的种类,因此理论上支持256种陷阱。前32种由CPU为自己保留,int 3
被称为“调试器陷阱”。
以下为INTEL开发者手册中的原文(Intel’s Architecture software developer’s manual):
INT 3
指令生成一个特殊的单字节操作码(CC),用于调用异常处理程序。(这种单字节形式很有价值,因为它可用于用断点替换任何指令的第一个字节,包括其他单字节指令,而不会覆盖其他代码)。
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
以上的代码功能很简单,在两行中分别打印 “Hello” 和 “World!“。现在我想在打印 “Hello” 完后设置一个断点。我们先使用 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
我们需要在第一个 int 0x80
后设置断点,具体的地址是0x8048096。注意,真正的调试器在代码行和函数上设置断点,而不是在一些裸存储器地址上设置断点。但是我们距离那里还很远 —— 为了像真正的调试器那样设置断点,我们仍然必须首先阐明符号和调试信息,并且系列中还需要另外一两部分来实现这些主题。现在,我们将使用裸内存地址。
在调试器中设置断点
要在跟踪进程中的某个目标地址处设置断点,调试器将执行以下操作:
- 记住存储在目标地址的数据
- 用
int 3
指令替换目标地址的第一个字节
然后,当调试器要求OS运行该进程时(正如我们在上一篇文章中看到的那样使用PTRACE_CONT
),该进程将运行并最终触发 int 3
。这是调试器再次进入的地址,接收到其子进程(或跟踪进程)已停止的信号。它可以:
- 用原始指令替换目标地址处的
int 3
指令 - 将跟踪过程的指令指针向后滚动一个。这是必需的,因为此时指令指针现在指向
int 3
之后,已经执行中断指令。 - 允许用户以某种方式与进程交互,因为进程仍然在期望的目标地址处停止。此时调试器允许您查看变量值,调用堆栈等的部分。
- 当用户想要继续运行时,除非用户要求取消断点,否则调试器将负责将断点放回目标地址(因为它已在步骤1中删除)。
让我们看看其中一些步骤是如何转换为实际代码的。我们将使用第1部分中介绍的调试器“模板”(分析子进程并跟踪它)。在任何情况下,本文末尾都有一个指向该示例的完整源代码的链接。
/* 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);
这里调试器从跟踪进程中获取指令指针,并检查当前存在于0x8048096
的 word。当运行跟踪本文开头列出的汇编程序时,会打印:
[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
最后,如前所述,为了让子进程继续运行,我们必须做一些工作。我们用原始指令替换陷阱,让进程继续运行。
/* 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);
请注意,我们不会在此处恢复断点。这可以通过在单步模式下执行原始指令,然后放回陷阱然后再执行PTRACE_CONT
来完成。本文后面演示的调试库实现了这一点。
回到 INT 3
我们重新研究 int 3
,根据英特尔手册:
这种单字节形式很有价值,因为它可用于用断点替换任何指令的第一个字节,包括其他一个字节指令,而不会覆盖其他代码
x86
上的 int
指令占用两个字节 —— 0xcd
加上后面的中断号。 int 3
可以编码为 cd 03
,但是为它保留了一个特殊的单字节指令 —— 0xcc
。
为什么这样?因为这允许我们插入断点而不会覆盖多个指令。这很重要。考虑以下示例代码:
...
jz foo
dec eax
foo:
call bar
...
假设我们想在 dec eax
上放置一个断点。这恰好是单字节指令(操作码为0x48
)。如果替换断点指令超过1个字节,我们将被强制覆盖下一个指令(call
)的一部分,这会使其乱码并可能产生完全无效的东西。jz foo
将会触发跳转,进程不会再 dec eax
处停止,CPU将直接执行后面的无效指令。
对 int 3
进行特殊的1字节编码可以解决这个问题。由于1字节是指令可以在x86
上运行的最短字节,因此我们保证只有想要中断的指令才会被更改。
细节封装
我们将代码示例中的许多低级细节封装成API。我已经对一个名为debuglib
的小实用程序库进行了一些封装 - 它的代码可以在本文末尾下载。在这里,我只是想展示一下它的用法示例。我们将跟踪用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
来反汇编可执行文件,查看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
,我们可以不必直接修改EIP
和进程的内存空间。在跟踪上面显示的简单C代码时,让我们看看它打印的是什么:
$ 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
完整代码
链接 是此部分的完整源代码文件,具体包含以下内容:
debuglib.h
和debuglib.c
—— 用于封装调试器的一些内部工作的简单库bp_manual.c
- 本文首先介绍的设置断点的“手动”方式。使用debuglib
库获取一些样板代码。bp_use_lib.c
- 对其大多数代码使用debuglib
,如在C程序中跟踪循环的第二个代码示例中所示。
总结
我们已经介绍了如何在调试器中实现断点。虽然操作系统之间的实现细节不同,但是当你使用x86
构架时,它基本上都是同一主题的变体 —— 用int 3
代替我们希望停止的指令。