当前位置: 首页 > 科技观察

开发Linux调试器(第2部分):断点

时间:2023-03-20 12:10:22 科技观察

在本系列的第1部分中,我们编写了一个小型进程启动器作为调试器的基础。在此博客中,我们将了解断点在x86Linux上的工作原理,以及如何将设置断点的功能添加到我们的工具中。系列文章索引这些链接会随着后续文章的发布而逐渐生效。准备环境断点寄存器和内存精灵和矮人源代码和信号源代码级步进源代码级断点调用堆栈读取变量10.步后断点是如何形成的?有两种类型的断点:硬件和软件。硬件断点通常涉及设置依赖于体系结构的寄存器来为您生成断点,而软件断点涉及修改正在执行的代码。在本文中,我们将只关注软件断点,因为它们相对简单,您可以根据需要设置任意数量。在x86机器上任何时候最多只能有4个硬件断点,但它们允许您在读取或写入给定地址时触发,而不仅仅是在代码执行到那里时触发。前面说了软件断点是通过修改正在执行的代码来实现的,那么问题来了:我们如何修改代码呢?为了设置断点,我们需要做哪些修改?我们如何告诉调试器?第一个问题的答案很明显是ptrace。我们之前在我们的程序中使用它来设置跟踪并继续执行程序,但我们也可以使用它来读取或写入内存。我们的更改是让处理器在执行遇到断点时暂停并向程序发出信号。在x86机器上,这是通过int3覆盖该地址处的指令来实现的。x86机器有一个中断向量表,操作系统可以使用它来注册各种事件的处理程序,例如页面错误、保护错误和无效操作码。这就像注册错误处理回调,但在硬件级别。当处理器执行int3指令时,控制被传递给断点中断处理器,对于Linux,它向进程发送SIGTRAP信号。你可以在下图中看到这个过程,我们用0xcc覆盖了mov指令的第一个字节,这是init3的指令代码。断点拼图的最后一块是告诉调试器如何中断。如果您还记得上一篇文章,我们可以使用waitpid来监听发送到被调试程序的信号。这里我们也可以做同样的事情:设置断点,继续程序执行,调用waitpid等待SIGTRAP发生。然后可以通过打印已运行到的源代码位置,或通过使用图形用户界面更改调试器中关注的代码行,将此断点传达给用户。实现软件断点我们将实现一个断点类来表示某个位置的断点,我们可以根据需要启用或禁用断点。classbreakpoint{public:breakpoint(pid_tpid,std::intptr_taddr):m_pid{pid},m_addr{addr},m_enabled{false},m_saved_data{}{}voidenable();voiddisable();autois_enabled()const->bool{returnm_enabled;}autoget_address()const->std::intptr_t{returnm_addr;}private:pid_tm_pid;std::intptr_tm_addr;boolm_enabled;uint64_tm_saved_data;//datawhichusedtobeatthebreakpointaddress};这里的大部分代码都是跟踪状态;真正的魔法是启用和禁用功能。正如我们在上面了解到的,我们将用一个编码为0xcc的int3指令替换指定地址处的当前指令。我们还想保存该地址的先前值,以便稍后恢复该代码;我们不想忘记执行用户的(原始)代码。voidbreakpoint::enable(){m_saved_data=ptrace(PTRACE_PEEKDATA,m_pid,m_addr,nullptr);uint64_tint3=0xcc;uint64_tdata_with_int3=((m_saved_data&~0xff)|int3);=true;}PTRACE_PEEKDATA请求告诉ptrace如何读取被跟踪进程的内存。我们给它一个进程ID和一个地址,它向我们返回该地址的当前64位内容。(m_saved_data&~0xff)将此数据的低位字节清零,然后我们用我们的int3指令按位或(或)它来设置断点。***我们通过PTRACE_POKEDATA用我们的新数据覆盖那部分内存来设置断点。disable的实现比较简单,我们只需要恢复0xcc覆盖的原始数据即可。voidbreakpoint::disable(){ptrace(PTRACE_POKEDATA,m_pid,m_addr,m_saved_data);m_enabled=false;}在调试器中添加断点为了支持通过用户界面设置断点,我们需要修改调试器类中的三个地方:debugger添加断点存储数据结构添加set_breakpoint_at_address函数添加break命令到我们的handle_command函数中我会把我的断点保存到std::unordered_map结构中,这样我就可以方便快捷的判断一个给定的是否有地址处的断点,如果是,则检索断点对象。classdebugger{//...voidset_breakpoint_at_address(std::intptr_taddr);//...private://...std::unordered_mapm_breakpoints;}在set_breakpoint_at_address函数中我们将创建一个新的断点对象,启用它,将它添加到数据结构中,并向用户打印一条消息。如果愿意,您可以重构所有输出,这样您就可以将调试器用作库或命令行工具,为了简单起见,我将它们全部组合在一起。voiddebugger::set_breakpoint_at_address(std::intptr_taddr){std::cout<<"Setbreakpointataddress0x"<,那么您应该会看到程序的反汇编。找到main函数,找到要设置断点的call指令。比如我编译了一个helloworld程序,反汇编得到如下主要反汇编代码:0000000000400936

:400936:55push%rbp400937:4889e5mov%rsp,%rbp40093a:be350a4000mov$0x400a35,%esi:40093fbf6010mov10x60,%edi400944:e8d7feffffcallq400820<_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>400949:b800000000mov$0x0,%eax40094e:5dpop%rbp40094f:c3retq正如你看到的,要没有输出,我们要在0x400944设置断点,要看到输出,Toseta0x400949处的断点。总结您现在应该有一个可以启动您的程序并允许您在内存地址上设置断点的调试器。稍后我们将添加读写内存和寄存器的功能。同样,如果您有任何问题,请在评论框中告诉我。您可以在此处找到该项目的代码。