我们打算编写这些函数非常简单的版本,但真正的调试器有 thread plan 的概念,它封装了所有的单步信息。例如,调试器可能有一些复杂的逻辑去决定断点的位置,然后有一些回调函数用于判断单步操作是否完成。这其中有非常多的基础设施,我们只采用一种朴素的方法。我们可能会意外地跳过断点,但如果你愿意的话,你可以花一些时间把所有的细节都处理好。
跳过 step_over 对于我们来说是三个中最难的。理论上,解决方法就是在下一行源码中设置一个断点,但下一行源码是什么呢?它可能不是当前行后续的那一行,因为我们可能处于一个循环、或者某种条件结构之中。真正的调试器一般会检查当前正在执行什么指令然后计算出所有可能的分支目标,然后在所有分支目标中设置断点。对于一个小的项目,我不打算实现或者集成一个 x86 指令模拟器,因此我们要想一个更简单的解决办法。有几个可怕的选择,一个是一直逐步执行直到当前函数新的一行,或者在当前函数的每一行都设置一个断点。如果我们是要跳过一个函数调用,前者将会相当的低效,因为我们需要逐步执行那个调用图中的每个指令,因此我会采用第二种方法。
voiddebugger::step_over(){ auto func = get_function_from_pc(get_pc()); auto func_entry = at_low_pc(func); auto func_end = at_high_pc(func);
auto line = get_line_entry_from_pc(func_entry); auto start_line = get_line_entry_from_pc(get_pc());
std::vector<std::intptr_t> to_delete{};
while (line->address < func_end) { if (line->address != start_line->address && !m_breakpoints.count(line->address)) { set_breakpoint_at_address(line->address); to_delete.push_back(line->address); } ++line; }
auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_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 (auto addr : to_delete) { remove_breakpoint(addr); } }
这个函数有一点复杂,我们将它拆开来看。
1 2 3 4
auto func = get_function_from_pc(get_pc()); auto func_entry = at_low_pc(func); auto func_end = at_high_pc(func);
auto frame_pointer = get_register_value(m_pid, reg::rbp); auto return_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。
1 2 3 4 5 6
continue_execution();
for (auto addr : to_delete) { remove_breakpoint(addr); }