执行控制:step
逐指令执行
实现目标:step逐指令执行
在实现了反汇编以及添加移除断点功能后,我们将开始进一步探索如何控制调试进程的执行,如step逐指令执行、continue运行到断点位置,在后面符号级调试器开发章节,我们还会实现next逐语句执行。
本节我们先实现 step
命令来支持逐指令执行。
代码实现
逐指令执行,通过执行 ptrace(PTRACE_SINGLESTEP,...)
操作即可由内核代为完成。但是在上述操作执行之前,step命令还有些特殊因素要考虑方能正常执行。
此时的PC值有可能是越过了一个断点之后的地址,比如:
- 一条经过指令patch后的多字节指令,首字节处修改为了0xCC,当前寄存器PC值实际上是该多字节指令的第二个字节的地址,而非首字节的地址。如果对PC值不做修改,处理器执行的时候从第二字节开始解码会解码失败,无法执行指令;
- 一条单字节指令,如果我们直接decode下一个地址处的指令,还会漏掉断点处原来的一字节指令;
为了保证step正常执行,在 ptrace(PTRACE_SINGLESTEP,...)
之前,需要首先通过 ptrace(PTRACE_PEEKTEXT,...)
去读取 PC-1
地址处的数据,如果是0xCC,则表明此处为一个断点,需要将添加断点前的原始数据还原、PC=PC-1,然后再继续执行。
file:cmd/debug/step.go
package debug
import (
"fmt"
"syscall"
"github.com/spf13/cobra"
)
var stepCmd = &cobra.Command{
Use: "step",
Short: "执行一条指令",
Annotations: map[string]string{
cmdGroupKey: cmdGroupCtrlFlow,
},
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("step")
// 读取PC值
regs := syscall.PtraceRegs{}
err := syscall.PtraceGetRegs(TraceePID, ®s)
if err != nil {
return fmt.Errorf("get regs error: %v", err)
}
buf := make([]byte, 1)
n, err := syscall.PtracePeekText(TraceePID, uintptr(regs.PC()-1), buf)
if err != nil || n != 1 {
return fmt.Errorf("peek text error: %v, bytes: %d", err, n)
}
// read a breakpoint
if buf[0] == 0xCC {
regs.SetPC(regs.PC() - 1)
// TODO refactor breakpoint.Disable()/Enable() methods
orig := breakpoints[uintptr(regs.PC())].Orig
n, err := syscall.PtracePokeText(TraceePID, uintptr(regs.PC()), []byte{orig})
if err != nil || n != 1 {
return fmt.Errorf("poke text error: %v, bytes: %d", err, n)
}
}
err = syscall.PtraceSingleStep(TraceePID)
if err != nil {
return fmt.Errorf("single step error: %v", err)
}
// MUST: 当发起了某些对tracee执行控制的ptrace request之后,要调用syscall.Wait等待并获取tracee状态变化
var wstatus syscall.WaitStatus
var rusage syscall.Rusage
_, err = syscall.Wait4(TraceePID, &wstatus, syscall.WALL, &rusage)
if err != nil {
return fmt.Errorf("wait error: %v", err)
}
// display current pc
regs = syscall.PtraceRegs{}
err = syscall.PtraceGetRegs(TraceePID, ®s)
if err != nil {
return fmt.Errorf("get regs error: %v", err)
}
fmt.Printf("single step ok, current PC: %#x\n", regs.PC())
return nil
},
}
func init() {
debugRootCmd.AddCommand(stepCmd)
}
以上就是step命令的实现代码,但这并不是一个十分友好的实现:
- 它确实实现了逐指令执行,完成了本节目标;
- 每逐指令执行之后,它还能打印当前寄存器PC值,方便我们确定下条待执行指令地址;
美中不足的是,没有将当前待执行指令的前后指令打印出来,并通过箭头指示下条待执行指令,一种更好的交互可能是这样:
godbg> step
=> 地址1 汇编指令1
地址2 汇编指令2
地址3 汇编指令3
...
这里会影响到调试体验,我们将在后续过程中予以完善。
ps:上述代码是 hitzhangjie/godbg 中的实现,我们重点介绍了step的实现。另外在 hitzhangjie/golang-debuger-lessons /11_step 下,我们也提供了一个step执行的示例,只有一个源文件,与其他demo互不影响,您也可以按照你的想法修改测试下,不用担心改坏整个 godbg的问题。
FIXME(demo) 这里的代码实际上也是godbg中的早期实现版本了,后续为了增强维护性,早就进行了大范围的重构。
代码测试
启动一个程序,获取其进程pid,然后执行 godbg attach <pid>
对进程进行调试,等调试会话就绪之后,我们输入 disass
反汇编看下当前指令地址之后的汇编指令有哪些。
godbg> disass
0x40ab47 movb $0x0,0x115(%rdx)
0x40ab4e mov 0x18(%rsp),%rcx
0x40ab53 mov 0x38(%rsp),%rdx
0x40ab58 mov (%rdx),%ebx
0x40ab5a test %ebx,%ebx
0x40ab5c jne 0x4c
0x40ab5e mov 0x30(%rax),%rbx
0x40ab62 movb $0x1,0x115(%rbx)
0x40ab69 mov %rdx,(%rsp)
0x40ab6d movl $0x0,0x8(%rsp)
然后尝试执行 step
命令,观察输出情况。
godbg> step
step
single step ok, current PC: 0x40ab4e
godbg> step
step
single step ok, current PC: 0x40ab53
godbg> step
step
single step ok, current PC: 0x40ab58
godbg>
我们执行了step指令3次,step每次执行一条指令之后,会输出执行指令后的PC值,依次是0x40ab4e、0x40ab53、0x40ab58,依次是下条指令的首地址。
不禁要问,执行系统调用 ptrace(PTRACE_SINGLESTEP,...)
时,内核是如何实现逐指令执行的?只执行一条机器指令后立即停下来,有点奇妙!
更多相关内容:SINGLESTEP
内核是如何处理PTRACE_SINGLESTEP请求的呢?SINGLESTEP确实比较特殊,在man(2)手册里面并没有找到太多有价值的信息:
$ man 2 ptrace
------------------------------------------------------------------
NAME
ptrace - process trace
SYNOPSIS
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
Description
...
PTRACE_SINGLESTEP stops
[Details of these kinds of stops are yet to be documented.]
man(2)手册里面没有太多有价值的相关信息,查看内核源码以及Intel开发手册之后,可以了解到这方面的细节。
SINGLESTEP调试在Intel平台上是借助了处理器自身硬件特性来实现的,参考《Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 1: Basic Architecture》,Intel架构处理器是有一个标识寄存器EFLAGS,当通过内核将标志寄存器的TF标志置为1时,处理器会自动进入单步执行模式,清0退出单步执行模式。
System Flags and IOPL Field
The system flags and IOPL field in the EFLAGS register control operating-system or executive operations. They should not be modified by application programs. The functions of the system flags are as follows:
TF (bit 8) Trap flag — Set to enable single-step mode for debugging; clear to disable single-step mode.
- 我们执行系统调用
syscall.PtraceSingleStep(...)
时,实际上是ptrace(PTRACE_SINGLESTEP, pid...)
,此时内核会将被跟踪的tracee的task_struct中的寄存器部分的flags设置为flags |= TRAP,然后调度tracee执行。 调度器执行tracee时会先将其进程控制块task_struct中的硬件上下文信息还原到处理器寄存器中,然后再执行对应tracee的指令。此时处理器发现EFLAGS.TF=1,进入单步执行模式。处理器执行单条指令,执行完成后处理器会自动生成一个陷阱中断,然后重置该标志位。这里的陷阱中断是type-1中断 (中断1是调试异常#DB,中断3是断点异常#BP)。
Single-step interrupt When a system is instructed to single-step, it will execute one instruction and then stop. ... The Intel 8086 trap flag and type-1 interrupt response make it quite easy to implement a single-step feature in an 8086-based system. If the trap flag is set, the 8086 will automatically do a type-1 interrupt after each instruction executes. When the 8086 does a type-1 interrupt, ... The trap flag is reset when the 8086 does a type-1 interrupt, so the single-step mode will be disabled during the interrupt-service procedure.
- 内核中断服务程序负责处理这个中断(中断1,#DB异常),会像处理中断3#BP异常时那样,给tracee发送SIGTRAP,信号处理时进而会暂停tracee调度,然后通过SIGCHLD、__wake_up_parent唤醒tracer,以这种方式通知调试器tracer,“嘿,tracee已经单步执行了一条指令,并且已经停下来等待接收后续调试命令了”。
ps:这就是Intel平台下单步执行的一些细节信息,读者如果对其他硬件平台感兴趣,也可以自行了解下它们是如何设计实现来解决单步调试问题的。
本节小结
本节主要探讨了调试器中step逐指令执行功能的实现原理与具体实现,核心内容包括:通过ptrace(PTRACE_SINGLESTEP)系统调用实现单步执行;处理断点恢复的特殊情况,确保PC值正确性;理解Intel平台下基于EFLAGS.TF标志位的硬件单步执行机制。
本节内容为读者深入理解调试器执行控制机制以及后续实现continue、next等高级调试命令奠定了重要基础。