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

开发Linux调试器(六):源码级一步步执行

时间:2023-03-21 14:34:22 科技观察

在之前的博文中,我们了解了DWARF信息以及它如何让我们将机器码与上层源码链接起来。这一次,我们通过向调试器添加源代码级逐步调试,将这些知识付诸实践。系列文章索引这些链接会随着后续文章的发布而逐渐生效。准备环境断点寄存器和内存精灵和矮人源代码和信号源代码级步进源级断点调用堆栈展开读取变量后续步骤揭秘指令级步进我们正在超越自己。首先让我们通过用户界面揭开指令级单步执行的神秘面纱。我决定把它拆分成两个函数:single_step_instruction可以供代码的其他部分使用,single_step_instruction_with_breakpoint_check确保某个断点是否启用。voiddebugger::single_step_instruction(){ptrace(PTRACE_SINGLESTEP,m_pid,nullptr,nullptr);wait_for_signal();}voiddebugger::single_step_instruction_with_breakpoint_check(){//首先,检查我们是否需要禁用或启用断点if(m_breakpoints.count(get_pc())){step_over_breakpoint();}else{single_step_instruction();}}和以前一样,另一个命令被集成到我们的handle_command函数中:elseif(is_prefix(command,"stepi")){single_step_instruction_with_breakpoint_check();autoline_entry=get_line_entry_from_pc(get_pc());print_source(line_entry->file->path,line_entry->line);}有了这些新函数,我们就可以开始实现我们的源码级分步执行函数了。实现步进我们打算编写这些函数的非常简单的版本,但真正的调试器有一个线程计划的概念,它封装了所有步进信息。例如,调试器可能有一些复杂的逻辑来确定断点在哪里,然后有一些回调来确定单步执行是否完成。涉及很多基础设施,我们只是采取了一种幼稚的方法。我们可能会不小心越过断点,但如果需要,您可以花一些时间来了解所有细节。对于step_out,我们只是在函数的返回地址处设置一个断点,然后继续执行。我现在不想深入探讨调用堆栈展开的细节——这些将在后面的部分中介绍——但足以说明返回地址存储在堆栈帧开头的最后8个字节中。所以我们会读取栈指针,然后读取内存对应地址处的值:错误的;如果(!m_breakpoints.count(return_address)){set_breakpoint_at_address(return_address);should_remove_breakpoint=true;}continue_execution();if(should_remove_breakpoint){remove_breakpoint(return_address);}}remove_breakpoint(id::debuggerreakstd::intptr_taddr){if(m_breakpoints.at(addr).is_enabled()){m_breakpoints.at(addr).disable();}m_breakpoints.erase(addr);}接下来是跳入step_in。一个简单的算法是继续逐步执行指令,直到到达新的一行。voiddebugger::step_in(){autoline=get_line_entry_from_pc(get_pc())->line;while(get_line_entry_from_pc(get_pc())->line==line){single_step_instruction_with_breakpoint_check();}autoline_entry=get_line_entry_from_pc(get_pcsource_print());(line_entry->file->path,line_entry->line);跳过step_over对我们来说是三者中最难的。理论上解决办法是在下一行源码处打断点,但是下一行源码是什么?它可能不是当前行之后的行,因为我们可能处于循环中,或者某种条件结构中。真正的调试器通常会检查当前正在执行的指令并计算所有可能的分支目标,然后在所有分支目标上设置断点。对于一个小项目,我不打算实现或集成一个x86指令模拟器,所以让我们找到一个更简单的解决方案。有几个可怕的选择,一个是一直单步执行直到当前函数的新行,或者在当前函数的每一行设置一个断点。如果我们要跳过一个函数调用,前者会相当低效,因为我们需要逐步执行该调用图中的每条指令,所以我会选择第二种方法。voiddebugger::step_over(){autofunc=get_function_from_pc(get_pc());autofunc_entry=at_low_pc(func);autofunc_end=at_high_pc(func);autoline=get_line_entry_from_pc(func_entry);autostart_line=get_line_entry_from_pc(get_pc());std::vectorto_delete{};while(line->addressaddress!=start_line->address&&!m_breakpoints.count(line->address)){set_breakpoint_at_address(line->address));to_delete.push_back(line->address);}++line;}autoframe_pointer=get_register_value(m_pid,reg::rbp);autoreturn_address=read_memory(frame_pointer+8);if(!m_breakpoints.count(return_address)){set_breakpoint_at_address(return_address);to_delete.push_back(return_address);}continue_execution();for(autoaddr:to_delete){remove_breakpoint(addr);}}这个函数有一点复杂,我们将它拆开来看看。autofunc=get_function_from_pc(get_pc());autofunc_entry=at_low_pc(func);autofunc_end=at_high_pc(func);at_low_pc和at_high_pc是libelfin中的函数,它们可以给我们指定函数DWARF信息入口的最小和最大程序计数器值。autoline=get_line_entry_from_pc(func_entry);autostart_line=get_line_entry_from_pc(get_pc());std::vectorbreakpoints_to_remove{};while(line->addressaddress!=start_line->address&&!m_breakpoints.count(line->address)){set_breakpoint_at_address(line->address);breakpoints_to_remove.push_back(line->address);}++line;}我们需要把我们设置的所有断点去掉,这样就不用泄漏我们的步进函数,为此我们将它们存储在std::vector中。为了设置所有断点,我们循环遍历行表条目,直到找到一个不在我们函数范围内的条目。对于每一个,我们确保它不是我们当前所在的行,并且没有在该位置设置断点。autoframe_pointer=get_register_value(m_pid,reg::rbp);autoreturn_address=read_memory(frame_pointer+8);if(!m_breakpoints.count(return_address)){set_breakpoint_at_address(return_address);to_delete.push_back(return_address);}这里我们设置一个在返回地址处断点,就像跳出step_out一样。continue_execution();for(autoaddr:to_delete){remove_breakpoint(addr);}***,我们继续执行,直到***其中一个断点,然后把我们设置的临时断点全部去掉。它并不漂亮,但让我们就此打住吧。当然,我们还需要将这个新功能添加到UI中:elseif(is_prefix(command,"step")){step_in();}elseif(is_prefix(command,"next")){step_over();}elseif(is_prefix(command,"finish")){step_out();}test我通过实现一个调用一系列不同函数的简单函数来进行测试:voida(){intfoo=1;}voidb(){intfoo=2;a();}voidc(){intfoo=3;b();}voidd(){intfoo=4;c();}void(){intfoo=5;d();}voidf(){intfoo=6;e();}intmain(){f();}您应该能够在main的地址处设置断点,并在整个程序中跳入、跳过和跳出函数。如果您尝试跳出main函数或进入任何动态链接库,就会发生意想不到的事情。您可以在此处找到这篇博文的相关代码。下次我们将利用新的DWARF技巧来实现源代码级断点。