调试器如何工作:第二部分——断点
原作者:Eli Bendersky
http://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
这是关于调试器如何工作系列文章的第二部分。在这之前确保你读过第一部分。
在这部分
我将展示在调试器中如何实现断点。断点是调试的两大支柱之一——另一个是能够在被调试进程内存里查看值。在第一部分里我们已经预览过另一个支柱,但断点仍然笼罩在神秘的面纱下。看完本文,它们不再是了。
软中断
为了在x86架构上实现断点,使用软中断(也称为“陷入”)。在我们进入细节之前,我想大概地解释一下中断及陷入的概念。
一个CPU有单个执行流,一条一条地执行指令[1]。为了处理异步事件像IO及硬件时钟,CPU使用中断。一个硬件中断通常是一个专用的电子信号,附加一个特殊的“响应电路”。这个电路通知中断的活动,使得CPU停止当前的执行,保存其状态,跳转到该中断处理例程所在的一个预定义地址。当处理例程完成工作时,CPU从停止处重新开始执行。
软中断理论上类似,但实际使用中有一点不同。CPU支持允许软件模拟中断的特殊指令。当执行这样的一条指令时,CPU把它像中断那样处理——暂停正常的执行流,保存状态,跳转到处理例程。这样的“陷入”允许现代OS的许多奇迹(任务调度,虚拟内存,内存保护,调试)能高效地实现。
一些编程错误(比如除0)也被CPU处理为陷入,并且通常被称为“异常”。在这里硬件与软件的边界是模糊的,因为很难确切辨别这样的异常是硬件中断还是软中断。但我已经离题太远,是时候回到断点上来了。
理论上的int3
有了前一节,我现在可以简单地说断点通过称为int3的特殊陷入在CPU上实现。Int是 “陷入指令”——对预定义中断处理例程调用的x86术语。x86支持带有一个指明所发生中断编号的8比特操作数的int指令,因此理论上支持256个陷入。头32个由CPU保留,编号3是我们这里感兴趣的+它成为“陷入到调试器”。
言归正传,我从圣经摘录如下[2]:
INT 3指令生成一个为调试异常处理例程所用的特殊单字节操作码(CC)。(这个单字节形式是有价值的,因为通过断点它可以用于替换任何指令的第一个字节,包括其他单字节指令,而无需改写其他代码)。
括号里的部分是重要的,但解释它还为时过早。在本文后面我们再回到这里来。
实用中的int3
是的,知道背后的理论是很棒的,OK,但这意味着什么呢?我们如何使用int3来实现断点?或者改述常见的编程Q&A行话——请告诉我代码!
实用中,这确实非常简单。一旦你的进程执行int3执行,OS暂停它[3]。在Linux上(它是我们在本文里考虑的)然后向该进程发送一个信号——SIGTRAP。
坦率地——这就是所有!现在回忆系列的第一部分,其子进程(或它依附进行调试的进程)得到信号,一个追踪进程(调试者)都会得到通知,你可以感觉到我们正在去往哪里。
就这样,不再纠缠计算机架构。是时候看例子和代码了。
手动设置断点
现在我准备展示在一个程序里设置一个断点的代码。我准备使用的目标程序如下:
section .text
; The _start symbolmust be declared for the linker (ld)
global _start
_start:
; Prepare argumentsfor 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_writesystem call
int 0x80
; Now print the othermessage
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!”。它非常像前一篇文章里展示的程序。
我想在第一个打印后,第二个打印前设置一个断点。比如说在第一个int0x80[4]后,在movedx, len2指令上。首先,我们需要知道这条指令映射到哪个地址。运行objdump–d:
traced_printer2: fileformat 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。等一下,这不是真正调试器的工作方式,对吧?真正的调试器在函数及代码行上设置断点,而不是在裸露的内存地址上?完全正确。但我们离此很远——像真正的调试器那样设置断点,我们仍然首先必须包括符号及调试信息,还需要系列的一到两个部分,才能谈论这些议题。目前,我们将只能处理裸内存地址。
在这里我很想再次离题,因此你有两个选择。如果你真的想知道为什么地址是0x8048096以及它意味着什么,阅读下一节。如果不是,你只想知道断点,你可以跳过它。
离题——进程地址与入口
坦白地说,0x8048096本身没有太多含义,它只是距离这个可执行文件代码节的开头几个字节。如果你仔细看上面的输出列表,你会看到代码节在0x8048080开始。这告诉OS在这个进程的虚拟地址空间里,将代码节映射到这个地址。在Linux上这些地址可以是绝对的(即可执行文件在载入内存时,不进行重定位),因为使用虚拟内存系统,每个进程获得自己的内存块并看到自己所有的完整的32位地址空间(称为“线性”地址)。
如果我们使用readelf查看ELF[5],我们得到:
$ readelf -h traced_printer2
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 0000
Class: ELF32
Data: 2's complement,little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executablefile)
Machine: Intel 80386
Version: 0x1
Entry pointaddress: 0x8048080
Start of programheaders: 52 (bytes into file)
Start of section headers: 220 (bytes into file)
Flags: 0x0
Size of thisheader: 52 (bytes)
Size of programheaders: 32 (bytes)
Number of programheaders: 2
Size of section headers: 40 (bytes)
Number of sectionheaders: 4
Section header stringtable index: 3
注意头部的“entrypoint address”节,它也指向0x8048080。因此如果我们解释ELF文件里编码好对OS的指示,它说:
1. 将代码节(带有指定内容)映射到地址0x8048080
2. 在入口开始执行——地址0x8048080
但为什么又是0x8048080?答案是,由于历史的原因。Google了一下,有些来源称每个进程地址空间的首128MB是保留给栈的。128MB恰好是0x8000000,是可执行文件其他节可以开始的地址。特别的,0x8048080是Linuxld链接器使用的默认入口点。可以通过向ld传递-Ttext参数来改变这个入口点。
总之,这个地址没有什么特别的,我们可以随便改变它。只要ELF可执行文件是正确构建的,并且头部的入口点地址能匹配程序代码的真正起点,就没问题。
使用int3在调试器中设置断点
要在被追踪进程的某个目标地址设置断点,调试器要完成以下工作:
1. 记住保存在目标地址的数据
2. 以int 3指令替换目标地址的第一个字节
然后,当调试器要求OS运行该进程时(使用我们在之前文章里看过的PTRACE_CONT),进程将运行并最终击中int3,在那里它将暂停,OS将向它发送一个信号。在那里调试器再次插手,收到一个子进程(或被追踪进程)被暂停的信号。然后它可以:
1. 以原来的指令替换目标地址的int 3指令。
2. 将被追踪进程的指令指针回滚一步。这是需要的,因为指针指针现在指向int 3之后,已经执行它了。
3. 允许用户以某种方式与进程交互,因为进程仍然暂停在目标地址。这时你的调试器可以让你窥探变量的值,调用栈等等。
4. 在用户希望保存运行时,调试器将负责将断点放回目标地址(因为它在第一步被移走了),除非用户要求删除这个断点。
让我们看一下这些步骤中的某些如何被翻译为真实的代码。我们将使用第一部分中出示的调试器“template”(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 at0x%08x: 0x%08x\n", addr, data);
这里调试器从被追踪进程获取指令指针,并检查当前在0x8048096的内存字。在追踪列出在文章开头的汇编程序时,打印:
[13028] Child started.EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba
目前为止,很好。接着:
/* Write the trap instruction 'int 3' into the address */
unsigneddata_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);
/* See what's there again... */
unsignedreadback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, dataat 0x%08x: 0x%08x\n", addr,readback_data);
注意int 3是如何被插入目标地址的。这打印:
[13028] After trap, dataat 0x08048096: 0x000007cc
再次,如期望的——0xbc被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 atEIP = 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。本文后面展示的调试库实现了这个功能。
更多关于int3
现在是时候回来查看int3以及intel手册里有趣的注解。这里又是一个:
这个单字节形式是有价值的,因为它可以用于替换断点上任何指令的第一个字节,包括其他单字节指令,无需改写其他代码。
在x86上int指令占据2个字节——0xcd跟在中断号后[6]。Int 3可以被编码为cd03,但为它保留了一个特殊的单字节指令——0xcc。
为什么这样?因为这允许我们插入一个断点而无需改写多条指令。这是重要的。考虑这个例子:
.. some code ..
jz foo
dec eax
foo:
call bar
.. some code ..
假设我们希望在deceax上放置一个断点。这正好是一个单字节指令(操作码0x48)。如果替换的断点指令长于1字节,我们将被迫改写下一条指令(call)的部分,这将篡改它,可能生成完全无效的东西。如果执行分支jzfoo会怎么样?没有停在deceax,CPU直接执行之后的无效指令。
让int 3有一个特殊的1字节编码解决了这个问题。因为1字节是x86上指令的最短形式,我们能保证只有我们想中断的指令被改变。
血淋淋细节的封装
在前一节展示的代码例子的许多底层细节可以容易地封装在一个便利的API里。我已经在一个小的称为debuglib应用库里进行了部分封装——在文章末尾可以下载它的代码。这里我只想用一个例子展示它的用法,但有点不一样。我们将追踪一个以C编写的程序。
追踪一个C程序
到目前为止,出于简单的目的,我关注在汇编语言写的追踪目标。是时候上一级看一下如何追踪以C编写的程序。
结果是事情没有特别不同——只是找出在哪里放置断点更难一些。考虑这个简单的程序:
#include <stdio.h>
voiddo_stuff()
{
printf("Hello, ");
}
intmain()
{
for (int i = 0; i < 4; ++i)
do_stuff();
printf("world!\n");
return0;
}
假定我想在do_stuff的入口放置一个断点。我将使用老朋友objdump来反汇编这个可执行文件,但这次里面有很多东西。特别的,看代码节有点不管用,因为它包含了许多我目前不感兴趣的C运行时初始化代码。这样让我们在输出里找出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库来制作这个例子。下面是完整的调试器方法:
voidrun_debugger(pid_tchild_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 untilit either exits or hits the
** breakpointagain.
*/
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;
}
elseif (rc == 1) {
continue;
}
else {
procmsg("unexpected: %d\n",rc);
break;
}
}
cleanup_breakpoint(bp);
}
不是自己不辞辛苦地修改EIP以及目标进程的内存空间,我们只需要使用create_breakpoint,resume_from_breakpoint以及cleanup_breakpoint。让我们看一下在追踪上面展示的简单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——首先展示在本文的“手动”设置断点的方式。
· Bp_use_lib.c——在大部分代码里使用debuglib,就像追踪循环中C程序的第二个代码例子那样。
结论与下一步
我们已经讨论了如何在调试器里实现断点。尽管实现细节依OS有所不同,当你在x86时,它基本上是同一个主题的变奏——将我们希望处理而停止的指令替换为int3。
也就是说,我确认某些读者,就像我,对于需要指出要暂停的内存地址,不是那么激动。我们更想说“暂停在do_stuff”,或甚至是“暂停在do_stuff的这一行”,并让调试器执行。在下一篇文章我将展示这如何做到。
参考
在准备这篇文章期间,我发现以下资源与文章是有帮助的:
· How debugger works
· UnderstandingELF using readelf and objdump
· Implementingbreakpoints on x86 Linux
· NASMmanual
· SOdiscussion of the ELF entry point
· This Hacker News discussion系列的第一部分
· GDBInternals
[1] 从高层次上看这是对的。下到残酷的细节,现在许多CPU并行执行多条指令,一些指令的顺序被打乱。
[2] 这里所说的圣经,当然是Intel的Architecturesoftware developer手册,卷2A。
[3] OS如何可以像这样暂停一个进程?OS向CPU注册了自己的int 3处理例程,就这样!
[4] 等一下,又是int?是的!Linux使用int 0x80来实现从用户进程进入OS内核的系统调用。用户将系统调用号及参数放入寄存器并执行int 0x80。然后CPU跳转到合适的中断处理例程,其中OS注册了一个查看这些寄存器并决定执行哪个系统调用的方法。
[5] ELF (Executable and LinkableFormat) 是Linux对目标文件、共享库及可执行文件使用的格式。
[6] 细心的读者可以在上面的输出列表里看到int 0x80被翻译为cd 80。