软件动态断点:移除断点
设计目标:godbg> clear -n [bpNo.] 移除断点
前面介绍了如何添加断点、显示断点列表,现在我们来看看如何移除断点。
移除断点与新增断点,都是需要借助ptrace来实现。回想下新增断点首先通过PTRACEPEEKDATA/PTRACEPOKEDATA来实现对指令数据的备份、覆写,移除断点的逻辑有点相反,先将原来备份的指令数据覆写回断点对应的指令地址处,然后,从已添加断点集合中移除即可。
ps: 在Linux下PTRACE_PEEKTEXT/PTRACE_PEEKDATA,以及PTRACE_POKETEXT/PTRACE_POKEDATA并没有什么不同,所以执行ptrace操作的时候,ptrace request可以任选一个。
为了可读性,读写指令时倾向于PTRACE_PEEKTEXT/PTRACE_POKETEXT,读写数据时则倾向于PTRACE_PEEKDATA/PTRACE_POKEDATA。
代码实现
首先解析断点编号参数 -n <breakNo>,并从已添加断点集合中查询,是否有编号为n的断点存在,如果没有则 <breakNo> 为无效参数。
如果断点确实存在,则执行ptrace(PTRACE_POKEDATA,...)将原来备份的1字节指令数据覆写回原指令地址,即消除了断点。然后,再从已添加断点集合中删除这个断点。
clear 操作实现比较简单,在 hitzhangjie/godbg 中进行了实现,读者可以查看 godbg 的源码。但是我们也强调过了,上述repo提供的是一个功能相对完备的调试器,代码量会比较大。因此我们也在 hitzhangjie/golang-debugger-lessons)/8_clear 提供了测试用例,测试用例中演示了break、breakpoints、continue、clear这几个断点相关操作。
package debug
import (
    "errors"
    "fmt"
    "strings"
    "syscall"
    "godbg/target"
    "github.com/spf13/cobra"
)
var clearCmd = &cobra.Command{
    Use:   "clear <n>",
    Short: "清除指定编号的断点",
    Long:  `清除指定编号的断点`,
    Annotations: map[string]string{
        cmdGroupKey: cmdGroupBreakpoints,
    },
    RunE: func(cmd *cobra.Command, args []string) error {
        fmt.Printf("clear %s\n", strings.Join(args, " "))
        id, err := cmd.Flags().GetUint64("n")
        if err != nil {
            return err
        }
        // 查找断点
        var brk *target.Breakpoint
        for _, b := range breakpoints {
            if b.ID != id {
                continue
            }
            brk = b
            break
        }
        if brk == nil {
            return errors.New("断点不存在")
        }
        // 移除断点
        n, err := syscall.PtracePokeData(TraceePID, brk.Addr, []byte{brk.Orig})
        if err != nil || n != 1 {
            return fmt.Errorf("移除断点失败: %v", err)
        }
        delete(breakpoints, brk.Addr)
        ...
        fmt.Println("移除断点成功")
        return nil
    },
}
func init() {
    debugRootCmd.AddCommand(clearCmd)
    clearCmd.Flags().Uint64P("n", "n", 1, "断点编号")
}
代码测试
首先运行一个待调试程序,获取其pid,然后通过 godbg attach <pid>调试目标进程,首先通过命令 disass显示汇编指令列表,然后执行 b <locspec>命令添加几个断点。
godbg> b 0x4653af
break 0x4653af
添加断点成功
godbg> b 0x4653b6
break 0x4653b6
添加断点成功
godbg> b 0x4653c2
break 0x4653c2
添加断点成功
这里我们执行了3次断点添加操作,breakpoints可以看到添加的断点列表:
godbg> breakpoints
breakpoint[1] 0x4653af 
breakpoint[2] 0x4653b6 
breakpoint[3] 0x4653c2
然后我们执行 clear -n 2移除第2个断点:
godbg> clear -n 2
clear 
移除断点成功
接下来再次执行 breakpoints查看剩余的断点:
godbg> bs
breakpoint[1] 0x4653af 
breakpoint[3] 0x4653c2
现在断点2已经被移除了,我们的添加、移除断点的功能是正常的。
思考:仅还原指令数据就可以吗
大家考虑这么一种特殊情况:存在某个线程线程已经停在了要删除的断点处,换言之,它已经执行了被patched指令的第1字节0xCC,当前PC指向第2字节,如果我们对这个线程的PC不做回退,那么当我们执行continue恢复其执行时,CPU取指令、指令译码将从上述还原后的完整指令的第2字节开始,而不是第1字节。这样,显然CPU指令译码时会出错。
所以上述clear命令的实现是不完备的,需要补充查找受影响的线程列表,以及rewind线程PC的逻辑。
godbg中clearCmd的后续实现代码如下,您可以查看 [hitzhangjie/godbg]:
var clearCmd = &cobra.Command{
    Use:   "clear <breakpoint no.>",
    Short: "清除指定编号的断点",
    Long:  `清除指定编号的断点`,
    ...
    RunE: func(cmd *cobra.Command, args []string) error {
        //fmt.Printf("clear %s\n", strings.Join(args, " "))
        id, err := cmd.Flags().GetUint64("n")
        if err != nil {
            return err
        }
        // 查找断点
        var brk *target.Breakpoint
        for _, b := range target.DBPProcess.Breakpoints {
            if b.ID != id {
                continue
            }
            brk = b
            break
        }
        if brk == nil {
            return errors.New("断点不存在")
        }
        // 移除断点
        _, err = target.DBPProcess.ClearBreakpoint(brk.Addr)
        if err != nil {
            return err
        }
        fmt.Println("移除断点成功")
        return nil
    },
}
// ClearBreakpoint 移除指定地址处断点,并rewind受影响的thread
func (p *DebuggedProcess) ClearBreakpoint(addr uintptr) (*Breakpoint, error) {
    brk, err := p.RestoreInstruction(addr)
    if err != nil {
        return nil, err
    }
    // 是否有线程需要rewind pc
    bpStoppedThreads, err := p.ThreadStoppedAtBreakpoint()
    if err != nil {
        return nil, fmt.Errorf("检查线程停在断点处失败: %v", err)
    }
    for tid, bpAddr := range bpStoppedThreads {
        if bpAddr != brk.Addr {
            continue
        }
        regs, err := p.ReadRegister(tid)
        if err != nil {
            return nil, fmt.Errorf("读取寄存器失败: %v", err)
        }
        regs.SetPC(regs.PC() - 1)
        if err = p.WriteRegister(tid, regs); err != nil {
            return nil, fmt.Errorf("写入寄存器失败: %v", err)
        }
    }
    return brk, nil
}
// ThreadStoppedAtBreakpoint 检查所有线程是否停在断点处
func (p *DebuggedProcess) ThreadStoppedAtBreakpoint() (map[int]uintptr, error) {
    threadStoppedAtBP := make(map[int]uintptr)
    if len(p.Threads) == 0 {
        return threadStoppedAtBP, nil
    }
    for tid, thread := range p.Threads {
        regs, err := p.ReadRegister(thread.Tid)
        if err != nil {
            // 线程可能已经退出,跳过
            if err == syscall.ESRCH {
                fmt.Fprintf(os.Stderr, "warn: thread %d exited\n", tid)
                continue
            }
            return nil, fmt.Errorf("read register for thread %d: %v", tid, err)
        }
        // 检查PC-1位置是否有断点(因为断点指令已经执行)
        pc := regs.PC()
        if bp, exists := p.Breakpoints[uintptr(pc-1)]; exists {
            threadStoppedAtBP[tid] = bp.Addr
        }
    }
    return threadStoppedAtBP, nil
}
// 移除断点,还原指令数据+从断点列表中移除
func (p *DebuggedProcess) RestoreInstruction(addr uintptr) (*Breakpoint, error) {
    brk, ok := p.Breakpoints[addr]
    if !ok {
        return nil, ErrBreakpointNotExisted
    }
    // 移除断点
    pid := p.Process.Pid
    err := p.ExecPtrace(func() error {
        n, err := syscall.PtracePokeData(pid, brk.Addr, []byte{brk.Orig})
        if err != nil || n != 1 {
            return fmt.Errorf("ptrace poke data err: %v", err)
        }
        delete(p.Breakpoints, brk.Addr)
        return nil
    })
    if err != nil {
        return nil, err
    }
    return brk, nil
}
本节小结
本节主要探讨了调试器中动态断点的移除功能实现,核心内容包括断点移除与添加的对称性、ptrace系统调用的反向操作,以及删除断点时的断点编号验证、断点查找、指令恢复和断点集合清理步骤。ptrace操作PTRACE_PEEKTEXT/PTRACE_POKETEXT用于指令操作,PTRACE_PEEKDATA/PTRACE_POKEDATA用于数据操作,但实际功能相同。
本节内容完善了调试器断点管理的核心功能,与前面的断点添加、断点列表显示功能共同构成了完整的断点操作体系,为读者理解调试器内部机制提供了重要的实践基础。通过本节的学习,读者可以掌握断点移除的底层实现原理,为后续学习更复杂的调试器功能(如条件断点、断点修改等)奠定了技术基础。