Breakpoint
断点是调试器能力的核心功能之一,在介绍指令级调试器时,我们详细介绍了断点的底层工作原理。如果您忘记了0xCC的作用,如果你忘记了 ptrace(PTRACE_PEEKDATA/POKEDATA/PEEKTEXT/POKETEXT, ...)
是干什么用的,如果你忘记了处理器执行到0xCC时会发生什么,如果你忘记了内核如何响应SIGTRAP信号,如果你忘记了子进程状态变化如何通过SIGCHLD通知到父进程,如果忘记了ptracer调用wait4是用来干什么的 …… 只要这里面有一个问题,你觉得模糊,那我认为,你都应该赶紧翻到 第6章 动态断点 小节快速回顾一下。
实现目标: breakpoint
breakpoints
clear
clearall
toggle
本节实现目标,我们将把断点操作强相关的几个调试命令,一起进行介绍,就不机械地每个调试命令单独一节内容了。其实还有另外几个 condition
, on
, trace
, watch
,这几个调试命令虽然也与断点相关,但是相对来说是比较高级点的用法,我们还是单独介绍下,以示重视,也希望在日后调试时能更好地帮助大家调试。
基础知识
除了本文开头的那些基础知识以外,符号级调试器添加断点时用到的位置描述locspec,以及可能涉及到表达式求值的操作evalexpr,甚至你想在特定指令地址处添加断点,可能先反汇编看下有哪些指令等,这些我们都当做本节前置内容进行安排,也介绍过了。如果你理解了这些内容,本文内容理解起来就简单多了。
但是,我也必须强调,和指令级调试器在指令处添加断点这种应用场景相比,符号级调试器里面会用断点用的更多样些。下面我们就来掰扯掰扯。
应用及挑战
除了用户显示创建的断点 break <locspec>
,一些调试命令也会主动创建断点。在指令级调试器里,step命令控制单步指令执行,是借助 ptrace(PTRACE_SINGLESTEP, ...)
打开了CPU的单步执行模式,这里的单步执行强调的步进一条指令,而非一行源代码。在符号级调试器里,要想实现next、stepin、stepout等操作,我们就需要从当前PC出发,去分析下一行可能要执行的指令的地址。
以实现next逐行执行,可能情况会比我们想象的复杂一点: 1)如果程序只有顺序执行的语句,没有分支控制、循环控制、跳转、函数调用等逻辑,实现next很简单,从PC找到Line,Line++并且有该源码行有有效的PC,在此处添加断点后,主动continue运行到此处,就ok了; 2)但是如果包含了上述可能改变程序执行路径的操作呢,我们如何知道当前程序执行位置是否处于一个if-else、switch-case、forloop中,以及break、continue后应该跳转到那里?DWARF调试信息并不描述这些,我们需要借助AST来分析函数体中执行的语句。确定当前执行位置处于什么程序构造中,然后知道执行到某一行后next操作应该调到新的哪一行。然后再在这行起始指令地址处添加断点。 3)类似地stepin、stepout,进入一个函数、退出一个函数,也都需要自动添加类似的断点。这个相对来说比较简单,因为每个函数都有入口地址,但是返回地址就需要通过DWARF CFA进行计算了。
所以你看,在符号级调试器里面,断点的应用是非常广泛的,你可以主动添加,调试命令也会隐式添加。为了实现next这样一个简单的操作,就涉及到了指令patch(物理断点)、AST分析、函数体分析、DWARF行号表、DWARF调用帧信息表等多种关键技术。
断点的类型
所以,从这里开始,我们的断点就可以分为两类:1)用户显示创建的断点;2)调试器其他调试命令自动隐式创建的断点。为了区分1)2)两种类型的断点,以及识别是哪种情况下自动隐式创建的断点,我们需要定义一个类型来区分 BreakpointKind
。
// BreakpointKind determines the behavior of delve when the
// breakpoint is reached.
type BreakpointKind uint16
const (
// UserBreakpoint is a user set breakpoint
UserBreakpoint BreakpointKind = (1 << iota)
// NextBreakpoint is a breakpoint set by Next, Continue will stop on it and delete it
NextBreakpoint
// NextDeferBreakpoint is a breakpoint set by Next on the first deferred function.
// In addition to check their condition, breakpoints of this kind will also
// check that the function has been called by runtime.gopanic or through runtime.deferreturn.
NextDeferBreakpoint
// StepBreakpoint is a breakpoint set by Step on a CALL instruction,
// Continue will set a new breakpoint (of NextBreakpoint kind) on the
// destination of CALL, delete this breakpoint and then continue again
StepBreakpoint
// WatchOutOfScopeBreakpoint is a breakpoint used to detect when a watched
// stack variable goes out of scope.
WatchOutOfScopeBreakpoint
// StackResizeBreakpoint is a breakpoint used to detect stack resizes to
// adjust the watchpoint of stack variables.
StackResizeBreakpoint
// PluginOpenBreakpoint is a breakpoint used to detect that a plugin has
// been loaded and we should try to enable suspended breakpoints.
PluginOpenBreakpoint
// StepIntoNewProc is a breakpoint used to step into a newly created
// goroutine.
StepIntoNewProcBreakpoint
// NextInactivatedBreakpoint a NextBreakpoint that has been inactivated, see rangeFrameInactivateNextBreakpoints
NextInactivatedBreakpoint
StepIntoRangeOverFuncBodyBreakpoint
steppingMask = NextBreakpoint | NextDeferBreakpoint | StepBreakpoint | StepIntoNewProcBreakpoint | NextInactivatedBreakpoint | StepIntoRangeOverFuncBodyBreakpoint
)
逻辑断点 vs 物理断点
另外,源代码中,同一个源代码位置,将来生成的机器指令后,可能会对应多个机器指令地址,为什么呢?联想下Go Generics,一个泛型函数 func Add[T ~int|~int32|~int64](a, b T) T {return a+b;}
,如果程序中使用了 Add(1,2), Add(uint(1), uint(2))
那么这个泛型函数就会为int、uint分别实例化两个函数。当然转成机器指令后,同一个源码行(泛型函数定义位置)就对应着两个机器指令地址(一个是int类型实例化位置,一个是uint类型实例化位置)。
这样添加断点的时候,我们还是执行 break Add
,对吧,我们压根不知道实例化的两个函数的地址,符号级调试时我们也不想去理解。但是如果要能够真正对其进行调试,调试器必须知道分别在这两个实例化的函数位置添加断点。这里就出现了一个源代码位置的断点,对应着2个物理内存地址的断点。为了描述这种层次关系,我们提出 “逻辑断点” 和 “物理断点” 的概念。
- 逻辑断点:
break <address>
以外的所有其他添加断点方式,对应的每个源码位置,都对应的会创建一个逻辑断点,1个逻辑断点对应着1个或者多个物理断点; - 物理断点:逻辑断点强调的是源代码位置,对应着具体实现时,还是要落地到对指令地址处添加断点0xCC,这个是指的物理断点。而Go、编译器的某些特性可能会导致一个逻辑断点对应多个物理断点,比如Go泛型stenciling实现方案,编译器内联优化,any else?
ps: 一行源代码包括多个语句,为了调试方便,是否也应该为每个语句的开始处添加断点?测试了下dlv,不支持。
OK,我们也需要意识到这点区别,逻辑断点 和 物理断点。
断点重叠管理 breaklet
同一个指令地址处的断点,叫什么来着?对,物理断点。刚提过哈!这里需要注意,在同一个物理断点处有可能存在多个“逻辑断点”在此处重叠,这几个逻辑断点都希望在此处添加物理断点,但是只有一个字节需要patch为0xCC,那怎么来表示多个不同逻辑断点在此处有断点呢?移除其中一个逻辑断点,不会对其他逻辑断点产生影响呢?这就是为什么要引入 Breaklet
。
每个Breaklet(联想下tasklet表示中断服务里的一部分处理逻辑),breaklet这里可以理解成1个物理断点的1小部分控制逻辑即可,同一个物理断点的多个breaklets共同决定了这个物理断点的行为。
- 同一个物理断点可能有多个breaklets;
- 每个breaklet有自己的断点类型,
BreakpointKind
; - 每个breaklet有自己的条件,
Cond ast.Expr
; - 不同BreakpointKind类型的Breaklets,有可能是兼容的,有可能是不兼容的,不兼容就不能添加Breakpoint.Breaklets;
后面介绍 condition
, on
, trace
, watch
时会再次提到这些。
代码实现
下面我们分别看看这几个调试命令时如何实现的。
break | breakpoint
先来看看break支持的操作:
- 你可以使用locspec支持的任意位置描述形式来指定断点位置,
- 你还可以额外指定一个参数来为断点位置命名,这在一次复杂的调试活动中添加了大量断点时,是非常有用的,方便我们识别重要的断点位置,
- 你还可以添加
if <condition>
表达式,只有当表达式条件成立时,执行到断点位置tracee才会停下来。
ps:如果你创建断点后,发现应该加个条件,避免它被不必要的触发,也是可以的。可以使用调试命令 condition <breakpoint> <bool expr>
。但是这两种方式的工作原理是相同的。
(tinydbg) help break
Sets a breakpoint.
break [name] [locspec] [if <condition>]
Locspec is a location specifier in the form of:
* *<address> Specifies the location of memory address address. address can be specified as a decimal, hexadecimal or octal number
* <filename>:<line> Specifies the line in filename. filename can be the partial path to a file or even just the base name as long as the expression remains unambiguous.
* <line> Specifies the line in the current file
* +<offset> Specifies the line offset lines after the current one
* -<offset> Specifies the line offset lines before the current one
* <function>[:<line>] Specifies the line inside function.
The full syntax for function is <package>.(*<receiver type>).<function name> however the only required element is the function name,
everything else can be omitted as long as the expression remains unambiguous. For setting a breakpoint on an init function (ex: main.init),
the <filename>:<line> syntax should be used to break in the correct init function at the correct location.
* /<regex>/ Specifies the location of all the functions matching regex
If locspec is omitted a breakpoint will be set on the current line.
If you would like to assign a name to the breakpoint you can do so with the form:
break mybpname main.go:4
Finally, you can assign a condition to the newly created breakpoint by using the 'if' postfix form, like so:
break main.go:55 if i == 5
Alternatively you can set a condition on a breakpoint after created by using the 'on' command.
See also: "help on", "help cond" and "help clear"
OK,接下来我们看看断点命令的执行细节?
clientside:
debug_breakpoint.go:breakpointCmd.cmdFn(...),
i.e., breakpoint(...)
\--> _, err := setBreakpoint(t, ctx, false, args)
\--> args := config.Split2PartsBySpace(argstr)
\--> parse breakpoint name, locspec and condition
\--> locs, substSpec, findLocErr := t.client.FindLocation(ctx.Scope, spec, true, t.substitutePathRules())
`break [name] [locspec]
\--> if findLocErr != nil retries following:
1) research locations by removing `if condition`
`break [name] [locspec] [if condition]
2) research locations by checking if suspended breakpoints should be set if:
- plugin.Open called,
- isErrProcessExited(err),
- or t.client.FollowExecEnabled() true
\--> if findLocErr != nil then failed
\--> foreach loc in locs do
\--> bp, err := t.client.CreateBreakpointWithExpr(requestedBp, spec, t.substitutePathRules(), false)
\--> set breakpoints for function return addresses if
`trace [name] [locspec]`
1) it's a tracepoint
2) and locspec contains function Name
3) and locs[0].Function != nil
foreach loc in locs do
\--> if loc.Function == nil then continue
\--> addrs, err := t.client.(*rpc2.RPCClient).FunctionReturnLocations(locs[0].Function.Name())
\--> foreach addr in addrs do
\--> _, err = t.client.CreateBreakpoint(&api.Breakpoint{Addr: addrs[j], TraceReturn: true, Line: -1, LoadArgs: &ShortLoadConfig})
serverside: