3.9 Delve调试器

目前Go语言支持GDB、LLDB和Delve几种调试器。其中GDB是最早支持的调试工具,LLDB是macOS系统推荐的标准调试工具。但是GDB和LLDB对Go语言的专有特性都缺乏很大支持,而只有Delve是专门为Go语言设计开发的调试工具。而且Delve本身也是采用Go语言开发,对Windows平台也提供了一样的支持。本节我们基于Delve简单解释如何调试Go汇编程序。

3.9.1 Delve入门

首先根据官方的文档正确安装Delve调试器。我们会先构造一个简单的Go语言代码,用于熟悉下Delve的简单用法。

创建main.go文件,main函数先通过循初始化一个切片,然后输出切片的内容:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. nums := make([]int, 5)
  7. for i := 0; i < len(nums); i++ {
  8. nums[i] = i * i
  9. }
  10. fmt.Println(nums)
  11. }

命令行进入包所在目录,然后输入dlv debug命令进入调试:

  1. $ dlv debug
  2. Type 'help' for list of commands.
  3. (dlv)

输入help命令可以查看到Delve提供的调试命令列表:

  1. (dlv) help
  2. The following commands are available:
  3. args ------------------------ Print function arguments.
  4. break (alias: b) ------------ Sets a breakpoint.
  5. breakpoints (alias: bp) ----- Print out info for active breakpoints.
  6. clear ----------------------- Deletes breakpoint.
  7. clearall -------------------- Deletes multiple breakpoints.
  8. condition (alias: cond) ----- Set breakpoint condition.
  9. config ---------------------- Changes configuration parameters.
  10. continue (alias: c) --------- Run until breakpoint or program termination.
  11. disassemble (alias: disass) - Disassembler.
  12. down ------------------------ Move the current frame down.
  13. exit (alias: quit | q) ------ Exit the debugger.
  14. frame ----------------------- Set the current frame, or execute command...
  15. funcs ----------------------- Print list of functions.
  16. goroutine ------------------- Shows or changes current goroutine
  17. goroutines ------------------ List program goroutines.
  18. help (alias: h) ------------- Prints the help message.
  19. list (alias: ls | l) -------- Show source code.
  20. locals ---------------------- Print local variables.
  21. next (alias: n) ------------- Step over to next source line.
  22. on -------------------------- Executes a command when a breakpoint is hit.
  23. print (alias: p) ------------ Evaluate an expression.
  24. regs ------------------------ Print contents of CPU registers.
  25. restart (alias: r) ---------- Restart process.
  26. set ------------------------- Changes the value of a variable.
  27. source ---------------------- Executes a file containing a list of delve...
  28. sources --------------------- Print list of source files.
  29. stack (alias: bt) ----------- Print stack trace.
  30. step (alias: s) ------------- Single step through program.
  31. step-instruction (alias: si) Single step a single cpu instruction.
  32. stepout --------------------- Step out of the current function.
  33. thread (alias: tr) ---------- Switch to the specified thread.
  34. threads --------------------- Print out info for every traced thread.
  35. trace (alias: t) ------------ Set tracepoint.
  36. types ----------------------- Print list of types
  37. up -------------------------- Move the current frame up.
  38. vars ------------------------ Print package variables.
  39. whatis ---------------------- Prints type of an expression.
  40. Type help followed by a command for full documentation.
  41. (dlv)

每个Go程序的入口是main.main函数,我们可以用break在此设置一个断点:

  1. (dlv) break main.main
  2. Breakpoint 1 set at 0x10ae9b8 for main.main() ./main.go:7

然后通过breakpoints查看已经设置的所有断点:

  1. (dlv) breakpoints
  2. Breakpoint unrecovered-panic at 0x102a380 for runtime.startpanic()
  3. /usr/local/go/src/runtime/panic.go:588 (0)
  4. print runtime.curg._panic.arg
  5. Breakpoint 1 at 0x10ae9b8 for main.main() ./main.go:7 (0)

我们发现除了我们自己设置的main.main函数断点外,Delve内部已经为panic异常函数设置了一个断点。

通过vars命令可以查看全部包级的变量。因为最终的目标程序可能含有大量的全局变量,我们可以通过一个正则参数选择想查看的全局变量:

  1. (dlv) vars main
  2. main.initdone· = 2
  3. runtime.main_init_done = chan bool 0/0
  4. runtime.mainStarted = true
  5. (dlv)

然后就可以通过continue命令让程序运行到下一个断点处:

  1. (dlv) continue
  2. > main.main() ./main.go:7 (hits goroutine(1):1 total:1) (PC: 0x10ae9b8)
  3. 2:
  4. 3: import (
  5. 4: "fmt"
  6. 5: )
  7. 6:
  8. => 7: func main() {
  9. 8: nums := make([]int, 5)
  10. 9: for i := 0; i < len(nums); i++ {
  11. 10: nums[i] = i * i
  12. 11: }
  13. 12: fmt.Println(nums)
  14. (dlv)

输入next命令单步执行进入main函数内部:

  1. (dlv) next
  2. > main.main() ./main.go:8 (PC: 0x10ae9cf)
  3. 3: import (
  4. 4: "fmt"
  5. 5: )
  6. 6:
  7. 7: func main() {
  8. => 8: nums := make([]int, 5)
  9. 9: for i := 0; i < len(nums); i++ {
  10. 10: nums[i] = i * i
  11. 11: }
  12. 12: fmt.Println(nums)
  13. 13: }
  14. (dlv)

进入函数之后可以通过args和locals命令查看函数的参数和局部变量:

  1. (dlv) args
  2. (no args)
  3. (dlv) locals
  4. nums = []int len: 842350763880, cap: 17491881, nil

因为main函数没有参数,因此args命令没有任何输出。而locals命令则输出了局部变量nums切片的值:此时切片还未完成初始化,切片的底层指针为nil,长度和容量都是一个随机数值。

再次输入next命令单步执行后就可以查看到nums切片初始化之后的结果了:

  1. (dlv) next
  2. > main.main() ./main.go:9 (PC: 0x10aea12)
  3. 4: "fmt"
  4. 5: )
  5. 6:
  6. 7: func main() {
  7. 8: nums := make([]int, 5)
  8. => 9: for i := 0; i < len(nums); i++ {
  9. 10: nums[i] = i * i
  10. 11: }
  11. 12: fmt.Println(nums)
  12. 13: }
  13. (dlv) locals
  14. nums = []int len: 5, cap: 5, [...]
  15. i = 17601536
  16. (dlv)

此时因为调试器已经到了for语句行,因此局部变量出现了还未初始化的循环迭代变量i。

下面我们通过组合使用break和condition命令,在循环内部设置一个条件断点,当循环变量i等于3时断点生效:

  1. (dlv) break main.go:10
  2. Breakpoint 2 set at 0x10aea33 for main.main() ./main.go:10
  3. (dlv) condition 2 i==3
  4. (dlv)

然后通过continue执行到刚设置的条件断点,并且输出局部变量:

  1. (dlv) continue
  2. > main.main() ./main.go:10 (hits goroutine(1):1 total:1) (PC: 0x10aea33)
  3. 5: )
  4. 6:
  5. 7: func main() {
  6. 8: nums := make([]int, 5)
  7. 9: for i := 0; i < len(nums); i++ {
  8. => 10: nums[i] = i * i
  9. 11: }
  10. 12: fmt.Println(nums)
  11. 13: }
  12. (dlv) locals
  13. nums = []int len: 5, cap: 5, [...]
  14. i = 3
  15. (dlv) print nums
  16. []int len: 5, cap: 5, [0,1,4,0,0]
  17. (dlv)

我们发现当循环变量i等于3时,nums切片的前3个元素已经正确初始化。

我们还可以通过stack查看当前执行函数的栈帧信息:

  1. (dlv) stack
  2. 0 0x00000000010aea33 in main.main
  3. at ./main.go:10
  4. 1 0x000000000102bd60 in runtime.main
  5. at /usr/local/go/src/runtime/proc.go:198
  6. 2 0x0000000001053bd1 in runtime.goexit
  7. at /usr/local/go/src/runtime/asm_amd64.s:2361
  8. (dlv)

或者通过goroutine和goroutines命令查看当前Goroutine相关的信息:

  1. (dlv) goroutine
  2. Thread 101686 at ./main.go:10
  3. Goroutine 1:
  4. Runtime: ./main.go:10 main.main (0x10aea33)
  5. User: ./main.go:10 main.main (0x10aea33)
  6. Go: /usr/local/go/src/runtime/asm_amd64.s:258 runtime.rt0_go (0x1051643)
  7. Start: /usr/local/go/src/runtime/proc.go:109 runtime.main (0x102bb90)
  8. (dlv) goroutines
  9. [4 goroutines]
  10. * Goroutine 1 - User: ./main.go:10 main.main (0x10aea33) (thread 101686)
  11. Goroutine 2 - User: /usr/local/go/src/runtime/proc.go:292 runtime.gopark (0x102c189)
  12. Goroutine 3 - User: /usr/local/go/src/runtime/proc.go:292 runtime.gopark (0x102c189)
  13. Goroutine 4 - User: /usr/local/go/src/runtime/proc.go:292 runtime.gopark (0x102c189)
  14. (dlv)

最后完成调试工作后输入quit命令退出调试器。至此我们已经掌握了Delve调试器器的简单用法。

3.9.2 调试汇编程序

用Delve调试Go汇编程序的过程比调试Go语言程序更加简单。调试汇编程序时,我们需要时刻关注寄存器的状态,如果涉及函数调用或局部变量或参数还需要重点关注栈寄存器SP的状态。

为了编译演示,我们重新实现一个更简单的main函数:

  1. package main
  2. func main() { asmSayHello() }
  3. func asmSayHello()

在main函数中调用汇编语言实现的asmSayHello函数输出一个字符串。

asmSayHello函数在main_amd64.s文件中实现:

  1. #include "textflag.h"
  2. #include "funcdata.h"
  3. // "Hello World!\n"
  4. DATA text<>+0(SB)/8,$"Hello Wo"
  5. DATA text<>+8(SB)/8,$"rld!\n"
  6. GLOBL text<>(SB),NOPTR,$16
  7. // func asmSayHello()
  8. TEXT ·asmSayHello(SB), $16-0
  9. NO_LOCAL_POINTERS
  10. MOVQ $text<>+0(SB), AX
  11. MOVQ AX, (SP)
  12. MOVQ $16, 8(SP)
  13. CALL runtime·printstring(SB)
  14. RET

参考前面的调试流程,在执行到main函数断点时,可以disassemble反汇编命令查看main函数对应的汇编代码:

  1. (dlv) break main.main
  2. Breakpoint 1 set at 0x105011f for main.main() ./main.go:3
  3. (dlv) continue
  4. > main.main() ./main.go:3 (hits goroutine(1):1 total:1) (PC: 0x105011f)
  5. 1: package main
  6. 2:
  7. => 3: func main() { asmSayHello() }
  8. 4:
  9. 5: func asmSayHello()
  10. (dlv) disassemble
  11. TEXT main.main(SB) /path/to/pkg/main.go
  12. main.go:3 0x1050110 65488b0c25a0080000 mov rcx, qword ptr gs:[0x8a0]
  13. main.go:3 0x1050119 483b6110 cmp rsp, qword ptr [rcx+0x10]
  14. main.go:3 0x105011d 761a jbe 0x1050139
  15. => main.go:3 0x105011f* 4883ec08 sub rsp, 0x8
  16. main.go:3 0x1050123 48892c24 mov qword ptr [rsp], rbp
  17. main.go:3 0x1050127 488d2c24 lea rbp, ptr [rsp]
  18. main.go:3 0x105012b e880000000 call $main.asmSayHello
  19. main.go:3 0x1050130 488b2c24 mov rbp, qword ptr [rsp]
  20. main.go:3 0x1050134 4883c408 add rsp, 0x8
  21. main.go:3 0x1050138 c3 ret
  22. main.go:3 0x1050139 e87288ffff call $runtime.morestack_noctxt
  23. main.go:3 0x105013e ebd0 jmp $main.main
  24. (dlv)

虽然main函数内部只有一行函数调用语句,但是却生成了很多汇编指令。在函数的开头通过比较rsp寄存器判断栈空间是否不足,如果不足则跳转到0x1050139地址调用runtime.morestack函数进行栈扩容,然后跳回到main函数开始位置重新进行栈空间测试。而在asmSayHello函数调用之前,先扩展rsp空间用于临时存储rbp寄存器的状态,在函数返回后通过栈恢复rbp的值并回收临时栈空间。通过对比Go语言代码和对应的汇编代码,我们可以加深对Go汇编语言的理解。

从汇编语言角度深刻Go语言各种特性的工作机制对调试工作也是一个很大的帮助。如果希望在汇编指令层面调试Go代码,Delve还提供了一个step-instruction单步执行汇编指令的命令。

现在我们依然用break命令在asmSayHello函数设置断点,并且输入continue命令让调试器执行到断点位置停下:

  1. (dlv) break main.asmSayHello
  2. Breakpoint 2 set at 0x10501bf for main.asmSayHello() ./main_amd64.s:10
  3. (dlv) continue
  4. > main.asmSayHello() ./main_amd64.s:10 (hits goroutine(1):1 total:1) (PC: 0x10501bf)
  5. 5: DATA text<>+0(SB)/8,$"Hello Wo"
  6. 6: DATA text<>+8(SB)/8,$"rld!\n"
  7. 7: GLOBL text<>(SB),NOPTR,$16
  8. 8:
  9. 9: // func asmSayHello()
  10. => 10: TEXT ·asmSayHello(SB), $16-0
  11. 11: NO_LOCAL_POINTERS
  12. 12: MOVQ $text<>+0(SB), AX
  13. 13: MOVQ AX, (SP)
  14. 14: MOVQ $16, 8(SP)
  15. 15: CALL runtime·printstring(SB)
  16. (dlv)

此时我们可以通过regs查看全部的寄存器状态:

  1. (dlv) regs
  2. rax = 0x0000000001050110
  3. rbx = 0x0000000000000000
  4. rcx = 0x000000c420000300
  5. rdx = 0x0000000001070be0
  6. rdi = 0x000000c42007c020
  7. rsi = 0x0000000000000001
  8. rbp = 0x000000c420049f78
  9. rsp = 0x000000c420049f70
  10. r8 = 0x7fffffffffffffff
  11. r9 = 0xffffffffffffffff
  12. r10 = 0x0000000000000100
  13. r11 = 0x0000000000000286
  14. r12 = 0x000000c41fffff7c
  15. r13 = 0x0000000000000000
  16. r14 = 0x0000000000000178
  17. r15 = 0x0000000000000004
  18. rip = 0x00000000010501bf
  19. rflags = 0x0000000000000206
  20. ...
  21. (dlv)

因为AMD64的各种寄存器非常多,项目的信息中刻意省略了非通用的寄存器。如果再单步执行到13行时,可以发现AX寄存器值的变化。

  1. (dlv) regs
  2. rax = 0x00000000010a4060
  3. rbx = 0x0000000000000000
  4. rcx = 0x000000c420000300
  5. ...
  6. (dlv)

因此我们可以推断汇编程序内部定义的text<>数据的地址为0x00000000010a4060。我们可以用过print命令来查看该内存内的数据:

  1. (dlv) print *(*[5]byte)(uintptr(0x00000000010a4060))
  2. [5]uint8 [72,101,108,108,111]
  3. (dlv)

我们可以发现输出的[5]uint8 [72,101,108,108,111]刚好是对应“Hello”字符串。通过类似的方法,我们可以通过查看SP对应的栈指针位置,然后查看栈中局部变量的值。

至此我们就掌握了Go汇编程序的简单调试技术。