调试器如何工作(2)

调试器如何工作:第二部分——断点

原作者: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, &regs);

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, &regs);

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, &regs);

/* 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 <[email protected]>

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。

时间: 2024-10-28 06:54:05

调试器如何工作(2)的相关文章

调试器如何工作(3)

调试器如何工作:第三部分--调试信息 原作者:Eli Bendersky http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information 这是关于调试器如何工作系列文章的第三部分.在这之前确保你读过第一.第二部分. 在这部分 我将解释调试器如何明白,在它跋涉机器代码里,在哪里找到C函数与变量,以及它用来在C源代码与机器语言内存字间进行映射的数据. 调试信息 现代编译器在翻译高级语言代码

Visual Studio 2017 调试器的工作进程(msvsmon.exe)意外退出 调试将终止

原文:Visual Studio 2017 调试器的工作进程(msvsmon.exe)意外退出 调试将终止 开发环境: Windows 10 in Parallels Desktop Visual Studio 15.6 场景还原: 使用 Visual Studio 15.6 (即 Visual Studio 2017) 新建 ASP.NET Core MVC 项目,启动调试,提示: 调试器的工作进程(msvsmon.exe)意外退出.调试将终止 其实这个问题曾经多次遇到过,但是都没做记录,导致

与调试器共舞 - LLDB 的华尔兹

你是否曾经苦恼于理解你的代码,而去尝试打印一个变量的值? 1 NSLog(@"%@", whatIsInsideThisThing); 或者跳过一个函数调用来简化程序的行为? 1 NSNumber *n = @7; // 实际应该调用这个函数:Foo(); 或者短路一个逻辑检查? 1 if (1 || theBooleanAtStake) { ... } 或者伪造一个函数实现? 1 2 3 4 5 6 int calculateTheTrickyValue {   return 9;

[Win32]一个调试器的实现(一)调试事件与调试循环

[Win32]一个调试器的实现(一)调试事件与调试循环 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 前言 程序员离不开调试器,它可以动态显示程序的执行过程,对于解决程序问题有极大的帮助.如果你和我一样对调试器的工作原理很感兴趣,那么这一系列文章很适合你,这些文章记录了我开发一个调试器雏形的过程,希望对你有帮助.或许我

调试器工作原理

调试器工作原理(3):调试信息 本文是调试器工作原理探究系列的第三篇,在阅读前请先确保已经读过本系列的第一和第二篇. 本篇主要内容 在本文中我将向大家解释关于调试器是如何在机器码中寻找C函数以及变量的,以及调试器使用了何种数据能够在C源代码的行号和机器码中来回映射. 调试信息 现代的编译器在转换高级语言程序代码上做得十分出色,能够将源代码中漂亮的缩进.嵌套的控制结构以及任意类型的变量全都转化为一长串的比特流--这就是机器码.这么做的唯一目的就是希望程序能在目标CPU上尽可能快的运行.大多数的C代

[连载]《C#通讯(串口和网络)框架的设计与实现》- 11.调试器的设计

目       录 第十一章     调试器设计... 2 11.1         调试接口... 2 11.2         界面方式调试... 3 11.3         命令行方式调试... 5 11.4         小结... 6 第十一章      调试器设计 SuperIO 框架平台设计.开发完毕后,想把代码编译成程序集(DLL),二次开发都通过引用DLL实现接口.继承类库来实现驱动和插件的开发,SuperIO框架的代码不会轻易去改变.这是框架设计最终要达到的效果,但是在二

【转】浅谈LLDB调试器

随着Xcode 5的发布,LLDB调试器已经取代了GDB,成为了Xcode工程中默认的调试器.它与LLVM编译器一起,带给我们更丰富的流程控制和数据检测的调试功能.LLDB为Xcode提供了底层调试环境,其中包括内嵌在Xcode IDE中的位于调试区域的控制面板,在这里我们可以直接调用LLDB命令.如图1所示: 图1:位于Xcode调试区域的控制台 在本文中,我们主要整理一下LLDB调试器提供给我们的调试命令,更详细的内容可以查看The LLDB Debugger. LLDB命令结构 在使用LL

Java类加载器的工作原理

Java类加载器的作用就是在运行时加载类.Java类加载器基于三个机制:委托.可见性和单一性.委托机制是指将加载一个类的请求交给父类加载 器,如果这个父类加载器不能够找到或者加载这个类,那么再加载它.可见性的原理是子类的加载器可以看见所有的父类加载器加载的类,而父类加载器看不到子类 加载器加载的类.单一性原理是指仅加载一个类一次,这是由委托机制确保子类加载器不会再次加载父类加载器加载过的类.正确理解类加载器能够帮你解决 NoClassDefFoundError和java.lang.ClassNo

《python灰帽子》学习笔记:调试器设置

一.构造 C  数据类型 C Type | Python Type | ctypes Type _______________________________________________________________________________________ char | 1-character | string c_char wchar_t | 1-character Unicode | string c_wchar char | int/long | c_byte char |