断点是如何形成的
有两种类型的断点:硬件断点和软件断点。硬件断点通常需要设置处理器中寄存器的值以产生断点,而软件断点则需要修改正在执行的代码。本文将以软件断点为主,因为它更简单,而且想要多少都可以,在X86上,同时只能设置4个硬件断点,不过硬件断点既可以在读取或写入给定地址触发,也可以在执行时触发。
我上面说过,软件断点是通过修改执行代码来实现的,所以问题来了:
我们如何修改代码?
我们做什么修改才能设置断点?
调试器如何收到通知?
第一个问题的答案当然是ptrace。我们之前使用它来跟踪和继续执行程序,现在也可以使用它来读写内存。并发送信号通知程序。
在x86上,是通过使用 int 3 指令覆盖这个地址上的指令来实现的。x86具有中断向量表(IDT),操作系统可以使用它来注册各种事件的处理程序,例如缺页异常,保护错误和无效操作码。这有点像注册错误处理回调,但是是硬件级别的。当处理器执行 int 3指令时,系统会执行断点中断处理程序,在Linux系统下,进程会产生一个SIGTRAP的信号。您可以在下图中看到此过程,其中我们用0xcc覆盖mov指令的第一个字节,这是 int 3的指令编码。
最后一个需要解决的问题是调试器如何通知用户断点已经触发。如果您还记得上一篇文章,我们可以使用waitpid来等待发送给调试程序的信号。我们在这里可以做同样的事情:设置断点、继续程序、调用waitpid并等到SIGTRAP信号发生。然后通过打印已经到达的源代码位置或者改变有界面的调试器中的选中行来将该断点已触发传送给用户。
//breakpoint.h
#ifndef DEBUGGER_BREAKPOINT_H
#define DEBUGGER_BREAKPOINT_H
#include <cstdint>
#include <signal.h>
class breakpoint
{
public:
breakpoint(pid_t pid,std::intptr_t addr) :m_pid{pid},m_addr{addr},m_enabled{false},m_saved_data{}
{}
void enable();
void disable();
auto is_enable()const->bool
{
return m_enabled;
}
auto get_address()const->std::intptr_t
{
return m_addr;
}
private:
pid_t m_pid;
std::intptr_t m_addr;
bool m_enabled;
uint8_t m_saved_data;//存储断点的数据
};
#endif //DEBUGGER_BREAKPOINT_H
还是注释好看啊
#include "breakpoint.h"
#include <sys/ptrace.h>
void breakpoint::enable()
{
auto data=ptrace(PTRACE_PEEKDATA,m_pid,m_addr,nullptr);//从m_pid的m_addr出读取一个字作为调用的结果返回
m_saved_data= static_cast<uint8_t>(data&0xff);//保存最低字节,这个字节在高位保存,所以rip-1就得到了设置0xcc地址的地方
uint64_t int3=0xcc;//int3断点
uint64_t data_with_uint3=((data&~0xff)|int3);//最低字节改为0xcc
ptrace(PTRACE_POKEDATA,m_pid,m_addr,data_with_uint3);//将int3写入m_addr指向的写入内存
m_enabled=true;
}
void breakpoint::disable()
{
auto data=ptrace(PTRACE_PEEKDATA,m_pid,m_addr, nullptr);
auto restored_data=((data&~0xff)|m_saved_data);//恢复m_addr指向被修改的int3断点
ptrace(PTRACE_POKEDATA,m_pid,m_addr, restored_data);//将原来的数据写入m_addr这个地址
m_enabled=false;
}
来看看我们的set_breakpoint_at_address函数
//debugger.cpp
void debugger::set_breakpoint_at_address(std::intptr_t addr)//设置断点
{
std::cout<<"Set breakpoint at address 0x"<<std::hex<<addr<<endl;
breakpoint bp{m_pid,addr};//构造了一个新的断点,addr是断点所在的位置
bp.enable();//设置int3断点
m_breakpoints.insert(make_pair(addr,bp));
//m_breakpoints[addr]=bp;c++14可以这么写
}
单步函数:
void debugger::step_over_breakpoint()
{
auto possible_breakpoint_location=get_pc()-1;//uint64_t data_with_uint3=((data&~0xff)|int3);//最低字节改为0xcc,最低字节是高地址,和字节序有关。当前的pc值指向下一条指令的第一个字节,pc-1就是本指令的最高地址,是本条指令的最低字节
if(m_breakpoints.count(possible_breakpoint_location))//如果当前地址是断点
{
// auto &bp=m_breakpoints[possible_breakpoint_location];//c++14可以这么写
auto &bp=m_breakpoints.at(possible_breakpoint_location);//得到当前这个位置上的breakpoint对象
if(bp.is_enable())//如果是断点的话会返回true
{
auto previous_instruction_address=possible_breakpoint_location;
set_pc(previous_instruction_address);//pc指针指向当前地址
bp.disable();//恢复当前地址被修改成int3的数据
ptrace(PTRACE_SINGLESTEP,m_pid, nullptr,nullptr);//重启子进程的执行,但指定子进程在下个入口或从系统调用退出时,或者执行单个指令后停止执行,这可用于实现单步调试
wait_for_signal();//调试进程阻塞在这里,当子进程执行单个指令后退出时候,状态会被调试进程收到,调试进程解除阻塞,退出step_over_break_point
//bp.enable();//加入int3断点
}
}
}
我们在continue_execution()函数中调用step_over_breakpoint()函数,调试进程将会在这里阻塞
void debugger::continue_execution()
{
/*ptrace(PTRACE_CONT,m_pid, nullptr, nullptr);//重新运行
int wait_status;
auto options=0;
waitpid(m_pid,&wait_status,options);//在这里重新等待子进程的状态改变*/
step_over_breakpoint();//调试进程在这里阻塞,一直等到被调试进程运行完一条指令
ptrace(PTRACE_CONT,m_pid,nullptr, nullptr);//被调试进程执行完一条指令退出step_over函数后,父进程继续让其运行,
wait_for_signal();//子进程没有运行到下一个断点的时候,父进程阻塞在这里,当运行到下一个断点是偶,触发int3断点,父进程解除阻塞,跳出hand_command函数
}
以上就是整个断点的设计逻辑。源码将会在写完后上传。
参考博客:https://blog.tartanllama.xyz/writing-a-linux-debugger-registers/