动态断点
实现目标:添加断点
断点按照其“生命周期”进行分类,可以分为“静态断点”和“动态断点”。
- 静态断点的生命周期是整个程序执行期间,一般是通过执行指令
int 0x3h
来强制插入0xCC
充当断点,其实现简单,在编码时就可以安插断点,但是不灵活; - 动态断点的生成、移除,是通过运行时指令patch,其生命周期是与调试活动中的操作相关的,其最大的特点就是灵活,一般是只能借由调试器来生成。
不管是静态断点还是动态断点,其原理是类似的,都是通过一字节指令0xCC
来实现暂停任务执行的操作,处理器执行完0xCC
之后会暂停当前任务执行。
ps:我们在章节4.2中有提到
int 0x3h
(编码后指令0xCC)是如何工作的,如果读者忘了其工作原理,可自行查阅相关章节。
断点按照“实现方式”的不同,也可以细分为“软件断点”和“硬件断点”。
- 硬件断点一般是借助硬件特有的调试端口来实现,如将感兴趣的指令地址写入调试端口(寄存器),当PC命中时就会触发停止tracee执行的操作,并通知tracer;
- 软件断点是相对于硬件断点而言的,如果断点实现是不借助于硬件调试端口的话,一般都可以归为软件断点。
我们先只关注软件断点,并且只关注动态断点。断点的添加、移除是调试过程的基石,在我们掌握了在特定地址处添加、移除断点之后,我们可以研究下断点的应用,如step、next、continue等。
在熟练掌握了这些操作之后,我们将在后续章节结合DWARF来实现符号级断点,那时将允许你对一行语句、函数、分支控制添加、移除断点,断点的价值就进一步凸显出来了。
代码实现
我们使用break
命令来添加断点,可以简单缩写成b
,使用方式如下:
# 注意<locspec>的写法
break <locspec>
locspec表示一个代码中的位置,可以是指令地址,也可以是一个源文件中的位置。如果是后者,我们需要查询行号表先将源码中的位置转换成指令地址。有了指令地址之后,我们就可以对该地址处的指令数据进行patch以达到添加、移除断点的目的。
本章节,我们先只考虑locspec为指令地址的情况。
locspec支持的格式,直接关系到添加断点的效率。delve中定义了一系列的locspec格式,感兴趣可以参考dlv中的实现:https://sourcegraph.com/github.com/go-delve/delve@master/-/blob/pkg/locspec/locations.go
现在来看下我们的实现代码:
package debug
import (
"errors"
"fmt"
"strconv"
"strings"
"syscall"
"github.com/spf13/cobra"
)
var breakCmd = &cobra.Command{
Use: "break <locspec>",
Short: "在源码中添加断点",
Long: `在源码中添加断点,源码位置可以通过locspec格式指定。
当前支持的locspec格式,包括两种:
- 指令地址
- [文件名:]行号
- [文件名:]函数名`,
Aliases: []string{"b", "breakpoint"},
Annotations: map[string]string{
cmdGroupKey: cmdGroupBreakpoints,
},
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("break %s\n", strings.Join(args, " "))
if len(args) != 1 {
return errors.New("参数错误")
}
locStr := args[0]
addr, err := strconv.ParseUint(locStr, 0, 64)
if err != nil {
return fmt.Errorf("invalid locspec: %v", err)
}
// 记录地址addr处的原始1字节数据
orig := [1]byte{}
n, err := syscall.PtracePeekData(TraceePID, uintptr(addr), orig[:])
if err != nil || n != 1 {
return fmt.Errorf("peek text, %d bytes, error: %v", n, err)
}
breakpointsOrigDat[uintptr(addr)] = orig[0]
// 将addr出的一字节数据覆写为0xCC
n, err = syscall.PtracePokeText(TraceePID, uintptr(addr), []byte{0xCC})
if err != nil || n != 1 {
return fmt.Errorf("poke text, %d bytes, error: %v", n, err)
}
fmt.Printf("添加断点成功\n")
return nil
},
}
func init() {
debugRootCmd.AddCommand(breakCmd)
}
这里的实现逻辑并不复杂,我们来看下。
首先假定用户输入的是一个指令地址,这个地址可以通过disass查看反汇编时获得。我们先尝试将这个指令地址字符串转换成uint64数值,如果失败则认为这是一个非法的地址。
如果地址有效,则尝试通过系统调用syscall.PtracePeekData(pid, addr, buf)
来尝试读取指令地址处开始的一字节数据,这个数据是汇编指令编码后的第1字节的数据,我们需要将其暂存起来,然后再通过syscall.PtracePokeData(pid, addr, buf)
写入指令0xCC
。
等我们准备结束调试会话时,或者显示执行clear
清除断点时,需要将数据这里的0xCC还原为原始数据。
代码测试
下面来测试一下,首先我们启动一个测试程序,获取其pid,这个程序最好一直死循环不退出,方便我们测试。
然后我们先执行godbg attach <pid>
准备开始调试,调试会话启动后,我们执行disass反汇编命令查看汇编指令对应的指令地址。
godbg attach 479
process 479 attached succ
process 479 stopped: true
godbg>
godbg> disass
.............
0x465326 MOV [RSP+Reg(0)+0x8], RSI
0x46532b MOV [RSP+Reg(0)+0x10], RBX
0x465330 CALL .-400789
0x465335 MOVZX ECX, [RSP+Reg(0)+0x18]
0x46533a MOV RAX, [RSP+Reg(0)+0x38]
0x46533f MOV RDX, [RSP+Reg(0)+0x30]
.............
godbg>
随机选择一条汇编指令的地址,在调试会话中输入break <address>
,我们看到提示断点添加成功了。
godbg> b 0x46532b
break 0x46532b
添加断点成功
godbg>
godbg> exit
最后执行exit退出调试。
这里我们只展示了断点的添加逻辑,断点的移除逻辑,其实实现过程非常相似,我们将在clear命令的实现时再予以介绍。
实现了break、clear之后我们再来看step、next、continue等控制执行流程的调试命令如何实现。