所有写过比hello world程序更复杂的人都应该在某些地方用到过调试器(如果没有用过,赶紧放下手头的工作去学习使用调试器!!)。尽管这些工具被如此广泛的使用,但是鲜有资料来介绍它们是如何工作的以及如何自己动手来写一个这样的工具,特别是相较于编译器这样的工具链以及技术体系来讲。在这个系列中,我们将会学习一些调试器的技巧以及实现一个能够调试Linux程序的调试器。
我们的调试器将具备以下功能:
在最后,我们将会列出一个功能大纲,以供你添加到自己的调试器中:
在此项目中,我将专注于C和C++,但这些知识对于可以编译到机器代码和输出标准DWARF调试信息(如果你目前还不了解这是写什么东西,不要担心,后边会一一揭晓)的任何语言都应该是适用的。此外,我的重点将在于调试器中的一些事情,以及大多数时间运行的部分,因此,为了简单化,像强大的错误处理这样的事情并不会涉及到。
以下的链接将会随着其他帖子的发布而上线:
在我们投入工作以前,让我们先准备好我们的环境。在此教程中,我将依赖于2个组件:Linenoise,用来处理我们的命令行输入;libelfin用来解析调试信息。你可以使用更加传统的libdwarf来替代libefin,但是libdwarf的界面基本已经见不到了,并且libefin提供了一个几乎完整的DWARF 表达式计算器,这个计算器将会在你读取变量信息的时候节省大量的时间。 请确保使用了我fork的libelfin的fbreg分支,该分支在某些地方做了一些hack,用以支持在x86平台上读取变量。
一旦在系统中装完这些库,或者其他的你喜欢的任何支持库,那么是时候来开工了。我使用CMake文件来让这些库和我的其他代码一起编译。
在我们正式调试一些东西以前,我们需要运行这个调试程序。让我们用经典的fork/exec模式来完成这个过程。
调用fork
会使我们的程序分裂成2个进程。在子进程中fork
返回0
,而在父进程中将会返回子进程的进程ID。
在子进程中,我们要用我们要调试的程序替换我们当前执行的任何东西。
在这里,我们第一次遇到了ptrace
这个函数,对于编写一个调试来讲,这个函数可是大有帮助。ptrace
允许我们通过读取寄存器,读取内存,单步以及其他的一些功能来观察和控制另一个进程的执行。它的API非常丑陋,这是一个单一的函数,提供了一个你想要做的事情的枚举值,剩下的参数根据你给定的参数被使用或者被忽略。它看起来就像这样:
request
参数用来说明要对我们想要调试程序执行何种操作;pid
是被调试进程的PID;addr
是在某些调试环节中需要用到的内存地址;data
是特定请求的资源。ptrace的返回值一般会给出错误信息,因此你可能需要在你的代码中来检查实际的返回值;我为了简洁,所以忽略了。更多的信息可以参考man page。
之前的代码中,我们发送的请求PTRACE_TRACEME
指明了该进程应该允许被父进程调试。其他所有的参数都被忽略,因为API设计不重要(译注:/嘲讽脸)。
接下来,调用exec
的诸多形式之一的execl
函数。执行指定的程序,将它的名称作为命令行参数,用nullptr
来终止参数列表。如果需要,此处可以传进任何参数。
完成这些之后,我们的子进程的工作就完成了;就让它一直跑着吧,等待我们对它调试工作的完成。
现在子进程已经跑起来了,我们需要能够和它进行交互。为了达成这个目的,我们需要创建一个debugger
类,在类中创建一个循环来监听我们的输入,然后从我们的父进程的main
函数中运行。
在代码中的run
函数中,我需们要一直等待,直到子进程运行完成,然后一直从linenoise
函数获取输入直到接收到EOF(CTRL+D)为止。
当被调试的进程运行起来之后,将会收到一个SIGTRAP
信号,表明了这是trace事件或者断点。我们可以一直等待直到waitpid
发出这个信号。
当被调试进程已经准备就绪时,就可以监听我们的输入了。linenoise
函数自动显示提示和处理用户的输入。这意味着我们不需要有太多的工作就得到了一个很好的历史命令以及命令导航。当获取输入之后,将输入传递给handle_command
这个短小的函数,然后将命令加入到命令行历史中,释放资源。
我们的命令将会和dbg和lldb有相似的格式。用户输入continue
或者cont
甚至只是一个c
来是程序继续运行。如果用户项在某个地址设置一个断点,那么就需要输入break 0xDEADFEEF
,break
之后的地址0xDEADFEEF
需要同16进制来指明。让我们加入这些命令吧。
split
和is_prefix
是一对小辅助函数:
在debugger类中加入continue_execution
:
现在,continue_execution
函数就会使用ptrace
来通知进程继续执行,然后,waitpid
会阻塞直到收到相应的信号。
此刻,应该就可以来编译一些些C、C++代码然后跑在你自己的调试器里了,就能看看它在入口处停下,然后从调试器中继续执行。接下来的部分我们将会学习如何使用我们的调试器来下断点。如果有任何疑问,请在评论中告诉我!
可以在此处找到这篇文章的代码。
注1:如果您想要其他资源,这里有一些之前就存在的资源:1 2 3 4
有两种主要的断点:硬件断点和软件断点。相较于软件断点需要修改正在运行的代码产生,硬件断点通常通过设置特定架构的寄存器来产生。在此系列文章中,我们仅仅涉及软件断点,因为软件断点比较简单,并且可以设置任意个数。在x86平台上,在任意时刻最多只能设置4个硬件断点,但是你可以控制指定地址的断点类型是读或是写,而不是仅仅是执行断点。
上文中提到了软件断点就是修改正在执行的代码,问题来了:
对于第一个问题,毋庸置疑,就是ptrace了。之前我们使用它来设置进程的调试环境然后让程序继续执行,同样的,我们可以使用它来读写内存。
当执行到断点处也就是我们做修改的地址的时候,处理器会暂停程序的运行然后通知调试器。在x86架构上,这个步骤是通过在指定地址处写入int 3
指令来完成的。x86架构有一个中断向量表,操作系统可以向该表注册处理例程来处理多种事件,比如说分页错误,保护错误,非法指令等。它有点像注册的错误处理函数,但是却是运行在硬件环境下。当处理器运行int 3
指令的时候,程序的控制权就转交给了调试器,在Linux下,调试器会收到SIGTRAP
信号。一下的流程图显示了将代码中mov
指令的第一个字节修改为0xCC
,也就是int 3
的机器码。
最后一个问题是调试器是如何收到断点的通知的。如果你还记得之前的文章的话,我们可以使用waitpid
来监听发送到调试器的信号。在这里也可以这样做:设置断点,继续程序,调用waitpid
然后阻塞一直等到收到SIGTRAP
信号为止。然后可以将该断点传达给 用户,比如输出当前运行到的源码的位置,或者在一个GUI界面的调试器中更改当前停下的这一行。
我们将一个breakpoint
类来表示某个位置的断点,这样,我们就可以在需要的时候更改断点的状态,有效还是无效。
这个类大部分只是用来追踪状态;真正有用的地方发生在enable
函数和disable
函数中。
正如我们之前所了解的,需要将指定的地址的指令替换成int 3
,也就是0xCC
。当然,我们也想将那个地址的值保存一下,以便之后可以重新恢复;忘记执行用户的代码可不是我们想要的结果!
`ptrace
中PTRACE_PEEKDATA
参数指明了如何读取被调试进程的内存。给这个函数一个PID和一个内存地址,之后它就会返回这个地址一个64位的数据。(m_saved_data & ~0xff)
将该数据的最低字节置零,然后将int 3
和该数据进行位或|
来设置断点。最终,通过传入PTRACE_POKEDATA
来将新的数据写入之前读入的地址。
disable
实现起来就简单多了,只需写入被0xCC
替换的原始的数据即可。
接下来对我们的debugger类做三个修改,以便支持通过我们的接口来设置断点。
我将会在std::unordered_map<std::intptr_t, breakpoint>
结构体中存储断点,这样就很容易迅速的检测指定地址是否有一个断点,如果需要检测的话,只需在breakpoint对象中检索了。
set_breakpoint_at_address
函数中,将会创建一个心得断点,然后使其有效,再加入保存断点的数据结构中,然后向用户输出信息。如果你喜欢的话,你可以考虑打印所有的消息,将调试器作为一个库和命令行工具来使用,我只不过是为了简便而把它们混合在一起了。
现在我们将增加我们的命令处理程序来调用我们的新函数。
我在结果上只是删除了字符串的前两个字符,并调用了std :: stol
,当然你想要的话,可以使解析过程更加健壮一些。std::stol
可以指定基数转换,这使得十六进制读取变的更加简便。
如果你已经尝试过了,可能会发现如果继续从断点处执行,没有任何效果。这是因为断点还在内存中呢,于是再一次的命中了。一个简便的做法是禁止掉断点,单步,然后重新使断点有效,继续执行即可。不幸的是,我们同样需要修改EIP到断点之前,暂时放下这个问题,在下一篇文章中我们将会学习如何控制寄存器。
当然了,如果在一些地址上设置断点可能是无效的如果你不知道这个地址是什么的话。在将来,我们的调试器将会具备在函数名称上设置断点,或者在源代码行数上设置断点,现在,我们可以手动来完成这点。
一个简单的测试我们的调试器的方法是写一个hello world程序,该程序向std::cerr
(避免缓冲)输出,然后在调用输出操作的地方下断点。如果继续调试,那么期望中的执行将会停止而不输出任何东西。这时可以重新开始调试,在函数调用之后再下断点,这是就能看见消息被成功的输出了。
可以使用objdump
来找到我们需要的地址。如果打开一个终端然后执行objdump -d <your program>
命令,应该可以看见代码的反汇编。看见反汇编之后,就能找到main
函数并且确定下断点的call
指令的位置。举个例子,下边是我写的hello world程序,反汇编之后得到了main
函数:
可以看见,应该是在0x400944
处设置断点没有输出,在0x400949
处设置断点有输出。
现在,你应该有了一个具备运行程序并且允许用户在内存地址上设置软件断点的调试器。下一次,我们将增加读写内存和寄存器的功能。当然,可以在评论中留下你的问题!
你可以在这里找到本篇文章的代码。
上一篇文章中,我们在调试器中加入了简单的地址断点。这一次,我们将给调试器加入读写寄存器和内存的功能,这样就可以在控制RIP,观察程序的状态,以及改变程序的行为了。
在我们正真的读取寄存器前,调试器需要知道一些关于x8664架构的相关知识。包括通用寄存器,专用寄存器以及浮点寄存器和向量寄存器。为了简单期间,我将省略后两者(浮点以及向量寄存器),当然如果你喜欢的话你可以选择去加入相关支持。x86_64架构也允许你用32,16或者8位的方式来访问64位寄存器,但是我将会一直使用64位的。由于简化了一些东西,所以对寄存器来说,我们只需要知道它的名字以及它在DWARF中的寄存器号,以及它被存储在ptrace
返回的结构中什么位置就可以了。我选择用一个枚举来引用寄存器,然后来构建一个和ptrace
中的寄存器结构顺序相同的全局寄存器描述符数组。
一般你可以在/usr/include/sys/user.h
找到关于寄存器相关的数据结构。如果你想自己去查看一番,DWARF寄存器号是根据System V x86_64 ABI这个规范来设置的。
现在,就可以写一大堆函数来与寄存器交互了。我们希望能够通过DWARF寄存器号来读取,写入,接收寄存器的值,并且可以通过命长来查找寄存器或者通过寄存器来查找名称。让我们从声明get_register_value
函数开始吧:
同样的,ptrace
给了我们一种简单的访问我们想要的数据的方式。只需构建一个user_regs_struct
实例,然后和PTRACE_GETREGS
请求一起传给ptrace
即可。
现在,我们想根据被请求的寄存器读取regs
。可以通过写一个繁杂的switch case结构,但是由于我们已经构建了g_register_descriptors
这个表,表中的寄存器顺序和user_regs_struct
完全一致,于是就可以通过索引来查找寄存器描述符,并且以uint64_t
数组的方式来访问user_regs_struct
。
转换到uint_64_t
是安全的,因为user_regs_struct
是标准的布局类型,但是我认为指针在算数运算上是unsigned byte(译注:实际上是signed byte,参考内核地址高20(intel架构)位全被置1)。现有编译器甚至对此没有警告,我比较懒,也不想多花心思了,但是如果你想保持最大可能的正确性就需要一个大的switch case了。
set_register_value
也是一样的,我仅仅是写到相应位置,然后在最后写回寄存器:
接下来就是通过DWARF寄存器号来查找相应的值了。这一次我会检查一个错误条件,以防万得到一些奇怪的DWARF信息:
差不多完成了,现在我们就有了下边看起来这样的寄存器值了:
最后,加一些简单的辅助函数来dump寄存器的内容:
如你所见,iostreams有一个非常简洁的接口,可以很好地输出十六进制数据。如果你喜欢,可以封装一些IO操作来避免混乱。
这些就足够支持我们在调试器其它部分处理寄存器了,现在,可以将其添加到UI中去了。
我们需要做的就是将一个新的命令加入到handle_command
函数中。在下边的代码示意中,用户可以通过输入register read rax
或者register write rax 0x42
以及其他的命令来操纵寄存器。
在设置断点时,我们已经读取和写入内存,所以只需要添加一些函数来封装一下ptrace调用。
你可能希望一次添加对读取和写入大于WORD(16位)型数据的支持,只需通过在每次要读取另一个WORD时递增地址即可。同时也可以使用process_vm_readv
和process_vm_writev
或者使用/proc/<pid>/mem
来替代ptrace
。
现在,为我们的UI加入相关命令:
110/5000
您是不是要找: Before we test out our changes, we’re now in a position to implement a more sane version of continue execution)
在测试更改之前,我们现在可以执行一个更加正确的版本的continue_execution
。因为可以获取RIP,所以只需检查我们的断点保存结构来确定是否运行到了一个断点的位置。如果是,先禁止断点然后在继续运行前步过一次。
首先,为了清晰简洁,先添加几个辅助函数:
然后,可以写一个步过断点的函数:
首先,检查此刻RIP所处的位置是不是被设置了断点,如果是,将RIP后退一个字节(译注:0xCC断点触发时0xCC本身已经被执行过了,所以停下的位置和下断点的位置差了一个字节,需要将RIP回拨一个字节),禁用断点(译注:将原始的指令数据写回来),单步步过此处原来的指令,然后重新设置断点(译注:再将0xCC写回去)R
wait_for_signal
函数将封装一些常用的waitpid
模式:
最后,重新写的continue_execution
就像这样:
现在我们可以读取和修改寄存器,hello world程序于是就可以有一些乐子了。首先来测试一下在call指令上下断点,然后从断点处继续运行吧。应该可以看见Hello world
已经被输出。乐子来了,在输出的那个call后边下一个断点,继续运行,然后将设置调用参数的代码的地址写入RIP并继续。你应该可以看见由于RIP被改变Hello world
被输出了两次。以防你不知道在哪里设置断点,下边我给出我的objdump
:
你需要将RIP移回到0x40093a
,以便对esi
和edi
进行正确的赋值。
在下一篇文章中,我们将会首次探索一下DWARF信息,以及向调试器加入几种单步操作。之后,我们将有一个具备大部分功能的工具,可以通过代码来单步,设置断点到想要的地方去,修改数据以及更多功能。有问题,尽管在回复区提问!
到目前为止,可能你已经听到了关于调试信息或者关于除了解析代码以外的理解源代码的方法的DWARF的只言片语。今天,我们将介绍源代码级的调试信息的细节,以备在该系列的余下部分使用它。
ELF和DWARF可能是在程序员日常生活中经常使用但是可能却没有听说过的两个部件。ELF(Executable and Linkable Format)是Linux世界最广泛中使用的一种Object File Format;它指定了一种将各部分数据存储在二进制文件的方式,比如说代码,静态数据,调试信息,以及一些字符串等这些数据。同时,也告诉加载器以何种方式对待二进制文件以及准备好执行,这涉及到将二进制文件的不同部分加载到内存中,以及根据其他一些组件的位置来修复(重定位)相关的数据位等等。我不会在文章中包含太多的ELF相关的知识,但是如果感兴趣的话你可以看一下这个精彩的图表或者这个ELF标准文档。
DWARF是ELF文件通常使用的调试信息格式。通常来讲DWARF对ELF来说并不是必须的,但是这两者是被串联开发在一起的,并且一起使用非常好。这个格式允许编译器告诉调试器源代码是如何与被执行的二进制文件相关的。调试信息被分割在ELF不同的区段中,每一部分都传达了本区块的相关信息。一下是一些预定义的一些区段,如果信息过时的话,可以从这里获取最新信息,DWARF调试信息简介:
我们最感兴趣的是.debug_line
和.debug_info
区段,所以让我们用一个简单的程序来看一下一些DWARF信息吧:
如果在编译程序的时候指定了-g
选项,然后通过dwarfdump
运行结果,应该类似以下信息的行号区段:
开始的一大串信息是关于如何理解dump的一些说明,主行号信息从0x00400770
这行开始。本质上,它映射了代码内存地址和在文件中的行和列信息。NS
表示该地址标志着新语句的开始,这通常用于设置断点或单步。PE
标志着函数头部的结束,这有助于设置函数入口断点。ET
标示该映射块的结尾。信息实际上并不是像这样编码,实际的编码是一种非常节省空间的程序,由它来建立这些行号信息。
那么,如果我们想在variable.cpp中的第4行下一个断点,应该怎么做呢? 查找与该文件相对应的条目,然后找到相关的行号,找到相关的地址,然后设置一个断点就可以了。在我们的小程序中,就是这一条:
所以我们需要在0x00400686
地址处设置一个断点。如果你想尝试一下,你可以用你已经写过的调试器手工完成。
相反的工作也是如此,如果我们有一个内存位置 - 比如一个RIP,并且想要找出它在源代码中的哪个位置,只需在行号信息表中找到最接近的映射地址,并从中获取行号即可。
.debug_info
是DWARF的核心所在。它给了我们程序中存在的关于类型,功能,变量,希望和梦想的信息。该区段的基本单位是DWARF信息入口,也就是被亲切地称为DIE的东西。DIE包含一个标签,告诉你代表什么样的源代码级的条目,后面是一系列适用于该条目的属性。以下是之前的那个简单程序的.debug_info
:
第一个DIE表示一个编译单元(CU),它本质上是一个源文件,其中包含所有#include
并且被解析的包含文件。以下是它们的包含注释的属性:
其他DIE遵循类似的方案,你可以直观地看出不同属性的含义。
现在我们可以尝试使用我们新发现的DWARF知识来解决一些实际问题。
比如说我们有一个RIP,并想弄清楚我们处在那个函数中。一个简单的算法是:
这可以用于大多数目标,但是在成员函数和内联存在的情况下,事情会变得更加困难。例如,存在内联的情况下,一旦我们发现某个函数范围包含了RIP,需要对该DIE的子条目进行递归,以查看是否有任何更匹配的内联函数。我不会在这个调试器的代码中处理内联,但是如果你喜欢,你可以添加对它的支持。
同样的,这取决于是否要支持成员函数,命名空间等。对于单独的函数,你可以在不同的编译单元中的函数中迭代查找,直到找到具有正确名称的函数。如果你的编译器足够友好的填写了.debug_pubnames
部分,则可以更有效地做到这一点。
一旦找到该函数,就可以在给定的内存地址DW_AT_low_pc
上设置断点。但是,这将会在在函数头部开始时中断,最好在用户代码开始时中断。由于行表信息可以指定指定函数头部结束的内存地址,因此可以直接在行表中查找DW_AT_low_pc
的值,然后继续读取,直到找到标记为函数头部结尾的条目。有些编译器不会输出这个信息,所以另外一个选择是在该函数的第二行条目给出的地址上设置一个断点。
假设我们要在示例程序中的main
设置一个断点。我们搜索main
函数,并得到这个DIE:
这告诉我们,函数从0x00400670
开始。如果我们在行号表中查看,我们得到这个条目:
我们想跳过函数头部,所以我们读取下一个条目:
Clang在这个条目中包含了头部结尾标志,所以我们知道在这里停下来,并在地址0x00400676
上设置一个断点。
读取变量可能非常复杂。它们是可以在整个函数中变化的难以捉摸的东西,存储在寄存器中,放在内存中,被优化,被隐藏在角落里,等等等等乱七八糟。还好,我们简单的例子确实很简单。如果我们想要读取变量a
的内容,则需要查看一下它的DW_AT_location
属性。
reg6
在x86架构上是RBP,由System V x86_64 ABI指定。现在我们读取RBP的内容,从中减去8,就找到了我们的变量。如果我们想实际上的理解这个变量,还需要查看它的类型:
如果在调试信息中查找这种类型,我们得到这个DIE:
这告诉我们,该类型是一个8字节(64位)有符号整数类型,因此我们可以直接将这些字节解释为int64_t
并将其显示给用户。
当然,这些类型可能会比这更复杂,因为它们必须能够表达类似于C ++类型的东西,但是这给出了它们如何工作的基本思想。
暂时回到RBP,Clang可以很好地根据RBP来追踪帧基址。最近版本的GCC更倾向于DW_OP_call_frame_cfa
,它涉及解析.eh_frame ELF
部分,这是一个完全不同的文章,我并不打算写。如果你告诉GCC使用DWARF 2而不是更新的版本,它会倾向于输出位置列表,这更容易阅读:
位置列表根据RIP给出不同的位置。这个例子展示了如果RIP位于距DW_AT_low_pc
的0x0
偏移的位置,那么帧基址距离寄存器7中存储的值的偏移量为8,如果它位于0x1
和0x4
之间,那么它距离寄存器7中存储的值偏移为16,等等。
这么多信息会让你的头脑晕晕乎乎,但好消息是,在接下来的几篇文章中,我们将有一个库来为我们完成这些艰难的工作。理解实际操作中的内容,特别是在出现问题时,或者你希望支持一些DWARF内容(在使用的任何DWARF库中未实现)时仍然有用。
如果你想了解有关DWARF的更多信息,那么可以从这里获取相关标准。在撰写本文时,DWARF 5刚刚被发布,但是DWARF 4更受欢迎。
在之前的几部分中我们学习了关于DWARF信息以及这些信息是如何在被执行的机器码和高级语言之间建立起联系的。在这部分中,我们将实现一些能够被调试器使用的DWARF相关原语。我们还将借此机会让调试器在命中断点之时输出当前源代码的上下文信息。
正如在再还系列的开始时所提到的,我们将会使用libelfin
来处理DWARF信息。希望你在我的第一篇文章时就已经得到了该工具,如果没有的话,你可使用我从仓库fork出的fbreg
分支。
一旦弄好了libelfin
,就是时候把它加入到我们的调试器中了。第一步,解析ELF可执行文件并且从中获取DWARF信息。使用libelfin
来完成这一步是非常简单的,仅仅需要对调试器做如下的改变:
这里我采取了一个比较笨拙的方法,只需遍历编译单元,直到知道到包含RIP的代码,然后一直迭代,直到在子节点中找到相关函数(DW_TAG_subprogram
)。正如在上篇提到的,你可以想成员函数一样来处理这些,如果你想的话你还可以使用内联。 接下来是get_line_entry_from_pc
:
同样的,我们只需找到正确的便宜单元,然后请求行列表来获取相关条目。
当命中断点的时候或者在源码上单步的时候,我们需要知道源代码被执行到哪里了。
现在,可以输出源码了,只需要将其挂载到我们的调试器中。当调试器从断点或者(实际上)但不中获取信号的时候是显示源码的上好时机了。这样做的话,调试器就需要一个更好的信号处理了。
我们希望能够输出什么样的信号被发送给了进程,同时亦希望知道该信号是如何被产生的。例如,我们想知道收到的SIGTRAP
信号是由于命中断点还是一个单步执行完产生的,亦或者是由于新线程建立而产生的,等等。 幸运的是,ptrace
再一次支援了我们。ptrace
有一个参数PTRACE_GETSIGINFO
,该参数将会给出进程之前发出的信号的相关信息。如下:
这里出现了一个siginfo_t
的对象,它提供了如下的信息:
我将使用si——signo
来找出是哪一个信号被发送,然后使用si_code
来获取有关该信号的更多信息。放置该段代码的最佳地方是在我们的wait_for_signal
函数中:
现在处理SIGTRAP
只需知道SI_KERNEL
或者TRAP_BPKPT
将会在断点命中时被发送,TRAP_TRACE
将会在单步完成的时候被发送:
你可以处理一堆不同风格的信号。详情请参阅man sigaction
。 由于我们现在在得到SIGTRAP
时修正RIP,所以可以去掉step_over_breakpoint
中的部分代码:
现在,你应该可以在某些地址设置断点,运行程序,查看鼠标标记的正在被执行的代码的源代码了。
下一次我们将添加源码级的断点。可以在此处获取源码
之前的几篇文章中,我们了解了DWARF信息,以及如何让机器码与高级代码相关联。本篇文章中,我们将会通过向调试器添加源码级单步而把这些知识付诸实践。
我们正在超越我们自己!首先,让我们在用户界面上显示源码级单步。我决定将其拆分成可以使得其他代码使用的single_step_instruction
和一个single_step_instruction_with_break_check
,以确保任何断点都可以被禁用或启用。
像以前一样,我们的函数handle_command
函数加入了另一个命令:
添加完这些功能之后,我们可以开始实现源级单步的功能。
让我们先来个简单的版本,但是真正的调试器往往倾向于使用线程计划来封装所有的单步信息。举例,调试器可能会有一些非常复杂的逻辑来决定断点位置,然后使用一些回调函数来决定单步操作是否已经被完成。这要求许多底层构建的完整程度,因此,我们仅仅是采用一种可能比较简单的方法。最后我可能会意外的步过了某些断点,但是如果你愿意,你可以多花点时间,来完善这些细节。
对于step_out
(步出),我们仅仅是在函数返回地址设置一个断点然后使之继续执行。我还没有对栈展开的细节做深入的研究-这部分将在出现在之后的文章中-但是现在可以说的是,返回地址是存储在栈帧之后的一个8字节长的值。所以,只需读取栈帧指针然后在相关地址读取一个字即可(注:作者可能将数据总线大小称为一个字):
下边的step_in
。一个简单的算法是一直步过指令直到我们到达源码新的一行。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)