Delve的内部架构与实现
Delve的内部架构与实现
前言
本文内容系本人基于Alessandro Arzilli在GopherCon EU 2018上的talk整理加工而成,转载请注明出处。原视频及ppt链接在文章末尾。
目的
- 对delve的架构做一个总体概述
- 解释为什么其他debugger在调试Go程序时会遇到困难
什么是Delve?
Delve是一款专用于Go编程语言的debugger,旨在为Go提供一个简单、功能齐全的调试工具。
github主页:github.com/derekparker…
为什么会有Delve?
这就要谈到Go语言的底层实现了。简单来说,有两点:
-
Go语言中的堆栈概念与我们平时理解的C/C++那种“程序堆栈”的概念不太一样。传统意义上的“栈”被Go语言的运行时消耗掉了,用于调度器、GC等;而对于用户代码而言,它们所消耗的“堆”和“栈”实际上都是Go运行时向OS申请的堆内存!这也就意味着,Go语言在程序逻辑上的“栈”是可以被移动的,也正是因为这一特点,对普通指针所作的算术运算不能奏效,除非你使用unsafe包。
-
由于Go的“栈”是动态的,goroutine可以在创建的时候只使用很小的一块内存空间(默认为2KB),这也是创建goroutine的代价比创建线程要小的原因。而当goroutine的栈已经不足以储存它所需要存储的数据时,就会进行扩容,如果当前栈的下一块内存空间已经被占用了,那么整个栈将会被拷贝到内存的另一块位置。
传统的调试工具如gdb,是基于“栈不会在内存中发生移动”这一假设而设计的,因此它们无法正确地处理Go语言中栈发生移动的情形,Go语言需要一款自己的调试工具,Delve也就应运而生了。
架构
Go语言程序是编译成汇编代码再去执行的,而汇编指令需要进行大量的寄存器访问,debugger的工作主要关注两个特殊的寄存器:程序计数器(PC)和堆栈指针(SP)。
Delve的整体架构如下,分为三层:
- UI Layer,直接与用户交互;
- Symbolic Layer,拥有代码行号、数据类型、变量名等信息;
- Target Layer,控制目标进程,不关心用户源码。
Target Layer
下面首先详细介绍一下Target Layer的功能:
- 附加到目标进程或与目标进程(也就是正在debug的进程)脱离
- 枚举目标进程中的线程
- 可以启动/停止单个线程(或整个进程)。
- 接收“调试事件(debug event)”(线程的创建/死亡,最重要的是线程在断点上的停止)。
- 可以读/写目标进程的内存
- 可以读/写一个停止的线程的CPU寄存器
Symbolic Layer
编译器会向可执行文件中写入一些用于调试的信息,我们称为debug symbol,Symbolic Layer所做的事情就是从可执行文件中读取这些符号。Go语言采用的debug symbol规范是DWARFv4(2018年),我们可以看看DWARF有哪些section:
emmmm看起来也不少,不过好在Delve只需要关心其中的三种:
- debug_line:这是一个表,它将指令地址映射到文件:行号。
- debug_frame:堆栈解压信息。
- debug_info:描述程序中的所有函数、类型和变量。
debug_line太过简单,一看就明白什么意思,debug_frame实现比较复杂,Alessandro Arzilli在大会上也未作说明,这里我们重点关注一下debug_info。
以下面这段代码为例:
package main
type Point struct {
X, Y int
}
func NewPoint(x, y int) Point {
p := Point{ x, y }
return p
}
它的debug_info长这样:
NewPoint函数作为一个Subprogram节点,它拥有三个子节点,其中两个是形参,一个是局部变量,变量均通过一个reference指向具体的类型,对于结构体类型而言,它又通过child指向每个成员。
那么,Delve是如何打印出下面这样的堆栈信息的呢?
上面出现的信息一共有三种:
- 指令地址
- 函数名
- 文件:行号
Delve通过debug_info找到函数名,通过debug_line找到某条指令对应的代码在哪个文件的哪一行,这都很好理解,稍微复杂一点的是debug_frame获取指令地址的过程:
开始时,PC0PC_0PC0和SP0SP_0SP0分别取PC寄存器和SP寄存器的值,然后通过在debug_frame中查找PCiPC_iPCi,获取对应的栈帧大小,再由此计算出PCi+1PC_{i+1}PCi+1和SPi+1SP_{i+1}SPi+1的值,以此类推,最后打印堆栈的地址依次为PC0PC_0PC0,PC1PC_1PC1,PC2PC_2PC2,...
Delve的真实架构
之前所说的三层模型其实是一种更为简单的抽象,在Delve的实际实现中,UI层与后两层是分离的,通过一个Service层进行交互,如下图所示。
Delve这么设计的目的在于用户能够更方便地定制自己想要的UI,已经比较成熟的UI如下:
一些Delve功能的具体实现
Variable Evaluation
当我们在delve中输入print a
命令,UI层将其翻译成EvalExpression(”a”)
,由Symbolic层通过debug_info确定变量a的地址和大小(int是8个字节),再由Target Layer读取指定内存地址的内容。
Target层将读取的8个字节向上传递至Symbolic层,为其添加地址、变量名、类型信息以后,再交给UI层,最终将
a = int(1)
返回给用户。
因为Delve是专为Go语言设计的,在打印变量信息方面,具有gdb所不具备的优势,对比如下:
从上图可以看到,对于error接口类型和channel类型,gdb只能打印出变量的地址,而delve能够打印出对用户更友好的信息。
Creating Breakpoints
当用户输入
break main.f
时,UI层将其翻译为SetBreakpoint(FunctionName: “main.f”)
,Symbolic层在debug_info中查找main.f
的地址,再由Target层向该地址写入断点。Target层写入断点的操作实际上是将原先处于这一地址的指令覆盖掉,替换成一条新的指令:当执行该指令时,线程停止运行并让OS通知debugger(debug event)。
在给程序打断点这件事上,delve也比gdb更为优秀,这同样与Go语言的设计有关:
橙色部分代码的作用是在函数执行前进行判断,该函数是否需要更多的栈空间,如果需要,则调用runtime.morestack
函数进行扩栈操作。break main.f
会在函数f的起始位置打断点,如果使用gdb的话,那么断点对应的汇编代码地址是0x452eb0
,如果发生了扩栈,那么这个断点将会被hit两次,给用户的感觉就像是函数执行了两次一样。而delve针对Go语言的特点做了优化,它会越过前三行汇编指令,将断点打在0x452ebf
处。
— The End
码字不易,如果对你有帮助,动动手指点个赞吧!
原视频链接:www.youtube.com/watch?v=IKn…
ppt可在此获取:speakerdeck.com/aarzilli/in…
转载自:https://juejin.cn/post/7168354155491229732