4.调试
[TOC]
背景
目前 Go 语言支持 GDB、LLDB、Delve 调试器,其中 GDB 是最早支持的调试工具,LLDB 是 macOS 系统推荐的标准调试工具。只有 Delve 是专门为 Go 语言设计开发的调试工具(比如优化了 error, channel 结构体的打印、针对协程栈扩容做了优化等),所以使用 Delve可以轻松调试 Go 汇编程序。
delve 的安装:go install github.com/go-delve/delve/cmd/dlv@latest
验证安装:dlv version
dlv 使用
dlv 有如下常见使用方法:
debug:
dlv debug ${源代码.go},调试 go 源码文件attach:
dlv attach ${进程pid},调试正在运行的进程,断点会hang住进程,导致上游健康检查失败,因此不建议线上使用core:
dlv core ${bin} ${core_file},分析转储文件,可用于分析程序 panic 时的现场、原因api:
./dlv --listen=127.0.0.1:26953 --headless=true --api-version=2 --check-go-version=false --only-same-user=false attach ${pid},可用于远程调试
下面以 debug 方式为例,看一看如何调试 Go 源码
大家都知道向一个 nil 的切片追加元素,不会有任何问题,在源码中是怎么实现的呢?接下来我们使用 dlv 调试跟踪一下,先写一个 demo1.go:
// dlv/demo1.go
package main
import (
"fmt"
)
func demo1() {
var s []int
s = append(s, 1)
fmt.Println(s)
}再写个 main.go 文件调用 demo1:
进入命令行包目录,然后输入 dlv debug 进入调试
因为这里我们想看到 append 的内部实现,所以在 append 那行加上断点,执行 break 命令:
执行 continue 命令,运行到断点处:
接下来我们执行 si 命令进入 append 方法:
从以上内容我们看到调用了 runtime.growslice 方法,我们在这里加一个断点:
之后我们再次执行 continue 执行到该断点处:
点进 /usr/local/go/src/runtime/slice.go:157 就可以看到完整的切片扩容策略;到这里大家也就明白了为啥向 nil 的切片追加数据不会有问题了,因为在容量不够时会调用 growslice 函数进行扩容
常用命令
dlv --help:可看到 dlv 命令行支持的命令
dlv debug ${xxx.go}:调试源代码
dlv exec ${bin}:调试可执行文件
dlv attach ${pid}:调试指定 pid 进程
通过 dlv debug ${xxx.go} 进入交互模式后,可通过 help/h 命令查看支持的交互命令
控制流
continue/c:执行函数到下一断点
next/n:跳到下一个断点
step/s:单步调试
step-instruction/si:执行单步 CPU 指令
stepout/so:跳出当前单步调试到下一个断点
断点管理
break/b ${文件名:行号} 或 ${包名.函数名} :打断点
breakpoints/bp:列出所有断点
clear ${断点号}:清除断点
condition/cond ${断点号} ${条件表达式}:为断点添加条件
watch -[r/w/rw] ${变量名}:在变量读/写时打断点
查看状态
print/p ${表达式}:打印
stack/bt :查看当前函数调用栈信息
locals :查看当前函数所有变量值
funcs ${关键词}:列出包含关键词的函数
协程相关命令
goroutines/grs 命令查看所有协程
goroutine/gr 命令查看当前协程
gr ${goid} 进入指定协程
下面放另一个例子看下上述命令的用法,先放源代码:
然后修改下 main.go:
然后进入 dlv:
远程调试
其实很多 Golang IDE 的 debugger 使用的就是 dlv,以 IntelliJ IDEA 的 为例,点击 debug 图标后,可以在控制台输出看到它使用的 dlv 路径:
其中的 --listen 参数表示将 dlv 作为一个服务启动,可通过其后面指定的地址交互。这样可以在生产环境机器上启动 dlv,然后在 IDE 的 debugger 里配置这个地址,就可以远程 debug、短点调试了
在 IDE 里新建远程配置,然后按提示在远端机器上安装好 dlv 环境后 执行如下命令:

之后在 IDE 里打断点并启动 debug,便可以像在本地一样调试了

基本原理
delve 的源码可以看这里:https://github.com/go-delve/delve
其基本原理是:
将断点这一地址的指令覆盖掉,替换成一条新的指令:当执行该指令时,线程暂停运行并发送信号
将 dlv 进程作为被调试程序的父进程,子进程执行到断点时会发信号给 dlv 进程,dlv 进程等待用户的进一步指令
架构
Delve的整体架构如下:
UI Layer,直接与用户交互,接受用户输入的指令;各 IDE 实现的就是这层
Service Layer抽象出的一个服务层,作为 UI 和 Symbolic 层中间的层,通过 RPC 交互
Symbolic Layer,转换源码和内存地址,拥有代码行号、数据类型、变量名等信息
Target Layer,控制被调试进程,读写目标进程的寄存器、内存

Service Layer
提供
FindLocation(根据函数名找代码地址)、CreateBreakpoint(创建断点)这样的 API通过这样的架构可以分离客户端和被调试程序,从而支持远端调试、支持多种客户端
API 支持
JSON/DAP两种协议,后者主要被VS Code使用。基于这些 API 你也可以自己写一个客户端,API文档在这里
Symbol Layer
编译器会向可执行文件中写入一些用于调试的信息,我们称为 debug symbol,Symbolic Layer 所做的事情就是从可执行文件中读取这些符号
Go语言采用的 debug symbol 规范是 DWARFv4 (2018年),DWARF 中比较重要的有三种:
debug_line:这是一个表,它将指令地址映射到文件:行号
debug_frame:堆栈解压信息
debug_info:描述程序中的所有函数、类型和变量
dlv 通过 debug_info 找到函数名,通过 debug_line 找到某条指令对应的代码在哪个文件的哪一行,再通过 debug_frame 获取指令地址
Target Layer
作用是控制目标进程。在delve 中提供了对 target layer 的三种实现方式:
pkg/proc/native: 使用 OS API call 控制目标进程, 如 Linux 下调用了
ptrace, waitpid, tgkillAPIpkg/proc/core: 读取 Linux 的 core dump 文件
pkg/proc/gdbserial: 通过 TCP/IP 连接服务器。采用的协议叫做 GDB Remote Serial Protocol,GDB的标准远程通信协议
这里展开看下 pkg/proc/native/proc_linux.go 里对 Attach 的实现:
可以看到 Linux 下的 dlv attach 命令实际调用了 Linux 提供的 ptrace API。
ptrace 系统调用于进程跟踪,它提供了父进程可以观察和控制其子进程执行的能力,并允许父进程检查和替换子进程的内核镜像(包括寄存器)的值。其基本原理是:当使用了 ptrace 跟踪后,所有发送给被跟踪的子进程的信号(除了SIGKILL),都会被转发给父进程,而子进程则会被阻塞,这时子进程的状态就会被系统标注为 TASK_TRACED。父进程收到信号后,就可以对停止下来的子进程进行检查和修改,然后让子进程继续运行。
当我们用 dlv 设置断点时, dlv 会把断点处的指令修改成中断指令(amd/386 架构下为 int 3 (0xCC) ,3 号中断,可以触发 debugger,这是一种约定),同时把断点信息及修改前的指令保存起来。当被调试子进程运行到断点处时,便会执行中断命令产生 SIGTRAP 信号。由于 dlv 已经用 ptrace 和调试进程建立了跟踪关系,此时的 SIGTRAP 信号会被发送给 dlv,dlv 通过和已有的断点信息做对比 (通过指令位置)来判断这次 SIGTRAP 是不是一个断点。如果是的话,就等待用户的输入以做进一步的处理

源码解读
命令行工具是用 cobra 构建的
这里以 attach 命令为例向下追踪,可以看到起了一个 RPC 服务:
Run 方法里注册了 RPC 方法,并路由了请求:
终端部分的代码在 connect 方法里:
接下来以 break 命令为例,看下命令是如何从客户端传递到服务端并执行的。
客户端这边,在上面的代码里将 break 命令和 breakpoint 函数做了注册。breakpoint 方法的实现如下:
看一下 RPC 侧对 CreateBreakpoint 方法的实现,这里以 v2 版的实现为例:
WriteBreakpoint 是一个 interface,在 target 层有不同的实现:
这里追一下 native 里的实现:
硬中断分支逻辑如下,最后实际调用 Linux ptrace 系统调用:
软中断分支逻辑如下:
dbp.bi.Arch.BreakpointInstruction() 根据不同的 CPU架构有不同的取值:
WriteMemory 方法底层调用的也是 Linux ptrace 系统调用:
参考
Last updated